diff --git a/cmd/flex-auth/main_test.go b/cmd/flex-auth/main_test.go index 10b62f5..f08ecf3 100644 --- a/cmd/flex-auth/main_test.go +++ b/cmd/flex-auth/main_test.go @@ -103,6 +103,36 @@ func TestRunCheckOpsWarden(t *testing.T) { } } +func TestRunRailiancePlatformCredentialGrantContract(t *testing.T) { + var stdout, stderr bytes.Buffer + code := run([]string{"test-policy", "--file", railiancePlatformPath("policy_package.md")}, &stdout, &stderr) + if code != 0 || !strings.Contains(stdout.String(), `"valid": true`) { + t.Fatalf("test-policy code = %d, stderr = %s, stdout = %s", code, stderr.String(), stdout.String()) + } + + stdout.Reset() + stderr.Reset() + code = run([]string{ + "check", + "--registry", railiancePlatformPath("registry_snapshot.json"), + "--policy", railiancePlatformPath("policy_package.md"), + "--request", railiancePlatformPath("check_request_allow.json"), + }, &stdout, &stderr) + if code != 0 { + t.Fatalf("check code = %d, stderr = %s", code, stderr.String()) + } + var decision api.DecisionEnvelope + if err := json.Unmarshal(stdout.Bytes(), &decision); err != nil { + t.Fatal(err) + } + if decision.Effect != api.DecisionEffectAllow || decision.Reason != "credential_grant_allowed" { + t.Fatalf("decision = %s/%s; want allow/credential_grant_allowed", decision.Effect, decision.Reason) + } + if decision.Binding == nil || decision.Binding.Context["requested_ttl_seconds"] != float64(900) { + t.Fatalf("binding = %+v; want normalized numeric TTL", decision.Binding) + } +} + 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) @@ -370,6 +400,10 @@ func opsPath(name string) string { return filepath.Join("..", "..", "examples", "ops-warden", name) } +func railiancePlatformPath(name string) string { + return filepath.Join("..", "..", "examples", "railiance-platform", name) +} + func opsWardenProductionSignRequest(subjectID, actor, actorType, principal string, ttlHours float64) api.CheckRequest { return api.CheckRequest{ ID: "check:ops-warden-production-" + actor, diff --git a/docs/railiance-platform-action-vocabulary.md b/docs/railiance-platform-action-vocabulary.md new file mode 100644 index 0000000..5b011cc --- /dev/null +++ b/docs/railiance-platform-action-vocabulary.md @@ -0,0 +1,16 @@ +# Railiance Platform credential-grant action vocabulary + +flex-auth uses one protected-system action for the credential broker: + +| Action | Resource type | Meaning | +| --- | --- | --- | +| `issue` | `credential-grant` | Authorize issuance of one bounded credential lease from a registered grant. | + +The request subject is the requesting actor. `context.bound_subject` is the +identity to which the resulting credential is bound. Grant id, credential +type, issuer, audience, TTL ceiling, permitted actor classes, purposes, and +delivery modes are registry-owned resource attributes, not caller assertions. + +`context.requested_ttl_seconds` is numeric seconds. Parsing the broker's source +duration string happens once in the selected wire translator; the policy +rejects strings to prevent unit ambiguity. diff --git a/examples/railiance-platform/README.md b/examples/railiance-platform/README.md new file mode 100644 index 0000000..5a614fd --- /dev/null +++ b/examples/railiance-platform/README.md @@ -0,0 +1,16 @@ +# Railiance Platform credential-grant example + +This package provides the flex-auth side of `FLEX-WP-0012`: registered grant +metadata, representative requester subjects, a default-deny `issue` policy, +and fixtures for TTL, actor type, purpose, delivery mode, and unknown grants. + +The wire translation is intentionally outside the policy. Both candidate +integration shapes must emit `requested_ttl_seconds` as a positive number; +duration strings such as `15m` are rejected rather than guessed. + +```bash +go run ./cmd/flex-auth load-registry --file examples/railiance-platform/registry_snapshot.json +go run ./cmd/flex-auth test-policy --file examples/railiance-platform/policy_package.md +go run ./cmd/flex-auth check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_allow.json +go run ./cmd/flex-auth check --registry examples/railiance-platform/registry_snapshot.json --policy examples/railiance-platform/policy_package.md --request examples/railiance-platform/check_request_deny_unknown.json +``` diff --git a/examples/railiance-platform/check_request_allow.json b/examples/railiance-platform/check_request_allow.json new file mode 100644 index 0000000..668646b --- /dev/null +++ b/examples/railiance-platform/check_request_allow.json @@ -0,0 +1,21 @@ +{ + "id": "check:credential-grant-allow-http-shape", + "tenant": "tenant:platform", + "subject": { + "id": "agent:codex/railiance-platform", + "type": "Agent" + }, + "action": "issue", + "resource": { + "id": "credential-grant:rapp-postgres/audit-core-runtime", + "type": "credential-grant", + "system": "railiance-platform" + }, + "context": { + "actor_type": "approved-agent", + "bound_subject": "agent:codex/railiance-platform", + "purpose": "audit-core-runtime", + "delivery_mode": "exec-env", + "requested_ttl_seconds": 900 + } +} diff --git a/examples/railiance-platform/check_request_deny_unknown.json b/examples/railiance-platform/check_request_deny_unknown.json new file mode 100644 index 0000000..26bb249 --- /dev/null +++ b/examples/railiance-platform/check_request_deny_unknown.json @@ -0,0 +1,21 @@ +{ + "id": "check:credential-grant-unknown-http-shape", + "tenant": "tenant:platform", + "subject": { + "id": "agent:codex/railiance-platform", + "type": "Agent" + }, + "action": "issue", + "resource": { + "id": "credential-grant:unknown/grant", + "type": "credential-grant", + "system": "railiance-platform" + }, + "context": { + "actor_type": "approved-agent", + "bound_subject": "agent:codex/railiance-platform", + "purpose": "unknown", + "delivery_mode": "exec-env", + "requested_ttl_seconds": 900 + } +} diff --git a/examples/railiance-platform/policy_fixtures.yaml b/examples/railiance-platform/policy_fixtures.yaml new file mode 100644 index 0000000..81c7249 --- /dev/null +++ b/examples/railiance-platform/policy_fixtures.yaml @@ -0,0 +1,59 @@ +- id: fixture:credential-grant-allow + request: &allow_request + id: check:credential-grant-allow + tenant: tenant:platform + subject: {id: agent:codex/railiance-platform, type: Agent} + action: issue + resource: + id: credential-grant:rapp-postgres/audit-core-runtime + type: credential-grant + system: railiance-platform + attributes: + grant_id: rapp-postgres/audit-core-runtime + credential_type: openbao-database-credential + issuer: openbao + audience: audit-core + max_ttl_seconds: 3600 + allowed_actor_types: [human-operator, approved-agent, ci-runner] + allowed_purposes: [audit-core-runtime] + allowed_delivery_modes: [exec-env] + context: &allow_context + actor_type: approved-agent + bound_subject: agent:codex/railiance-platform + purpose: audit-core-runtime + delivery_mode: exec-env + requested_ttl_seconds: 900 + expect: {effect: allow, reason: credential_grant_allowed} +- id: fixture:credential-grant-ttl-deny + request: + <<: *allow_request + id: check:credential-grant-ttl-deny + context: {<<: *allow_context, requested_ttl_seconds: 5400} + expect: {effect: deny, reason: ttl_out_of_bounds} +- id: fixture:credential-grant-actor-type-deny + request: + <<: *allow_request + id: check:credential-grant-actor-deny + context: {<<: *allow_context, actor_type: unapproved-agent} + expect: {effect: deny, reason: actor_type_not_allowed} +- id: fixture:credential-grant-purpose-deny + request: + <<: *allow_request + id: check:credential-grant-purpose-deny + context: {<<: *allow_context, purpose: unrelated-purpose} + expect: {effect: deny, reason: purpose_not_allowed} +- id: fixture:credential-grant-delivery-deny + request: + <<: *allow_request + id: check:credential-grant-delivery-deny + context: {<<: *allow_context, delivery_mode: chat} + expect: {effect: deny, reason: delivery_mode_not_allowed} +- id: fixture:credential-grant-unknown-deny + request: + <<: *allow_request + id: check:credential-grant-unknown-deny + resource: + id: credential-grant:unknown/grant + type: credential-grant + system: railiance-platform + expect: {effect: deny, reason: unknown_grant} diff --git a/examples/railiance-platform/policy_package.md b/examples/railiance-platform/policy_package.md new file mode 100644 index 0000000..c7e9012 --- /dev/null +++ b/examples/railiance-platform/policy_package.md @@ -0,0 +1,158 @@ +--- +id: railiance-platform.credential-grant.issue +name: Railiance Platform credential-grant issuance +namespace: railiance-platform:credential-grant +version: v1 +status: ready +package: flexauth.railiance_platform.credential_grant +actions: [issue] +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:credential-broker + tenant: tenant:platform + planes: [Identity, Secret, Audit] + capabilities: [Use, Operate, Audit] + exposure_modes: [Metadata] + conditions: [PurposeBound, TimeLimited, Logged] + restrictions: [PrivilegeEscalationBlocked, SecretAccessBlocked] +activation: {mode: local} +metadata: + source: examples/railiance-platform/policy_package.md + ttl_unit: seconds +--- + +# Railiance Platform Credential-grant Issuance + +The policy evaluates non-secret grant metadata only. Duration strings such as +`15m` must be parsed by the eventual wire translator into the numeric +`context.requested_ttl_seconds`; Rego never infers duration units. + +## Rules + +```rego +import future.keywords.if +import future.keywords.in + +decision := {"effect": "allow", "reason": "credential_grant_allowed"} if { + allowed +} else := {"effect": "deny", "reason": first_denial} if { + true +} + +allowed if { + input.action == "issue" + input.resource.system == "railiance-platform" + input.resource.type == "credential-grant" + input.tenant == "tenant:platform" + known_grant + actor_type_allowed + purpose_allowed + delivery_mode_allowed + ttl_allowed + has_bound_subject +} + +default first_denial := "no_matching_rule" + +first_denial := "wrong_action" if { + input.action != "issue" +} else := "wrong_system" if { + input.resource.system != "railiance-platform" +} else := "wrong_resource_type" if { + input.resource.type != "credential-grant" +} else := "wrong_tenant" if { + input.tenant != "tenant:platform" +} else := "unknown_grant" if { + not known_grant +} else := "actor_type_not_allowed" if { + not actor_type_allowed +} else := "purpose_not_allowed" if { + not purpose_allowed +} else := "delivery_mode_not_allowed" if { + not delivery_mode_allowed +} else := "ttl_out_of_bounds" if { + not ttl_allowed +} else := "missing_subject_binding" if { + not has_bound_subject +} + +known_grant if { + is_string(input.resource.attributes.grant_id) + input.resource.id == sprintf("credential-grant:%s", [input.resource.attributes.grant_id]) + is_number(input.resource.attributes.max_ttl_seconds) +} + +actor_type_allowed if { + is_string(input.context.actor_type) + input.context.actor_type in input.resource.attributes.allowed_actor_types +} + +purpose_allowed if { + is_string(input.context.purpose) + input.context.purpose != "" + input.context.purpose in input.resource.attributes.allowed_purposes +} + +delivery_mode_allowed if { + is_string(input.context.delivery_mode) + input.context.delivery_mode in input.resource.attributes.allowed_delivery_modes +} + +ttl_allowed if { + is_number(input.context.requested_ttl_seconds) + input.context.requested_ttl_seconds > 0 + input.context.requested_ttl_seconds <= input.resource.attributes.max_ttl_seconds +} + +has_bound_subject if { + is_string(input.context.bound_subject) + input.context.bound_subject != "" +} +``` + +## Tests + +```rego test +package flexauth.railiance_platform.credential_grant_test + +import future.keywords.if +import data.flexauth.railiance_platform.credential_grant + +request := { + "tenant": "tenant:platform", + "subject": {"id": "agent:codex/railiance-platform", "type": "Agent"}, + "action": "issue", + "resource": { + "id": "credential-grant:rapp-postgres/audit-core-runtime", + "type": "credential-grant", + "system": "railiance-platform", + "attributes": { + "grant_id": "rapp-postgres/audit-core-runtime", + "max_ttl_seconds": 3600, + "allowed_actor_types": ["human-operator", "approved-agent", "ci-runner"], + "allowed_purposes": ["audit-core-runtime"], + "allowed_delivery_modes": ["exec-env"] + } + }, + "context": {"actor_type": "approved-agent", "bound_subject": "agent:codex/railiance-platform", "purpose": "audit-core-runtime", "delivery_mode": "exec-env", "requested_ttl_seconds": 900} +} + +test_allow if { + credential_grant.decision.effect == "allow" with input as request +} + +test_ttl_string_denied if { + credential_grant.decision.reason == "ttl_out_of_bounds" with input as object.union(request, {"context": object.union(request.context, {"requested_ttl_seconds": "15m"})}) +} + +test_unknown_grant_denied if { + credential_grant.decision.reason == "unknown_grant" with input as object.union(request, {"resource": {"id": "credential-grant:missing", "type": "credential-grant", "system": "railiance-platform", "attributes": {}}}) +} +``` diff --git a/examples/railiance-platform/protected_system_manifest.yaml b/examples/railiance-platform/protected_system_manifest.yaml new file mode 100644 index 0000000..c1e27dd --- /dev/null +++ b/examples/railiance-platform/protected_system_manifest.yaml @@ -0,0 +1,19 @@ +id: railiance-platform +name: Railiance Platform Credential Broker +resource_types: + - name: credential-grant + scope_level: Resource + planes: [Identity, Secret, Audit] + metadata: + description: Non-secret authorization metadata for a bounded credential lease. +actions: + - name: issue + capabilities: [Use, Operate, Audit] + planes: [Identity, Secret, Audit] + exposure_modes: [Metadata] + metadata: + required_context: [actor_type, bound_subject, purpose, delivery_mode, requested_ttl_seconds] +caring_profiles: [caring-0.4.0-rc2] +metadata: + flex_auth_contract: protected-system-v0 + tenant: tenant:platform diff --git a/examples/railiance-platform/registry_snapshot.json b/examples/railiance-platform/registry_snapshot.json new file mode 100644 index 0000000..526c758 --- /dev/null +++ b/examples/railiance-platform/registry_snapshot.json @@ -0,0 +1,45 @@ +{ + "systems": [ + { + "id": "railiance-platform", + "name": "Railiance Platform Credential Broker", + "resource_types": [ + { + "name": "credential-grant", + "scope_level": "Resource", + "planes": ["Identity", "Secret", "Audit"] + } + ], + "actions": [ + { + "name": "issue", + "capabilities": ["Use", "Operate", "Audit"], + "planes": ["Identity", "Secret", "Audit"], + "exposure_modes": ["Metadata"] + } + ], + "caring_profiles": ["caring-0.4.0-rc2"], + "metadata": {"tenant": "tenant:platform"} + } + ], + "resource_manifests": [ + { + "id": "railiance-platform-credential-grants", + "system": "railiance-platform", + "resources": [ + {"id": "credential-grant:rapp-postgres/audit-core-runtime", "type": "credential-grant", "owner": "team:platform-security", "attributes": {"grant_id": "rapp-postgres/audit-core-runtime", "credential_type": "openbao-database-credential", "issuer": "openbao", "audience": "audit-core", "max_ttl_seconds": 3600, "allowed_actor_types": ["human-operator", "approved-agent", "ci-runner"], "allowed_purposes": ["audit-core-runtime"], "allowed_delivery_modes": ["exec-env"]}}, + {"id": "credential-grant:rapp-postgres/audit-core-migration", "type": "credential-grant", "owner": "team:platform-security", "attributes": {"grant_id": "rapp-postgres/audit-core-migration", "credential_type": "openbao-database-credential", "issuer": "openbao", "audience": "audit-core", "max_ttl_seconds": 1800, "allowed_actor_types": ["human-operator", "approved-agent", "ci-runner"], "allowed_purposes": ["audit-core-migration"], "allowed_delivery_modes": ["exec-env"]}}, + {"id": "credential-grant:ops-warden/warden-sign", "type": "credential-grant", "owner": "team:platform-security", "attributes": {"grant_id": "ops-warden/warden-sign", "credential_type": "openbao-token", "issuer": "openbao", "audience": "ops-warden", "max_ttl_seconds": 3600, "allowed_actor_types": ["human-operator", "approved-agent", "ci-runner"], "allowed_purposes": ["flex-auth-openbao-smoke", "ops-warden-production-sign-smoke"], "allowed_delivery_modes": ["exec-env", "response-wrap", "local-token-file", "kubernetes-auth"]}} + ], + "actions": ["issue"], + "caring_profile": "caring-0.4.0-rc2", + "metadata": {"tenant": "tenant:platform"} + } + ], + "tenants": [{"id": "tenant:platform", "name": "Platform Tenant"}], + "subjects": [ + {"id": "operator:platform", "type": "Human", "roles": ["Operator"], "tenant": "tenant:platform"}, + {"id": "agent:codex/railiance-platform", "type": "Agent", "roles": ["Operator"], "tenant": "tenant:platform"}, + {"id": "ci:railiance-platform", "type": "Automation", "roles": ["Operator"], "tenant": "tenant:platform"} + ] +} diff --git a/examples/railiance-platform/resource_manifest.yaml b/examples/railiance-platform/resource_manifest.yaml new file mode 100644 index 0000000..cdb6ab0 --- /dev/null +++ b/examples/railiance-platform/resource_manifest.yaml @@ -0,0 +1,44 @@ +id: railiance-platform-credential-grants +system: railiance-platform +resources: + - id: credential-grant:rapp-postgres/audit-core-runtime + type: credential-grant + owner: team:platform-security + attributes: + grant_id: rapp-postgres/audit-core-runtime + credential_type: openbao-database-credential + issuer: openbao + audience: audit-core + max_ttl_seconds: 3600 + allowed_actor_types: [human-operator, approved-agent, ci-runner] + allowed_purposes: [audit-core-runtime] + allowed_delivery_modes: [exec-env] + - id: credential-grant:rapp-postgres/audit-core-migration + type: credential-grant + owner: team:platform-security + attributes: + grant_id: rapp-postgres/audit-core-migration + credential_type: openbao-database-credential + issuer: openbao + audience: audit-core + max_ttl_seconds: 1800 + allowed_actor_types: [human-operator, approved-agent, ci-runner] + allowed_purposes: [audit-core-migration] + allowed_delivery_modes: [exec-env] + - id: credential-grant:ops-warden/warden-sign + type: credential-grant + owner: team:platform-security + attributes: + grant_id: ops-warden/warden-sign + credential_type: openbao-token + issuer: openbao + audience: ops-warden + max_ttl_seconds: 3600 + allowed_actor_types: [human-operator, approved-agent, ci-runner] + allowed_purposes: [flex-auth-openbao-smoke, ops-warden-production-sign-smoke] + allowed_delivery_modes: [exec-env, response-wrap, local-token-file, kubernetes-auth] +actions: [issue] +caring_profile: caring-0.4.0-rc2 +metadata: + flex_auth_contract: resource-registration-v0 + tenant: tenant:platform diff --git a/examples/railiance-platform/subject_manifest.yaml b/examples/railiance-platform/subject_manifest.yaml new file mode 100644 index 0000000..608affd --- /dev/null +++ b/examples/railiance-platform/subject_manifest.yaml @@ -0,0 +1,23 @@ +id: subjects:railiance-platform +tenants: + - id: tenant:platform + name: Platform Tenant +subjects: + - id: operator:platform + type: Human + display_name: Platform Operator + organization_relation: ServiceProvider + roles: [Operator] + tenant: tenant:platform + - id: agent:codex/railiance-platform + type: Agent + display_name: Railiance Platform Agent + organization_relation: ServiceProvider + roles: [Operator] + tenant: tenant:platform + - id: ci:railiance-platform + type: Automation + display_name: Railiance Platform CI + organization_relation: ServiceProvider + roles: [Operator] + tenant: tenant:platform diff --git a/workplans/FLEX-WP-0012-credential-grant-authorization-surface.md b/workplans/FLEX-WP-0012-credential-grant-authorization-surface.md index ed7dc66..df3ea2a 100644 --- a/workplans/FLEX-WP-0012-credential-grant-authorization-surface.md +++ b/workplans/FLEX-WP-0012-credential-grant-authorization-surface.md @@ -135,7 +135,7 @@ place duration normalization explicitly. ```task id: FLEX-WP-0012-T02 -status: todo +status: done priority: medium state_hub_task_id: "7e9c4e59-e59c-4617-a87f-99142952fe78" ``` @@ -168,11 +168,20 @@ id. Verify against the real binary: `test-policy`, `load-registry`, and Done when all Rego tests and fixtures pass and default-deny is demonstrated for an unregistered grant. +Completed 2026-08-23. `examples/railiance-platform/` registers all three +current catalog grants and representative requester subjects. The policy uses +numeric `requested_ttl_seconds`; duration parsing belongs to the selected wire +translator, and strings are explicitly denied. Three embedded Rego tests and +six external fixtures pass, covering allow, TTL, actor type, purpose, delivery +mode, and unknown-grant default deny. Real `load-registry` and `check` commands +returned `credential_grant_allowed` for the registered runtime grant and +`unknown_grant` for an unregistered id. + ## T03 - Implement the decided integration and prove it end to end ```task id: FLEX-WP-0012-T03 -status: todo +status: wait priority: medium state_hub_task_id: "3335b2b7-cf1a-411d-95b3-03c4b4c35659" ``` @@ -199,6 +208,10 @@ request metadata only. Nothing in this workplan should read, log, or persist a credential value; if a task appears to require one, that is a signal the design has drifted, not a reason to handle secrets here. +Waiting on T01's wire-translation decision. The policy-side normalized request +contract is implemented; either integration option must produce its numeric +TTL field without changing policy semantics. + Done when allow and all four denials are demonstrated against the real helper over real HTTP, `go test ./...` is green, and `gofmt`/`go vet` are clean. @@ -206,7 +219,7 @@ over real HTTP, `go test ./...` is green, and `gofmt`/`go vet` are clean. ```task id: FLEX-WP-0012-T04 -status: todo +status: wait priority: low state_hub_task_id: "40015a87-040c-4d48-b360-fd5566dbc552" ``` @@ -225,3 +238,7 @@ actually meant before promising an endpoint. Update `docs/` with a credential-grant action vocabulary, matching the existing per-consumer vocabulary docs. + +The source-side action vocabulary is now present at +`docs/railiance-platform-action-vocabulary.md`; final consumer/deployment +handoff waits on T01 and T03.