From 0fde95a87cf084b926724754da680eab1aad8db2 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 23 Jun 2026 21:17:42 +0200 Subject: [PATCH] FLEX-WP-0006: implement ops-warden signing gate policy --- cmd/flex-auth/main.go | 17 +- cmd/flex-auth/main_test.go | 100 +++++ docs/ops-warden-policy-gate-handoff.md | 82 ++++ docs/workplan-planning-map.md | 21 +- examples/README.md | 2 + examples/ops-warden/README.md | 34 ++ .../ops-warden/check_request_allow_adm.json | 23 ++ .../ops-warden/check_request_allow_agt.json | 22 ++ .../ops-warden/check_request_allow_atm.json | 22 ++ ...heck_request_deny_actor_type_mismatch.json | 22 ++ ...eck_request_deny_disallowed_principal.json | 22 ++ ...heck_request_deny_missing_fingerprint.json | 21 + .../check_request_deny_ttl_above_max.json | 22 ++ .../check_request_deny_unknown_subject.json | 22 ++ examples/ops-warden/policy_fixtures.yaml | 337 ++++++++++++++++ examples/ops-warden/policy_package.md | 257 ++++++++++++ .../ops-warden/protected_system_manifest.yaml | 36 ++ examples/ops-warden/registry_snapshot.json | 366 ++++++++++++++++++ examples/ops-warden/resource_manifest.yaml | 59 +++ examples/ops-warden/subject_manifest.yaml | 54 +++ internal/decision/engine.go | 10 + internal/policy/package_test.go | 22 ++ pkg/api/canonical.go | 2 + schemas/check_request.schema.json | 1 + ...0006-ops-warden-ssh-signing-policy-gate.md | 230 +++++++++++ 25 files changed, 1796 insertions(+), 10 deletions(-) create mode 100644 docs/ops-warden-policy-gate-handoff.md create mode 100644 examples/ops-warden/README.md create mode 100644 examples/ops-warden/check_request_allow_adm.json create mode 100644 examples/ops-warden/check_request_allow_agt.json create mode 100644 examples/ops-warden/check_request_allow_atm.json create mode 100644 examples/ops-warden/check_request_deny_actor_type_mismatch.json create mode 100644 examples/ops-warden/check_request_deny_disallowed_principal.json create mode 100644 examples/ops-warden/check_request_deny_missing_fingerprint.json create mode 100644 examples/ops-warden/check_request_deny_ttl_above_max.json create mode 100644 examples/ops-warden/check_request_deny_unknown_subject.json create mode 100644 examples/ops-warden/policy_fixtures.yaml create mode 100644 examples/ops-warden/policy_package.md create mode 100644 examples/ops-warden/protected_system_manifest.yaml create mode 100644 examples/ops-warden/registry_snapshot.json create mode 100644 examples/ops-warden/resource_manifest.yaml create mode 100644 examples/ops-warden/subject_manifest.yaml create mode 100644 workplans/FLEX-WP-0006-ops-warden-ssh-signing-policy-gate.md diff --git a/cmd/flex-auth/main.go b/cmd/flex-auth/main.go index e9ffb49..1dddab8 100644 --- a/cmd/flex-auth/main.go +++ b/cmd/flex-auth/main.go @@ -337,6 +337,16 @@ func runServe(args []string, stdout, stderr io.Writer) int { return fail(stderr, err) } + mux := newServeMux(engine) + + fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr) + if err := http.ListenAndServe(*addr, mux); err != nil { + return fail(stderr, err) + } + return 0 +} + +func newServeMux(engine *decisioncore.Engine) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("content-type", "application/json") @@ -368,12 +378,7 @@ func runServe(args []string, stdout, stderr io.Writer) int { decisions, err := engine.BatchCheck(r.Context(), request) writeHTTP(w, decisions, err) }) - - fmt.Fprintf(stderr, "flex-auth serving on http://%s\n", *addr) - if err := http.ListenAndServe(*addr, mux); err != nil { - return fail(stderr, err) - } - return 0 + return mux } func buildEngine(ctx context.Context, registryPath, policyPath, logPath string) (*decisioncore.Engine, error) { diff --git a/cmd/flex-auth/main_test.go b/cmd/flex-auth/main_test.go index 73f4aa0..3d6e371 100644 --- a/cmd/flex-auth/main_test.go +++ b/cmd/flex-auth/main_test.go @@ -2,7 +2,11 @@ package main import ( "bytes" + "context" "encoding/json" + "net/http" + "net/http/httptest" + "os" "path/filepath" "strings" "testing" @@ -74,6 +78,76 @@ func TestRunBatchCheck(t *testing.T) { } } +func TestRunCheckOpsWarden(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{ + "check", + "--registry", opsPath("registry_snapshot.json"), + "--policy", opsPath("policy_package.md"), + "--request", opsPath("check_request_allow_adm.json"), + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("code = %d, stderr = %s", code, stderr.String()) + } + + var decision api.DecisionEnvelope + if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil { + t.Fatalf("unmarshal decision: %v\n%s", err, stdout.String()) + } + if decision.Effect != api.DecisionEffectAllow { + t.Fatalf("decision.Effect = %q; want allow", decision.Effect) + } + if decision.ID == "" { + t.Fatal("decision.ID is empty; ops-warden needs a policy_decision_id") + } +} + +func TestServeOpsWardenCheckContract(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "decisions.jsonl") + engine, err := buildEngine(context.Background(), opsPath("registry_snapshot.json"), opsPath("policy_package.md"), logPath) + if err != nil { + t.Fatalf("buildEngine: %v", err) + } + server := httptest.NewServer(newServeMux(engine)) + defer server.Close() + + allow := postCheck(t, server.URL+"/v1/check", opsPath("check_request_allow_adm.json")) + if allow.Effect != api.DecisionEffectAllow || allow.ID == "" { + t.Fatalf("allow decision = %+v; want allow with id", allow) + } + + deny := postCheck(t, server.URL+"/v1/check", opsPath("check_request_deny_ttl_above_max.json")) + if deny.Effect != api.DecisionEffectDeny || deny.Reason != "ttl_out_of_bounds" { + t.Fatalf("deny decision = %+v; want ttl_out_of_bounds deny", deny) + } + + resp, err := http.Get(server.URL + "/v1/check") + if err != nil { + t.Fatalf("GET /v1/check: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("GET /v1/check status = %d; want 405", resp.StatusCode) + } + + resp, err = http.Post(server.URL+"/v1/check", "application/json", strings.NewReader(`{"subject":`)) + if err != nil { + t.Fatalf("POST malformed /v1/check: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("malformed POST status = %d; want 400", resp.StatusCode) + } + + logData, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read decision log: %v", err) + } + if !strings.Contains(string(logData), allow.ID) || !strings.Contains(string(logData), deny.ID) { + t.Fatalf("decision log does not contain both decision ids\nlog: %s\nallow: %s deny: %s", string(logData), allow.ID, deny.ID) + } +} + func TestRunValidateAccessDescriptor(t *testing.T) { var stdout, stderr bytes.Buffer code := run([]string{"validate", "--kind", "access-descriptor", "--file", examplePath("access_descriptor.yaml")}, &stdout, &stderr) @@ -88,3 +162,29 @@ func TestRunValidateAccessDescriptor(t *testing.T) { func examplePath(name string) string { return filepath.Join("..", "..", "examples", "caring", name) } + +func opsPath(name string) string { + return filepath.Join("..", "..", "examples", "ops-warden", name) +} + +func postCheck(t *testing.T, url, path string) api.DecisionEnvelope { + t.Helper() + + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + t.Fatalf("POST %s: %v", url, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("POST %s status = %d; want 200", path, resp.StatusCode) + } + var decision api.DecisionEnvelope + if err := json.NewDecoder(resp.Body).Decode(&decision); err != nil { + t.Fatalf("decode %s response: %v", path, err) + } + return decision +} diff --git a/docs/ops-warden-policy-gate-handoff.md b/docs/ops-warden-policy-gate-handoff.md new file mode 100644 index 0000000..32e4018 --- /dev/null +++ b/docs/ops-warden-policy-gate-handoff.md @@ -0,0 +1,82 @@ +# Ops-Warden Policy Gate Handoff + +Date: 2026-06-23 +Workplan: FLEX-WP-0006 +Ops-warden unblocker: WARDEN-WP-0009 T01 + +## Published flex-auth assets + +- Policy package: examples/ops-warden/policy_package.md +- Policy fixtures: examples/ops-warden/policy_fixtures.yaml +- Combined registry fixture: examples/ops-warden/registry_snapshot.json +- Protected-system manifest: examples/ops-warden/protected_system_manifest.yaml +- Resource manifest: examples/ops-warden/resource_manifest.yaml +- Subject manifest: examples/ops-warden/subject_manifest.yaml +- Service request fixtures: examples/ops-warden/check_request_*.json + +## Local service command + + flex-auth serve --addr 127.0.0.1:8080 --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --log /tmp/flex-auth-ops-warden-decisions.jsonl + +Ops-warden can point policy.flex_auth_url at that base URL for local smoke. +Production should keep policy.fail_closed true unless an explicit break-glass +procedure exists. + +## Fixture coverage + +Allow fixtures: + +- fixture:ops-warden-adm-sign-allow +- fixture:ops-warden-agt-sign-allow +- fixture:ops-warden-atm-sign-allow + +Deny fixtures: + +- fixture:ops-warden-unknown-subject-deny +- fixture:ops-warden-actor-type-mismatch-deny +- fixture:ops-warden-ttl-above-max-deny +- fixture:ops-warden-disallowed-principal-deny +- fixture:ops-warden-missing-fingerprint-deny + +## Non-secret smoke evidence + +CLI validation on 2026-06-23: + +- protected-system manifest: valid +- resource manifest: valid +- subject manifest: valid +- registry snapshot: loaded 1 system, 1 resource manifest, 3 subjects, + 3 groups, 3 relationships, and 1 tenant +- policy package: valid with 8 passing fixtures + +Local /v1/check service smoke on 2026-06-23: + +- allow request: effect allow, reason signing_policy_matched, + decision id decision:706efe49f68d9ef1 +- deny request: effect deny, reason ttl_out_of_bounds, + decision id decision:b69bdc25a988f367 +- GET /v1/check: HTTP 405 +- malformed POST /v1/check: HTTP 400 +- decision log contained both decision ids + +## Production sequence for ops-warden + +1. Deploy the flex-auth registry and policy package above to the selected + flex-auth runtime. +2. Configure ops-warden policy.flex_auth_url to the flex-auth base URL. +3. Set policy.enabled: true. +4. Keep policy.tenant as tenant:platform unless a tenant-specific policy package + is introduced. +5. Run one allow-path sign smoke and confirm signatures.log includes + policy_decision_id. +6. Run one deny-path smoke with fail_closed true and preserve only non-secret + evidence. + +## Ownership boundary + +flex-auth owns the authorization decision for the signing request. ops-warden +continues to own actor inventory, SSH CA operation, OpenBao SSH engine +integration, host documentation, and signatures.log production evidence. + +No SSH private keys, OpenBao tokens, database credentials, or real public-key +material are stored in these fixtures. diff --git a/docs/workplan-planning-map.md b/docs/workplan-planning-map.md index a013642..c74cc80 100644 --- a/docs/workplan-planning-map.md +++ b/docs/workplan-planning-map.md @@ -1,6 +1,6 @@ # Flex-Auth Workplan Planning Map -Date: 2026-05-17 +Date: 2026-06-23 ## Purpose @@ -21,9 +21,10 @@ This document captures the current sequencing view for flex-auth workplans. | --- | --- | --- | --- | --- | | `FLEX-WP-0001` | complete | done | none | Repo intent, boundaries, and authorization landscape research are complete. | | `FLEX-WP-0005` | complete | done | `FLEX-WP-0001` | Foundations and Topaz alignment are complete: ADR-001/002/003, Go skeleton, `FlexAuthResourceManifest` schema pin, Topaz mapping spike, IAM Profile citation, ops-warden boundary clarification. | -| `FLEX-WP-0002` | P0 | ready | `FLEX-WP-0001`, `FLEX-WP-0005` | Standalone policy-as-code core: schemas, local registry, CARING profile/descriptors, Rego-in-Markdown policy packages, check APIs, explanations, decision log, CLI/service skeleton, tests. | -| `FLEX-WP-0003` | P1 | blocked | `FLEX-WP-0002` | Markitect consumer integration and first CARING benchmark: resource namespace, manifest import, action vocabulary, descriptor fixtures, decision fixtures, integration docs. | -| `FLEX-WP-0004` | P2 | blocked | `FLEX-WP-0002`, `FLEX-WP-0005` | Delegated PDP and directory adapters: Topaz adapter implementation (evaluation already done in `0005`), OpenFGA/SpiceDB, OPA/Cedar, Keycloak Authorization Services, Entra/Graph/SCIM, CARING envelope preservation. | +| `FLEX-WP-0002` | complete | completed | `FLEX-WP-0001`, `FLEX-WP-0005` | Standalone policy-as-code core is complete: schemas, local registry, CARING profile/descriptors, Rego-in-Markdown policy packages, check APIs, explanations, decision log, CLI/service skeleton, tests. | +| `FLEX-WP-0003` | complete | completed | `FLEX-WP-0002` | Markitect consumer integration and first CARING benchmark are complete: resource namespace, manifest import, action vocabulary, descriptor fixtures, decision fixtures, integration docs. | +| `FLEX-WP-0004` | complete | completed | `FLEX-WP-0002`, `FLEX-WP-0005` | Delegated PDP and directory adapter boundary work is complete: Topaz adapter shape, OpenFGA/SpiceDB, OPA/Cedar, Keycloak Authorization Services, Entra/Graph/SCIM, CARING envelope preservation. | +| `FLEX-WP-0006` | complete | finished | `FLEX-WP-0002`, `FLEX-WP-0005` | Ops-warden unblocker is complete: flex-auth publishes `ssh-certificate` / `sign` policies, fixtures, and `/v1/check` smoke evidence for the opt-in pre-sign gate shipped in ops-warden `WARDEN-WP-0007` and tracked for production in `WARDEN-WP-0009`. | ## Dependency Notes @@ -58,6 +59,14 @@ now implements the Topaz adapter against the spike's output. Delegated adapters must preserve flex-auth's CARING descriptor and conformance fields even when backend-native role semantics differ. +`FLEX-WP-0006` was the cross-repo integration unblocker for +ops-warden. ops-warden already implements the opt-in policy call +(`policy.enabled: true`) and production OpenBao signing works without the +gate. flex-auth now publishes the protected-system manifest, +`ssh-certificate` / `sign` policy package, allow/deny fixtures, and +`POST /v1/check` evidence that ops-warden can use before enabling +`policy.enabled` in production. + ## State Hub Mirror Native State Hub dependency edges: @@ -68,3 +77,7 @@ Native State Hub dependency edges: - `FLEX-WP-0003 -> FLEX-WP-0002` - `FLEX-WP-0004 -> FLEX-WP-0002` - `FLEX-WP-0004 -> FLEX-WP-0005` (Topaz adapter consumes the spike) +- `FLEX-WP-0006 -> FLEX-WP-0002` +- `FLEX-WP-0006 -> FLEX-WP-0005` +- ops-warden: `WARDEN-WP-0009` waits for `FLEX-WP-0006` output before + production enablement of `policy.enabled`. diff --git a/examples/README.md b/examples/README.md index 505544a..979d67d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -13,6 +13,8 @@ examples/ # decision, registry, and audit fixtures (P2.1) markitect/ # FlexAuthResourceManifest fixtures, decision # fixtures, and Rego-in-Markdown policy packages + ops-warden/ # SSH certificate signing policy-gate fixtures + # for ops-warden policy.enabled smoke checks topaz/ # docker-compose + sample directory and policy # for the Topaz alignment spike (P5.4) policies/ # generic Rego-in-Markdown packages used by diff --git a/examples/ops-warden/README.md b/examples/ops-warden/README.md new file mode 100644 index 0000000..3d37398 --- /dev/null +++ b/examples/ops-warden/README.md @@ -0,0 +1,34 @@ +# Ops-Warden SSH Signing Policy Gate + +This example is the flex-auth side of ops-warden's opt-in pre-sign gate. +When `policy.enabled: true`, ops-warden calls `POST /v1/check` before signing +or issuing an SSH certificate. + +Files: + +- `protected_system_manifest.yaml` declares the `ops-warden` protected system, + `ssh-certificate` resource type, and `sign` action. +- `resource_manifest.yaml` declares fixture SSH certificate actor resources and + non-secret policy attributes such as allowed principals and TTL maxima. +- `subject_manifest.yaml` declares non-secret fixture actors for `adm`, `agt`, + and `atm` signing paths. +- `registry_snapshot.json` is the combined local registry used by the CLI and + service examples. +- `policy_package.md` is the Rego-in-Markdown policy package. +- `policy_fixtures.yaml` contains allow and deny expectations for package + validation. +- `check_request_*.json` files are ops-warden-shaped `/v1/check` requests. + +Run locally: + +```bash +flex-auth validate --kind protected-system --file examples/ops-warden/protected_system_manifest.yaml +flex-auth validate --kind resource-manifest --file examples/ops-warden/resource_manifest.yaml +flex-auth validate --kind subject-manifest --file examples/ops-warden/subject_manifest.yaml +flex-auth load-registry --file examples/ops-warden/registry_snapshot.json +flex-auth test-policy --file examples/ops-warden/policy_package.md +flex-auth check --registry examples/ops-warden/registry_snapshot.json --policy examples/ops-warden/policy_package.md --request examples/ops-warden/check_request_allow_adm.json +``` + +The fixture public-key fingerprints are examples only. Do not put real keys, +OpenBao tokens, or private signing material in these files. diff --git a/examples/ops-warden/check_request_allow_adm.json b/examples/ops-warden/check_request_allow_adm.json new file mode 100644 index 0000000..05b49ce --- /dev/null +++ b/examples/ops-warden/check_request_allow_adm.json @@ -0,0 +1,23 @@ +{ + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "platform-steward", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "platform", + "root" + ], + "actor_type": "adm", + "ttl_hours": 4, + "pubkey_fingerprint": "SHA256:example-adm-fingerprint" + } +} diff --git a/examples/ops-warden/check_request_allow_agt.json b/examples/ops-warden/check_request_allow_agt.json new file mode 100644 index 0000000..a1684bd --- /dev/null +++ b/examples/ops-warden/check_request_allow_agt.json @@ -0,0 +1,22 @@ +{ + "id": "check:ops-warden-ci-deploy-agent-agt", + "tenant": "tenant:platform", + "subject": { + "id": "ci-deploy-agent", + "type": "agt" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/ci-deploy-agent", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "deploy" + ], + "actor_type": "agt", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-agt-fingerprint" + } +} diff --git a/examples/ops-warden/check_request_allow_atm.json b/examples/ops-warden/check_request_allow_atm.json new file mode 100644 index 0000000..89625c0 --- /dev/null +++ b/examples/ops-warden/check_request_allow_atm.json @@ -0,0 +1,22 @@ +{ + "id": "check:ops-warden-backup-automation-atm", + "tenant": "tenant:platform", + "subject": { + "id": "backup-automation", + "type": "atm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/backup-automation", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "backup" + ], + "actor_type": "atm", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-atm-fingerprint" + } +} diff --git a/examples/ops-warden/check_request_deny_actor_type_mismatch.json b/examples/ops-warden/check_request_deny_actor_type_mismatch.json new file mode 100644 index 0000000..1ade1d6 --- /dev/null +++ b/examples/ops-warden/check_request_deny_actor_type_mismatch.json @@ -0,0 +1,22 @@ +{ + "id": "check:ops-warden-ci-deploy-agent-agt", + "tenant": "tenant:platform", + "subject": { + "id": "ci-deploy-agent", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/ci-deploy-agent", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "deploy" + ], + "actor_type": "agt", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-agt-fingerprint" + } +} diff --git a/examples/ops-warden/check_request_deny_disallowed_principal.json b/examples/ops-warden/check_request_deny_disallowed_principal.json new file mode 100644 index 0000000..b88be53 --- /dev/null +++ b/examples/ops-warden/check_request_deny_disallowed_principal.json @@ -0,0 +1,22 @@ +{ + "id": "check:ops-warden-ci-deploy-agent-agt", + "tenant": "tenant:platform", + "subject": { + "id": "ci-deploy-agent", + "type": "agt" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/ci-deploy-agent", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "root" + ], + "actor_type": "agt", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-agt-fingerprint" + } +} diff --git a/examples/ops-warden/check_request_deny_missing_fingerprint.json b/examples/ops-warden/check_request_deny_missing_fingerprint.json new file mode 100644 index 0000000..db3bb75 --- /dev/null +++ b/examples/ops-warden/check_request_deny_missing_fingerprint.json @@ -0,0 +1,21 @@ +{ + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "platform-steward", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "platform" + ], + "actor_type": "adm", + "ttl_hours": 4 + } +} diff --git a/examples/ops-warden/check_request_deny_ttl_above_max.json b/examples/ops-warden/check_request_deny_ttl_above_max.json new file mode 100644 index 0000000..8e0a52e --- /dev/null +++ b/examples/ops-warden/check_request_deny_ttl_above_max.json @@ -0,0 +1,22 @@ +{ + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "platform-steward", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "platform" + ], + "actor_type": "adm", + "ttl_hours": 12, + "pubkey_fingerprint": "SHA256:example-adm-fingerprint" + } +} diff --git a/examples/ops-warden/check_request_deny_unknown_subject.json b/examples/ops-warden/check_request_deny_unknown_subject.json new file mode 100644 index 0000000..237adcd --- /dev/null +++ b/examples/ops-warden/check_request_deny_unknown_subject.json @@ -0,0 +1,22 @@ +{ + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "unknown-actor", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden" + }, + "context": { + "principals": [ + "platform" + ], + "actor_type": "adm", + "ttl_hours": 4, + "pubkey_fingerprint": "SHA256:example-adm-fingerprint" + } +} diff --git a/examples/ops-warden/policy_fixtures.yaml b/examples/ops-warden/policy_fixtures.yaml new file mode 100644 index 0000000..9dd80b6 --- /dev/null +++ b/examples/ops-warden/policy_fixtures.yaml @@ -0,0 +1,337 @@ +[ + { + "id": "fixture:ops-warden-adm-sign-allow", + "request": { + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "platform-steward", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "platform-steward", + "actor_type": "adm", + "allowed_subjects": [ + "platform-steward", + "iam:platform-steward" + ], + "allowed_principals": [ + "platform", + "root" + ], + "max_ttl_hours": 8 + } + }, + "context": { + "principals": [ + "platform", + "root" + ], + "actor_type": "adm", + "ttl_hours": 4, + "pubkey_fingerprint": "SHA256:example-adm-fingerprint" + } + }, + "expect": { + "effect": "allow", + "reason": "signing_policy_matched" + } + }, + { + "id": "fixture:ops-warden-agt-sign-allow", + "request": { + "id": "check:ops-warden-ci-deploy-agent-agt", + "tenant": "tenant:platform", + "subject": { + "id": "ci-deploy-agent", + "type": "agt" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/ci-deploy-agent", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "ci-deploy-agent", + "actor_type": "agt", + "allowed_subjects": [ + "ci-deploy-agent", + "iam:ci-deploy-agent" + ], + "allowed_principals": [ + "deploy", + "git" + ], + "max_ttl_hours": 2 + } + }, + "context": { + "principals": [ + "deploy" + ], + "actor_type": "agt", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-agt-fingerprint" + } + }, + "expect": { + "effect": "allow", + "reason": "signing_policy_matched" + } + }, + { + "id": "fixture:ops-warden-atm-sign-allow", + "request": { + "id": "check:ops-warden-backup-automation-atm", + "tenant": "tenant:platform", + "subject": { + "id": "backup-automation", + "type": "atm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/backup-automation", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "backup-automation", + "actor_type": "atm", + "allowed_subjects": [ + "backup-automation", + "iam:backup-automation" + ], + "allowed_principals": [ + "backup" + ], + "max_ttl_hours": 1 + } + }, + "context": { + "principals": [ + "backup" + ], + "actor_type": "atm", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-atm-fingerprint" + } + }, + "expect": { + "effect": "allow", + "reason": "signing_policy_matched" + } + }, + { + "id": "fixture:ops-warden-unknown-subject-deny", + "request": { + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "unknown-actor", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "platform-steward", + "actor_type": "adm", + "allowed_subjects": [ + "platform-steward", + "iam:platform-steward" + ], + "allowed_principals": [ + "platform", + "root" + ], + "max_ttl_hours": 8 + } + }, + "context": { + "principals": [ + "platform" + ], + "actor_type": "adm", + "ttl_hours": 4, + "pubkey_fingerprint": "SHA256:example-adm-fingerprint" + } + }, + "expect": { + "effect": "deny", + "reason": "unknown_subject" + } + }, + { + "id": "fixture:ops-warden-actor-type-mismatch-deny", + "request": { + "id": "check:ops-warden-ci-deploy-agent-agt", + "tenant": "tenant:platform", + "subject": { + "id": "ci-deploy-agent", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/ci-deploy-agent", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "ci-deploy-agent", + "actor_type": "agt", + "allowed_subjects": [ + "ci-deploy-agent", + "iam:ci-deploy-agent" + ], + "allowed_principals": [ + "deploy", + "git" + ], + "max_ttl_hours": 2 + } + }, + "context": { + "principals": [ + "deploy" + ], + "actor_type": "agt", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-agt-fingerprint" + } + }, + "expect": { + "effect": "deny", + "reason": "actor_type_mismatch" + } + }, + { + "id": "fixture:ops-warden-ttl-above-max-deny", + "request": { + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "platform-steward", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "platform-steward", + "actor_type": "adm", + "allowed_subjects": [ + "platform-steward", + "iam:platform-steward" + ], + "allowed_principals": [ + "platform", + "root" + ], + "max_ttl_hours": 8 + } + }, + "context": { + "principals": [ + "platform" + ], + "actor_type": "adm", + "ttl_hours": 12, + "pubkey_fingerprint": "SHA256:example-adm-fingerprint" + } + }, + "expect": { + "effect": "deny", + "reason": "ttl_out_of_bounds" + } + }, + { + "id": "fixture:ops-warden-disallowed-principal-deny", + "request": { + "id": "check:ops-warden-ci-deploy-agent-agt", + "tenant": "tenant:platform", + "subject": { + "id": "ci-deploy-agent", + "type": "agt" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/ci-deploy-agent", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "ci-deploy-agent", + "actor_type": "agt", + "allowed_subjects": [ + "ci-deploy-agent", + "iam:ci-deploy-agent" + ], + "allowed_principals": [ + "deploy", + "git" + ], + "max_ttl_hours": 2 + } + }, + "context": { + "principals": [ + "root" + ], + "actor_type": "agt", + "ttl_hours": 1, + "pubkey_fingerprint": "SHA256:example-agt-fingerprint" + } + }, + "expect": { + "effect": "deny", + "reason": "disallowed_principal" + } + }, + { + "id": "fixture:ops-warden-missing-fingerprint-deny", + "request": { + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": { + "id": "platform-steward", + "type": "adm" + }, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "platform-steward", + "actor_type": "adm", + "allowed_subjects": [ + "platform-steward", + "iam:platform-steward" + ], + "allowed_principals": [ + "platform", + "root" + ], + "max_ttl_hours": 8 + } + }, + "context": { + "principals": [ + "platform" + ], + "actor_type": "adm", + "ttl_hours": 4 + } + }, + "expect": { + "effect": "deny", + "reason": "missing_pubkey_fingerprint" + } + } +] diff --git a/examples/ops-warden/policy_package.md b/examples/ops-warden/policy_package.md new file mode 100644 index 0000000..ad81d20 --- /dev/null +++ b/examples/ops-warden/policy_package.md @@ -0,0 +1,257 @@ +--- +id: ops-warden.ssh-certificate.sign +name: Ops-Warden SSH certificate signing +namespace: ops-warden:ssh-certificate +version: v1 +status: ready +package: flexauth.ops_warden.ssh_signing +actions: + - sign +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:ssh-signing + tenant: tenant:platform + planes: + - Identity + - Secret + - Audit + capabilities: + - Use + - Operate + - Audit + exposure_modes: + - Metadata + conditions: + - TimeLimited + - Logged + restrictions: + - PrivilegeEscalationBlocked + - SecretAccessBlocked +activation: + mode: local +metadata: + source: examples/ops-warden/policy_package.md + ops_warden_policy_gate: v2 +--- + +# Ops-Warden SSH Certificate Signing + +This package authorizes ops-warden's opt-in pre-sign policy gate. The caller +keeps SSH CA custody, actor inventory, and OpenBao signing; flex-auth decides +whether a specific `sign` request is allowed now. + +## Rules + +```rego +import future.keywords.contains +import future.keywords.if +import future.keywords.in + +actor_types := {"adm", "agt", "atm"} + +decision := {"effect": "allow", "reason": "signing_policy_matched"} if { + allowed +} else := {"effect": "deny", "reason": first_denial} if { + true +} + +allowed if { + input.action == "sign" + input.resource.system == "ops-warden" + input.resource.type == "ssh-certificate" + effective_tenant == "tenant:platform" + valid_actor_type + subject_type_matches_context + actor_type_matches_resource + resource_id_matches_actor + subject_id_allowed + valid_ttl + has_pubkey_fingerprint + principals_allowed +} + +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 != "" +} + +default first_denial := "no_matching_rule" + +first_denial := "wrong_action" if { + input.action != "sign" +} else := "wrong_system" if { + input.resource.system != "ops-warden" +} else := "wrong_resource_type" if { + input.resource.type != "ssh-certificate" +} else := "wrong_tenant" if { + effective_tenant != "tenant:platform" +} else := "unknown_actor_resource" if { + not has_actor_resource +} else := "unknown_subject" if { + not subject_id_allowed +} else := "actor_type_mismatch" if { + not valid_actor_type +} else := "actor_type_mismatch" if { + not subject_type_matches_context +} else := "actor_type_mismatch" if { + not actor_type_matches_resource +} else := "actor_resource_mismatch" if { + not resource_id_matches_actor +} else := "ttl_out_of_bounds" if { + not valid_ttl +} else := "missing_pubkey_fingerprint" if { + not has_pubkey_fingerprint +} else := "missing_principal" if { + not has_principals +} else := "disallowed_principal" if { + count(disallowed_principals) > 0 +} + +has_actor_resource if { + is_string(input.resource.attributes.actor_id) + input.resource.attributes.actor_id != "" +} + +valid_actor_type if { + is_string(input.context.actor_type) + input.context.actor_type in actor_types +} + +subject_type_matches_context if { + input.subject.type == input.context.actor_type +} + +subject_type_matches_context if { + input.subject.attributes.actor_type == input.context.actor_type +} + +actor_type_matches_resource if { + input.context.actor_type == input.resource.attributes.actor_type +} + +resource_id_matches_actor if { + input.resource.id == sprintf("ssh-cert:actor/%s", [input.resource.attributes.actor_id]) +} + +subject_id_allowed if { + input.subject.id in input.resource.attributes.allowed_subjects +} + +has_ttl if { + is_number(input.context.ttl_hours) +} + +valid_ttl if { + has_ttl + input.context.ttl_hours > 0 + input.context.ttl_hours <= input.resource.attributes.max_ttl_hours +} + +has_pubkey_fingerprint if { + is_string(input.context.pubkey_fingerprint) + input.context.pubkey_fingerprint != "" +} + +has_principals if { + count(input.context.principals) > 0 +} + +principals_allowed if { + has_principals + count(disallowed_principals) == 0 +} + +allowed_principal(principal) if { + principal in input.resource.attributes.allowed_principals +} + +disallowed_principals contains principal if { + principal := input.context.principals[_] + not allowed_principal(principal) +} +``` + +## Tests + +```rego test +package flexauth.ops_warden.ssh_signing_test + +import future.keywords.if +import data.flexauth.ops_warden.ssh_signing + +adm_request := { + "id": "check:ops-warden-platform-steward-adm", + "tenant": "tenant:platform", + "subject": {"id": "platform-steward", "type": "adm"}, + "action": "sign", + "resource": { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "system": "ops-warden", + "attributes": { + "actor_id": "platform-steward", + "actor_type": "adm", + "allowed_subjects": ["platform-steward", "iam:platform-steward"], + "allowed_principals": ["platform", "root"], + "max_ttl_hours": 8 + } + }, + "context": { + "actor_type": "adm", + "principals": ["platform"], + "pubkey_fingerprint": "SHA256:example-adm-fingerprint", + "ttl_hours": 4 + } +} + +test_adm_sign_allowed if { + ssh_signing.decision.effect == "allow" with input as adm_request +} + +test_high_ttl_denied if { + ssh_signing.decision.reason == "ttl_out_of_bounds" with input as { + "tenant": "tenant:platform", + "subject": {"id": "platform-steward", "type": "adm"}, + "action": "sign", + "resource": adm_request.resource, + "context": { + "actor_type": "adm", + "principals": ["platform"], + "pubkey_fingerprint": "SHA256:example-adm-fingerprint", + "ttl_hours": 12 + } + } +} + +test_missing_fingerprint_denied if { + ssh_signing.decision.reason == "missing_pubkey_fingerprint" with input as { + "tenant": "tenant:platform", + "subject": {"id": "platform-steward", "type": "adm"}, + "action": "sign", + "resource": adm_request.resource, + "context": { + "actor_type": "adm", + "principals": ["platform"], + "ttl_hours": 4 + } + } +} +``` diff --git a/examples/ops-warden/protected_system_manifest.yaml b/examples/ops-warden/protected_system_manifest.yaml new file mode 100644 index 0000000..84fd0e2 --- /dev/null +++ b/examples/ops-warden/protected_system_manifest.yaml @@ -0,0 +1,36 @@ +id: ops-warden +name: Ops Warden +resource_types: + - name: ssh-certificate + scope_level: Resource + planes: + - Identity + - Secret + - Audit + metadata: + description: Short-lived SSH certificate signing request. +actions: + - name: sign + capabilities: + - Use + - Operate + - Audit + planes: + - Identity + - Secret + - Audit + exposure_modes: + - Metadata + metadata: + required_context: + - principals + - actor_type + - pubkey_fingerprint + - ttl_hours +caring_profiles: + - caring-0.4.0-rc2 +metadata: + flex_auth_contract: protected-system-v0 + ops_warden_policy_gate: v2 + policy_enabled_config: policy.enabled + tenant: tenant:platform diff --git a/examples/ops-warden/registry_snapshot.json b/examples/ops-warden/registry_snapshot.json new file mode 100644 index 0000000..c5a924e --- /dev/null +++ b/examples/ops-warden/registry_snapshot.json @@ -0,0 +1,366 @@ +{ + "systems": [ + { + "id": "ops-warden", + "name": "Ops Warden", + "resource_types": [ + { + "name": "ssh-certificate", + "scope_level": "Resource", + "planes": [ + "Identity", + "Secret", + "Audit" + ], + "metadata": { + "description": "Short-lived SSH certificate signing request." + } + } + ], + "actions": [ + { + "name": "sign", + "capabilities": [ + "Use", + "Operate", + "Audit" + ], + "planes": [ + "Identity", + "Secret", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "metadata": { + "required_context": [ + "principals", + "actor_type", + "pubkey_fingerprint", + "ttl_hours" + ] + } + } + ], + "caring_profiles": [ + "caring-0.4.0-rc2" + ], + "metadata": { + "flex_auth_contract": "protected-system-v0", + "ops_warden_policy_gate": "v2", + "policy_enabled_config": "policy.enabled", + "tenant": "tenant:platform" + } + } + ], + "resource_manifests": [ + { + "id": "ops-warden-ssh-certificates", + "system": "ops-warden", + "resources": [ + { + "id": "ssh-cert:actor/platform-steward", + "type": "ssh-certificate", + "labels": [ + "ssh-signing", + "adm" + ], + "trust_zone": "platform", + "owner": "team:platform-security", + "attributes": { + "actor_id": "platform-steward", + "actor_type": "adm", + "allowed_subjects": [ + "platform-steward", + "iam:platform-steward" + ], + "allowed_principals": [ + "platform", + "root" + ], + "max_ttl_hours": 8 + } + }, + { + "id": "ssh-cert:actor/ci-deploy-agent", + "type": "ssh-certificate", + "labels": [ + "ssh-signing", + "agt" + ], + "trust_zone": "platform", + "owner": "team:platform-security", + "attributes": { + "actor_id": "ci-deploy-agent", + "actor_type": "agt", + "allowed_subjects": [ + "ci-deploy-agent", + "iam:ci-deploy-agent" + ], + "allowed_principals": [ + "deploy", + "git" + ], + "max_ttl_hours": 2 + } + }, + { + "id": "ssh-cert:actor/backup-automation", + "type": "ssh-certificate", + "labels": [ + "ssh-signing", + "atm" + ], + "trust_zone": "platform", + "owner": "team:platform-security", + "attributes": { + "actor_id": "backup-automation", + "actor_type": "atm", + "allowed_subjects": [ + "backup-automation", + "iam:backup-automation" + ], + "allowed_principals": [ + "backup" + ], + "max_ttl_hours": 1 + } + } + ], + "actions": [ + "sign" + ], + "caring_profile": "caring-0.4.0-rc2", + "metadata": { + "flex_auth_contract": "resource-registration-v0", + "tenant": "tenant:platform" + } + } + ], + "tenants": [ + { + "id": "tenant:platform", + "name": "Platform Tenant" + } + ], + "subjects": [ + { + "id": "platform-steward", + "type": "Agent", + "display_name": "Platform Steward", + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ], + "groups": [ + "group:ops-warden-admins" + ], + "tenant": "tenant:platform", + "metadata": { + "actor_type": "adm" + } + }, + { + "id": "ci-deploy-agent", + "type": "Agent", + "display_name": "CI Deploy Agent", + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ], + "groups": [ + "group:ops-warden-agents" + ], + "tenant": "tenant:platform", + "metadata": { + "actor_type": "agt" + } + }, + { + "id": "backup-automation", + "type": "Automation", + "display_name": "Backup Automation", + "organization_relation": "ServiceProvider", + "roles": [ + "Operator" + ], + "groups": [ + "group:ops-warden-automations" + ], + "tenant": "tenant:platform", + "metadata": { + "actor_type": "atm" + } + } + ], + "groups": [ + { + "id": "group:ops-warden-admins", + "display_name": "Ops Warden Admin Actors", + "members": [ + "platform-steward" + ], + "tenant": "tenant:platform" + }, + { + "id": "group:ops-warden-agents", + "display_name": "Ops Warden Agent Actors", + "members": [ + "ci-deploy-agent" + ], + "tenant": "tenant:platform" + }, + { + "id": "group:ops-warden-automations", + "display_name": "Ops Warden Automation Actors", + "members": [ + "backup-automation" + ], + "tenant": "tenant:platform" + } + ], + "relationships": [ + { + "id": "rel:platform-steward-sign-platform-steward", + "system": "ops-warden", + "subject": "group:ops-warden-admins", + "relation": "signer", + "object": "ssh-cert:actor/platform-steward", + "tenant": "tenant:platform", + "conditions": [ + "TimeLimited", + "Logged" + ], + "caring": { + "id": "descriptor:ops-warden-adm-signer", + "profile": "caring-0.4.0-rc2", + "subject_type": "Group", + "organization_relation": "ServiceProvider", + "canonical_role": "Operator", + "scope": { + "level": "Resource", + "id": "ssh-cert:actor/platform-steward", + "tenant": "tenant:platform", + "resource": "ssh-cert:actor/platform-steward" + }, + "planes": [ + "Identity", + "Secret", + "Audit" + ], + "capabilities": [ + "Use", + "Operate", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "conditions": [ + "TimeLimited", + "Logged" + ], + "restrictions": [ + "PrivilegeEscalationBlocked", + "SecretAccessBlocked" + ], + "access_path": "mediated" + } + }, + { + "id": "rel:ci-deploy-agent-sign-ci-deploy-agent", + "system": "ops-warden", + "subject": "group:ops-warden-agents", + "relation": "signer", + "object": "ssh-cert:actor/ci-deploy-agent", + "tenant": "tenant:platform", + "conditions": [ + "TimeLimited", + "Logged" + ], + "caring": { + "id": "descriptor:ops-warden-agt-signer", + "profile": "caring-0.4.0-rc2", + "subject_type": "Group", + "organization_relation": "ServiceProvider", + "canonical_role": "Operator", + "scope": { + "level": "Resource", + "id": "ssh-cert:actor/ci-deploy-agent", + "tenant": "tenant:platform", + "resource": "ssh-cert:actor/ci-deploy-agent" + }, + "planes": [ + "Identity", + "Secret", + "Audit" + ], + "capabilities": [ + "Use", + "Operate", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "conditions": [ + "TimeLimited", + "Logged" + ], + "restrictions": [ + "PrivilegeEscalationBlocked", + "SecretAccessBlocked" + ], + "access_path": "mediated" + } + }, + { + "id": "rel:backup-automation-sign-backup-automation", + "system": "ops-warden", + "subject": "group:ops-warden-automations", + "relation": "signer", + "object": "ssh-cert:actor/backup-automation", + "tenant": "tenant:platform", + "conditions": [ + "TimeLimited", + "Logged" + ], + "caring": { + "id": "descriptor:ops-warden-atm-signer", + "profile": "caring-0.4.0-rc2", + "subject_type": "Group", + "organization_relation": "ServiceProvider", + "canonical_role": "Operator", + "scope": { + "level": "Resource", + "id": "ssh-cert:actor/backup-automation", + "tenant": "tenant:platform", + "resource": "ssh-cert:actor/backup-automation" + }, + "planes": [ + "Identity", + "Secret", + "Audit" + ], + "capabilities": [ + "Use", + "Operate", + "Audit" + ], + "exposure_modes": [ + "Metadata" + ], + "conditions": [ + "TimeLimited", + "Logged" + ], + "restrictions": [ + "PrivilegeEscalationBlocked", + "SecretAccessBlocked" + ], + "access_path": "mediated" + } + } + ] +} diff --git a/examples/ops-warden/resource_manifest.yaml b/examples/ops-warden/resource_manifest.yaml new file mode 100644 index 0000000..c4ecc5b --- /dev/null +++ b/examples/ops-warden/resource_manifest.yaml @@ -0,0 +1,59 @@ +id: ops-warden-ssh-certificates +system: ops-warden +resources: + - id: ssh-cert:actor/platform-steward + type: ssh-certificate + labels: + - ssh-signing + - adm + trust_zone: platform + owner: team:platform-security + attributes: + actor_id: platform-steward + actor_type: adm + allowed_subjects: + - platform-steward + - iam:platform-steward + allowed_principals: + - platform + - root + max_ttl_hours: 8 + - id: ssh-cert:actor/ci-deploy-agent + type: ssh-certificate + labels: + - ssh-signing + - agt + trust_zone: platform + owner: team:platform-security + attributes: + actor_id: ci-deploy-agent + actor_type: agt + allowed_subjects: + - ci-deploy-agent + - iam:ci-deploy-agent + allowed_principals: + - deploy + - git + max_ttl_hours: 2 + - id: ssh-cert:actor/backup-automation + type: ssh-certificate + labels: + - ssh-signing + - atm + trust_zone: platform + owner: team:platform-security + attributes: + actor_id: backup-automation + actor_type: atm + allowed_subjects: + - backup-automation + - iam:backup-automation + allowed_principals: + - backup + max_ttl_hours: 1 +actions: + - sign +caring_profile: caring-0.4.0-rc2 +metadata: + flex_auth_contract: resource-registration-v0 + tenant: tenant:platform diff --git a/examples/ops-warden/subject_manifest.yaml b/examples/ops-warden/subject_manifest.yaml new file mode 100644 index 0000000..e92caf9 --- /dev/null +++ b/examples/ops-warden/subject_manifest.yaml @@ -0,0 +1,54 @@ +id: subjects:ops-warden-platform +tenants: + - id: tenant:platform + name: Platform Tenant +subjects: + - id: platform-steward + type: Agent + display_name: Platform Steward + organization_relation: ServiceProvider + roles: + - Operator + groups: + - group:ops-warden-admins + tenant: tenant:platform + metadata: + actor_type: adm + - id: ci-deploy-agent + type: Agent + display_name: CI Deploy Agent + organization_relation: ServiceProvider + roles: + - Operator + groups: + - group:ops-warden-agents + tenant: tenant:platform + metadata: + actor_type: agt + - id: backup-automation + type: Automation + display_name: Backup Automation + organization_relation: ServiceProvider + roles: + - Operator + groups: + - group:ops-warden-automations + tenant: tenant:platform + metadata: + actor_type: atm +groups: + - id: group:ops-warden-admins + display_name: Ops Warden Admin Actors + members: + - platform-steward + tenant: tenant:platform + - id: group:ops-warden-agents + display_name: Ops Warden Agent Actors + members: + - ci-deploy-agent + tenant: tenant:platform + - id: group:ops-warden-automations + display_name: Ops Warden Automation Actors + members: + - backup-automation + tenant: tenant:platform diff --git a/internal/decision/engine.go b/internal/decision/engine.go index 1a82865..3fec7c9 100644 --- a/internal/decision/engine.go +++ b/internal/decision/engine.go @@ -105,6 +105,7 @@ func (e *Engine) BatchCheck(ctx context.Context, request api.BatchCheckRequest) for _, resource := range request.Resources { decision, err := e.Check(ctx, api.CheckRequest{ ID: request.ID, + Tenant: request.Tenant, Subject: request.Subject, Action: request.Action, Resource: resource, @@ -188,6 +189,15 @@ func (e *Engine) normalizeRequest(request api.CheckRequest) (api.CheckRequest, r normalized := request facts := registryFacts{} + if normalized.Tenant != "" { + if normalized.Subject.Tenant == "" { + normalized.Subject.Tenant = normalized.Tenant + } + if normalized.Resource.Tenant == "" { + normalized.Resource.Tenant = normalized.Tenant + } + } + if subject, ok := e.store.Subject(request.Subject.ID); ok { facts.subjectFound = true facts.subject = subject diff --git a/internal/policy/package_test.go b/internal/policy/package_test.go index 8765f46..a2bc599 100644 --- a/internal/policy/package_test.go +++ b/internal/policy/package_test.go @@ -74,6 +74,28 @@ func TestRedactPolicyPackageMarkdownValidates(t *testing.T) { } } +func TestOpsWardenPolicyPackageMarkdownValidates(t *testing.T) { + pkg, err := policy.LoadAndValidateFile(context.Background(), filepath.Join("..", "..", "examples", "ops-warden", "policy_package.md")) + if err != nil { + t.Fatalf("LoadAndValidateFile: %v", err) + } + + if !pkg.Valid { + t.Fatalf("pkg.Valid = false\n%s", formatValidation(pkg.Validation)) + } + if pkg.Metadata.Namespace != "ops-warden:ssh-certificate" { + t.Fatalf("metadata.Namespace = %q; want ops-warden:ssh-certificate", pkg.Metadata.Namespace) + } + if len(pkg.Validation.Fixtures) != 8 { + t.Fatalf("Validation.Fixtures len = %d; want 8", len(pkg.Validation.Fixtures)) + } + for _, fixture := range pkg.Validation.Fixtures { + if !fixture.Passed { + t.Fatalf("fixture %s failed: %s\nactual: %+v", fixture.ID, fixture.Error, fixture.Actual) + } + } +} + func TestCaringFindingsAreAdvisoryUntilEnforced(t *testing.T) { doc := inlinePolicy(false, "allow") pkg, err := policy.Load([]byte(doc), "inline-policy.md") diff --git a/pkg/api/canonical.go b/pkg/api/canonical.go index 5eeab8f..e539706 100644 --- a/pkg/api/canonical.go +++ b/pkg/api/canonical.go @@ -148,6 +148,7 @@ type DecisionExpectation struct { // CheckRequest is the stable protected-system-facing decision request. type CheckRequest struct { ID string `json:"id,omitempty" yaml:"id,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` Subject SubjectRef `json:"subject" yaml:"subject"` Action string `json:"action" yaml:"action"` Resource ResourceRef `json:"resource" yaml:"resource"` @@ -159,6 +160,7 @@ type CheckRequest struct { // BatchCheckRequest evaluates one subject/action against multiple resources. type BatchCheckRequest struct { ID string `json:"id,omitempty" yaml:"id,omitempty"` + Tenant string `json:"tenant,omitempty" yaml:"tenant,omitempty"` Subject SubjectRef `json:"subject" yaml:"subject"` Action string `json:"action" yaml:"action"` Resources []ResourceRef `json:"resources" yaml:"resources"` diff --git a/schemas/check_request.schema.json b/schemas/check_request.schema.json index 11f8c9f..7503afd 100644 --- a/schemas/check_request.schema.json +++ b/schemas/check_request.schema.json @@ -7,6 +7,7 @@ "required": ["subject", "action", "resource"], "properties": { "id": {"type": "string", "minLength": 1}, + "tenant": {"type": "string", "minLength": 1}, "subject": {"$ref": "#/$defs/subject_ref"}, "action": {"type": "string", "minLength": 1}, "resource": {"$ref": "#/$defs/resource_ref"}, diff --git a/workplans/FLEX-WP-0006-ops-warden-ssh-signing-policy-gate.md b/workplans/FLEX-WP-0006-ops-warden-ssh-signing-policy-gate.md new file mode 100644 index 0000000..c3b64ed --- /dev/null +++ b/workplans/FLEX-WP-0006-ops-warden-ssh-signing-policy-gate.md @@ -0,0 +1,230 @@ +--- +id: FLEX-WP-0006 +type: workplan +title: "Ops-Warden SSH Signing Policy Gate" +domain: infotech +repo: flex-auth +status: finished +owner: codex +topic_slug: flex-auth +planning_priority: P0 +planning_order: 60 +depends_on_workplans: + - FLEX-WP-0002 + - FLEX-WP-0005 +related_workplans: + - WARDEN-WP-0009 +created: "2026-06-23" +updated: "2026-06-23" +state_hub_workstream_id: "bbea4049-8acc-4d7c-8cf5-3106c6b93f7f" +--- + +# FLEX-WP-0006: Ops-Warden SSH Signing Policy Gate + +## Purpose + +Publish the flex-auth policy package, registry fixtures, and service contract +evidence needed for ops-warden's opt-in pre-sign gate. + +Ops-warden already shipped the caller side in `WARDEN-WP-0007`: +`policy.enabled: true` makes `warden sign` and local-backend `warden issue` +call `POST /v1/check` before signing. Production OpenBao-backed signing was +verified in `WARDEN-WP-0008`. The remaining blocked work is flex-auth-owned: +policies for `resource.type: ssh-certificate` and `action: sign`. + +This workplan unblocks `ops-warden/workplans/WARDEN-WP-0009-flex-auth-policy-gate-production.md`. + +## Gate Contract + +The shipped ops-warden gate sends this flex-auth decision request: + +| Field | Meaning | +| --- | --- | +| `subject.id` | `WARDEN_POLICY_SUBJECT` when set, otherwise actor name | +| `subject.type` | Actor type: `adm`, `agt`, or `atm` | +| `tenant` | `policy.tenant`, default `tenant:platform` | +| `resource.id` | `ssh-cert:actor/` | +| `resource.type` | `ssh-certificate` | +| `resource.system` | `ops-warden` | +| `action` | `sign` | +| `context.principals` | Requested SSH certificate principals from inventory | +| `context.actor_type` | Actor type: `adm`, `agt`, or `atm` | +| `context.pubkey_fingerprint` | SHA256 fingerprint of the submitted public key text | +| `context.ttl_hours` | Requested certificate TTL | + +Allow responses must return `effect: allow` plus a decision `id` or +`request_id`; ops-warden records that value as `policy_decision_id` in +`signatures.log`. Deny responses must include a human-readable `reason` that +the ops-warden CLI can surface. + +## Policy Boundary + +flex-auth decides whether this specific signing request is allowed now. +ops-warden remains responsible for SSH CA operation, OpenBao integration, actor +inventory, host documentation, and local scorecard checks. + +The flex-auth policy must not request or store SSH private keys, OpenBao +tokens, database credentials, or other secrets. Acceptance evidence should use +fixtures, non-secret request bodies, decision ids, and sanitized logs only. + +## T1 - Pin the ops-warden protected-system contract + +```task +id: FLEX-WP-0006-T01 +status: done +priority: high +state_hub_task_id: "8831b904-dbef-4d55-8eb5-053c939c86b3" +``` + +Add an ops-warden protected-system manifest and registry fixture slice that +declares: + +- protected system `ops-warden` +- resource type `ssh-certificate` +- action `sign` +- tenant `tenant:platform` +- supported actor subject types `adm`, `agt`, and `atm` +- required context fields: `principals`, `actor_type`, + `pubkey_fingerprint`, and `ttl_hours` + +Output should live under `examples/ops-warden/` unless implementation reveals a +more idiomatic local path. The manifest and fixture must validate with the +existing `flex-auth validate` and `flex-auth load-registry` commands. + +## T2 - Author the ssh-certificate sign policy package + +```task +id: FLEX-WP-0006-T02 +status: done +priority: high +state_hub_task_id: "9ea206fa-f93d-46b6-8b8e-e8669dd502d4" +``` + +Create the Rego-in-Markdown policy package for ops-warden signing decisions. + +Minimum allow criteria: + +- request action is `sign` +- resource system is `ops-warden` +- resource type is `ssh-certificate` +- tenant is `tenant:platform` unless the fixture explicitly tests another + configured tenant +- subject and `context.actor_type` are aligned with the actor resource +- requested TTL is positive and within the policy's configured maximum for the + actor type +- requested principals are non-empty and allowed for the actor fixture +- `context.pubkey_fingerprint` is present + +Minimum deny criteria: + +- unknown subject or actor resource +- mismatched `subject.type`, `context.actor_type`, or + `resource.id: ssh-cert:actor/` +- TTL outside policy bounds +- missing fingerprint +- disallowed or empty principals +- wrong action, system, resource type, or tenant + +The package should produce clear deny reasons and preserve the standard +decision envelope so ops-warden can surface `reason` without special casing. + +## T3 - Add allow and deny fixtures + +```task +id: FLEX-WP-0006-T03 +status: done +priority: high +state_hub_task_id: "6bf5cb9b-d46c-49cf-aec0-12f6e864a1f8" +``` + +Add fixture requests and expected decisions for the gate. + +Required fixtures: + +- allow: valid `adm` sign request +- allow: valid `agt` sign request, if an agent actor fixture exists +- deny: unknown subject +- deny: actor type mismatch +- deny: TTL above policy max +- deny: missing or disallowed principal +- deny: missing `pubkey_fingerprint` + +Wire the fixtures into Go tests or the existing policy fixture runner so +`make test` proves the package behavior. Fixture data must remain non-secret. + +## T4 - Verify the `/v1/check` service contract + +```task +id: FLEX-WP-0006-T04 +status: done +priority: high +state_hub_task_id: "077d29db-30e0-4447-90fd-620c0884306c" +``` + +Run `flex-auth serve` with the ops-warden registry and policy package, then +exercise `POST /v1/check` with ops-warden-shaped JSON. + +Acceptance evidence: + +- allow response includes `effect: allow` and a stable decision `id` +- deny response includes `effect: deny` and a useful `reason` +- decision log records the allow and deny decisions without secrets +- method mismatch and malformed JSON fail predictably +- the documented behavior is compatible with ops-warden `fail_closed: true` + +If the current service shape needs a small compatibility adjustment, keep it +inside the stable v1 API instead of adding an ops-warden-specific route. + +## T5 - Hand off production-readiness evidence to ops-warden + +```task +id: FLEX-WP-0006-T05 +status: done +priority: medium +state_hub_task_id: "06cac0b1-51c0-4ae0-b605-c940f7821ac7" +``` + +Publish the handoff notes ops-warden needs to close `WARDEN-WP-0009 T01` and +start its production enablement smoke. + +Include: + +- policy package path and version +- registry fixture path +- local service command +- allow and deny fixture names +- non-secret decision ids from local smoke +- expected `policy.enabled` production sequence +- reminder that OpenBao SSH signing and SSH CA custody remain ops-warden-owned + +Record progress in State Hub and ask the custodian operator to run +`make fix-consistency REPO=flex-auth` from `~/state-hub` after this workplan is +merged or otherwise accepted. + + +## Implementation Summary + +Implemented on 2026-06-23. + +- Added examples/ops-warden/ with the protected-system manifest, subject and + resource manifests, combined registry snapshot, policy package, check + requests, and allow/deny fixtures. +- Added top-level tenant support to CheckRequest and BatchCheckRequest so the + shipped ops-warden policy gate request shape is accepted by /v1/check. +- Added CLI and HTTP service tests for the ops-warden allow path, deny path, + malformed JSON, method mismatch, and decision-log recording. +- Added docs/ops-warden-policy-gate-handoff.md with non-secret smoke evidence + and the ops-warden production enablement sequence. + +## Exit Criteria + +- flex-auth contains a validated ops-warden protected-system manifest and + registry fixture. +- flex-auth contains an `ssh-certificate` / `sign` policy package with allow + and deny fixtures for `adm`, `agt`, and `atm`-style actors where applicable. +- `make test` passes. +- `POST /v1/check` accepts the shipped ops-warden request shape and returns + decision envelopes compatible with `policy.enabled: true`. +- A sanitized handoff note exists for ops-warden `WARDEN-WP-0009`. +- State Hub progress is logged and the operator is told to run + `make fix-consistency REPO=flex-auth`.