diff --git a/plans/fluid-telegram-operator-credential-lane.md b/plans/fluid-telegram-operator-credential-lane.md index 368fcd6..96bf329 100644 --- a/plans/fluid-telegram-operator-credential-lane.md +++ b/plans/fluid-telegram-operator-credential-lane.md @@ -113,9 +113,18 @@ until recovery is verified and must never be exposed through this OIDC role. - token TTL: `15m`, maximum TTL `30m` - user claim: `sub`; groups claim: `groups` - scopes: `openid,profile,email,groups` -- proposed bound claim: `groups=fluid-telegram-operators` — **unconfirmed** -- callbacks: Bao UI plus localhost and loopback CLI callbacks already accepted - by the platform contract +- bound claims (`bound_claims_type=string`): `groups=[fluid-telegram-operators]` + and `/assurance/level=aal2` — confirmed by key-cape 2026-10-08 (message + `b2ed890f-a05a-4b96-95eb-2cd9d8b32caa`, key-cape `e0b5fae`). Human ID tokens + always carry `groups` (LLDAP group names); `aal2` is set only after a + verified privacyIDEA factor, the same pin as CCR-2026-0034. +- precondition: LLDAP group `fluid-telegram-operators` does not exist yet; + creating it with its members is a NetKingdom directory act needing the + founder go. KeyCape does not create groups. +- callbacks (subset of the `openbao-admin` client registration): + `http://localhost:8250/oidc/callback`, `http://127.0.0.1:8250/oidc/callback`, + `http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback`. Never the + retired `https://bao.coulomb.social/...` (2026-09-15). The role is campaign-specific because its policy is campaign-specific. A second campaign receives a second policy and role after its own review; it is @@ -143,7 +152,8 @@ This section is incomplete while T01 and T02 are open; the plan must remain access to KV metadata because the provisioner uses data GETs to detect absence; metadata evidence comes from the separately scoped builder/operator verification path. -- **Identity binding:** an unconfirmed group is a hard blocker, not a warning. +- **Identity binding:** the group and assurance claims are confirmed; the + group's existence in LLDAP is a hard blocker, not a warning. The role must not be readable by every OIDC user. - **Write-once semantics:** KV-v2 create/update distinction is documented, but activation also requires an owner-run non-secret probe proving the installed @@ -177,7 +187,7 @@ This section is incomplete while T01 and T02 are open; the plan must remain ## 5. Executive summary (phase 3) -Not yet renderable. The owner-approved tenant segment, OIDC bound claim, live -structure survey, CCR/executor extension, and installed-engine create/update +Not yet renderable. The owner-approved tenant segment, the LLDAP group's +creation, the live structure survey, CCR/executor extension, and installed-engine create/update probe are open inputs. When those close, this section must ask for one explicit approval covering the exact objects and capability matrix above. diff --git a/src/ops_mason/executor.py b/src/ops_mason/executor.py index 357941b..b7ffd51 100644 --- a/src/ops_mason/executor.py +++ b/src/ops_mason/executor.py @@ -93,6 +93,11 @@ class OIDCKVSpec: audit_log_path: Path | None = None +# KeyCape sets assurance.level=aal2 only after a verified privacyIDEA factor +# (key-cape reply 2026-10-08, as in CCR-2026-0034); every attended role pins it. +_REQUIRED_ASSURANCE = {"/assurance/level": "aal2"} +_UI_CALLBACK = "http://127.0.0.1:18200/ui/vault/auth/{mount}/oidc/callback" + _OPERATOR_MATRIX = ( ("operator-app", ("read",)), ("operator-session", ("create", "read", "update")), @@ -138,6 +143,8 @@ def _validate_oidc_spec(spec: OIDCKVSpec) -> None: for uri in spec.allowed_redirect_uris: if not isinstance(uri, str): raise BuildRefused("OIDC callback must be a URI") + if uri == _UI_CALLBACK.format(mount=spec.auth_mount): + continue try: u = urlsplit(uri) valid = (u.scheme == "http" and u.hostname in {"localhost", "127.0.0.1"} @@ -146,7 +153,7 @@ def _validate_oidc_spec(spec: OIDCKVSpec) -> None: except ValueError: valid = False if not valid: - raise BuildRefused("initial matrix engine accepts only reviewed loopback CLI callbacks on port 8250") + raise BuildRefused("matrix engine accepts only the reviewed loopback CLI callbacks on port 8250 and the operator-tunnel UI callback") def oidc_role_document(spec: OIDCKVSpec) -> dict: @@ -154,7 +161,8 @@ def oidc_role_document(spec: OIDCKVSpec) -> dict: return { "role_type": "oidc", "user_claim": "sub", "groups_claim": "groups", "oidc_scopes": ["openid", "profile", "email", "groups"], - "bound_claims": {"groups": spec.bound_group}, + "bound_claims_type": "string", + "bound_claims": {"groups": [spec.bound_group], **_REQUIRED_ASSURANCE}, "allowed_redirect_uris": list(spec.allowed_redirect_uris), "token_policies": [spec.policy_name], "token_no_default_policy": True, "token_ttl": spec.token_ttl, "token_max_ttl": spec.token_max_ttl, diff --git a/tests/test_oidc_matrix.py b/tests/test_oidc_matrix.py index af2f0bd..3c3b07b 100644 --- a/tests/test_oidc_matrix.py +++ b/tests/test_oidc_matrix.py @@ -67,7 +67,8 @@ def test_exact_role_binding_and_value_free_build(tmp_path): assert run.call_args_list[0].args[0]==['bao','policy','write',s.policy_name,'-'] assert run.call_args_list[1].args[0]==['bao','write','auth/netkingdom/role/'+s.role_name,'-'] role=json.loads(run.call_args_list[1].kwargs['input']) - assert role['bound_claims']=={'groups':s.bound_group} + assert role['bound_claims']=={'groups':[s.bound_group],'/assurance/level':'aal2'} + assert role['bound_claims_type']=='string' assert role['token_no_default_policy'] is True and role['token_explicit_max_ttl']=='30m' assert role['token_policies']==[s.policy_name] assert build_spec_document(s)['engine']=='openbao-oidc-kv-read-write' @@ -80,3 +81,12 @@ def test_failure_suppresses_output_and_never_claims_success(tmp_path): with patch('ops_mason.executor.subprocess.run',return_value=MagicMock(returncode=1,stdout='sensitive-marker',stderr='sensitive-marker')): with pytest.raises(BuildError) as e:build_oidc_kv_lane(_plan(tmp_path,spec=s),s) assert 'sensitive-marker' not in str(e.value) and not s.audit_log_path.exists() + + +def test_callbacks_accept_operator_ui_and_refuse_retired_public_ui(tmp_path): + s=spec(tmp_path) + ui='http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback' + assert ui in oidc_role_document(replace(s,allowed_redirect_uris=(ui,)))['allowed_redirect_uris'] + for bad in ('https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback', + 'http://127.0.0.1:18200/ui/vault/auth/other/oidc/callback'): + with pytest.raises(BuildRefused):oidc_role_document(replace(s,allowed_redirect_uris=(bad,))) diff --git a/workplans/MASON-WP-0005-fluid-telegram-operator-credential-lane.md b/workplans/MASON-WP-0005-fluid-telegram-operator-credential-lane.md index e8a6851..d8feadd 100644 --- a/workplans/MASON-WP-0005-fluid-telegram-operator-credential-lane.md +++ b/workplans/MASON-WP-0005-fluid-telegram-operator-credential-lane.md @@ -44,8 +44,8 @@ consumer workplan `FT-WP-0002` (`f2373858-c932-5a4d-81b6-db9a3595135a`). ```task id: MASON-WP-0005-T01 status: wait -blocked_on: message-from:key-cape -blocking_reason: "KeyCape/NetKingdom must confirm the OIDC group claim, assurance level and callbacks for the attended provisioner role; railiance-platform (2026-09-09) declines to decide these. Tenant/path acceptance moves to the plan approval (T03)." +blocked_on: human +blocking_reason: "Claim, assurance and callbacks confirmed by key-cape (2026-10-08). Remaining: founder go for net-kingdom to create LLDAP group fluid-telegram-operators with its members, and a scoped OpenBao grant (bao-session.sh grant MASON-WP-0005-T01) for the live survey." priority: high state_hub_task_id: "18c9099f-882f-575c-93b9-cc68d7fbc2af" ``` @@ -72,6 +72,16 @@ plan approval); the construction contract and writer authority are ops-mason's side. Re-qualified from `message-from:railiance-platform` to `message-from:key-cape`; asked key-cape for the claim the same day. +2026-10-08: key-cape answered (message `b2ed890f`, source key-cape `e0b5fae`): +`groups_claim=groups`, bound `groups=[fluid-telegram-operators]`; bound +`/assurance/level=aal2` with `bound_claims_type=string` (as CCR-2026-0034); +callbacks are the two loopback CLI URIs on 8250 plus the operator-tunnel UI +callback on 18200. Recorded in the plan's OIDC role shape. The executor now +always pins `aal2` and accepts that exact UI callback, refusing the retired +public one (`tests/test_oidc_matrix.py`, 153 passed). Two inputs remain, both +human: the founder go for net-kingdom to create the LLDAP group with its +members, and a scoped grant for the live survey (no operator session today). + ## Extend the construction and credential-change contracts ```task