NK-WP-0056-T04: svc-privacyidea lane in OpenBao custody with attended write-through
bind-lane svc-privacyidea (CCR-2026-0055) has no workload reader. Setup and verify prove the value is current by an LLDAP sign-in as svc-privacyidea, fence coding agents, write KV with CAS=0 and refuse any workload role. realm_bootstrap.py --rotate-bind rewrites only BINDPW on the established service bind, --from-openbao reads it in memory under the attended platform-admin session, and an applied rotation requires --verify-user. Lane tests: 20; realm_bootstrap tests: 102. 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
12834b6e8a
commit
c28651e55b
5 changed files with 211 additions and 25 deletions
|
|
@ -231,3 +231,39 @@ def test_switch_bind_refuses_other_drift_or_unknown_bind():
|
|||
with pytest.raises(r.Refused):r.repair(Fake(state),lambda:'x',apply=True,switch_bind=True)
|
||||
state=snapshot();state['resolver']['data']['BINDDN']='uid=someone,ou=people,dc=netkingdom,dc=local'
|
||||
with pytest.raises(r.Refused):r.repair(Fake(state),lambda:'x',apply=True,switch_bind=True)
|
||||
|
||||
|
||||
def test_rotate_bind_rewrites_only_the_password_on_the_service_bind():
|
||||
client=Fake(snapshot());before=copy.deepcopy(client.state)
|
||||
preview=r.repair(Fake(snapshot()),lambda:(_ for _ in ()).throw(AssertionError('no read in preview')),rotate_bind=True)
|
||||
assert preview['actions']==['rotate-bind'] and preview['configuration_writes']==0
|
||||
report=r.repair(client,lambda:'rotated-synthetic',apply=True,rotate_bind=True)
|
||||
assert report['actions']==['rotate-bind'] and report['configuration_writes']==1
|
||||
assert client.calls==[('/resolver/'+r.RESOLVER,{'type':'ldapresolver','BINDDN':r.CORE['BINDDN'],'BINDPW':'rotated-synthetic'})]
|
||||
assert client.state==before
|
||||
|
||||
|
||||
def test_rotate_bind_refuses_legacy_bind_pending_repairs_or_combined_switch():
|
||||
with pytest.raises(r.Refused):r.repair(Fake(legacy()),lambda:'x',apply=True,rotate_bind=True)
|
||||
state=snapshot();del state['resolver']['data']['TIMEOUT']
|
||||
with pytest.raises(r.Refused):r.repair(Fake(state),lambda:'x',apply=True,rotate_bind=True)
|
||||
with pytest.raises(r.Refused):r.repair(Fake(snapshot()),lambda:'x',apply=True,rotate_bind=True,switch_bind=True)
|
||||
|
||||
|
||||
class Run:
|
||||
def __init__(self,policies=('platform-admin',),path='auth/netkingdom/oidc/callback',data=None):
|
||||
self.out={('token','lookup'):{'data':{'policies':list(policies),'path':path}},
|
||||
('read',):{'data':{'data':data if data is not None else {'LDAP_BIND_PASSWORD':'from-openbao-synthetic'}}}}
|
||||
self.argv=[]
|
||||
def __call__(self,argv):
|
||||
self.argv.append(argv)
|
||||
key=('token','lookup') if argv[1:3]==['token','lookup'] else ('read',)
|
||||
if key==('read',):assert argv[-1]==r.BIND_KV
|
||||
return subprocess.CompletedProcess(argv,0,json.dumps(self.out[key]).encode(),b'')
|
||||
|
||||
|
||||
def test_openbao_bind_reads_only_under_attended_platform_admin():
|
||||
assert r.openbao_bind(Run())=='from-openbao-synthetic'
|
||||
for run in (Run(policies=('default',)),Run(policies=('platform-admin','root')),Run(path='auth/kubernetes/login'),
|
||||
Run(data={'LDAP_BIND_PASSWORD':'x','OTHER':'y'})):
|
||||
with pytest.raises(r.Refused):r.openbao_bind(run)
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ BIND_LINE = 'user: uid=svc-authelia,ou=people,dc=netkingdom,dc=local'
|
|||
KEYCAPE_CCR = 'CCR-2026-0054-net-kingdom-keycape-bind.yaml'
|
||||
KEYCAPE_KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-keycape'
|
||||
KEYCAPE_DN = 'uid=svc-keycape,ou=people,dc=netkingdom,dc=local'
|
||||
PI_CCR = 'CCR-2026-0055-net-kingdom-privacyidea-bind.yaml'
|
||||
PI_KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-privacyidea'
|
||||
|
||||
pytestmark = pytest.mark.skipif(not (PLATFORM / 'credential-change-requests' / CCR).exists(),
|
||||
reason='railiance-platform checkout with CCR-2026-0052 required')
|
||||
|
|
@ -75,18 +77,21 @@ class FakeBao:
|
|||
|
||||
|
||||
def build(tmp_path, monkeypatch, name='svc-provisioner'):
|
||||
authelia, keycape = name == 'svc-authelia', name == 'svc-keycape'
|
||||
ccr_file = AUTHELIA_CCR if authelia else KEYCAPE_CCR if keycape else CCR
|
||||
authelia, keycape, pi = name == 'svc-authelia', name == 'svc-keycape', name == 'svc-privacyidea'
|
||||
ccr_file = AUTHELIA_CCR if authelia else KEYCAPE_CCR if keycape else PI_CCR if pi else CCR
|
||||
tool = load()
|
||||
platform = tmp_path / 'platform'
|
||||
(platform / 'credential-change-requests').mkdir(parents=True); (platform / 'openbao/policies').mkdir(parents=True)
|
||||
(platform / 'scripts').mkdir()
|
||||
shutil.copy(PLATFORM / 'scripts/credential-change.py', platform / 'scripts/credential-change.py')
|
||||
shutil.copy(PLATFORM / f'openbao/policies/net-kingdom-{name}-bind-eso.hcl', platform / 'openbao/policies/')
|
||||
if pi:
|
||||
shutil.copy(PLATFORM / 'scripts/attended_service_delivery.py', platform / 'scripts/')
|
||||
else:
|
||||
shutil.copy(PLATFORM / f'openbao/policies/net-kingdom-{name}-bind-eso.hcl', platform / 'openbao/policies/')
|
||||
import yaml
|
||||
ccr = yaml.safe_load((PLATFORM / 'credential-change-requests' / ccr_file).read_text())
|
||||
ccr['status'] = 'approved'
|
||||
ccr['openbao']['auth']['bound_claims_confirmed'] = True
|
||||
if not pi: ccr['openbao']['auth']['bound_claims_confirmed'] = True
|
||||
ccr['review']['comments'] = [{'reviewer': f'User ({r})', 'decision': 'approved', 'source_ref': 'statehub:decision:synthetic',
|
||||
'comment': 'synthetic', 'reviewed_at': '2026-10-30T00:00:00+00:00'}
|
||||
for r in ('platform-owner', 'net-kingdom-owner')]
|
||||
|
|
@ -95,7 +100,11 @@ def build(tmp_path, monkeypatch, name='svc-provisioner'):
|
|||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
monkeypatch.setattr(tool, 'PLATFORM', platform)
|
||||
bao = (FakeBao(AUTHELIA_KV, 'net-kingdom-svc-authelia-bind-eso') if authelia
|
||||
else FakeBao(KEYCAPE_KV, 'net-kingdom-svc-keycape-bind-eso') if keycape else FakeBao())
|
||||
else FakeBao(KEYCAPE_KV, 'net-kingdom-svc-keycape-bind-eso') if keycape
|
||||
else FakeBao(PI_KV, 'none') if pi else FakeBao())
|
||||
# LLDAP accepts exactly the account's current password.
|
||||
monkeypatch.setattr(tool, 'directory_login', lambda endpoint, user, password:
|
||||
user == name and password == live['value'])
|
||||
monkeypatch.setattr(tool, 'bao', bao)
|
||||
monkeypatch.setattr(tool, 'data', lambda r: json.loads(r.stdout))
|
||||
monkeypatch.setattr(tool, 'capabilities', lambda token, paths: json.loads(
|
||||
|
|
@ -136,7 +145,8 @@ def build(tmp_path, monkeypatch, name='svc-provisioner'):
|
|||
raise AssertionError(args)
|
||||
monkeypatch.setattr(tool, 'kube', kube)
|
||||
custody = tmp_path / 'custody'; (custody / 'lldap').mkdir(parents=True, mode=0o700); custody.chmod(0o700)
|
||||
key = 'LLDAP_SVC_AUTHELIA_PASS' if authelia else 'LLDAP_SVC_KEYCAPE_PASS' if keycape else 'LLDAP_SVC_PROVISIONER_PASS'
|
||||
key = {'svc-authelia': 'LLDAP_SVC_AUTHELIA_PASS', 'svc-keycape': 'LLDAP_SVC_KEYCAPE_PASS',
|
||||
'svc-privacyidea': 'LLDAP_SVC_PRIVACYIDEA_PASS'}.get(name, 'LLDAP_SVC_PROVISIONER_PASS')
|
||||
env = custody / 'lldap/secrets.env'; env.write_text(f'{key}={VALUE}\n'); env.chmod(0o600)
|
||||
return SimpleNamespace(tool=tool, bao=bao, live=live, digest=digest, custody=str(custody), ccr=path, name=name)
|
||||
|
||||
|
|
@ -296,3 +306,35 @@ def test_keycape_manifest_creates_a_dedicated_secret():
|
|||
'creationPolicy': 'Owner', 'deletionPolicy': 'Retain'}
|
||||
assert es['spec']['data'] == [{'secretKey': 'password', 'remoteRef': {
|
||||
'key': 'workloads/net-kingdom/directory-binds/svc-keycape', 'property': 'LDAP_BIND_PASSWORD'}}]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def privacyidea(tmp_path, monkeypatch):
|
||||
if not (PLATFORM / 'credential-change-requests' / PI_CCR).exists(): pytest.skip('CCR-2026-0055 required')
|
||||
return build(tmp_path, monkeypatch, 'svc-privacyidea')
|
||||
|
||||
|
||||
def test_privacyidea_setup_and_verify_need_no_workload_access(privacyidea):
|
||||
r = run(privacyidea, 'setup')
|
||||
assert r['status'] == 'custody_prepared' and r['custody_is_current'] and r['workload_access'] is False
|
||||
assert PI_KV in privacyidea.bao.writes
|
||||
assert not any(w.startswith(('auth/kubernetes/role/', 'sys/policies/acl/net-kingdom')) for w in privacyidea.bao.writes)
|
||||
assert run(privacyidea, 'verify')['status'] == 'custody_verified'
|
||||
|
||||
|
||||
def test_privacyidea_setup_refuses_a_password_ldap_rejects(privacyidea):
|
||||
privacyidea.live['value'] = OTHER # the directory has moved on; custody is stale
|
||||
with pytest.raises(privacyidea.tool.Refused, match='custody_not_current'): run(privacyidea, 'setup')
|
||||
assert privacyidea.bao.writes == []
|
||||
|
||||
|
||||
def test_privacyidea_verify_refuses_a_stale_kv_value(privacyidea):
|
||||
run(privacyidea, 'setup')
|
||||
privacyidea.live['value'] = OTHER # rotated in LLDAP without a new KV version
|
||||
with pytest.raises(privacyidea.tool.Refused, match='kv_not_current'): run(privacyidea, 'verify')
|
||||
|
||||
|
||||
def test_privacyidea_verify_refuses_a_workload_role(privacyidea):
|
||||
run(privacyidea, 'setup')
|
||||
privacyidea.bao.store['auth/kubernetes/role/net-kingdom-svc-privacyidea-bind-eso'] = {'policies': ['x']}
|
||||
with pytest.raises(privacyidea.tool.Refused, match='unexpected_workload_role'): run(privacyidea, 'verify')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue