NK-WP-0054-T05 phase 2b: each directory consumer binds with its own account
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Identity provider journey acceptance / provider (push) Failing after 12s
Build and Publish identity-provisioner / build-and-push (push) Successful in 50s

Custody gains LLDAP_SVC_{AUTHELIA,KEYCAPE,PRIVACYIDEA,PROVISIONER}_PASS and
LLDAP_LDAP_USER_PASS becomes ld-admin's. Authelia, KeyCape, the privacyIDEA
resolver and identity-provisioner (new Secret identity-provisioner-ldap) switch
to svc-* accounts; LLDAP's configured super-admin is ld-admin. The LLDAP image
pin waits for the running digest.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 308701@bnt-lap001
Assistant-Session: 5548e447-444c-4dcc-bed1-05c16eebfe56
This commit is contained in:
tegwick 2026-10-28 20:33:04 +01:00
parent 2ebc4f0ae8
commit 145ca8e08c
17 changed files with 76 additions and 29 deletions

View file

@ -25,6 +25,7 @@ TARGETS = {
'privacyidea': ('mfa', 'privacyidea-config', ('privacyidea',)),
'lldap': ('sso', 'lldap-secrets', ('lldap',)),
'authelia': ('sso', 'authelia-secrets', ('authelia', 'lldap')),
'identity-provisioner': ('sso', 'identity-provisioner-ldap', ('lldap',)),
}
ROTATIONS = {
'postgresql': {'PI_DB_PASSWORD': 'password'},
@ -167,12 +168,14 @@ def service_values(component, values):
'@net-kingdom-pg-rw.databases.svc.cluster.local:5432/privacyidea_db'}
if component == 'lldap':
return {key: literal(values['lldap'], key) for key in ('LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS')}
if component == 'identity-provisioner':
return {'username': 'svc-provisioner', 'password': literal(values['lldap'], 'LLDAP_SVC_PROVISIONER_PASS')}
env = values['authelia']
return {'jwt_secret': literal(env, 'AUTHELIA_JWT_SECRET'),
'session_secret': literal(env, 'AUTHELIA_SESSION_SECRET'),
'storage_encryption_key': literal(env, 'AUTHELIA_STORAGE_ENCRYPTION_KEY'),
'oidc_hmac_secret': literal(env, 'AUTHELIA_OIDC_HMAC_SECRET'),
'ldap_password': literal(values['lldap'], 'LLDAP_LDAP_USER_PASS')}
'ldap_password': literal(values['lldap'], 'LLDAP_SVC_AUTHELIA_PASS')}
def publish(namespace, name, desired, old, allowed):

View file

@ -279,7 +279,7 @@ BREAKGLASS_PASSWORD)
echo " PI_PEPPER Password hashing pepper (PERMANENT — read help)"
echo " PI_DB_PASSWORD privacyIDEA database password"
echo " LLDAP_JWT_SECRET LLDAP JWT signing key"
echo " LLDAP_LDAP_USER_PASS LLDAP admin + LDAP bind password (3-way coordinated)"
echo " LLDAP_LDAP_USER_PASS ld-admin, LLDAP configured super-admin (refused here)"
echo " AUTHELIA_SESSION_SECRET Authelia session cookie key"
echo " AUTHELIA_KEYCAPE_CLIENT_SECRET Authelia↔KeyCape OIDC client secret"
echo " KEYCAPE_RSA_KEY KeyCape JWT signing key (causes auth outage)"

View file

