fix(security): make admin bootstrap private and non-overwriting
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
tegwick 2026-10-12 15:02:15 +02:00
parent 6ae4708ea0
commit 19a1c75d30
11 changed files with 573 additions and 203 deletions

View file

@ -257,4 +257,23 @@ remove historical client-side annotations or prove native consumer adoption.
The last-rotated timestamp records successful script steps only. No live rotation
was run for these source tests. CUST-WP-0073-T05 remains founder-deferred. RSA
rotation recovery/rollback, delegated helper argv/annotation handling and
`privacyidea/bootstrap-admin.sh` still require source and native acceptance.
`keycape/create-pi-token.sh` still require source and native acceptance. The
admin-bootstrap source repair below leaves native MFA and legacy-object revocation open.
## Current-profile admin bootstrap (2026-10-12 source repair)
The current KeyCape profile does not consume the old Keycloak trigger-admin
credential. `privacyidea/bootstrap-admin.sh` now creates/verifies only pi-admin
and its explicitly scoped bootstrap policy; it creates no trigger account, token
or Kubernetes Secret. It never deletes existing objects. Owner inventory and
revocation are required for historical credentials, independent of source repair.
Input is one private generated service-file assignment, parsed without sourcing
shell content. The password is sent through kubectl stdin into a version-scoped
privacyIDEA 3.12.2 adapter. That adapter verifies existing passwords and uses
insert-only writes for absent account/policy rows. It refuses broad/changed
legacy policies and existing installations that require policy migration.
The success receipt proves account/password and scoped-policy state only; MFA,
effective privileges, deployed-image behavior and consumer acceptance remain
attended native gates. See the privacyIDEA README for the exact operation scope.

View file

@ -37,7 +37,7 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co
- [x] `k8s/privacyidea/middleware.yaml` — rate-limit + admin IP allowlist (ipWhiteList, Traefik v2)
- [x] `k8s/privacyidea/ingress.yaml` — pink.coulomb.social + pink-account.coulomb.social
- [x] `k8s/privacyidea/enckey-bootstrap.sh` — extract enckey + audit keys post-start
- [x] `k8s/privacyidea/bootstrap-admin.sh` — create pi-admin + trigger-admin
- [x] `k8s/privacyidea/bootstrap-admin.sh` — create/verify pi-admin (current KeyCape profile)
- [x] `k8s/verify-t04.sh` — verify pod, service, middlewares, ingresses, TLS, secrets, PVCs
### BLOCKER — wrong image (2026-03-20)
@ -53,7 +53,7 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co
- [ ] `kubectl apply -f pvc.yaml configmap.yaml middleware.yaml deployment.yaml ingress.yaml`
- [ ] Wait for pod Running/Ready (up to 3 min — DB migrations run on first boot)
- [ ] `./enckey-bootstrap.sh` — extract enckey+auditkeys, store in KeePassXC, create DR Secrets
- [ ] `./bootstrap-admin.sh` — create pi-admin and trigger-admin
- [ ] `./bootstrap-admin.sh` — create/verify pi-admin (current KeyCape profile)
- [ ] Log in to pink.coulomb.social, enroll TOTP for pi-admin, verify MFA challenge
- [ ] Run `../verify-t04.sh` — 0 FAILs
- [ ] Commit and mark T04 done
@ -61,7 +61,7 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co
### Done-criteria for T04
- privacyIDEA pod Running+Ready in mfa namespace
- pink.coulomb.social and pink-account.coulomb.social reachable with valid TLS
- pi-admin and trigger-admin accounts exist
- pi-admin exists and attended MFA acceptance is recorded; no legacy trigger account is required
- pi-admin has enrolled a TOTP token and MFA challenge fires on login
- privacyidea-enckey and privacyidea-auditkeys Secrets exist (DR copies)
- verify-t04.sh: 0 FAILs

View file

@ -86,14 +86,45 @@ the local copies.
./bootstrap-admin.sh
```
Creates `pi-admin` (full admin) and `trigger-admin` (triggerchallenge only).
Also creates the `privacyidea-trigger-admin` K8s Secret used by Keycloak in T05.
Creates or verifies `pi-admin` for the current KeyCape profile. It does not
provision the legacy Keycloak `trigger-admin` account, policy or Kubernetes Secret.
Existing legacy objects are not deleted: inventory their actual consumers and
approve revocation separately before removing them.
**Immediately after the script completes:**
1. Log in to `https://pink.coulomb.social` as `pi-admin`.
2. Navigate to **Users → pi-admin → Enroll token** and enroll a TOTP or hardware token.
3. Log out, log back in — the MFA challenge must appear.
4. Verify the `trigger-admin-rights` policy at Config → Policies.
Run this only as an authorized, exclusive first-bootstrap or verification
operation. Password resets and policy migration are separate procedures. The private secrets directory and privacyidea
subdirectory must be owned by the operator without group/other access. Its
single-link regular `secrets.env` file must also be private; the script parses
exactly one generated alphanumeric `PI_ADMIN_PASSWORD` assignment without
executing shell content. Values travel over kubectl stdin, not argv, environment
assignments, REST bearer output or terminal text. No public-URL fallback is used.
The obsolete optional `pi-url` argument is refused.
The in-pod adapter is scoped to privacyIDEA 3.12.2. It verifies an existing
password without changing it. New account and policy rows use insert-only DB
writes; conflicts fail instead of updating existing records. The bootstrap
`pi-admin-all-rights` policy is restricted to `adminuser=pi-admin`. A conflicting
legacy policy, unknown version, credential mismatch, ambiguous pod or command
failure stops the script. Introducing a first admin policy into an existing
multi-admin/policy installation requires a separate owner-reviewed migration.
Configured `SUPERUSER_REALM` entries are refused because this local-admin profile
cannot safely grant username-based rights across administrative realms.
An error after a DB commit can leave created state; rerun only after reviewing
metadata, using the same approved credential. A successful retry never rotates it.
The secret remains in the selected private custody file. Transfer it through the
approved encrypted handoff and reconcile successful recovery before cleanup.
The helper does not prove runtime agent isolation or perform MFA enrollment.
**Attended acceptance remains required:**
1. Review effective admin policies and current identity mappings.
2. Complete the approved MFA enrollment procedure for the admin identity.
3. Perform a fresh login and confirm the required MFA challenge and denial cases.
4. Record the result; script success and `pi_admin_created` are not MFA acceptance.
No live bootstrap or account/policy change was performed for this source repair.
The source audit found that the upstream 3.12.2 `admin add` path can update an
existing password, so the old retry behavior was not reliably create-only.
---
@ -133,7 +164,7 @@ privacyIDEA sits entirely behind the NetworkPolicies applied in T02 (netpol-mfa.
| Source | Destination | Port | Purpose |
|--------|-------------|------|---------|
| Traefik (kube-system) | privacyIDEA (mfa) | 8080 | User-facing portal |
| Keycloak (sso) | privacyIDEA (mfa) | 8080 | Provider API (triggerchallenge) |
| KeyCape (sso) | privacyIDEA (mfa) | 8080 | MFA API |
| privacyIDEA (mfa) | PostgreSQL (databases) | 5432 | Database |
Outbound to anything other than PostgreSQL and kube-dns is denied.
@ -144,9 +175,9 @@ Outbound to anything other than PostgreSQL and kube-dns is denied.
### Rate limiting adjustment
The default rate limit (20 req/min, burst 5) is conservative. If Keycloak's
triggerchallenge calls trigger false positives (HTTP 429), raise the average
in `middleware.yaml` and reapply. Alternatively, the Keycloak-to-PI path is
The default rate limit (20 req/min, burst 5) is conservative. If KeyCape's
MFA calls trigger false positives (HTTP 429), raise the average
in `middleware.yaml` and reapply. Alternatively, the KeyCape-to-PI path is
cluster-internal and not subject to the Ingress middleware.
### Admin WebUI IP restriction

View file

@ -0,0 +1,100 @@
"""Version-scoped in-pod bootstrap. Password input is JSON on stdin only.
Existing credentials/policies are never updated. Insert-only DB writes and
unique constraints protect against concurrent creation. Native MFA/effective
policy acceptance is a separate attended procedure.
"""
import json
import logging
import re
import sys
NAME = 'pi-admin'
POLICY = 'pi-admin-all-rights'
class Refused(Exception):
pass
def admitted_policy(policy):
if (policy.get('name') != POLICY or policy.get('scope') != 'admin'
or policy.get('adminuser') != [NAME] or policy.get('action') != {'*': True}
or policy.get('active') is not True or policy.get('priority') != 1
or policy.get('check_all_resolvers') is not False
or policy.get('user_case_insensitive') is not False):
return False
return all(not policy.get(key) for key in (
'realm', 'adminrealm', 'resolver', 'pinode', 'user', 'client', 'time', 'conditions', 'user_agents'))
def ensure(password, admins, policies, verify, insert):
existing = NAME in admins
named = [policy for policy in policies if policy.get('name') == POLICY]
if len(named) > 1 or (named and not admitted_policy(named[0])): raise Refused()
if not named:
# Introducing the first admin policy changes defaults for every admin.
# Existing deployments need their owner's policy migration, not bootstrap.
if any(admin != NAME for admin in admins) or any(p.get('scope') == 'admin' for p in policies):
raise Refused()
if existing and not verify(NAME, password): raise Refused()
if not existing or not named:
insert(password, account=not existing, policy=not named)
if not verify(NAME, password): raise Refused()
return {'result': 'verified' if existing else 'created', 'mfa_verified': False}
def main():
from importlib.metadata import version
if version('privacyIDEA') != '3.12.2': raise Refused()
raw = sys.stdin.buffer.read(1025)
if len(raw) > 1024: raise Refused()
payload = json.loads(raw)
if (not isinstance(payload, dict) or set(payload) != {'password'}
or not isinstance(payload['password'], str)
or not re.fullmatch(r'[A-Za-z0-9]{32,128}', payload['password'])):
raise Refused()
# Avoid library diagnostics carrying credential/configuration data.
logging.disable(logging.CRITICAL)
from privacyidea.cli import create_silent_app
from privacyidea.lib.auth import list_db_admin, verify_db_admin
from privacyidea.lib.crypto import hash_with_pepper
from privacyidea.lib.policy import get_policies
from privacyidea.models import Admin, Policy, db
from privacyidea.models.config import save_config_timestamp
app = create_silent_app()
with app.app_context():
# Username-only bootstrap policy must not admit namesakes in other realms.
if app.config.get('SUPERUSER_REALM', []): raise Refused()
def insert(password, *, account, policy):
try:
# Do not call Admin.save/create_db_admin or set_policy: they upsert.
if account:
db.session.add(Admin(username=NAME, password=hash_with_pepper(password)))
if policy:
db.session.add(Policy(name=POLICY, scope='admin', action='*', adminuser=NAME))
db.session.flush() # constraint conflicts fail without updates
if policy: save_config_timestamp()
db.session.commit()
except Exception:
db.session.rollback()
raise Refused() from None
result = ensure(payload['password'], [admin.username for admin in list_db_admin()],
get_policies(), verify_db_admin, insert)
if not any(admitted_policy(p) for p in get_policies()): raise Refused()
return result
if __name__ == '__main__':
# Only the fixed receipt may reach the caller; suppress all library output.
import os
receipt_fd = os.dup(1)
with open(os.devnull, 'w') as sink:
os.dup2(sink.fileno(), 1); os.dup2(sink.fileno(), 2)
try:
result = main()
os.write(receipt_fd, (json.dumps(result) + '\n').encode())
except Exception:
sys.exit(1)
finally:
os.close(receipt_fd)

View file

@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Pass one private bootstrap credential to the admitted pod over stdin."""
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys
class Refused(Exception):
pass
def private(info, directory=False):
kind = stat.S_ISDIR if directory else stat.S_ISREG
if not kind(info.st_mode) or info.st_uid != os.getuid() or info.st_mode & 0o077:
raise Refused()
if not directory and info.st_nlink != 1:
raise Refused()
def read_password(root):
root = Path(os.path.abspath(root))
for ancestor in (root, *root.parents):
if ancestor.is_symlink(): raise Refused()
descriptors = []
try:
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
root_fd = os.open(root, flags); descriptors.append(root_fd)
private(os.fstat(root_fd), True)
parent_fd = os.open('privacyidea', flags, dir_fd=root_fd); descriptors.append(parent_fd)
private(os.fstat(parent_fd), True)
fd = os.open('secrets.env', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent_fd)
descriptors.append(fd); private(os.fstat(fd))
raw = os.read(fd, 65537)
if len(raw) > 65536: raise Refused()
values = [line.split('=', 1)[1] for line in raw.decode().splitlines()
if line.startswith('PI_ADMIN_PASSWORD=')]
# This entry point consumes gen-secrets.sh's format, never shell code.
if len(values) != 1 or not re.fullmatch(r'[A-Za-z0-9]{32,128}', values[0]): raise Refused()
return values[0]
finally:
for fd in reversed(descriptors): os.close(fd)
def run(args, payload=None):
completed = subprocess.run(args, input=payload, stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL, timeout=60, check=False)
if completed.returncode or len(completed.stdout) > 4096: raise Refused()
return completed.stdout
def main():
if len(sys.argv) > 2: raise Refused()
here = Path(__file__).resolve().parent
password = read_password(sys.argv[1] if len(sys.argv) == 2 else here / '../../bootstrap/secrets')
pod = run(['kubectl', 'get', 'pod', '-n', 'mfa', '-l', 'app.kubernetes.io/name=privacyidea',
'--field-selector=status.phase=Running', '-o', 'jsonpath={.items[*].metadata.name}']).decode().strip()
if not re.fullmatch(r'[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?', pod): raise Refused()
script = (here / 'bootstrap-admin-remote.py').read_text()
response = run(['kubectl', 'exec', '-i', '-n', 'mfa', pod, '--', 'python3', '-c', script],
json.dumps({'password': password}).encode())
receipt = json.loads(response)
if (not isinstance(receipt, dict) or set(receipt) != {'result', 'mfa_verified'}
or receipt['result'] not in ('created', 'verified')
or receipt['mfa_verified'] is not False): raise Refused()
print('pi-admin bootstrap credential and scoped bootstrap policy verified.')
print('MFA enrollment, effective-policy review and a fresh sign-in remain required; this is not MFA acceptance.')
print('Use the approved custody entry for pi-admin; no credential is printed or exported.')
print('Legacy trigger accounts and Secrets require a separate inventory/revocation review.')
if __name__ == '__main__':
try:
main()
except (Refused, OSError, ValueError, TypeError, subprocess.SubprocessError):
print('ERROR: admin bootstrap refused or failed; use approved metadata-only diagnostics. No credential was printed.', file=sys.stderr)
sys.exit(1)

View file

@ -1,178 +1,8 @@
#!/usr/bin/env bash
# bootstrap-admin.sh — create pi-admin and trigger-admin in privacyIDEA
#
# Run AFTER enckey-bootstrap.sh (key material must exist before admin setup).
#
# What it does:
# 1. Creates pi-admin — full admin (single-credential bootstrap moment).
# 2. Creates trigger-admin — limited admin for Keycloak's triggerchallenge calls.
# 3. Creates the trigger-admin policy (triggerchallenge right only, via REST API).
# 4. Prints instructions to immediately enroll MFA for pi-admin.
#
# Usage:
# ./bootstrap-admin.sh [secrets-dir] [pi-url]
#
# <secrets-dir> default: ../../bootstrap/secrets
# <pi-url> default: https://pink.coulomb.social
#
# The script uses the in-cluster kubectl exec path for creating admin users
# (avoids the need for a network route to pink.coulomb.social during bootstrap).
# The trigger-admin policy is created via REST API, so the URL must be reachable.
# First-bootstrap/verification entry point for the current KeyCape profile.
# No legacy Keycloak trigger credential is generated or published.
set +x
set -euo pipefail
NAMESPACE="mfa"
SECRETS_DIR="${1:-../../bootstrap/secrets}"
PI_URL="${2:-https://pink.coulomb.social}"
PI_ENV="$SECRETS_DIR/privacyidea/secrets.env"
if [[ ! -f "$PI_ENV" ]]; then
echo "ERROR: $PI_ENV not found — run gen-secrets.sh first." >&2
exit 1
fi
PI_ADMIN_PASS=$(bash -c "source '$PI_ENV' 2>/dev/null; echo \$PI_ADMIN_PASSWORD")
if [[ -z "$PI_ADMIN_PASS" ]]; then
echo "ERROR: PI_ADMIN_PASSWORD not found in $PI_ENV" >&2
exit 1
fi
# trigger-admin gets its own random password (generated here, stored in KeePassXC).
TRIGGER_PASS=$(openssl rand -base64 32 | tr -d '\n/+=' | head -c 40)
# ── 1. Find running pod ───────────────────────────────────────────────────────
PI_POD=$(kubectl get pod -n "$NAMESPACE" \
-l app.kubernetes.io/name=privacyidea \
--field-selector=status.phase=Running \
-o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "")
if [[ -z "$PI_POD" ]]; then
echo "ERROR: no Running privacyIDEA pod found in namespace '$NAMESPACE'." >&2
exit 1
fi
echo "Using pod: $PI_POD"
# ── 2. Create pi-admin (full admin) ──────────────────────────────────────────
echo ""
echo "Creating admin: pi-admin"
if kubectl exec -n "$NAMESPACE" "$PI_POD" -- \
pi-manage admin add pi-admin --password "$PI_ADMIN_PASS" 2>&1 | grep -q "already exist"; then
echo " pi-admin already exists — skipping."
else
echo " pi-admin created."
fi
# ── 3. Create trigger-admin (limited admin) ───────────────────────────────────
echo ""
echo "Creating admin: trigger-admin"
if kubectl exec -n "$NAMESPACE" "$PI_POD" -- \
pi-manage admin add trigger-admin --password "$TRIGGER_PASS" 2>&1 | grep -q "already exist"; then
echo " trigger-admin already exists — skipping password update."
echo " WARNING: if you need to rotate trigger-admin's password, do so manually."
else
echo " trigger-admin created."
fi
# ── 4. Create K8s Secret for trigger-admin credentials ───────────────────────
# Keycloak's privacyIDEA Provider reads the trigger-admin credentials from
# a K8s Secret that is referenced in the Keycloak realm configuration (T05/T06).
echo ""
echo "Creating K8s Secret: privacyidea-trigger-admin (namespace: mfa)"
kubectl create secret generic privacyidea-trigger-admin \
--namespace=mfa \
--from-literal=username=trigger-admin \
--from-literal=password="$TRIGGER_PASS" \
--dry-run=client -o yaml | kubectl apply -f -
echo " Done."
# ── 5. Create pi-admin-all-rights policy via pi-manage ───────────────────────
# Once ANY admin policy exists, privacyIDEA enforces it for all admins.
# The trigger-admin-rights policy (step 6) restricts trigger-admin to
# triggerchallenge only — but that restriction also locks out pi-admin from
# the REST API unless pi-admin has its own explicit policy granting full rights.
# This policy must be created via pi-manage (in-pod) because there is no REST
# access before it exists.
echo ""
echo "Creating pi-admin-all-rights policy via pi-manage ..."
if kubectl exec -n "$NAMESPACE" "$PI_POD" -- \
pi-manage config policy create pi-admin-all-rights admin "*" 2>&1 | grep -qi "already exist\|exists"; then
echo " pi-admin-all-rights already exists — skipping."
else
echo " pi-admin-all-rights created."
fi
# ── 6. Create trigger-admin policy via REST API ───────────────────────────────
# This restricts trigger-admin to the triggerchallenge action only.
# Requires pink.coulomb.social to be reachable.
echo ""
echo "Creating trigger-admin policy via REST API ($PI_URL)..."
echo " (Skip this step if $PI_URL is not yet reachable — do it via the WebUI instead.)"
echo ""
# Authenticate as pi-admin to get a bearer token.
AUTH_RESPONSE=$(curl -sf -X POST "$PI_URL/auth" \
-H "Content-Type: application/json" \
-d "{\"username\":\"pi-admin\",\"password\":\"$PI_ADMIN_PASS\"}" || echo "CURL_FAILED")
if [[ "$AUTH_RESPONSE" == "CURL_FAILED" ]]; then
echo " Could not reach $PI_URL — skipping REST policy creation."
echo " Create the trigger-admin policy manually (see README.md — Post-deploy steps)."
else
PI_TOKEN=$(echo "$AUTH_RESPONSE" | python3 -c \
"import sys,json; print(json.load(sys.stdin)['result']['value']['token'])" 2>/dev/null || echo "")
if [[ -z "$PI_TOKEN" ]]; then
echo " ERROR: could not extract auth token from response." >&2
echo " Create the trigger-admin policy manually (see README.md)." >&2
else
# Create policy: trigger-admin can only call triggerchallenge.
POLICY_RESP=$(curl -sf -X POST "$PI_URL/policy/trigger-admin-rights" \
-H "Authorization: $PI_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scope": "admin",
"action": "triggerchallenge",
"adminrealm": "*",
"adminuser": "trigger-admin",
"realm": "*",
"priority": 1
}' || echo "CURL_FAILED")
if [[ "$POLICY_RESP" == "CURL_FAILED" ]]; then
echo " WARNING: policy creation request failed." >&2
echo " Create the trigger-admin policy manually (see README.md)." >&2
else
echo " Policy 'trigger-admin-rights' created."
fi
fi
fi
# ── 7. Summary ────────────────────────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════════════════════════"
echo " Admin bootstrap complete."
echo "════════════════════════════════════════════════════════════"
echo ""
echo "CRITICAL — Do these steps NOW:"
echo ""
echo " 1. Store trigger-admin password in KeePassXC:"
echo " KeePassXC group: net-kingdom/privacyIDEA"
echo " Entry: trigger-admin → username=trigger-admin password=<below>"
echo " trigger-admin password: $TRIGGER_PASS"
echo " Then shred this terminal history."
echo ""
echo " 2. Log in to the privacyIDEA WebUI as pi-admin:"
echo " $PI_URL"
echo " Enroll MFA for pi-admin IMMEDIATELY (TOTP or hardware token)."
echo " Until MFA is enrolled, pi-admin has only password authentication."
echo ""
echo " 3. Verify the trigger-admin policy:"
echo " WebUI → Config → Policies → trigger-admin-rights"
echo " Scope: admin Action: triggerchallenge AdminUser: trigger-admin"
echo " If it was not created automatically, create it here."
echo ""
echo " 4. Test admin MFA:"
echo " Log out, log back in as pi-admin — MFA challenge must appear."
echo ""
echo "Next step: run ../verify-t04.sh"
umask 077
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
exec python3 "$SCRIPT_DIR/bootstrap-admin.py" "$@"

View file

@ -8,7 +8,7 @@
#
# After first pod starts successfully:
# 5. enckey-bootstrap.sh — extract enckey + audit keys, create DR Secrets
# 6. bootstrap-admin.sh — create pi-admin (+ MFA enrolment) and trigger-admin
# 6. bootstrap-admin.sh — create/verify pi-admin; attended MFA acceptance follows
#
# Container port: 8080.
# ghcr.io/gpappsoft/privacyidea-docker listens on port 8080 (gunicorn).

View file

@ -143,11 +143,11 @@ for secret in privacyidea-config; do
done
# DR secrets — created by enckey-bootstrap.sh (may not exist yet on first run)
for secret in privacyidea-enckey privacyidea-auditkeys privacyidea-trigger-admin; do
for secret in privacyidea-enckey privacyidea-auditkeys; do
if kubectl get secret "$secret" -n "$NAMESPACE" &>/dev/null; then
pass "Secret $secret exists"
else
warn "Secret $secret not found — run enckey-bootstrap.sh / bootstrap-admin.sh"
warn "Secret $secret not found — run enckey-bootstrap.sh"
fi
done
@ -189,12 +189,8 @@ if [[ -n "$PI_POD" ]]; then
warn "pi-admin not found — run bootstrap-admin.sh"
fi
if kubectl exec -n "$NAMESPACE" "$PI_POD" -- \
pi-manage admin list 2>/dev/null | grep -q "trigger-admin"; then
pass "Admin user trigger-admin exists"
else
warn "trigger-admin not found — run bootstrap-admin.sh"
fi
# Legacy trigger-account presence is not a current KeyCape readiness gate.
else
warn "Skipping key material checks — no running pod"
fi

View file

@ -0,0 +1,172 @@
"""Bootstrap tests use synthetic credentials and fake kubectl, never a cluster."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import pytest
ROOT = Path(__file__).resolve().parents[1]
PI = ROOT / 'sso-mfa/k8s/privacyidea'
PASSWORD = 'SyntheticAdminCredential12345678901234567890'
@pytest.fixture
def bootstrap(tmp_path):
scripts = tmp_path / 'privacyidea'; scripts.mkdir()
for name in ('bootstrap-admin.sh', 'bootstrap-admin.py', 'bootstrap-admin-remote.py'):
if (PI / name).exists(): shutil.copyfile(PI / name, scripts / name)
private = tmp_path / 'private'; private.mkdir(mode=0o700)
component = private / 'privacyidea'; component.mkdir(mode=0o700)
file = component / 'secrets.env'; file.write_text('PI_ADMIN_PASSWORD=' + PASSWORD + '\n'); file.chmod(0o600)
binary = tmp_path / 'bin'; binary.mkdir()
(binary / 'kubectl').write_text('''#!/usr/bin/python3
import json,os,sys
args=sys.argv[1:]
raw=sys.stdin.read() if '-i' in args or args[-1:]==['-'] else ''
with open(os.environ['CALLS'],'a') as f:f.write(json.dumps({'args':args,'stdin':raw})+'\\n')
if args[0]=='get':
print(os.environ.get('PODS','privacyidea-disposable'))
elif 'python3' in args:
if os.environ.get('FAIL'):
print(os.environ['PASSWORD'],file=sys.stderr);sys.exit(1)
print(os.environ.get('REMOTE_RESULT','{"result":"verified","mfa_verified":false}'))
else:
print(os.environ['PASSWORD'])
if os.environ.get('FAIL'):sys.exit(1)
''')
(binary / 'curl').write_text('#!/bin/sh\necho CURL_FAILED\nexit 1\n')
(binary / 'openssl').write_text('#!/bin/sh\nprintf "%s" "$PASSWORD"\n')
for command in binary.iterdir(): command.chmod(0o700)
env={'PATH':str(binary)+':'+os.environ['PATH'],'HOME':str(tmp_path),'KUBECONFIG':str(tmp_path/'unused'),
'CALLS':str(tmp_path/'calls'),'PASSWORD':PASSWORD}
return scripts,private,file,env
def invoke(bootstrap, **extra):
scripts,private,_,env=bootstrap
return subprocess.run(['bash','-x',str(scripts/'bootstrap-admin.sh'),str(private)],
env={**env,**extra},capture_output=True,text=True,timeout=10)
def calls(bootstrap):
p=Path(bootstrap[3]['CALLS'])
return [json.loads(line) for line in p.read_text().splitlines()] if p.exists() else []
def test_bootstrap_has_no_secret_output_or_secret_command_arguments(bootstrap):
result=invoke(bootstrap)
assert result.returncode==0
assert bool(PASSWORD in result.stdout+result.stderr) is False
observed=calls(bootstrap)
assert bool(PASSWORD in json.dumps([c['args'] for c in observed])) is False
remote=next(c for c in observed if 'python3' in c['args'])
assert json.loads(remote['stdin'])=={'password':PASSWORD}
assert 'MFA' in result.stdout
def test_bootstrap_does_not_create_legacy_trigger_account_or_secret(bootstrap):
result=invoke(bootstrap)
assert result.returncode==0
commands=[c['args'] for c in calls(bootstrap)]
assert all('trigger-admin' not in ' '.join(c) for c in commands)
assert all(c[0] not in ('create','apply','delete') for c in commands)
def test_failed_admin_operation_is_not_reported_as_complete(bootstrap):
result=invoke(bootstrap,FAIL='1')
assert result.returncode!=0
assert bool(PASSWORD in result.stdout+result.stderr) is False
assert 'bootstrap complete' not in result.stdout.lower()
@pytest.mark.parametrize('pods',['','privacyidea-one\nprivacyidea-two','unexpected/shape'])
def test_missing_or_ambiguous_pod_refused(bootstrap,pods):
result=invoke(bootstrap,PODS=pods)
assert result.returncode!=0
assert all(c['args'][0]=='get' for c in calls(bootstrap))
@pytest.mark.parametrize('defect',['missing','duplicate','public-file','public-directory','symlink','hardlink','executable-value'])
def test_invalid_credential_input_never_reaches_cluster(bootstrap,defect):
_,private,file,_=bootstrap
marker=private/'executed'
if defect=='missing':file.unlink()
elif defect=='duplicate':file.write_text(file.read_text()*2)
elif defect=='public-file':file.chmod(0o644)
elif defect=='public-directory':file.parent.chmod(0o755)
elif defect=='symlink':
target=file.with_name('other');file.rename(target);file.symlink_to(target)
elif defect=='hardlink':os.link(file,file.with_name('alias'))
else:file.write_text('PI_ADMIN_PASSWORD=$(touch '+str(marker)+'; echo '+PASSWORD+')\n')
result=invoke(bootstrap)
assert result.returncode!=0
assert not marker.exists()
assert not calls(bootstrap)
assert bool(PASSWORD in result.stdout+result.stderr) is False
@pytest.mark.parametrize('response',['not-json','{"result":"verified","mfa_verified":0}','{"result":"verified","mfa_verified":true}', '{"result":"verified","mfa_verified":false,"password":"extra"}'])
def test_malformed_or_unexpected_remote_receipt_refused(bootstrap,response):
result=invoke(bootstrap,REMOTE_RESULT=response)
assert result.returncode!=0
assert response not in result.stdout+result.stderr
@pytest.fixture
def remote():
import importlib.util
spec=importlib.util.spec_from_file_location('admin_bootstrap_remote',PI/'bootstrap-admin-remote.py')
module=importlib.util.module_from_spec(spec);spec.loader.exec_module(module)
return module
def policy():
return {'name':'pi-admin-all-rights','scope':'admin','adminuser':['pi-admin'],
'action':{'*':True},'active':True,'priority':1,'check_all_resolvers':False,
'user_case_insensitive':False}
def test_new_account_and_scoped_policy_requested_together(remote):
effects=[]
def insert(password,**flags): effects.append(flags)
result=remote.ensure(PASSWORD,[],[],lambda *_:bool(effects),insert)
assert effects==[{'account':True,'policy':True}]
assert result=={'result':'created','mfa_verified':False}
def test_existing_matching_account_is_verified_without_mutation(remote):
effects=[]
result=remote.ensure(PASSWORD,['pi-admin'],[policy()],lambda *_:True,lambda *a,**kw:effects.append(kw))
assert result=={'result':'verified','mfa_verified':False}
assert effects==[]
def test_existing_mismatching_account_is_never_reset(remote):
effects=[]
with pytest.raises(remote.Refused):
remote.ensure(PASSWORD,['pi-admin'],[policy()],lambda *_:False,lambda *a,**kw:effects.append(kw))
assert effects==[]
@pytest.mark.parametrize('change',[{'adminuser':[]},{'scope':'user'},{'action':{'triggerchallenge':True}},
{'active':False},{'conditions':[('restriction',)]},{'user_case_insensitive':True}])
def test_existing_unexpected_policy_is_never_overwritten(remote,change):
effects=[]
with pytest.raises(remote.Refused):
remote.ensure(PASSWORD,['pi-admin'],[{**policy(),**change}],lambda *_:True,lambda *a,**kw:effects.append(kw))
assert effects==[]
@pytest.mark.parametrize('admins,policies',[(['other-admin'],[]),(['pi-admin'],[{'name':'other','scope':'admin'}])])
def test_policy_migration_requires_owner_review(remote,admins,policies):
effects=[]
with pytest.raises(remote.Refused):
remote.ensure(PASSWORD,admins,policies,lambda *_:True,lambda *a,**kw:effects.append(kw))
assert effects==[]
def test_failed_post_creation_verification_is_failure(remote):
with pytest.raises(remote.Refused):
remote.ensure(PASSWORD,[],[],lambda *_:False,lambda *a,**kw:None)

View file

@ -0,0 +1,123 @@
"""Run the in-pod adapter against actual privacyIDEA 3.12.2 models on SQLite.
Install that exact package in a disposable environment. No native cluster or
production config is used; this does not replace deployed-image acceptance.
"""
import importlib.metadata
import importlib.util
import io
from pathlib import Path
import sys
import pytest
pytest.importorskip('privacyidea')
if importlib.metadata.version('privacyIDEA') != '3.12.2':
pytest.skip('adapter verification requires privacyIDEA 3.12.2', allow_module_level=True)
from flask import Flask
from privacyidea.models import Admin, Policy, db
from privacyidea.lib.crypto import hash_with_pepper
from privacyidea.lib.auth import verify_db_admin
import privacyidea.cli
ROOT=Path(__file__).resolve().parents[1]
PASSWORD='SyntheticAdminCredential12345678901234567890'
@pytest.fixture
def native(tmp_path,monkeypatch):
spec=importlib.util.spec_from_file_location('admin_remote_native',ROOT/'sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py')
remote=importlib.util.module_from_spec(spec);spec.loader.exec_module(remote)
app=Flask('disposable-bootstrap')
app.config.update(SQLALCHEMY_DATABASE_URI='sqlite:///'+str(tmp_path/'db.sqlite'),
SQLALCHEMY_TRACK_MODIFICATIONS=False,PI_PEPPER='synthetic-pepper',SECRET_KEY='synthetic-key')
db.init_app(app)
with app.app_context(): db.create_all()
monkeypatch.setattr(privacyidea.cli,'create_silent_app',lambda:app)
def invoke(password=PASSWORD):
import json
monkeypatch.setattr(sys,'stdin',io.TextIOWrapper(io.BytesIO(json.dumps({'password':password}).encode())))
return remote.main()
import logging
previous_disable = logging.root.manager.disable
try:
yield app,remote,invoke
finally:
logging.disable(previous_disable)
def test_real_model_creation_and_repeat_preserve_password_hash(native):
app,_,invoke=native
assert invoke()=={'result':'created','mfa_verified':False}
with app.app_context():
before=Admin.query.filter_by(username='pi-admin').one().password
assert verify_db_admin('pi-admin',PASSWORD)
assert [a.username for a in Admin.query.all()]==['pi-admin']
assert Policy.query.one().adminuser=='pi-admin'
assert invoke()=={'result':'verified','mfa_verified':False}
with app.app_context(): assert Admin.query.one().password==before
def test_real_model_mismatch_never_resets_password(native):
app,remote,invoke=native
invoke()
with app.app_context(): before=Admin.query.one().password
with pytest.raises(remote.Refused):invoke('DifferentSyntheticPassword1234567890123456')
with app.app_context(): assert Admin.query.one().password==before
def test_real_legacy_broad_policy_refused_before_account_creation(native):
app,remote,invoke=native
with app.app_context():
db.session.add(Policy(name='pi-admin-all-rights',scope='admin',action='*'));db.session.commit()
with pytest.raises(remote.Refused):invoke()
with app.app_context():
assert Admin.query.count()==0
assert Policy.query.one().adminuser==''
def test_real_other_admin_requires_explicit_policy_migration(native):
app,remote,invoke=native
with app.app_context():
db.session.add(Admin(username='other-admin',password=hash_with_pepper(PASSWORD)));db.session.commit()
with pytest.raises(remote.Refused):invoke()
with app.app_context():
assert [a.username for a in Admin.query.all()]==['other-admin']
assert Policy.query.count()==0
def test_real_failed_policy_insert_rolls_back_account(native,monkeypatch):
app,remote,invoke=native
original=db.session.add
def fail_policy(obj,*args,**kwargs):
if isinstance(obj,Policy):raise RuntimeError('synthetic-failure')
return original(obj,*args,**kwargs)
monkeypatch.setattr(db.session,'add',fail_policy)
with pytest.raises(remote.Refused):invoke()
with app.app_context():
assert Admin.query.count()==0
assert Policy.query.count()==0
def test_real_concurrent_account_conflict_never_updates_existing_row(native,monkeypatch):
import privacyidea.lib.auth
app,remote,invoke=native
with app.app_context():
db.session.add(Admin(username='pi-admin',password=hash_with_pepper('ExistingSyntheticPassword1234567890123456')))
db.session.commit();before=Admin.query.one().password
# Simulate an account appearing after the initial absence observation.
monkeypatch.setattr(privacyidea.lib.auth,'list_db_admin',lambda:[])
with pytest.raises(remote.Refused):invoke()
with app.app_context():
assert Admin.query.one().password==before
assert Policy.query.count()==0
def test_real_external_admin_realm_requires_owner_profile(native):
app,remote,invoke=native
app.config['SUPERUSER_REALM']=['directory-admins']
with pytest.raises(remote.Refused):invoke()
with app.app_context():
assert Admin.query.count()==0
assert Policy.query.count()==0

View file

@ -74,9 +74,11 @@ signal deferral and arbitrary-child copy retention are explicitly not solved by
this helper. Evidence: Custodian `custody-entrypoints.json`.
Concrete uncovered callers remain (T02 stays progress):
- `sso-mfa/k8s/privacyidea/bootstrap-admin.sh` prints the trigger-admin password
and publishes a fresh generated Secret even when the existing account retained
its previous password. Its command pipelines can misreport failures as creation.
- The separate `keycape/create-pi-token.sh` still uses credential-bearing argv,
ambient/public-URL fallback, raw child-output handling and client-side Secret
apply. Its token lifecycle and current consumer authority need reconciliation.
- Legacy trigger account/Secret inventory and owner-approved revocation are still
required; removing automatic creation does not revoke historical credentials.
- RSA rotation rollback/custody and native multi-consumer acceptance remain open;
the output repair below is not lifecycle closure.
- Delegated create/apply helpers still need full tracing/error/Secret-annotation
@ -106,6 +108,23 @@ script timestamps are not native consumer/revocation evidence; partial updates
retain the local candidate for recovery. No live rotation occurred. Evidence:
Custodian `rotation-output.json`. T02 and the founder-deferred rotation stay open.
2026-10-12 admin-bootstrap reconciliation: current SCOPE marks Keycloak backlog;
KeyCape uses the separate pi-admin token path. Removed automatic legacy trigger
account/policy/Secret creation and corresponding current readiness expectations.
Fourteen old-source tests failed (two passed). The new entry point parses owned
private input without executing shell code, sends credentials only on kubectl
stdin, sanitizes failures and requires one pod plus a strict value-free receipt.
Upstream 3.12.2 inspection corrects the earlier mismatch hypothesis: `admin add`
uses an upsert and may reset an existing password. The new version-scoped in-pod
adapter verifies existing credentials and refuses mismatches or conflicting
policies; new rows use insert-only transactional writes and a policy scoped to
pi-admin. Disposable actual privacyIDEA 3.12.2/SQLite tests verify repeat stability,
wrong-password refusal, legacy-policy/other-admin refusal, rollback and conflict
handling. These do not prove the deployed image/PostgreSQL or effective MFA.
Existing objects are not deleted or rotated. Current callers and the native
acceptance gate remain open. Evidence: Custodian `admin-bootstrap.json`.
## Verify owner-approved recovery and current native callers
```task