diff --git a/cabana/admin_openapi.go b/cabana/admin_openapi.go new file mode 100644 index 0000000..5516a0d --- /dev/null +++ b/cabana/admin_openapi.go @@ -0,0 +1,379 @@ +package cabana + +// Admin API annotations. swag reads these with the handler package so +// docs/openapi.json lists every D-09 route. The functions are not mounted; +// service.mount in http.go is the runtime route table, and +// TestPhase09PermissionMatrix fails if the two lists diverge. + +// ErrorBody is one D-10 error object. +type ErrorBody struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` +} + +// ErrorEnvelope is the D-10 error envelope. +type ErrorEnvelope struct { + Error ErrorBody `json:"error"` +} + +// SuccessMeta is the D-10 meta object. +type SuccessMeta struct { + Locale string `json:"locale,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"per_page,omitempty"` + Total int `json:"total,omitempty"` + LastPage int `json:"last_page,omitempty"` +} + +// SuccessEnvelope is the D-10 success envelope. +type SuccessEnvelope struct { + Data any `json:"data"` + Meta SuccessMeta `json:"meta"` +} + +// AdminLoginData is the admin login payload. +type AdminLoginData struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` +} + +// AdminLoginEnvelope is the admin login success body. +type AdminLoginEnvelope struct { + Data AdminLoginData `json:"data"` + Meta SuccessMeta `json:"meta"` +} + +// AdminLogin documents POST /_admin/api/v1/auth/login. +// +// @Summary Admin login +// @Tags admin +// @Accept json +// @Produce json +// @Success 200 {object} AdminLoginEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Router /_admin/api/v1/auth/login [post] +func AdminLogin() {} + +// AdminRefresh documents POST /_admin/api/v1/auth/refresh. +// +// @Summary Refresh an admin token +// @Tags admin +// @Accept json +// @Produce json +// @Success 200 {object} AdminLoginEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Router /_admin/api/v1/auth/refresh [post] +func AdminRefresh() {} + +// AdminLogout documents POST /_admin/api/v1/auth/logout. +// +// @Summary Admin logout +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Router /_admin/api/v1/auth/logout [post] +func AdminLogout() {} + +// AdminMe documents GET /_admin/api/v1/auth/me. +// +// @Summary Current admin +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Router /_admin/api/v1/auth/me [get] +func AdminMe() {} + +// AdminNavigation documents GET /_admin/api/v1/navigation. +// +// @Summary Admin navigation +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Router /_admin/api/v1/navigation [get] +func AdminNavigation() {} + +// AdminSettingsList documents GET /_admin/api/v1/settings. +// +// @Summary List admin settings +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Router /_admin/api/v1/settings [get] +func AdminSettingsList() {} + +// AdminSettingsSchema documents GET /_admin/api/v1/settings/{code}/schema. +// +// @Summary Admin settings schema +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param code path string true "Settings code" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /_admin/api/v1/settings/{code}/schema [get] +func AdminSettingsSchema() {} + +// AdminSettingsGet documents GET /_admin/api/v1/settings/{code}. +// +// @Summary Read admin settings +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param code path string true "Settings code" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /_admin/api/v1/settings/{code} [get] +func AdminSettingsGet() {} + +// AdminSettingsPut documents PUT /_admin/api/v1/settings/{code}. +// +// @Summary Update admin settings +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param code path string true "Settings code" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /_admin/api/v1/settings/{code} [put] +func AdminSettingsPut() {} + +// AdminListSchema documents the list schema route. +// +// @Summary Admin list schema +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/list [get] +func AdminListSchema() {} + +// AdminFormSchema documents the form schema route. +// +// @Summary Admin form schema +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/form [get] +func AdminFormSchema() {} + +// AdminRelationSchema documents the relation schema route. +// +// @Summary Admin relation schema +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param name path string true "Relation name" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/schema/relation/{name} [get] +func AdminRelationSchema() {} + +// AdminList documents the record list route. +// +// @Summary List admin records +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [get] +func AdminList() {} + +// AdminCreate documents the record create route. +// +// @Summary Create an admin record +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller} [post] +func AdminCreate() {} + +// AdminBulkDelete documents the bulk delete route. +// +// @Summary Bulk-delete admin records +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/bulk-delete [post] +func AdminBulkDelete() {} + +// AdminShow documents the record show route. +// +// @Summary Show an admin record +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Record id" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [get] +func AdminShow() {} + +// AdminUpdate documents the record update route. +// +// @Summary Update an admin record +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Record id" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [put] +func AdminUpdate() {} + +// AdminDelete documents the record delete route. +// +// @Summary Delete an admin record +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Record id" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 404 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id} [delete] +func AdminDelete() {} + +// AdminRelationLinked documents the linked-relation route. +// +// @Summary List linked relation records +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name} [get] +func AdminRelationLinked() {} + +// AdminRelationCandidates documents the relation candidate route. +// +// @Summary List relation candidates +// @Tags admin +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/candidates [get] +func AdminRelationCandidates() {} + +// AdminRelationLink documents the relation link route. +// +// @Summary Link relation records +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/link [post] +func AdminRelationLink() {} + +// AdminRelationUnlink documents the relation unlink route. +// +// @Summary Unlink relation records +// @Tags admin +// @Accept json +// @Produce json +// @Security BackendBearer +// @Param vendor path string true "Vendor" +// @Param plugin path string true "Plugin" +// @Param controller path string true "Controller" +// @Param id path integer true "Owner id" +// @Param name path string true "Relation name" +// @Success 200 {object} SuccessEnvelope +// @Failure 401 {object} ErrorEnvelope +// @Failure 403 {object} ErrorEnvelope +// @Failure 422 {object} ErrorEnvelope +// @Router /_admin/api/v1/{vendor}/{plugin}/{controller}/{id}/relations/{name}/unlink [post] +func AdminRelationUnlink() {} diff --git a/cabana/phase09_contract_test.go b/cabana/phase09_contract_test.go new file mode 100644 index 0000000..3eef44a --- /dev/null +++ b/cabana/phase09_contract_test.go @@ -0,0 +1,91 @@ +package cabana + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestPhase09ContractInventory fails when the committed OpenAPI document +// drops a D-09 route or a protected route's 401 response. +func TestPhase09ContractInventory(t *testing.T) { + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("caller") + } + specPath := filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..", "fonoteka.go", "docs", "openapi.json")) + raw, err := os.ReadFile(specPath) + if err != nil { + t.Fatalf("read %s: %v", specPath, err) + } + var spec struct { + Paths map[string]map[string]struct { + Responses map[string]json.RawMessage `json:"responses"` + Security []map[string]json.RawMessage `json:"security"` + } `json:"paths"` + Components struct { + SecuritySchemes map[string]json.RawMessage `json:"securitySchemes"` + } `json:"components"` + } + if err := json.Unmarshal(raw, &spec); err != nil { + t.Fatal(err) + } + if _, ok := spec.Components.SecuritySchemes["BackendBearer"]; !ok { + t.Fatal("openapi is missing the BackendBearer scheme") + } + public := map[string]bool{ + "POST /_admin/api/v1/auth/login": true, + "POST /_admin/api/v1/auth/refresh": true, + } + seen := map[string]bool{} + for _, route := range phase09Routes { + method, path, ok := splitRoute(route.key) + if !ok { + t.Fatalf("bad route key %s", route.key) + } + method = strings.ToLower(method) + ops, ok := spec.Paths[path] + if !ok { + t.Fatalf("openapi missing %s", path) + } + op, ok := ops[method] + if !ok { + t.Fatalf("openapi missing %s %s", method, path) + } + key := route.key + if seen[key] { + t.Fatalf("duplicate contract route %s", key) + } + seen[key] = true + if _, ok := op.Responses["200"]; !ok { + t.Fatalf("%s has no 200 response", key) + } + if public[key] { + if _, ok := op.Responses["401"]; !ok { + t.Fatalf("%s has no 401 response", key) + } + continue + } + if len(op.Security) == 0 { + t.Fatalf("%s has no BackendBearer security requirement", key) + } + if _, ok := op.Responses["401"]; !ok { + t.Fatalf("%s has no 401 response", key) + } + } + if len(seen) != len(phase09Routes) { + t.Fatalf("contract routes=%d want %d", len(seen), len(phase09Routes)) + } +} + +func splitRoute(key string) (method, path string, ok bool) { + for i := 0; i < len(key); i++ { + if key[i] == ' ' { + return key[:i], key[i+1:], key[i+1:] != "" + } + } + return "", "", false +} diff --git a/scripts/check-phase9.sh b/scripts/check-phase9.sh new file mode 100755 index 0000000..e12f3d3 --- /dev/null +++ b/scripts/check-phase9.sh @@ -0,0 +1,180 @@ +#!/usr/bin/env bash +# Phase 9 fail-closed gate. Stages refuse skipped PostgreSQL tests, zero-test +# runs, and an OpenAPI document that does not list the admin surface. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$(cd "$ROOT/../fonoteka.go" && pwd)" +REVIEW="$ROOT/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-SECURITY-REVIEW.md" +VALIDATION="$ROOT/.planning/phases/09-backend-admin-authentication-and-schema-pipeline/09-VALIDATION.md" + +usage() { + cat >&2 <<'EOF' +usage: + check-phase9.sh --self-test + check-phase9.sh --security + check-phase9.sh --postgres + check-phase9.sh --openapi + check-phase9.sh --evidence + check-phase9.sh --all +EOF + exit 2 +} + +# phase9_detect reads go test -json. Exit 1 fail, 2 skip, 3 zero tests. +phase9_detect() { + python3 - "$1" <<'PY' +import json, sys +path = sys.argv[1] +saw = False +with open(path, encoding="utf-8", errors="replace") as fh: + for raw in fh: + line = raw.strip() + if not line.startswith("{"): + continue + try: + ev = json.loads(line) + except json.JSONDecodeError: + print("refuse: non-json test output", file=sys.stderr) + sys.exit(4) + action = ev.get("Action") + test = ev.get("Test") or "" + if action == "skip" and test: + print(f"refuse: skipped {test}", file=sys.stderr) + sys.exit(2) + if action == "fail": + name = test or ev.get("Package") or "unknown" + print(f"refuse: failed {name}", file=sys.stderr) + sys.exit(1) + if action == "pass" and test: + saw = True +if not saw: + print("refuse: zero tests", file=sys.stderr) + sys.exit(3) +PY +} + +phase9_go() { + local dir="$1" + shift + local log err + log="$(mktemp)" + err="$(mktemp)" + set +e + (cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err" + local rc=$? + set -e + if [[ -s "$err" ]]; then + cat "$err" >&2 + fi + local dc=0 + phase9_detect "$log" || dc=$? + if [[ "$rc" -ne 0 || "$dc" -ne 0 ]]; then + tail -n 30 "$log" >&2 || true + rm -f "$log" "$err" + echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 + exit 1 + fi + rm -f "$log" "$err" +} + +expect_detect_fails() { + local name="$1" + local payload="$2" + local log + log="$(mktemp)" + printf '%s\n' "$payload" >"$log" + local dc=0 + phase9_detect "$log" || dc=$? + rm -f "$log" + if [[ "$dc" -eq 0 ]]; then + echo "refuse: self-test $name accepted a bad run" >&2 + exit 1 + fi +} + +run_self_test() { + bash -n "${BASH_SOURCE[0]}" + local log + log="$(mktemp)" + printf '%s\n' '{"Action":"pass","Test":"TestPhase09GuardIsolation"}' >"$log" + phase9_detect "$log" + rm -f "$log" + expect_detect_fails skip '{"Action":"skip","Test":"TestPhase09MigrationsFreshRollback"}' + expect_detect_fails zero '{"Action":"pass","Package":"git.golem15.com/golem15/summercms/lagoon"}' + expect_detect_fails fail '{"Action":"fail","Test":"TestPhase09ContractInventory"}' + for flag in --self-test --security --postgres --openapi --evidence --all; do + grep -q -- "$flag" "${BASH_SOURCE[0]}" || { + echo "refuse: missing mode $flag" >&2 + exit 1 + } + done + echo "phase9 self-test passed" +} + +run_security() { + phase9_go "$ROOT" ./bouncer ./cabana -run '^TestPhase09' + phase9_go "$APP" ./plugins/golem15/fonoteka -run '^TestPhase09Security' + echo "phase9 security passed" +} + +run_postgres() { + phase9_go "$ROOT" ./lagoon -run '^TestPhase09MigrationsFreshRollback$' + phase9_go "$APP" ./plugins/golem15/fonoteka -run '^TestPhase09AssembledAcceptance$' + echo "phase9 postgres passed" +} + +run_openapi() { + (cd "$APP" && bash scripts/check-openapi.sh) + phase9_go "$ROOT" ./cabana -run '^TestPhase09ContractInventory$' + echo "phase9 openapi passed" +} + +run_evidence() { + [[ -f "$REVIEW" && -f "$VALIDATION" ]] || { + echo "refuse: security review or validation file is missing" >&2 + exit 1 + } + python3 - "$REVIEW" "$VALIDATION" <<'PY' +import pathlib, sys +review = pathlib.Path(sys.argv[1]).read_text() +validation = pathlib.Path(sys.argv[2]).read_text() +required = [f"T-09-{i:02d}" for i in range(1, 22)] + ["T-09-SC"] +missing = [item for item in required if item not in review] +if missing: + print("refuse: review missing " + ", ".join(missing), file=sys.stderr) + sys.exit(1) +if "nyquist_compliant: true" not in validation: + print("refuse: validation is not nyquist_compliant", file=sys.stderr) + sys.exit(1) +for line in validation.splitlines(): + if line.startswith("|") and "pending" in line: + print("refuse: validation row still pending: " + line, file=sys.stderr) + sys.exit(1) +for req in ("AUTH-08", "ADMIN-01", "ADMIN-02", "ADMIN-03", "ADMIN-04", "ADMIN-05"): + if req not in validation: + print(f"refuse: validation missing {req}", file=sys.stderr) + sys.exit(1) +print("phase9 evidence files passed") +PY + run_security + run_postgres + run_openapi + echo "phase9 evidence passed" +} + +case "${1:-}" in +--self-test) run_self_test ;; +--security) run_security ;; +--postgres) run_postgres ;; +--openapi) run_openapi ;; +--evidence) run_evidence ;; +--all) + run_self_test + run_security + run_postgres + run_openapi + run_evidence + ;; +*) usage ;; +esac