@ -81,18 +81,29 @@ EOF
# ── LLDAP ──────────────────────────────────────────────────────────────────────
LLDAP_JWT_SECRET="$(rnd_hex 32)" # 64 hex chars — LLDAP JWT signing key
LLDAP_LDAP_USER_PASS="$(rnd_b64 32 40)" # 40 printable chars — LLDAP admin + Authelia/KeyCape bind password
LLDAP_LDAP_USER_PASS="$(rnd_b64 32 40)" # 40 printable chars — ld-admin, LLDAP's configured super-admin (sealed fallback)
# Per-consumer directory accounts (Service Identity SI-3, NK-WP-0054-T05). The human `admin`
# password is personal and is never generated into custody.
LLDAP_SVC_AUTHELIA_PASS="$(rnd_b64 32 40)" # svc-authelia, read-only bind
LLDAP_SVC_KEYCAPE_PASS="$(rnd_b64 32 40)" # svc-keycape, read-only bind
LLDAP_SVC_PRIVACYIDEA_PASS="$(rnd_b64 32 40)" # svc-privacyidea, read-only resolver bind
LLDAP_SVC_PROVISIONER_PASS="$(rnd_b64 32 40)" # svc-provisioner, lldap_admin (exception SI-X6)
cat > "$OUT_DIR/lldap/secrets.env" <<EOF
# LLDAP secrets — KeePassXC group: net-kingdom/LLDAP
# Entry: jwt-secret → password=LLDAP_JWT_SECRET (no username)
# Entry: admin → username=admin password=LLDAP_LDAP_USER_PASS
# Entry: ld-admin → username=ld-admin password=LLDAP_LDAP_USER_PASS
# Entries: svc-authelia, svc-keycape, svc-privacyidea, svc-provisioner → LLDAP_SVC_*_PASS
#
# LLDAP_LDAP_USER_PASS is also the LDAP bind password for Authelia and KeyCape.
# It is read from this file by both authelia/create-secrets.sh and keycape/create-secrets.sh.
# Each consumer binds with its own account: apply-service-secrets.py (Authelia, provisioner),
# keycape/create-secrets.py and privacyidea/realm_bootstrap.py read their key from this file.
LLDAP_JWT_SECRET=$LLDAP_JWT_SECRET
LLDAP_LDAP_USER_PASS=$LLDAP_LDAP_USER_PASS
LLDAP_SVC_AUTHELIA_PASS=$LLDAP_SVC_AUTHELIA_PASS
LLDAP_SVC_KEYCAPE_PASS=$LLDAP_SVC_KEYCAPE_PASS
LLDAP_SVC_PRIVACYIDEA_PASS=$LLDAP_SVC_PRIVACYIDEA_PASS
LLDAP_SVC_PROVISIONER_PASS=$LLDAP_SVC_PROVISIONER_PASS
EOF
# ── Authelia ───────────────────────────────────────────────────────────────────

View file

@ -65,7 +65,7 @@ data:
group_name_attribute: cn
mail_attribute: mail
display_name_attribute: displayName
user: uid=admin,ou=people,dc=netkingdom,dc=local
user: uid=svc-authelia,ou=people,dc=netkingdom,dc=local
# password: injected via AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
session:

View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
# Private creation or exact-match verification; rotations require --rotate-key.
set +x
set -euo pipefail
umask 077
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/../../bootstrap/apply-service-secrets.py" identity-provisioner "$@"

View file

@ -29,9 +29,13 @@ spec:
ports: [{name: http, containerPort: 8080}]
env:
- {name: LLDAP_URL, value: "http://lldap.sso.svc.cluster.local:17170"}
# Own directory account svc-provisioner (lldap_admin, exception SI-X6), not the human admin.
- name: LLDAP_ADMIN_USERNAME
valueFrom:
secretKeyRef: {name: identity-provisioner-ldap, key: username}
- name: LLDAP_ADMIN_PASSWORD
valueFrom:
secretKeyRef: {name: lldap-secrets, key: LLDAP_LDAP_USER_PASS}
secretKeyRef: {name: identity-provisioner-ldap, key: password}
- name: PROVISIONER_SERVICE_TOKEN
valueFrom:
secretKeyRef: {name: identity-provisioner-token, key: token}

View file

