net-kingdom/sso-mfa/k8s/privacyidea/README.md
tegwick 710dc99585
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
docs: index privacyIDEA and KeyCape helper entry points from the NK49 caller ledger
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 696317@bnt-lap001
Assistant-Session: bab9e511-0915-45b3-926a-2fab552edff6
2026-10-23 00:19:09 +02:00

344 lines
16 KiB
Markdown

# T04 — Phase 3: Deploy privacyIDEA
Exercise status: unknown for the procedures in this runbook; no per-procedure successful-run receipt with operator attribution was established in the 2026-09-05 review. See [procedure inventory](../../../docs/attended-procedure-inventory.md).
Phase 3 of NK-WP-0001: deploys the MFA core (privacyIDEA) in the `mfa` namespace.
**Hostnames (config points CP-NK-002 / CP-NK-003):**
- `pink.coulomb.social` — main portal and API
- `pink-account.coulomb.social` — self-service token portal
**Prerequisites:**
- T02 complete: `mfa` namespace and NetworkPolicies applied, cert-manager running.
- T03 complete: PostgreSQL cluster `net-kingdom-pg` in `databases` namespace is Ready.
- T01 Phase 0a complete: `gen-secrets.sh` run, all secrets in KeePassXC.
---
## Apply order
### Step 1 — Create the config Secret
```bash
cd sso-mfa/k8s/privacyidea
chmod +x create-secrets.sh enckey-bootstrap.sh bootstrap-admin.sh
./create-secrets.sh
```
Creates `privacyidea-config` in the `mfa` namespace (PI_SECRET_KEY, PI_PEPPER,
PI_SQLALCHEMY_DATABASE_URI).
---
### Step 2 — Apply manifests
```bash
# From sso-mfa/k8s/privacyidea/
kubectl apply -f pvc.yaml
kubectl apply -f configmap.yaml
kubectl apply -f middleware.yaml
kubectl apply -f deployment.yaml
kubectl apply -f ingress.yaml
```
**Wait for the pod to reach Running state** (DB migrations run on first start —
allow up to 3 minutes):
```bash
kubectl get pods -n mfa -w
# Expected: privacyidea-<hash> 1/1 Running
```
If the pod is stuck in `Init` or `CrashLoopBackOff`, check logs:
```bash
kubectl logs -n mfa -l app.kubernetes.io/name=privacyidea --previous
```
Common causes:
- `privacyidea-config` Secret missing → run `create-secrets.sh`
- PostgreSQL not reachable → verify T03, check NetworkPolicies
- Wrong DB password → reconcile the database, CNPG application Secret and privacyIDEA URI through the reviewed named `PI_DB_PASSWORD` rotation; ordinary reapplication refuses differing values. See [service Secret custody](../../bootstrap/README.md#service-secret-creation-and-explicit-rotation).
---
### Step 3 — Extract key material and create DR Secrets
Export the existing key set from the verified Ready pod into an owned private
custody root containing a private `privacyidea` subdirectory:
```bash
bash enckey-bootstrap.sh /absolute/private/custody
```
This preserves existing local copies and creates or verifies matching DR Secrets:
`privacyidea-enckey` (`enckey`) and `privacyidea-auditkeys` (`private.pem`,
`public.pem`). Missing keys are refused. Only an explicitly reviewed first
bootstrap can request `--initialize-audit-keys`; it never generates an encryption
key. Follow [key custody and PVC recovery](KEY-RECOVERY.md) for private export,
independent backup, failure handling and recovery acceptance.
---
### Step 4 — Bootstrap admin accounts
```bash
./bootstrap-admin.sh
```
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.
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.
---
### Step 5 — Verify
```bash
cd sso-mfa/k8s
chmod +x verify-t04.sh
./verify-t04.sh
```
---
## Container port note
The deployment uses `containerPort: 8080`. The official `privacyidea/privacyidea`
image uses nginx internally; the default nginx port may be 80 depending on the
image version.
**If the pod starts but requests return "connection refused":**
```bash
# Check what port the container actually listens on:
kubectl exec -n mfa <pod> -- ss -tlnp | grep LISTEN
```
If the container uses port 80, update:
1. `deployment.yaml`: `containerPort: 80`, Service `targetPort: 80`
2. `sso-mfa/k8s/network-policies/netpol-mfa.yaml`: `port: 80` in privacyIDEA rules
3. Reapply both files.
---
## NetworkPolicy design
privacyIDEA sits entirely behind the NetworkPolicies applied in T02 (netpol-mfa.yaml):
| Source | Destination | Port | Purpose |
|--------|-------------|------|---------|
| Traefik (kube-system) | privacyIDEA (mfa) | 8080 | User-facing portal |
| KeyCape (sso) | privacyIDEA (mfa) | 8080 | MFA API |
| privacyIDEA (mfa) | PostgreSQL (databases) | 5432 | Database |
Outbound to anything other than PostgreSQL and kube-dns is denied.
---
## Post-deploy steps (after verify-t04.sh passes)
### Rate limiting adjustment
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
Update `middleware.yaml` `privacyidea-admin-allowlist.spec.ipWhiteList.sourceRange`
to your actual VPN/office CIDRs and reapply:
```bash
kubectl apply -f middleware.yaml
```
### Self-service portal
Enable the self-service portal policy in privacyIDEA:
Config → Policies → New policy:
- Scope: `user`
- Action: `enrollTOTP`, `enrollHOTP`, or token types you want users to manage
- URL for self-service: `https://pink-account.coulomb.social`
---
## Disaster recovery
Follow [key custody and PVC recovery](KEY-RECOVERY.md). Restore the original
matching encryption/audit files into the actual `privacyidea-data` PVC while the
application is stopped, using the reviewed mounted recovery pod. Creating a
Secret and restarting cannot restore these files; the deployment does not mount
the DR Secrets. The former unmounted helper-pod example is withdrawn. Native
recovery acceptance requires the matching database backup, existing-token checks
and audit-signature verification before closure.
## Scoped factor-state diagnostic
`check-user-mfa-state.sh` reports only factor counts in `coulomb`. It no longer
reads the retired `keycape-pi-token` Secret, queries administrative realm/user
catalogues, or guesses that the historic `netkingdom` realm is accessible.
```bash
bash sso-mfa/k8s/privacyidea/check-user-mfa-state.sh platform-root \
--token-file /absolute/private/diagnostic.jwt
```
The file must contain an owner-admitted, unexpired provider admin-role JWT with
`tokenlist` visibility in `coulomb`, optionally followed by one newline. It and
its parent must be owned/private (normally 0600/0700), unlinked, and outside Git.
The helper does not retrieve, mint, renew, export or cache credentials. Missing
input stops before any credential or network access; raw values are never argv.
RPF-WP-0040's renewable factor-reader lane is delivered to **KeyCape**, not this
workstation helper. That contract is not permission to extract its Secret or
share its identity. An attended diagnostic needs its own explicitly admitted
owner delivery/execution arrangement; this source change neither creates nor
claims one. Use owner credential routing before requesting that arrangement.
The historical resolver/admin-token lanes remain separately blocked.
The helper uses the canonical HTTPS provider with system CA verification, or an
explicit literal loopback URL for a reviewed local forward/fixture. It refuses
other origins, redirects and ambient proxies. HTTP loopback is only as trustworthy
as the reviewed local process/forward. There is no insecure-TLS flag.
The existing disabled, unassigned `KCFACTORSCOPE01` token proves realm visibility
before and after the lookup. Provider denial, missing/mismatched proof, malformed
responses, unknown enrollment states or incomplete pages return `unverified`,
never a successful zero. The diagnostic requests at most 1000 factors and refuses
larger/incomplete results rather than publishing partial counts. The provider
API is paginated; see the [upstream token API](https://privacyidea.readthedocs.io/en/v3.12.1/modules/api/token.html).
JSON output contains total, active-confirmed, active-pending and inactive counts.
It deliberately records `user_existence: not_checked` and `mfa_login: not_tested`:
a scoped zero-factor response does not prove that a user exists, and counts do
not prove an MFA login. No token serial, metadata, provider error body or credential
is emitted. Separate HTTP observations are not an atomic provider snapshot.
Actual login remains `verify-t06.sh`/the supported user journey; pending enrollment
must be confirmed before it counts as an active confirmed factor.
## Configuration-preserving realm repair
The attended entrypoint remains:
```bash
bash sso-mfa/k8s/privacyidea/repair-realm-live.sh
```
It prompts for pi-admin in the terminal, reads current configuration, and asks
for an LLDAP bind password only when the named resolver is missing. Passwords
stay in process memory: no shell evaluation, environment-variable transport,
password arguments or temporary credential files. `MFA_USER` selects the user
for the final known-user lookup and fresh token-backed MFA proof. A failed proof
returns nonzero even if configuration writes already succeeded.
For an owner-reviewed bootstrap using existing private generated files:
```bash
bash sso-mfa/k8s/privacyidea/bootstrap-realm.sh /absolute/private/root
bash sso-mfa/k8s/privacyidea/bootstrap-realm.sh /absolute/private/root --apply
```
The first command authenticates and previews configuration actions; the second
executes the reviewed repair. `privacyidea/secrets.env` and `lldap/secrets.env`
are parsed as literal `NAME=value` records, never shell code. Root and component
directories must be owned/private (0700); files must be owned/private regular
unlinked files (0600). Shell-escaped `%q` files are not the input format. The old
third positional bind-password argument is rejected. An optional second
positional URL, or `PI_URL`, selects only the canonical verified HTTPS provider
or an explicit literal loopback fixture/approved forward. Proxy and redirect
credential forwarding are refused by shared `pi_api.py`.
The supported repair creates an absent `lldap-coulomb` LDAP resolver and absent
`coulomb` realm. On an existing resolver it fills only missing TIMEOUT,
CACHE_TIMEOUT and SIZELIMIT, preserving valid custom values, the bind credential
and other fields. It refuses a different resolver type/backend or an existing
realm missing the expected binding; those need owner-reviewed migration. Existing
realm bindings/priorities and defaults survive. It sets `coulomb` as the initial
default only when the initial realm inventory is empty.
No enrollment, deletion, passthrough or other policy is written. The deployed
onboarding guard, possession confirmation, pending-only cancellation and audited
recovery remain with their established KeyCape/NetKingdom owner procedures.
Replaying the old bootstrap's `enrollTOTP, delete, disable` or passthrough policy
would undo that hardening. A fresh instance still needs those owner-managed
onboarding components; a configuration receipt is not full IAM readiness.
The provider has no compare-and-swap operation for these writes. Use an exclusive
owner maintenance window. The helper checks for drift before each write and
reads back afterward; that does not make the sequence atomic. On failure it
reports unverified/possible partial changes and stops, with no automatic retry,
policy rewrite, deletion or rollback. Reconcile the retained state before retry.
Native disposable privacyIDEA 3.12.2 verifies that resolver POST preserves omitted
fields, including the bind password. This corrects the older runbook's general
replacement claim; it does not reclassify historical deployment evidence.
Reference: [provider resolver API](https://privacyidea.readthedocs.io/en/v3.12.2/modules/api/resolver.html).
Provider/package checks do not prove live LDAP reachability, current custody,
installed hooks, user enrollment or a complete KeyCape browser sign-in.
## Attended resolver password reconciliation
The update and reconcile helpers share `resolver_cutover.py`. Follow the
[current procedure](../../../docs/keycape-exposure-resolver-reconciliation.md)
for preview, digest-bound minimal apply, explicit cluster pins and truthful
partial receipts. Credentials stay in attended process memory. Native fixture
proof is not production acceptance or authority to perform exposure rotation.
## Entry-point index
Each helper in this directory, with its intended caller, from the NK-WP-0049-T02
[caller ledger](../../../docs/evidence/2026-10-23-nk49-t02-caller-ledger/caller-ledger.json).
"Greenfield only" helpers never update existing credentials or policy. On the
current estate, re-running one needs its own owner decision. All helpers run in
an attended operator session for namespace `mfa`. None of them prints a
credential.
| Helper | Purpose | Status / caller |
|---|---|---|
| `bootstrap-admin.py` | Pass one private bootstrap credential to the admitted pod over stdin | Greenfield only |
| `bootstrap-admin-remote.py` | In-pod, insert-only admin bootstrap | Greenfield only; called by `bootstrap-admin.py` |
| `realm_bootstrap.py` | Repair missing realm/resolver configuration while preserving policy | Library for `bootstrap-realm.sh` and the realm repair above |
| `activate-onboarding-policy.py` | Provider-local enrollment policy activation; never changes tokens | Current; NK-WP-0042 scoped enrollment |
| `deploy-onboarding-guard.py` | Deploy the reviewed enrollment hook, preserving runtime secrets | Current; NK-WP-0042 |
| `enable-factor-confirmation.py` | Require possession proof for newly enrolled TOTP; existing factors untouched | Current; NK-WP-0042 |
| `deploy-factor-recovery.py` | Deploy the private P04 factor-recovery service with provider custody and CAS guards | Current; recovery journey acceptance open under NK-WP-0042 |
| `key-custody-remote.py` | In-pod key transport | Internal; invoke only through `key-custody.py` |
| `keycape-scope-probe.py` | Idempotent, inert realm-visibility probe; emits no key | Diagnostic |
| `mfa_state.py` | Read-only scoped factor diagnostic | Library for `check-user-mfa-state.sh` |
| `verify_mfa.py` | Attended functional privacyIDEA proof | Called by `sso-mfa/k8s/verify-t06.sh` |