diff --git a/docs/construction-plan-format.md b/docs/construction-plan-format.md index f45844c..b1077f3 100644 --- a/docs/construction-plan-format.md +++ b/docs/construction-plan-format.md @@ -98,7 +98,7 @@ one, per `MASON-WP-0001`'s own scoping. ## Binding OpenBao approval to execution inputs -The AppRole and Kubernetes-auth builders additionally require two frontmatter +The AppRole, Kubernetes-auth and attended OIDC matrix builders require two frontmatter fields: `build_spec` and `approved_spec_sha256`. `build_spec` is the complete value-free output of `ops_mason.executor.build_spec_document(spec)`. It includes the engine, exact policy/role/path and identity bindings, token bounds, policy @@ -148,8 +148,8 @@ by this format migration. The existing AppRole engine creates only exact KV-v2 read lanes: one literal entry path, `read` on data and metadata, no wildcard, empty/parent path segment, -HCL injection, or write capability. OIDC matrix writers remain a separate, -blocked contract under MASON-WP-0005. Role and policy names must be single +HCL injection, or write capability. The separate attended OIDC matrix engine is implemented under MASON-WP-0005; +its live construction remains blocked on identity, platform review and plan approval. Role and policy names must be single literal names; Kubernetes service-account names/namespaces must be explicit, nonempty, unique DNS labels. Token use counts must be explicit nonnegative integers (zero is deliberately unlimited when approved); TTLs are positive @@ -185,3 +185,37 @@ Do not blindly retry or delete them: inspect structure and audit metadata, resolve/revoke any partial issuance through the scoped custody procedure, and review a fresh delivery destination before retrying. The executor does not claim transactional rollback or automatically widen its revocation authority. + + +### Attended OIDC operator matrix + +`OIDCKVSpec` and `build_oidc_kv_lane` implement the ops-mason side of the +fluid-telegram four-entry matrix. Draft and review `build_spec_document(spec)` +and its digest through the same exact-spec approval process. The spec requires +campaign coordinates, one owner-confirmed group, identity-confirmation and +platform-matrix-review references, explicit loopback CLI callbacks, policy/role +names and token bounds. These references are review inputs, not authorization +tokens or proof that an owner accepted a draft. + +The renderer emits four literal KV-v2 data stanzas: operator-app read; +operator-session and bot-token create/read/update; redaction-salt create/read. +It adds only the exact token revoke-self update endpoint. There are no metadata, +parent, sibling, wildcard, deletion or salt-update grants. The engine writes +policy/auth-role structure only; it never reads/writes KV values or issues a +login token. The approved role has one bound group, no default policy, explicit +15-minute initial / 30-minute maximum lifetime caps and non-periodic tokens. + +Initial callback support is deliberately limited to owner-reviewed +`http://localhost:8250/oidc/callback` and +`http://127.0.0.1:8250/oidc/callback`. A private UI callback or additional MFA +claim shape requires matching owner-confirmed contract work before extending +this engine. A group parameter or a local test does not establish installed +issuer/MFA assurance. MASON-WP-0005-T01/T03/T05 keep those live obligations. + +On failure the engine suppresses Bao output and does not claim a successful +audit receipt. It can leave a written policy if the auth-role write fails; +inspect object/audit metadata and use the approved rollback procedure before a +retry. Metadata-only local tests do not prove installed KV create/update/CAS +behavior or agent-policy effective denial. Those remain native owner checks. +Platform CCR request-type support and review remain a separate T02 dependency; +the generic read-lane CCR renderer still must refuse this write matrix. diff --git a/docs/evidence/2026-10-04-operator-matrix-source.json b/docs/evidence/2026-10-04-operator-matrix-source.json new file mode 100644 index 0000000..3262d4f --- /dev/null +++ b/docs/evidence/2026-10-04-operator-matrix-source.json @@ -0,0 +1,19 @@ +{ + "workplan": "MASON-WP-0005", + "task": "T02", + "status": "progress", + "actual_source_tests": 92, + "elapsed_seconds": 1.33, + "engine": "openbao-oidc-kv-read-write", + "execution": "mock subprocess only; no OpenBao calls", + "platform_ccr_extension": "not implemented; pending owner support/review", + "identity": "unconfirmed live group/MFA/callbacks remain T01", + "construction_plan": "draft unchanged", + "remaining": [ + "platform contract support/review", + "confirmed IAM claim and assurance", + "live survey", + "exact construction approval", + "native effective-policy and salt/CAS proof" + ] +} diff --git a/src/ops_mason/executor.py b/src/ops_mason/executor.py index 3ba2eb8..357941b 100644 --- a/src/ops_mason/executor.py +++ b/src/ops_mason/executor.py @@ -76,6 +76,107 @@ class KubernetesKVSpec: bao_bin: str = "bao" +@dataclass(frozen=True) +class OIDCKVSpec: + """Attended four-entry operator matrix; all identity evidence is reviewed input.""" + policy_name: str + role_name: str + campaign_path: str + bound_group: str + allowed_redirect_uris: tuple[str, ...] + identity_confirmation_ref: str + platform_matrix_review_ref: str + auth_mount: str = "netkingdom" + token_ttl: str = "15m" + token_max_ttl: str = "30m" + bao_bin: str = "bao" + audit_log_path: Path | None = None + + +_OPERATOR_MATRIX = ( + ("operator-app", ("read",)), + ("operator-session", ("create", "read", "update")), + ("bot-token", ("create", "read", "update")), + ("redaction-salt", ("create", "read")), +) + + +def oidc_policy_hcl(spec: OIDCKVSpec) -> str: + """Render exact KV data paths plus self-revocation; no metadata grants.""" + _validate_oidc_spec(spec) + mount, rest = spec.campaign_path.split("/", 1) + stanzas = [] + for entry, caps in _OPERATOR_MATRIX: + encoded = ", ".join(json.dumps(c) for c in caps) + stanzas.append(f'path "{mount}/data/{rest}/{entry}" {{\n capabilities = [{encoded}]\n}}\n') + stanzas.append('path "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n') + return "\n".join(stanzas) + + +def _validate_oidc_spec(spec: OIDCKVSpec) -> None: + from urllib.parse import urlsplit + for value, label in ((spec.role_name, "OIDC role"), (spec.auth_mount, "auth mount"), + (spec.bound_group, "bound group")): + _name(value, label) + if not isinstance(spec.campaign_path, str): + raise BuildRefused("campaign path must be literal") + parts = spec.campaign_path.split("/") + if len(parts) != 5 or parts[0:2] != ["platform", "workloads"] or parts[3] != "fluid-telegram": + raise BuildRefused("operator matrix requires platform/workloads//fluid-telegram/") + for part in parts: + _name(part, "campaign path segment") + for ref in (spec.identity_confirmation_ref, spec.platform_matrix_review_ref): + if not isinstance(ref, str) or not ref.strip() or ref.strip().lower() in {"pending", "unconfirmed", "unknown", "draft"}: + raise BuildRefused("confirmed identity and platform matrix review references are required") + ttl, maximum = _duration(spec.token_ttl, "token_ttl"), _duration(spec.token_max_ttl, "token_max_ttl") + if not 0 < ttl <= 900 or not ttl <= maximum <= 1800: + raise BuildRefused("attended OIDC token bounds must be at most 15m/30m") + if not isinstance(spec.allowed_redirect_uris, tuple) or not spec.allowed_redirect_uris: + raise BuildRefused("explicit OIDC callback tuple required") + if len(set(spec.allowed_redirect_uris)) != len(spec.allowed_redirect_uris): + raise BuildRefused("duplicate OIDC callback") + for uri in spec.allowed_redirect_uris: + if not isinstance(uri, str): + raise BuildRefused("OIDC callback must be a URI") + try: + u = urlsplit(uri) + valid = (u.scheme == "http" and u.hostname in {"localhost", "127.0.0.1"} + and u.port == 8250 and u.path == "/oidc/callback" and not u.username + and not u.password and not u.query and not u.fragment) + except ValueError: + valid = False + if not valid: + raise BuildRefused("initial matrix engine accepts only reviewed loopback CLI callbacks on port 8250") + + +def oidc_role_document(spec: OIDCKVSpec) -> dict: + _validate_oidc_spec(spec) + return { + "role_type": "oidc", "user_claim": "sub", "groups_claim": "groups", + "oidc_scopes": ["openid", "profile", "email", "groups"], + "bound_claims": {"groups": spec.bound_group}, + "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, + "token_explicit_max_ttl": spec.token_max_ttl, "token_num_uses": 0, + } + + +def build_oidc_kv_lane(plan: ConstructionPlan, spec: OIDCKVSpec) -> dict[str, str]: + """Write only policy/auth structure after fresh exact-spec approval.""" + spec = replace(spec) + plan = _approved_build(plan, spec) + _run(spec.bao_bin, ["policy", "write", spec.policy_name, "-"], input_text=oidc_policy_hcl(spec)) + _run(spec.bao_bin, ["write", f"auth/{spec.auth_mount}/role/{spec.role_name}", "-"], + input_text=json.dumps(oidc_role_document(spec))) + objects = {"approved_spec_sha256": plan.approved_spec_sha256, + "policy_name": spec.policy_name, "oidc_role_name": spec.role_name, + "campaign_path": spec.campaign_path} + record_build(plan_id=plan.id, plan_path=str(plan.path), approved_by=plan.approved_by or "", + approved_at=plan.approved_at or "", objects=objects, log_path=spec.audit_log_path) + return objects + + def _run(bao_bin: str, args: list[str], input_text: str | None = None) -> str: try: proc = subprocess.run( @@ -131,7 +232,7 @@ def _hash(value: str) -> None: raise BuildRefused("reused policy requires an exact SHA-256 content pin") -def _validate_spec(spec: AppRoleKVSpec | KubernetesKVSpec) -> None: +def _validate_spec(spec: AppRoleKVSpec | KubernetesKVSpec | OIDCKVSpec) -> None: _name(spec.policy_name, "policy name") _duration(spec.token_ttl, "token_ttl") if not isinstance(spec.bao_bin, str) or not spec.bao_bin.strip(): @@ -160,6 +261,8 @@ def _validate_spec(spec: AppRoleKVSpec | KubernetesKVSpec) -> None: spec.delivery_dir, spec.delivery_dir / "role_id", spec.delivery_dir / "secret_id" }: raise BuildRefused("audit path must not overlap credential delivery") + elif isinstance(spec, OIDCKVSpec): + _validate_oidc_spec(spec) else: _name(spec.role_name, "Kubernetes role name") _hash(spec.policy_sha256) @@ -173,7 +276,7 @@ def _validate_spec(spec: AppRoleKVSpec | KubernetesKVSpec) -> None: raise BuildRefused("duplicate service account binding") -def build_spec_document(spec: AppRoleKVSpec | KubernetesKVSpec) -> dict: +def build_spec_document(spec: AppRoleKVSpec | KubernetesKVSpec | OIDCKVSpec) -> dict: """Render the complete, value-free execution input for phase-3 review.""" _validate_spec(spec) fields = asdict(spec) @@ -183,7 +286,7 @@ def build_spec_document(spec: AppRoleKVSpec | KubernetesKVSpec) -> dict: elif isinstance(value, tuple): fields[key] = list(value) return { - "engine": "openbao-approle-kv" if isinstance(spec, AppRoleKVSpec) else "openbao-kubernetes-kv", + "engine": "openbao-approle-kv" if isinstance(spec, AppRoleKVSpec) else "openbao-oidc-kv-read-write" if isinstance(spec, OIDCKVSpec) else "openbao-kubernetes-kv", **fields, } diff --git a/tests/test_oidc_matrix.py b/tests/test_oidc_matrix.py new file mode 100644 index 0000000..7c2a4a0 --- /dev/null +++ b/tests/test_oidc_matrix.py @@ -0,0 +1,77 @@ +from dataclasses import replace +from unittest.mock import MagicMock, patch +import json +import pytest +from ops_mason.executor import OIDCKVSpec, BuildRefused, BuildError, oidc_policy_hcl, oidc_role_document, build_oidc_kv_lane, build_spec_document +from test_executor import _plan + + +def spec(tmp_path): + return OIDCKVSpec(policy_name='workload-kv-fluid-telegram-hall-of-helix-operator', + role_name='fluid-telegram-hall-of-helix-workload-kv', + campaign_path='platform/workloads/coulomb/fluid-telegram/hall-of-helix', + bound_group='reviewed-test-operators', allowed_redirect_uris=('http://localhost:8250/oidc/callback',), + identity_confirmation_ref='test-only:iam-owner-receipt', platform_matrix_review_ref='test-only:platform-owner-receipt', + audit_log_path=tmp_path/'audit.jsonl') + + +def test_matrix_has_exact_data_scope_and_write_once_salt(tmp_path): + s=spec(tmp_path);h=oidc_policy_hcl(s) + assert h.count('platform/data/workloads/coulomb/fluid-telegram/hall-of-helix/')==4 + assert 'metadata/' not in h and '*' not in h and '/destroy/' not in h + salt=h.split('/redaction-salt"')[1].split('}')[0] + assert '"create", "read"' in salt and '"update"' not in salt + app=h.split('/operator-app"')[1].split('}')[0] + assert '"read"' in app and '"create"' not in app + assert 'auth/token/revoke-self' in h + + +@pytest.mark.parametrize('field,value',[ + ('campaign_path','platform/workloads/coulomb/fluid-telegram/*'), + ('campaign_path','platform/workloads/coulomb/fluid-telegram'), + ('campaign_path','platform/workloads/coulomb/fluid-telegram/../sibling'), + ('campaign_path','platform/workloads/coulomb/another-workload/campaign'), + ('bound_group','*'),('bound_group','group,other'), + ('identity_confirmation_ref','unconfirmed'),('platform_matrix_review_ref','pending'), + ('allowed_redirect_uris',('https://public.example/ui/callback',)), + ('allowed_redirect_uris',('http://user@localhost:8250/oidc/callback',)), + ('allowed_redirect_uris',('http://localhost:8250/oidc/callback?x=1',)), + ('allowed_redirect_uris',()),('allowed_redirect_uris',('http://localhost:bad/oidc/callback',)), + ('token_ttl','16m'),('token_max_ttl','31m'),('token_max_ttl','1m')]) +def test_refuses_unsafe_matrix_before_bao(tmp_path,field,value): + s=replace(spec(tmp_path),**{field:value}) + with patch('ops_mason.executor.subprocess.run') as run: + with pytest.raises(BuildRefused):build_oidc_kv_lane(_plan(tmp_path,spec=spec(tmp_path)),s) + run.assert_not_called() + + +def test_draft_and_changed_approval_never_execute(tmp_path): + s=spec(tmp_path) + with patch('ops_mason.executor.subprocess.run') as run: + with pytest.raises(BuildRefused):build_oidc_kv_lane(_plan(tmp_path,status='draft',spec=s),s) + p=_plan(tmp_path,spec=s) + with pytest.raises(BuildRefused):build_oidc_kv_lane(p,replace(s,bound_group='other-reviewed-group')) + run.assert_not_called() + + +def test_exact_role_binding_and_value_free_build(tmp_path): + s=spec(tmp_path);p=_plan(tmp_path,spec=s) + with patch('ops_mason.executor.subprocess.run',return_value=MagicMock(returncode=0,stdout='suppressed-response')) as run: + result=build_oidc_kv_lane(p,s) + assert len(run.call_args_list)==2 + 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['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' + assert 'suppressed-response' not in s.audit_log_path.read_text() + assert result['approved_spec_sha256']==p.approved_spec_sha256 + + +def test_failure_suppresses_output_and_never_claims_success(tmp_path): + s=spec(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() 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 dd6d433..5de317a 100644 --- a/workplans/MASON-WP-0005-fluid-telegram-operator-credential-lane.md +++ b/workplans/MASON-WP-0005-fluid-telegram-operator-credential-lane.md @@ -9,7 +9,7 @@ flavor: planning owner: codex topic_slug: helix-forge created: "2026-09-04" -updated: "2026-10-03" +updated: "2026-10-04" state_hub_workstream_id: "483e56d6-6601-5377-9066-66225214c046" --- @@ -76,7 +76,7 @@ side. Re-qualified from `message-from:railiance-platform` to ```task id: MASON-WP-0005-T02 -status: todo +status: progress priority: high state_hub_task_id: "69c558d9-664f-5ce0-80b2-d2e7544353a3" ``` @@ -202,3 +202,25 @@ role. T01–T06 remain wait: accepted tenant/matrix, confirmed identity/MFA/callbacks, approved matching writer contracts, live survey, explicit construction approval and verification are outstanding. The platform design remains proposed. No lane was built or activated. Evidence and precise resumption conditions: [review](../docs/evidence/2026-09-28-loose-end-review.md). Existing tasks retain all remaining obligations; no new task or workplan was opened. + + +## Operator-matrix source delivery — 2026-10-04 + +T02 advances to progress. The ops-mason engine now renders the exact four-entry +OIDC matrix, writes only policy/auth structure, checks confirmed identity and +platform-review reference parameters, enforces narrow loopback callbacks and +bounded tokens, and reuses the existing fresh exact-spec construction approval +check before any Bao command. Value output is suppressed; audit records hold +only coordinates and approval digest. Actual source tests cover no-approval, +changed scope, unsafe paths/claims/callbacks, salt create-only capabilities, +role payload, no KV-value calls, and failure redaction. + +Platform's design is still a proposal: no matching operator-matrix request type +exists in its current CCR schema/validator. The old September note saying the +schema/validator are already drafted does not establish implementation or +acceptance. Platform contract support/review remains in T02; confirmed claim, +issuer/MFA assurance and callbacks remain T01, then live survey and explicit +exact-plan approval T03. No production role, policy, login or credential was +created. The construction plan stays draft; local synthetic identity references +in tests do not count as owner returns. This source wave also preserves all +unrelated dirty owner files and the existing untracked construction plan.