@ -6,7 +6,7 @@
"environment": "production",
"lldap": {
"url": "ldap://lldap.sso.svc.cluster.local:3890",
"bindDN": "uid=admin,ou=people,dc=netkingdom,dc=local",
"bindDN": "uid=svc-keycape,ou=people,dc=netkingdom,dc=local",
"bindPW": "",
"baseDN": "dc=netkingdom,dc=local",
"userOU": "ou=people",

View file

@ -103,7 +103,7 @@ def main():
here = Path(__file__).resolve().parent
root = sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets'
with custody(root) as dirs:
bind_password = env_value(dirs['lldap'], 'LLDAP_LDAP_USER_PASS')
bind_password = env_value(dirs['lldap'], 'LLDAP_SVC_KEYCAPE_PASS')
client_secret = env_value(dirs['authelia'], 'AUTHELIA_KEYCAPE_CLIENT_SECRET')
config = json.loads((here / 'bootstrap-config.json').read_text())
config['lldap']['bindPW'] = bind_password

View file

@ -43,7 +43,8 @@ spec:
containers:
- name: lldap
# Check https://hub.docker.com/r/lldap/lldap for latest stable tag.
# Pin to the running digest before the bind split (NK-WP-0054-T05 runbook); the restore
# tools and the rehearsal use v0.6.2 (sha256:9e605a66...). Never pin to an older version.
image: lldap/lldap:stable
imagePullPolicy: IfNotPresent
@ -66,6 +67,10 @@ spec:
value: "17170"
- name: LLDAP_LDAP_PORT
value: "3890"
# Configured super-admin is the sealed service-admin account, not the human (NK-WP-0054-T05).
# LLDAP creates it only when lldap_admin has no member, so the split creates ld-admin first.
- name: LLDAP_LDAP_USER_DN
value: ld-admin
# Sensitive values from Secret
- name: LLDAP_JWT_SECRET
valueFrom:

View file

@ -17,8 +17,9 @@ CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786'
KUBE = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope',
'--server', 'https://127.0.0.1:16444', '--request-timeout=20s']
NAMESPACE = 'sso'
SECRET = 'lldap-secrets'
KEY = 'LLDAP_LDAP_USER_PASS'
# The provisioner's own directory account (NK-WP-0054-T05); needs the image with LLDAP_ADMIN_USERNAME.
SECRET = 'identity-provisioner-ldap'
KEY = 'password'
# The password and returned directory token remain in this child process only.
# Child stdin carries the operator input; no credential is a process argument.
VERIFY = '''import json,os,sys
@ -26,7 +27,7 @@ from urllib.error import HTTPError
from provisioner import LLDAPProvisioner
try:
assert os.environ['LLDAP_URL']=='http://lldap.sso.svc.cluster.local:17170'
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=json.load(sys.stdin)['password'])
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=json.load(sys.stdin)['password'],admin_username='svc-provisioner')
token=p._login()
p._gql(token,'query { groups { id } }',{})
except Exception:
@ -36,7 +37,7 @@ print('directory-check-passed')
VERIFY_RELOADED = '''import os,sys
from provisioner import LLDAPProvisioner
try:
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=os.environ['LLDAP_ADMIN_PASSWORD'])
p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=os.environ['LLDAP_ADMIN_PASSWORD'],admin_username=os.environ['LLDAP_ADMIN_USERNAME'])
token=p._login()
p._gql(token,'query { groups { id } }',{})
except Exception:

View file

@ -20,7 +20,7 @@ class ReconcileTests(unittest.TestCase):
self.assertEqual(['test','test','replace'],[x['op'] for x in changes])
self.assertEqual('/metadata/uid',changes[0]['path'])
self.assertEqual('/metadata/resourceVersion',changes[1]['path'])
self.assertEqual('/data/LLDAP_LDAP_USER_PASS',changes[2]['path'])
self.assertEqual('/data/password',changes[2]['path'])
self.assertEqual(SYNTHETIC,base64.b64decode(changes[2]['value']).decode())
def test_stale_metadata_stops_before_credential_use(self):
@ -45,7 +45,7 @@ class ReconcileTests(unittest.TestCase):
self.assertEqual(SYNTHETIC,json.loads(run.call_args.kwargs['data'])['password'])
def test_apply_dry_runs_then_writes_reloads_and_verifies(self):
results=['directory-check-passed','secret/lldap-secrets','secret/lldap-secrets','restarted','ready','reloaded-check-passed']
results=['directory-check-passed','secret/identity-provisioner-ldap','secret/identity-provisioner-ldap','restarted','ready','reloaded-check-passed']
with patch.object(repair,'inspect',return_value=META), patch.object(repair,'run',side_effect=results) as run:
result=repair.reconcile(SYNTHETIC,mode='apply',expected_uid=META['uid'],expected_resource_version=META['resource_version'])
self.assertEqual('reconciled',result['result'])

View file

@ -15,7 +15,7 @@ from pi_api import request
RESOLVER = 'lldap-coulomb'
REALM = 'coulomb'
CORE = {'LDAPURI': 'ldap://lldap.sso.svc.cluster.local:3890',
'LDAPBASE': 'dc=netkingdom,dc=local', 'BINDDN': 'uid=admin,ou=people,dc=netkingdom,dc=local'}
'LDAPBASE': 'dc=netkingdom,dc=local', 'BINDDN': 'uid=svc-privacyidea,ou=people,dc=netkingdom,dc=local'}
TUNING = {'TIMEOUT': '5', 'CACHE_TIMEOUT': '120', 'SIZELIMIT': '500'}
@ -35,7 +35,7 @@ def private_inputs(root):
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
with module.custody(root, ['privacyidea', 'lldap']) as dirs:
admin = password(module.read_env(dirs['privacyidea']).get('PI_ADMIN_PASSWORD'))
bind = password(module.read_env(dirs['lldap']).get('LLDAP_LDAP_USER_PASS'))
bind = password(module.read_env(dirs['lldap']).get('LLDAP_SVC_PRIVACYIDEA_PASS'))
return admin, bind

