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
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue