diff --git a/Makefile b/Makefile index a0e8401..9a482d0 100644 --- a/Makefile +++ b/Makefile @@ -203,6 +203,11 @@ service-identity-test: ## Run service-identity schema/validator tests (NK-WP-005 service-identity-check: ## Evaluate the checked-in service identity declarations (read-only, offline) python3 tools/service-identity/validate.py tools/service-identity/declarations/*.yaml +SI_METADATA ?= ../railiance-platform/docs/evidence/2026-11-03-nk-wp-0054-t04-identity-metadata +service-identity-observe: ## Declared vs owner live metadata, read-only (NK-WP-0054-T04): SI_METADATA=dir + python3 tools/service-identity/observe.py --openbao $(SI_METADATA)/openbao-identity-metadata.json \ + --postgresql $(SI_METADATA)/postgresql-identity-metadata.json + bounded-execution-admission-test: ## Run bounded-execution admission validator tests (NK-WP-0056-T01) python3 -m pytest tools/bounded-execution-admission/tests diff --git a/docs/evidence/2026-11-07-nk-wp-0054-t04-conformance/README.md b/docs/evidence/2026-11-07-nk-wp-0054-t04-conformance/README.md new file mode 100644 index 0000000..ed77cfa --- /dev/null +++ b/docs/evidence/2026-11-07-nk-wp-0054-t04-conformance/README.md @@ -0,0 +1,43 @@ +# NK-WP-0054-T04: first live conformance run, 2026-11-07 + +`report.json` was produced by `tools/service-identity/observe.py` with `--now 2026-11-07T14:00:00Z` (exit 1: +one non-conformant finding). The run was read-only and offline. It read no Secret and changed nothing. + +**Inputs.** The railiance-platform owner metadata, at `b0b6c0c`, in +`docs/evidence/2026-11-03-nk-wp-0054-t04-identity-metadata/`: + +| File | SHA-256 | collected_at | +|---|---|---| +| `openbao-identity-metadata.json` | `60da6df0934dcf9a9a5e25c7f05724a3b5edc719065ff5d5fe03e5537f58aa1b` | 2026-11-03T20:18:53Z | +| `postgresql-identity-metadata.json` | `e627278b02250d87986ae40e43e38c6ceaecdbc56652b447ac7324734f00541a` | 2026-11-03T20:18:54Z | + +The declarations are those at this commit, all with `owner_confirmed: false`. The freshness bound is 7 days, +and both inputs were 89.7 h old. + +## Result + +Declared identities: 5 conformant, 2 unknown, 0 non-conformant. + +| Identity | Verdict | Owner | Why | +|---|---|---|---| +| openbao/netkingdom-platform-admin | unknown | railiance-platform | `token_max_ttl` 0. Renewal falls back to the mount or system maximum, which the export omits. The declaration says a one-hour token. | +| bounded-executor/net-kingdom-bounded-executor | unknown | railiance-platform | `kubernetes/net-kingdom-bind-executor-svc-authelia-write` is absent. The export predates the 2026-11-04 go-live. | +| credential-broker, fluid-telegram-operators, secrets-engine-openbao, coding-agent | conformant | railiance-platform | Policies, aal2, groups, TTLs and the observe-only boundary all match. | +| postgresql/privacyidea | conformant | railiance-platform | No privilege flags; owns `privacyidea_db`. | + +Undeclared observed roles: 1 non-conformant, 14 unknown. + +- **Non-conformant:** `keycape/platform-admin` grants the full `platform-admin` policy and binds only + `groups: [net-kingdom-admins]`, with no `/assurance/level` aal2 claim. It still lists the retired + `bao.coulomb.social` callback. This is the "compatibility alias" from railiance-platform `docs/openbao.md`. It + shares KeyCape client `openbao-admin`, and its role is not declared in railiance-platform `openbao/auth/`. + So a second administrator login exists that the aal2 rule does not cover. +- **Unknown (SI-11, undeclared):** + - nine human OIDC KV-read roles on `netkingdom` without aal2: eight bound to `net-kingdom-admins` and one + to `whynot-design`; + - `kubernetes/external-secrets-issue-core`, which shares a declared policy; + - four PostgreSQL login roles: `postgres` (superuser, CNPG), `streaming_replica` (CNPG), `interhub` and + `probe`. + +Not covered: KeyCape (its live view is KEY-WP-0045-T02, founder-run), LLDAP, Authelia and privacyIDEA. Posture +feedback does not consume these findings yet. diff --git a/docs/evidence/2026-11-07-nk-wp-0054-t04-conformance/report.json b/docs/evidence/2026-11-07-nk-wp-0054-t04-conformance/report.json new file mode 100644 index 0000000..f0f5bc4 --- /dev/null +++ b/docs/evidence/2026-11-07-nk-wp-0054-t04-conformance/report.json @@ -0,0 +1,232 @@ +{ + "as_of": "2026-11-07T14:00:00Z", + "freshness": [ + { + "age_hours": 89.7, + "collected_at": "2026-11-03T20:18:53Z", + "fresh": true, + "max_age_days": 7, + "service": "openbao", + "source_kind": "live" + }, + { + "age_hours": 89.7, + "collected_at": "2026-11-03T20:18:54Z", + "fresh": true, + "max_age_days": 7, + "service": "postgresql", + "source_kind": "live" + } + ], + "identities": [ + { + "evidence": "openbao", + "findings": [ + { + "detail": "netkingdom/platform-admin has no role max ttl; renewal is bounded by the mount/system default, which the export does not include", + "identity": "netkingdom-platform-admin", + "owner": "railiance-platform", + "rule": "lifetime", + "verdict": "unknown" + } + ], + "identity": "netkingdom-platform-admin", + "observed_as": [ + "netkingdom/platform-admin" + ], + "owner": "railiance-platform", + "service": "openbao", + "verdict": "unknown" + }, + { + "evidence": "openbao", + "findings": [], + "identity": "credential-broker-keycape-client-secret-generate", + "observed_as": [ + "netkingdom/credential-broker-keycape-client-secret-generate" + ], + "owner": "railiance-platform", + "service": "openbao", + "verdict": "conformant" + }, + { + "evidence": "openbao", + "findings": [], + "identity": "fluid-telegram-operators", + "observed_as": [ + "netkingdom/fluid-telegram-hall-of-helix-workload-kv" + ], + "owner": "railiance-platform", + "service": "openbao", + "verdict": "conformant" + }, + { + "evidence": "openbao", + "findings": [], + "identity": "secrets-engine-openbao", + "observed_as": [ + "keycape-services/secrets-engine-openbao" + ], + "owner": "railiance-platform", + "service": "openbao", + "verdict": "conformant" + }, + { + "evidence": "openbao", + "findings": [], + "identity": "coding-agent", + "observed_as": [ + "netkingdom/coding-agent", + "approle/coding-agent-railiance-platform" + ], + "owner": "railiance-platform", + "service": "openbao", + "verdict": "conformant" + }, + { + "evidence": "openbao", + "findings": [ + { + "detail": "role kubernetes/net-kingdom-bind-executor-svc-authelia-write is not in the export (missing, or created after collected_at)", + "identity": "net-kingdom-bounded-executor", + "owner": "railiance-platform (OpenBao role) for NetKingdom", + "rule": "observed", + "verdict": "unknown" + } + ], + "identity": "net-kingdom-bounded-executor", + "observed_as": [ + "kubernetes/net-kingdom-bind-executor-svc-authelia-write" + ], + "owner": "railiance-platform (OpenBao role) for NetKingdom", + "service": "bounded-executor", + "verdict": "unknown" + }, + { + "evidence": "postgresql", + "findings": [], + "identity": "privacyidea", + "observed_as": [ + "net-kingdom-pg/privacyidea" + ], + "owner": "railiance-platform", + "service": "postgresql", + "verdict": "conformant" + } + ], + "summary": { + "conformant": 5, + "unknown": 2 + }, + "undeclared": [ + { + "detail": "undeclared role grants admin policies ['platform-admin'] without binding aal2", + "identity": "keycape/platform-admin", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "non-conformant" + }, + { + "detail": "undeclared role shares declared policies ['workload-kv-read-issue-core-runtime']", + "identity": "kubernetes/external-secrets-issue-core", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-binky-company-email-imap'])", + "identity": "netkingdom/binky-company-email-imap-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-binky-qonto-api'])", + "identity": "netkingdom/binky-qonto-api-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-forgejo-admin'])", + "identity": "netkingdom/forgejo-admin-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-informed-decision-sitting-requester-client'])", + "identity": "netkingdom/informed-decision-sitting-requester-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-policy-nexus-forgejo-source'])", + "identity": "netkingdom/policy-nexus-forgejo-source-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-railiance-backup-offsite-lane'])", + "identity": "netkingdom/railiance-backup-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-secrets-engine-approval-client'])", + "identity": "netkingdom/secrets-engine-approval-client-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['net-kingdom-admins'], policies ['workload-kv-read-secrets-engine-requester-client'])", + "identity": "netkingdom/secrets-engine-requester-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared human OIDC role without aal2 (groups ['whynot-design'], policies ['workload-kv-read-whynot-design-npm-publish'])", + "identity": "netkingdom/whynot-design-workload-kv-read", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared login role (flags none, owns ['interhub'])", + "identity": "net-kingdom-pg/interhub", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared login role (flags ['superuser', 'create_role', 'create_db', 'replication', 'bypass_rls'], owns ['postgres'])", + "identity": "net-kingdom-pg/postgres", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared login role (flags none, owns ['probe'])", + "identity": "net-kingdom-pg/probe", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + }, + { + "detail": "undeclared login role (flags ['replication'], owns nothing)", + "identity": "net-kingdom-pg/streaming_replica", + "owner": "railiance-platform", + "rule": "SI-11", + "verdict": "unknown" + } + ], + "undeclared_summary": { + "non-conformant": 1, + "unknown": 14 + } +} diff --git a/tools/service-identity/observe.py b/tools/service-identity/observe.py new file mode 100644 index 0000000..f8e3477 --- /dev/null +++ b/tools/service-identity/observe.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +# /// script +# dependencies = ["PyYAML>=6,<7"] +# /// +"""Live conformance check: declared service identities against owner metadata (NK-WP-0054-T04). + +Compares the checked-in declarations (`tools/service-identity/declarations/`) with value-safe, owner-provided +metadata (railiance-platform `service-identity-metadata.v1`, exported by `scripts/service_identity_metadata_export.py`). +Each mapped identity ends `conformant`, `non-conformant` or `unknown`; every finding names its owner. Observed roles +that no declaration covers are reported too, because an undeclared login path is itself a finding. + +Offline and read-only: it reads two JSON files and the declarations, never contacts a service, never reads a Secret +and changes nothing. The result is a proposal input for posture feedback, not authority. + + python3 tools/service-identity/observe.py --openbao FILE --postgresql FILE [--now ISO] [--max-age-days 7] [--json] +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import pathlib +import sys +from typing import Any + +import yaml + +ROOT = pathlib.Path(__file__).resolve().parents[2] +DECLARATIONS = ROOT / "tools/service-identity/declarations" +SCHEMA = "railiance-platform.service-identity-metadata.v1" +AAL2 = "/assurance/level" + +# Declared identity -> the OpenBao roles that realize it, with the facts the declaration states about them. +# Kept here, not in the declaration schema, until the owner confirms the declarations (owner_confirmed: false). +OPENBAO_MAP: dict[tuple[str, str], list[dict[str, Any]]] = { + ("openbao", "netkingdom-platform-admin"): [ + {"mount": "netkingdom", "role": "platform-admin", "policies": ["platform-admin", "operator-custody"], + "groups": ["net-kingdom-admins"], "ttl": 3600}], + ("openbao", "credential-broker-keycape-client-secret-generate"): [ + {"mount": "netkingdom", "role": "credential-broker-keycape-client-secret-generate", + "policies": ["credential-broker-keycape-client-secret-generate-issuer"], "groups": ["net-kingdom-admins"], + "ttl": 900}], + ("openbao", "fluid-telegram-operators"): [ + {"mount": "netkingdom", "role": "fluid-telegram-hall-of-helix-workload-kv", + "policies": ["workload-kv-fluid-telegram-hall-of-helix-operator"], "groups": ["fluid-telegram-operators"], + "ttl": 900, "max_ttl": 1800, "kv_data_paths": 4, "no_list_delete": True}], + ("openbao", "secrets-engine-openbao"): [ + {"mount": "keycape-services", "role": "secrets-engine-openbao", "policies": ["secrets-engine-login-self"], + "ttl": 300, "bound_subject": "service:secrets-engine"}], + ("openbao", "coding-agent"): [ + {"mount": "netkingdom", "role": "coding-agent", + "policies": ["agent-high-risk-boundary", "workload-kv-read-issue-core-runtime"], "ttl": 900, "max_ttl": 900, + "observe_only": True}, + {"mount": "approle", "role": "coding-agent-railiance-platform", + "policies": ["agent-high-risk-boundary", "workload-kv-read-issue-core-runtime"], "ttl": 900, "max_ttl": 900, + "observe_only": True}], + ("bounded-executor", "net-kingdom-bounded-executor"): [ + {"mount": "kubernetes", "role": "net-kingdom-bind-executor-svc-authelia-write", + "service_account": ("sso", "nk-bind-executor")}], +} + +# Policies that make a role an administrator of OpenBao itself. +ADMIN_POLICIES = {"platform-admin", "operator-custody"} + + +def load_metadata(path: pathlib.Path, service: str, now: dt.datetime, max_age: dt.timedelta) -> tuple[dict, dict]: + doc = json.loads(path.read_text()) + if doc.get("schema") != SCHEMA or doc.get("service") != service: + raise SystemExit(f"{path}: not {SCHEMA} for {service}") + if doc.get("value_safe") is not True: + raise SystemExit(f"{path}: not marked value_safe; refusing to read it") + collected = dt.datetime.fromisoformat(doc["collected_at"].replace("Z", "+00:00")) + age = now - collected + freshness = {"service": service, "collected_at": doc["collected_at"], "source_kind": doc.get("source_kind"), + "age_hours": round(age.total_seconds() / 3600, 1), "max_age_days": max_age.days, + "fresh": dt.timedelta(0) <= age <= max_age} + return doc[service], freshness + + +def finding(identity: str, owner: str, verdict: str, rule: str, detail: str) -> dict[str, str]: + return {"identity": identity, "owner": owner, "verdict": verdict, "rule": rule, "detail": detail} + + +def check_role(spec: dict, role: dict | None, declared: dict, ident: str, owner: str) -> list[dict]: + where = f"{spec['mount']}/{spec['role']}" + if role is None: + return [finding(ident, owner, "unknown", "observed", + f"role {where} is not in the export (missing, or created after collected_at)")] + out = [] + if "policies" in spec and sorted(role.get("token_policies", [])) != sorted(spec["policies"]): + out.append(finding(ident, owner, "non-conformant", "grants", + f"{where} policies {role.get('token_policies')} != declared {spec['policies']}")) + claims = role.get("bound_claims") or {} + if declared.get("second_factor") == "required" and claims.get(AAL2) != "aal2": + out.append(finding(ident, owner, "non-conformant", "second_factor", + f"{where} does not bind {AAL2}=aal2")) + if "groups" in spec and sorted(claims.get("groups") or []) != sorted(spec["groups"]): + out.append(finding(ident, owner, "non-conformant", "consumers", + f"{where} binds groups {claims.get('groups')} != declared {spec['groups']}")) + if "ttl" in spec and role.get("token_ttl_seconds", 0) > spec["ttl"]: + out.append(finding(ident, owner, "non-conformant", "lifetime", + f"{where} token ttl {role.get('token_ttl_seconds')}s > declared {spec['ttl']}s")) + if "max_ttl" in spec and role.get("token_max_ttl_seconds") != spec["max_ttl"]: + out.append(finding(ident, owner, "non-conformant", "lifetime", + f"{where} max ttl {role.get('token_max_ttl_seconds')}s != declared {spec['max_ttl']}s")) + if "ttl" in spec and "max_ttl" not in spec and not role.get("token_max_ttl_seconds") \ + and not role.get("token_explicit_max_ttl_seconds"): + out.append(finding(ident, owner, "unknown", "lifetime", + f"{where} has no role max ttl; renewal is bounded by the mount/system default, " + "which the export does not include")) + if "bound_subject" in spec and role.get("bound_subject") != spec["bound_subject"]: + out.append(finding(ident, owner, "non-conformant", "subject", + f"{where} bound_subject {role.get('bound_subject')} != {spec['bound_subject']}")) + if "service_account" in spec: + ns, sa = spec["service_account"] + if role.get("bound_service_account_names") != [sa] or role.get("bound_service_account_namespaces") != [ns]: + out.append(finding(ident, owner, "non-conformant", "consumers", f"{where} is not bound to exactly {ns}/{sa}")) + rules = role.get("effective_rules") or [] + if spec.get("observe_only"): + data_reads = [r["path"] for r in rules if "/data/" in r["path"] + and set(r["capabilities"]) & {"read", "create", "update", "delete", "list"} + and "deny" not in r["capabilities"]] + if data_reads: + out.append(finding(ident, owner, "non-conformant", "grants", f"{where} can read KV data: {data_reads}")) + elif not role.get("effective_complete"): + out.append(finding(ident, owner, "unknown", "grants", f"{where} effective rules are incomplete")) + if "kv_data_paths" in spec: + data = [r for r in rules if "/data/" in r["path"]] + if len(data) != spec["kv_data_paths"] or any(r["wildcard"] for r in data): + out.append(finding(ident, owner, "non-conformant", "grants", + f"{where} has {len(data)} KV data paths (declared {spec['kv_data_paths']} exact)")) + if spec.get("no_list_delete") and any({"list", "delete"} & set(r["capabilities"]) for r in data): + out.append(finding(ident, owner, "non-conformant", "grants", f"{where} allows list or delete")) + return out + + +def check_openbao(meta: dict, decls: dict[str, dict]) -> tuple[list[dict], list[dict]]: + roles = {(r["mount"], r["name"]): r for r in meta["roles"]} + results, mapped = [], set() + for (service, ident), specs in OPENBAO_MAP.items(): + doc = decls.get(service) + declared = next((i for i in (doc or {}).get("identities", []) if i["name"] == ident), None) + if declared is None: + continue + owner = declared.get("owner", doc.get("owner")) + findings = [] + for spec in specs: + mapped.add((spec["mount"], spec["role"])) + findings += check_role(spec, roles.get((spec["mount"], spec["role"])), declared, ident, owner) + verdict = ("non-conformant" if any(f["verdict"] == "non-conformant" for f in findings) + else "unknown" if findings else "conformant") + results.append({"service": service, "evidence": "openbao", "identity": ident, "owner": owner, "verdict": verdict, + "observed_as": [f"{s['mount']}/{s['role']}" for s in specs], "findings": findings}) + + # Undeclared roles: alternative paths to a declared identity's privileges, and human logins without aal2. + declared_policies = {p for specs in OPENBAO_MAP.values() for s in specs for p in s.get("policies", [])} + undeclared = [] + for key, role in sorted(roles.items()): + if key in mapped: + continue + where = f"{key[0]}/{key[1]}" + policies = set(role.get("token_policies", [])) + claims = role.get("bound_claims") or {} + human = role.get("role_type") == "oidc" + if policies & ADMIN_POLICIES: + undeclared.append(finding(where, "railiance-platform", "non-conformant", "SI-11", + f"undeclared role grants admin policies {sorted(policies & ADMIN_POLICIES)}" + + ("" if claims.get(AAL2) == "aal2" else " without binding aal2"))) + elif policies & declared_policies: + undeclared.append(finding(where, "railiance-platform", "unknown", "SI-11", + f"undeclared role shares declared policies {sorted(policies & declared_policies)}")) + elif human and claims.get(AAL2) != "aal2": + undeclared.append(finding(where, "railiance-platform", "unknown", "SI-11", + f"undeclared human OIDC role without aal2 (groups {claims.get('groups')}, " + f"policies {sorted(policies)})")) + return results, undeclared + + +def check_postgresql(meta: dict, decls: dict[str, dict]) -> tuple[list[dict], list[dict]]: + roles = {r["name"]: r for r in meta["roles"]} + owners = {d["name"]: d["owner"] for d in meta["databases"]} + doc = decls.get("postgresql") or {} + results, declared_names = [], set() + for ident in doc.get("identities", []): + name, owner = ident["name"], ident.get("owner", doc.get("owner")) + declared_names.add(name) + role, findings = roles.get(name), [] + if role is None: + findings.append(finding(name, owner, "unknown", "observed", "role not in the export")) + else: + for flag in ("superuser", "create_role", "create_db", "replication", "bypass_rls"): + if role.get(flag): + findings.append(finding(name, owner, "non-conformant", "grants", f"role has {flag}")) + if ident.get("interactive_login") is False and role.get("member_of"): + findings.append(finding(name, owner, "unknown", "grants", f"member of {role['member_of']}")) + for grant in ident.get("grants", []): + if grant["mode"] == "ADMINISTER" and owners.get(grant["scope"]) != name: + findings.append(finding(name, owner, "non-conformant", "grants", + f"declared owner of {grant['scope']}, observed {owners.get(grant['scope'])}")) + verdict = ("non-conformant" if any(f["verdict"] == "non-conformant" for f in findings) + else "unknown" if findings else "conformant") + results.append({"service": "postgresql", "evidence": "postgresql", "identity": name, "owner": owner, "verdict": verdict, + "observed_as": [f"{meta['cluster']}/{name}"], "findings": findings}) + undeclared = [] + for name, role in sorted(roles.items()): + if name in declared_names: + continue + flags = [f for f in ("superuser", "create_role", "create_db", "replication", "bypass_rls") if role.get(f)] + owns = sorted(db for db, o in owners.items() if o == name) + undeclared.append(finding(f"{meta['cluster']}/{name}", "railiance-platform", "unknown", "SI-11", + f"undeclared login role (flags {flags or 'none'}, owns {owns or 'nothing'})")) + return results, undeclared + + +def run(openbao: pathlib.Path, postgresql: pathlib.Path, now: dt.datetime, max_age_days: int) -> dict: + decls = {d["service"]: d for d in (yaml.safe_load(p.read_text()) for p in sorted(DECLARATIONS.glob("*.yaml")))} + max_age = dt.timedelta(days=max_age_days) + ob_meta, ob_fresh = load_metadata(openbao, "openbao", now, max_age) + pg_meta, pg_fresh = load_metadata(postgresql, "postgresql", now, max_age) + ob_results, ob_undeclared = check_openbao(ob_meta, decls) + pg_results, pg_undeclared = check_postgresql(pg_meta, decls) + results = ob_results + pg_results + stale = [f["service"] for f in (ob_fresh, pg_fresh) if not f["fresh"]] + if stale: # stale evidence cannot establish conformance + for r in results: + if r["verdict"] == "conformant" and r["evidence"] in stale: + r["verdict"] = "unknown" + r["findings"].append(finding(r["identity"], r["owner"], "unknown", "freshness", "evidence is stale")) + counts: dict[str, int] = {} + for r in results: + counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1 + return {"as_of": now.isoformat(timespec="seconds").replace("+00:00", "Z"), "freshness": [ob_fresh, pg_fresh], + "summary": counts, "identities": results, "undeclared": ob_undeclared + pg_undeclared, + "undeclared_summary": {"non-conformant": sum(f["verdict"] == "non-conformant" for f in ob_undeclared + pg_undeclared), + "unknown": sum(f["verdict"] == "unknown" for f in ob_undeclared + pg_undeclared)}} + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--openbao", type=pathlib.Path, required=True) + ap.add_argument("--postgresql", type=pathlib.Path, required=True) + ap.add_argument("--now", help="ISO time for the freshness bound (default: now)") + ap.add_argument("--max-age-days", type=int, default=7) + ap.add_argument("--json", action="store_true") + args = ap.parse_args(argv) + now = dt.datetime.fromisoformat(args.now.replace("Z", "+00:00")) if args.now else dt.datetime.now(dt.UTC) + report = run(args.openbao, args.postgresql, now, args.max_age_days) + if args.json: + print(json.dumps(report, indent=2, sort_keys=True)) + else: + for f in report["freshness"]: + print(f"evidence {f['service']}: collected {f['collected_at']}, {f['age_hours']} h old, " + f"{'fresh' if f['fresh'] else 'STALE'} (bound {f['max_age_days']} d)") + for r in report["identities"]: + print(f"{r['service']}/{r['identity']}: {r['verdict']} [owner {r['owner']}]") + for f in r["findings"]: + print(f" - {f['verdict']} {f['rule']}: {f['detail']}") + for f in report["undeclared"]: + print(f"undeclared {f['identity']}: {f['verdict']} {f['rule']} [owner {f['owner']}]: {f['detail']}") + print(f"summary: {report['summary']}; undeclared: {report['undeclared_summary']} (as of {report['as_of']})") + nonconf = report["summary"].get("non-conformant", 0) + report["undeclared_summary"]["non-conformant"] + return 1 if nonconf else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/service-identity/tests/test_observe.py b/tools/service-identity/tests/test_observe.py new file mode 100644 index 0000000..e5002ff --- /dev/null +++ b/tools/service-identity/tests/test_observe.py @@ -0,0 +1,145 @@ +"""Live conformance check against synthetic owner metadata (NK-WP-0054-T04).""" + +from __future__ import annotations + +import copy +import datetime as dt +import importlib.util +import json +import pathlib + +import pytest + +HERE = pathlib.Path(__file__).resolve().parent +spec = importlib.util.spec_from_file_location("observe", HERE.parent / "observe.py") +O = importlib.util.module_from_spec(spec) +spec.loader.exec_module(O) + +NOW = dt.datetime(2026, 11, 7, 12, tzinfo=dt.UTC) +COLLECTED = "2026-11-05T12:00:00Z" + + +def oidc(name, policies, groups, aal2=True, ttl=900, max_ttl=900, rules=()): + claims = {"groups": groups} | ({"/assurance/level": "aal2"} if aal2 else {}) + return {"mount": "netkingdom", "name": name, "role_type": "oidc", "token_policies": policies, + "bound_claims": claims, "token_ttl_seconds": ttl, "token_max_ttl_seconds": max_ttl, + "effective_complete": True, "effective_rules": list(rules)} + + +def kv(path, *caps): + return {"path": path, "capabilities": list(caps), "wildcard": False} + + +AGENT_RULES = [kv("platform/data/workloads/x", "deny"), kv("platform/metadata/workloads/x", "read")] + + +def openbao_doc(): + roles = [ + oidc("platform-admin", ["platform-admin", "operator-custody"], ["net-kingdom-admins"], ttl=3600, max_ttl=3600), + oidc("credential-broker-keycape-client-secret-generate", + ["credential-broker-keycape-client-secret-generate-issuer"], ["net-kingdom-admins"]), + oidc("fluid-telegram-hall-of-helix-workload-kv", ["workload-kv-fluid-telegram-hall-of-helix-operator"], + ["fluid-telegram-operators"], max_ttl=1800, + rules=[kv(f"platform/data/workloads/ft/{i}", "create", "read", "update") for i in range(4)]), + {"mount": "keycape-services", "name": "secrets-engine-openbao", "role_type": "jwt", + "token_policies": ["secrets-engine-login-self"], "bound_subject": "service:secrets-engine", + "token_ttl_seconds": 300, "token_max_ttl_seconds": 300, "effective_rules": []}, + {"mount": "netkingdom", "name": "coding-agent", "role_type": "jwt", + "token_policies": ["agent-high-risk-boundary", "workload-kv-read-issue-core-runtime"], + "token_ttl_seconds": 900, "token_max_ttl_seconds": 900, "effective_complete": True, + "effective_rules": AGENT_RULES}, + {"mount": "approle", "name": "coding-agent-railiance-platform", "role_type": "approle", + "token_policies": ["agent-high-risk-boundary", "workload-kv-read-issue-core-runtime"], + "token_ttl_seconds": 900, "token_max_ttl_seconds": 900, "effective_complete": True, + "effective_rules": AGENT_RULES}, + {"mount": "kubernetes", "name": "net-kingdom-bind-executor-svc-authelia-write", "role_type": "kubernetes", + "bound_service_account_names": ["nk-bind-executor"], "bound_service_account_namespaces": ["sso"], + "token_policies": ["x"], "effective_rules": []}, + ] + return {"schema": O.SCHEMA, "service": "openbao", "value_safe": True, "source_kind": "live", + "collected_at": COLLECTED, "openbao": {"auth_mounts": [], "policies": [], "roles": roles}} + + +def pg_doc(): + role = {"name": "privacyidea", "can_login": True, "superuser": False, "create_role": False, "create_db": False, + "replication": False, "bypass_rls": False, "member_of": []} + return {"schema": O.SCHEMA, "service": "postgresql", "value_safe": True, "source_kind": "live", + "collected_at": COLLECTED, + "postgresql": {"cluster": "pg", "roles": [role], + "databases": [{"name": "privacyidea_db", "owner": "privacyidea"}]}} + + +def run(tmp_path, ob=None, pg=None, now=NOW): + a, b = tmp_path / "ob.json", tmp_path / "pg.json" + a.write_text(json.dumps(ob or openbao_doc())) + b.write_text(json.dumps(pg or pg_doc())) + return O.run(a, b, now, 7) + + +def verdicts(report): + return {r["identity"]: r["verdict"] for r in report["identities"]} + + +def test_matching_metadata_is_conformant(tmp_path): + report = run(tmp_path) + assert set(verdicts(report).values()) == {"conformant"}, report + assert report["undeclared"] == [] + + +def test_missing_aal2_binding_is_non_conformant(tmp_path): + ob = openbao_doc() + del ob["openbao"]["roles"][0]["bound_claims"]["/assurance/level"] + assert verdicts(run(tmp_path, ob=ob))["netkingdom-platform-admin"] == "non-conformant" + + +def test_undeclared_admin_path_without_aal2_is_non_conformant(tmp_path): + ob = openbao_doc() + alias = copy.deepcopy(ob["openbao"]["roles"][0]) + alias.update(mount="keycape", token_policies=["platform-admin"]) + del alias["bound_claims"]["/assurance/level"] + ob["openbao"]["roles"].append(alias) + report = run(tmp_path, ob=ob) + [f] = report["undeclared"] + assert f["identity"] == "keycape/platform-admin" and f["verdict"] == "non-conformant" and f["owner"] + assert O.main(["--openbao", str(tmp_path / "ob.json"), "--postgresql", str(tmp_path / "pg.json"), + "--now", "2026-11-07T12:00:00Z"]) == 1 + + +def test_unbounded_max_ttl_is_unknown_not_a_pass(tmp_path): + ob = openbao_doc() + ob["openbao"]["roles"][0]["token_max_ttl_seconds"] = 0 + assert verdicts(run(tmp_path, ob=ob))["netkingdom-platform-admin"] == "unknown" + + +def test_agent_data_read_is_non_conformant(tmp_path): + ob = openbao_doc() + ob["openbao"]["roles"][4]["effective_rules"] = [kv("platform/data/workloads/x", "read")] + assert verdicts(run(tmp_path, ob=ob))["coding-agent"] == "non-conformant" + + +def test_missing_role_is_unknown(tmp_path): + ob = openbao_doc() + ob["openbao"]["roles"].pop() + assert verdicts(run(tmp_path, ob=ob))["net-kingdom-bounded-executor"] == "unknown" + + +def test_postgres_privilege_flag_and_undeclared_role(tmp_path): + pg = pg_doc() + pg["postgresql"]["roles"][0]["create_db"] = True + pg["postgresql"]["roles"].append({"name": "postgres", "superuser": True, "member_of": []}) + report = run(tmp_path, pg=pg) + assert verdicts(report)["privacyidea"] == "non-conformant" + assert [f["identity"] for f in report["undeclared"]] == ["pg/postgres"] + + +def test_stale_evidence_cannot_be_conformant(tmp_path): + report = run(tmp_path, now=NOW + dt.timedelta(days=30)) + assert set(verdicts(report).values()) == {"unknown"} + assert not any(f["fresh"] for f in report["freshness"]) + + +def test_refuses_metadata_not_marked_value_safe(tmp_path): + ob = openbao_doc() + ob["value_safe"] = False + with pytest.raises(SystemExit): + run(tmp_path, ob=ob) diff --git a/workplans/NK-WP-0054-service-identity-policy.md b/workplans/NK-WP-0054-service-identity-policy.md index 87ce57b..e59d9ff 100644 --- a/workplans/NK-WP-0054-service-identity-policy.md +++ b/workplans/NK-WP-0054-service-identity-policy.md @@ -195,7 +195,7 @@ status: wait priority: medium assignee: claude-code depends_on: [NK-WP-0054-T03] -blocking_reason: "OpenBao and PostgreSQL live metadata delivered 2026-11-03 (railiance-platform b0b6c0c). Next: run the conformance check against it with the freshness bound on collected_at; KeyCape live view (KEY-WP-0045-T02) is still the founder's." +blocking_reason: "First run 2026-11-07 (OpenBao, PostgreSQL): keycape/platform-admin alias is an undeclared admin login without aal2, routed to railiance-platform. Remaining: owner answers on that and the 14 undeclared roles, a fresh OpenBao export after the 2026-11-04 go-live, the founder's KeyCape live view (KEY-WP-0045-T02), then the posture-feedback feed." state_hub_task_id: "c8db4607-9306-572b-a3cb-c35ca5989ec7" ``` @@ -231,6 +231,15 @@ The founder's live export commands are in RPF-WP-0067-T03. net-kingdom-pg, 5 roles, 4 databases), schema `railiance-platform.service-identity-metadata.v1`. Collected by the founder; no Secret read, secret-shape guard passed. Apply the evidence-freshness bound to `collected_at`. +**2026-11-07 first run:** `tools/service-identity/observe.py` (`make service-identity-observe`, 9 tests) compares +the declarations against that metadata, with a 7-day freshness bound. +[Report](../docs/evidence/2026-11-07-nk-wp-0054-t04-conformance/README.md): declared identities 5 conformant, +2 unknown (platform-admin has no role max TTL; the executor's OpenBao role postdates the export). Undeclared: +**1 non-conformant**, `keycape/platform-admin`, the compatibility alias mount. It grants `platform-admin` without +an aal2 binding and keeps the retired `bao.coulomb.social` callback. There are also 14 unknown: nine human KV-read roles without +aal2, one ESO role sharing a declared policy, and four PostgreSQL roles. All owners are railiance-platform; the +findings are routed to them. KeyCape, LLDAP, Authelia and privacyIDEA are not covered yet. + ## Split the directory bind from the human admin ```task