NK-WP-0048-T04: SecurityCaseType oidc-browser-relying-party 1.0 (proposed)
Composes key-cape.oidc-client-registration; confidential/public conditions, one justified attended step, threat model, fit table of hand-built clients, Forgejo and public-portal examples. Validator gains when/required_when and provider discovery. 43 tests pass. Adds T08 (identity-provisioner groups ensure). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 5457@bnt-lap001 Assistant-Session: 792bf84a-3783-4f5d-a811-cded61dd2012
This commit is contained in:
parent
9f49aae925
commit
fa2cb57384
12 changed files with 976 additions and 76 deletions
|
|
@ -5,38 +5,134 @@
|
|||
"description": "Structure of security-case-types/<type-id>/type.yaml (canon/standards/security-case-types_v0.1.md). Semantic rules (rollback coverage, template placeholders, secret-shaped values, acceptance by risk class) are enforced by tools/security-case-types/security_case_validator.py.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["apiVersion", "kind", "spec"],
|
||||
"required": [
|
||||
"apiVersion",
|
||||
"kind",
|
||||
"spec"
|
||||
],
|
||||
"properties": {
|
||||
"apiVersion": {"const": "netkingdom.io/security-case-type/v0.1"},
|
||||
"kind": {"const": "SecurityCaseType"},
|
||||
"apiVersion": {
|
||||
"const": "netkingdom.io/security-case-type/v0.1"
|
||||
},
|
||||
"kind": {
|
||||
"const": "SecurityCaseType"
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "version", "status", "title", "purpose", "parameters", "composition",
|
||||
"materialization", "follow_through", "verification", "rollback", "acceptance"],
|
||||
"required": [
|
||||
"id",
|
||||
"version",
|
||||
"status",
|
||||
"title",
|
||||
"purpose",
|
||||
"parameters",
|
||||
"composition",
|
||||
"materialization",
|
||||
"follow_through",
|
||||
"verification",
|
||||
"rollback",
|
||||
"acceptance"
|
||||
],
|
||||
"properties": {
|
||||
"id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$"},
|
||||
"version": {"type": "string", "pattern": "^\\d+\\.\\d+$"},
|
||||
"status": {"enum": ["draft", "proposed", "acknowledged", "implemented", "deprecated", "retired"]},
|
||||
"title": {"type": "string", "minLength": 1},
|
||||
"purpose": {"type": "string", "minLength": 1},
|
||||
"parameters": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/parameter"}},
|
||||
"composition": {"$ref": "#/$defs/composition"},
|
||||
"materialization": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/materialization"}},
|
||||
"follow_through": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/step"}},
|
||||
"verification": {"type": "array", "minItems": 2, "items": {"$ref": "#/$defs/check"}},
|
||||
"rollback": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/rollback"}},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9][a-z0-9-]*$"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d+\\.\\d+$"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"draft",
|
||||
"proposed",
|
||||
"acknowledged",
|
||||
"implemented",
|
||||
"deprecated",
|
||||
"retired"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"purpose": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"parameters": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/parameter"
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"$ref": "#/$defs/composition"
|
||||
},
|
||||
"materialization": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/materialization"
|
||||
}
|
||||
},
|
||||
"follow_through": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/step"
|
||||
}
|
||||
},
|
||||
"verification": {
|
||||
"type": "array",
|
||||
"minItems": 2,
|
||||
"items": {
|
||||
"$ref": "#/$defs/check"
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"$ref": "#/$defs/rollback"
|
||||
}
|
||||
},
|
||||
"acceptance": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["accepted_by", "risk_class"],
|
||||
"required": [
|
||||
"accepted_by",
|
||||
"risk_class"
|
||||
],
|
||||
"properties": {
|
||||
"accepted_by": {"type": "array", "minItems": 1, "uniqueItems": true,
|
||||
"items": {"enum": ["owner", "founder"]}},
|
||||
"risk_class": {"enum": ["low", "medium", "high"]}
|
||||
"accepted_by": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"enum": [
|
||||
"owner",
|
||||
"founder"
|
||||
]
|
||||
}
|
||||
},
|
||||
"risk_class": {
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"history": {"type": "array", "items": {"type": "object"}}
|
||||
"history": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -44,83 +140,251 @@
|
|||
"parameter": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["name", "type", "case_settable", "sensitivity", "description"],
|
||||
"required": [
|
||||
"name",
|
||||
"type",
|
||||
"case_settable",
|
||||
"sensitivity",
|
||||
"description"
|
||||
],
|
||||
"properties": {
|
||||
"name": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"},
|
||||
"type": {"enum": ["string", "integer", "number", "boolean", "array", "object"]},
|
||||
"case_settable": {"type": "boolean"},
|
||||
"required": {"type": "boolean"},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]*$"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"string",
|
||||
"integer",
|
||||
"number",
|
||||
"boolean",
|
||||
"array",
|
||||
"object"
|
||||
]
|
||||
},
|
||||
"case_settable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"required": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default": {},
|
||||
"sensitivity": {"enum": ["public", "operational", "security_sensitive", "secret_reference"]},
|
||||
"sensitivity": {
|
||||
"enum": [
|
||||
"public",
|
||||
"operational",
|
||||
"security_sensitive",
|
||||
"secret_reference"
|
||||
]
|
||||
},
|
||||
"constraints": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"pattern": {"type": "string"},
|
||||
"enum": {"type": "array"},
|
||||
"min_items": {"type": "integer", "minimum": 0},
|
||||
"unique": {"type": "boolean"}
|
||||
"pattern": {
|
||||
"type": "string"
|
||||
},
|
||||
"enum": {
|
||||
"type": "array"
|
||||
},
|
||||
"min_items": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"unique": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"required_when": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": {"type": "string", "minLength": 1}
|
||||
"description": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"composition": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["authority", "initial_trust", "capabilities", "providers"],
|
||||
"required": [
|
||||
"authority",
|
||||
"initial_trust",
|
||||
"capabilities",
|
||||
"providers"
|
||||
],
|
||||
"properties": {
|
||||
"authority": {"enum": ["platform", "netkingdom", "tenant"]},
|
||||
"initial_trust": {"type": "array", "minItems": 1, "items": {"type": "string"}},
|
||||
"capabilities": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"type": "string"}},
|
||||
"providers": {"type": "object", "additionalProperties": {"type": "string", "minLength": 1}},
|
||||
"parameter_bindings": {"type": "object", "additionalProperties": {"type": "string"}}
|
||||
"authority": {
|
||||
"enum": [
|
||||
"platform",
|
||||
"netkingdom",
|
||||
"tenant"
|
||||
]
|
||||
},
|
||||
"initial_trust": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"providers": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"parameter_bindings": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"materialization": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "kind", "template"],
|
||||
"required": [
|
||||
"id",
|
||||
"kind",
|
||||
"template"
|
||||
],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"kind": {"enum": ["openbao_policy", "openbao_role", "kv_path", "catalog_entry"]},
|
||||
"template": {"type": "string", "minLength": 1},
|
||||
"description": {"type": "string"}
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"kind": {
|
||||
"enum": [
|
||||
"openbao_policy",
|
||||
"openbao_role",
|
||||
"kv_path",
|
||||
"catalog_entry"
|
||||
]
|
||||
},
|
||||
"template": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"when": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]* (==|!=) [A-Za-z0-9_.:/-]+$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"step": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "owner", "entry_point", "auth", "warden_executes"],
|
||||
"required": [
|
||||
"id",
|
||||
"owner",
|
||||
"entry_point",
|
||||
"auth",
|
||||
"warden_executes"
|
||||
],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"owner": {"type": "string", "minLength": 1},
|
||||
"entry_point": {"type": "string", "minLength": 1},
|
||||
"auth": {"type": "string", "minLength": 1},
|
||||
"warden_executes": {"type": "boolean"},
|
||||
"attended": {"type": "boolean"},
|
||||
"attended_justification": {"type": "string"},
|
||||
"description": {"type": "string"}
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"owner": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"entry_point": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"auth": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"warden_executes": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"attended": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"attended_justification": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"when": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]* (==|!=) [A-Za-z0-9_.:/-]+$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"check": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["id", "check", "expect", "reads_secret_value"],
|
||||
"required": [
|
||||
"id",
|
||||
"check",
|
||||
"expect",
|
||||
"reads_secret_value"
|
||||
],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"check": {"type": "string", "minLength": 1},
|
||||
"expect": {"enum": ["allow", "present", "deny"]},
|
||||
"reads_secret_value": {"const": false}
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"check": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"expect": {
|
||||
"enum": [
|
||||
"allow",
|
||||
"present",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"reads_secret_value": {
|
||||
"const": false
|
||||
},
|
||||
"when": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]* (==|!=) [A-Za-z0-9_.:/-]+$"
|
||||
}
|
||||
}
|
||||
},
|
||||
"rollback": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["reverses", "action"],
|
||||
"required": [
|
||||
"reverses",
|
||||
"action"
|
||||
],
|
||||
"properties": {
|
||||
"reverses": {"type": "string", "minLength": 1},
|
||||
"action": {"type": "string", "minLength": 1}
|
||||
"reverses": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"when": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z][a-z0-9_]* (==|!=) [A-Za-z0-9_.:/-]+$"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
"""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.
|
||||
Copied with the template: rename it to test_<type_id_with_underscores>_contract.py
|
||||
(pytest needs unique module names) and add type-specific tests beside it.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
|
|
@ -20,7 +21,9 @@ SPEC.loader.exec_module(validator)
|
|||
|
||||
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
|
||||
EXAMPLES = sorted((TYPE_DIR / "examples").glob("*.yaml"))
|
||||
DECLARATIONS = sorted(str(p) for p in (REPO / "capabilities" / "playbooks").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"
|
||||
|
||||
|
|
@ -41,6 +44,7 @@ def test_examples_exist_and_validate():
|
|||
|
||||
|
||||
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:
|
||||
|
|
|
|||
100
security-case-types/oidc-browser-relying-party/README.md
Normal file
100
security-case-types/oidc-browser-relying-party/README.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# oidc-browser-relying-party 1.0
|
||||
|
||||
An internal web service signs its human users in through NetKingdom (KeyCape)
|
||||
with an OIDC authorization-code client. This type covers the client
|
||||
registration, the LLDAP groups that gate sign-in, and, for confidential
|
||||
clients, the client secret, minted straight into OpenBao custody and readable
|
||||
only by KeyCape and the consuming service.
|
||||
|
||||
Canon: `canon/standards/security-case-types_v0.1.md`. Status: **proposed**; it
|
||||
awaits acknowledgment through informed-decision (NK-WP-0048-T05).
|
||||
|
||||
## Purpose
|
||||
|
||||
Until now every browser client was registered by its own script in
|
||||
`sso-mfa/k8s/keycape/` (`register-coulomb-social.py`, `portal-client-rollout.py`,
|
||||
`register-user-engine-portal.py`, `vergabe-client-rollout.py`,
|
||||
`openbao-client-config.py`), each with its own test and an attended founder run.
|
||||
The scripts share one pattern: a non-exporting merge into the live client
|
||||
registry, with inspect, dry-run, apply and remove modes. key-cape now declares
|
||||
that pattern once as the playbook capability `key-cape.oidc-client-registration`.
|
||||
This type composes it.
|
||||
|
||||
### Reference instances and fit
|
||||
|
||||
| Client | Fits 1.0 | Why not, and what would cover it |
|
||||
| --- | --- | --- |
|
||||
| user-engine portal | yes | — |
|
||||
| vergabe-demo-company | yes | — |
|
||||
| Forgejo (pending, KEY-WP-0038) | yes | first confidential case and the reference case for `implemented` |
|
||||
| coulomb.social | partly | also registers plain-http loopback redirects for local development |
|
||||
| openbao-admin | no | a CLI client with plain-http loopback callbacks; candidate type `oidc-cli-loopback-client` |
|
||||
| informed-decision-approver | no | carries a resource `audience`, a registration-bound `tenant` and custom scopes; candidate 1.1 parameters or a separate type |
|
||||
|
||||
Adopting an existing client as a case does not change its live registration
|
||||
(canon section 9).
|
||||
|
||||
## Threat model
|
||||
|
||||
Assets: the users' sessions at the consuming service; the confidential client
|
||||
secret; group memberships that decide who may sign in and with what rights.
|
||||
|
||||
| Threat | Control in this type |
|
||||
| --- | --- |
|
||||
| Authorization code or token sent to an attacker-controlled URL | exact `redirect_uris`, https only, no wildcards, validated by the type and enforced by KeyCape |
|
||||
| Anyone with a NetKingdom account gains access to the service | `access_groups` gate sign-in at the consumer; membership is explicit (`initial_members`), never implied |
|
||||
| Stolen password grants admin rights | `mfa_required: "true"` is pinned on the client when the service grants administrative or owner rights, not left to the deployment default |
|
||||
| Client secret leaks through git, chat or a case file | the secret never appears in a case; it is minted into `secret_ref` by the credential broker, and only two policies can read it |
|
||||
| Secret readable by a broad identity | verification includes a negative check that a default-only identity cannot read `secret_ref` |
|
||||
| Over-broad scopes leak profile data | `allowed_scopes` is a closed list; `openid` first |
|
||||
| Silent drift between hand-built clients | one capability, one type, one rollback; per-client scripts retire as clients are adopted |
|
||||
|
||||
Out of scope: the consuming service's own authorization model (how groups map to
|
||||
roles there), token lifetime policy (KeyCape's), and federation (C5).
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep per-client scripts.** Rejected by the founder (2026-10-08): too much
|
||||
manual work, and every script is a separate review surface.
|
||||
- **Wildcard or prefix redirect URIs.** Rejected: the classic open-redirect
|
||||
weakness; exact strings cost nothing.
|
||||
- **Auto-create Forgejo/consumer accounts for any NetKingdom user.** Rejected:
|
||||
sign-in is gated by explicit groups; the consumer decides account creation
|
||||
(Forgejo: link existing accounts, no automatic creation).
|
||||
- **Public clients everywhere (PKCE only).** Allowed for SPAs and native apps;
|
||||
server-side services use confidential clients, so that a stolen authorization
|
||||
code alone is not enough.
|
||||
- **Put the client secret in the case for convenience.** Rejected: cases carry
|
||||
custody references only (canon section 9).
|
||||
|
||||
## Attended steps
|
||||
|
||||
`register-client` is attended. KeyCape's live client registry is a Kubernetes
|
||||
Secret, and agents do not read or write Secrets. ops-warden groups every attended
|
||||
step of a run into one operator session (WARDEN-WP-0044). The way to remove this
|
||||
step is a KeyCape change: load clients from a non-secret registry and reference
|
||||
confidential secrets from custody. That would be a 1.1 or 2.0 of this type.
|
||||
|
||||
## Known limits
|
||||
|
||||
- No loopback or plain-http redirects (CLI and local development clients).
|
||||
- No resource `audience`, registration-bound `tenant` or custom scopes.
|
||||
- No service (`client_credentials`) clients: that is the `oidc-service-client` type.
|
||||
- Group creation assumes the identity-provisioner exposes `groups ensure`; until
|
||||
it does, `ensure-groups` is the owner's open item (NK-WP-0048-T04).
|
||||
- `custody` uses the railiance-platform credential broker; that owner has not yet
|
||||
declared a `c3.runtime-secrets` capability, so `mint-secret` is a follow-through
|
||||
step rather than a composed capability. When it is declared, compose it.
|
||||
|
||||
## Verification and rollback
|
||||
|
||||
Verification proves the registration with the authorization endpoint (accepts
|
||||
each registered redirect, refuses an unregistered one), checks group existence,
|
||||
and for confidential clients checks custody by capabilities only: the default
|
||||
identity is denied, the consumer's policy is allowed. Nothing reads a secret
|
||||
value.
|
||||
|
||||
Rollback is the exact inverse, in reverse order. The hand-over is removed first,
|
||||
then the registration, then the secret and its policies, then the case's group
|
||||
memberships. Groups are deleted only if this case created them and no other case
|
||||
uses them.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
# Illustrative public (PKCE-only) client. Example values only.
|
||||
apiVersion: netkingdom.io/security-case/v0.1
|
||||
kind: SecurityCase
|
||||
spec:
|
||||
id: example-public-portal
|
||||
type: oidc-browser-relying-party@1.0
|
||||
status: proposed
|
||||
owner:
|
||||
repo: net-kingdom
|
||||
person: example-owner
|
||||
parameters:
|
||||
client_id: example-portal
|
||||
display_name: Example portal
|
||||
redirect_uris: https://portal.example.invalid/auth/callback
|
||||
allowed_scopes: openid,profile,email
|
||||
client_type: public
|
||||
mfa_required: "false"
|
||||
access_groups: example-portal-users
|
||||
consuming_service: example-portal
|
||||
consumer_owner_repo: net-kingdom
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
# First KnownSecurityCase (NK-WP-0048-T06), from the request the founder accepted
|
||||
# on 2026-10-08 (key-cape docs/forgejo-oidc-client-request.yaml, KEY-WP-0038-T01).
|
||||
apiVersion: netkingdom.io/security-case/v0.1
|
||||
kind: SecurityCase
|
||||
spec:
|
||||
id: forgejo-netkingdom-oidc
|
||||
type: oidc-browser-relying-party@1.0
|
||||
status: proposed
|
||||
owner:
|
||||
repo: railiance-apps
|
||||
person: founder
|
||||
parameters:
|
||||
client_id: forgejo
|
||||
display_name: Forgejo source forge
|
||||
redirect_uris: https://forgejo.coulomb.social/user/oauth2/netkingdom/callback
|
||||
allowed_scopes: openid,profile,email,groups
|
||||
client_type: confidential
|
||||
mfa_required: "true"
|
||||
access_groups: forge-admins,forge-coulomb-owners,forge-coulomb-readers
|
||||
initial_members: forge-admins=tegwick;forge-coulomb-owners=tegwick
|
||||
secret_ref: bao:platform/workloads/forgejo/netkingdom-oidc-client
|
||||
consuming_service: forgejo
|
||||
consumer_owner_repo: railiance-apps
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
"""Type-specific tests for oidc-browser-relying-party 1.0."""
|
||||
|
||||
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)
|
||||
|
||||
TYPE = yaml.safe_load((TYPE_DIR / "type.yaml").read_text())
|
||||
FORGEJO = yaml.safe_load((TYPE_DIR / "examples" / "forgejo.yaml").read_text())
|
||||
PUBLIC = yaml.safe_load((TYPE_DIR / "examples" / "example-public-portal.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 test_forgejo_case_matches_the_accepted_request():
|
||||
accepted = yaml.safe_load((REPO.parent / "key-cape" / "docs" / "forgejo-oidc-client-request.yaml").read_text())
|
||||
p = FORGEJO["spec"]["parameters"]
|
||||
assert p["client_id"] == accepted["client"]["clientId"]
|
||||
assert p["redirect_uris"].split(",") == accepted["client"]["redirectUris"]
|
||||
assert p["allowed_scopes"].split(",") == accepted["client"]["allowedScopes"]
|
||||
assert p["client_type"] == accepted["client"]["clientType"]
|
||||
assert p["mfa_required"] == str(accepted["client"]["mfaRequired"]).lower()
|
||||
assert p["secret_ref"] == "bao:" + accepted["custody"]["proposed_openbao_path"]
|
||||
assert set(p["access_groups"].split(",")) == set(accepted["forgejo_mapping"]["required_claim"]["any_of"])
|
||||
|
||||
|
||||
def test_redirects_must_be_exact_https():
|
||||
t = as_implemented()
|
||||
for bad in ["https://forgejo.coulomb.social/*", "http://forgejo.coulomb.social/cb",
|
||||
"https://a.example/cb, https://b.example/cb"]:
|
||||
assert any("redirect_uris" in m for m in msgs(v.validate_case(with_params(FORGEJO, redirect_uris=bad), t))), bad
|
||||
|
||||
|
||||
def test_confidential_client_needs_custody_reference_and_public_does_not():
|
||||
t = as_implemented()
|
||||
assert any("required when client_type == confidential" in m
|
||||
for m in msgs(v.validate_case(with_params(FORGEJO, secret_ref=None), t)))
|
||||
assert msgs(v.validate_case(PUBLIC, t)) == []
|
||||
|
||||
|
||||
def test_secret_value_instead_of_reference_is_refused():
|
||||
t = as_implemented()
|
||||
found = msgs(v.validate_case(with_params(FORGEJO, secret_ref="hvs.CAESIabcdefghijklmnop"), t))
|
||||
assert found
|
||||
|
||||
|
||||
def test_scopes_are_a_closed_list():
|
||||
t = as_implemented()
|
||||
assert any("allowed_scopes" in m
|
||||
for m in msgs(v.validate_case(with_params(FORGEJO, allowed_scopes="openid,admin"), t)))
|
||||
|
||||
|
||||
def test_public_case_renders_without_custody_steps():
|
||||
issues, r = v.render_case(PUBLIC, as_implemented(), DECLS)
|
||||
assert msgs(issues) == []
|
||||
assert [m["id"] for m in r["materialization"]] == ["catalog-entry"]
|
||||
assert "mint-secret" not in [s["id"] for s in r["follow_through"]]
|
||||
assert {c["id"] for c in r["verification"]} >= {"authorize-refuses-other-redirect"}
|
||||
assert not {c["id"] for c in r["verification"]} & {"custody-denied-to-default", "custody-allowed-to-consumer"}
|
||||
|
||||
|
||||
def test_confidential_case_renders_custody_and_one_attended_step():
|
||||
issues, r = v.render_case(FORGEJO, as_implemented(), DECLS)
|
||||
assert msgs(issues) == []
|
||||
steps = {s["id"]: s for s in r["follow_through"]}
|
||||
assert set(steps) == {"ensure-groups", "mint-secret", "register-client", "hand-over"}
|
||||
assert [s for s in steps.values() if s.get("attended")] == [steps["register-client"]]
|
||||
assert steps["mint-secret"]["entry_point"].endswith("bao:platform/workloads/forgejo/netkingdom-oidc-client")
|
||||
assert steps["hand-over"]["owner"] == "railiance-apps"
|
||||
plan = r["composition"]
|
||||
assert plan["execution"]["permitted"] is False
|
||||
assert r["execution"] == {"permitted": False}
|
||||
|
|
@ -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
|
||||
205
security-case-types/oidc-browser-relying-party/type.yaml
Normal file
205
security-case-types/oidc-browser-relying-party/type.yaml
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
apiVersion: netkingdom.io/security-case-type/v0.1
|
||||
kind: SecurityCaseType
|
||||
spec:
|
||||
id: oidc-browser-relying-party
|
||||
version: "1.0"
|
||||
status: proposed
|
||||
title: "Browser OIDC relying party for an internal service"
|
||||
purpose: >-
|
||||
Let an internal web service sign its human users in through NetKingdom
|
||||
(KeyCape): an exact-redirect OIDC client, optional MFA pin, the LLDAP groups
|
||||
the service maps to its own roles, and, for confidential clients, a client
|
||||
secret minted straight into OpenBao custody. Replaces the per-client rollout
|
||||
scripts in sso-mfa/k8s/keycape. First case: Forgejo (KEY-WP-0038).
|
||||
|
||||
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: Name shown on the sign-in page.
|
||||
- name: redirect_uris
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {pattern: "^https://[^*\\s,]+(,https://[^*\\s,]+)*$"}
|
||||
sensitivity: security_sensitive
|
||||
description: Comma-separated exact https redirect URIs. No wildcards, no plain http.
|
||||
- name: allowed_scopes
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {enum: ["openid", "openid,profile", "openid,profile,email", "openid,profile,email,groups", "openid,email", "openid,email,groups", "openid,profile,groups", "openid,groups"]}
|
||||
sensitivity: security_sensitive
|
||||
description: Scopes the client may request, openid first.
|
||||
- name: client_type
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {enum: ["confidential", "public"]}
|
||||
sensitivity: security_sensitive
|
||||
description: confidential for server-side services that can hold a secret; public for SPAs and native apps (PKCE only).
|
||||
- name: mfa_required
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {enum: ["true", "false"]}
|
||||
sensitivity: security_sensitive
|
||||
description: Pin MFA on this client. Required "true" when the service grants administrative or owner rights.
|
||||
- name: access_groups
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {pattern: "^[a-z][a-z0-9-]{1,62}(,[a-z][a-z0-9-]{1,62})*$"}
|
||||
sensitivity: operational
|
||||
description: Comma-separated LLDAP groups that gate sign-in and that the service maps to its roles. Created if absent.
|
||||
- name: initial_members
|
||||
type: string
|
||||
required: false
|
||||
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-]*=[a-z][a-z0-9._-]*(\\+[a-z][a-z0-9._-]*)*)*$"}
|
||||
sensitivity: operational
|
||||
description: "Initial memberships as group=user+user;group=user. Usernames are directory usernames, never secrets."
|
||||
- name: secret_ref
|
||||
type: string
|
||||
required: false
|
||||
case_settable: true
|
||||
constraints:
|
||||
pattern: "^bao:platform/workloads/[a-z0-9/_-]+$"
|
||||
required_when: "client_type == confidential"
|
||||
sensitivity: secret_reference
|
||||
description: OpenBao custody path for a confidential client's secret. Omitted for public clients.
|
||||
- name: consuming_service
|
||||
type: string
|
||||
case_settable: true
|
||||
constraints: {pattern: "^[a-z0-9][a-z0-9-]*$"}
|
||||
sensitivity: operational
|
||||
description: The service that signs users in with this client.
|
||||
- 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 service's configuration.
|
||||
- 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: key-cape.oidc-client-registration
|
||||
parameter_bindings:
|
||||
key-cape.oidc-client-registration.client_id: client_id
|
||||
key-cape.oidc-client-registration.display_name: display_name
|
||||
key-cape.oidc-client-registration.redirect_uris: redirect_uris
|
||||
key-cape.oidc-client-registration.allowed_scopes: allowed_scopes
|
||||
key-cape.oidc-client-registration.client_type: client_type
|
||||
key-cape.oidc-client-registration.mfa_required: mfa_required
|
||||
|
||||
materialization:
|
||||
- id: client-secret-path
|
||||
kind: kv_path
|
||||
when: "client_type == confidential"
|
||||
template: "{secret_ref}"
|
||||
- id: keycape-reader-policy
|
||||
kind: openbao_policy
|
||||
when: "client_type == confidential"
|
||||
template: "keycape-client-{client_id}-read"
|
||||
- id: consumer-reader-policy
|
||||
kind: openbao_policy
|
||||
when: "client_type == confidential"
|
||||
template: "{consuming_service}-oidc-client-{client_id}-read"
|
||||
- id: catalog-entry
|
||||
kind: catalog_entry
|
||||
template: "oidc-client-{client_id}"
|
||||
|
||||
follow_through:
|
||||
- id: ensure-groups
|
||||
owner: net-kingdom
|
||||
entry_point: "identity-provisioner groups ensure --groups {access_groups} --members '{initial_members}'"
|
||||
auth: "NetKingdom identity-provisioner service identity with LLDAP group administration, via the ops-warden proxy"
|
||||
warden_executes: true
|
||||
attended: false
|
||||
- id: mint-secret
|
||||
owner: railiance-platform
|
||||
when: "client_type == confidential"
|
||||
entry_point: "railiance-platform credential broker: generate a client secret into {secret_ref}"
|
||||
auth: "credential broker issuer, scoped to the case's KV path"
|
||||
warden_executes: true
|
||||
attended: false
|
||||
- id: register-client
|
||||
owner: key-cape
|
||||
entry_point: "keycape-client register (key-cape.oidc-client-registration) for {client_id}"
|
||||
auth: "operator write access to the live KeyCape client registry in namespace sso"
|
||||
warden_executes: false
|
||||
attended: true
|
||||
attended_justification: >-
|
||||
The live KeyCape client registry is a Kubernetes Secret, and agents do
|
||||
not read or write Secrets. Until KeyCape loads clients from a
|
||||
non-secret registry with secrets referenced from custody, one attended
|
||||
operator session applies the rendered registration. ops-warden groups
|
||||
all attended steps of a run into that one session.
|
||||
- id: hand-over
|
||||
owner: "{consumer_owner_repo}"
|
||||
entry_point: "configure {consuming_service} with issuer {issuer}, client {client_id} and custody {secret_ref}"
|
||||
auth: "the consumer owner's own deployment path"
|
||||
warden_executes: false
|
||||
attended: false
|
||||
|
||||
verification:
|
||||
- id: authorize-accepts-redirects
|
||||
check: "GET {issuer}/authorize with client {client_id} and each of {redirect_uris} returns the login page"
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
- id: authorize-refuses-other-redirect
|
||||
check: "GET {issuer}/authorize with client {client_id} and an unregistered redirect returns an OAuth error"
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
- id: custody-denied-to-default
|
||||
when: "client_type == confidential"
|
||||
check: "a default-only OpenBao identity has no read capability on {secret_ref}"
|
||||
expect: deny
|
||||
reads_secret_value: false
|
||||
- id: custody-allowed-to-consumer
|
||||
when: "client_type == confidential"
|
||||
check: "policy {consuming_service}-oidc-client-{client_id}-read grants read on {secret_ref} (capabilities check only)"
|
||||
expect: allow
|
||||
reads_secret_value: false
|
||||
- id: groups-present
|
||||
check: "LLDAP groups {access_groups} exist"
|
||||
expect: present
|
||||
reads_secret_value: false
|
||||
|
||||
rollback:
|
||||
- reverses: hand-over
|
||||
action: "remove the OIDC source for {client_id} from {consuming_service} (owner {consumer_owner_repo})"
|
||||
- reverses: register-client
|
||||
action: "keycape-client register --remove {client_id}"
|
||||
- reverses: mint-secret
|
||||
when: "client_type == confidential"
|
||||
action: "destroy the secret at {secret_ref}"
|
||||
- reverses: ensure-groups
|
||||
action: "remove the case's initial memberships; delete groups {access_groups} only if this case created them and no other case uses them"
|
||||
- reverses: client-secret-path
|
||||
when: "client_type == confidential"
|
||||
action: "remove the KV path {secret_ref}"
|
||||
- reverses: keycape-reader-policy
|
||||
when: "client_type == confidential"
|
||||
action: "delete policy keycape-client-{client_id}-read"
|
||||
- reverses: consumer-reader-policy
|
||||
when: "client_type == confidential"
|
||||
action: "delete policy {consuming_service}-oidc-client-{client_id}-read"
|
||||
- reverses: catalog-entry
|
||||
action: "remove catalog entry oidc-client-{client_id}"
|
||||
|
||||
acceptance:
|
||||
accepted_by: [owner, founder]
|
||||
risk_class: medium
|
||||
|
|
@ -29,6 +29,7 @@ parameters that are not custody references, and high-risk types without founder
|
|||
acceptance.
|
||||
|
||||
To start a type: copy `security-case-types/_template/`, rename the directory to
|
||||
the type id, and rewrite every section. The template's tests travel with it.
|
||||
the type id, and rewrite every section. The template's tests travel with it:
|
||||
rename `tests/test_type.py` to `tests/test_<type_id>_contract.py` so module names stay unique.
|
||||
|
||||
Tests: `python3 -m pytest tools/security-case-types/tests security-case-types/*/tests`.
|
||||
|
|
|
|||
|
|
@ -88,6 +88,28 @@ def _walk_strings(value: Any, path: str):
|
|||
yield from _walk_strings(v, f"{path}[{i}]")
|
||||
|
||||
|
||||
WHEN = re.compile(r"^([a-z][a-z0-9_]*) (==|!=) ([A-Za-z0-9_.:/-]+)$")
|
||||
|
||||
|
||||
def check_when(expr: Any, params: dict[str, dict], path: str) -> list[Issue]:
|
||||
"""A condition is '<parameter> == <value>' or '<parameter> != <value>'."""
|
||||
m = WHEN.fullmatch(str(expr))
|
||||
if not m:
|
||||
return [err(path, "must be '<parameter> == <value>' or '<parameter> != <value>'")]
|
||||
if m.group(1) not in params:
|
||||
return [err(path, f"condition uses unknown parameter {m.group(1)!r}")]
|
||||
return []
|
||||
|
||||
|
||||
def holds(expr: str | None, values: dict[str, Any]) -> bool:
|
||||
if not expr:
|
||||
return True
|
||||
name, op, literal = WHEN.fullmatch(expr).groups()
|
||||
actual = values.get(name)
|
||||
actual = str(actual).lower() if isinstance(actual, bool) else str(actual)
|
||||
return (actual == literal) if op == "==" else (actual != literal)
|
||||
|
||||
|
||||
def secret_shaped(text: str) -> bool:
|
||||
return any(p.search(text) for p in SECRET_SHAPES)
|
||||
|
||||
|
|
@ -123,6 +145,14 @@ def validate_type(data: dict[str, Any], type_dir: Path | None = None) -> list[Is
|
|||
_validate_verification(spec["verification"], params, issues)
|
||||
_validate_rollback(spec["rollback"], step_ids, spec["materialization"], issues)
|
||||
_validate_acceptance(spec["acceptance"], issues)
|
||||
for section in ("materialization", "follow_through", "verification", "rollback"):
|
||||
for i, item in enumerate(spec[section] if isinstance(spec[section], list) else []):
|
||||
if isinstance(item, dict) and "when" in item:
|
||||
issues.extend(check_when(item["when"], params, f"type.spec.{section}[{i}].when"))
|
||||
for i, p in enumerate(spec["parameters"] if isinstance(spec["parameters"], list) else []):
|
||||
rw = (p.get("constraints") or {}).get("required_when") if isinstance(p, dict) else None
|
||||
if rw is not None:
|
||||
issues.extend(check_when(rw, params, f"type.spec.parameters[{i}].constraints.required_when"))
|
||||
|
||||
for path, text in _walk_strings(spec, "type.spec"):
|
||||
if secret_shaped(text):
|
||||
|
|
@ -345,7 +375,10 @@ def validate_case(case: dict[str, Any], type_data: dict[str, Any], *, require_im
|
|||
for name, p in params.items():
|
||||
path = f"case.spec.parameters.{name}"
|
||||
if name not in values:
|
||||
if p.get("case_settable") and p.get("required", True) and "default" not in p:
|
||||
rw = (p.get("constraints") or {}).get("required_when")
|
||||
if rw and WHEN.fullmatch(str(rw)) and holds(rw, {**{q: d.get("default") for q, d in params.items() if "default" in d}, **values}):
|
||||
issues.append(err(path, f"required when {rw}"))
|
||||
elif not rw and p.get("case_settable") and p.get("required", True) and "default" not in p:
|
||||
issues.append(err(path, "required"))
|
||||
continue
|
||||
if not p.get("case_settable"):
|
||||
|
|
@ -390,9 +423,21 @@ def effective_values(case: dict[str, Any], type_data: dict[str, Any]) -> dict[st
|
|||
return values
|
||||
|
||||
|
||||
def _applicable(items: list[dict], values: dict[str, Any]) -> list[dict]:
|
||||
return [{k: v for k, v in i.items() if k != "when"} for i in items if holds(i.get("when"), values)]
|
||||
|
||||
|
||||
class _Blank(dict):
|
||||
"""Template values where an optional parameter a case leaves out renders empty."""
|
||||
|
||||
def __missing__(self, key: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _fill(value: Any, values: dict[str, Any]) -> Any:
|
||||
if isinstance(value, str):
|
||||
return value.format_map({k: (",".join(map(str, v)) if isinstance(v, list) else v) for k, v in values.items()})
|
||||
return value.format_map(_Blank({k: (",".join(map(str, v)) if isinstance(v, list) else v)
|
||||
for k, v in values.items()}))
|
||||
if isinstance(value, dict):
|
||||
return {k: _fill(v, values) for k, v in value.items()}
|
||||
if isinstance(value, list):
|
||||
|
|
@ -432,10 +477,10 @@ def render_case(case: dict[str, Any], type_data: dict[str, Any], declarations: l
|
|||
"case": cspec["id"],
|
||||
"type": f"{tspec['id']}@{tspec['version']}",
|
||||
"composition": plan,
|
||||
"materialization": _fill(tspec["materialization"], values),
|
||||
"follow_through": _fill(tspec["follow_through"], values),
|
||||
"verification": _fill(tspec["verification"], values),
|
||||
"rollback": _fill(tspec["rollback"], values),
|
||||
"materialization": _fill(_applicable(tspec["materialization"], values), values),
|
||||
"follow_through": _fill(_applicable(tspec["follow_through"], values), values),
|
||||
"verification": _fill(_applicable(tspec["verification"], values), values),
|
||||
"rollback": _fill(_applicable(tspec["rollback"], values), values),
|
||||
"execution": {"permitted": False},
|
||||
}
|
||||
rendered["digest"] = "sha256:" + hashlib.sha256(
|
||||
|
|
@ -443,6 +488,23 @@ def render_case(case: dict[str, Any], type_data: dict[str, Any], declarations: l
|
|||
return issues, rendered
|
||||
|
||||
|
||||
def discover_declarations(type_data: dict[str, Any], repos_root: Path) -> tuple[list[str], list[Issue]]:
|
||||
"""Find each pinned provider's declaration in its owning repository.
|
||||
|
||||
A pin '<repo>.<name>' resolves to <repos_root>/<repo>/capabilities/playbooks/<pin>.yaml,
|
||||
the publication path the Playbook Capability Contract defines.
|
||||
"""
|
||||
paths, issues = [], []
|
||||
for cap, pin in sorted(type_data["spec"]["composition"].get("providers", {}).items()):
|
||||
repo = pin.split(".", 1)[0]
|
||||
path = repos_root / repo / "capabilities" / "playbooks" / f"{pin}.yaml"
|
||||
if path.is_file():
|
||||
paths.append(str(path))
|
||||
else:
|
||||
issues.append(err(f"composition.providers.{cap}", f"declaration {pin!r} not found at {path}"))
|
||||
return paths, issues
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _report(issues: list[Issue]) -> int:
|
||||
|
|
@ -463,7 +525,9 @@ def main(argv: list[str] | None = None) -> int:
|
|||
help="validate an example case against a type that is not implemented yet")
|
||||
rd = sub.add_parser("render")
|
||||
rd.add_argument("case")
|
||||
rd.add_argument("declarations", nargs="+")
|
||||
rd.add_argument("declarations", nargs="*", help="declaration files; omit to discover them under --repos-root")
|
||||
rd.add_argument("--repos-root", default=str(TOOL_DIR.parents[2]),
|
||||
help="directory holding the owning repositories (default: the parent of this checkout)")
|
||||
rd.add_argument("--types", required=True)
|
||||
rd.add_argument("--allow-unimplemented", action="store_true")
|
||||
args = ap.parse_args(argv)
|
||||
|
|
@ -485,7 +549,11 @@ def main(argv: list[str] | None = None) -> int:
|
|||
if code == 0:
|
||||
print(f"OK case {case['spec']['id']}")
|
||||
return code
|
||||
declarations, load_issues = composer.load_declarations(args.declarations)
|
||||
decl_paths, discover_issues = (args.declarations, []) if args.declarations else \
|
||||
discover_declarations(type_data, Path(args.repos_root))
|
||||
if has_errors(discover_issues):
|
||||
return _report(discover_issues)
|
||||
declarations, load_issues = composer.load_declarations(decl_paths)
|
||||
issues, rendered = render_case(case, type_data, declarations,
|
||||
require_implemented=not args.allow_unimplemented)
|
||||
issues = load_issues + issues
|
||||
|
|
|
|||
|
|
@ -157,3 +157,29 @@ def test_template_conforms_to_the_json_schemas():
|
|||
bad["spec"]["surprise"] = True
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
jsonschema.validate(bad, case_schema)
|
||||
|
||||
|
||||
def test_conditions_filter_rendering_and_require_parameters():
|
||||
t = implemented()
|
||||
t["spec"]["parameters"][3]["required"] = False
|
||||
t["spec"]["parameters"][3]["constraints"]["required_when"] = "operator_username == bootstrap-admin"
|
||||
t["spec"]["materialization"][0]["when"] = "operator_username == bootstrap-admin"
|
||||
t["spec"]["rollback"][1]["when"] = "operator_username == bootstrap-admin"
|
||||
assert msgs(v.validate_type(t)) == []
|
||||
c = copy.deepcopy(CASE)
|
||||
del c["spec"]["parameters"]["signing_key_ref"]
|
||||
assert any("required when" in m for m in msgs(v.validate_case(c, t)))
|
||||
c["spec"]["parameters"]["operator_username"] = "someone-else"
|
||||
assert msgs(v.validate_case(c, t)) == []
|
||||
_, r = v.render_case(c, t, DECLS)
|
||||
assert [m["id"] for m in r["materialization"]] == ["signing-key-policy", "catalog-entry"]
|
||||
assert all("when" not in m for m in r["materialization"])
|
||||
|
||||
|
||||
def test_bad_condition_is_refused():
|
||||
t = copy.deepcopy(TYPE)
|
||||
t["spec"]["materialization"][0]["when"] = "nonexistent == x"
|
||||
t["spec"]["follow_through"][0]["when"] = "operator_username is x"
|
||||
found = msgs(v.validate_type(t))
|
||||
assert any("unknown parameter 'nonexistent'" in m for m in found)
|
||||
assert any("must be '<parameter> == <value>'" in m for m in found)
|
||||
|
|
|
|||
|
|
@ -125,9 +125,8 @@ travel with every copy). 31 tests pass, including the composer's existing suite.
|
|||
|
||||
```task
|
||||
id: NK-WP-0048-T04
|
||||
status: wait
|
||||
depends_on: [NK-WP-0048-T02, NK-WP-0048-T03, MASON-WP-0010-T01, WARDEN-WP-0044-T01]
|
||||
blocking_reason: "The first type needs the inventory, the template, and ops-mason/ops-warden's sections."
|
||||
status: done
|
||||
depends_on: [NK-WP-0048-T02, NK-WP-0048-T03]
|
||||
priority: high
|
||||
state_hub_task_id: "2fffe112-7d55-5680-9952-78d74f44f87f"
|
||||
```
|
||||
|
|
@ -155,10 +154,26 @@ state_hub_task_id: "2fffe112-7d55-5680-9952-78d74f44f87f"
|
|||
|
||||
Acceptance: complete per canon section 3, tests green, status `proposed`.
|
||||
|
||||
2026-10-08: done. `security-case-types/oidc-browser-relying-party/` 1.0 (proposed):
|
||||
`type.yaml` with 12 parameters (the custody reference is required only for
|
||||
confidential clients), composition over key-cape's new
|
||||
`key-cape.oidc-client-registration` declaration (contract PASS, `maturity:
|
||||
planned`), materialization and follow-through, verification and rollback, all
|
||||
conditional on client type where it matters. One attended step,
|
||||
`register-client`, justified by the live registry being a Kubernetes Secret.
|
||||
README with threat model, alternatives, an honest fit table (portal and vergabe
|
||||
fit; Forgejo is the first confidential case; openbao-admin, coulomb.social and
|
||||
informed-decision-approver do not fit 1.0), and known limits. Examples: Forgejo
|
||||
(checked against the accepted request) and a public portal. The validator gained
|
||||
`when` / `required_when` conditions and provider discovery. 43 tests pass.
|
||||
ops-mason's and ops-warden's sections are drafted in the type. Their owners
|
||||
review them in MASON-WP-0010-T01 and WARDEN-WP-0044-T01 before acknowledgment
|
||||
(T05).
|
||||
|
||||
```task
|
||||
id: NK-WP-0048-T05
|
||||
status: wait
|
||||
depends_on: [NK-WP-0048-T04]
|
||||
depends_on: [NK-WP-0048-T04, MASON-WP-0010-T01, WARDEN-WP-0044-T01]
|
||||
needs_human: true
|
||||
blocking_reason: "A new SecurityCaseType is acknowledged as an informed-decision by the founder."
|
||||
priority: high
|
||||
|
|
@ -174,7 +189,7 @@ moves to `acknowledged`.
|
|||
```task
|
||||
id: NK-WP-0048-T06
|
||||
status: wait
|
||||
depends_on: [NK-WP-0048-T05, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
|
||||
depends_on: [NK-WP-0048-T05, NK-WP-0048-T08, KEY-WP-0038-T04, MASON-WP-0010-T03, WARDEN-WP-0044-T03]
|
||||
blocking_reason: "Implementation needs the acknowledged type and both executors' light paths."
|
||||
priority: high
|
||||
state_hub_task_id: "d9b26bcf-a7c6-59a2-92a0-f913d76a203c"
|
||||
|
|
@ -205,3 +220,19 @@ From the T02 ranking, open the next type drafts (likely the confidential service
|
|||
client and the workload KV read lane), each to be acknowledged on its own.
|
||||
Record which existing hand-built instances get adopted as cases, without
|
||||
rewriting them in place.
|
||||
|
||||
```task
|
||||
id: NK-WP-0048-T08
|
||||
status: todo
|
||||
priority: high
|
||||
```
|
||||
|
||||
### T08 — identity-provisioner: ensure service-access groups
|
||||
|
||||
The type's `ensure-groups` step calls `identity-provisioner groups ensure --groups
|
||||
<list> --members <group=user+user;...>`. The provisioner already has idempotent
|
||||
`_ensure_group` and `_add_group`, but exposes only tenant-scoped operations. Add
|
||||
the general operation (create if absent, add the listed memberships, never
|
||||
remove others), its rollback (remove only this case's memberships), tests, and
|
||||
the ops-warden route for its service identity.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue