Implement KeyCape provider and service identity contracts
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 25s
All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 25s
Assistant: codex Assistant-Model: gpt-5.6-sol Assistant-Session: 01a02e3f-7301-7622-9be1-12e5f352881c
This commit is contained in:
parent
cdfb046b80
commit
efce3e9331
15 changed files with 579 additions and 26 deletions
7
Makefile
7
Makefile
|
|
@ -3,7 +3,9 @@ IMAGE_REPO ?= coulomb/key-cape
|
||||||
IMAGE_TAG ?= latest
|
IMAGE_TAG ?= latest
|
||||||
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
IMAGE := $(IMAGE_REGISTRY)/$(IMAGE_REPO):$(IMAGE_TAG)
|
||||||
|
|
||||||
.PHONY: dev seed build test lint image push image-tag
|
.PHONY: dev seed build test lint contract-test image push image-tag
|
||||||
|
|
||||||
|
PLAYBOOK_VALIDATOR ?= ../net-kingdom/tools/playbook-capability-contract/playbook_contract_validator.py
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
docker compose -f docker-compose.dev.yml up
|
docker compose -f docker-compose.dev.yml up
|
||||||
|
|
@ -20,6 +22,9 @@ test:
|
||||||
lint:
|
lint:
|
||||||
cd src && go vet ./...
|
cd src && go vet ./...
|
||||||
|
|
||||||
|
contract-test:
|
||||||
|
python3 $(PLAYBOOK_VALIDATOR) capabilities/playbooks/*.yaml
|
||||||
|
|
||||||
image:
|
image:
|
||||||
docker build -t $(IMAGE) .
|
docker build -t $(IMAGE) .
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,14 @@ clients:
|
||||||
|
|
||||||
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
|
Config is validated at startup — the server exits 1 with validation errors if config is invalid.
|
||||||
|
|
||||||
|
Confidential `client_credentials` registrations may set a per-client
|
||||||
|
`tokenLifetime` from 1 minute through 1 hour. This overrides the server default
|
||||||
|
for that client and lets machine-login consumers carry an explicit maximum TTL.
|
||||||
|
See `config/service-clients.example.yaml` and
|
||||||
|
`docs/openbao-service-auth-contract.md` for the non-secret OpenBao service
|
||||||
|
identity contracts. Client secret values remain outside Git and are resolved
|
||||||
|
only from the registered `env:` reference at startup.
|
||||||
|
|
||||||
`browserBaseURL` is used only for the human browser redirect to Authelia.
|
`browserBaseURL` is used only for the human browser redirect to Authelia.
|
||||||
`tokenBaseURL` is used for server-side code exchange. If either is omitted,
|
`tokenBaseURL` is used for server-side code exchange. If either is omitted,
|
||||||
KeyCape falls back to `baseURL`.
|
KeyCape falls back to `baseURL`.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
| workplan | KEY-WP-0006 | finished | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
| workplan | KEY-WP-0006 | finished | — | workplans/KEY-WP-0006-client-credentials-service-tokens.md |
|
||||||
| workplan | KEY-WP-0007 | finished | — | workplans/KEY-WP-0007-user-engine-portal-oidc-client.md |
|
| workplan | KEY-WP-0007 | finished | — | workplans/KEY-WP-0007-user-engine-portal-oidc-client.md |
|
||||||
| workplan | KEY-WP-0008 | finished | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
| workplan | KEY-WP-0008 | finished | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
||||||
|
| workplan | KEY-WP-0009 | active | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
|
||||||
| task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
| task | KEY-WP-0001-T01 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
||||||
| task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
| task | KEY-WP-0001-T02 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
||||||
| task | KEY-WP-0001-T03 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
| task | KEY-WP-0001-T03 | done | — | workplans/KEY-WP-0001-keycape-implementation.md |
|
||||||
|
|
@ -72,3 +73,7 @@
|
||||||
| task | KEY-WP-0008-T02 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
| task | KEY-WP-0008-T02 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
||||||
| task | KEY-WP-0008-T03 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
| task | KEY-WP-0008-T03 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
||||||
| task | KEY-WP-0008-T04 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
| task | KEY-WP-0008-T04 | done | — | workplans/KEY-WP-0008-registration-handoff-and-client-mfa-policy.md |
|
||||||
|
| task | KEY-WP-0009-T01 | progress | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
|
||||||
|
| task | KEY-WP-0009-T02 | todo | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
|
||||||
|
| task | KEY-WP-0009-T03 | todo | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
|
||||||
|
| task | KEY-WP-0009-T04 | todo | — | workplans/KEY-WP-0009-provider-capabilities-and-service-identities.md |
|
||||||
|
|
|
||||||
107
capabilities/playbooks/key-cape.lightweight-sso.yaml
Normal file
107
capabilities/playbooks/key-cape.lightweight-sso.yaml
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
apiVersion: netkingdom.io/playbook-capability/v0.1
|
||||||
|
kind: PlaybookCapabilityDeclaration
|
||||||
|
metadata:
|
||||||
|
id: key-cape.lightweight-sso
|
||||||
|
name: KeyCape lightweight SSO runtime
|
||||||
|
owner: key-cape
|
||||||
|
repo: key-cape
|
||||||
|
domain: infotech
|
||||||
|
contract_version: "0.1"
|
||||||
|
source_links:
|
||||||
|
- label: Container entry point
|
||||||
|
path: Dockerfile
|
||||||
|
- label: OIDC server
|
||||||
|
path: src/cmd/keycape/main.go
|
||||||
|
spec:
|
||||||
|
playbook:
|
||||||
|
path: Dockerfile
|
||||||
|
type: containerized-go-service
|
||||||
|
invocation: /keycape --config /etc/keycape/config.yaml
|
||||||
|
description: Start the profile-constrained KeyCape OIDC issuer and its static client registry.
|
||||||
|
capabilities:
|
||||||
|
- id: c1.lightweight-sso
|
||||||
|
tier: C1
|
||||||
|
resource_kinds:
|
||||||
|
- identities
|
||||||
|
- roles_scopes_policies
|
||||||
|
- secrets_credentials
|
||||||
|
description: Provide the lightweight NetKingdom IAM Profile issuer with OIDC authorization code plus PKCE and bounded service-token issuance.
|
||||||
|
parameters:
|
||||||
|
- name: config_path
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: /etc/keycape/config.yaml
|
||||||
|
sensitivity: secret_reference
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Mounted KeyCape configuration path; secret values remain in the owning delivery system.
|
||||||
|
- name: issuer
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: https://kc.coulomb.social
|
||||||
|
constraints:
|
||||||
|
pattern: ^https://[^\n\r]+$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Exact issuer used in discovery and every signed token.
|
||||||
|
- name: default_token_lifetime
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: 15m
|
||||||
|
constraints:
|
||||||
|
pattern: ^[1-9][0-9]*[smh]$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Server default token lifetime; confidential clients may declare a shorter bounded lifetime.
|
||||||
|
- name: signing_key_path
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: /etc/keycape/key.pem
|
||||||
|
sensitivity: secret_reference
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Mounted RS256 signing-key path; the declaration never carries key material.
|
||||||
|
responsibilities:
|
||||||
|
- resource_kind: identities
|
||||||
|
owner: key-cape
|
||||||
|
resources:
|
||||||
|
- oidc:issuer
|
||||||
|
- oidc:static-clients
|
||||||
|
- iam-profile:normalized-claims
|
||||||
|
repo_owns: OIDC protocol behavior, static registration, signed identity claims, token expiry, and profile conformance.
|
||||||
|
netkingdom_orchestrates: Select the C1 provider and supply approved non-secret issuer and client policy inputs.
|
||||||
|
- resource_kind: roles_scopes_policies
|
||||||
|
owner: key-cape
|
||||||
|
resources:
|
||||||
|
- iam-profile:client-scope-allowlists
|
||||||
|
- iam-profile:service-roles
|
||||||
|
repo_owns: Enforce registered scopes, client types, service subjects, tenant claims, and assurance claim shape.
|
||||||
|
netkingdom_orchestrates: Define scenario-required identity posture without changing KeyCape enforcement mechanics.
|
||||||
|
- resource_kind: secrets_credentials
|
||||||
|
owner: railiance-platform
|
||||||
|
resources:
|
||||||
|
- keycape:signing-key
|
||||||
|
- keycape:confidential-client-secrets
|
||||||
|
repo_owns: Custody, approved materialization, and rotation of KeyCape signing and confidential-client material; KeyCape consumes only mounted values.
|
||||||
|
netkingdom_orchestrates: Require approved custody and delivery before C1 readiness can be asserted.
|
||||||
|
trust:
|
||||||
|
requires:
|
||||||
|
- state: cluster_trust
|
||||||
|
readiness_checks: []
|
||||||
|
- state: runtime_secret_trust
|
||||||
|
readiness_checks: []
|
||||||
|
satisfies:
|
||||||
|
- state: runtime_identity_trust
|
||||||
|
readiness_checks:
|
||||||
|
- id: keycape-health
|
||||||
|
description: The selected KeyCape runtime reports healthy.
|
||||||
|
evidence: GET /healthz returns status ok and the expected deployed version.
|
||||||
|
- id: keycape-discovery-jwks
|
||||||
|
description: Discovery names the exact issuer and its JWKS verifies a freshly issued token.
|
||||||
|
evidence: /.well-known/openid-configuration and /jwks pass the owning deployment verifier.
|
||||||
|
- id: keycape-profile-conformance
|
||||||
|
description: Supported and rejected OIDC behavior matches the NetKingdom IAM Profile.
|
||||||
|
evidence: make test and the NetKingdom IAM Profile conformance suite exit zero for the selected runtime.
|
||||||
|
catalog:
|
||||||
|
publish: capabilities/playbooks/key-cape.lightweight-sso.yaml
|
||||||
|
maturity: implemented
|
||||||
|
consumers:
|
||||||
|
- netkingdom-security-scenario-composer
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
apiVersion: netkingdom.io/playbook-capability/v0.1
|
||||||
|
kind: PlaybookCapabilityDeclaration
|
||||||
|
metadata:
|
||||||
|
id: key-cape.privacyidea-token-authority
|
||||||
|
name: KeyCape privacyIDEA token-authority integration
|
||||||
|
owner: key-cape
|
||||||
|
repo: key-cape
|
||||||
|
domain: infotech
|
||||||
|
contract_version: "0.1"
|
||||||
|
source_links:
|
||||||
|
- label: Runtime adapter
|
||||||
|
path: src/internal/adapters/privacyidea/adapter.go
|
||||||
|
- label: MFA authorization flow
|
||||||
|
path: src/internal/server/oidc/authorize.go
|
||||||
|
spec:
|
||||||
|
playbook:
|
||||||
|
path: src/internal/adapters/privacyidea/adapter.go
|
||||||
|
type: service-integration
|
||||||
|
invocation: POST /authorize/callback via /keycape --config /etc/keycape/config.yaml
|
||||||
|
description: Fail closed through privacyIDEA enrollment discovery and OTP validation, then emit verified AAL2 assurance through KeyCape.
|
||||||
|
capabilities:
|
||||||
|
- id: c2b.token-authority
|
||||||
|
tier: C2b
|
||||||
|
resource_kinds:
|
||||||
|
- identities
|
||||||
|
- secrets_credentials
|
||||||
|
description: Integrate the external privacyIDEA token authority into the KeyCape authorization flow without implementing or claiming C2a built-in factors.
|
||||||
|
parameters:
|
||||||
|
- name: privacyidea_base_url
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: http://privacyidea.sso.svc.cluster.local:80
|
||||||
|
constraints:
|
||||||
|
pattern: ^https?://[^\n\r]+$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Cluster-local privacyIDEA API endpoint selected by the deployment owner.
|
||||||
|
- name: privacyidea_admin_token_ref
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: kubernetes://sso/keycape-config#config.yaml
|
||||||
|
sensitivity: secret_reference
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Reference to the mounted KeyCape configuration carrying privacyIDEA API authentication; never a token value.
|
||||||
|
- name: realm
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: coulomb
|
||||||
|
constraints:
|
||||||
|
pattern: ^[a-z0-9][a-z0-9_-]{0,62}$
|
||||||
|
sensitivity: operational
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Exact privacyIDEA realm used for token lookup and validation.
|
||||||
|
- name: require_for_all
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: platform_only
|
||||||
|
description: Fail-closed bootstrap posture requiring MFA for upstream users while enrollment remains independently verified.
|
||||||
|
responsibilities:
|
||||||
|
- resource_kind: identities
|
||||||
|
owner: key-cape
|
||||||
|
resources:
|
||||||
|
- oidc:mfa-challenge
|
||||||
|
- iam-profile:assurance
|
||||||
|
repo_owns: Invoke the selected privacyIDEA realm, fail closed on provider errors, and emit AAL2 only after successful OTP verification.
|
||||||
|
netkingdom_orchestrates: Select C2b, require step-up for protected clients/actions, and consume assurance evidence from the profile.
|
||||||
|
- resource_kind: secrets_credentials
|
||||||
|
owner: privacyidea
|
||||||
|
resources:
|
||||||
|
- privacyidea:tokens
|
||||||
|
- privacyidea:enrollment-and-lifecycle
|
||||||
|
- privacyidea:custody-keys
|
||||||
|
repo_owns: Factor enrollment, token state, validation decisions, custody keys, and token lifecycle; KeyCape owns only the adapter and assurance mapping.
|
||||||
|
netkingdom_orchestrates: Require the external authority and its custody/readiness evidence without transferring token ownership to KeyCape.
|
||||||
|
trust:
|
||||||
|
requires:
|
||||||
|
- state: runtime_identity_trust
|
||||||
|
readiness_checks: []
|
||||||
|
- state: runtime_secret_trust
|
||||||
|
readiness_checks: []
|
||||||
|
satisfies:
|
||||||
|
- state: runtime_identity_trust
|
||||||
|
readiness_checks:
|
||||||
|
- id: privacyidea-adapter-fail-closed
|
||||||
|
description: Token lookup and validation errors cannot bypass MFA.
|
||||||
|
evidence: go test ./internal/adapters/privacyidea ./internal/server/oidc exits zero.
|
||||||
|
- id: privacyidea-aal2-proof
|
||||||
|
description: A selected enrolled identity completes OTP validation and receives AAL2/MFA assurance; an invalid OTP is denied.
|
||||||
|
evidence: The owning deployment's non-secret login verifier records positive AAL2 and negative invalid-factor outcomes.
|
||||||
|
catalog:
|
||||||
|
publish: capabilities/playbooks/key-cape.privacyidea-token-authority.yaml
|
||||||
|
maturity: implemented-integration
|
||||||
|
consumers:
|
||||||
|
- netkingdom-security-scenario-composer
|
||||||
26
config/service-clients.example.yaml
Normal file
26
config/service-clients.example.yaml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
# Non-secret static registration fragments for KeyCape's bounded OpenBao JWT
|
||||||
|
# consumers. Merge these entries into the deployment-owned KeyCape config.
|
||||||
|
# The named environment variables must be materialized by an approved
|
||||||
|
# out-of-repository custody path; this file never contains their values.
|
||||||
|
clients:
|
||||||
|
- clientId: "codex-railiance-platform"
|
||||||
|
displayName: "Railiance platform coding agent"
|
||||||
|
allowedScopes: ["openbao:login"]
|
||||||
|
grantTypes: ["client_credentials"]
|
||||||
|
clientType: "confidential"
|
||||||
|
secretRef: "env:KEYCAPE_CODEX_RAILIANCE_PLATFORM_CLIENT_SECRET"
|
||||||
|
serviceSubject: "service:codex:railiance-platform"
|
||||||
|
tenant: "tenant:coulomb"
|
||||||
|
roles: ["coding-agent"]
|
||||||
|
tokenLifetime: "15m"
|
||||||
|
|
||||||
|
- clientId: "secrets-engine-openbao"
|
||||||
|
displayName: "secrets-engine OpenBao login"
|
||||||
|
allowedScopes: ["openbao:login"]
|
||||||
|
grantTypes: ["client_credentials"]
|
||||||
|
clientType: "confidential"
|
||||||
|
secretRef: "env:KEYCAPE_SECRETS_ENGINE_OPENBAO_CLIENT_SECRET"
|
||||||
|
serviceSubject: "service:secrets-engine"
|
||||||
|
tenant: "tenant:coulomb"
|
||||||
|
roles: ["secrets-engine"]
|
||||||
|
tokenLifetime: "15m"
|
||||||
86
docs/openbao-service-auth-contract.md
Normal file
86
docs/openbao-service-auth-contract.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# KeyCape service authentication for OpenBao
|
||||||
|
|
||||||
|
Date: 2026-08-23
|
||||||
|
Status: accepted provider contract; live client provisioning remains a
|
||||||
|
deployment and custody operation
|
||||||
|
|
||||||
|
## Ownership boundary
|
||||||
|
|
||||||
|
KeyCape owns static confidential-client registration, client authentication,
|
||||||
|
JWT signing, IAM Profile service claims, expiry, and disabling future issuance.
|
||||||
|
The OpenBao platform owner owns JWT auth mounts, exact-bound roles, policies,
|
||||||
|
OpenBao token TTL/use limits, and value-safe authorization evidence. The
|
||||||
|
consumer owns renewal and fail-closed use. OpenBao or its approved delivery
|
||||||
|
path owns confidential-client secret custody.
|
||||||
|
|
||||||
|
KeyCape accepts the `codex-railiance-platform` issuance identity and the
|
||||||
|
`secrets-engine-openbao` service identity under this contract. It does not own
|
||||||
|
the resulting OpenBao identity token or policy. ops-warden remains the SSH
|
||||||
|
certificate issuer only, and user-engine is not in the machine-principal path.
|
||||||
|
|
||||||
|
## Exchange and claims
|
||||||
|
|
||||||
|
The consumer sends `POST /token` with `grant_type=client_credentials`, HTTP
|
||||||
|
Basic client authentication, and the allow-listed `scope=openbao:login`.
|
||||||
|
KeyCape returns an RS256 access token and no ID token or refresh token.
|
||||||
|
|
||||||
|
Every issued service token contains:
|
||||||
|
|
||||||
|
| Claim | Contract |
|
||||||
|
| --- | --- |
|
||||||
|
| `iss` | the configured KeyCape issuer |
|
||||||
|
| `sub` | the registration's exact `serviceSubject` |
|
||||||
|
| `aud` | the exact `clientId` |
|
||||||
|
| `iat`, `exp` | issue and expiry time; maximum per-client lifetime below |
|
||||||
|
| `principal_type` | `service` |
|
||||||
|
| `tenant` | the registration's exact tenant |
|
||||||
|
| `roles` | the registration's allow-listed roles |
|
||||||
|
| `groups` | an empty array |
|
||||||
|
| `scope` | only requested scopes from the registration allow-list |
|
||||||
|
| `assurance` | AAL1, `client_secret`, non-MFA, source `key-cape` |
|
||||||
|
|
||||||
|
OpenBao JWT roles must bind the exact issuer, audience, subject,
|
||||||
|
`principal_type`, tenant, and required role. A consumer must reject an issuer
|
||||||
|
or claim mismatch before attempting OpenBao login.
|
||||||
|
|
||||||
|
## Accepted registrations
|
||||||
|
|
||||||
|
The source-of-truth non-secret fragments are in
|
||||||
|
`config/service-clients.example.yaml`.
|
||||||
|
|
||||||
|
| Client | Subject | Role | Scope | Maximum JWT lifetime |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `codex-railiance-platform` | `service:codex:railiance-platform` | `coding-agent` | `openbao:login` | 15 minutes |
|
||||||
|
| `secrets-engine-openbao` | `service:secrets-engine` | `secrets-engine` | `openbao:login` | 15 minutes |
|
||||||
|
|
||||||
|
The confidential secret is referenced as an environment variable in KeyCape
|
||||||
|
configuration and must be materialized without entering Git, State Hub, chat,
|
||||||
|
process arguments, or normal logs. Registering or rotating the live value is a
|
||||||
|
separate approved custody action.
|
||||||
|
|
||||||
|
## Renewal, expiry, and revocation
|
||||||
|
|
||||||
|
- There is no refresh token. Obtain a new KeyCape JWT with a new
|
||||||
|
`client_credentials` exchange when no more than three minutes remain.
|
||||||
|
- A consumer may retry a transient exchange failure with bounded backoff only
|
||||||
|
while its current JWT is still valid. It must stop the privileged operation
|
||||||
|
at expiry.
|
||||||
|
- KeyCape JWTs are self-contained and cannot be recalled after issue. Disabling
|
||||||
|
the client or rotating its secret stops new issuance; the residual exposure
|
||||||
|
is bounded by the 15-minute JWT lifetime and the OpenBao token's independent
|
||||||
|
TTL/use limits.
|
||||||
|
- After OpenBao login, the consumer must explicitly revoke the OpenBao token in
|
||||||
|
its cleanup path. A cleanup failure is actionable evidence and must not print
|
||||||
|
a JWT, OpenBao token, client secret, or token accessor.
|
||||||
|
|
||||||
|
## Failure semantics
|
||||||
|
|
||||||
|
Authentication, scope, claim, signature, issuer, expiry, OpenBao login, and
|
||||||
|
cleanup failures are fail-closed. Steady-state consumers must not implicitly
|
||||||
|
fall back to bootstrap token files, an operator identity, a broader AppRole, or
|
||||||
|
an unbound JWT role. Bootstrap files remain explicitly selected setup or
|
||||||
|
break-glass providers and require their own authorization and evidence.
|
||||||
|
|
||||||
|
Verification must inspect metadata and capabilities without reading a secret
|
||||||
|
value. A coding-agent check must prove the `agent-high-risk-boundary` deny wins
|
||||||
|
against any otherwise readable workload policy.
|
||||||
|
|
@ -266,6 +266,7 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
|
||||||
for i := range cfgClients {
|
for i := range cfgClients {
|
||||||
c := &cfgClients[i]
|
c := &cfgClients[i]
|
||||||
clientSecret := ""
|
clientSecret := ""
|
||||||
|
var clientTokenLifetime time.Duration
|
||||||
if strings.HasPrefix(c.SecretRef, "env:") {
|
if strings.HasPrefix(c.SecretRef, "env:") {
|
||||||
envName := strings.TrimPrefix(c.SecretRef, "env:")
|
envName := strings.TrimPrefix(c.SecretRef, "env:")
|
||||||
clientSecret = os.Getenv(envName)
|
clientSecret = os.Getenv(envName)
|
||||||
|
|
@ -273,18 +274,26 @@ func buildClientRegistry(cfgClients []config.ClientConfig) (map[string]*domain.C
|
||||||
return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName)
|
return nil, fmt.Errorf("client %q secret environment variable %q is empty", c.ClientID, envName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.TokenLifetime != "" {
|
||||||
|
parsedLifetime, parseErr := time.ParseDuration(c.TokenLifetime)
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, fmt.Errorf("client %q tokenLifetime is invalid: %w", c.ClientID, parseErr)
|
||||||
|
}
|
||||||
|
clientTokenLifetime = parsedLifetime
|
||||||
|
}
|
||||||
m[c.ClientID] = &domain.Client{
|
m[c.ClientID] = &domain.Client{
|
||||||
ClientID: c.ClientID,
|
ClientID: c.ClientID,
|
||||||
DisplayName: c.DisplayName,
|
DisplayName: c.DisplayName,
|
||||||
RedirectURIs: c.RedirectURIs,
|
RedirectURIs: c.RedirectURIs,
|
||||||
AllowedScopes: c.AllowedScopes,
|
AllowedScopes: c.AllowedScopes,
|
||||||
GrantTypes: c.GrantTypes,
|
GrantTypes: c.GrantTypes,
|
||||||
ClientType: c.ClientType,
|
ClientType: c.ClientType,
|
||||||
SecretRef: c.SecretRef,
|
SecretRef: c.SecretRef,
|
||||||
ClientSecret: clientSecret,
|
ClientSecret: clientSecret,
|
||||||
ServiceSubject: c.ServiceSubject,
|
ServiceSubject: c.ServiceSubject,
|
||||||
Tenant: c.Tenant,
|
Tenant: c.Tenant,
|
||||||
Roles: c.Roles,
|
Roles: c.Roles,
|
||||||
|
TokenLifetime: clientTokenLifetime,
|
||||||
MFARequired: c.MFARequired,
|
MFARequired: c.MFARequired,
|
||||||
RegistrationURL: c.RegistrationURL,
|
RegistrationURL: c.RegistrationURL,
|
||||||
EnrollmentURL: c.EnrollmentURL,
|
EnrollmentURL: c.EnrollmentURL,
|
||||||
|
|
|
||||||
|
|
@ -29,16 +29,17 @@ type Config struct {
|
||||||
|
|
||||||
// ClientConfig is a static OIDC client registration.
|
// ClientConfig is a static OIDC client registration.
|
||||||
type ClientConfig struct {
|
type ClientConfig struct {
|
||||||
ClientID string `yaml:"clientId"`
|
ClientID string `yaml:"clientId"`
|
||||||
DisplayName string `yaml:"displayName"`
|
DisplayName string `yaml:"displayName"`
|
||||||
RedirectURIs []string `yaml:"redirectUris"`
|
RedirectURIs []string `yaml:"redirectUris"`
|
||||||
AllowedScopes []string `yaml:"allowedScopes"`
|
AllowedScopes []string `yaml:"allowedScopes"`
|
||||||
GrantTypes []string `yaml:"grantTypes"`
|
GrantTypes []string `yaml:"grantTypes"`
|
||||||
ClientType string `yaml:"clientType"` // "confidential" | "public"
|
ClientType string `yaml:"clientType"` // "confidential" | "public"
|
||||||
SecretRef string `yaml:"secretRef,omitempty"`
|
SecretRef string `yaml:"secretRef,omitempty"`
|
||||||
ServiceSubject string `yaml:"serviceSubject,omitempty"`
|
ServiceSubject string `yaml:"serviceSubject,omitempty"`
|
||||||
Tenant string `yaml:"tenant,omitempty"`
|
Tenant string `yaml:"tenant,omitempty"`
|
||||||
Roles []string `yaml:"roles,omitempty"`
|
Roles []string `yaml:"roles,omitempty"`
|
||||||
|
TokenLifetime string `yaml:"tokenLifetime,omitempty"`
|
||||||
MFARequired *bool `yaml:"mfaRequired,omitempty"`
|
MFARequired *bool `yaml:"mfaRequired,omitempty"`
|
||||||
RegistrationURL string `yaml:"registrationUrl,omitempty"`
|
RegistrationURL string `yaml:"registrationUrl,omitempty"`
|
||||||
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`
|
EnrollmentURL string `yaml:"enrollmentUrl,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,78 @@ func TestValidate_MissingPrivateKeyPEM(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidate_ClientCredentialsTokenLifetime(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "key")
|
||||||
|
cfg := validConfig(keyPath)
|
||||||
|
cfg.Clients[0] = config.ClientConfig{
|
||||||
|
ClientID: "service-client",
|
||||||
|
ClientType: "confidential",
|
||||||
|
GrantTypes: []string{"client_credentials"},
|
||||||
|
AllowedScopes: []string{"openbao:login"},
|
||||||
|
SecretRef: "env:SERVICE_CLIENT_SECRET",
|
||||||
|
ServiceSubject: "service:test",
|
||||||
|
Tenant: "tenant:coulomb",
|
||||||
|
TokenLifetime: "15m",
|
||||||
|
}
|
||||||
|
if errs := config.ValidateConfig(cfg); len(errs) != 0 {
|
||||||
|
t.Fatalf("valid per-client token lifetime rejected: %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Clients[0].TokenLifetime = "90m"
|
||||||
|
if errs := config.ValidateConfig(cfg); !containsErr(errs, "between 1m and 1h") {
|
||||||
|
t.Fatalf("expected bounded tokenLifetime error, got %v", errs)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Clients[0].TokenLifetime = "not-a-duration"
|
||||||
|
if errs := config.ValidateConfig(cfg); !containsErr(errs, "valid duration") {
|
||||||
|
t.Fatalf("expected invalid tokenLifetime error, got %v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidate_PublicClientRejectsTokenLifetime(t *testing.T) {
|
||||||
|
keyPath := writeTempFile(t, "key")
|
||||||
|
cfg := validConfig(keyPath)
|
||||||
|
cfg.Clients[0].TokenLifetime = "15m"
|
||||||
|
if errs := config.ValidateConfig(cfg); !containsErr(errs, "only supported for client_credentials") {
|
||||||
|
t.Fatalf("expected public-client tokenLifetime error, got %v", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceClientExampleContracts(t *testing.T) {
|
||||||
|
cfg, err := config.Load(filepath.Join("..", "..", "..", "config", "service-clients.example.yaml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load service client examples: %v", err)
|
||||||
|
}
|
||||||
|
cfg.Issuer = "https://kc.coulomb.social"
|
||||||
|
cfg.Port = 8080
|
||||||
|
cfg.PrivateKeyPEM = writeTempFile(t, "key")
|
||||||
|
if errs := config.ValidateConfig(cfg); len(errs) != 0 {
|
||||||
|
t.Fatalf("service client examples must validate: %v", errs)
|
||||||
|
}
|
||||||
|
if len(cfg.Clients) != 2 {
|
||||||
|
t.Fatalf("service client examples: want 2, got %d", len(cfg.Clients))
|
||||||
|
}
|
||||||
|
|
||||||
|
codingAgent := cfg.Clients[0]
|
||||||
|
if codingAgent.ClientID != "codex-railiance-platform" ||
|
||||||
|
codingAgent.ServiceSubject != "service:codex:railiance-platform" ||
|
||||||
|
codingAgent.Tenant != "tenant:coulomb" ||
|
||||||
|
codingAgent.TokenLifetime != "15m" {
|
||||||
|
t.Fatalf("coding-agent contract drifted: %+v", codingAgent)
|
||||||
|
}
|
||||||
|
if len(codingAgent.Roles) != 1 || codingAgent.Roles[0] != "coding-agent" ||
|
||||||
|
len(codingAgent.AllowedScopes) != 1 || codingAgent.AllowedScopes[0] != "openbao:login" {
|
||||||
|
t.Fatalf("coding-agent authorization contract drifted: %+v", codingAgent)
|
||||||
|
}
|
||||||
|
|
||||||
|
secretsEngine := cfg.Clients[1]
|
||||||
|
if secretsEngine.ClientID != "secrets-engine-openbao" ||
|
||||||
|
secretsEngine.ServiceSubject != "service:secrets-engine" ||
|
||||||
|
secretsEngine.TokenLifetime != "15m" {
|
||||||
|
t.Fatalf("secrets-engine contract drifted: %+v", secretsEngine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Env var loading test
|
// Env var loading test
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ValidateConfig validates a loaded Config and returns a list of human-readable
|
// ValidateConfig validates a loaded Config and returns a list of human-readable
|
||||||
|
|
@ -56,6 +57,16 @@ func ValidateConfig(cfg *Config) []string {
|
||||||
if c.ServiceSubject == "" || c.Tenant == "" {
|
if c.ServiceSubject == "" || c.Tenant == "" {
|
||||||
errs = append(errs, prefix+": client_credentials requires serviceSubject and tenant")
|
errs = append(errs, prefix+": client_credentials requires serviceSubject and tenant")
|
||||||
}
|
}
|
||||||
|
if c.TokenLifetime != "" {
|
||||||
|
lifetime, err := time.ParseDuration(c.TokenLifetime)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, prefix+": tokenLifetime must be a valid duration")
|
||||||
|
} else if lifetime < time.Minute || lifetime > time.Hour {
|
||||||
|
errs = append(errs, prefix+": tokenLifetime must be between 1m and 1h")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if c.TokenLifetime != "" {
|
||||||
|
errs = append(errs, prefix+": tokenLifetime is only supported for client_credentials clients")
|
||||||
}
|
}
|
||||||
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
|
// Warn about wildcard redirect URIs (they are blocked at runtime anyway).
|
||||||
for _, uri := range c.RedirectURIs {
|
for _, uri := range c.RedirectURIs {
|
||||||
|
|
|
||||||
|
|
@ -52,10 +52,13 @@ type Client struct {
|
||||||
ClientSecret string `yaml:"-" json:"-"`
|
ClientSecret string `yaml:"-" json:"-"`
|
||||||
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
|
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
|
||||||
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
|
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
|
||||||
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
|
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
|
||||||
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
|
// TokenLifetime overrides the server default for this confidential client.
|
||||||
RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"`
|
// It is internal runtime policy, not identity data serialized into tokens.
|
||||||
EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"`
|
TokenLifetime time.Duration `yaml:"-" json:"-"`
|
||||||
|
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
|
||||||
|
RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"`
|
||||||
|
EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Membership links a user to a group.
|
// Membership links a user to a group.
|
||||||
|
|
|
||||||
|
|
@ -232,11 +232,15 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
tokenLifetime := h.TokenLifetime
|
||||||
|
if client.TokenLifetime > 0 {
|
||||||
|
tokenLifetime = client.TokenLifetime
|
||||||
|
}
|
||||||
claims := map[string]interface{}{
|
claims := map[string]interface{}{
|
||||||
"iss": h.Issuer,
|
"iss": h.Issuer,
|
||||||
"sub": client.ServiceSubject,
|
"sub": client.ServiceSubject,
|
||||||
"aud": clientID,
|
"aud": clientID,
|
||||||
"exp": now.Add(h.TokenLifetime).Unix(),
|
"exp": now.Add(tokenLifetime).Unix(),
|
||||||
"iat": now.Unix(),
|
"iat": now.Unix(),
|
||||||
"tenant": client.Tenant,
|
"tenant": client.Tenant,
|
||||||
"principal_type": "service",
|
"principal_type": "service",
|
||||||
|
|
@ -265,7 +269,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_ = json.NewEncoder(w).Encode(tokenResponse{
|
_ = json.NewEncoder(w).Encode(tokenResponse{
|
||||||
AccessToken: jwtToken, TokenType: "Bearer",
|
AccessToken: jwtToken, TokenType: "Bearer",
|
||||||
ExpiresIn: int(h.TokenLifetime.Seconds()),
|
ExpiresIn: int(tokenLifetime.Seconds()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,30 @@ func TestTokenHandler_ClientCredentials_ReturnsScopedServiceToken(t *testing.T)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTokenHandler_ClientCredentials_UsesPerClientLifetime(t *testing.T) {
|
||||||
|
h := serviceTokenHandler(t)
|
||||||
|
h.ClientConfig["rapp-qonto"].TokenLifetime = 5 * time.Minute
|
||||||
|
req := tokenRequest(url.Values{
|
||||||
|
"grant_type": {"client_credentials"},
|
||||||
|
"scope": {"finance.qonto.read"},
|
||||||
|
})
|
||||||
|
req.SetBasicAuth("rapp-qonto", "test-service-secret")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
resp := decodeTokenResponse(t, w.Body.String())
|
||||||
|
if got := int(resp["expires_in"].(float64)); got != 300 {
|
||||||
|
t.Fatalf("expires_in: want 300, got %d", got)
|
||||||
|
}
|
||||||
|
claims := parseJWTPayload(t, resp["access_token"].(string))
|
||||||
|
ttl := int64(claims["exp"].(float64) - claims["iat"].(float64))
|
||||||
|
if ttl != 300 {
|
||||||
|
t.Fatalf("JWT lifetime: want 300 seconds, got %d", ttl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) {
|
func TestTokenHandler_ClientCredentials_RejectsWrongSecret(t *testing.T) {
|
||||||
h := serviceTokenHandler(t)
|
h := serviceTokenHandler(t)
|
||||||
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}})
|
req := tokenRequest(url.Values{"grant_type": {"client_credentials"}})
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,96 @@
|
||||||
|
---
|
||||||
|
id: KEY-WP-0009
|
||||||
|
type: workplan
|
||||||
|
title: "Provider capability declarations and bounded service identities"
|
||||||
|
domain: infotech
|
||||||
|
repo: key-cape
|
||||||
|
status: finished
|
||||||
|
owner: codex
|
||||||
|
topic_slug: netkingdom
|
||||||
|
created: "2026-08-23"
|
||||||
|
updated: "2026-08-23"
|
||||||
|
depends_on:
|
||||||
|
- NK-WP-0030
|
||||||
|
- KEY-WP-0006
|
||||||
|
---
|
||||||
|
|
||||||
|
# KEY-WP-0009 — Provider capabilities and bounded service identities
|
||||||
|
|
||||||
|
Publish KeyCape-owned security-scenario interfaces and make the existing
|
||||||
|
service-token issuer precise enough for OpenBao machine-login consumers. This
|
||||||
|
work accepts identity issuance ownership without taking over OpenBao roles,
|
||||||
|
policies, secret custody, or privacyIDEA token lifecycle.
|
||||||
|
|
||||||
|
## Publish C1 and C2b provider declarations
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0009-T01
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Publish Playbook Capability Contract v0.1 declarations for the KeyCape C1
|
||||||
|
runtime and its privacyIDEA-backed C2b integration. Name exact entry points,
|
||||||
|
parameter authority, resource ownership, trust requirements, and readiness
|
||||||
|
evidence. Do not claim C2a or privacyIDEA-owned token lifecycle.
|
||||||
|
|
||||||
|
Published `capabilities/playbooks/key-cape.lightweight-sso.yaml` and
|
||||||
|
`capabilities/playbooks/key-cape.privacyidea-token-authority.yaml`. The latter
|
||||||
|
claims the KeyCape integration for C2b while leaving factor enrollment, token
|
||||||
|
state, custody keys, validation decisions, and lifecycle with privacyIDEA.
|
||||||
|
|
||||||
|
## Define bounded service-auth contracts
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0009-T02
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Define the KeyCape service-auth claims, renewal/expiry, failure, custody, and
|
||||||
|
owner boundaries required by secrets-engine and OpenBao JWT roles. Add
|
||||||
|
per-client access-token lifetimes so a bounded client contract does not depend
|
||||||
|
on an unrelated global default.
|
||||||
|
|
||||||
|
Added a validated 1m-1h per-client `tokenLifetime` override, applied it to JWT
|
||||||
|
`exp` and `expires_in`, and documented claims, renewal, expiry, residual JWT
|
||||||
|
validity, explicit OpenBao cleanup, and no-fallback failure semantics in
|
||||||
|
`docs/openbao-service-auth-contract.md`.
|
||||||
|
|
||||||
|
## Accept coding-agent issuance ownership
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0009-T03
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Publish the non-secret static registration for `codex-railiance-platform` with
|
||||||
|
the exact audience, subject, tenant, role, scope, and 15-minute lifetime already
|
||||||
|
accepted by railiance-platform. KeyCape owns JWT issuance and client disablement;
|
||||||
|
railiance-platform owns the exact-bound OpenBao role and policy; OpenBao owns
|
||||||
|
resulting token enforcement; secret values remain outside this repository.
|
||||||
|
|
||||||
|
Accepted ownership through the exact non-secret registration in
|
||||||
|
`config/service-clients.example.yaml`. Added the parallel reviewed
|
||||||
|
`secrets-engine-openbao` contract. Live value generation/materialization,
|
||||||
|
deployment merge, and OpenBao role/policy application remain with their named
|
||||||
|
custody and platform owners and are not implied by this source registration.
|
||||||
|
|
||||||
|
## Validate and hand off
|
||||||
|
|
||||||
|
```task
|
||||||
|
id: KEY-WP-0009-T04
|
||||||
|
status: done
|
||||||
|
priority: high
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the canonical declaration validator, Go formatting/build/vet/tests, and
|
||||||
|
repository checks. Reply to NetKingdom, secrets-engine, ops-warden, and
|
||||||
|
railiance-platform with revision-independent paths and exact ownership status.
|
||||||
|
|
||||||
|
Both declarations pass NetKingdom's canonical validator. The full Go suite,
|
||||||
|
vet, build, `gofmt`, YAML parsing, and `git diff --check` pass using an explicit
|
||||||
|
Go 1.23 toolchain and writable local cache; the workstation's default Go cache
|
||||||
|
is read-only and was not used. State Hub handoffs name only non-secret paths and
|
||||||
|
ownership facts.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue