From 02b15a7f72a35fdaee56bc28465423490e0596b0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 3 Oct 2026 08:57:11 +0200 Subject: [PATCH] access-engine: secret-read evaluator for warden fetch checks (CUST-WP-0075) Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 3775@bnt-lap001 Assistant-Session: cfacb6a5-ad5b-4f79-ac67-053e3b0d34a3 --- charts/flex-auth/templates/deployment.yaml | 4 + charts/flex-auth/values.yaml | 4 + cmd/flex-auth/ops_warden_fetch_test.go | 219 +++++++++++++ examples/ops-warden-fetch/README.md | 84 +++++ ...request_allow_forgejo_admin_api_token.json | 20 ++ ..._request_allow_openrouter_llm_connect.json | 20 ++ .../check_request_deny_non_secret_type.json | 20 ++ .../check_request_deny_unknown_need.json | 20 ++ .../check_request_deny_wrong_system.json | 20 ++ .../ops-warden-fetch/policy_fixtures.yaml | 300 ++++++++++++++++++ examples/ops-warden-fetch/policy_package.md | 246 ++++++++++++++ .../ops-warden-fetch/registry_snapshot.json | 256 +++++++++++++++ tests/stage1.sh | 20 ++ tools/gen_ops_warden_fetch.py | 192 +++++++++++ values/ops-warden-fetch.yaml | 30 ++ workplans/ADHOC-2026-10-03.md | 87 +++++ 16 files changed, 1542 insertions(+) create mode 100644 cmd/flex-auth/ops_warden_fetch_test.go create mode 100644 examples/ops-warden-fetch/README.md create mode 100644 examples/ops-warden-fetch/check_request_allow_forgejo_admin_api_token.json create mode 100644 examples/ops-warden-fetch/check_request_allow_openrouter_llm_connect.json create mode 100644 examples/ops-warden-fetch/check_request_deny_non_secret_type.json create mode 100644 examples/ops-warden-fetch/check_request_deny_unknown_need.json create mode 100644 examples/ops-warden-fetch/check_request_deny_wrong_system.json create mode 100644 examples/ops-warden-fetch/policy_fixtures.yaml create mode 100644 examples/ops-warden-fetch/policy_package.md create mode 100644 examples/ops-warden-fetch/registry_snapshot.json create mode 100644 tools/gen_ops_warden_fetch.py create mode 100644 values/ops-warden-fetch.yaml create mode 100644 workplans/ADHOC-2026-10-03.md diff --git a/charts/flex-auth/templates/deployment.yaml b/charts/flex-auth/templates/deployment.yaml index 0c413e0..55b0ff1 100644 --- a/charts/flex-auth/templates/deployment.yaml +++ b/charts/flex-auth/templates/deployment.yaml @@ -39,6 +39,10 @@ spec: - {{ required "callerAuth.kubernetesURL is required when callerAuth.mode is warn or enforce" .Values.callerAuth.kubernetesURL | quote }} - --caller-binding - {{ $binding | quote }} + {{- range .Values.callerAuth.extraBindings }} + - --caller-binding + - {{ . | quote }} + {{- end }} {{- end }} ports: - name: http diff --git a/charts/flex-auth/values.yaml b/charts/flex-auth/values.yaml index ca46bb0..f0dc11d 100644 --- a/charts/flex-auth/values.yaml +++ b/charts/flex-auth/values.yaml @@ -36,6 +36,10 @@ callerAuth: audience: flex-auth kubernetesURL: https://kubernetes.default.svc binding: "" + # Further resource.system=principal pairs, each rendered as its own + # --caller-binding (e.g. ops-warden-fetch binds every catalog owner system + # to the ops-warden ServiceAccount). + extraBindings: [] reviewer: mountPath: /var/run/secrets/flex-auth-reviewer # Empty: kubelet mints the API server's default audiences so TokenReview diff --git a/cmd/flex-auth/ops_warden_fetch_test.go b/cmd/flex-auth/ops_warden_fetch_test.go new file mode 100644 index 0000000..fb96dce --- /dev/null +++ b/cmd/flex-auth/ops_warden_fetch_test.go @@ -0,0 +1,219 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/netkingdom/flex-auth/internal/callerauth" + "github.com/netkingdom/flex-auth/pkg/api" +) + +// The ops-warden secret-read pin (CUST-WP-0075, bug b792c5d0): warden's +// check_fetch_policy sends action read on resource type secret with +// resource.system = the lane's owner_repo. + +const opsWardenPrincipal = "system:serviceaccount:ops-warden:ops-warden" + +func fetchPath(name string) string { + return filepath.Join("..", "..", "examples", "ops-warden-fetch", name) +} + +func loadFetchRequest(t *testing.T, name string) api.CheckRequest { + t.Helper() + raw, err := os.ReadFile(fetchPath(name)) + if err != nil { + t.Fatal(err) + } + var request api.CheckRequest + if err := json.Unmarshal(raw, &request); err != nil { + t.Fatalf("%s: %v", name, err) + } + return request +} + +func TestOpsWardenFetchDecisions(t *testing.T) { + engine, err := buildEngine(context.Background(), fetchPath("registry_snapshot.json"), fetchPath("policy_package.md"), "") + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + cases := []struct { + file string + effect api.DecisionEffect + reason string + }{ + {"check_request_allow_forgejo_admin_api_token.json", api.DecisionEffectAllow, "secret_read_lane_matched"}, + {"check_request_allow_openrouter_llm_connect.json", api.DecisionEffectAllow, "secret_read_lane_matched"}, + {"check_request_deny_unknown_need.json", api.DecisionEffectDeny, "unknown_need"}, + {"check_request_deny_wrong_system.json", api.DecisionEffectDeny, "wrong_system"}, + {"check_request_deny_non_secret_type.json", api.DecisionEffectDeny, "wrong_resource_type"}, + } + for _, tc := range cases { + t.Run(tc.file, func(t *testing.T) { + decision, err := engine.Check(context.Background(), loadFetchRequest(t, tc.file)) + if err != nil { + t.Fatal(err) + } + if decision.Effect != tc.effect || decision.Reason != tc.reason { + t.Fatalf("decision = %s/%s; want %s/%s", decision.Effect, decision.Reason, tc.effect, tc.reason) + } + if decision.ID == "" { + t.Fatal("decision.ID is empty; warden needs a policy_decision_id") + } + }) + } +} + +// fetchBindings reads the caller bindings exactly as the values file declares +// them, so the test exercises the configuration that would be deployed. +func fetchBindings(t *testing.T) (string, map[string]string) { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "values", "ops-warden-fetch.yaml")) + if err != nil { + t.Fatal(err) + } + var values struct { + CallerAuth struct { + Mode string `yaml:"mode"` + Binding string `yaml:"binding"` + ExtraBindings []string `yaml:"extraBindings"` + } `yaml:"callerAuth"` + } + if err := yaml.Unmarshal(raw, &values); err != nil { + t.Fatal(err) + } + bindings := map[string]string{} + for _, pair := range append([]string{values.CallerAuth.Binding}, values.CallerAuth.ExtraBindings...) { + system, principal, ok := strings.Cut(pair, "=") + if !ok { + t.Fatalf("malformed binding %q", pair) + } + bindings[system] = principal + } + return values.CallerAuth.Mode, bindings +} + +func TestOpsWardenFetchCallerBindings(t *testing.T) { + mode, bindings := fetchBindings(t) + if mode != "enforce" { + t.Fatalf("callerAuth.mode = %q; the fetch pin must enforce", mode) + } + for system, principal := range bindings { + if principal != opsWardenPrincipal { + t.Fatalf("system %q bound to %q; only ops-warden may ask", system, principal) + } + } + engine, err := buildEngine(context.Background(), fetchPath("registry_snapshot.json"), fetchPath("policy_package.md"), "") + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + serve := func(username string) *httptest.Server { + authenticator, err := callerauth.New(callerauth.ModeEnforce, fixedTokenReviewer{identity: callerauth.Identity{ + Username: username, + Audiences: []string{"flex-auth"}, + }}, "flex-auth", bindings, nil) + if err != nil { + t.Fatal(err) + } + return httptest.NewServer(newServeMuxWithCallerAuth(engine, authenticator)) + } + post := func(server *httptest.Server, request api.CheckRequest, token bool) (int, api.DecisionEnvelope) { + body, _ := json.Marshal(request) + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/check", bytes.NewReader(body)) + req.Header.Set("content-type", "application/json") + if token { + req.Header.Set("authorization", "Bearer workload-token") + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var envelope api.DecisionEnvelope + if resp.StatusCode == http.StatusOK { + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + t.Fatal(err) + } + } + return resp.StatusCode, envelope + } + + warden := serve(opsWardenPrincipal) + defer warden.Close() + allow := loadFetchRequest(t, "check_request_allow_forgejo_admin_api_token.json") + + if code, envelope := post(warden, allow, true); code != http.StatusOK || envelope.Effect != api.DecisionEffectAllow { + t.Fatalf("ops-warden forgejo read = %d %s/%s; want 200 allow", code, envelope.Effect, envelope.Reason) + } + if code, _ := post(warden, allow, false); code != http.StatusUnauthorized { + t.Fatalf("anonymous = %d; want 401", code) + } + unbound := allow + unbound.Resource.System = "secrets-engine" + unbound.Context = map[string]any{"need_id": "forgejo-admin-api-token", "owner_repo": "secrets-engine"} + if code, _ := post(warden, unbound, true); code != http.StatusForbidden { + t.Fatalf("ops-warden on unbound system = %d; want 403", code) + } + + other := serve("system:serviceaccount:another:caller") + defer other.Close() + if code, _ := post(other, allow, true); code != http.StatusForbidden { + t.Fatalf("wrong caller = %d; want 403", code) + } +} + +// TestOpsWardenFetchRegistryMatchesPolicy keeps the two generated artifacts in +// step: every catalog lane in the package is a registry resource of its owner +// system, and nothing else is. Drift against the catalog itself is +// tools/gen_ops_warden_fetch.py --check (tests/stage1.sh). +func TestOpsWardenFetchRegistryMatchesPolicy(t *testing.T) { + raw, err := os.ReadFile(fetchPath("policy_package.md")) + if err != nil { + t.Fatal(err) + } + block := regexp.MustCompile(`(?s)catalog_lanes := \{(.*?)\}`).FindSubmatch(raw) + if block == nil { + t.Fatal("catalog_lanes block not found") + } + var fromPolicy []string + for _, m := range regexp.MustCompile(`"([^"]+)": "([^"]+)"`).FindAllSubmatch(block[1], -1) { + fromPolicy = append(fromPolicy, string(m[2])+"/secret:"+string(m[1])) + } + + snapRaw, err := os.ReadFile(fetchPath("registry_snapshot.json")) + if err != nil { + t.Fatal(err) + } + var snap struct { + ResourceManifests []struct { + System string `json:"system"` + Resources []struct { + ID string `json:"id"` + } `json:"resources"` + } `json:"resource_manifests"` + } + if err := json.Unmarshal(snapRaw, &snap); err != nil { + t.Fatal(err) + } + var fromRegistry []string + for _, manifest := range snap.ResourceManifests { + for _, resource := range manifest.Resources { + fromRegistry = append(fromRegistry, manifest.System+"/"+resource.ID) + } + } + sort.Strings(fromPolicy) + sort.Strings(fromRegistry) + if len(fromPolicy) == 0 || strings.Join(fromPolicy, ",") != strings.Join(fromRegistry, ",") { + t.Fatalf("policy lanes %v\nregistry %v", fromPolicy, fromRegistry) + } +} diff --git a/examples/ops-warden-fetch/README.md b/examples/ops-warden-fetch/README.md new file mode 100644 index 0000000..736109c --- /dev/null +++ b/examples/ops-warden-fetch/README.md @@ -0,0 +1,84 @@ +# ops-warden secret-read example (`flex-auth-ops-warden-fetch`) + +Policy, registry and fixtures for ops-warden's **secret-fetch** policy check +(`check_fetch_policy` in ops-warden `src/warden/policy.py`): before +`warden access --fetch/--exec` proxies a non-SSH credential read, it asks +whether `read` on `secret:[/]` owned by `` is +allowed. Opened by the-custodian bug `b792c5d0` (CUST-WP-0075): those checks +were sent to the SSH signing pin `flex-auth-ops-warden`, whose package admits +only `resource.system == "ops-warden"` and whose caller bindings know only that +system, so every other owner got 403. + +| File | What it is | +| --- | --- | +| `policy_package.md` | `ops-warden.secret.read` v1, `allow_ttl: 5m`; the `catalog_lanes` block is generated | +| `registry_snapshot.json` | generated: one system per owner, one `secret:` resource per lane | +| `policy_fixtures.yaml` | 15 fixtures: 4 allows, 11 denial branches | +| `check_request_*.json` | standalone requests for `POST /v1/check` | +| `../../values/ops-warden-fetch.yaml` | the pin; caller bindings generated, image digest set by hand after CI | + +## Why a separate pin + +One decision class per pin, independently rollable (FLEX-WP-0016). The server +serves one policy package per process. SSH signing (`sign` on +`ssh-certificate`, system `ops-warden`) and secret reads (`read` on `secret`, +system = owner repo) differ in action, resource type, systems and caller +bindings. Folding reads into the signing pin would redeploy the lane the +ops-bridge tunnels depend on for every catalog change. + +## The rule + +Allow when all hold, otherwise deny with the first failing reason: +`action == read` (`wrong_action`), `resource.type == secret` +(`wrong_resource_type`), tenant `tenant:platform` (`wrong_tenant`), +`subject.type == operator` (`wrong_subject_type`), non-empty subject id +(`missing_subject`), `context.need_id` present (`missing_need_id`) and in the +catalog set (`unknown_need`), `resource.system` a catalog owner +(`unknown_system`) and the owner of that need (`wrong_system`), +`resource.id == secret:` or `secret:/` +(`resource_need_mismatch`), `context.owner_repo == resource.system` +(`owner_repo_mismatch`). + +Caller authentication is `enforce`: every owner system is bound to +`system:serviceaccount:ops-warden:ops-warden`. Another caller, an anonymous +call, or an owner system missing from the bindings is rejected with 401/403 +before the package runs. + +For a registered resource the registry's `type` wins over the caller's +(FLEX-DEC-2026-012), so a non-secret type only reaches the package on an +unregistered id; the package denies it either way. + +## Regenerate when the catalog changes + +The readable set is every ops-warden catalog entry with `status: active`, +`exec_capable: true` and `lane: secret`. After a lane is added or retired: + +```bash +python3 tools/gen_ops_warden_fetch.py # rewrites the three generated files +python3 tools/gen_ops_warden_fetch.py --check # exit 1 on drift (tests/stage1.sh runs it) +go run ./cmd/flex-auth test-policy --file examples/ops-warden-fetch/policy_package.md +``` + +`--catalog PATH` overrides the default `~/ops-warden/registry/routing/catalog.yaml`. +A new **owner system** adds a caller binding, so it needs a pin redeploy. A new +lane under an existing owner changes only the package and registry, which are +baked into the image, so it needs a new image too (`deploy/README.md`). + +## Verify + +```bash +go run ./cmd/flex-auth test-policy --file examples/ops-warden-fetch/policy_package.md # 6 Rego tests, 15 fixtures +go run ./cmd/flex-auth load-registry -file examples/ops-warden-fetch/registry_snapshot.json +go test ./cmd/flex-auth -run OpsWardenFetch +``` + +## Not yet deployed + +`values/ops-warden-fetch.yaml` carries no image digest until CI builds an image +containing this directory. The deploy steps (founder go required) are in +`workplans/ADHOC-2026-10-03.md`. + +Residual (build mode, close-down checklist row 1): `subject.id` is whatever +ops-warden sends (its subject env or `operator`); it is not bound to an attended +human identity. The caller binding proves that ops-warden asked, not which +operator did. diff --git a/examples/ops-warden-fetch/check_request_allow_forgejo_admin_api_token.json b/examples/ops-warden-fetch/check_request_allow_forgejo_admin_api_token.json new file mode 100644 index 0000000..381a0c2 --- /dev/null +++ b/examples/ops-warden-fetch/check_request_allow_forgejo_admin_api_token.json @@ -0,0 +1,20 @@ +{ + "id": "check:ops-warden-fetch-forgejo-admin-api-token", + "subject": { + "id": "operator", + "type": "operator", + "tenant": "tenant:platform" + }, + "action": "read", + "resource": { + "id": "secret:forgejo-admin-api-token", + "type": "secret", + "system": "railiance-platform", + "tenant": "tenant:platform" + }, + "context": { + "need_id": "forgejo-admin-api-token", + "owner_repo": "railiance-platform", + "domain": null + } +} diff --git a/examples/ops-warden-fetch/check_request_allow_openrouter_llm_connect.json b/examples/ops-warden-fetch/check_request_allow_openrouter_llm_connect.json new file mode 100644 index 0000000..e1ceadd --- /dev/null +++ b/examples/ops-warden-fetch/check_request_allow_openrouter_llm_connect.json @@ -0,0 +1,20 @@ +{ + "id": "check:ops-warden-fetch-openrouter-llm-connect", + "subject": { + "id": "operator", + "type": "operator", + "tenant": "tenant:platform" + }, + "action": "read", + "resource": { + "id": "secret:openrouter-llm-connect", + "type": "secret", + "system": "railiance-platform", + "tenant": "tenant:platform" + }, + "context": { + "need_id": "openrouter-llm-connect", + "owner_repo": "railiance-platform", + "domain": null + } +} diff --git a/examples/ops-warden-fetch/check_request_deny_non_secret_type.json b/examples/ops-warden-fetch/check_request_deny_non_secret_type.json new file mode 100644 index 0000000..72f5639 --- /dev/null +++ b/examples/ops-warden-fetch/check_request_deny_non_secret_type.json @@ -0,0 +1,20 @@ +{ + "id": "check:ops-warden-fetch-forgejo-admin-api-token", + "subject": { + "id": "operator", + "type": "operator", + "tenant": "tenant:platform" + }, + "action": "read", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "railiance-platform", + "tenant": "tenant:platform" + }, + "context": { + "need_id": "forgejo-admin-api-token", + "owner_repo": "railiance-platform", + "domain": null + } +} diff --git a/examples/ops-warden-fetch/check_request_deny_unknown_need.json b/examples/ops-warden-fetch/check_request_deny_unknown_need.json new file mode 100644 index 0000000..1804686 --- /dev/null +++ b/examples/ops-warden-fetch/check_request_deny_unknown_need.json @@ -0,0 +1,20 @@ +{ + "id": "check:ops-warden-fetch-no-such-need", + "subject": { + "id": "operator", + "type": "operator", + "tenant": "tenant:platform" + }, + "action": "read", + "resource": { + "id": "secret:no-such-need", + "type": "secret", + "system": "railiance-platform", + "tenant": "tenant:platform" + }, + "context": { + "need_id": "no-such-need", + "owner_repo": "railiance-platform", + "domain": null + } +} diff --git a/examples/ops-warden-fetch/check_request_deny_wrong_system.json b/examples/ops-warden-fetch/check_request_deny_wrong_system.json new file mode 100644 index 0000000..ec22306 --- /dev/null +++ b/examples/ops-warden-fetch/check_request_deny_wrong_system.json @@ -0,0 +1,20 @@ +{ + "id": "check:ops-warden-fetch-forgejo-admin-api-token", + "subject": { + "id": "operator", + "type": "operator", + "tenant": "tenant:platform" + }, + "action": "read", + "resource": { + "id": "secret:forgejo-admin-api-token", + "type": "secret", + "system": "key-cape", + "tenant": "tenant:platform" + }, + "context": { + "need_id": "forgejo-admin-api-token", + "owner_repo": "key-cape", + "domain": null + } +} diff --git a/examples/ops-warden-fetch/policy_fixtures.yaml b/examples/ops-warden-fetch/policy_fixtures.yaml new file mode 100644 index 0000000..0c4226a --- /dev/null +++ b/examples/ops-warden-fetch/policy_fixtures.yaml @@ -0,0 +1,300 @@ +- id: fixture:ops-warden-fetch-allow-forgejo-admin-api-token + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:forgejo-admin-api-token + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: railiance-platform + domain: null + expect: + effect: allow + reason: secret_read_lane_matched +- id: fixture:ops-warden-fetch-allow-openrouter-llm-connect + request: + id: check:ops-warden-fetch-openrouter-llm-connect + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:openrouter-llm-connect + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: openrouter-llm-connect + owner_repo: railiance-platform + domain: null + expect: + effect: allow + reason: secret_read_lane_matched +- id: fixture:ops-warden-fetch-allow-rapp-qonto-keycape-client + request: + id: check:ops-warden-fetch-rapp-qonto-keycape-client + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:rapp-qonto-keycape-client + type: secret + system: key-cape + tenant: tenant:platform + context: + need_id: rapp-qonto-keycape-client + owner_repo: key-cape + domain: null + expect: + effect: allow + reason: secret_read_lane_matched +- id: fixture:ops-warden-fetch-allow-domain-suffix + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:forgejo-admin-api-token/infotech + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: railiance-platform + domain: infotech + expect: + effect: allow + reason: secret_read_lane_matched +- id: fixture:ops-warden-fetch-deny-unknown-need + request: + id: check:ops-warden-fetch-no-such-need + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:no-such-need + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: no-such-need + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: unknown_need +- id: fixture:ops-warden-fetch-deny-not-exec-capable-need + request: + id: check:ops-warden-fetch-net-kingdom-lldap-bind-credential + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:net-kingdom-lldap-bind-credential + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: net-kingdom-lldap-bind-credential + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: unknown_need +- id: fixture:ops-warden-fetch-deny-login-lane-need + request: + id: check:ops-warden-fetch-openbao-platform-admin-login + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:openbao-platform-admin-login + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: openbao-platform-admin-login + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: unknown_need +- id: fixture:ops-warden-fetch-deny-wrong-system + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:forgejo-admin-api-token + type: secret + system: key-cape + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: key-cape + domain: null + expect: + effect: deny + reason: wrong_system +- id: fixture:ops-warden-fetch-deny-unknown-system + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:forgejo-admin-api-token + type: secret + system: secrets-engine + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: secrets-engine + domain: null + expect: + effect: deny + reason: unknown_system +- id: fixture:ops-warden-fetch-deny-non-secret-type + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:forgejo-admin-api-token + type: ssh-certificate + system: railiance-platform + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: wrong_resource_type +- id: fixture:ops-warden-fetch-deny-wrong-action + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: write + resource: + id: secret:forgejo-admin-api-token + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: wrong_action +- id: fixture:ops-warden-fetch-deny-wrong-tenant + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:acme + action: read + resource: + id: secret:forgejo-admin-api-token + type: secret + system: railiance-platform + tenant: tenant:acme + context: + need_id: forgejo-admin-api-token + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: wrong_tenant +- id: fixture:ops-warden-fetch-deny-wrong-subject-type + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: agt + tenant: tenant:platform + action: read + resource: + id: secret:forgejo-admin-api-token + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: wrong_subject_type +- id: fixture:ops-warden-fetch-deny-resource-need-mismatch + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:openrouter-llm-connect + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: railiance-platform + domain: null + expect: + effect: deny + reason: resource_need_mismatch +- id: fixture:ops-warden-fetch-deny-owner-repo-mismatch + request: + id: check:ops-warden-fetch-forgejo-admin-api-token + subject: + id: operator + type: operator + tenant: tenant:platform + action: read + resource: + id: secret:forgejo-admin-api-token + type: secret + system: railiance-platform + tenant: tenant:platform + context: + need_id: forgejo-admin-api-token + owner_repo: key-cape + domain: null + expect: + effect: deny + reason: owner_repo_mismatch diff --git a/examples/ops-warden-fetch/policy_package.md b/examples/ops-warden-fetch/policy_package.md new file mode 100644 index 0000000..2b3b9dd --- /dev/null +++ b/examples/ops-warden-fetch/policy_package.md @@ -0,0 +1,246 @@ +--- +id: ops-warden.secret.read +name: Ops-Warden secret read (catalog fetch lanes) +namespace: ops-warden:secret-read +version: v1 +status: ready +package: flexauth.ops_warden.secret_read +allow_ttl: 5m +actions: + - read +owner: team:platform-security +fixtures: + - policy_fixtures.yaml +caring: + profile: caring-0.4.0-rc2 + enforce: false + canonical_roles: + - Operator + organization_relations: + - ServiceProvider + scopes: + - level: Platform + id: platform:secret-read + tenant: tenant:platform + planes: + - Secret + - Audit + capabilities: + - View + - Use + exposure_modes: + - SecretMaterial + conditions: + - PurposeBound + - TimeLimited + - Logged + restrictions: + - PrivilegeEscalationBlocked +activation: + mode: local +metadata: + source: examples/ops-warden-fetch/policy_package.md + generator: tools/gen_ops_warden_fetch.py + catalog: ops-warden/registry/routing/catalog.yaml + origin: CUST-WP-0075 (bug b792c5d0) +--- + +# Ops-Warden Secret Read + +ops-warden's `warden access --fetch/--exec` asks this package, before it +proxies a non-SSH credential read, whether `read` on `secret:` owned by +`` is allowed (ops-warden `src/warden/policy.py` +`check_fetch_policy`). ops-warden is the conduit, never the owner; the owner +system is the lane's `owner_repo`, and no secret value is in the request. + +The readable set is **not authored here**. `catalog_lanes` maps each need id to +its owner system and is generated from ops-warden's routing catalog: every entry +with `status: active`, `exec_capable: true` and `lane: secret`. Regenerate with +`python3 tools/gen_ops_warden_fetch.py`; `--check` reports drift. + +The set lives in the package rather than being read from registry attributes so +that a caller cannot assert its way onto a lane the registry does not know (the +engine passes caller attributes through for unregistered resources, +FLEX-DEC-2026-012). + +Who may ask is enforced before this package runs: the pin's caller bindings map +every owner system to the ops-warden ServiceAccount, in `enforce` mode, so any +other caller gets HTTP 403 and an owner system absent from the bindings gets 403 +as well. The package repeats the system check as `unknown_system` / +`wrong_system` for offline evaluation and defence in depth. + +## Rules + +```rego +import future.keywords.if +import future.keywords.in + +# BEGIN GENERATED catalog_lanes (tools/gen_ops_warden_fetch.py; do not edit by hand) +catalog_lanes := { + "agent-harness-forgejo-deploy": "railiance-platform", + "binky-company-email-imap": "railiance-platform", + "binky-qonto-api": "railiance-platform", + "email-connect-transactional": "railiance-platform", + "forgejo-admin-api-token": "railiance-platform", + "issue-core-ingestion-api-key": "railiance-platform", + "openrouter-llm-connect": "railiance-platform", + "policy-nexus-forgejo-source-read": "railiance-platform", + "railiance-backup-offsite-lane": "railiance-platform", + "rapp-qonto-keycape-client": "key-cape", + "reuse-surface-hub-write-token": "railiance-platform", + "whynot-design-npm-publish": "railiance-platform", +} +# END GENERATED catalog_lanes + +catalog_systems := {system | system := catalog_lanes[_]} + +decision := {"effect": "allow", "reason": "secret_read_lane_matched"} if { + allowed +} else := {"effect": "deny", "reason": first_denial} if { + true +} + +allowed if { + input.action == "read" + input.resource.type == "secret" + effective_tenant == "tenant:platform" + input.subject.type == "operator" + has_subject_id + need_known + catalog_lanes[need_id] == input.resource.system + resource_id_matches_need + context_owner_matches +} + +default need_id := "" + +need_id := input.context.need_id if { + is_string(input.context.need_id) +} + +need_known if { + need_id != "" + catalog_lanes[need_id] +} + +has_subject_id if { + is_string(input.subject.id) + input.subject.id != "" +} + +default effective_tenant := "" + +effective_tenant := input.tenant if { + is_string(input.tenant) + input.tenant != "" +} else := input.resource.tenant if { + is_string(input.resource.tenant) + input.resource.tenant != "" +} else := input.subject.tenant if { + is_string(input.subject.tenant) + input.subject.tenant != "" +} + +# warden sends secret:, or secret:/ when a domain is set. +resource_id_matches_need if { + not has_domain + input.resource.id == sprintf("secret:%s", [need_id]) +} + +resource_id_matches_need if { + has_domain + input.resource.id == sprintf("secret:%s/%s", [need_id, input.context.domain]) +} + +has_domain if { + is_string(input.context.domain) + input.context.domain != "" +} + +context_owner_matches if { + input.context.owner_repo == input.resource.system +} + +default first_denial := "no_matching_rule" + +first_denial := "wrong_action" if { + input.action != "read" +} else := "wrong_resource_type" if { + input.resource.type != "secret" +} else := "wrong_tenant" if { + effective_tenant != "tenant:platform" +} else := "wrong_subject_type" if { + input.subject.type != "operator" +} else := "missing_subject" if { + not has_subject_id +} else := "missing_need_id" if { + need_id == "" +} else := "unknown_need" if { + not need_known +} else := "unknown_system" if { + not input.resource.system in catalog_systems +} else := "wrong_system" if { + catalog_lanes[need_id] != input.resource.system +} else := "resource_need_mismatch" if { + not resource_id_matches_need +} else := "owner_repo_mismatch" if { + not context_owner_matches +} +``` + +## Tests + +```rego test +package flexauth.ops_warden.secret_read_test + +import future.keywords.if +import data.flexauth.ops_warden.secret_read + +forgejo := { + "subject": {"id": "operator", "type": "operator", "tenant": "tenant:platform"}, + "action": "read", + "resource": { + "id": "secret:forgejo-admin-api-token", + "type": "secret", + "system": "railiance-platform", + "tenant": "tenant:platform" + }, + "context": {"need_id": "forgejo-admin-api-token", "owner_repo": "railiance-platform", "domain": null} +} + +test_forgejo_admin_read_allowed if { + secret_read.decision.effect == "allow" with input as forgejo +} + +test_domain_suffix_allowed if { + secret_read.decision.effect == "allow" with input as object.union(forgejo, { + "resource": object.union(forgejo.resource, {"id": "secret:forgejo-admin-api-token/infotech"}), + "context": object.union(forgejo.context, {"domain": "infotech"}), + }) +} + +test_unknown_need_denied if { + secret_read.decision.reason == "unknown_need" with input as object.union(forgejo, { + "resource": object.union(forgejo.resource, {"id": "secret:no-such-need"}), + "context": object.union(forgejo.context, {"need_id": "no-such-need"}), + }) +} + +test_unknown_system_denied if { + secret_read.decision.reason == "unknown_system" with input as object.union(forgejo, { + "resource": object.union(forgejo.resource, {"system": "ops-warden"}), + "context": object.union(forgejo.context, {"owner_repo": "ops-warden"}), + }) +} + +test_wrong_action_denied if { + secret_read.decision.reason == "wrong_action" with input as object.union(forgejo, {"action": "write"}) +} + +test_spoofed_domain_denied if { + secret_read.decision.reason == "resource_need_mismatch" with input as object.union(forgejo, { + "resource": object.union(forgejo.resource, {"id": "secret:forgejo-admin-api-token/other"}), + "context": object.union(forgejo.context, {"domain": "infotech"}), + }) +} +``` diff --git a/examples/ops-warden-fetch/registry_snapshot.json b/examples/ops-warden-fetch/registry_snapshot.json new file mode 100644 index 0000000..9601430 --- /dev/null +++ b/examples/ops-warden-fetch/registry_snapshot.json @@ -0,0 +1,256 @@ +{ + "systems": [ + { + "id": "key-cape", + "name": "key-cape secrets read through ops-warden", + "resource_types": [ + { + "name": "secret", + "scope_level": "Resource", + "planes": [ + "Secret", + "Audit" + ] + } + ], + "actions": [ + { + "name": "read", + "capabilities": [ + "View", + "Use" + ], + "planes": [ + "Secret", + "Audit" + ], + "exposure_modes": [ + "SecretMaterial" + ] + } + ], + "caring_profiles": [ + "caring-0.4.0-rc2" + ], + "metadata": { + "tenant": "tenant:platform", + "conduit": "ops-warden", + "generated_by": "tools/gen_ops_warden_fetch.py" + } + }, + { + "id": "railiance-platform", + "name": "railiance-platform secrets read through ops-warden", + "resource_types": [ + { + "name": "secret", + "scope_level": "Resource", + "planes": [ + "Secret", + "Audit" + ] + } + ], + "actions": [ + { + "name": "read", + "capabilities": [ + "View", + "Use" + ], + "planes": [ + "Secret", + "Audit" + ], + "exposure_modes": [ + "SecretMaterial" + ] + } + ], + "caring_profiles": [ + "caring-0.4.0-rc2" + ], + "metadata": { + "tenant": "tenant:platform", + "conduit": "ops-warden", + "generated_by": "tools/gen_ops_warden_fetch.py" + } + } + ], + "resource_manifests": [ + { + "id": "ops-warden-fetch-key-cape", + "system": "key-cape", + "resources": [ + { + "id": "secret:rapp-qonto-keycape-client", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:key-cape", + "attributes": { + "need_id": "rapp-qonto-keycape-client", + "catalog_lane": "secret" + } + } + ] + }, + { + "id": "ops-warden-fetch-railiance-platform", + "system": "railiance-platform", + "resources": [ + { + "id": "secret:agent-harness-forgejo-deploy", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "agent-harness-forgejo-deploy", + "catalog_lane": "secret" + } + }, + { + "id": "secret:binky-company-email-imap", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "binky-company-email-imap", + "catalog_lane": "secret" + } + }, + { + "id": "secret:binky-qonto-api", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "binky-qonto-api", + "catalog_lane": "secret" + } + }, + { + "id": "secret:email-connect-transactional", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "email-connect-transactional", + "catalog_lane": "secret" + } + }, + { + "id": "secret:forgejo-admin-api-token", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "forgejo-admin-api-token", + "catalog_lane": "secret" + } + }, + { + "id": "secret:issue-core-ingestion-api-key", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "issue-core-ingestion-api-key", + "catalog_lane": "secret" + } + }, + { + "id": "secret:openrouter-llm-connect", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "openrouter-llm-connect", + "catalog_lane": "secret" + } + }, + { + "id": "secret:policy-nexus-forgejo-source-read", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "policy-nexus-forgejo-source-read", + "catalog_lane": "secret" + } + }, + { + "id": "secret:railiance-backup-offsite-lane", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "railiance-backup-offsite-lane", + "catalog_lane": "secret" + } + }, + { + "id": "secret:reuse-surface-hub-write-token", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "reuse-surface-hub-write-token", + "catalog_lane": "secret" + } + }, + { + "id": "secret:whynot-design-npm-publish", + "type": "secret", + "labels": [ + "ops-warden-fetch", + "lane:secret" + ], + "owner": "repo:railiance-platform", + "attributes": { + "need_id": "whynot-design-npm-publish", + "catalog_lane": "secret" + } + } + ] + } + ], + "tenants": [ + { + "id": "tenant:platform", + "name": "Platform Tenant" + } + ] +} diff --git a/tests/stage1.sh b/tests/stage1.sh index cfadb62..24f1911 100755 --- a/tests/stage1.sh +++ b/tests/stage1.sh @@ -33,6 +33,26 @@ echo "$ops_render" | grep -q 'ops-warden=system:serviceaccount:ops-warden:ops-wa echo "$ops_render" | grep -q production_registry_snapshot.json \ || { echo "ops-warden render omits the production registry" >&2; exit 1; } +# The fetch pin's digest stays empty until CI builds an image containing +# examples/ops-warden-fetch; render it with a placeholder until then. +fetch_digest_args=() +if grep -Eq '^\s*digest:\s*(""|)\s*$' values/ops-warden-fetch.yaml; then + echo "note: values/ops-warden-fetch.yaml has no image digest yet (not deployable); rendering with a placeholder" + fetch_digest_args=(--set image.digest=sha256:0000000000000000000000000000000000000000000000000000000000000000) +fi +echo "helm template values/ops-warden-fetch.yaml" +fetch_render="$(helm template flex-auth-ops-warden-fetch charts/flex-auth -f values/ops-warden-fetch.yaml "${fetch_digest_args[@]}" --namespace flex-auth)" +echo "$fetch_render" | grep -A1 -- '--caller-auth-mode' | grep -q enforce \ + || { echo "ops-warden-fetch caller-auth-mode must be enforce" >&2; exit 1; } +for system in railiance-platform key-cape; do + echo "$fetch_render" | grep -q "${system}=system:serviceaccount:ops-warden:ops-warden" \ + || { echo "ops-warden-fetch render omits the ${system} binding" >&2; exit 1; } +done +echo "$fetch_render" | grep -q examples/ops-warden-fetch/policy_package.md \ + || { echo "ops-warden-fetch render omits its policy package" >&2; exit 1; } +python3 tools/gen_ops_warden_fetch.py --check >/dev/null 2>&1 || [ ! -f "${HOME}/ops-warden/registry/routing/catalog.yaml" ] \ + || { echo "ops-warden-fetch is out of date with the routing catalog; run tools/gen_ops_warden_fetch.py" >&2; exit 1; } + canary_render="$(helm template flex-auth-canary charts/flex-auth -f values/stage2-canary.yaml --namespace flex-auth)" if echo "$canary_render" | grep -q tokenreviews; then echo "isolated canary must not create TokenReview RBAC" >&2 diff --git a/tools/gen_ops_warden_fetch.py b/tools/gen_ops_warden_fetch.py new file mode 100644 index 0000000..2182bde --- /dev/null +++ b/tools/gen_ops_warden_fetch.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""Generate the ops-warden secret-read pin from ops-warden's routing catalog. + +The flex-auth-ops-warden-fetch pin answers warden's ``check_fetch_policy`` +(action ``read`` on resource type ``secret``, resource.system = the lane's +``owner_repo``). Which (need_id, owner_repo) pairs are readable is not authored +here: it is every catalog entry with ``status: active``, ``exec_capable: true`` +and ``lane: secret``. This script writes that set into three places: + + examples/ops-warden-fetch/registry_snapshot.json systems + one resource per lane + examples/ops-warden-fetch/policy_package.md the GENERATED catalog_lanes block + values/ops-warden-fetch.yaml one caller binding per owner system + +Usage: + python3 tools/gen_ops_warden_fetch.py [--catalog PATH] # write + python3 tools/gen_ops_warden_fetch.py [--catalog PATH] --check # exit 1 on drift + +The default catalog is ~/ops-warden/registry/routing/catalog.yaml. The image +digest already in the values file is preserved. No secret material is read: +the catalog is a pointer layer (ops-warden ADR-0001). +""" +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parent.parent +EXAMPLE_DIR = ROOT / "examples" / "ops-warden-fetch" +REGISTRY = EXAMPLE_DIR / "registry_snapshot.json" +POLICY = EXAMPLE_DIR / "policy_package.md" +VALUES = ROOT / "values" / "ops-warden-fetch.yaml" +DEFAULT_CATALOG = Path(os.path.expanduser("~/ops-warden/registry/routing/catalog.yaml")) + +CALLER_PRINCIPAL = "system:serviceaccount:ops-warden:ops-warden" +TENANT = "tenant:platform" +BEGIN = "# BEGIN GENERATED catalog_lanes" +END = "# END GENERATED catalog_lanes" + + +def readable_lanes(catalog_path: Path) -> list[tuple[str, str]]: + doc = yaml.safe_load(catalog_path.read_text()) + lanes = [] + for entry in doc.get("entries", []): + if entry.get("status") != "active": + continue + if entry.get("exec_capable") is not True: + continue + if entry.get("lane") != "secret": + continue + need_id, owner = entry.get("id"), entry.get("owner_repo") + if not need_id or not owner: + raise SystemExit(f"catalog entry without id/owner_repo: {entry!r}") + lanes.append((need_id, owner)) + if not lanes: + raise SystemExit("catalog yielded no readable lanes; refusing to write an empty policy") + return sorted(lanes) + + +def render_registry(lanes: list[tuple[str, str]]) -> str: + systems = sorted({owner for _, owner in lanes}) + snapshot = { + "systems": [ + { + "id": system, + "name": f"{system} secrets read through ops-warden", + "resource_types": [ + {"name": "secret", "scope_level": "Resource", "planes": ["Secret", "Audit"]} + ], + "actions": [ + { + "name": "read", + "capabilities": ["View", "Use"], + "planes": ["Secret", "Audit"], + "exposure_modes": ["SecretMaterial"], + } + ], + "caring_profiles": ["caring-0.4.0-rc2"], + "metadata": { + "tenant": TENANT, + "conduit": "ops-warden", + "generated_by": "tools/gen_ops_warden_fetch.py", + }, + } + for system in systems + ], + "resource_manifests": [ + { + "id": f"ops-warden-fetch-{system}", + "system": system, + "resources": [ + { + "id": f"secret:{need_id}", + "type": "secret", + "labels": ["ops-warden-fetch", "lane:secret"], + "owner": f"repo:{system}", + "attributes": {"need_id": need_id, "catalog_lane": "secret"}, + } + for need_id, owner in lanes + if owner == system + ], + } + for system in systems + ], + "tenants": [{"id": TENANT, "name": "Platform Tenant"}], + } + return json.dumps(snapshot, indent=2) + "\n" + + +def render_policy(current: str, lanes: list[tuple[str, str]]) -> str: + body = "\n".join(f' "{need_id}": "{owner}",' for need_id, owner in lanes) + block = f"{BEGIN} (tools/gen_ops_warden_fetch.py; do not edit by hand)\ncatalog_lanes := {{\n{body}\n}}\n{END}" + pattern = re.compile(re.escape(BEGIN) + r".*?" + re.escape(END), re.S) + if not pattern.search(current): + raise SystemExit(f"{POLICY}: generated markers not found") + return pattern.sub(lambda _: block, current, count=1) + + +def render_values(current: str | None, lanes: list[tuple[str, str]]) -> str: + digest = "" + if current: + match = re.search(r"^\s*digest:\s*(\S*)\s*$", current, re.M) + if match: + digest = match.group(1).strip("\"'") + systems = sorted({owner for _, owner in lanes}) + bindings = [f"{system}={CALLER_PRINCIPAL}" for system in systems] + extra = "".join(f"\n - {b}" for b in bindings[1:]) or " []" + return f"""# ops-warden secret-read pin (CUST-WP-0075, access-engine ADHOC-2026-10-03). +# Answers warden check_fetch_policy: action read on resource type secret, system = +# the lane's owner_repo. Separate from flex-auth-ops-warden (SSH signing) so each +# decision class rolls independently (FLEX-WP-0016). Isolated: the workstation +# reaches it through the ops-bridge tunnel flex-auth-ops-warden-fetch-railiance01 +# (127.0.0.1:19091). Every owner system is bound to the ops-warden ServiceAccount, +# so only ops-warden may ask; callerAuth enforce as on the signing pin. +# +# GENERATED by tools/gen_ops_warden_fetch.py from ops-warden's routing catalog, +# except image.digest, which the script preserves. Regenerate, do not hand-edit. +name: flex-auth-ops-warden-fetch +image: + repository: forgejo.coulomb.social/coulomb/flex-auth + digest: {digest or '""'} +args: + - serve + - --addr + - 0.0.0.0:8080 + - --registry + - /opt/flex-auth/examples/ops-warden-fetch/registry_snapshot.json + - --policy + - /opt/flex-auth/examples/ops-warden-fetch/policy_package.md +callerAuth: + mode: enforce + kubernetesURL: https://10.43.0.1 + binding: {bindings[0]} + extraBindings:{extra} +consumer: + isolated: true +""" + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--catalog", type=Path, default=DEFAULT_CATALOG) + parser.add_argument("--check", action="store_true", help="report drift, write nothing") + args = parser.parse_args() + + lanes = readable_lanes(args.catalog) + values_current = VALUES.read_text() if VALUES.exists() else None + outputs = { + REGISTRY: render_registry(lanes), + POLICY: render_policy(POLICY.read_text(), lanes), + VALUES: render_values(values_current, lanes), + } + drift = [p for p, text in outputs.items() if not p.exists() or p.read_text() != text] + if args.check: + for path in drift: + print(f"drift: {path.relative_to(ROOT)}", file=sys.stderr) + print(f"{len(lanes)} readable lanes; {'drift' if drift else 'in sync'}") + return 1 if drift else 0 + for path in drift: + path.write_text(outputs[path]) + print(f"wrote {path.relative_to(ROOT)}") + print(f"{len(lanes)} readable lanes across {len({o for _, o in lanes})} owner systems") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/values/ops-warden-fetch.yaml b/values/ops-warden-fetch.yaml new file mode 100644 index 0000000..2bb7ad9 --- /dev/null +++ b/values/ops-warden-fetch.yaml @@ -0,0 +1,30 @@ +# ops-warden secret-read pin (CUST-WP-0075, access-engine ADHOC-2026-10-03). +# Answers warden check_fetch_policy: action read on resource type secret, system = +# the lane's owner_repo. Separate from flex-auth-ops-warden (SSH signing) so each +# decision class rolls independently (FLEX-WP-0016). Isolated: the workstation +# reaches it through the ops-bridge tunnel flex-auth-ops-warden-fetch-railiance01 +# (127.0.0.1:19091). Every owner system is bound to the ops-warden ServiceAccount, +# so only ops-warden may ask; callerAuth enforce as on the signing pin. +# +# GENERATED by tools/gen_ops_warden_fetch.py from ops-warden's routing catalog, +# except image.digest, which the script preserves. Regenerate, do not hand-edit. +name: flex-auth-ops-warden-fetch +image: + repository: forgejo.coulomb.social/coulomb/flex-auth + digest: "" +args: + - serve + - --addr + - 0.0.0.0:8080 + - --registry + - /opt/flex-auth/examples/ops-warden-fetch/registry_snapshot.json + - --policy + - /opt/flex-auth/examples/ops-warden-fetch/policy_package.md +callerAuth: + mode: enforce + kubernetesURL: https://10.43.0.1 + binding: key-cape=system:serviceaccount:ops-warden:ops-warden + extraBindings: + - railiance-platform=system:serviceaccount:ops-warden:ops-warden +consumer: + isolated: true diff --git a/workplans/ADHOC-2026-10-03.md b/workplans/ADHOC-2026-10-03.md new file mode 100644 index 0000000..3ea8b1c --- /dev/null +++ b/workplans/ADHOC-2026-10-03.md @@ -0,0 +1,87 @@ +--- +id: FLEX-WP-ADHOC-2026-10-03 +type: workplan +title: "2026-10-03 secret-read evaluator for ops-warden fetch checks" +domain: infotech +repo: flex-auth +status: blocked +owner: claude +topic_slug: netkingdom +created: "2026-10-03" +updated: "2026-10-03" +--- + +# 2026-10-03 — secret-read evaluator for ops-warden fetch checks + +Opened from the-custodian bug `b792c5d0-f1ff-41cf-90a7-5697ef582a41` +(CUST-WP-0075, CCR-2026-0031 provisioning; ops-warden side `faf11061`). +Founder direction 2026-10-03 (the-custodian `docs/agent-environment-orientation.md` +§0): fixing the security layer is wanted; live changes still need his go. + +**Bug.** ops-warden `check_fetch_policy` posts `read` on `secret:` +with `resource.system = ` to `policy.flex_auth_url` +(`127.0.0.1:19090`, the tunnel to `flex-auth-ops-warden`, the SSH signing pin). +That pin binds only system `ops-warden`, and its package returns `wrong_system` +for anything else, so every non-SSH fetch lane got 403. + +## Build the secret-read evaluator (repo side) + +```task +id: FLEX-WP-ADHOC-2026-10-03-T01 +status: done +priority: high +``` + +Separate isolated pin `flex-auth-ops-warden-fetch` (one decision class per pin, +FLEX-WP-0016): + +- `examples/ops-warden-fetch/`: package `ops-warden.secret.read` v1, registry, + 15 fixtures, 6 Rego tests, check requests. +- `values/ops-warden-fetch.yaml`: enforce; key-cape and railiance-platform bound + to the ops-warden ServiceAccount. +- `tools/gen_ops_warden_fetch.py`: generates the registry, `catalog_lanes` and + the bindings from ops-warden's catalog; `--check` reports drift. +- Chart `callerAuth.extraBindings`; `tests/stage1.sh` render and drift check. +- `cmd/flex-auth/ops_warden_fetch_test.go`: decisions, caller bindings read from + the values file, registry/policy parity. + +2026-10-03: `make ci` green; 12 lanes across 2 owner systems. + +## Deploy the pin (founder go required) + +```task +id: FLEX-WP-ADHOC-2026-10-03-T02 +status: wait +priority: high +needs_human: true +blocking_reason: "Live railiance01 change; founder go needed for CI image digest, helm install and tunnel." +``` + +Steps, none run yet: + +1. Push `main`; Forgejo `image.yaml` builds (trigger `examples/**`). Take the + digest from "Report immutable digest" and set `image.digest` in + `values/ops-warden-fetch.yaml` (the generator preserves it). Commit. +2. Preflight on railiance01: CPU headroom + (`kubectl describe node | grep -A4 "Allocated resources"`; the new pod + requests 25m CPU / 32Mi), then a server dry-run of + `helm template flex-auth-ops-warden-fetch charts/flex-auth -f values/ops-warden-fetch.yaml --namespace flex-auth`. +3. Install on the node (`export KUBECONFIG=/etc/rancher/k3s/k3s.yaml`; chart and + values copied with scp): + `helm upgrade --install flex-auth-ops-warden-fetch charts/flex-auth -f values/ops-warden-fetch.yaml --namespace flex-auth --atomic`. + `flex-auth-ops-warden` (signing) is not touched. +4. Read the new Service ClusterIP (`kubectl -n flex-auth get svc flex-auth-ops-warden-fetch`). +5. Workstation tunnel in ops-bridge (`~/.config/bridge/tunnels.yaml`), modelled on + `flex-auth-ops-warden-railiance01`: name `flex-auth-ops-warden-fetch-railiance01`, + host `92.205.62.239`, `remote_host: `, `remote_port: 8080`, + `local_port: 19091`, health check `http://127.0.0.1:19091/healthz`; then + `bridge up flex-auth-ops-warden-fetch-railiance01`. +6. Probe with an ops-warden ServiceAccount token minted for audience `flex-auth` + (as in FLEX-WP-0016-T03): + `check_request_allow_forgejo_admin_api_token.json` → 200 allow; + `check_request_deny_unknown_need.json` → 200 deny `unknown_need`; + no token → 401; `resource.system: ops-warden` → 403. +7. ops-warden points its fetch checks at `http://127.0.0.1:19091`, separate from + the signing URL `:19090` (its side: WARDEN-WP-0039-T03). + +Close-down checklist row 1 (the-custodian) is updated by message, not by edit.