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 := $(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:
|
||||
docker compose -f docker-compose.dev.yml up
|
||||
|
|
@ -20,6 +22,9 @@ test:
|
|||
lint:
|
||||
cd src && go vet ./...
|
||||
|
||||
contract-test:
|
||||
python3 $(PLAYBOOK_VALIDATOR) capabilities/playbooks/*.yaml
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
`tokenBaseURL` is used for server-side code exchange. If either is omitted,
|
||||
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-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-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-T02 | 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-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-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 {
|
||||
c := &cfgClients[i]
|
||||
clientSecret := ""
|
||||
var clientTokenLifetime time.Duration
|
||||
if strings.HasPrefix(c.SecretRef, "env:") {
|
||||
envName := strings.TrimPrefix(c.SecretRef, "env:")
|
||||
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)
|
||||
}
|
||||
}
|
||||
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{
|
||||
ClientID: c.ClientID,
|
||||
DisplayName: c.DisplayName,
|
||||
RedirectURIs: c.RedirectURIs,
|
||||
AllowedScopes: c.AllowedScopes,
|
||||
GrantTypes: c.GrantTypes,
|
||||
ClientType: c.ClientType,
|
||||
SecretRef: c.SecretRef,
|
||||
ClientSecret: clientSecret,
|
||||
ServiceSubject: c.ServiceSubject,
|
||||
Tenant: c.Tenant,
|
||||
ClientID: c.ClientID,
|
||||
DisplayName: c.DisplayName,
|
||||
RedirectURIs: c.RedirectURIs,
|
||||
AllowedScopes: c.AllowedScopes,
|
||||
GrantTypes: c.GrantTypes,
|
||||
ClientType: c.ClientType,
|
||||
SecretRef: c.SecretRef,
|
||||
ClientSecret: clientSecret,
|
||||
ServiceSubject: c.ServiceSubject,
|
||||
Tenant: c.Tenant,
|
||||
Roles: c.Roles,
|
||||
TokenLifetime: clientTokenLifetime,
|
||||
MFARequired: c.MFARequired,
|
||||
RegistrationURL: c.RegistrationURL,
|
||||
EnrollmentURL: c.EnrollmentURL,
|
||||
|
|
|
|||
|
|
@ -29,16 +29,17 @@ type Config struct {
|
|||
|
||||
// ClientConfig is a static OIDC client registration.
|
||||
type ClientConfig struct {
|
||||
ClientID string `yaml:"clientId"`
|
||||
DisplayName string `yaml:"displayName"`
|
||||
RedirectURIs []string `yaml:"redirectUris"`
|
||||
AllowedScopes []string `yaml:"allowedScopes"`
|
||||
GrantTypes []string `yaml:"grantTypes"`
|
||||
ClientType string `yaml:"clientType"` // "confidential" | "public"
|
||||
SecretRef string `yaml:"secretRef,omitempty"`
|
||||
ServiceSubject string `yaml:"serviceSubject,omitempty"`
|
||||
Tenant string `yaml:"tenant,omitempty"`
|
||||
ClientID string `yaml:"clientId"`
|
||||
DisplayName string `yaml:"displayName"`
|
||||
RedirectURIs []string `yaml:"redirectUris"`
|
||||
AllowedScopes []string `yaml:"allowedScopes"`
|
||||
GrantTypes []string `yaml:"grantTypes"`
|
||||
ClientType string `yaml:"clientType"` // "confidential" | "public"
|
||||
SecretRef string `yaml:"secretRef,omitempty"`
|
||||
ServiceSubject string `yaml:"serviceSubject,omitempty"`
|
||||
Tenant string `yaml:"tenant,omitempty"`
|
||||
Roles []string `yaml:"roles,omitempty"`
|
||||
TokenLifetime string `yaml:"tokenLifetime,omitempty"`
|
||||
MFARequired *bool `yaml:"mfaRequired,omitempty"`
|
||||
RegistrationURL string `yaml:"registrationUrl,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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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 == "" {
|
||||
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).
|
||||
for _, uri := range c.RedirectURIs {
|
||||
|
|
|
|||
|
|
@ -52,10 +52,13 @@ type Client struct {
|
|||
ClientSecret string `yaml:"-" json:"-"`
|
||||
ServiceSubject string `yaml:"serviceSubject,omitempty" json:"serviceSubject,omitempty"`
|
||||
Tenant string `yaml:"tenant,omitempty" json:"tenant,omitempty"`
|
||||
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
|
||||
MFARequired *bool `yaml:"mfaRequired,omitempty" json:"mfaRequired,omitempty"`
|
||||
RegistrationURL string `yaml:"registrationUrl,omitempty" json:"registrationUrl,omitempty"`
|
||||
EnrollmentURL string `yaml:"enrollmentUrl,omitempty" json:"enrollmentUrl,omitempty"`
|
||||
Roles []string `yaml:"roles,omitempty" json:"roles,omitempty"`
|
||||
// TokenLifetime overrides the server default for this confidential client.
|
||||
// It is internal runtime policy, not identity data serialized into tokens.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -232,11 +232,15 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
|
|||
}
|
||||
|
||||
now := time.Now()
|
||||
tokenLifetime := h.TokenLifetime
|
||||
if client.TokenLifetime > 0 {
|
||||
tokenLifetime = client.TokenLifetime
|
||||
}
|
||||
claims := map[string]interface{}{
|
||||
"iss": h.Issuer,
|
||||
"sub": client.ServiceSubject,
|
||||
"aud": clientID,
|
||||
"exp": now.Add(h.TokenLifetime).Unix(),
|
||||
"exp": now.Add(tokenLifetime).Unix(),
|
||||
"iat": now.Unix(),
|
||||
"tenant": client.Tenant,
|
||||
"principal_type": "service",
|
||||
|
|
@ -265,7 +269,7 @@ func (h *TokenHandler) serveClientCredentials(w http.ResponseWriter, r *http.Req
|
|||
w.WriteHeader(http.StatusOK)
|
||||
_ = json.NewEncoder(w).Encode(tokenResponse{
|
||||
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) {
|
||||
h := serviceTokenHandler(t)
|
||||
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