NK-WP-0056-T14 done: SecurityCaseType oidc-service-client 0.1 (proposed)
Confidential client_credentials service clients: one audience, closed resource scopes, fixed service subject and tenant, at most 15m; secret minted in custody, read only by KeyCape (ESO) and the consumer's own bound Kubernetes-auth role (no Secret copy). First case nk-bind-executor-approval (approval-engine create/read/consume as service:nk-bind-executor). Capability net-kingdom.keycape-service-client-registration; keycape-client.py renders service clients in key-cape's shape (adoption of secrets-engine-approval reproduces key-cape's record). Acknowledgement memo r1 validated against informed-decision intake and State Hub dispatcher; hub decision f8044f81. Test fixture's synthetic signing key is now computed, not a literal (secret scan generic-api-key). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5502@bnt-lap001 Assistant-Session: 68b3b02f-5d0f-4e71-8421-796f62e50196
This commit is contained in:
parent
8cecf8949a
commit
5d5f15df55
13 changed files with 1184 additions and 4 deletions
|
|
@ -356,7 +356,7 @@
|
||||||
| task | NK-WP-0056-T11 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
| task | NK-WP-0056-T11 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
||||||
| task | NK-WP-0056-T12 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
| task | NK-WP-0056-T12 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
||||||
| task | NK-WP-0056-T13 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
| task | NK-WP-0056-T13 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
||||||
| task | NK-WP-0056-T14 | progress | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
| task | NK-WP-0056-T14 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
||||||
| task | NK-WP-0056-T15 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
| task | NK-WP-0056-T15 | todo | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
||||||
| task | NK-WP-0056-T16 | wait | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
| task | NK-WP-0056-T16 | wait | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
||||||
| task | NK-WP-0056-T17 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
| task | NK-WP-0056-T17 | done | — | workplans/NK-WP-0056-bounded-executor-path.md |
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,135 @@
|
||||||
|
apiVersion: netkingdom.io/playbook-capability/v0.1
|
||||||
|
kind: PlaybookCapabilityDeclaration
|
||||||
|
metadata:
|
||||||
|
id: net-kingdom.keycape-service-client-registration
|
||||||
|
name: KeyCape confidential service client registration (live registry)
|
||||||
|
owner: net-kingdom
|
||||||
|
repo: net-kingdom
|
||||||
|
domain: infotech
|
||||||
|
contract_version: "0.1"
|
||||||
|
source_links:
|
||||||
|
- label: Entry point
|
||||||
|
path: sso-mfa/k8s/keycape/keycape-client.py
|
||||||
|
- label: Tests
|
||||||
|
path: sso-mfa/k8s/keycape/test_keycape_client.py
|
||||||
|
- label: SecurityCaseType
|
||||||
|
path: security-case-types/oidc-service-client/type.yaml
|
||||||
|
spec:
|
||||||
|
playbook:
|
||||||
|
path: sso-mfa/k8s/keycape/keycape-client.py
|
||||||
|
type: cli-sequence
|
||||||
|
invocation: keycape-client.py apply --client-file <client.yaml> --case-acceptance <record> --expected-uid <uid> --expected-resource-version <rv>
|
||||||
|
description: >-
|
||||||
|
Add or remove exactly one client_credentials service client in the live
|
||||||
|
KeyCape client registry, with the same safeguards as browser clients:
|
||||||
|
contained kubectl, cluster and issuer pins, byte-preserving merge,
|
||||||
|
compare-and-swap, KeyCape check-config, read-back, and no secret values.
|
||||||
|
Field rules are key-cape's (src/internal/config/validate.go): confidential,
|
||||||
|
env secretRef, serviceSubject and tenant required, no redirect URIs, token
|
||||||
|
lifetime 1m-1h. Replaces per-client scripts such as approval-clients-rollout.py.
|
||||||
|
capabilities:
|
||||||
|
- id: c1.lightweight-sso
|
||||||
|
tier: C1
|
||||||
|
resource_kinds:
|
||||||
|
- identities
|
||||||
|
- roles_scopes_policies
|
||||||
|
description: Register one confidential service client with the running lightweight issuer.
|
||||||
|
parameters:
|
||||||
|
- name: client_id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
constraints:
|
||||||
|
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
|
||||||
|
sensitivity: operational
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: OAuth2 client_id, unique in the registry.
|
||||||
|
- name: display_name
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
constraints:
|
||||||
|
pattern: ^[^\n\r]{1,80}$
|
||||||
|
sensitivity: public
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: Human-readable client name.
|
||||||
|
- name: audience
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
constraints:
|
||||||
|
pattern: ^[a-z0-9][a-z0-9-]{1,62}$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: The single resource server the tokens are for (aud).
|
||||||
|
- name: allowed_scopes
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
constraints:
|
||||||
|
pattern: ^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*(,[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)*$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: Comma-separated resource scopes of the audience; no OIDC identity scopes.
|
||||||
|
- name: service_subject
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
constraints:
|
||||||
|
pattern: ^service:[a-z0-9][a-z0-9-]{1,62}$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: The sub KeyCape puts into the client's tokens.
|
||||||
|
- name: tenant
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
constraints:
|
||||||
|
pattern: ^tenant:[a-z0-9][a-z0-9-]{1,62}$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: The registration-bound tenant claim.
|
||||||
|
- name: roles
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
constraints:
|
||||||
|
pattern: ^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$
|
||||||
|
sensitivity: operational
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: Comma-separated roles carried in the token.
|
||||||
|
- name: token_lifetime
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
constraints:
|
||||||
|
pattern: ^(5|10|15)m$
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
tuning_authority: netkingdom_tunable
|
||||||
|
description: Access-token lifetime; at most 15m (stricter than KeyCape's 1h ceiling).
|
||||||
|
responsibilities:
|
||||||
|
- resource_kind: identities
|
||||||
|
owner: net-kingdom
|
||||||
|
resources:
|
||||||
|
- oidc:static-clients
|
||||||
|
repo_owns: Live registry change for exactly this client, its idempotency and removal; field rules are key-cape's canonical model.
|
||||||
|
netkingdom_orchestrates: Supply the case's validated, non-secret client fields.
|
||||||
|
- resource_kind: roles_scopes_policies
|
||||||
|
owner: key-cape
|
||||||
|
resources:
|
||||||
|
- iam-profile:client-scope-allowlists
|
||||||
|
repo_owns: Issue client_credentials tokens only for the registered scopes, audience, subject and tenant; refuse excess scopes.
|
||||||
|
netkingdom_orchestrates: Choose audience, scopes, subject and lifetime per SecurityCaseType.
|
||||||
|
trust:
|
||||||
|
requires:
|
||||||
|
- state: runtime_secret_trust
|
||||||
|
readiness_checks: []
|
||||||
|
- state: runtime_identity_trust
|
||||||
|
readiness_checks: []
|
||||||
|
satisfies:
|
||||||
|
- state: tenant_onboarding_trust
|
||||||
|
readiness_checks:
|
||||||
|
- id: service-token-claims
|
||||||
|
description: A client_credentials exchange for each allowed scope returns a token with the registered sub, aud, tenant and lifetime.
|
||||||
|
evidence: The consuming service's own exchange, reported as claims only (no secret or token value).
|
||||||
|
- id: service-token-refuses-excess-scope
|
||||||
|
description: A client_credentials exchange for a scope outside allowed_scopes is refused.
|
||||||
|
evidence: KeyCape returns invalid_scope.
|
||||||
|
catalog:
|
||||||
|
publish: capabilities/playbooks/net-kingdom.keycape-service-client-registration.yaml
|
||||||
|
maturity: implemented
|
||||||
|
consumers:
|
||||||
|
- netkingdom-security-scenario-composer
|
||||||
|
- netkingdom-security-case-types
|
||||||
129
security-case-types/oidc-service-client/README.md
Normal file
129
security-case-types/oidc-service-client/README.md
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
# oidc-service-client 0.1
|
||||||
|
|
||||||
|
An internal workload obtains short-lived KeyCape access tokens for one resource server with the `client_credentials`
|
||||||
|
grant. The type covers:
|
||||||
|
|
||||||
|
- the client registration: audience, resource scopes, service subject, tenant, roles and lifetime;
|
||||||
|
- the client secret, minted straight into OpenBao custody and readable only by KeyCape (through ESO) and by the
|
||||||
|
consuming workload (through its own Kubernetes-auth login).
|
||||||
|
|
||||||
|
Canon: `canon/standards/security-case-types_v0.1.md`. Status: **proposed** (2026-10-31): complete, tests green,
|
||||||
|
Decision Memo prepared (`decision/`, hub decision `f8044f81`). Next: `acknowledged` by the founder through
|
||||||
|
informed-decision. Its first case and
|
||||||
|
reference instance is the bounded executor's approval client (NK-WP-0056-T14, `examples/nk-bind-executor-approval.yaml`).
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
KeyCape supports confidential `client_credentials` clients (`src/internal/config/validate.go`: confidential, `env:`
|
||||||
|
secretRef, `serviceSubject` and `tenant` required, no redirect URIs, lifetime 1m–1h). Every such client so far was
|
||||||
|
registered by its own script:
|
||||||
|
|
||||||
|
- `sso-mfa/k8s/keycape/approval-clients-rollout.py` (`secrets-engine-approval`, `approval-engine-operator`);
|
||||||
|
- `secrets-engine-openbao-client-rollout.py`.
|
||||||
|
|
||||||
|
Each script hard-codes its client. The browser type (`oidc-browser-relying-party`) names service clients with a
|
||||||
|
resource audience, tenant and custom scopes as needing "a separate type". This is that type.
|
||||||
|
|
||||||
|
Its first case is driven by a live need. approval-engine accepts `POST /v1/approvals` only when `binding.actor`
|
||||||
|
equals the token's `sub`, so the bounded executor must hold its own client to file the approval it later consumes.
|
||||||
|
|
||||||
|
### Reference instances and fit
|
||||||
|
|
||||||
|
| Client | Fits 0.1 | Note |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| nk-bind-executor-approval (new) | yes | first case |
|
||||||
|
| secrets-engine-approval | yes | adoption would not change its live registration (canon section 9) |
|
||||||
|
| approval-engine-operator | partly | its scope list is wider than any single workload should hold; adopt only after review |
|
||||||
|
|
||||||
|
## Threat model
|
||||||
|
|
||||||
|
**Assets:**
|
||||||
|
|
||||||
|
- the client secret, which lets its holder obtain tokens as `service_subject`;
|
||||||
|
- the tokens, which a resource server treats as the workload's identity: for approval-engine, the right to create
|
||||||
|
approvals with that actor and to consume them.
|
||||||
|
|
||||||
|
**Actors:**
|
||||||
|
|
||||||
|
- an anonymous network caller;
|
||||||
|
- another workload in the same namespace;
|
||||||
|
- a coding agent;
|
||||||
|
- an operator;
|
||||||
|
- a compromised consuming workload.
|
||||||
|
|
||||||
|
**Controls:**
|
||||||
|
|
||||||
|
- The secret is generated inside custody (`mint-secret`, `generated_in_custody`). It is never returned, never written
|
||||||
|
by hand and never in Git.
|
||||||
|
- Only two readers exist: KeyCape through the ESO role, and the consumer through its own role. The consumer role is
|
||||||
|
bound to one ServiceAccount in one namespace with audience `openbao`, and its policy reads one path. No metadata,
|
||||||
|
list, parent or sibling access.
|
||||||
|
- There is no Secret copy in the consumer's namespace. The consumer reads natively per call, so a pod in that
|
||||||
|
namespace without the ServiceAccount has nothing to mount.
|
||||||
|
- Tokens last at most 15m and there is no refresh token.
|
||||||
|
- Scopes are a closed list per case. KeyCape refuses an excess scope (`invalid_scope`), and the type verifies that
|
||||||
|
refusal.
|
||||||
|
- The subject and tenant are fixed by the registration. The workload cannot choose them.
|
||||||
|
- What a compromised consumer can do is bounded by its scopes at one audience. For approval-engine that means:
|
||||||
|
- it can create approvals naming itself as actor, which still need a human entry under `human_control`;
|
||||||
|
- it can consume approvals of its tenant, which APPROVAL-WP-0007-T02 `actor_consumes` narrows.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Keep per-client scripts.** Each repeats the custody, merge and verification logic, and each needs its own
|
||||||
|
attended founder run. This is the pattern the security case canon replaced for browser clients.
|
||||||
|
- **Extend `oidc-browser-relying-party`.** The two shapes differ in grant, required fields, the meaning of `sub`, and
|
||||||
|
how the consumer receives the secret. A combined type would carry parameters that are forbidden in one mode
|
||||||
|
(KeyCape rejects `serviceSubject` on browser clients). It would be a looser validation, which is a major change by
|
||||||
|
canon section 6.
|
||||||
|
- **ESO-deliver the secret into the consumer's namespace** (as the browser type does). Rejected for service clients:
|
||||||
|
- a long-lived Secret copy beside the workload is readable by anything that can mount Secrets there;
|
||||||
|
- the bounded executor's lanes already read natively per call (CCR-2026-0058 and CCR-2026-0059).
|
||||||
|
- **A static bearer instead of a client.** approval-engine expects KeyCape-issued tokens with `aud`, `sub`, tenant and
|
||||||
|
assurance. A stored bearer would bypass expiry and the subject binding.
|
||||||
|
|
||||||
|
## Attended steps
|
||||||
|
|
||||||
|
`bind-env` and `register-client` change the live identity provider: the KeyCape Deployment and its client registry,
|
||||||
|
which is a Kubernetes Secret. Agents do not read or write Secrets. Both steps run in one attended operator session,
|
||||||
|
`bind-env` first, because KeyCape resolves a confidential client's secret at start and fails closed without it. The
|
||||||
|
restart drops in-flight logins for every client, so runs go in a quiet window. `mint-secret` and `deliver-secret`
|
||||||
|
are unattended.
|
||||||
|
|
||||||
|
## Lanes, value handling and gates
|
||||||
|
|
||||||
|
| Step | Lane | Value handling |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| mint-secret | keycape-client-secret-generate (railiance-platform broker) | generated in custody, never returned |
|
||||||
|
| deliver-secret | ESO role `external-secrets-keycape-<client_id>` | none |
|
||||||
|
| bind-env, register-client | key-cape-oidc-login (attended) | none |
|
||||||
|
| hand-over | the consumer's own deployment path | none; the consumer reads through `workload-kv-read-keycape-<client_id>-<consuming_service>` |
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- **One audience per case.** A workload that calls two resource servers holds two cases.
|
||||||
|
- **Scopes are not checked against the audience's own scope list.** The type admits any `resource:verb` shape; the
|
||||||
|
audience owner reviews the case. A stricter check would need the audience to publish its scopes.
|
||||||
|
- **KeyCape's client registry is still a Secret.** Registration stays attended until KeyCape loads clients from a
|
||||||
|
non-secret registry.
|
||||||
|
- **Rotation is mint-secret, then the consumer's next exchange.** KeyCape reads the secret at start, so a rotation
|
||||||
|
also restarts KeyCape. That is not automated.
|
||||||
|
- **Adopting `approval-engine-operator`** needs a scope review first; see the reference table.
|
||||||
|
|
||||||
|
## Verification and rollback
|
||||||
|
|
||||||
|
Verification never reads the secret or a token:
|
||||||
|
|
||||||
|
- the consumer reports the claims of its own exchanges (sub, aud, tenant, scope and lifetime);
|
||||||
|
- an excess scope is refused;
|
||||||
|
- a default identity has no access to custody;
|
||||||
|
- the consumer role admits only its ServiceAccount and audience;
|
||||||
|
- the consumer policy grants read on the one path;
|
||||||
|
- ESO delivery to KeyCape reports Ready.
|
||||||
|
|
||||||
|
Rollback reverses every follow-through step and every materialized item in dependency order: consumer configuration,
|
||||||
|
registration, env binding, delivery, custody, roles before their policies, then the catalog entry.
|
||||||
|
|
||||||
|
## History
|
||||||
|
|
||||||
|
- 0.1 (2026-10-31): first version, for NK-WP-0056-T14; proposed with memo `nk-wp-0056-t14-oidc-service-client-r1`.
|
||||||
105
security-case-types/oidc-service-client/decision/build_intake.py
Normal file
105
security-case-types/oidc-service-client/decision/build_intake.py
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build the informed-decision memo intake envelope that acknowledges oidc-service-client 0.1.
|
||||||
|
|
||||||
|
Writes intake.json beside this file and validates it with informed-decision's own intake validator and State Hub's
|
||||||
|
dispatcher contract, when those checkouts are present beside net-kingdom. The envelope is queued by an operator
|
||||||
|
through the State Hub memo dispatcher (informed-decision deploy/lifecycle/README.md); this script never submits
|
||||||
|
anything.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
HERE = Path(__file__).resolve().parent
|
||||||
|
HUB_DECISION = "f8044f81-f94c-483a-81f9-4f2b02a60d5f"
|
||||||
|
# Each revision needs a new id: the dispatcher refuses new content under a delivered idempotency key.
|
||||||
|
MEMO_ID = "nk-wp-0056-t14-oidc-service-client-r1"
|
||||||
|
ITEM = f"{MEMO_ID}-packet"
|
||||||
|
TARGET = "oidc-service-client@0.1"
|
||||||
|
|
||||||
|
packet = (HERE / "packet.md").read_text()
|
||||||
|
digest = "sha256:" + hashlib.sha256(packet.encode("utf-8")).hexdigest()
|
||||||
|
locator = {"kind": "work_record", "workplan": "NK-WP-0056", "task": "NK-WP-0056-T14"}
|
||||||
|
|
||||||
|
envelope = {
|
||||||
|
"kind": "informed-decision.memo-intake",
|
||||||
|
"version": 1,
|
||||||
|
"idempotency_key": f"state-hub.{HUB_DECISION}.{MEMO_ID}",
|
||||||
|
"memo_class": "decision-record",
|
||||||
|
"review_group": "net-kingdom-admins",
|
||||||
|
"drafted_by": "net-kingdom NK-WP-0056 (claude-code)",
|
||||||
|
"serves": {"hub_decision_id": HUB_DECISION, "resolves": "on-answer"},
|
||||||
|
"approval_intent": {
|
||||||
|
"action": "acknowledge",
|
||||||
|
"principal": "net-kingdom-admins",
|
||||||
|
"purpose": "Admit oidc-service-client 0.1 as a SecurityCaseType (canon security-case-types_v0.1 section 5).",
|
||||||
|
"target": {"id": TARGET, "type": "security-case-type", "system": "net-kingdom"},
|
||||||
|
},
|
||||||
|
"memo": {
|
||||||
|
"id": MEMO_ID,
|
||||||
|
"version": 1,
|
||||||
|
"question": ("Acknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads "
|
||||||
|
"that obtain KeyCape client_credentials tokens for one resource server?"),
|
||||||
|
"requested_act": "approve",
|
||||||
|
"binding_level": "organizational",
|
||||||
|
"brief": (
|
||||||
|
"NK-WP-0056-T14. Acknowledging admits the shape of confidential service clients: one audience, a closed "
|
||||||
|
"list of resource scopes, a fixed service subject and tenant, at most 15m, the secret minted in custody "
|
||||||
|
"and read only by KeyCape (ESO) and the consumer's own bound role. It registers nothing and accepts no "
|
||||||
|
"case; the first case, the bounded executor's approval client, keeps its own acceptance. One attended "
|
||||||
|
"operator session per case remains, because KeyCape's live client registry is a Kubernetes Secret."
|
||||||
|
),
|
||||||
|
"binding": {
|
||||||
|
"justification": None,
|
||||||
|
"principal": {"display_name": "Named reviewer in net-kingdom-admins", "id": "pending-human-session",
|
||||||
|
"identifiers": [], "kind": "person", "role": "reviewer"},
|
||||||
|
"target": {"environment": "prod", "id": TARGET, "kind": "security-case-type",
|
||||||
|
"label": "SecurityCaseType oidc-service-client 0.1", "requires_new_bind": False},
|
||||||
|
"terms": None,
|
||||||
|
},
|
||||||
|
"step_kind": "approve",
|
||||||
|
"packet": [{"item_id": ITEM, "label": ITEM, "hash": digest}],
|
||||||
|
"highlights": [
|
||||||
|
{"id": f"{MEMO_ID}-h1", "item_id": ITEM, "severity": "critical", "required_ack": True,
|
||||||
|
"locator": locator,
|
||||||
|
"note": ("Acknowledging changes nothing live and accepts no case. It decides the shape every future "
|
||||||
|
"service client case will follow, including who may read the client secret.")},
|
||||||
|
{"id": f"{MEMO_ID}-h2", "item_id": ITEM, "severity": "warning", "required_ack": True,
|
||||||
|
"locator": locator,
|
||||||
|
"note": ("The consumer reads the secret natively per call (no Secret copy in its namespace); one "
|
||||||
|
"attended operator session per case remains for bind-env and register-client.")},
|
||||||
|
],
|
||||||
|
"locale": "en",
|
||||||
|
"approval_id": None,
|
||||||
|
"approval_binding_digest": None,
|
||||||
|
"sealed": False,
|
||||||
|
},
|
||||||
|
"packet": [{"item_id": ITEM, "media_type": "text/markdown", "content": packet}],
|
||||||
|
}
|
||||||
|
|
||||||
|
out = HERE / "intake.json"
|
||||||
|
out.write_text(json.dumps(envelope, indent=2, ensure_ascii=False) + "\n")
|
||||||
|
print(f"wrote {out.name}; packet {digest}")
|
||||||
|
|
||||||
|
infd = HERE.parents[3] / "informed-decision"
|
||||||
|
if infd.is_dir():
|
||||||
|
sys.path.insert(0, str(infd))
|
||||||
|
from informed_decision import intake # noqa: E402
|
||||||
|
|
||||||
|
grant = intake.SubmitterGrant(submitter_id="state-hub", memo_classes=frozenset({"decision-record"}),
|
||||||
|
review_groups=frozenset({"net-kingdom-admins"}))
|
||||||
|
validated = intake.validate_submission(envelope, submitter_id="state-hub", grant=grant)
|
||||||
|
print("informed-decision intake validation: PASS", validated.memo.id)
|
||||||
|
else:
|
||||||
|
print("informed-decision checkout not found beside net-kingdom; validation skipped")
|
||||||
|
|
||||||
|
hub = HERE.parents[3] / "state-hub"
|
||||||
|
if hub.is_dir():
|
||||||
|
sys.path.insert(0, str(hub))
|
||||||
|
from api.services import informed_decision_contract as contract # noqa: E402
|
||||||
|
|
||||||
|
contract.validate_envelope(envelope)
|
||||||
|
print("state-hub dispatcher envelope validation: PASS")
|
||||||
|
else:
|
||||||
|
print("state-hub checkout not found beside net-kingdom; dispatcher validation skipped")
|
||||||
93
security-case-types/oidc-service-client/decision/intake.json
Normal file
93
security-case-types/oidc-service-client/decision/intake.json
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
{
|
||||||
|
"kind": "informed-decision.memo-intake",
|
||||||
|
"version": 1,
|
||||||
|
"idempotency_key": "state-hub.f8044f81-f94c-483a-81f9-4f2b02a60d5f.nk-wp-0056-t14-oidc-service-client-r1",
|
||||||
|
"memo_class": "decision-record",
|
||||||
|
"review_group": "net-kingdom-admins",
|
||||||
|
"drafted_by": "net-kingdom NK-WP-0056 (claude-code)",
|
||||||
|
"serves": {
|
||||||
|
"hub_decision_id": "f8044f81-f94c-483a-81f9-4f2b02a60d5f",
|
||||||
|
"resolves": "on-answer"
|
||||||
|
},
|
||||||
|
"approval_intent": {
|
||||||
|
"action": "acknowledge",
|
||||||
|
"principal": "net-kingdom-admins",
|
||||||
|
"purpose": "Admit oidc-service-client 0.1 as a SecurityCaseType (canon security-case-types_v0.1 section 5).",
|
||||||
|
"target": {
|
||||||
|
"id": "oidc-service-client@0.1",
|
||||||
|
"type": "security-case-type",
|
||||||
|
"system": "net-kingdom"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"memo": {
|
||||||
|
"id": "nk-wp-0056-t14-oidc-service-client-r1",
|
||||||
|
"version": 1,
|
||||||
|
"question": "Acknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads that obtain KeyCape client_credentials tokens for one resource server?",
|
||||||
|
"requested_act": "approve",
|
||||||
|
"binding_level": "organizational",
|
||||||
|
"brief": "NK-WP-0056-T14. Acknowledging admits the shape of confidential service clients: one audience, a closed list of resource scopes, a fixed service subject and tenant, at most 15m, the secret minted in custody and read only by KeyCape (ESO) and the consumer's own bound role. It registers nothing and accepts no case; the first case, the bounded executor's approval client, keeps its own acceptance. One attended operator session per case remains, because KeyCape's live client registry is a Kubernetes Secret.",
|
||||||
|
"binding": {
|
||||||
|
"justification": null,
|
||||||
|
"principal": {
|
||||||
|
"display_name": "Named reviewer in net-kingdom-admins",
|
||||||
|
"id": "pending-human-session",
|
||||||
|
"identifiers": [],
|
||||||
|
"kind": "person",
|
||||||
|
"role": "reviewer"
|
||||||
|
},
|
||||||
|
"target": {
|
||||||
|
"environment": "prod",
|
||||||
|
"id": "oidc-service-client@0.1",
|
||||||
|
"kind": "security-case-type",
|
||||||
|
"label": "SecurityCaseType oidc-service-client 0.1",
|
||||||
|
"requires_new_bind": false
|
||||||
|
},
|
||||||
|
"terms": null
|
||||||
|
},
|
||||||
|
"step_kind": "approve",
|
||||||
|
"packet": [
|
||||||
|
{
|
||||||
|
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
|
||||||
|
"label": "nk-wp-0056-t14-oidc-service-client-r1-packet",
|
||||||
|
"hash": "sha256:0ed6b3a56d9e4d67d96e51f9ea0e4ddfaca60dee5db9e46f1680576f007758fe"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"highlights": [
|
||||||
|
{
|
||||||
|
"id": "nk-wp-0056-t14-oidc-service-client-r1-h1",
|
||||||
|
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
|
||||||
|
"severity": "critical",
|
||||||
|
"required_ack": true,
|
||||||
|
"locator": {
|
||||||
|
"kind": "work_record",
|
||||||
|
"workplan": "NK-WP-0056",
|
||||||
|
"task": "NK-WP-0056-T14"
|
||||||
|
},
|
||||||
|
"note": "Acknowledging changes nothing live and accepts no case. It decides the shape every future service client case will follow, including who may read the client secret."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "nk-wp-0056-t14-oidc-service-client-r1-h2",
|
||||||
|
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
|
||||||
|
"severity": "warning",
|
||||||
|
"required_ack": true,
|
||||||
|
"locator": {
|
||||||
|
"kind": "work_record",
|
||||||
|
"workplan": "NK-WP-0056",
|
||||||
|
"task": "NK-WP-0056-T14"
|
||||||
|
},
|
||||||
|
"note": "The consumer reads the secret natively per call (no Secret copy in its namespace); one attended operator session per case remains for bind-env and register-client."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"locale": "en",
|
||||||
|
"approval_id": null,
|
||||||
|
"approval_binding_digest": null,
|
||||||
|
"sealed": false
|
||||||
|
},
|
||||||
|
"packet": [
|
||||||
|
{
|
||||||
|
"item_id": "nk-wp-0056-t14-oidc-service-client-r1-packet",
|
||||||
|
"media_type": "text/markdown",
|
||||||
|
"content": "# Acknowledge SecurityCaseType oidc-service-client 0.1\n\nWorkplan: NK-WP-0056 (net-kingdom), task NK-WP-0056-T14\nHub decision: f8044f81-f94c-483a-81f9-4f2b02a60d5f\nCanon: canon/standards/security-case-types_v0.1.md (accepted 2026-10-08), section 5\nType: security-case-types/oidc-service-client/ (type.yaml, README.md, tests)\nFirst case: examples/nk-bind-executor-approval.yaml\n\n## Question\n\nAcknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads that obtain KeyCape\nclient_credentials tokens for one resource server?\n\n## One act\n\nMove the type from `proposed` to `acknowledged`. From then on, a confidential service client is a SecurityCase of\nthis type:\n\n- its owner fills in the parameters (audience, resource scopes, service subject, tenant, lifetime of at most 15m,\n consuming ServiceAccount);\n- the case is accepted by the owner and the founder (risk class medium);\n- ops-mason materializes it, and ops-warden follows through.\n\nThe type becomes `implemented` only after its first case is live and verified. That case is the bounded executor's\napproval client `nk-bind-executor-approval`.\n\n## Why now\n\napproval-engine accepts `POST /v1/approvals` only when `binding.actor` equals the token's `sub`. The bounded\nexecutor (NK-WP-0056) must therefore file its own approval as `service:nk-bind-executor`, which needs its own KeyCape\nclient. Every existing service client was registered by a one-off script. The browser type names service clients\nas needing \"a separate type\".\n\n## What acknowledging does not do\n\n- It registers no client, mints no secret, and changes nothing live.\n- It accepts no case. `nk-bind-executor-approval` keeps its own acceptance.\n- It does not change `oidc-browser-relying-party`.\n\n## Shape (decided here, then reused by every case)\n\n- **Grant:** `client_credentials` only. Confidential, an `env:` secretRef, `serviceSubject` and `tenant`, no\n redirect URIs. These are KeyCape's own rules. The type is stricter: a closed list of resource scopes, and at most\n 15m.\n- **Custody:** `platform/workloads/keycape/<client_id>`, minted inside custody by the railiance-platform broker and\n never returned. Exactly two readers:\n - KeyCape, through ESO;\n - the consuming workload, through its own Kubernetes-auth role (one ServiceAccount, one namespace, audience\n `openbao`, 15m, one policy).\n- **No Secret copy beside the consumer.** It reads per call, as the bounded executor's lanes already do\n (CCR-2026-0058 and CCR-2026-0059).\n- **Verification reads no value:**\n - the consumer reports claims (sub, aud, tenant, scope and lifetime);\n - an excess scope is refused;\n - custody is denied to a default identity;\n - the consumer role admits only its ServiceAccount and audience.\n- **Rollback** covers every step and every materialized item.\n\n## Attended steps\n\nOne attended operator session per case remains (`bind-env`, then `register-client`), because KeyCape's live client\nregistry is a Kubernetes Secret and KeyCape reads the client secret at start. Removing it needs a KeyCape change and\na new type version.\n\n## Alternatives considered\n\n- **Keep per-client scripts:** repeats custody and verification per client.\n- **Extend the browser type:** a looser validation and a major change. KeyCape rejects `serviceSubject` on browser\n clients.\n- **ESO-deliver into the consumer's namespace:** leaves a long-lived Secret copy beside the workload.\n- **A static bearer:** bypasses expiry and the subject binding.\n\n## Reviews requested before you answer\n\n- ops-mason: the materialization section;\n- ops-warden: the follow-through and its lanes;\n- key-cape: the field rules;\n- railiance-platform: the mint lane and the consumer role.\n\nIf a review changes the type, a new memo revision replaces this one.\n"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
80
security-case-types/oidc-service-client/decision/packet.md
Normal file
80
security-case-types/oidc-service-client/decision/packet.md
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
# Acknowledge SecurityCaseType oidc-service-client 0.1
|
||||||
|
|
||||||
|
Workplan: NK-WP-0056 (net-kingdom), task NK-WP-0056-T14
|
||||||
|
Hub decision: f8044f81-f94c-483a-81f9-4f2b02a60d5f
|
||||||
|
Canon: canon/standards/security-case-types_v0.1.md (accepted 2026-10-08), section 5
|
||||||
|
Type: security-case-types/oidc-service-client/ (type.yaml, README.md, tests)
|
||||||
|
First case: examples/nk-bind-executor-approval.yaml
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
Acknowledge oidc-service-client 0.1 as the reusable SecurityCaseType for internal workloads that obtain KeyCape
|
||||||
|
client_credentials tokens for one resource server?
|
||||||
|
|
||||||
|
## One act
|
||||||
|
|
||||||
|
Move the type from `proposed` to `acknowledged`. From then on, a confidential service client is a SecurityCase of
|
||||||
|
this type:
|
||||||
|
|
||||||
|
- its owner fills in the parameters (audience, resource scopes, service subject, tenant, lifetime of at most 15m,
|
||||||
|
consuming ServiceAccount);
|
||||||
|
- the case is accepted by the owner and the founder (risk class medium);
|
||||||
|
- ops-mason materializes it, and ops-warden follows through.
|
||||||
|
|
||||||
|
The type becomes `implemented` only after its first case is live and verified. That case is the bounded executor's
|
||||||
|
approval client `nk-bind-executor-approval`.
|
||||||
|
|
||||||
|
## Why now
|
||||||
|
|
||||||
|
approval-engine accepts `POST /v1/approvals` only when `binding.actor` equals the token's `sub`. The bounded
|
||||||
|
executor (NK-WP-0056) must therefore file its own approval as `service:nk-bind-executor`, which needs its own KeyCape
|
||||||
|
client. Every existing service client was registered by a one-off script. The browser type names service clients
|
||||||
|
as needing "a separate type".
|
||||||
|
|
||||||
|
## What acknowledging does not do
|
||||||
|
|
||||||
|
- It registers no client, mints no secret, and changes nothing live.
|
||||||
|
- It accepts no case. `nk-bind-executor-approval` keeps its own acceptance.
|
||||||
|
- It does not change `oidc-browser-relying-party`.
|
||||||
|
|
||||||
|
## Shape (decided here, then reused by every case)
|
||||||
|
|
||||||
|
- **Grant:** `client_credentials` only. Confidential, an `env:` secretRef, `serviceSubject` and `tenant`, no
|
||||||
|
redirect URIs. These are KeyCape's own rules. The type is stricter: a closed list of resource scopes, and at most
|
||||||
|
15m.
|
||||||
|
- **Custody:** `platform/workloads/keycape/<client_id>`, minted inside custody by the railiance-platform broker and
|
||||||
|
never returned. Exactly two readers:
|
||||||
|
- KeyCape, through ESO;
|
||||||
|
- the consuming workload, through its own Kubernetes-auth role (one ServiceAccount, one namespace, audience
|
||||||
|
`openbao`, 15m, one policy).
|
||||||
|
- **No Secret copy beside the consumer.** It reads per call, as the bounded executor's lanes already do
|
||||||
|
(CCR-2026-0058 and CCR-2026-0059).
|
||||||
|
- **Verification reads no value:**
|
||||||
|
- the consumer reports claims (sub, aud, tenant, scope and lifetime);
|
||||||
|
- an excess scope is refused;
|
||||||
|
- custody is denied to a default identity;
|
||||||
|
- the consumer role admits only its ServiceAccount and audience.
|
||||||
|
- **Rollback** covers every step and every materialized item.
|
||||||
|
|
||||||
|
## Attended steps
|
||||||
|
|
||||||
|
One attended operator session per case remains (`bind-env`, then `register-client`), because KeyCape's live client
|
||||||
|
registry is a Kubernetes Secret and KeyCape reads the client secret at start. Removing it needs a KeyCape change and
|
||||||
|
a new type version.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Keep per-client scripts:** repeats custody and verification per client.
|
||||||
|
- **Extend the browser type:** a looser validation and a major change. KeyCape rejects `serviceSubject` on browser
|
||||||
|
clients.
|
||||||
|
- **ESO-deliver into the consumer's namespace:** leaves a long-lived Secret copy beside the workload.
|
||||||
|
- **A static bearer:** bypasses expiry and the subject binding.
|
||||||
|
|
||||||
|
## Reviews requested before you answer
|
||||||
|
|
||||||
|
- ops-mason: the materialization section;
|
||||||
|
- ops-warden: the follow-through and its lanes;
|
||||||
|
- key-cape: the field rules;
|
||||||
|
- railiance-platform: the mint lane and the consumer role.
|
||||||
|
|
||||||
|
If a review changes the type, a new memo revision replaces this one.
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# First case of oidc-service-client (NK-WP-0056-T14): the bounded executor's approval client.
|
||||||
|
# approval-engine accepts POST /v1/approvals only when binding.actor equals the token's sub, so the
|
||||||
|
# executor files its own approval request as service:nk-bind-executor, then reads and consumes it.
|
||||||
|
# Scopes follow approval-engine docs/bounded-execution-admission-carrier.md section 3.
|
||||||
|
apiVersion: netkingdom.io/security-case/v0.1
|
||||||
|
kind: SecurityCase
|
||||||
|
spec:
|
||||||
|
id: nk-bind-executor-approval
|
||||||
|
type: oidc-service-client@0.1
|
||||||
|
status: proposed
|
||||||
|
owner:
|
||||||
|
repo: net-kingdom
|
||||||
|
person: founder
|
||||||
|
parameters:
|
||||||
|
client_id: nk-bind-executor-approval
|
||||||
|
display_name: NetKingdom bounded executor (approval-engine)
|
||||||
|
audience: approval-engine
|
||||||
|
allowed_scopes: approval:create,approval:read,approval:consume
|
||||||
|
service_subject: service:nk-bind-executor
|
||||||
|
tenant: tenant:platform
|
||||||
|
roles: bounded-executor
|
||||||
|
token_lifetime: 15m
|
||||||
|
consuming_service: bounded-executor
|
||||||
|
consumer_namespace: sso
|
||||||
|
consumer_service_account: nk-bind-executor
|
||||||
|
consumer_owner_repo: net-kingdom
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""Type-specific tests for oidc-service-client 0.1 (NK-WP-0056-T14)."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
TYPE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
REPO = TYPE_DIR.parents[1]
|
||||||
|
SPEC = importlib.util.spec_from_file_location(
|
||||||
|
"security_case_validator", REPO / "tools" / "security-case-types" / "security_case_validator.py")
|
||||||
|
v = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = v
|
||||||
|
SPEC.loader.exec_module(v)
|
||||||
|
KC_SPEC = importlib.util.spec_from_file_location("keycape_client", REPO / "sso-mfa/k8s/keycape/keycape-client.py")
|
||||||
|
kc = importlib.util.module_from_spec(KC_SPEC)
|
||||||
|
KC_SPEC.loader.exec_module(kc)
|
||||||
|
|
||||||
|
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
|
||||||
|
EXECUTOR = yaml.safe_load((TYPE_DIR / "examples" / "nk-bind-executor-approval.yaml").read_text())
|
||||||
|
DECL_PATHS, _ = v.discover_declarations(TYPE, REPO.parent)
|
||||||
|
DECLS, _ = v.composer.load_declarations(DECL_PATHS)
|
||||||
|
|
||||||
|
|
||||||
|
def msgs(issues):
|
||||||
|
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
|
||||||
|
|
||||||
|
|
||||||
|
def as_implemented():
|
||||||
|
t = copy.deepcopy(TYPE)
|
||||||
|
t["spec"]["status"] = "implemented"
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def with_params(case, **changes):
|
||||||
|
c = copy.deepcopy(case)
|
||||||
|
for k, val in changes.items():
|
||||||
|
if val is None:
|
||||||
|
c["spec"]["parameters"].pop(k, None)
|
||||||
|
else:
|
||||||
|
c["spec"]["parameters"][k] = val
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
def render(case=EXECUTOR):
|
||||||
|
issues, r = v.render_case(case, as_implemented(), DECLS)
|
||||||
|
assert msgs(issues) == []
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def test_executor_case_renders_custody_and_one_attended_session():
|
||||||
|
r = render()
|
||||||
|
order = [s["id"] for s in r["follow_through"]]
|
||||||
|
assert order == ["mint-secret", "deliver-secret", "bind-env", "register-client", "hand-over"]
|
||||||
|
attended = [s for s in r["follow_through"] if s.get("attended")]
|
||||||
|
assert [s["id"] for s in attended] == ["bind-env", "register-client"]
|
||||||
|
assert {s["lane"] for s in attended} == {"key-cape-oidc-login"}
|
||||||
|
steps = {s["id"]: s for s in r["follow_through"]}
|
||||||
|
assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/keycape/nk-bind-executor-approval")
|
||||||
|
assert "KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET" in steps["bind-env"]["entry_point"]
|
||||||
|
assert steps["hand-over"]["owner"] == "net-kingdom"
|
||||||
|
assert r["execution"] == {"permitted": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_consumer_reads_natively_with_its_own_bound_role():
|
||||||
|
items = {m["id"]: m for m in render()["materialization"]}
|
||||||
|
role = yaml.safe_load(items["consumer-reader-role"]["body"])
|
||||||
|
assert items["consumer-reader-role"]["template"] == "workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor"
|
||||||
|
assert role["bound_service_account_names"] == ["nk-bind-executor"]
|
||||||
|
assert role["bound_service_account_namespaces"] == ["sso"]
|
||||||
|
assert role["audience"] == "openbao"
|
||||||
|
assert role["token_policies"] == ["workload-kv-read-keycape-nk-bind-executor-approval-bounded-executor"]
|
||||||
|
assert role["token_no_default_policy"] is True and role["token_ttl"] == "15m"
|
||||||
|
policy = items["consumer-reader-policy"]["body"]
|
||||||
|
assert 'path "platform/data/workloads/keycape/nk-bind-executor-approval"' in policy
|
||||||
|
assert "metadata" not in policy and "list" not in policy and "*" not in policy
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_secret_copy_is_delivered_to_the_consumer():
|
||||||
|
r = render()
|
||||||
|
deliveries = [s for s in r["follow_through"] if "ExternalSecret" in s["entry_point"]]
|
||||||
|
assert [s["entry_point"].split()[1] for s in deliveries] == ["sso/keycape-nk-bind-executor-approval-client"]
|
||||||
|
assert not any("eso" in m["id"] and "consumer" in m["id"] for m in r["materialization"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_custody_path_is_derived_not_case_settable():
|
||||||
|
assert not [p for p in TYPE["spec"]["parameters"] if "path" in p["name"] or p["name"] == "secret_ref"]
|
||||||
|
issues = v.validate_case(with_params(EXECUTOR, secret_ref="env:OTHER"), as_implemented())
|
||||||
|
assert msgs(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_refusals():
|
||||||
|
for change in [dict(allowed_scopes="openid,approval:read"), dict(token_lifetime="1h"),
|
||||||
|
dict(service_subject="nk-bind-executor"), dict(tenant="platform"), dict(audience=None),
|
||||||
|
dict(consumer_service_account=None)]:
|
||||||
|
assert msgs(v.validate_case(with_params(EXECUTOR, **change), as_implemented())), change
|
||||||
|
|
||||||
|
|
||||||
|
def test_verification_reads_no_value_and_has_negative_checks():
|
||||||
|
checks = render()["verification"]
|
||||||
|
assert all(c["reads_secret_value"] is False for c in checks)
|
||||||
|
assert {c["id"] for c in checks if c["expect"] == "deny"} == {
|
||||||
|
"excess-scope-refused", "custody-denied-to-default", "consumer-login-bound"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_keycape_record_from_the_case():
|
||||||
|
record = kc.client_from_case(EXECUTOR)
|
||||||
|
assert record["grantTypes"] == ["client_credentials"] and record["clientType"] == "confidential"
|
||||||
|
assert record["serviceSubject"] == "service:nk-bind-executor" and record["audience"] == "approval-engine"
|
||||||
|
assert record["allowedScopes"] == ["approval:create", "approval:read", "approval:consume"]
|
||||||
|
assert "redirectUris" not in record and record["tokenLifetime"] == "15m"
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_step_and_item_has_a_rollback():
|
||||||
|
reversed_ids = {r["reverses"] for r in TYPE["spec"]["rollback"]}
|
||||||
|
assert {s["id"] for s in TYPE["spec"]["follow_through"]} <= reversed_ids
|
||||||
|
assert {m["id"] for m in TYPE["spec"]["materialization"]} <= reversed_ids
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Tests every SecurityCaseType carries: the type validates, its example cases
|
||||||
|
validate against it, and each example renders into a composer plan.
|
||||||
|
|
||||||
|
Copied with the template; keep it and add type-specific tests beside it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
TYPE_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
REPO = TYPE_DIR.parents[1]
|
||||||
|
VALIDATOR = REPO / "tools" / "security-case-types" / "security_case_validator.py"
|
||||||
|
SPEC = importlib.util.spec_from_file_location("security_case_validator", VALIDATOR)
|
||||||
|
validator = importlib.util.module_from_spec(SPEC)
|
||||||
|
sys.modules[SPEC.name] = validator
|
||||||
|
SPEC.loader.exec_module(validator)
|
||||||
|
|
||||||
|
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
|
||||||
|
EXAMPLES = sorted((TYPE_DIR / "examples").glob("*.yaml"))
|
||||||
|
# Provider declarations live in their owning repositories, checked out side by
|
||||||
|
# side with net-kingdom (the contract's publication path).
|
||||||
|
DECLARATIONS, DISCOVERY_ISSUES = validator.discover_declarations(TYPE, REPO.parent)
|
||||||
|
# Until the type is implemented, examples are checked as if it were.
|
||||||
|
IMPLEMENTED = TYPE["spec"]["status"] == "implemented"
|
||||||
|
|
||||||
|
|
||||||
|
def errors(issues):
|
||||||
|
return [f"{i.path}: {i.message}" for i in issues if i.level == "ERROR"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_is_complete():
|
||||||
|
assert errors(validator.validate_type(TYPE, TYPE_DIR)) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_examples_exist_and_validate():
|
||||||
|
assert EXAMPLES
|
||||||
|
for path in EXAMPLES:
|
||||||
|
case = yaml.safe_load(path.read_text())
|
||||||
|
assert errors(validator.validate_case(case, TYPE, require_implemented=IMPLEMENTED)) == [], path.name
|
||||||
|
|
||||||
|
|
||||||
|
def test_examples_render_deterministically():
|
||||||
|
assert errors(DISCOVERY_ISSUES) == [], "provider declarations must be checked out beside net-kingdom"
|
||||||
|
declarations, load_issues = validator.composer.load_declarations(DECLARATIONS)
|
||||||
|
assert errors(load_issues) == []
|
||||||
|
for path in EXAMPLES:
|
||||||
|
case = yaml.safe_load(path.read_text())
|
||||||
|
issues, first = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
|
||||||
|
assert errors(issues) == [], path.name
|
||||||
|
_, second = validator.render_case(case, TYPE, declarations, require_implemented=IMPLEMENTED)
|
||||||
|
assert first["digest"] == second["digest"]
|
||||||
|
assert first["execution"] == {"permitted": False}
|
||||||
|
assert first["composition"]["execution"]["permitted"] is False
|
||||||
301
security-case-types/oidc-service-client/type.yaml
Normal file
301
security-case-types/oidc-service-client/type.yaml
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
apiVersion: netkingdom.io/security-case-type/v0.1
|
||||||
|
kind: SecurityCaseType
|
||||||
|
spec:
|
||||||
|
id: oidc-service-client
|
||||||
|
version: "0.1"
|
||||||
|
status: proposed
|
||||||
|
title: "Confidential OIDC service client (client_credentials) for an internal workload"
|
||||||
|
purpose: >-
|
||||||
|
Let an internal workload obtain short-lived KeyCape access tokens for one
|
||||||
|
resource server (client_credentials): an exact audience, a closed list of
|
||||||
|
resource scopes, a fixed service subject and tenant, and a client secret
|
||||||
|
minted straight into OpenBao custody, readable only by KeyCape (through ESO)
|
||||||
|
and by the consuming workload (through its own Kubernetes-auth login).
|
||||||
|
Replaces per-client rollout scripts such as approval-clients-rollout.py.
|
||||||
|
First case: the bounded executor's approval client (NK-WP-0056-T14).
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
- name: client_id
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z0-9][a-z0-9-]{1,62}$"}
|
||||||
|
sensitivity: operational
|
||||||
|
description: OAuth2 client_id, unique in KeyCape.
|
||||||
|
- name: display_name
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[^\\n\\r]{1,80}$"}
|
||||||
|
sensitivity: public
|
||||||
|
description: Human-readable client name.
|
||||||
|
- name: audience
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z0-9][a-z0-9-]{1,62}$"}
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: >-
|
||||||
|
The one resource server the tokens are for (the aud claim). One client per
|
||||||
|
audience: a workload that calls two resource servers holds two cases.
|
||||||
|
- name: allowed_scopes
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*(,[a-z][a-z0-9-]*:[a-z][a-z0-9-]*)*$"}
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: >-
|
||||||
|
Comma-separated resource scopes of the audience (for example
|
||||||
|
approval:read). OIDC identity scopes (openid, profile, email, groups) are
|
||||||
|
not admitted: a service token carries no directory user.
|
||||||
|
- name: service_subject
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^service:[a-z0-9][a-z0-9-]{1,62}$"}
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: >-
|
||||||
|
The sub KeyCape writes into every token of this client. Resource servers
|
||||||
|
compare it exactly (approval-engine: binding.actor must equal it), so it
|
||||||
|
is the workload's identity at the resource server.
|
||||||
|
- name: tenant
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^tenant:[a-z0-9][a-z0-9-]{1,62}$"}
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: The registration-bound tenant claim.
|
||||||
|
- name: roles
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$"}
|
||||||
|
sensitivity: operational
|
||||||
|
description: Comma-separated roles carried in the token, if the resource server reads them.
|
||||||
|
- name: token_lifetime
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {enum: ["5m", "10m", "15m"]}
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: Access-token lifetime, at most 15m. There is no refresh token; the workload exchanges again.
|
||||||
|
- name: consuming_service
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"}
|
||||||
|
sensitivity: operational
|
||||||
|
description: The workload that uses the client.
|
||||||
|
- name: consumer_namespace
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z0-9][a-z0-9-]{0,62}$"}
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: Namespace of the consuming workload's ServiceAccount.
|
||||||
|
- name: consumer_service_account
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z0-9][a-z0-9-]{0,62}$"}
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: The ServiceAccount whose openbao-audience token may read the client secret.
|
||||||
|
- name: consumer_owner_repo
|
||||||
|
type: string
|
||||||
|
case_settable: true
|
||||||
|
constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"}
|
||||||
|
sensitivity: operational
|
||||||
|
description: The repository that owns the consuming workload.
|
||||||
|
- name: issuer
|
||||||
|
type: string
|
||||||
|
case_settable: false
|
||||||
|
default: https://kc.coulomb.social
|
||||||
|
sensitivity: security_sensitive
|
||||||
|
description: The NetKingdom issuer; fixed by the type.
|
||||||
|
|
||||||
|
composition:
|
||||||
|
authority: netkingdom
|
||||||
|
initial_trust: [runtime_secret_trust, runtime_identity_trust]
|
||||||
|
capabilities: [c1.lightweight-sso]
|
||||||
|
providers:
|
||||||
|
c1.lightweight-sso: net-kingdom.keycape-service-client-registration
|
||||||
|
parameter_bindings:
|
||||||
|
net-kingdom.keycape-service-client-registration.client_id: client_id
|
||||||
|
net-kingdom.keycape-service-client-registration.display_name: display_name
|
||||||
|
net-kingdom.keycape-service-client-registration.audience: audience
|
||||||
|
net-kingdom.keycape-service-client-registration.allowed_scopes: allowed_scopes
|
||||||
|
net-kingdom.keycape-service-client-registration.service_subject: service_subject
|
||||||
|
net-kingdom.keycape-service-client-registration.tenant: tenant
|
||||||
|
net-kingdom.keycape-service-client-registration.token_lifetime: token_lifetime
|
||||||
|
|
||||||
|
materialization:
|
||||||
|
# Custody follows oidc-browser-relying-party (platform/workloads/keycape/<client_id>,
|
||||||
|
# KeyCape reads through ESO). The consumer reads natively with its own short
|
||||||
|
# Kubernetes-auth role, as the bounded executor's lanes do (CCR-2026-0058/0059),
|
||||||
|
# so no second Secret copy exists in the consumer's namespace.
|
||||||
|
- id: client-secret-path
|
||||||
|
kind: kv_path
|
||||||
|
template: "platform/workloads/keycape/{client_id}"
|
||||||
|
body: "field CLIENT_SECRET; minted by the credential broker, never written by hand"
|
||||||
|
- id: keycape-reader-policy
|
||||||
|
kind: openbao_policy
|
||||||
|
template: "workload-kv-read-keycape-{client_id}-eso"
|
||||||
|
body: |
|
||||||
|
path "platform/data/workloads/keycape/{client_id}" {{
|
||||||
|
capabilities = ["read"]
|
||||||
|
}}
|
||||||
|
description: KeyCape reads its client secret only through ESO. No metadata, list, parent or sibling access.
|
||||||
|
- id: eso-role
|
||||||
|
kind: openbao_role
|
||||||
|
template: "external-secrets-keycape-{client_id}"
|
||||||
|
body: |
|
||||||
|
auth: kubernetes
|
||||||
|
bound_service_account_names: [external-secrets]
|
||||||
|
bound_service_account_namespaces: [external-secrets]
|
||||||
|
token_policies: [workload-kv-read-keycape-{client_id}-eso]
|
||||||
|
token_no_default_policy: true
|
||||||
|
token_ttl: 15m
|
||||||
|
token_max_ttl: 15m
|
||||||
|
description: The Kubernetes-auth role deliver-secret uses; bound to the ESO service account only.
|
||||||
|
- id: consumer-reader-policy
|
||||||
|
kind: openbao_policy
|
||||||
|
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
|
||||||
|
body: |
|
||||||
|
path "platform/data/workloads/keycape/{client_id}" {{
|
||||||
|
capabilities = ["read"]
|
||||||
|
}}
|
||||||
|
path "auth/token/lookup-self" {{
|
||||||
|
capabilities = ["read"]
|
||||||
|
}}
|
||||||
|
path "auth/token/revoke-self" {{
|
||||||
|
capabilities = ["update"]
|
||||||
|
}}
|
||||||
|
description: The consuming workload reads the same secret. No metadata, list, parent or sibling access.
|
||||||
|
- id: consumer-reader-role
|
||||||
|
kind: openbao_role
|
||||||
|
template: "workload-kv-read-keycape-{client_id}-{consuming_service}"
|
||||||
|
body: |
|
||||||
|
auth: kubernetes
|
||||||
|
bound_service_account_names: [{consumer_service_account}]
|
||||||
|
bound_service_account_namespaces: [{consumer_namespace}]
|
||||||
|
audience: openbao
|
||||||
|
token_policies: [workload-kv-read-keycape-{client_id}-{consuming_service}]
|
||||||
|
token_no_default_policy: true
|
||||||
|
token_ttl: 15m
|
||||||
|
token_max_ttl: 15m
|
||||||
|
token_explicit_max_ttl: 15m
|
||||||
|
description: >-
|
||||||
|
Native login for the consuming workload, one role for this lane only
|
||||||
|
(railiance-platform: policies are never combined in one role).
|
||||||
|
- id: catalog-entry
|
||||||
|
kind: catalog_entry
|
||||||
|
template: "{consuming_service}-keycape-{client_id}"
|
||||||
|
body: |
|
||||||
|
id: {consuming_service}-keycape-{client_id}
|
||||||
|
owner_repo: key-cape
|
||||||
|
warden_executes: false
|
||||||
|
risk: high
|
||||||
|
status: active once verification passes
|
||||||
|
workload_ref: applicable, name {consuming_service}, declaration_ref of the consumer's published declaration or the explicit unknown form, never inferred
|
||||||
|
path_template: platform/workloads/keycape/{client_id}
|
||||||
|
field: CLIENT_SECRET
|
||||||
|
delegation: native, intended_owner key-cape
|
||||||
|
rotation: mint-secret then verification; automatable false until a run has been proven
|
||||||
|
description: One entry per case; carries the consumer's workload reference for zone selection (ADR-0009).
|
||||||
|
|
||||||
|
follow_through:
|
||||||
|
- id: mint-secret
|
||||||
|
owner: railiance-platform
|
||||||
|
entry_point: "railiance-platform credential broker: generate a client secret inside custody at bao:platform/workloads/keycape/{client_id}"
|
||||||
|
auth: "the caller's broker grant for the case's KV path, through its lane; the value is generated in custody and never returned"
|
||||||
|
lane: keycape-client-secret-generate
|
||||||
|
warden_executes: true
|
||||||
|
attended: false
|
||||||
|
value_handling: generated_in_custody
|
||||||
|
- id: deliver-secret
|
||||||
|
owner: railiance-platform
|
||||||
|
entry_point: "ExternalSecret sso/keycape-{client_id}-client (key client-secret) from bao:platform/workloads/keycape/{client_id}"
|
||||||
|
auth: "railiance-platform ESO delivery through role external-secrets-keycape-{client_id}; no value leaves custody"
|
||||||
|
warden_executes: false
|
||||||
|
attended: false
|
||||||
|
value_handling: none
|
||||||
|
ready_check: "the delivery for sso/keycape-{client_id}-client reports Ready=True (status only, no value read)"
|
||||||
|
- id: bind-env
|
||||||
|
owner: net-kingdom
|
||||||
|
entry_point: "patch Deployment sso/keycape: env KEYCAPE_{client_id:env}_CLIENT_SECRET from sso/keycape-{client_id}-client"
|
||||||
|
auth: "operator kube credentials for namespace sso"
|
||||||
|
lane: key-cape-oidc-login
|
||||||
|
warden_executes: false
|
||||||
|
attended: true
|
||||||
|
value_handling: none
|
||||||
|
attended_justification: >-
|
||||||
|
A change to the live identity provider's Deployment. It runs in the same
|
||||||
|
attended operator session as register-client, before it, because KeyCape
|
||||||
|
resolves a confidential client's secret at start and fails closed if the
|
||||||
|
binding is missing.
|
||||||
|
- id: register-client
|
||||||
|
owner: net-kingdom
|
||||||
|
entry_point: "sso-mfa/k8s/keycape/keycape-client.py apply --client-file <client-from-case> --case-acceptance <record> --check-config <keycape binary> (net-kingdom.keycape-service-client-registration)"
|
||||||
|
auth: "operator write access to the live KeyCape client registry in namespace sso"
|
||||||
|
lane: key-cape-oidc-login
|
||||||
|
warden_executes: false
|
||||||
|
attended: true
|
||||||
|
value_handling: none
|
||||||
|
attended_justification: >-
|
||||||
|
The live KeyCape client registry is a Kubernetes Secret, and agents do not
|
||||||
|
read or write Secrets. One attended operator session applies the rendered
|
||||||
|
registration together with bind-env. KeyCape loads clients at startup, so
|
||||||
|
the restart drops in-flight logins for every client; runs go in a quiet
|
||||||
|
window. The rendered registry must pass KeyCape's own startup checks
|
||||||
|
(keycape check-config) before the restart.
|
||||||
|
- id: hand-over
|
||||||
|
owner: "{consumer_owner_repo}"
|
||||||
|
entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id}, audience {audience} and its OpenBao role workload-kv-read-keycape-{client_id}-{consuming_service}; it exchanges a fresh token per call and keeps neither the secret nor a token beyond the call"
|
||||||
|
auth: "the consumer owner's own deployment path"
|
||||||
|
warden_executes: false
|
||||||
|
attended: false
|
||||||
|
value_handling: none
|
||||||
|
|
||||||
|
verification:
|
||||||
|
- id: service-token-claims
|
||||||
|
check: "{consuming_service} performs one client_credentials exchange per scope in {allowed_scopes} through its own lane and reports the token's sub, aud, tenant, scope and lifetime; expected sub {service_subject}, aud {audience}, tenant {tenant}, lifetime at most {token_lifetime} (claims only; the verifier reads no secret and no token)"
|
||||||
|
expect: present
|
||||||
|
reads_secret_value: false
|
||||||
|
- id: excess-scope-refused
|
||||||
|
check: "{consuming_service} requests a scope outside {allowed_scopes} for client {client_id}; KeyCape returns invalid_scope"
|
||||||
|
expect: deny
|
||||||
|
reads_secret_value: false
|
||||||
|
- id: custody-denied-to-default
|
||||||
|
check: "a default-only OpenBao identity has no read capability on bao:platform/workloads/keycape/{client_id}"
|
||||||
|
expect: deny
|
||||||
|
reads_secret_value: false
|
||||||
|
- id: consumer-login-bound
|
||||||
|
check: "role workload-kv-read-keycape-{client_id}-{consuming_service} admits only {consumer_namespace}/{consumer_service_account} with audience openbao; another ServiceAccount and a wrong audience are denied (capabilities and login result only)"
|
||||||
|
expect: deny
|
||||||
|
reads_secret_value: false
|
||||||
|
- id: custody-allowed-to-consumer
|
||||||
|
check: "policy workload-kv-read-keycape-{client_id}-{consuming_service} grants read on bao:platform/workloads/keycape/{client_id} and nothing else (capabilities check only)"
|
||||||
|
expect: allow
|
||||||
|
reads_secret_value: false
|
||||||
|
- id: secret-delivered
|
||||||
|
check: "the delivery for sso/keycape-{client_id}-client reports Ready=True (status only)"
|
||||||
|
expect: present
|
||||||
|
reads_secret_value: false
|
||||||
|
|
||||||
|
rollback:
|
||||||
|
- reverses: hand-over
|
||||||
|
action: "remove client {client_id} from {consuming_service}'s configuration (owner {consumer_owner_repo})"
|
||||||
|
- reverses: register-client
|
||||||
|
action: "keycape-client.py apply --operation remove for {client_id}"
|
||||||
|
- reverses: bind-env
|
||||||
|
action: "remove env KEYCAPE_{client_id:env}_CLIENT_SECRET from Deployment sso/keycape, after register-client is reversed"
|
||||||
|
- reverses: deliver-secret
|
||||||
|
action: "delete ExternalSecret sso/keycape-{client_id}-client and its target"
|
||||||
|
- reverses: mint-secret
|
||||||
|
action: "covered by client-secret-path: one metadata delete, after deliver-secret is reversed"
|
||||||
|
- reverses: client-secret-path
|
||||||
|
action: "delete metadata and all versions at platform/workloads/keycape/{client_id}, after the ExternalSecret is gone (this also reverses mint-secret)"
|
||||||
|
- reverses: eso-role
|
||||||
|
action: "delete Kubernetes-auth role external-secrets-keycape-{client_id}"
|
||||||
|
- reverses: keycape-reader-policy
|
||||||
|
action: "delete policy workload-kv-read-keycape-{client_id}-eso, after eso-role"
|
||||||
|
- reverses: consumer-reader-role
|
||||||
|
action: "delete Kubernetes-auth role workload-kv-read-keycape-{client_id}-{consuming_service}"
|
||||||
|
- reverses: consumer-reader-policy
|
||||||
|
action: "delete policy workload-kv-read-keycape-{client_id}-{consuming_service}, after consumer-reader-role"
|
||||||
|
- reverses: catalog-entry
|
||||||
|
action: "remove catalog entry {consuming_service}-keycape-{client_id}"
|
||||||
|
|
||||||
|
acceptance:
|
||||||
|
accepted_by: [owner, founder]
|
||||||
|
risk_class: medium
|
||||||
|
|
@ -60,6 +60,13 @@ Refused = lane.Refused
|
||||||
require = lane.require
|
require = lane.require
|
||||||
|
|
||||||
CASE_TYPE = re.compile(r'^oidc-browser-relying-party@1\.\d+$')
|
CASE_TYPE = re.compile(r'^oidc-browser-relying-party@1\.\d+$')
|
||||||
|
SERVICE_CASE_TYPE = re.compile(r'^oidc-service-client@0\.\d+$')
|
||||||
|
AUDIENCE = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
||||||
|
RESOURCE_SCOPE = re.compile(r'^[a-z][a-z0-9-]*:[a-z][a-z0-9-]*$')
|
||||||
|
SERVICE_SUBJECT = re.compile(r'^service:[a-z0-9][a-z0-9-]{1,62}$')
|
||||||
|
TENANT = re.compile(r'^tenant:[a-z0-9][a-z0-9-]{1,62}$')
|
||||||
|
ROLE = re.compile(r'^[a-z][a-z0-9-]{1,62}$')
|
||||||
|
LIFETIMES = {'5m', '10m', '15m'}
|
||||||
CLIENT_ID = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
CLIENT_ID = re.compile(r'^[a-z0-9][a-z0-9-]{1,62}$')
|
||||||
# KeyCape refuses '*' and '?' at startup; OAuth 2.0 forbids fragments.
|
# KeyCape refuses '*' and '?' at startup; OAuth 2.0 forbids fragments.
|
||||||
REDIRECT = re.compile(r'^https://[^*?#\s,]+$')
|
REDIRECT = re.compile(r'^https://[^*?#\s,]+$')
|
||||||
|
|
@ -76,6 +83,8 @@ def env_secret_name(client_id):
|
||||||
|
|
||||||
def client_from_params(params):
|
def client_from_params(params):
|
||||||
"""The KeyCape client record for a case of oidc-browser-relying-party."""
|
"""The KeyCape client record for a case of oidc-browser-relying-party."""
|
||||||
|
missing = {'client_id', 'display_name', 'redirect_uris', 'allowed_scopes', 'client_type', 'mfa_required'} - set(params)
|
||||||
|
require(not missing, 'missing_parameters')
|
||||||
cid = params['client_id']
|
cid = params['client_id']
|
||||||
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
|
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
|
||||||
redirects = [r for r in params['redirect_uris'].split(',')]
|
redirects = [r for r in params['redirect_uris'].split(',')]
|
||||||
|
|
@ -105,9 +114,49 @@ def client_from_params(params):
|
||||||
return client
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def service_client_from_params(params):
|
||||||
|
"""The KeyCape client record for a case of oidc-service-client (client_credentials). Field rules follow
|
||||||
|
key-cape src/internal/config/validate.go: confidential, env secretRef, serviceSubject and tenant, no redirect URIs;
|
||||||
|
the type is stricter (closed resource scopes, at most 15m)."""
|
||||||
|
missing = {'client_id', 'display_name', 'audience', 'allowed_scopes', 'service_subject', 'tenant',
|
||||||
|
'token_lifetime'} - set(params)
|
||||||
|
require(not missing, 'missing_parameters')
|
||||||
|
cid = params['client_id']
|
||||||
|
require(CLIENT_ID.fullmatch(cid or '') is not None, 'invalid_client_id')
|
||||||
|
require(AUDIENCE.fullmatch(params.get('audience') or '') is not None, 'audience_invalid')
|
||||||
|
scopes = (params.get('allowed_scopes') or '').split(',')
|
||||||
|
require(all(RESOURCE_SCOPE.fullmatch(s) for s in scopes) and len(set(scopes)) == len(scopes),
|
||||||
|
'allowed_scopes_must_be_distinct_resource_scopes')
|
||||||
|
require(SERVICE_SUBJECT.fullmatch(params.get('service_subject') or '') is not None, 'service_subject_invalid')
|
||||||
|
require(TENANT.fullmatch(params.get('tenant') or '') is not None, 'tenant_invalid')
|
||||||
|
require(params.get('token_lifetime') in LIFETIMES, 'token_lifetime_invalid')
|
||||||
|
for key in ('redirect_uris', 'client_type', 'mfa_required', 'secret_ref'):
|
||||||
|
require(key not in params, key + '_not_admitted_for_service_clients')
|
||||||
|
client = {
|
||||||
|
'clientId': cid,
|
||||||
|
'displayName': params['display_name'],
|
||||||
|
'audience': params['audience'],
|
||||||
|
'allowedScopes': scopes,
|
||||||
|
'grantTypes': ['client_credentials'],
|
||||||
|
'clientType': 'confidential',
|
||||||
|
'secretRef': 'env:' + env_name(cid),
|
||||||
|
'serviceSubject': params['service_subject'],
|
||||||
|
'tenant': params['tenant'],
|
||||||
|
}
|
||||||
|
if params.get('roles'):
|
||||||
|
roles = params['roles'].split(',')
|
||||||
|
require(all(ROLE.fullmatch(r) for r in roles) and len(set(roles)) == len(roles), 'roles_invalid')
|
||||||
|
client['roles'] = roles
|
||||||
|
client['tokenLifetime'] = params['token_lifetime']
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
def client_from_case(case):
|
def client_from_case(case):
|
||||||
spec = case.get('spec') or {}
|
spec = case.get('spec') or {}
|
||||||
require(CASE_TYPE.fullmatch(str(spec.get('type', ''))) is not None, 'case_type_not_oidc_browser_relying_party')
|
case_type = str(spec.get('type', ''))
|
||||||
|
if SERVICE_CASE_TYPE.fullmatch(case_type):
|
||||||
|
return service_client_from_params(spec.get('parameters') or {})
|
||||||
|
require(CASE_TYPE.fullmatch(case_type) is not None, 'case_type_not_a_keycape_client_type')
|
||||||
return client_from_params(spec.get('parameters') or {})
|
return client_from_params(spec.get('parameters') or {})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ def fixture(clients=None, tail='tokenLifetime: 15m\n'):
|
||||||
sort_keys=False) + tail)
|
sort_keys=False) + tail)
|
||||||
secret = {'metadata': {'uid': UID, 'resourceVersion': '7'},
|
secret = {'metadata': {'uid': UID, 'resourceVersion': '7'},
|
||||||
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(),
|
'data': {'config.yaml': base64.b64encode(raw.encode()).decode(),
|
||||||
'signing-key.pem': 'c3ludGhldGljLXNpZ25pbmcta2V5'}}
|
'signing-key.pem': base64.b64encode(b'synthetic-signing-key').decode()}}
|
||||||
return secret, raw
|
return secret, raw
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -97,6 +97,70 @@ class ClientRecordTests(unittest.TestCase):
|
||||||
allowed_scopes='openid,profile,email'))
|
allowed_scopes='openid,profile,email'))
|
||||||
|
|
||||||
|
|
||||||
|
EXECUTOR_CASE = Path(__file__).resolve().parents[3] / 'security-case-types/oidc-service-client/examples/nk-bind-executor-approval.yaml'
|
||||||
|
KEYCAPE_EXAMPLE = Path('/home/worsch/key-cape/config/service-clients.example.yaml')
|
||||||
|
SERVICE_PARAMS = {
|
||||||
|
'client_id': 'nk-bind-executor-approval', 'display_name': 'NetKingdom bounded executor (approval-engine)',
|
||||||
|
'audience': 'approval-engine', 'allowed_scopes': 'approval:create,approval:read,approval:consume',
|
||||||
|
'service_subject': 'service:nk-bind-executor', 'tenant': 'tenant:platform', 'roles': 'bounded-executor',
|
||||||
|
'token_lifetime': '15m', 'consuming_service': 'bounded-executor', 'consumer_namespace': 'sso',
|
||||||
|
'consumer_service_account': 'nk-bind-executor', 'consumer_owner_repo': 'net-kingdom',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceClientTests(unittest.TestCase):
|
||||||
|
"""oidc-service-client (NK-WP-0056-T14): client_credentials records in key-cape's canonical shape."""
|
||||||
|
|
||||||
|
def test_executor_case_renders_the_service_record(self):
|
||||||
|
case = yaml.safe_load(EXECUTOR_CASE.read_text())
|
||||||
|
self.assertEqual(kc.client_from_case(case), {
|
||||||
|
'clientId': 'nk-bind-executor-approval', 'displayName': 'NetKingdom bounded executor (approval-engine)',
|
||||||
|
'audience': 'approval-engine', 'allowedScopes': ['approval:create', 'approval:read', 'approval:consume'],
|
||||||
|
'grantTypes': ['client_credentials'], 'clientType': 'confidential',
|
||||||
|
'secretRef': 'env:KEYCAPE_NK_BIND_EXECUTOR_APPROVAL_CLIENT_SECRET',
|
||||||
|
'serviceSubject': 'service:nk-bind-executor', 'tenant': 'tenant:platform', 'roles': ['bounded-executor'],
|
||||||
|
'tokenLifetime': '15m'})
|
||||||
|
|
||||||
|
def test_adopting_secrets_engine_approval_reproduces_key_capes_record(self):
|
||||||
|
if not KEYCAPE_EXAMPLE.exists():
|
||||||
|
self.skipTest('key-cape checkout absent')
|
||||||
|
live = next(c for c in yaml.safe_load(KEYCAPE_EXAMPLE.read_text())['clients']
|
||||||
|
if c['clientId'] == 'secrets-engine-approval')
|
||||||
|
params = {'client_id': live['clientId'], 'display_name': live['displayName'], 'audience': live['audience'],
|
||||||
|
'allowed_scopes': ','.join(live['allowedScopes']), 'service_subject': live['serviceSubject'],
|
||||||
|
'tenant': live['tenant'], 'roles': ','.join(live['roles']), 'token_lifetime': live['tokenLifetime']}
|
||||||
|
self.assertEqual(kc.service_client_from_params(params), live)
|
||||||
|
|
||||||
|
def test_refusals(self):
|
||||||
|
for change in [dict(allowed_scopes='openid,approval:read'), dict(allowed_scopes='approval:read,approval:read'),
|
||||||
|
dict(token_lifetime='30m'), dict(service_subject='nk-bind-executor'),
|
||||||
|
dict(tenant='platform'), dict(audience='Approval Engine'), dict(roles='Bad Role'),
|
||||||
|
dict(redirect_uris='https://x.example/cb'), dict(client_type='public'),
|
||||||
|
dict(mfa_required='true'), dict(secret_ref='env:OTHER')]:
|
||||||
|
with self.subTest(change=change), self.assertRaises(kc.Refused):
|
||||||
|
kc.service_client_from_params(dict(SERVICE_PARAMS, **change))
|
||||||
|
|
||||||
|
def test_case_type_selects_the_builder(self):
|
||||||
|
case = {'spec': {'type': 'oidc-service-client@0.1', 'parameters': SERVICE_PARAMS}}
|
||||||
|
self.assertEqual(kc.client_from_case(case)['grantTypes'], ['client_credentials'])
|
||||||
|
with self.assertRaises(kc.Refused): # service parameters under the browser type
|
||||||
|
kc.client_from_case({'spec': {'type': 'oidc-browser-relying-party@1.1', 'parameters': SERVICE_PARAMS}})
|
||||||
|
with self.assertRaises(kc.Refused):
|
||||||
|
kc.client_from_case({'spec': {'type': 'oidc-service-client@1.0', 'parameters': SERVICE_PARAMS}})
|
||||||
|
|
||||||
|
def test_merge_adds_a_service_client_and_refuses_a_shared_secret_ref(self):
|
||||||
|
service = kc.service_client_from_params(SERVICE_PARAMS)
|
||||||
|
secret, raw = fixture()
|
||||||
|
encoded, changed = kc.replacement(secret, service)
|
||||||
|
self.assertTrue(changed)
|
||||||
|
expected = yaml.safe_load(raw)
|
||||||
|
expected['clients'].append(service)
|
||||||
|
self.assertEqual(yaml.safe_load(decode(encoded)), expected)
|
||||||
|
with self.assertRaises(kc.Refused):
|
||||||
|
kc.replacement(fixture(EXISTING + [dict(EXISTING[0], clientId='x', secretRef=service['secretRef'])])[0],
|
||||||
|
service)
|
||||||
|
|
||||||
|
|
||||||
class MergeTests(unittest.TestCase):
|
class MergeTests(unittest.TestCase):
|
||||||
def test_add_appends_only_this_client_and_preserves_bytes(self):
|
def test_add_appends_only_this_client_and_preserves_bytes(self):
|
||||||
secret, raw = fixture()
|
secret, raw = fixture()
|
||||||
|
|
|
||||||
|
|
@ -698,7 +698,7 @@ Follow-up 2026-10-31 for T10/T06 reachability:
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: NK-WP-0056-T14
|
id: NK-WP-0056-T14
|
||||||
status: progress
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
assignee: claude-code
|
assignee: claude-code
|
||||||
depends_on: [NK-WP-0056-T13]
|
depends_on: [NK-WP-0056-T13]
|
||||||
|
|
@ -730,6 +730,29 @@ The split of T14 into T14, T18 and T19 is a founder decision (2026-10-31).
|
||||||
|
|
||||||
Nothing is applied.
|
Nothing is applied.
|
||||||
|
|
||||||
|
**Done 2026-10-31.** SecurityCaseType `oidc-service-client` 0.1 is `proposed` (`security-case-types/oidc-service-client/`):
|
||||||
|
|
||||||
|
- type, README and two test files;
|
||||||
|
- the first case `examples/nk-bind-executor-approval.yaml`: audience `approval-engine`; scopes `approval:create`,
|
||||||
|
`approval:read` and `approval:consume`; subject `service:nk-bind-executor`; tenant `tenant:platform`; 15m;
|
||||||
|
- the consumer reads the secret natively through its own bound role (`sso/nk-bind-executor`, audience `openbao`),
|
||||||
|
with no Secret copy;
|
||||||
|
- new capability declaration `net-kingdom.keycape-service-client-registration`.
|
||||||
|
|
||||||
|
`keycape-client.py` renders service clients in key-cape's canonical shape. Adopting `secrets-engine-approval`
|
||||||
|
reproduces key-cape's example record exactly. Both builders refuse missing parameters.
|
||||||
|
|
||||||
|
Tests: 138 pass (type, browser type, validator, keycape-client). Memo `nk-wp-0056-t14-oidc-service-client-r1`
|
||||||
|
(`decision/`) passes informed-decision's intake validator and State Hub's dispatcher contract. Hub decision
|
||||||
|
`f8044f81`.
|
||||||
|
|
||||||
|
Next:
|
||||||
|
|
||||||
|
- reviews from ops-mason, ops-warden, key-cape and railiance-platform;
|
||||||
|
- the operator queues the memo;
|
||||||
|
- the founder acknowledges;
|
||||||
|
- the case is accepted and goes live, which makes the type `implemented` (as part of T18/T19).
|
||||||
|
|
||||||
## Executor approval client and request phase
|
## Executor approval client and request phase
|
||||||
|
|
||||||
```task
|
```task
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue