CCR-2026-0055 (proposed) and request type attended-service-credential-delivery
A value held in platform custody that no workload reads, delivered into a service by an attended, MFA-gated procedure (privacyIDEA resolver bind for NetKingdom NK-WP-0056-T04). The contract forbids any policy, auth role, ESO object or runtime frontdoor. Modelled on attended-host-key-delivery. 4 tests. The 3 failures in test_credential_change.py also occur on main. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Assistant: claude-code Assistant-Model: opus Assistant-Process: 896183@bnt-lap001 Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2
This commit is contained in:
parent
674a7132d0
commit
2d4a3838bf
5 changed files with 235 additions and 0 deletions
|
|
@ -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: []
|
||||||
|
|
@ -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.
|
||||||
70
scripts/attended_service_delivery.py
Normal file
70
scripts/attended_service_delivery.py
Normal file
|
|
@ -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.")
|
||||||
|
|
@ -518,6 +518,8 @@ def validate_ccr(path: Path) -> tuple[dict[str, Any], list[str], list[str]]:
|
||||||
owner = importlib.util.module_from_spec(spec)
|
owner = importlib.util.module_from_spec(spec)
|
||||||
spec.loader.exec_module(owner)
|
spec.loader.exec_module(owner)
|
||||||
errors.extend(owner.validate_contract(ccr))
|
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:
|
elif request_type in LANE_MODULES:
|
||||||
errors.extend(lane_module(ccr).validate(ccr, REPO_DIR))
|
errors.extend(lane_module(ccr).validate(ccr, REPO_DIR))
|
||||||
else:
|
else:
|
||||||
|
|
@ -724,6 +726,8 @@ def auth_payload(ccr: dict[str, Any]) -> dict[str, Any]:
|
||||||
def render_plan(ccr: dict[str, Any]) -> str:
|
def render_plan(ccr: dict[str, Any]) -> str:
|
||||||
if ccr.get("request_type") == "attended-host-key-delivery":
|
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."
|
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"]
|
openbao = ccr["openbao"]
|
||||||
auth = openbao["auth"]
|
auth = openbao["auth"]
|
||||||
payload = auth_payload(ccr)
|
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:
|
def render_operator_commands(ccr: dict[str, Any]) -> str:
|
||||||
if ccr.get("request_type") == "attended-host-key-delivery":
|
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."
|
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"]
|
openbao = ccr["openbao"]
|
||||||
auth = openbao["auth"]
|
auth = openbao["auth"]
|
||||||
auth_path = f"auth/{auth['mount']}/role/{auth['role']}"
|
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]):
|
def lane_module(ccr: dict[str, Any]):
|
||||||
"""Lazy-load the applier module for a lane request type, or None."""
|
"""Lazy-load the applier module for a lane request type, or None."""
|
||||||
name = LANE_MODULES.get(ccr.get("request_type"))
|
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]:
|
def runbook_readiness_blockers(ccr: dict[str, Any]) -> list[str]:
|
||||||
if ccr.get("request_type") == "attended-host-key-delivery":
|
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"]
|
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] = []
|
blockers: list[str] = []
|
||||||
status = ccr.get("status")
|
status = ccr.get("status")
|
||||||
if status not in RUNBOOK_ALLOWED_STATUSES:
|
if status not in RUNBOOK_ALLOWED_STATUSES:
|
||||||
|
|
|
||||||
40
tests/test_attended_service_delivery.py
Normal file
40
tests/test_attended_service_delivery.py
Normal file
|
|
@ -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) == []
|
||||||
Loading…
Add table
Add a link
Reference in a new issue