View file

@ -16,7 +16,7 @@ PASSWORD = 'SyntheticOnlyCredential12345678901234567890'
@pytest.fixture
def setup(tmp_path):
root = tmp_path / 'private'; root.mkdir(mode=0o700)
for component, key in [('lldap', 'LLDAP_LDAP_USER_PASS'), ('authelia', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'), ('keycape', None)]:
for component, key in [('lldap', 'LLDAP_SVC_KEYCAPE_PASS'), ('authelia', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'), ('keycape', None)]:
folder = root / component; folder.mkdir(mode=0o700)
file = folder / 'secrets.env'; file.write_text(f'{key}={PASSWORD}\n' if key else '# placeholder\n'); file.chmod(0o600)
binary = tmp_path / 'bin'; binary.mkdir()
@ -110,7 +110,7 @@ def test_bad_custody_refused_before_cluster_access(setup,defect):
target=root/'other';file.parent.rename(target);(root/'lldap').symlink_to(target)
elif defect=='hardlink':os.link(file,file.with_name('alias'))
elif defect=='duplicate':file.write_text(file.read_text()*2)
elif defect=='shell':file.write_text(f'LLDAP_LDAP_USER_PASS=$(touch {marker})\n')
elif defect=='shell':file.write_text(f'LLDAP_SVC_KEYCAPE_PASS=$(touch {marker})\n')
else:file.unlink()
result=invoke(setup)
assert result.returncode!=0

View file

@ -196,7 +196,7 @@ def test_bootstrap_preserves_policy_and_existing_configuration(
state["/policy/totp-self-enrollment"] = None
for folder, content in [
("privacyidea", "PI_ADMIN_PASSWORD=test-password\n"),
("lldap", "LLDAP_LDAP_USER_PASS=test-bind-password\n"),
("lldap", "LLDAP_SVC_PRIVACYIDEA_PASS=test-bind-password\n"),
]:
directory = tmp_path / folder
directory.mkdir(mode=0o700)
@ -204,7 +204,7 @@ def test_bootstrap_preserves_policy_and_existing_configuration(
(directory / "secrets.env").chmod(0o600)
state["/resolver/lldap-coulomb"]["result"]["value"]["lldap-coulomb"]["data"].update({
"LDAPURI": "ldap://lldap.sso.svc.cluster.local:3890", "LDAPBASE": "dc=netkingdom,dc=local",
"BINDDN": "uid=admin,ou=people,dc=netkingdom,dc=local"})
"BINDDN": "uid=svc-privacyidea,ou=people,dc=netkingdom,dc=local"})
proc = subprocess.run(
["bash", str(HELPERS / "bootstrap-realm.sh"), str(tmp_path), url, "--apply"],
capture_output=True,
@ -231,7 +231,7 @@ def test_attended_repair_keeps_inputs_in_memory_and_propagates_mfa(target, monke
url, state = target
state['/resolver/lldap-coulomb']['result']['value']['lldap-coulomb']['data'].update({
'LDAPURI':'ldap://lldap.sso.svc.cluster.local:3890','LDAPBASE':'dc=netkingdom,dc=local',
'BINDDN':'uid=admin,ou=people,dc=netkingdom,dc=local'})
'BINDDN':'uid=svc-privacyidea,ou=people,dc=netkingdom,dc=local'})
monkeypatch.setenv('PI_URL',url);monkeypatch.setenv('MFA_USER','test-user')
master,slave=pty.openpty()
proc=subprocess.Popen(['bash',str(HELPERS/'repair-realm-live.sh')],stdin=slave,stdout=subprocess.PIPE,stderr=subprocess.PIPE)

View file

@ -151,7 +151,7 @@ def test_transport_refuses_unapproved_destination():
@pytest.fixture
def private_root(tmp_path):
for component,key in [('privacyidea','PI_ADMIN_PASSWORD'),('lldap','LLDAP_LDAP_USER_PASS')]:
for component,key in [('privacyidea','PI_ADMIN_PASSWORD'),('lldap','LLDAP_SVC_PRIVACYIDEA_PASS')]:
directory=tmp_path/component;directory.mkdir(mode=0o700)
f=directory/'secrets.env';f.write_text(key+'=literal-$(never-execute)-quote\'"\\end\n');f.chmod(0o600)
return tmp_path

View file

@ -13,11 +13,12 @@ PASSWORD = 'SyntheticServiceCredential12345678901234567890'
CHANGED = 'ReplacementSyntheticCredential12345678901234567890'
VALUES = {
'privacyidea': ['PI_SECRET_KEY', 'PI_PEPPER', 'PI_DB_PASSWORD'],
'lldap': ['LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS'],
'lldap': ['LLDAP_JWT_SECRET', 'LLDAP_LDAP_USER_PASS', 'LLDAP_SVC_AUTHELIA_PASS', 'LLDAP_SVC_KEYCAPE_PASS',
'LLDAP_SVC_PRIVACYIDEA_PASS', 'LLDAP_SVC_PROVISIONER_PASS'],
'authelia': ['AUTHELIA_JWT_SECRET', 'AUTHELIA_SESSION_SECRET', 'AUTHELIA_STORAGE_ENCRYPTION_KEY',
'AUTHELIA_OIDC_HMAC_SECRET', 'AUTHELIA_KEYCAPE_CLIENT_SECRET'],
}
COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia']
COMPONENTS = ['postgresql', 'privacyidea', 'lldap', 'authelia', 'identity-provisioner']
ROTATIONS = [('postgresql','PI_DB_PASSWORD','password'), ('privacyidea','PI_SECRET_KEY','PI_SECRET_KEY'),
('privacyidea','PI_DB_PASSWORD','PI_SQLALCHEMY_DATABASE_URI'),
('lldap','LLDAP_JWT_SECRET','LLDAP_JWT_SECRET'), ('authelia','AUTHELIA_SESSION_SECRET','session_secret')]
@ -90,7 +91,7 @@ def check_private_result(result):
assert 'BEGIN PRIVATE KEY' not in result.stdout+result.stderr
def source(component):return 'privacyidea' if component=='postgresql' else component
def source(component):return {'postgresql':'privacyidea','identity-provisioner':'lldap'}.get(component,component)
@pytest.mark.parametrize('component',COMPONENTS)
@ -105,7 +106,8 @@ def test_create_uses_literal_inputs_stdin_and_no_apply_annotation(case,component
elif component=='privacyidea':
assert values['PI_PEPPER']==PASSWORD and values['PI_SECRET_KEY']==PASSWORD
assert values['PI_SQLALCHEMY_DATABASE_URI']==f'postgresql://privacyidea:{PASSWORD}@net-kingdom-pg-rw.databases.svc.cluster.local:5432/privacyidea_db'
elif component=='lldap':assert values=={k:PASSWORD for k in VALUES['lldap']}
elif component=='lldap':assert values=={k:PASSWORD for k in ('LLDAP_JWT_SECRET','LLDAP_LDAP_USER_PASS')}
elif component=='identity-provisioner':assert values=={'username':'svc-provisioner','password':PASSWORD}
else:
key=case[0]/'authelia/oidc_private_key.pem'
assert key.stat().st_mode & 0o077==0 and values['oidc_issuer_private_key']==key.read_text()

View file

@ -289,6 +289,20 @@ commands, a founder-run rehearsal on a restored backup, then the live run.
2026-10-28 founder decision: `svc-provisioner` gets its own account in `lldap_admin`, recorded as
exception SI-X6 (SI-3, expires 2027-04-30) in the standard and `declarations/lldap.yaml`.
**2026-10-28 phase 2 (source switch), parts a and b:** `identity-provisioner` logs in as
`LLDAP_ADMIN_USERNAME` (default `admin`) and refuses chosen usernames starting `svc-` or ending
`-admin` (`2ebc4f0`; its image needs a rebuild through the owner pipeline). Custody: the LLDAP
custody file now carries `LLDAP_LDAP_USER_PASS` as the `ld-admin` password plus
`LLDAP_SVC_{AUTHELIA,KEYCAPE,PRIVACYIDEA,PROVISIONER}_PASS`; the human `admin` password leaves
custody. Consumers: Authelia binds as `svc-authelia` (`ldap_password` from its own key), KeyCape as
`svc-keycape` (`create-secrets.py`, `bootstrap-config.json`), the privacyIDEA resolver as
`svc-privacyidea` (`realm_bootstrap.py`), the provisioner from the new Secret
`identity-provisioner-ldap` (`create-secrets.sh`, deployment, `identity-provisioner-reconcile.py`).
LLDAP's deployment sets `LLDAP_LDAP_USER_DN=ld-admin`. The image is **not** pinned yet: production
runs `lldap/lldap:stable`, and pinning to v0.6.2 could downgrade the database; the runbook reads the
running digest first. Service-account emails use `@service.netkingdom.invalid`. Left: 2c (Authelia
users filter requiring `net-kingdom-users`, synthetic check), then phases 3 and 4.
## Disposition of per-service admin accounts
```task