From 034634ac5fec4c73c62c488bd2e942a655d059c3 Mon Sep 17 00:00:00 2001 From: tegwick Date: Sat, 31 Oct 2026 09:20:53 +0100 Subject: [PATCH] NK-WP-0056-T04: svc-authelia bind lane (source only, CCR-2026-0053) bind-lane.py gains per-lane Secret keys. Authelia keeps its bind user in authelia-config, so setup refuses unless the live configuration binds uid=svc-authelia, and only the ldap_password key is decoded. The ESO manifest merges ldap_password alone into authelia-secrets. The tests are parametrized per lane (12). tools/service-binds is now listed in security-tools.json, which fixes the index test that failed after the pilot. Co-Authored-By: Claude Opus 5.5 Assistant: claude-code Assistant-Model: opus Assistant-Process: 896183@bnt-lap001 Assistant-Session: 3a18c01a-3205-4d02-94db-9efff25649a2 --- sso-mfa/k8s/authelia/bind-eso.yaml | 57 ++++++++++++ tests/test_service_bind_lane.py | 92 +++++++++++++++---- tools/security-tools.json | 3 + tools/service-binds/bind-lane.py | 31 +++++-- workplans/NK-WP-0056-bounded-executor-path.md | 8 ++ 5 files changed, 164 insertions(+), 27 deletions(-) create mode 100644 sso-mfa/k8s/authelia/bind-eso.yaml diff --git a/sso-mfa/k8s/authelia/bind-eso.yaml b/sso-mfa/k8s/authelia/bind-eso.yaml new file mode 100644 index 0000000..fcdc4bf --- /dev/null +++ b/sso-mfa/k8s/authelia/bind-eso.yaml @@ -0,0 +1,57 @@ +# NK-WP-0056-T04, railiance-platform CCR-2026-0053 (second lane after the svc-provisioner pilot). +# Projects only the svc-authelia bind password from platform custody into the key +# ldap_password of the existing Secret sso/authelia-secrets (created by apply-service-secrets.py). +# Merge keeps every other key (session, storage, OIDC) and the Secret's ownership; Retain +# leaves the working Secret in place if this ExternalSecret is removed. Authelia reads the +# file only at start-up, so an identical projection needs no restart. +# Apply only after `tools/service-binds/bind-lane.py setup svc-authelia` reports equal custody. +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: net-kingdom-svc-authelia-bind-eso + namespace: sso +automountServiceAccountToken: false +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: net-kingdom-svc-authelia-bind + namespace: sso +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: platform + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: net-kingdom-svc-authelia-bind-eso + serviceAccountRef: + name: net-kingdom-svc-authelia-bind-eso + audiences: [openbao] +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: authelia-secrets + namespace: sso +spec: + refreshInterval: 1h + secretStoreRef: + kind: SecretStore + name: net-kingdom-svc-authelia-bind + target: + # Explicit target metadata stops ESO copying this object's kubectl + # last-applied annotation onto the Secret (refused by reject-secret-last-applied). + template: + metadata: {} + name: authelia-secrets + creationPolicy: Merge + deletionPolicy: Retain + data: + - secretKey: ldap_password + remoteRef: + key: workloads/net-kingdom/directory-binds/svc-authelia + property: LDAP_BIND_PASSWORD diff --git a/tests/test_service_bind_lane.py b/tests/test_service_bind_lane.py index 97a325e..4e05f27 100644 --- a/tests/test_service_bind_lane.py +++ b/tests/test_service_bind_lane.py @@ -16,6 +16,9 @@ CCR = 'CCR-2026-0052-net-kingdom-identity-provisioner-bind.yaml' VALUE = 'SyntheticProvisionerBind1234567890abcdef' OTHER = 'DifferentProvisionerBind1234567890abcdef' KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-provisioner' +AUTHELIA_CCR = 'CCR-2026-0053-net-kingdom-authelia-bind.yaml' +AUTHELIA_KV = 'platform/data/workloads/net-kingdom/directory-binds/svc-authelia' +BIND_LINE = 'user: uid=svc-authelia,ou=people,dc=netkingdom,dc=local' pytestmark = pytest.mark.skipif(not (PLATFORM / 'credential-change-requests' / CCR).exists(), reason='railiance-platform checkout with CCR-2026-0052 required') @@ -27,10 +30,11 @@ def load(): class FakeBao: - def __init__(self): + def __init__(self, kv=KV, role='net-kingdom-svc-provisioner-bind-eso'): + self.kv_path = kv; self.role = role self.store = {'sys/policies/acl/agent-high-risk-boundary': {'policy': 'path "x" { capabilities = ["deny"] }\n'}} self.kv = {}; self.writes = []; self.identity = {'policies': ['platform-admin'], 'path': 'auth/netkingdom/oidc/callback'} - self.login_ok = {('net-kingdom-svc-provisioner-bind-eso', 'openbao')} + self.login_ok = {(role, 'openbao')} def __call__(self, args, *, payload=None, token=None, allow_failure=False): out = lambda d, code=0, err=b'': SimpleNamespace(returncode=code, stdout=json.dumps(d).encode(), stderr=err) @@ -38,7 +42,7 @@ class FakeBao: if args[:2] == ['token', 'create']: return out({'auth': {'client_token': 'child'}}) if args[0] == 'read': path = args[-1] - if path == KV: + if path == self.kv_path: return out({'data': {'data': self.kv['value'], 'metadata': {'version': self.kv['version']}}}) if self.kv \ else out({}, 2, b'No value found at ' + path.encode()) if path in self.store: return out({'data': self.store[path]}) @@ -47,17 +51,17 @@ class FakeBao: path = args[-2] if args[-1] == '-' else args[-1] if path == 'auth/token/revoke': return out({}) if path == 'sys/capabilities': - caps = {p: (['read'] if p == KV and token is None and payload['token'] == 'reader' else ['deny']) + caps = {p: (['read'] if p == self.kv_path and token is None and payload['token'] == 'reader' else ['deny']) for p in payload['paths']} return out({'data': caps}) if path == 'auth/kubernetes/login': sa, aud = payload['jwt'].split('|') if (sa, aud) in self.login_ok: - return out({'auth': {'client_token': 'reader', 'token_policies': ['net-kingdom-svc-provisioner-bind-eso'], + return out({'auth': {'client_token': 'reader', 'token_policies': [self.role], 'lease_duration': 900}}) return out({}, 2, b'Code: 403') self.writes.append(path) - if path == KV: + if path == self.kv_path: assert payload['options'] == {'cas': 0} self.kv = {'value': payload['data'], 'version': 1}; return out({'data': {'version': 1}}) if path.startswith('auth/kubernetes/role/'): @@ -67,28 +71,28 @@ class FakeBao: raise AssertionError(args) -@pytest.fixture -def lane(tmp_path, monkeypatch): +def build(tmp_path, monkeypatch, name='svc-provisioner'): + authelia = name == 'svc-authelia' + ccr_file = AUTHELIA_CCR if authelia 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 / 'openbao/policies/net-kingdom-svc-provisioner-bind-eso.hcl', platform / 'openbao/policies/') + 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).read_text()) + ccr = yaml.safe_load((PLATFORM / 'credential-change-requests' / ccr_file).read_text()) ccr['status'] = 'approved' 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')] - path = platform / 'credential-change-requests' / CCR + path = platform / 'credential-change-requests' / ccr_file path.write_text(json.dumps(ccr, indent=2) + '\n') digest = hashlib.sha256(path.read_bytes()).hexdigest() monkeypatch.setattr(tool, 'PLATFORM', platform) - bao = FakeBao() - for name in ('bao',): - monkeypatch.setattr(tool, name, bao) + bao = FakeBao(AUTHELIA_KV, 'net-kingdom-svc-authelia-bind-eso') if authelia else FakeBao() + monkeypatch.setattr(tool, 'bao', bao) monkeypatch.setattr(tool, 'data', lambda r: json.loads(r.stdout)) monkeypatch.setattr(tool, 'capabilities', lambda token, paths: json.loads( bao(['write', '-format=json', 'sys/capabilities', '-'], payload={'paths': paths, 'token': token}).stdout)['data']) @@ -99,28 +103,45 @@ def lane(tmp_path, monkeypatch): if old.returncode != 0: bao(['write', path, '-'], payload=wanted) check(json.loads(bao(['read', '-format=json', path]).stdout)['data']) monkeypatch.setattr(tool, 'ensure', ensure) - live = {'value': VALUE, 'ready': 'True'} + live = {'value': VALUE, 'ready': 'True', 'bind_line': BIND_LINE} def kube(kubeconfig, args, payload=None): verb = args[2:5] if verb == ['get', 'secret', 'identity-provisioner-ldap']: return json.dumps({'data': {'username': base64.b64encode(b'svc-provisioner').decode(), 'password': base64.b64encode(live['value'].encode()).decode()}}).encode() - if verb == ['get', 'externalsecret', 'identity-provisioner-ldap']: + if authelia and verb == ['get', 'secret', 'authelia-secrets']: + return json.dumps({'data': {'session_secret': base64.b64encode(b'unrelated').decode(), + 'ldap_password': base64.b64encode(live['value'].encode()).decode()}}).encode() + if authelia and verb == ['get', 'configmap', 'authelia-config']: + return json.dumps({'data': {'configuration.yml': 'authentication_backend:\n ldap:\n ' + live['bind_line'] + '\n'}}).encode() + if verb == ['get', 'externalsecret', 'authelia-secrets' if authelia else 'identity-provisioner-ldap']: return json.dumps({'status': {'conditions': [{'type': 'Ready', 'status': live['ready']}]}}).encode() if verb[:2] == ['create', 'token']: return (args[4] + '|' + args[5].split('=', 1)[1]).encode() raise AssertionError(args) monkeypatch.setattr(tool, 'kube', kube) custody = tmp_path / 'custody'; (custody / 'lldap').mkdir(parents=True, mode=0o700); custody.chmod(0o700) - env = custody / 'lldap/secrets.env'; env.write_text(f'LLDAP_SVC_PROVISIONER_PASS={VALUE}\n'); env.chmod(0o600) - return SimpleNamespace(tool=tool, bao=bao, live=live, digest=digest, custody=str(custody), ccr=path) + key = 'LLDAP_SVC_AUTHELIA_PASS' if authelia else '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) + + +@pytest.fixture +def lane(tmp_path, monkeypatch): + return build(tmp_path, monkeypatch) + + +@pytest.fixture +def authelia(tmp_path, monkeypatch): + if not (PLATFORM / 'credential-change-requests' / AUTHELIA_CCR).exists(): pytest.skip('CCR-2026-0053 required') + return build(tmp_path, monkeypatch, 'svc-authelia') def run(ns, action): receipt = {} - if action == 'setup': ns.tool.setup('svc-provisioner', ns.digest, ns.custody, 'kc', receipt) - else: ns.tool.verify('svc-provisioner', ns.digest, 'kc', receipt) + if action == 'setup': ns.tool.setup(ns.name, ns.digest, ns.custody, 'kc', receipt) + else: ns.tool.verify(ns.name, ns.digest, 'kc', receipt) assert VALUE not in json.dumps(receipt) return receipt @@ -191,3 +212,34 @@ def test_setup_refuses_unconfirmed_binding(lane): def yaml_load(path): import yaml return yaml.safe_load(path.read_text()) + + +def test_authelia_setup_then_verify_reads_only_its_key(authelia): + r = run(authelia, 'setup') + assert r['status'] == 'custody_and_reader_prepared' and AUTHELIA_KV in authelia.bao.writes + assert KV not in authelia.bao.writes + assert run(authelia, 'verify')['status'] == 'reader_and_delivery_verified' + + +def test_authelia_setup_refuses_when_config_binds_another_account(authelia): + authelia.live['bind_line'] = 'user: uid=admin,ou=people,dc=netkingdom,dc=local' + with pytest.raises(authelia.tool.Refused, match='live_bind_user'): run(authelia, 'setup') + assert authelia.bao.writes == [] + + +def test_authelia_setup_refuses_custody_drift(authelia): + authelia.live['value'] = OTHER + with pytest.raises(authelia.tool.Refused, match='custody_drift'): run(authelia, 'setup') + assert authelia.bao.writes == [] + + +def test_authelia_manifest_matches_contract(): + import yaml + docs = [d for d in yaml.safe_load_all((ROOT / 'sso-mfa/k8s/authelia/bind-eso.yaml').read_text()) if d] + sa, store, es = docs + assert sa['metadata'] == {'name': 'net-kingdom-svc-authelia-bind-eso', 'namespace': 'sso'} + assert store['spec']['provider']['vault']['auth']['kubernetes']['role'] == 'net-kingdom-svc-authelia-bind-eso' + assert es['spec']['target'] == {'template': {'metadata': {}}, 'name': 'authelia-secrets', + 'creationPolicy': 'Merge', 'deletionPolicy': 'Retain'} + assert es['spec']['data'] == [{'secretKey': 'ldap_password', 'remoteRef': { + 'key': 'workloads/net-kingdom/directory-binds/svc-authelia', 'property': 'LDAP_BIND_PASSWORD'}}] diff --git a/tools/security-tools.json b/tools/security-tools.json index 8e4b710..cb7dbfe 100644 --- a/tools/security-tools.json +++ b/tools/security-tools.json @@ -23,6 +23,9 @@ {"id": "rotation-lanes", "path": "tools/rotation-lanes", "answers": "Does every known credential have a rotation lane with blast radius, verification step and owner?", "run": "make rotation-lanes-check", "test": "make rotation-lanes-test", "agent_may_run": "yes", "standard": "canon/standards/credential-management_v0.2.md"}, + {"id": "service-binds", "path": "tools/service-binds", "answers": "Is an LLDAP service bind held in OpenBao and delivered by ESO exactly as its approved CCR says, equal to the live Secret?", + "run": "python3 tools/service-binds/bind-lane.py setup|verify --ccr-sha256 --kubeconfig --receipt ", "test": "python3 -m pytest tests/test_service_bind_lane.py", "agent_may_run": "no-attended", + "standard": "canon/standards/credential-management_v0.2.md"}, {"id": "custody-record", "path": "tools/custody-record", "answers": "Is a platform-root custody record valid, and may a root grant be observed from it?", "run": "uv run tools/custody-record/validate.py ", "test": "make custody-record-test", "agent_may_run": "yes", "standard": "docs/adr/ADR-0017-root-grant-custody-and-account-authority.md"}, diff --git a/tools/service-binds/bind-lane.py b/tools/service-binds/bind-lane.py index e0353f3..988f70b 100755 --- a/tools/service-binds/bind-lane.py +++ b/tools/service-binds/bind-lane.py @@ -35,7 +35,18 @@ LANES = { 'secret': 'identity-provisioner-ldap', 'username': 'svc-provisioner', 'external_secret': 'identity-provisioner-ldap', }, + # Authelia keeps the bind user in its configuration, not in the Secret, and reads + # ldap_password from a file at start-up only. + 'svc-authelia': { + 'ccr': 'CCR-2026-0053-net-kingdom-authelia-bind.yaml', + 'custody_key': 'LLDAP_SVC_AUTHELIA_PASS', + 'secret': 'authelia-secrets', 'secret_key': 'ldap_password', 'username': 'svc-authelia', + 'bind_user_config': ('authelia-config', 'configuration.yml', + 'user: uid=svc-authelia,ou=people,dc=netkingdom,dc=local'), + 'external_secret': 'authelia-secrets', + }, } +BIND_NAMES = ('svc-authelia', 'svc-keycape', 'svc-privacyidea', 'svc-provisioner') FIELD = 'LDAP_BIND_PASSWORD' BOUNDARY = 'sys/policies/acl/agent-high-risk-boundary' APPROVERS = {'platform-owner', 'net-kingdom-owner'} @@ -69,7 +80,7 @@ def contract(name, expected): or auth['bound_claims'] != {'service_account_names': [role], 'service_account_namespaces': ['sso']} or auth.get('bound_claims_confirmed') is not True or auth.get('token_no_default_policy') is not True or a.get('metadata_read') is not False or any(auth.get(k) != '15m' for k in ('ttl', 'token_max_ttl', 'token_explicit_max_ttl')) - or d['namespace'] != 'sso' or d['secret'] != spec['secret'] or d['key'] != 'password' + or d['namespace'] != 'sso' or d['secret'] != spec['secret'] or d['key'] != spec.get('secret_key', 'password') or d['service_account'] != role): raise Refused('ccr_shape') lane = {'service_account': role, 'namespace': 'sso', 'role': role, 'policy': role, @@ -88,10 +99,17 @@ def kube(kubeconfig, args, payload=None): def live_secret(kubeconfig, lane): - raw = json.loads(kube(kubeconfig, ['-n', 'sso', 'get', 'secret', lane['secret'], '-o', 'json'])) - values = {k: base64.b64decode(v, validate=True).decode() for k, v in raw.get('data', {}).items()} - if values.get('username') != lane['username'] or not values.get('password'): raise Refused('live_secret_shape') - return values['password'] + raw = json.loads(kube(kubeconfig, ['-n', 'sso', 'get', 'secret', lane['secret'], '-o', 'json'])).get('data', {}) + key = lane.get('secret_key', 'password') + if 'bind_user_config' in lane: + name, entry, line = lane['bind_user_config'] + config = json.loads(kube(kubeconfig, ['-n', 'sso', 'get', 'configmap', name, '-o', 'json'])).get('data', {}) + if line not in [l.strip() for l in config.get(entry, '').splitlines()]: raise Refused('live_bind_user') + elif base64.b64decode(raw.get('username', ''), validate=True).decode() != lane['username']: + raise Refused('live_secret_shape') + value = base64.b64decode(raw.get(key, ''), validate=True).decode() + if not value: raise Refused('live_secret_shape') + return value def custody_value(root, lane): @@ -178,8 +196,7 @@ def verify(name, expected, kubeconfig, receipt): or not 0 < auth['lease_duration'] <= 900): raise Refused('reader_policy_or_ttl') receipt['phase'] = 'reader_capabilities' - others = ['platform/data/workloads/net-kingdom/directory-binds/' + n - for n in ('svc-authelia', 'svc-keycape', 'svc-privacyidea')] + others = ['platform/data/workloads/net-kingdom/directory-binds/' + n for n in BIND_NAMES if n != name] paths = [lane['kv_path'], lane['kv_path'].replace('/data/', '/metadata/', 1), *others, 'auth/token/create'] cap = capabilities(token, paths) if cap[paths[0]] != ['read'] or any(cap[p] != ['deny'] for p in paths[1:]): raise Refused('reader_capabilities') diff --git a/workplans/NK-WP-0056-bounded-executor-path.md b/workplans/NK-WP-0056-bounded-executor-path.md index c69a7d1..efc3074 100644 --- a/workplans/NK-WP-0056-bounded-executor-path.md +++ b/workplans/NK-WP-0056-bounded-executor-path.md @@ -153,6 +153,14 @@ the CCR, then the founder-run setup, ESO apply and verify. Authelia, KeyCape and Finding: ESO targets need `template.metadata: {}` or the admission guard refuses the Secret update. Remaining: `svc-authelia`, `svc-keycape`, `svc-privacyidea`. +**2026-10-31, `svc-authelia` lane prepared (source only, nothing applied):** railiance-platform CCR-2026-0053 +(proposed) and policy `net-kingdom-svc-authelia-bind-eso`; `sso-mfa/k8s/authelia/bind-eso.yaml` (Merge of +`ldap_password` only into `authelia-secrets`, which also holds the session, storage and OIDC keys). `bind-lane.py` +gains per-lane Secret keys: Authelia keeps the bind user in `authelia-config`, so setup refuses unless the live +configuration binds `uid=svc-authelia`, and it decodes only the bind key. Authelia reads the file at start-up +only, so the identical projection needs no restart; a later rotation does. 12 lane tests. Next: owner approval +and binding confirmation, then the founder-run setup, ESO apply, verify and an Authelia restart with a login. + ## Synthetic verification instead of a founder sign-in ```task