diff --git a/docs/security-bootstrap-age-custody.md b/docs/security-bootstrap-age-custody.md index bdf5cd6..a520118 100644 --- a/docs/security-bootstrap-age-custody.md +++ b/docs/security-bootstrap-age-custody.md @@ -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. diff --git a/sso-mfa/WORKPLAN.md b/sso-mfa/WORKPLAN.md index 19f1810..7bbd142 100644 --- a/sso-mfa/WORKPLAN.md +++ b/sso-mfa/WORKPLAN.md @@ -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 diff --git a/sso-mfa/k8s/privacyidea/README.md b/sso-mfa/k8s/privacyidea/README.md index 1a96795..5ef5d3b 100644 --- a/sso-mfa/k8s/privacyidea/README.md +++ b/sso-mfa/k8s/privacyidea/README.md @@ -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 diff --git a/sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py b/sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py new file mode 100644 index 0000000..97a6b5d --- /dev/null +++ b/sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py @@ -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) diff --git a/sso-mfa/k8s/privacyidea/bootstrap-admin.py b/sso-mfa/k8s/privacyidea/bootstrap-admin.py new file mode 100644 index 0000000..3796767 --- /dev/null +++ b/sso-mfa/k8s/privacyidea/bootstrap-admin.py @@ -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) diff --git a/sso-mfa/k8s/privacyidea/bootstrap-admin.sh b/sso-mfa/k8s/privacyidea/bootstrap-admin.sh index cd03247..56d7f15 100755 --- a/sso-mfa/k8s/privacyidea/bootstrap-admin.sh +++ b/sso-mfa/k8s/privacyidea/bootstrap-admin.sh @@ -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] -# -# default: ../../bootstrap/secrets -# 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=" -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" "$@" diff --git a/sso-mfa/k8s/privacyidea/deployment.yaml b/sso-mfa/k8s/privacyidea/deployment.yaml index 7c110fd..bcccc83 100644 --- a/sso-mfa/k8s/privacyidea/deployment.yaml +++ b/sso-mfa/k8s/privacyidea/deployment.yaml @@ -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). diff --git a/sso-mfa/k8s/verify-t04.sh b/sso-mfa/k8s/verify-t04.sh index 9a3f541..141c554 100755 --- a/sso-mfa/k8s/verify-t04.sh +++ b/sso-mfa/k8s/verify-t04.sh @@ -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 diff --git a/tests/test_admin_bootstrap.py b/tests/test_admin_bootstrap.py new file mode 100644 index 0000000..7875e93 --- /dev/null +++ b/tests/test_admin_bootstrap.py @@ -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) diff --git a/tests/test_admin_bootstrap_privacyidea.py b/tests/test_admin_bootstrap_privacyidea.py new file mode 100644 index 0000000..aec7910 --- /dev/null +++ b/tests/test_admin_bootstrap_privacyidea.py @@ -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 diff --git a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md index a00a6eb..678b3d4 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -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