fix(security): make admin bootstrap private and non-overwriting
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a12d13-27f2-7163-b8c0-f682af02772c
This commit is contained in:
parent
6ae4708ea0
commit
19a1c75d30
11 changed files with 573 additions and 203 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
100
sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py
Normal file
100
sso-mfa/k8s/privacyidea/bootstrap-admin-remote.py
Normal 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)
|
||||
80
sso-mfa/k8s/privacyidea/bootstrap-admin.py
Normal file
80
sso-mfa/k8s/privacyidea/bootstrap-admin.py
Normal 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)
|
||||
|
|
@ -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" "$@"
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
172
tests/test_admin_bootstrap.py
Normal file
172
tests/test_admin_bootstrap.py
Normal 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)
|
||||
123
tests/test_admin_bootstrap_privacyidea.py
Normal file
123
tests/test_admin_bootstrap_privacyidea.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue