diff --git a/credential-change-requests/CCR-2026-0055-net-kingdom-privacyidea-bind.yaml b/credential-change-requests/CCR-2026-0055-net-kingdom-privacyidea-bind.yaml new file mode 100644 index 0000000..ee5fcff --- /dev/null +++ b/credential-change-requests/CCR-2026-0055-net-kingdom-privacyidea-bind.yaml @@ -0,0 +1,87 @@ +id: CCR-2026-0055 +kind: credential-change-request +schema_version: 1 +request_type: attended-service-credential-delivery +title: NetKingdom privacyIDEA resolver directory bind (svc-privacyidea) custody +status: proposed +created: '2026-10-31' +updated: '2026-10-31' +requester: + agent: claude-code + reason: 'NK-WP-0056-T04, fourth lane (after CCR-2026-0052/0053/0054): move the svc-privacyidea + LLDAP bind out of the founder''s personal SOPS custody into platform custody. + privacyIDEA stores the resolver password encrypted in its own database and accepts + it only through pi-admin with a fresh MFA code, so no workload reads it: delivery + is an attended write-through.' +review: + required: true + required_approvers: + - platform-owner + - net-kingdom-owner + comments: [] +target: + domain: infotech + tenant: platform + workload: net-kingdom-privacyidea + environment: production + purpose: Hold the svc-privacyidea LLDAP bind password in platform custody so that + its rotation needs no decrypted custody copy; privacyIDEA receives it only through + the attended resolver write. No other privacyIDEA, LLDAP or service credential. +openbao: + mount: platform + kv_path: platform/workloads/net-kingdom/directory-binds/svc-privacyidea + fields: + - LDAP_BIND_PASSWORD + auth: + method: attended-oidc + mount: netkingdom + role: platform-admin + runtime_access: false + initial_cas: 0 +access_frontdoor: + type: attended-service-config + catalog_id: openbao-platform-admin-login + readiness: pending-review + resolvable: false +lifecycle: + deactivate: Delete the KV entry only after the founder custody copy is confirmed + current; privacyIDEA keeps its stored resolver password. + rotate: Change the svc-privacyidea password in LLDAP, write the next KV version + with CAS on the current version, then run realm_bootstrap.py --rotate-bind --from-openbao + (pi-admin + MFA) and verify with an OTP login. Founder-run. + compromised: Reset svc-privacyidea in LLDAP first (read-only bind, lldap_strict_readonly), + then rotate as above. +delivery: + surface: attended-service-config + service: privacyIDEA (sso), LDAP resolver netkingdom-lldap + target: resolver BINDPW, stored encrypted in the privacyIDEA database + procedure: net-kingdom sso-mfa/k8s/privacyidea/realm_bootstrap.py --rotate-bind + --from-openbao + gate: attended platform-admin (aal2) OpenBao session and pi-admin with a fresh MFA + code +risk: + classification: medium + notes: + - svc-privacyidea is a read-only directory bind (lldap_strict_readonly); a leak + exposes directory attributes, not write access. + - No workload, Kubernetes or ESO reader exists; only the attended platform-admin + session reads the value, in memory. + - Initial KV write is CAS=0 from custody after the value is proven current by an + LLDAP sign-in as svc-privacyidea; no rotation happens in this request. + - Coding agents are fenced from the data and metadata paths in agent-high-risk-boundary + before the first write. +verification: + positive: + - The KV value signs in to LLDAP as svc-privacyidea (current password), compared + without display + - An OTP login resolves the user through the resolver after any write-through + negative: + - Coding-agent boundary denies data and metadata of the path + - No auth role or policy grants the path to a workload + activation_conditions: + - Explicit platform-owner and net-kingdom-owner approval of this exact request (sha256-bound + by the setup tool). + - Attended platform-admin (aal2) session; the founder runs setup with the decrypted + custody copy and the write-through with pi-admin MFA. + - Positive and negative results recorded in value-free receipts. + evidence: [] diff --git a/docs/credential-lane-designs/attended-service-credential-delivery.md b/docs/credential-lane-designs/attended-service-credential-delivery.md new file mode 100644 index 0000000..3800a24 --- /dev/null +++ b/docs/credential-lane-designs/attended-service-credential-delivery.md @@ -0,0 +1,21 @@ +# Attended service credential delivery + +Request type `attended-service-credential-delivery` (`scripts/attended_service_delivery.py`), first used by +CCR-2026-0055 (NetKingdom `svc-privacyidea`, NK-WP-0056-T04). + +Some services keep a credential in their own encrypted store and accept it only through an administrator API +that requires a person's MFA. privacyIDEA's LDAP resolver is one: its `BINDPW` lives encrypted in the +privacyIDEA database and only `pi-admin` with a fresh code may change it. ESO cannot deliver such a value, and an +unattended job would need an administrator credential without MFA. + +The contract therefore holds the value in OpenBao and nothing else: + +- `openbao.auth` is the attended `platform-admin` OIDC session on `netkingdom`; `runtime_access: false`; + `initial_cas: 0`. No policy, auth role, ESO object or other access may be declared. +- The runtime frontdoor stays disabled (`access_frontdoor.resolvable: false`). +- `delivery` names the service, the target inside it, the owner's procedure and the gate (the person's MFA). +- Approval follows `review.required_approvers`; from `applied` onward every listed role must have approved. + +The owner's procedure reads the value in memory under the attended session and writes it into the service; the +person's MFA remains the gate. A rotation needs no decrypted custody copy. Modelled on +`attended-host-key-delivery` (CCR-2026-0028), which delivers to a host file instead of a service. diff --git a/scripts/attended_service_delivery.py b/scripts/attended_service_delivery.py new file mode 100644 index 0000000..3431986 --- /dev/null +++ b/scripts/attended_service_delivery.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""attended-service-credential-delivery CCRs: a value in platform custody that no workload reads. + +NetKingdom NK-WP-0056-T04 (svc-privacyidea). Some services keep a credential in +their own encrypted store and accept it only through an administrator API that +requires a person's MFA (privacyIDEA's LDAP resolver bind). The value is held in +OpenBao, written once with CAS=0 from the attended platform-admin session, and +delivered by an attended procedure that reads it in memory and writes it into +the service. No Kubernetes or workload auth role, policy or ESO object exists, +and the runtime frontdoor stays disabled. Modelled on attended-host-key-delivery +(CCR-2026-0028). Nothing here reads, prints or accepts a secret value. +""" +from __future__ import annotations + +import re +from typing import Any + +REQUEST_TYPE = "attended-service-credential-delivery" +APPROVED = {"applied", "verified", "active"} # approve sets "approved" after the first reviewer +KV_RE = re.compile(r"^platform/workloads/[a-z0-9][a-z0-9-]*(/[a-z0-9][a-z0-9-]*)+$") +FIELD_RE = re.compile(r"^[A-Z][A-Z0-9_]*$") +AUTH = {"method": "attended-oidc", "mount": "netkingdom", "role": "platform-admin"} +DELIVERY_KEYS = ("surface", "service", "target", "procedure", "gate") +DOC = "docs/credential-lane-designs/attended-service-credential-delivery.md" + + +def validate_contract(ccr: dict[str, Any]) -> list[str]: + errors: list[str] = [] + if ccr.get("request_type") != REQUEST_TYPE: + errors.append("unsupported attended service contract") + openbao = ccr.get("openbao") + if not isinstance(openbao, dict): + return errors + ["openbao must be an object"] + if openbao.get("mount") != "platform" or not KV_RE.match(str(openbao.get("kv_path", ""))): + errors.append("openbao.kv_path must be a platform/workloads/... path on mount platform") + fields = openbao.get("fields") + if not isinstance(fields, list) or not fields or not all(isinstance(f, str) and FIELD_RE.match(f) for f in fields): + errors.append("openbao.fields must list upper-case field names") + if openbao.get("auth") != AUTH: + errors.append("openbao.auth must be the attended platform-admin OIDC session only") + if openbao.get("runtime_access") is not False or openbao.get("initial_cas") != 0: + errors.append("runtime_access must be false and initial_cas 0") + extra = set(openbao) - {"mount", "kv_path", "fields", "auth", "runtime_access", "initial_cas"} + if extra: + errors.append("openbao must not declare policies, roles or other access: " + ", ".join(sorted(extra))) + if (ccr.get("access_frontdoor") or {}).get("resolvable") is not False: + errors.append("runtime credential frontdoor must remain disabled") + delivery = ccr.get("delivery") + if not isinstance(delivery, dict) or any(not isinstance(delivery.get(k), str) or not delivery[k] for k in DELIVERY_KEYS): + errors.append("delivery needs surface, service, target, procedure and gate") + elif delivery["surface"] != "attended-service-config": + errors.append("delivery.surface must be attended-service-config") + if ccr.get("status") in APPROVED: + comments = (ccr.get("review") or {}).get("comments") or [] + approved = {str(c.get("reviewer", "")).removeprefix("User (").removesuffix(")") + for c in comments if c.get("decision") == "approved"} + for role in (ccr.get("review") or {}).get("required_approvers") or []: + if role not in approved: + errors.append("explicit role approval missing: " + role) + for section, keys in {"verification": ["positive", "negative", "activation_conditions"], + "lifecycle": ["deactivate", "rotate", "compromised"]}.items(): + for key in keys: + if not (ccr.get(section) or {}).get(key): + errors.append(f"{section}.{key} required") + return errors + + +def procedure_note(ccr: dict[str, Any]) -> str: + return (f"Attended delivery into {ccr['delivery']['service']} through {ccr['delivery']['procedure']} " + f"({DOC}). No workload policy, auth role or ESO object is created.") diff --git a/scripts/credential-change.py b/scripts/credential-change.py index 290a299..f407c84 100755 --- a/scripts/credential-change.py +++ b/scripts/credential-change.py @@ -518,6 +518,8 @@ def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]: owner = importlib.util.module_from_spec(spec) spec.loader.exec_module(owner) errors.extend(owner.validate_contract(ccr)) + elif request_type == "attended-service-credential-delivery": + errors.extend(attended_service_module().validate_contract(ccr)) elif request_type in LANE_MODULES: errors.extend(lane_module(ccr).validate(ccr, REPO_DIR)) else: @@ -724,6 +726,8 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]: def render_plan(ccr: dict[str, Any]) -> str: if ccr.get("request_type") == "attended-host-key-delivery": return "Use docs/credential-lane-designs/railiance-clock-host-delivery.md through the attended owner procedure. No workload policy or auth role is created." + if ccr.get("request_type") == "attended-service-credential-delivery": + return attended_service_module().procedure_note(ccr) openbao = ccr["openbao"] auth = openbao["auth"] payload = auth_payload(ccr) @@ -755,6 +759,8 @@ def render_plan(ccr: dict[str, Any]) -> str: def render_operator_commands(ccr: dict[str, Any]) -> str: if ccr.get("request_type") == "attended-host-key-delivery": return "Use docs/credential-lane-designs/railiance-clock-host-delivery.md through the attended owner procedure. No workload policy or auth role is created." + if ccr.get("request_type") == "attended-service-credential-delivery": + return attended_service_module().procedure_note(ccr) openbao = ccr["openbao"] auth = openbao["auth"] auth_path = f"auth/{auth['mount']}/role/{auth['role']}" @@ -835,6 +841,15 @@ LANE_MODULES = { } +def attended_service_module(): + """Owner contract for attended-service-credential-delivery (no workload access).""" + import importlib.util + spec = importlib.util.spec_from_file_location("attended_service_delivery", REPO_DIR / "scripts/attended_service_delivery.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def lane_module(ccr: dict[str, Any]): """Lazy-load the applier module for a lane request type, or None.""" name = LANE_MODULES.get(ccr.get("request_type")) @@ -1115,6 +1130,8 @@ def render_applier_apply_plan(ccr: dict[str, Any], warnings: list[str]) -> str: def runbook_readiness_blockers(ccr: dict[str, Any]) -> list[str]: if ccr.get("request_type") == "attended-host-key-delivery": return ["use the attended owner procedure in docs/credential-lane-designs/railiance-clock-host-delivery.md"] + if ccr.get("request_type") == "attended-service-credential-delivery": + return ["use the attended procedure: " + attended_service_module().procedure_note(ccr)] blockers: list[str] = [] status = ccr.get("status") if status not in RUNBOOK_ALLOWED_STATUSES: diff --git a/tests/test_attended_service_delivery.py b/tests/test_attended_service_delivery.py new file mode 100644 index 0000000..6f9c45f --- /dev/null +++ b/tests/test_attended_service_delivery.py @@ -0,0 +1,40 @@ +"""attended-service-credential-delivery contract (NetKingdom NK-WP-0056-T04); no values.""" +import copy +import importlib.util +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location("asd", ROOT / "scripts/attended_service_delivery.py") +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) +CCR = yaml.safe_load((ROOT / "credential-change-requests/CCR-2026-0055-net-kingdom-privacyidea-bind.yaml").read_text()) + + +def test_checked_in_request_is_valid(): + assert m.validate_contract(CCR) == [] + + +def test_any_workload_access_is_refused(): + for change in ({"policy_name": "x"}, {"runtime_access": True}, {"initial_cas": 1}, + {"auth": {"method": "kubernetes", "mount": "kubernetes", "role": "x"}}): + ccr = copy.deepcopy(CCR); ccr["openbao"].update(change) + assert m.validate_contract(ccr), change + ccr = copy.deepcopy(CCR); ccr["access_frontdoor"]["resolvable"] = True + assert "runtime credential frontdoor must remain disabled" in m.validate_contract(ccr) + + +def test_delivery_and_lifecycle_are_required(): + ccr = copy.deepcopy(CCR); del ccr["delivery"]["gate"] + assert "delivery needs surface, service, target, procedure and gate" in m.validate_contract(ccr) + ccr = copy.deepcopy(CCR); ccr["lifecycle"]["rotate"] = "" + assert "lifecycle.rotate required" in m.validate_contract(ccr) + + +def test_applied_stage_needs_every_required_approval(): + ccr = copy.deepcopy(CCR); ccr["status"] = "verified" + ccr["review"]["comments"] = [{"reviewer": "User (platform-owner)", "decision": "approved"}] + assert m.validate_contract(ccr) == ["explicit role approval missing: net-kingdom-owner"] + ccr["review"]["comments"].append({"reviewer": "net-kingdom-owner", "decision": "approved"}) + assert m.validate_contract(ccr) == []