Implement KeyCape provider and service identity contracts
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:
tegwick 2026-08-23 13:10:13 +02:00
parent cdfb046b80
commit efce3e9331
15 changed files with 579 additions and 26 deletions

View file

@ -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) .

View file

@ -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`.

View file

@ -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 |

View 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

View file

@ -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

View 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"

View 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.

View file

@ -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,

View file

@ -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"`

View file

@ -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
// ---------------------------------------------------------------------------

View file

@ -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 {

View file

@ -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.

View file

@ -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()),
})
}

View file

@ -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"}})

View file

@ -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.