diff --git a/canon/schemas/security-case-type_v0.1.schema.json b/canon/schemas/security-case-type_v0.1.schema.json index 10bebb1..7b0e839 100644 --- a/canon/schemas/security-case-type_v0.1.schema.json +++ b/canon/schemas/security-case-type_v0.1.schema.json @@ -5,38 +5,134 @@ "description": "Structure of security-case-types//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_.:/-]+$" + } } } } diff --git a/security-case-types/_template/tests/test_type.py b/security-case-types/_template/tests/test_type.py index 69475eb..7a4380c 100644 --- a/security-case-types/_template/tests/test_type.py +++ b/security-case-types/_template/tests/test_type.py @@ -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__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: diff --git a/security-case-types/oidc-browser-relying-party/README.md b/security-case-types/oidc-browser-relying-party/README.md new file mode 100644 index 0000000..c9f2806 --- /dev/null +++ b/security-case-types/oidc-browser-relying-party/README.md @@ -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. diff --git a/security-case-types/oidc-browser-relying-party/examples/example-public-portal.yaml b/security-case-types/oidc-browser-relying-party/examples/example-public-portal.yaml new file mode 100644 index 0000000..121bc84 --- /dev/null +++ b/security-case-types/oidc-browser-relying-party/examples/example-public-portal.yaml @@ -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 diff --git a/security-case-types/oidc-browser-relying-party/examples/forgejo.yaml b/security-case-types/oidc-browser-relying-party/examples/forgejo.yaml new file mode 100644 index 0000000..5467bfa --- /dev/null +++ b/security-case-types/oidc-browser-relying-party/examples/forgejo.yaml @@ -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 diff --git a/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py new file mode 100644 index 0000000..7f720f1 --- /dev/null +++ b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party.py @@ -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} diff --git a/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party_contract.py b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party_contract.py new file mode 100644 index 0000000..19dc96e --- /dev/null +++ b/security-case-types/oidc-browser-relying-party/tests/test_oidc_browser_relying_party_contract.py @@ -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 diff --git a/security-case-types/oidc-browser-relying-party/type.yaml b/security-case-types/oidc-browser-relying-party/type.yaml new file mode 100644 index 0000000..1cce94d --- /dev/null +++ b/security-case-types/oidc-browser-relying-party/type.yaml @@ -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 diff --git a/tools/security-case-types/README.md b/tools/security-case-types/README.md index 61938da..7259a11 100644 --- a/tools/security-case-types/README.md +++ b/tools/security-case-types/README.md @@ -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__contract.py` so module names stay unique. Tests: `python3 -m pytest tools/security-case-types/tests security-case-types/*/tests`. diff --git a/tools/security-case-types/security_case_validator.py b/tools/security-case-types/security_case_validator.py index aaae1c7..1dfc54f 100644 --- a/tools/security-case-types/security_case_validator.py +++ b/tools/security-case-types/security_case_validator.py @@ -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 ' == ' or ' != '.""" + m = WHEN.fullmatch(str(expr)) + if not m: + return [err(path, "must be ' == ' or ' != '")] + 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 '.' resolves to //capabilities/playbooks/.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 diff --git a/tools/security-case-types/tests/test_security_case_validator.py b/tools/security-case-types/tests/test_security_case_validator.py index a938d25..2aa7ab5 100644 --- a/tools/security-case-types/tests/test_security_case_validator.py +++ b/tools/security-case-types/tests/test_security_case_validator.py @@ -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 ' == '" in m for m in found) diff --git a/workplans/NK-WP-0048-security-case-types.md b/workplans/NK-WP-0048-security-case-types.md index 33a394a..2fbdd4f 100644 --- a/workplans/NK-WP-0048-security-case-types.md +++ b/workplans/NK-WP-0048-security-case-types.md @@ -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 + --members `. 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. +