diff --git a/docs/attended-procedure-inventory.md b/docs/attended-procedure-inventory.md index 3fc50b5..6cacdba 100644 --- a/docs/attended-procedure-inventory.md +++ b/docs/attended-procedure-inventory.md @@ -18,12 +18,12 @@ runbook or a dedicated procedure. Missing runbooks are listed separately. | `keycape/refresh-pi-token-live.sh` | [README.md](../sso-mfa/k8s/keycape/README.md) | unknown | | `keycape/register-coulomb-social.sh` | None found | unknown | | `keycape/verify-openbao-client.sh` | [README.md](../sso-mfa/k8s/keycape/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | -| `lldap/bootstrap-users.sh` | [README.md](../sso-mfa/k8s/lldap/README.md), [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md) | unknown | +| `lldap/bootstrap-users.sh` | [README.md](../sso-mfa/k8s/lldap/README.md), [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md) | source + isolated native fixture verified 2026-10-14; estate exercise still unknown; [procedure](../sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md) | | `lldap/break-glass.sh` | [platform-root-custody.md](../docs/platform-root-custody.md) | retired 2026-10-14; [owner routes](legacy-lldap-lifecycle-retirement.md) | | `lldap/create-secrets.sh` | [README.md](../sso-mfa/k8s/lldap/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | | `lldap/create-user.sh` | [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | retired 2026-10-14; [owner routes](legacy-lldap-lifecycle-retirement.md) | | `lldap/dry-run-nonroot-user.sh` | [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | retired 2026-10-14; [owner routes](legacy-lldap-lifecycle-retirement.md) | -| `lldap/manage-group-members.sh` | [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md) | unknown | +| `lldap/manage-group-members.sh` | [OPERATOR-GROUPS.md](../sso-mfa/k8s/lldap/OPERATOR-GROUPS.md) | source + isolated native fixture verified 2026-10-14; estate exercise still unknown; [procedure](../sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md) | | `postgresql/create-secrets.sh` | [README.md](../sso-mfa/k8s/postgresql/README.md), [smooth-bootstrap-guide.md](../docs/smooth-bootstrap-guide.md) | unknown | | `privacyidea/bootstrap-admin.sh` | [README.md](../sso-mfa/k8s/privacyidea/README.md) | unknown | | `privacyidea/bootstrap-realm.sh` | [privacyIDEA repair contract](../sso-mfa/k8s/privacyidea/README.md#configuration-preserving-realm-repair) | source repaired: literal private files, preview/explicit apply, configuration preservation; live acceptance pending | @@ -40,7 +40,7 @@ runbook or a dedicated procedure. Missing runbooks are listed separately. | `verify-t04.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | | `verify-t05.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | | `verify-t06.sh` | [verify-t06.md](../docs/verify-t06.md) | unexercised on provider; HTTP simulation passed | -| `verify-t07.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | +| `verify-t07.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | source + isolated native fixture verified 2026-10-14; estate exercise still unknown; [procedure](../sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md) | | `verify-t08.sh` | [DR-RUNBOOK.md](../sso-mfa/k8s/backup/DR-RUNBOOK.md) | unknown | ## Scripts without a matching runbook diff --git a/docs/legacy-lldap-lifecycle-retirement.md b/docs/legacy-lldap-lifecycle-retirement.md index 09b3507..ae8a358 100644 --- a/docs/legacy-lldap-lifecycle-retirement.md +++ b/docs/legacy-lldap-lifecycle-retirement.md @@ -61,8 +61,11 @@ limitation was already recorded in NK-WP-0049-T03 still needs the exact owner-approved recovery ceremony, separated key/snapshot custody, failed-key refusal, native restore and cleanup receipts. -`bootstrap-users.sh`, `manage-group-members.sh` and `verify-t07.sh` remain separate -IAM caller repairs under NK-WP-0049-T02. Their credential extraction and current -verification claims have not been repaired by this change. Incident cutover -helpers and the complete root-output inventory also remain open. This document -and these source tests do not close NK-WP-0049 or establish live recovery readiness. +The remaining LDAP group entry points now use the +[explicit-input maintenance adapter](../sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md) +under NK-WP-0049-T02. Group bootstrap creates only missing empty baseline groups; +service membership requires a reviewed preview and exact readback. T07 reports +structure with a nonzero incomplete exit and never reads the KeyCape config Secret. +Source and isolated native tests do not establish estate acceptance. Incident +cutover helpers, full caller/root-output inventory and owner-approved independent +recovery remain open. This retirement does not close NK-WP-0049. diff --git a/sso-mfa/WORKPLAN.md b/sso-mfa/WORKPLAN.md index 15edfa2..5514d97 100644 --- a/sso-mfa/WORKPLAN.md +++ b/sso-mfa/WORKPLAN.md @@ -133,19 +133,19 @@ Hostnames: kc.coulomb.social (KeyCape), auth.coulomb.social (Authelia), lldap.co - [x] `k8s/verify-t07.sh` — verifies groups, break-glass user, self-service portal, OIDC client registrations ### Pending (needs live cluster) -- [ ] Run `lldap/bootstrap-users.sh` to create groups +- [ ] Run owner-reviewed group preview/apply with explicit custody; see `k8s/lldap/GROUP-MAINTENANCE.md`. - Retired 2026-10-14: do not run the old break-glass creator. Preserve existing custody; NK-WP-0049-T03 tracks owner recovery verification. - [ ] Add first real user via LLDAP WebUI (lldap.coulomb.social) - [ ] Register first OIDC client in `keycape/create-secrets.sh` (clients: block) - [ ] User self-enrolls TOTP at pink-account.coulomb.social -- [ ] Run `verify-t07.sh` — 0 FAILs +- [ ] Retain actual lifecycle/login/recovery acceptance; `verify-t07.sh` now supplies structural diagnostics only (exit 2). ### Done-criteria for T07 - Groups net-kingdom-users and net-kingdom-admins exist in LLDAP - break-glass user exists and is in net-kingdom-admins - At least one regular user exists - At least one OIDC client registered in KeyCape -- verify-t07.sh: 0 FAILs +- Historical zero-FAIL T07 results are insufficient; use actual owner lifecycle/login/recovery receipts. ## T08 — Backups, DR, break-glass diff --git a/sso-mfa/k8s/backup/DR-RUNBOOK.md b/sso-mfa/k8s/backup/DR-RUNBOOK.md index 84850f4..cd95c34 100644 --- a/sso-mfa/k8s/backup/DR-RUNBOOK.md +++ b/sso-mfa/k8s/backup/DR-RUNBOOK.md @@ -165,13 +165,17 @@ kubectl rollout status deployment/keycape -n sso --timeout=60s cd privacyidea && ./enckey-bootstrap.sh /absolute/private/custody # Restore and verify existing LLDAP identities from the owner-reviewed backup. # Legacy break-glass creation is retired; do not overwrite existing passwords. -# Group bootstrap and verify-t07 remain under NK-WP-0049 caller repair. +# For group preview/apply and structural diagnostics, follow lldap/GROUP-MAINTENANCE.md. +# Those checks do not prove native recovery or broader T07 completion. # See ../../../docs/legacy-lldap-lifecycle-retirement.md for recovery limits. # Verify owner-managed factor renewal, ESO delivery and mounted reload (RPF-WP-0040). # Do not mint a personal admin token or replace existing KeyCape configuration. # 7. Verify -./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh --user platform-root && ./verify-t07.sh && ./verify-t08.sh +./verify-t04.sh && ./verify-t05.sh && ./verify-t06.sh --user platform-root && ./verify-t08.sh +# Separately, owner-invoked LDAP structure observation (exit 2 means incomplete acceptance): +./verify-t07.sh --root /absolute/private/custody +# Actual lifecycle, login and independent recovery receipts remain required. ``` --- diff --git a/sso-mfa/k8s/keycape/README.md b/sso-mfa/k8s/keycape/README.md index d36ef9c..155bc46 100644 --- a/sso-mfa/k8s/keycape/README.md +++ b/sso-mfa/k8s/keycape/README.md @@ -123,8 +123,9 @@ http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback The browser UI callback is paired with the named Railiance Platform operator tunnel at `http://127.0.0.1:18200`. The public `bao.coulomb.social` callbacks -were retired with that name on 2026-09-15; `openbao-client-config.py` and -`verify-t07.sh` fail if either is registered again. The preferred browser auth +were retired with that name on 2026-09-15; `openbao-client-config.py` enforces +the retired-callback restriction. `verify-t07.sh` now checks LDAP structure only +and cannot prove client registration. The preferred browser auth mount is `netkingdom`; `keycape` remains a compatibility alias. Keep the CLI callbacks unless there is a separate decision to retire CLI login. diff --git a/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md b/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md new file mode 100644 index 0000000..147a3fc --- /dev/null +++ b/sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md @@ -0,0 +1,121 @@ +# Owner-invoked LLDAP group maintenance + +Source repair: NK-WP-0049-T02, 2026-10-14. The shell entry points now delegate to +`lldap_admin.py`, which reuses the existing identity-provisioner directory +operations and service-group validation. This is the bootstrap/maintenance path, +not a new account lifecycle service, credential broker or authority boundary. + +## Scope and custody + +An authorized IAM custodian supplies an explicit private temporary custody root +outside Git. The root and `lldap/` directory must be owned by the caller and mode +0700; `lldap/secrets.env` must be an owned, single-link, non-symlink regular file +with mode 0600. The existing custody reader parses `LLDAP_LDAP_USER_PASS` literally. +Do not add shell quotes, `export`, or executable commands. Other generated fields +may remain in that file; this tool does not rewrite it. + +Credential ownership/routing and an exact live change approval remain required. +The adapter does not fetch Kubernetes Secrets, use an ambient admin password, +create accounts or credentials, invoke curl/kubectl, or install a permanent key. +A preview digest is an observation binding, not an approval, caller identity or +runtime isolation proof. Use the owner's protected execution context; do not +hand the directory admin credential to an unisolated agent. + +The default origin is `https://lldap.coulomb.social`. An explicit literal +`127.0.0.1` or `[::1]` origin with a port is accepted for an owner-approved forward +or isolated fixture. `localhost`, arbitrary hosts, HTTP on the public host, +userinfo, paths, queries and fragments are refused. TLS uses system trust; +redirects and ambient proxies are disabled. Credentials stay in Python memory +and the HTTP body/header, never in child-process arguments. Provider errors are +contained; output has fixed failure phases, not raw responses. + +## Bootstrap: only three empty groups + +```bash +./bootstrap-users.sh --root /absolute/private/custody +``` + +The default is a nonmutating preview. It names missing `net-kingdom-users`, +`net-kingdom-admins` and `activity-core-operators` groups, with a +`snapshot_sha256`. After reviewing that exact operation under the existing live +approval, apply using the preview digest: + +```bash +./bootstrap-users.sh --root /absolute/private/custody \ + --apply --expected-digest REVIEWED_PREVIEW_SHA256 +``` + +Existing groups, IDs, users and memberships must survive unchanged. This command +only creates missing groups without members. It cannot grant platform-admin +membership, reset a password or reconstruct missing users after data loss. + +## App-scoped membership + +Ordinary account onboarding stays in User Engine / identity-provisioner. To +preview an exact service-group change for an existing directory identity: + +```bash +./manage-group-members.sh add --user agent-codex --group activity-core-operators \ + --root /absolute/private/custody +./manage-group-members.sh remove --user agent-codex --group activity-core-operators \ + --root /absolute/private/custody +./manage-group-members.sh list --group activity-core-operators \ + --root /absolute/private/custody +``` + +Add/remove require `--apply --expected-digest REVIEWED_PREVIEW_SHA256` to mutate. +List is read-only and emits only member identifiers. Group and user must already +exist; this path never creates either. Tenant, platform (`net-kingdom-`), +suspension (`netkingdom-`) and LLDAP administrative groups are refused by the +existing service-group validator before login. Use their respective owners for +those operations; the old unrestricted membership helper is not a fallback. + +## Preservation, interruption and recovery + +The digest binds origin, operation, target and the observed directory users, +groups, IDs and memberships. Apply re-reads before its first write and refuses +stale state. Final readback must equal precisely the intended change, including +unchanged unrelated memberships. An already-satisfied change is a verified no-op. + +LLDAP does not provide a cross-query compare-and-swap transaction here. The owner +must hold an exclusive maintenance window across preview/apply; two reads are +not a lock. A failure during multiple group creations can leave partial state. +There is no blind rollback or retry and no group deletion. On failure retain the +non-secret receipt, inspect current state and reconcile under the owner before a +new reviewed preview. Do not infer failure means nothing changed. + +## T07 is a structural diagnostic + +```bash +../verify-t07.sh --root /absolute/private/custody +``` + +This checks actual directory structure and reports missing baseline groups, +user count and break-glass account/group presence without declaring them usable. +It does not read KeyCape's configuration Secret, execute inside a pod, infer OIDC +claims or treat a reachable portal as user acceptance. Even complete structural +observations return **2**, with `native_acceptance: unverified`. Invalid input, +authentication/transport failure or malformed responses return **1**. Both mean +the old T07 completion gate is unsatisfied. Do not use this diagnostic alone to +close user lifecycle, MFA, session revocation, client access or recovery tasks. + +Use current owner-specific issuer/consumer checks and actual authorized human +lifecycle/login/recovery receipts for those properties. The +[legacy lifecycle retirement notice](../../../docs/legacy-lldap-lifecycle-retirement.md) +and [verification audit](../../../docs/verification-audit-2026-09-05.md) describe +these limits. `openbao-client-config.py` remains the specific OpenBao client +configuration checker; a configuration check is not actual client login. + +## Verification and live limits + +Regression tests cover private input, refusals, previews, state drift, +preservation and malformed/failed readbacks. `tests/lldap_groups_native_fixture.py` +exercises the actual adapter against an immutable existing local LLDAP image, +with synthetic credentials, loopback-only publication and disposable storage. +It removes its own fixture container and private files. The pinned native test +image and results are in Custodian's `lldap-group-maintenance.json` receipt. + +Those receipts do not prove the currently deployed directory version, current +credential validity, attended authority, independent recovery or live enforcement +of earlier identity-provisioner source fixes. NK-WP-0049-T03 retains native owner +acceptance, and T02 retains incident-cutover/full caller-inventory work. diff --git a/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md b/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md index 7b98a9f..6e138a4 100644 --- a/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md +++ b/sso-mfa/k8s/lldap/OPERATOR-GROUPS.md @@ -15,7 +15,9 @@ domain rules. Distinct from platform groups (`net-kingdom-users` / **Do not** auto-add everyone in `net-kingdom-admins`. Membership is a conscious grant. Platform admin ≠ activity-core operator. -Group bootstrap via `bootstrap-users.sh` remains under credential-path repair in NK-WP-0049; it is not accepted by the lifecycle retirement tests. Authelia config: +Group bootstrap uses [explicit custody and reviewed preview/apply](GROUP-MAINTENANCE.md). +Only missing empty baseline groups are created; native estate acceptance remains +NK-WP-0049-T03. Authelia config: `sso-mfa/k8s/authelia/configmap.yaml` (`access_control.rules`). ## WebUI path @@ -27,18 +29,19 @@ Group bootstrap via `bootstrap-users.sh` remains under credential-path repair in 5. User re-authenticates at Authelia if an old session lacks the group claim (sign out / new browser session). -## Scripted path pending repair +## Scripted path -`manage-group-members.sh` still has a legacy admin-credential path. Its repair -and native verification remain in NK-WP-0049-T02/T03. The earlier automatic -Kubernetes Secret extraction and shell-export instructions are withdrawn. -Use the existing attended operator UI under the caller's established authority; -this document grants no new credential or group-administration permission. +Use [group maintenance](GROUP-MAINTENANCE.md) with explicit private custody. +For an existing identity and app-scoped group, `manage-group-members.sh add` or +`remove` previews an exact change; apply requires its reviewed snapshot digest. +`list` is read-only. Tenant/platform/LLDAP administrative groups are refused. +No Kubernetes Secret extraction or shell-export step is required. Existing live +authority and protected execution remain required; the source adapter grants none. ## Onboarding checklist (activity-core ops) 1. User has LLDAP account and is in `net-kingdom-users` (normal onboarding). -2. Operator adds them to `activity-core-operators` (attended WebUI under existing authority). +2. Operator adds them to `activity-core-operators` (attended WebUI or reviewed group adapter under existing authority). 3. User signs in at https://auth.coulomb.social (password; MFA via KeyCape path when using OIDC apps — ForwardAuth to ops hosts uses Authelia session). 4. Open https://activity.coulomb.social and https://temporal.coulomb.social — @@ -54,7 +57,7 @@ SSO outages; do not gate break-glass on LLDAP group membership. ## Adding a future app-operator group -1. Add the group name to `REQUIRED_GROUPS` in `bootstrap-users.sh`. +1. Have the service/IAM owner establish its app-scoped group through the existing provider or attended UI. The fixed baseline bootstrap is not a registry for new apps. 2. Document it in this table. 3. Add Authelia domain rules (member `one_factor` + domain `deny` fallback) in `sso-mfa/k8s/authelia/configmap.yaml` — see Authelia README. diff --git a/sso-mfa/k8s/lldap/README.md b/sso-mfa/k8s/lldap/README.md index fd90829..527ea79 100644 --- a/sso-mfa/k8s/lldap/README.md +++ b/sso-mfa/k8s/lldap/README.md @@ -35,10 +35,12 @@ kubectl rollout status deployment/lldap -n sso --timeout=120s ## Post-deploy bootstrap -After the pod is Running, seed required groups (preferred — idempotent): +After the pod is Running, preview the fixed group bootstrap under the IAM owner's authority. See [group maintenance](GROUP-MAINTENANCE.md) for private input, +reviewed apply, preservation, interruption and native acceptance limits: ```bash -./bootstrap-users.sh +./bootstrap-users.sh --root /absolute/private/custody +# Preview only. Apply requires --apply --expected-digest from the reviewed preview. # Groups: net-kingdom-users, net-kingdom-admins, activity-core-operators ``` diff --git a/sso-mfa/k8s/lldap/bootstrap-users.sh b/sso-mfa/k8s/lldap/bootstrap-users.sh index bbc4b6c..359c199 100755 --- a/sso-mfa/k8s/lldap/bootstrap-users.sh +++ b/sso-mfa/k8s/lldap/bootstrap-users.sh @@ -1,174 +1,5 @@ #!/usr/bin/env bash -# bootstrap-users.sh — seed required groups in LLDAP -# -# Run AFTER LLDAP is deployed and Running (T05a). -# -# What it does: -# 1. Authenticates to LLDAP via its GraphQL API. -# 2. Creates required platform + app-operator groups (idempotent). -# 3. Prints a user onboarding checklist (groups-only; individual users are -# onboarded through User Engine; IAM group management is separate). -# -# Groups created: -# net-kingdom-users — standard users; all human accounts go here. -# net-kingdom-admins — privileged users; KeyCape policies can enforce -# MFA step-up or grant extra scopes to this group. -# activity-core-operators — app-scoped: browser access to activity-core -# ops console + Temporal UI (Authelia domain rules). -# Not platform admin — grant consciously (NK-WP-0021). -# -# Usage: -# ./bootstrap-users.sh [lldap-url] [secrets-dir] -# -# default: https://lldap.coulomb.social -# default: ../../bootstrap/secrets - +# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md. +set +x set -euo pipefail - -LLDAP_URL="${1:-https://lldap.coulomb.social}" -SECRETS_DIR="${2:-../../bootstrap/secrets}" -LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" - -PASS_COUNT=0 -FAIL_COUNT=0 - -ok() { echo " [OK] $1"; ((PASS_COUNT++)); } -fail() { echo " [FAIL] $1"; ((FAIL_COUNT++)); } -info() { echo " [INFO] $1"; } - -if [[ ! -f "$LLDAP_ENV" ]]; then - echo "ERROR: $LLDAP_ENV not found — run sso-mfa/bootstrap/gen-secrets.sh first." >&2 - exit 1 -fi - -read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } -LLDAP_ADMIN_PASS=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS) - -if [[ -z "$LLDAP_ADMIN_PASS" ]]; then - echo "ERROR: LLDAP_LDAP_USER_PASS not found in $LLDAP_ENV" >&2 - exit 1 -fi - -# ── 1. Authenticate ─────────────────────────────────────────────────────────── -echo "" -echo "Authenticating to LLDAP at $LLDAP_URL ..." - -AUTH_RESP=$(curl -sf -X POST "$LLDAP_URL/auth/simple/login" \ - -H "Content-Type: application/json" \ - -d "{\"username\":\"admin\",\"password\":\"$LLDAP_ADMIN_PASS\"}" \ - 2>/dev/null || echo "CURL_FAILED") - -if [[ "$AUTH_RESP" == "CURL_FAILED" ]]; then - echo "ERROR: Could not reach $LLDAP_URL — is LLDAP deployed and ingress up?" >&2 - exit 1 -fi - -LLDAP_TOKEN=$(echo "$AUTH_RESP" | python3 -c \ - "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "") - -if [[ -z "$LLDAP_TOKEN" ]]; then - echo "ERROR: Authentication failed. Response: $AUTH_RESP" >&2 - exit 1 -fi -info "Authenticated as admin" - -gql() { - # gql - local query="$1"; local vars="${2:-{}}" - local body - body=$(python3 -c " -import json, sys -print(json.dumps({'query': sys.argv[1], 'variables': json.loads(sys.argv[2])})) -" "$query" "$vars") - curl -sf -X POST "$LLDAP_URL/api/graphql" \ - -H "Authorization: Bearer $LLDAP_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$body" 2>/dev/null || echo "CURL_FAILED" -} - -create_group() { - local name="$1" - echo "" - echo "Creating group: $name ..." - - # Check if group already exists - LIST_RESP=$(gql 'query { groups { id displayName } }') - if [[ "$LIST_RESP" != "CURL_FAILED" ]]; then - EXISTS=$(echo "$LIST_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if any(g['displayName']=='$name' for g in d.get('data',{}).get('groups',[])) else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$EXISTS" == "yes" ]]; then - ok "Group '$name' already exists — skipping" - return 0 - fi - fi - - RESP=$(gql 'mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }' \ - "{\"name\":\"$name\"}") - if [[ "$RESP" == "CURL_FAILED" ]]; then - fail "Group '$name' — curl request failed" - return 1 - fi - ERR=$(echo "$RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); errs=d.get('errors',[]); print(errs[0]['message'] if errs else '')" \ - 2>/dev/null || echo "") - if [[ -n "$ERR" ]]; then - fail "Group '$name' — $ERR" - return 1 - fi - GID=$(echo "$RESP" | python3 -c \ - "import sys,json; print(json.load(sys.stdin).get('data',{}).get('createGroup',{}).get('id','?'))" \ - 2>/dev/null || echo "?") - ok "Group '$name' created (id=$GID)" -} - -# ── 2. Create required groups ───────────────────────────────────────────────── -REQUIRED_GROUPS=( - "net-kingdom-users" - "net-kingdom-admins" - "activity-core-operators" -) - -for grp in "${REQUIRED_GROUPS[@]}"; do - create_group "$grp" -done - -# ── 3. Verify ───────────────────────────────────────────────────────────────── -echo "" -echo "Verifying groups ..." -LIST_RESP=$(gql 'query { groups { id displayName } }') -if [[ "$LIST_RESP" != "CURL_FAILED" ]]; then - for grp in "${REQUIRED_GROUPS[@]}"; do - EXISTS=$(echo "$LIST_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if any(g['displayName']=='$grp' for g in d.get('data',{}).get('groups',[])) else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$EXISTS" == "yes" ]]; then - ok "Group '$grp' confirmed" - else - fail "Group '$grp' not found after creation" - fi - done -else - fail "Could not retrieve group list from LLDAP" -fi - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -echo "════════════════════════════════════════════════════════════" -echo " LLDAP group bootstrap: PASS=$PASS_COUNT FAIL=$FAIL_COUNT" -echo "════════════════════════════════════════════════════════════" -echo "" -echo "Next: use the User Engine / identity-provisioner onboarding contract." -echo "IAM group helpers remain under NK-WP-0049 credential-path repair." -echo "App-operator membership: see OPERATOR-GROUPS.md (do not auto-grant all admins)." -echo "" -echo "Use docs/legacy-lldap-lifecycle-retirement.md for current lifecycle owner routes." -echo "Ordinary onboarding does not grant platform-admin or app-operator membership." -echo "Retain actual login, scope, MFA and consumer acceptance receipts." -echo "Break-glass: preserve existing offline custody; independent recovery remains unverified." -echo "" - -if [[ "$FAIL_COUNT" -gt 0 ]]; then - exit 1 -fi -exit 0 +exec python3 "$(dirname -- "$0")/lldap_admin.py" bootstrap "$@" diff --git a/sso-mfa/k8s/lldap/lldap_admin.py b/sso-mfa/k8s/lldap/lldap_admin.py new file mode 100644 index 0000000..35db314 --- /dev/null +++ b/sso-mfa/k8s/lldap/lldap_admin.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Owner-invoked LDAP group maintenance; no credential discovery or authority grant.""" +import argparse +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import re +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request + +ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(ROOT / 'identity-provisioner')) +from provisioner import LLDAPProvisioner, _service_group_request # noqa: E402 + +BASELINE = ('net-kingdom-users', 'net-kingdom-admins', 'activity-core-operators') +MAX_BODY = 1024 * 1024 + + +class Refused(Exception): + pass + + +class Parser(argparse.ArgumentParser): + def error(self, message): + raise Refused() + + +def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise Refused() + result[key] = value + return result + + +def endpoint(value): + if not isinstance(value, str) or not value.isascii() or any(ord(c) <= 32 for c in value): + raise Refused() + parsed = urllib.parse.urlsplit(value) + if parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or parsed.path not in ('', '/'): + raise Refused() + if value.rstrip('/') in ('https://lldap.coulomb.social', 'https://lldap.coulomb.social:443'): + return value.rstrip('/') + if parsed.hostname not in ('127.0.0.1', '::1') or parsed.scheme not in ('http', 'https') or parsed.port is None: + raise Refused() + if not 1 <= parsed.port <= 65535: + raise Refused() + expected = f'{parsed.scheme}://{("[::1]" if parsed.hostname == "::1" else parsed.hostname)}:{parsed.port}' + if value.rstrip('/') != expected: + raise Refused() + return expected + + +def private_password(root): + root = Path(os.path.abspath(root)) + if any((p / '.git').is_file() or (p / '.git/HEAD').is_file() for p in (root, *root.parents)): + raise Refused() + spec = importlib.util.spec_from_file_location('lldap_custody', ROOT / 'sso-mfa/bootstrap/apply-service-secrets.py') + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + with module.custody(root, ['lldap']) as directories: + value = module.read_env(directories['lldap']).get('LLDAP_LDAP_USER_PASS') + if not isinstance(value, str) or not value or len(value.encode()) > 4096 or any(ord(c) < 32 for c in value): + raise Refused() + return value + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): + return None + + +class Transport: + """Inject bounded, origin-checked transport into the existing provider.""" + def __init__(self, base): + self.base = endpoint(base) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + context.load_verify_locations(cafile=ssl.get_default_verify_paths().openssl_cafile) + self.opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), NoRedirect(), urllib.request.HTTPSHandler(context=context), + ) + + def __call__(self, request, timeout): + if request.full_url not in {self.base + '/auth/simple/login', self.base + '/api/graphql'}: + raise Refused() + if request.method != 'POST' or not isinstance(request.data, bytes) or len(request.data) > MAX_BODY: + raise Refused() + try: + with self.opener.open(request, timeout=min(timeout, 15)) as response: + if response.status != 200: + raise Refused() + raw = response.read(MAX_BODY + 1) + if len(raw) > MAX_BODY: + raise Refused() + body = json.loads(raw, object_pairs_hook=unique_object) + if not isinstance(body, dict): + raise Refused() + if request.full_url.endswith('/auth/simple/login'): + token = body.get('token') + if not isinstance(token, str) or not 1 <= len(token) <= 16384 or any(not 33 <= ord(c) <= 126 for c in token): + raise Refused() + return io.BytesIO(raw) + except urllib.error.HTTPError as exc: + exc.close() + raise Refused() from None + except Exception: + raise Refused() from None + + +def snapshot(provider, token): + value = provider._gql(token, 'query { users { id } groups { id displayName users { id } } }', {}) + if set(value) != {'users', 'groups'} or not isinstance(value['users'], list) or not isinstance(value['groups'], list): + raise Refused() + if len(value['users']) > 10000 or len(value['groups']) > 1000: + raise Refused() + users = [] + for user in value['users']: + uid = user.get('id') if isinstance(user, dict) else None + if not isinstance(uid, str) or not re.fullmatch(r'[a-z][a-z0-9._-]{0,127}', uid): + raise Refused() + users.append(uid) + if len(set(users)) != len(users): + raise Refused() + groups = {} + ids = set() + for group in value['groups']: + if not isinstance(group, dict): + raise Refused() + name, gid, members = group.get('displayName'), group.get('id'), group.get('users') + if (not isinstance(name, str) or not name or len(name) > 256 or any(ord(c) < 32 for c in name) + or type(gid) is not int or gid < 1 or not isinstance(members, list) + or name in groups or gid in ids or len(members) > 10000): + raise Refused() + member_ids = [member.get('id') if isinstance(member, dict) else None for member in members] + if any(not isinstance(uid, str) or uid not in users for uid in member_ids) or len(set(member_ids)) != len(member_ids): + raise Refused() + groups[name] = {'id': gid, 'members': sorted(member_ids)} + ids.add(gid) + return {'users': sorted(users), 'groups': groups} + + +def validate_operation(operation, action=None, group=None, user=None): + if operation not in {'bootstrap', 'membership', 'verify'}: + raise Refused() + if operation != 'membership': + if any(v is not None for v in (action, group, user)): + raise Refused() + return + if action not in {'add', 'remove', 'list'} or not isinstance(group, str): + raise Refused() + if action == 'list': + if user is not None: + raise Refused() + members = {} + else: + members = {group: [user]} + _service_group_request({'groups': [group], 'members': members, + 'idempotency_key': 'owner-group-maintenance', 'correlation_id': 'owner-group-maintenance'}) + + +def maintain(provider, base, operation, *, action=None, group=None, user=None, apply=False, expected_digest=None): + validate_operation(operation, action, group, user) + if (apply and (operation == 'verify' or action == 'list')) or (expected_digest is not None and not apply): + raise Refused() + if apply and (not isinstance(expected_digest, str) or not re.fullmatch(r'[0-9a-f]{64}', expected_digest)): + raise Refused() + token = provider._login() + before = snapshot(provider, token) + if operation == 'verify': + present = {name: name in before['groups'] for name in BASELINE} + return {'result': 'structure_observed', 'baseline_groups_present': present, + 'directory_user_count': len(before['users']), + 'break_glass_account_present': 'break-glass' in before['users'], + 'break_glass_platform_membership_present': 'break-glass' in before['groups'].get('net-kingdom-admins', {}).get('members', []), + 'native_acceptance': 'unverified', + 'remaining': ['governed_lifecycle', 'actual_login_and_mfa', 'issued_claims_and_consumer_access', + 'session_revocation', 'independent_recovery']} + if operation == 'membership' and (group not in before['groups'] or (user is not None and user not in before['users'])): + raise Refused() + binding = {'base': endpoint(base), 'operation': operation, 'action': action, 'group': group, 'user': user, 'before': before} + digest = hashlib.sha256(json.dumps(binding, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + if operation == 'bootstrap': + changes = [{'create_group': name} for name in BASELINE if name not in before['groups']] + elif action == 'list': + return {'result': 'membership_observed', 'group': group, 'members': before['groups'][group]['members'], 'native_acceptance': 'unverified'} + else: + member = user in before['groups'][group]['members'] + changes = [{'action': action, 'group': group, 'user': user}] if member != (action == 'add') else [] + result = {'operation': operation, 'changes': changes, 'snapshot_sha256': digest, 'native_acceptance': 'unverified'} + if not apply: + return dict(result, result='preview') + if digest != expected_digest: + raise Refused() + # LLDAP has no compare-and-swap transaction here. Caller must hold the + # owner-controlled exclusive window; check again before the first mutation. + if snapshot(provider, token) != before: + raise Refused() + if operation == 'bootstrap': + rows = [{'displayName': name, 'id': item['id']} for name, item in before['groups'].items()] + for change in changes: + provider._ensure_group(token, rows, change['create_group']) + elif changes: + method = provider._add_group if action == 'add' else provider._remove_group + method(token, user, before['groups'][group]['id']) + after = snapshot(provider, token) + expected = json.loads(json.dumps(before)) + if operation == 'bootstrap': + for change in changes: + name = change['create_group'] + if name not in after['groups'] or after['groups'][name]['members']: + raise Refused() + expected['groups'][name] = after['groups'][name] + elif changes: + members = set(expected['groups'][group]['members']) + if action == 'add': + members.add(user) + else: + members.discard(user) + expected['groups'][group]['members'] = sorted(members) + if after != expected: + raise Refused() + return dict(result, result='verified', recovery='no_automatic_rollback') + + +def main(argv=None): + phase = 'input' + try: + parser = Parser(description=__doc__) + sub = parser.add_subparsers(dest='operation', required=True, parser_class=Parser) + for operation in ('bootstrap', 'membership', 'verify'): + command = sub.add_parser(operation) + command.add_argument('--root', required=True, help='Explicit private custody directory outside Git') + command.add_argument('--lldap-url', default='https://lldap.coulomb.social') + if operation != 'verify': + command.add_argument('--apply', action='store_true') + command.add_argument('--expected-digest') + if operation == 'membership': + command.add_argument('action', choices=('add', 'remove', 'list')) + command.add_argument('--group', required=True) + command.add_argument('--user') + args = parser.parse_args(argv) + base = endpoint(args.lldap_url) + action, group, user = (getattr(args, name, None) for name in ('action', 'group', 'user')) + validate_operation(args.operation, action, group, user) + apply = getattr(args, 'apply', False) + expected = getattr(args, 'expected_digest', None) + if (expected is not None and not apply) or (apply and (action == 'list' or not isinstance(expected, str) or not re.fullmatch(r'[0-9a-f]{64}', expected))): + raise Refused() + transport = Transport(base) + password = private_password(args.root) + provider = LLDAPProvisioner(base_url=base, admin_password=password, opener=transport) + phase = 'maintenance' if apply else 'observation' + result = maintain(provider, base, args.operation, action=action, group=group, user=user, apply=apply, expected_digest=expected) + print(json.dumps(result, sort_keys=True)) + return 2 if args.operation == 'verify' else 0 + except Exception: + print(json.dumps({'result': 'unverified', 'phase': phase, 'recovery': 'reconcile_current_state_before_retry'}), file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/sso-mfa/k8s/lldap/manage-group-members.sh b/sso-mfa/k8s/lldap/manage-group-members.sh index 7abf5bd..3d158aa 100755 --- a/sso-mfa/k8s/lldap/manage-group-members.sh +++ b/sso-mfa/k8s/lldap/manage-group-members.sh @@ -1,179 +1,5 @@ #!/usr/bin/env bash -# manage-group-members.sh — add/remove/list LLDAP group membership (NK-WP-0021) -# -# Usage: -# ./manage-group-members.sh add [lldap-url] [secrets-dir] -# ./manage-group-members.sh remove [lldap-url] [secrets-dir] -# ./manage-group-members.sh list [lldap-url] [secrets-dir] -# -# Auth: LLDAP_LDAP_USER_PASS from secrets-dir/lldap/secrets.env, or env -# LLDAP_ADMIN_PASS (e.g. from kubectl secret for live ops). -# -# Examples: -# ./manage-group-members.sh add bernd activity-core-operators -# ./manage-group-members.sh list activity-core-operators - +# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md. +set +x set -euo pipefail - -ACTION="${1:-}" -shift || true - -LLDAP_URL="https://lldap.coulomb.social" -SECRETS_DIR="../../bootstrap/secrets" - -usage() { - sed -n '2,15p' "$0" | sed 's/^# \?//' - exit 1 -} - -case "$ACTION" in - add|remove) - USERNAME="${1:-}" - GROUP_NAME="${2:-}" - [[ -z "$USERNAME" || -z "$GROUP_NAME" ]] && usage - shift 2 || true - ;; - list) - GROUP_NAME="${1:-}" - [[ -z "$GROUP_NAME" ]] && usage - shift 1 || true - USERNAME="" - ;; - *) - usage - ;; -esac - -[[ -n "${1:-}" ]] && LLDAP_URL="$1" -[[ -n "${2:-}" ]] && SECRETS_DIR="$2" - -LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" -LLDAP_ADMIN_PASS="${LLDAP_ADMIN_PASS:-}" - -if [[ -z "$LLDAP_ADMIN_PASS" ]]; then - if [[ ! -f "$LLDAP_ENV" ]]; then - echo "ERROR: $LLDAP_ENV not found and LLDAP_ADMIN_PASS unset." >&2 - exit 1 - fi - LLDAP_ADMIN_PASS=$(bash -c "source '$LLDAP_ENV' 2>/dev/null; echo \"\${LLDAP_LDAP_USER_PASS:-}\"") -fi - -if [[ -z "$LLDAP_ADMIN_PASS" ]]; then - echo "ERROR: empty LLDAP admin password" >&2 - exit 1 -fi - -echo "Authenticating to LLDAP at $LLDAP_URL ..." -AUTH_RESP=$(curl -sS -X POST "$LLDAP_URL/auth/simple/login" \ - -H "Content-Type: application/json" \ - -d "{\"username\":\"admin\",\"password\":\"$LLDAP_ADMIN_PASS\"}") - -LLDAP_TOKEN=$(echo "$AUTH_RESP" | python3 -c \ - "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "") -if [[ -z "$LLDAP_TOKEN" ]]; then - echo "ERROR: Authentication failed" >&2 - exit 1 -fi - -# Build GraphQL POST body without shell-expanding $variables inside the query. -gql_post() { - local query_file="$1" - local vars_json="$2" - python3 - "$LLDAP_URL" "$LLDAP_TOKEN" "$query_file" "$vars_json" <<'PY' -import json, sys, urllib.request -url, token, qpath, vars_s = sys.argv[1:5] -query = open(qpath, encoding="utf-8").read() -body = json.dumps({"query": query, "variables": json.loads(vars_s)}).encode() -req = urllib.request.Request( - url.rstrip("/") + "/api/graphql", - data=body, - headers={ - "Authorization": f"Bearer {token}", - "Content-Type": "application/json", - }, - method="POST", -) -with urllib.request.urlopen(req, timeout=30) as resp: - print(resp.read().decode()) -PY -} - -TMPDIR_GQL=$(mktemp -d) -trap 'rm -rf "$TMPDIR_GQL"' EXIT - -cat >"$TMPDIR_GQL/list.gql" <<'EOF' -query { groups { id displayName users { id displayName email } } } -EOF - -GROUPS_JSON=$(gql_post "$TMPDIR_GQL/list.gql" '{}') -GROUP_ID=$(echo "$GROUPS_JSON" | python3 -c " -import sys, json -name = sys.argv[1] -d = json.load(sys.stdin) -for g in d.get('data', {}).get('groups', []) or []: - if g.get('displayName') == name: - print(g['id']) - break -" "$GROUP_NAME") - -if [[ -z "$GROUP_ID" ]]; then - echo "ERROR: group '$GROUP_NAME' not found — run bootstrap-users.sh first" >&2 - exit 1 -fi - -case "$ACTION" in - list) - echo "$GROUPS_JSON" | python3 -c " -import sys, json -name = sys.argv[1] -d = json.load(sys.stdin) -for g in d.get('data', {}).get('groups', []) or []: - if g.get('displayName') == name: - users = g.get('users') or [] - print(f\"Group {name} (id={g.get('id')}): {len(users)} member(s)\") - for u in users: - print(f\" - {u.get('id')} {u.get('displayName') or ''} {u.get('email') or ''}\") - break -" "$GROUP_NAME" - ;; - add) - cat >"$TMPDIR_GQL/add.gql" <<'EOF' -mutation AddToGroup($userId: String!, $groupId: Int!) { - addUserToGroup(userId: $userId, groupId: $groupId) { ok } -} -EOF - VARS=$(python3 -c "import json,sys; print(json.dumps({'userId':sys.argv[1],'groupId':int(sys.argv[2])}))" "$USERNAME" "$GROUP_ID") - RESP=$(gql_post "$TMPDIR_GQL/add.gql" "$VARS") - echo "$RESP" | python3 -c " -import sys, json -d = json.load(sys.stdin) -if d.get('errors'): - msg = d['errors'][0].get('message', str(d['errors'])) - if 'UNIQUE constraint' in msg or 'already' in msg.lower(): - print('OK: already a member (no-op)') - sys.exit(0) - print('ERROR:', msg, file=sys.stderr) - sys.exit(1) -print('OK: added user to group') -" - echo " user=$USERNAME group=$GROUP_NAME (id=$GROUP_ID)" - ;; - remove) - cat >"$TMPDIR_GQL/remove.gql" <<'EOF' -mutation RemoveFromGroup($userId: String!, $groupId: Int!) { - removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } -} -EOF - VARS=$(python3 -c "import json,sys; print(json.dumps({'userId':sys.argv[1],'groupId':int(sys.argv[2])}))" "$USERNAME" "$GROUP_ID") - RESP=$(gql_post "$TMPDIR_GQL/remove.gql" "$VARS") - echo "$RESP" | python3 -c " -import sys, json -d = json.load(sys.stdin) -if d.get('errors'): - print('ERROR:', d['errors'][0].get('message', d['errors']), file=sys.stderr) - sys.exit(1) -print('OK: removed user from group') -" - echo " user=$USERNAME group=$GROUP_NAME (id=$GROUP_ID)" - ;; -esac +exec python3 "$(dirname -- "$0")/lldap_admin.py" membership "$@" diff --git a/sso-mfa/k8s/verify-t07.sh b/sso-mfa/k8s/verify-t07.sh index 6947d62..3aab32a 100755 --- a/sso-mfa/k8s/verify-t07.sh +++ b/sso-mfa/k8s/verify-t07.sh @@ -1,240 +1,5 @@ #!/usr/bin/env bash -# verify-t07.sh — verify NK-WP-0001-T07 done-criteria -# -# Checks user management and self-service readiness. -# -# Sections: -# 1. LLDAP group: net-kingdom-users exists -# 2. LLDAP group: net-kingdom-admins exists -# 3. At least one non-admin user exists in LLDAP -# 4. Break-glass user exists and is in net-kingdom-admins -# 5. privacyIDEA self-service portal reachable -# 6. KeyCape config has OIDC clients, including openbao-admin -# -# Usage: -# chmod +x verify-t07.sh -# ./verify-t07.sh [lldap-url] [secrets-dir] -# -# default: https://lldap.coulomb.social -# default: ../bootstrap/secrets - +# Explicit private input and preview/readback adapter; see lldap/GROUP-MAINTENANCE.md. +set +x set -euo pipefail - -LLDAP_URL="${1:-https://lldap.coulomb.social}" -SECRETS_DIR="${2:-../bootstrap/secrets}" -LLDAP_ENV="$SECRETS_DIR/lldap/secrets.env" -SSO_NAMESPACE="sso" - -PASS=0 -FAIL=0 -WARN=0 - -pass() { echo " [PASS] $1"; ((PASS++)); } -fail() { echo " [FAIL] $1"; ((FAIL++)); } -warn() { echo " [WARN] $1"; ((WARN++)); } - -section() { echo ""; echo "── $1 ──────────────────────────────────────"; } - -# ── Authenticate to LLDAP ───────────────────────────────────────────────────── -LLDAP_TOKEN="" -if [[ -f "$LLDAP_ENV" ]]; then - read_env() { bash -c "source '$1' 2>/dev/null; echo \${$2}"; } - LLDAP_ADMIN_PASS=$(read_env "$LLDAP_ENV" LLDAP_LDAP_USER_PASS) - if [[ -n "$LLDAP_ADMIN_PASS" ]]; then - AUTH_RESP=$(curl -sf -X POST "$LLDAP_URL/auth/simple/login" \ - -H "Content-Type: application/json" \ - -d "{\"username\":\"admin\",\"password\":\"$LLDAP_ADMIN_PASS\"}" \ - 2>/dev/null || echo "CURL_FAILED") - if [[ "$AUTH_RESP" != "CURL_FAILED" ]]; then - LLDAP_TOKEN=$(echo "$AUTH_RESP" | python3 -c \ - "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "") - fi - fi -fi - -gql() { - if [[ -z "$LLDAP_TOKEN" ]]; then echo "NO_TOKEN"; return; fi - local query="$1"; local vars="${2:-{}}" - local body - body=$(python3 -c " -import json, sys -print(json.dumps({'query': sys.argv[1], 'variables': json.loads(sys.argv[2])})) -" "$query" "$vars") - curl -sf -X POST "$LLDAP_URL/api/graphql" \ - -H "Authorization: Bearer $LLDAP_TOKEN" \ - -H "Content-Type: application/json" \ - -d "$body" 2>/dev/null || echo "CURL_FAILED" -} - -GROUPS_RESP=$(gql 'query { groups { id displayName members { id } } }') - -# ── 1. net-kingdom-users group ─────────────────────────────────────────────── -section "1. LLDAP group: net-kingdom-users" -if [[ "$GROUPS_RESP" == "NO_TOKEN" ]]; then - warn "Skipping — could not authenticate to LLDAP at $LLDAP_URL" -elif [[ "$GROUPS_RESP" == "CURL_FAILED" ]]; then - fail "Could not query LLDAP groups — is LLDAP up?" -else - EXISTS=$(echo "$GROUPS_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if any(g['displayName']=='net-kingdom-users' for g in d.get('data',{}).get('groups',[])) else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$EXISTS" == "yes" ]]; then - pass "Group 'net-kingdom-users' exists" - else - fail "Group 'net-kingdom-users' not found — run lldap/bootstrap-users.sh" - fi -fi - -# ── 2. net-kingdom-admins group ────────────────────────────────────────────── -section "2. LLDAP group: net-kingdom-admins" -if [[ "$GROUPS_RESP" == "NO_TOKEN" || "$GROUPS_RESP" == "CURL_FAILED" ]]; then - warn "Skipping — see section 1" -else - EXISTS=$(echo "$GROUPS_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if any(g['displayName']=='net-kingdom-admins' for g in d.get('data',{}).get('groups',[])) else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$EXISTS" == "yes" ]]; then - pass "Group 'net-kingdom-admins' exists" - else - fail "Group 'net-kingdom-admins' not found — run lldap/bootstrap-users.sh" - fi -fi - -# ── 3. At least one non-admin user ─────────────────────────────────────────── -section "3. At least one regular user in LLDAP" -USERS_RESP=$(gql 'query { users { id displayName email } }') -if [[ "$USERS_RESP" == "NO_TOKEN" || "$USERS_RESP" == "CURL_FAILED" ]]; then - warn "Skipping — could not query users" -else - USERS=$(echo "$USERS_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); us=[u['id'] for u in d.get('data',{}).get('users',[]) if u['id'] not in ('admin','break-glass')]; print(len(us))" \ - 2>/dev/null || echo "0") - if [[ "$USERS" -gt 0 ]]; then - pass "$USERS regular user(s) exist in LLDAP" - else - warn "No regular users found — add users via the LLDAP WebUI ($LLDAP_URL) or provisioning script" - fi -fi - -# ── 4. Break-glass user ─────────────────────────────────────────────────────── -section "4. Break-glass account" -if [[ "$USERS_RESP" == "NO_TOKEN" || "$USERS_RESP" == "CURL_FAILED" ]]; then - warn "Skipping — could not query users" -else - BG_EXISTS=$(echo "$USERS_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); print('yes' if any(u['id']=='break-glass' for u in d.get('data',{}).get('users',[])) else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$BG_EXISTS" == "yes" ]]; then - pass "User 'break-glass' exists in LLDAP" - # Check group membership - BG_IN_ADMINS=$(echo "$GROUPS_RESP" | python3 -c \ - "import sys,json; d=json.load(sys.stdin); gs=[g for g in d.get('data',{}).get('groups',[]) if g['displayName']=='net-kingdom-admins']; print('yes' if gs and any(m['id']=='break-glass' for m in gs[0].get('members',[])) else 'no')" \ - 2>/dev/null || echo "no") - if [[ "$BG_IN_ADMINS" == "yes" ]]; then - pass "'break-glass' is in net-kingdom-admins group" - else - warn "'break-glass' is not in net-kingdom-admins — reconcile with the IAM recovery owner; legacy creation is retired" - fi - else - fail "User 'break-glass' not found — reconcile with the IAM recovery owner; legacy creation is retired" - fi -fi - -# ── 5. Self-service portal ──────────────────────────────────────────────────── -section "5. privacyIDEA self-service portal (pink-account.coulomb.social)" -PORTAL_STATUS=$(curl -sf -o /dev/null -w "%{http_code}" \ - "https://pink-account.coulomb.social" 2>/dev/null || echo "000") -if [[ "$PORTAL_STATUS" == "200" || "$PORTAL_STATUS" == "302" ]]; then - pass "Self-service portal reachable (HTTP $PORTAL_STATUS)" -elif [[ "$PORTAL_STATUS" == "000" ]]; then - warn "Self-service portal not reachable — check DNS and ingress" -else - warn "Self-service portal returned HTTP $PORTAL_STATUS" -fi - -# ── 6. KeyCape OIDC client registrations ───────────────────────────────────── -section "6. KeyCape OIDC client registrations" -KC_POD=$(kubectl get pod -n "$SSO_NAMESPACE" \ - -l app.kubernetes.io/name=keycape \ - --field-selector=status.phase=Running \ - -o jsonpath='{.items[0].metadata.name}' 2>/dev/null || echo "") -if [[ -n "$KC_POD" ]]; then - DISCOVERY=$(kubectl exec -n "$SSO_NAMESPACE" "$KC_POD" -- \ - wget -qO- "http://localhost:8080/.well-known/openid-configuration" 2>/dev/null || echo "") - if [[ -n "$DISCOVERY" ]]; then - pass "KeyCape OIDC discovery endpoint accessible" - # Check config for registered clients (KeyCape config in keycape-config Secret) - CONFIG=$(kubectl get secret keycape-config -n "$SSO_NAMESPACE" \ - -o jsonpath='{.data.config\.yaml}' 2>/dev/null | base64 -d 2>/dev/null || echo "") - CLIENT_COUNT=$(echo "$CONFIG" | python3 -c \ - "import sys; import yaml; cfg=yaml.safe_load(sys.stdin.read()); print(len(cfg.get('clients',[])))" \ - 2>/dev/null || echo "0") - if [[ "$CLIENT_COUNT" -gt 0 ]]; then - pass "$CLIENT_COUNT OIDC client(s) registered in KeyCape" - else - warn "No OIDC clients registered — add clients to keycape/create-secrets.sh and re-run it" - fi - OPENBAO_CLIENT_CHECK=$(echo "$CONFIG" | python3 -c ' -import sys -import yaml - -cfg = yaml.safe_load(sys.stdin.read()) or {} -clients = cfg.get("clients") or [] -target = next((client for client in clients if client.get("clientId") == "openbao-admin"), None) -if not target: - print("missing openbao-admin client") - raise SystemExit(2) -required_redirects = { - "http://localhost:8250/oidc/callback", - "http://127.0.0.1:8250/oidc/callback", - "http://127.0.0.1:18200/ui/vault/auth/netkingdom/oidc/callback", -} -required_scopes = {"openid", "profile", "email", "groups"} -missing_redirects = sorted(required_redirects - set(target.get("redirectUris") or [])) -missing_scopes = sorted(required_scopes - set(target.get("allowedScopes") or [])) -if target.get("clientType") != "public": - print("openbao-admin clientType must be public for the current PKCE-only KeyCape profile") - raise SystemExit(3) -if missing_redirects: - print("openbao-admin missing redirect URI(s): " + ", ".join(missing_redirects)) - raise SystemExit(4) -retired_redirects = sorted({ - "https://bao.coulomb.social/ui/vault/auth/netkingdom/oidc/callback", - "https://bao.coulomb.social/ui/vault/auth/keycape/oidc/callback", -} & set(target.get("redirectUris") or [])) -if retired_redirects: - print("openbao-admin still registers retired redirect URI(s): " + ", ".join(retired_redirects)) - raise SystemExit(6) -if missing_scopes: - print("openbao-admin missing scope(s): " + ", ".join(missing_scopes)) - raise SystemExit(5) -print("openbao-admin client has CLI/browser redirects and required scopes") -' 2>/dev/null || echo "missing or invalid openbao-admin client") - if [[ "$OPENBAO_CLIENT_CHECK" == openbao-admin* ]]; then - pass "$OPENBAO_CLIENT_CHECK" - else - fail "$OPENBAO_CLIENT_CHECK" - fi - else - warn "Skipping client check — KeyCape not reachable in-cluster" - fi -else - warn "Skipping OIDC client check — no running KeyCape pod" -fi - -# ── Summary ─────────────────────────────────────────────────────────────────── -echo "" -echo "════════════════════════════════════════════════════════════" -echo " T07 verification: PASS=$PASS WARN=$WARN FAIL=$FAIL" -echo "════════════════════════════════════════════════════════════" - -if [[ "$FAIL" -gt 0 ]]; then - echo " Result: INCOMPLETE — resolve FAIL items before marking T07 done" - exit 1 -elif [[ "$WARN" -gt 0 ]]; then - echo " Result: PARTIAL — required structure is in place; review WARN items" - exit 0 -else - echo " Result: COMPLETE — T07 done-criteria met; proceed to T08 (Backups, DR, break-glass)" - exit 0 -fi +exec python3 "$(dirname -- "$0")/lldap/lldap_admin.py" verify "$@" diff --git a/tests/lldap_groups_native_fixture.py b/tests/lldap_groups_native_fixture.py new file mode 100644 index 0000000..59e21a8 --- /dev/null +++ b/tests/lldap_groups_native_fixture.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Isolated local-Docker LLDAP proof; synthetic credentials and temporary storage only.""" +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import re +import secrets +import subprocess +import tempfile +import time +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('native_lldap_admin', ROOT / 'sso-mfa/k8s/lldap/lldap_admin.py') +admin = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(admin) + + +def command(args): + result = subprocess.run(args, capture_output=True, text=True, timeout=60) + if result.returncode: + raise RuntimeError('fixture_command_failed') + return result.stdout.strip() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--image', required=True, help='Existing local image sha256; never pull') + parser.add_argument('--output', required=True) + args = parser.parse_args() + if not re.fullmatch(r'sha256:[0-9a-f]{64}', args.image): + raise SystemExit('immutable local image required') + contexts = json.loads(command(['docker', 'context', 'inspect'])) + host = os.environ.get('DOCKER_HOST') or contexts[0]['Endpoints']['docker']['Host'] + if not host.startswith('unix://'): + raise SystemExit('local Unix Docker endpoint required') + assert command(['docker', 'image', 'inspect', args.image, '--format', '{{.Id}}']) == args.image + checks = {} + container = None + result = {'image': args.image, 'synthetic_only': True, 'checks': checks, 'outcome': 'unverified'} + try: + with tempfile.TemporaryDirectory(prefix='nk-lldap-fixture-', dir='/tmp') as temp: + directory = Path(temp) + password = secrets.token_hex(24) + env = directory / 'container.env' + env.write_text('LLDAP_LDAP_USER_PASS=' + password + '\nLLDAP_JWT_SECRET=' + secrets.token_hex(32) + '\nLLDAP_LDAP_BASE_DN=dc=example,dc=test\n') + env.chmod(0o600) + root = directory / 'custody' + root.mkdir(mode=0o700) + (root / 'lldap').mkdir(mode=0o700) + source = root / 'lldap/secrets.env' + source.write_text('LLDAP_LDAP_USER_PASS=' + password + '\n') + source.chmod(0o600) + container = command(['docker', 'run', '--detach', '--rm', '--pull=never', '--name', 'nk-lldap-groups-' + secrets.token_hex(5), + '--memory=256m', '--cpus=0.5', '--pids-limit=128', + '--publish', '127.0.0.1::17170', '--tmpfs', '/data:rw,nosuid,nodev,size=32m,uid=1000,gid=1000', + '--env-file', str(env), args.image]) + if not re.fullmatch(r'[0-9a-f]{64}', container): + raise RuntimeError('invalid_container_id') + port = json.loads(command(['docker', 'inspect', container, '--format', '{{json .NetworkSettings.Ports}}']))['17170/tcp'][0] + assert port['HostIp'] == '127.0.0.1' + base = 'http://127.0.0.1:' + port['HostPort'] + provider = admin.LLDAPProvisioner(base_url=base, admin_password=password, opener=admin.Transport(base)) + for _ in range(60): + try: + token = provider._login() + break + except Exception: + time.sleep(.25) + else: + raise RuntimeError('fixture_start_failed') + initial = admin.snapshot(provider, token) + preview = admin.maintain(provider, base, 'bootstrap') + assert admin.snapshot(provider, token) == initial + checks['preview_nonmutating'] = True + result1 = admin.maintain(provider, base, 'bootstrap', apply=True, expected_digest=preview['snapshot_sha256']) + assert result1['result'] == 'verified' + ready = admin.snapshot(provider, token) + assert all(name in ready['groups'] for name in admin.BASELINE) + assert all(ready['groups'][name] == value for name, value in initial['groups'].items()) + checks['bootstrap_exact_and_existing_preserved'] = True + preview = admin.maintain(provider, base, 'bootstrap') + assert preview['changes'] == [] + assert admin.maintain(provider, base, 'bootstrap', apply=True, expected_digest=preview['snapshot_sha256'])['result'] == 'verified' + checks['bootstrap_idempotent'] = True + provider._gql(token, 'mutation CreateUser($id: String!, $email: String!, $display: String!) { createUser(user: {id: $id, email: $email, displayName: $display}) { id } }', + {'id': 'fixture-user', 'email': 'fixture@example.test', 'display': 'Fixture'}) + for action in ('add', 'remove'): + kwargs = {'action': action, 'group': 'activity-core-operators', 'user': 'fixture-user'} + preview = admin.maintain(provider, base, 'membership', **kwargs) + updated = admin.maintain(provider, base, 'membership', **kwargs, apply=True, expected_digest=preview['snapshot_sha256']) + assert updated['result'] == 'verified' + listed = admin.maintain(provider, base, 'membership', action='list', group='activity-core-operators') + assert ('fixture-user' in listed['members']) == (action == 'add') + checks['exact_' + action + '_and_readback'] = True + before = admin.snapshot(provider, token) + for group in ('net-kingdom-admins', 'net-kingdom-users', 'lldap_admin'): + try: + admin.maintain(provider, base, 'membership', action='add', group=group, user='fixture-user') + except (ValueError, admin.Refused): + pass + else: + raise RuntimeError('reserved_group_accepted') + assert admin.snapshot(provider, token) == before + checks['reserved_group_refusal_preserves_state'] = True + preview = admin.maintain(provider, base, 'membership', action='add', group='activity-core-operators', user='fixture-user') + provider._gql(token, 'mutation CreateGroup($name: String!) { createGroup(name: $name) { id displayName } }', {'name': 'unrelated-drift'}) + try: + admin.maintain(provider, base, 'membership', action='add', group='activity-core-operators', user='fixture-user', apply=True, expected_digest=preview['snapshot_sha256']) + except admin.Refused: + pass + else: + raise RuntimeError('stale_preview_accepted') + assert 'fixture-user' not in admin.snapshot(provider, token)['groups']['activity-core-operators']['members'] + checks['stale_preview_refused'] = True + complete = subprocess.run(['bash', '-x', str(ROOT / 'sso-mfa/k8s/verify-t07.sh'), '--root', str(root), '--lldap-url', base], capture_output=True, text=True, timeout=30) + assert complete.returncode == 2 + assert password not in complete.stdout + complete.stderr + assert json.loads(complete.stdout)['native_acceptance'] == 'unverified' + checks['t07_structural_only_exit_and_no_trace_disclosure'] = True + result['outcome'] = 'passed' + except Exception: + result['outcome'] = 'failed' + finally: + if container and re.fullmatch(r'[0-9a-f]{64}', container): + try: + command(['docker', 'rm', '--force', container]) + checks['fixture_removed'] = True + except Exception: + checks['fixture_removed'] = False + result['outcome'] = 'failed' + Path(args.output).write_text(json.dumps(result, indent=2) + '\n') + print(json.dumps(result)) + return 0 if result['outcome'] == 'passed' else 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_lldap_group_maintenance.py b/tests/test_lldap_group_maintenance.py new file mode 100644 index 0000000..0f34f7f --- /dev/null +++ b/tests/test_lldap_group_maintenance.py @@ -0,0 +1,351 @@ +"""Credential boundaries, exact group effects and honest diagnostic outcomes.""" +import contextlib +import copy +import importlib.util +import io +import json +import os +from pathlib import Path +import subprocess +import sys +from unittest.mock import Mock, patch + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SPEC = importlib.util.spec_from_file_location('lldap_admin', ROOT / 'sso-mfa/k8s/lldap/lldap_admin.py') +admin = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(admin) + + +@pytest.mark.parametrize('script', ['lldap/bootstrap-users.sh', 'lldap/manage-group-members.sh', 'verify-t07.sh']) +def test_legacy_arguments_never_source_credentials_or_invoke_external_tools(tmp_path, script): + marker = tmp_path / 'sourced' + calls = tmp_path / 'calls' + root = tmp_path / 'private' + (root / 'lldap').mkdir(parents=True, mode=0o700) + (root / 'lldap/secrets.env').write_text(f'LLDAP_LDAP_USER_PASS=synthetic-private-marker\ntouch "{marker}"\n') + commands = tmp_path / 'bin' + commands.mkdir() + for name in ('curl', 'kubectl'): + executable = commands / name + executable.write_text(f'#!/bin/sh\nprintf called >> "{calls}"\nexit 1\n') + executable.chmod(0o700) + args = ['http://127.0.0.1:1', str(root)] + if 'manage-group' in script: + args = ['add', 'test-user', 'activity-core-operators', *args] + env = dict(os.environ, PATH=str(commands) + ':' + os.environ['PATH']) + for key in ('LLDAP_ADMIN_PASS', 'BASH_ENV'): + env.pop(key, None) + result = subprocess.run(['bash', '-x', str(ROOT / 'sso-mfa/k8s' / script), *args], + env=env, capture_output=True, text=True, timeout=10) + assert result.returncode != 0 + assert not marker.exists() and not calls.exists() + assert 'synthetic-private-marker' not in result.stdout + result.stderr + + +class Directory: + def __init__(self): + self.state = {'users': ['agent-codex', 'break-glass', 'tegwick'], 'groups': { + 'lldap_admin': {'id': 1, 'members': ['tegwick']}, + 'net-kingdom-admins': {'id': 2, 'members': ['break-glass']}, + 'activity-core-operators': {'id': 3, 'members': ['tegwick']}, + 'tenant:one:users': {'id': 4, 'members': ['agent-codex']}, + }} + self.writes = [] + self.login = 0 + self.reads = 0 + self.drop_write = False + self.extra_effect = False + self.drift_at = None + self.malformed = None + + def _login(self): + self.login += 1 + return 'fixture-token' + + def _gql(self, token, query, variables): + assert query == 'query { users { id } groups { id displayName users { id } } }' + self.reads += 1 + if self.reads == self.drift_at: + self.state['groups']['tenant:one:users']['members'] = [] + if self.malformed is not None: + return self.malformed + return {'users': [{'id': u} for u in self.state['users']], 'groups': [ + {'id': g['id'], 'displayName': name, 'users': [{'id': u} for u in g['members']]} + for name, g in self.state['groups'].items()]} + + def _ensure_group(self, token, groups, name): + self.writes.append(('create', name)) + if not self.drop_write: + self.state['groups'][name] = {'id': max(g['id'] for g in self.state['groups'].values()) + 1, 'members': []} + + def _add_group(self, token, user, gid): + self._write('add', user, gid) + + def _remove_group(self, token, user, gid): + self._write('remove', user, gid) + + def _write(self, action, user, gid): + self.writes.append((action, user, gid)) + if self.drop_write: + return + group = next(g for g in self.state['groups'].values() if g['id'] == gid) + members = set(group['members']) + (members.add if action == 'add' else members.discard)(user) + group['members'] = sorted(members) + if self.extra_effect: + self.state['groups']['tenant:one:users']['members'] = [] + + +def change(provider, **kwargs): + return admin.maintain(provider, 'https://lldap.coulomb.social', 'membership', **kwargs) + + +def test_bootstrap_preview_apply_and_idempotence_preserve_existing_state(): + provider = Directory() + before = copy.deepcopy(provider.state) + preview = admin.maintain(provider, 'https://lldap.coulomb.social', 'bootstrap') + assert preview['changes'] == [{'create_group': 'net-kingdom-users'}] + assert provider.writes == [] + result = admin.maintain(provider, 'https://lldap.coulomb.social', 'bootstrap', apply=True, expected_digest=preview['snapshot_sha256']) + assert result['result'] == 'verified' + assert provider.state['users'] == before['users'] + for name, group in before['groups'].items(): + assert provider.state['groups'][name] == group + assert provider.state['groups']['net-kingdom-users']['members'] == [] + provider.writes.clear() + preview = admin.maintain(provider, 'https://lldap.coulomb.social', 'bootstrap') + admin.maintain(provider, 'https://lldap.coulomb.social', 'bootstrap', apply=True, expected_digest=preview['snapshot_sha256']) + assert provider.writes == [] + + +@pytest.mark.parametrize('action,user', [('add', 'agent-codex'), ('remove', 'tegwick')]) +def test_membership_changes_only_exact_member(action, user): + provider = Directory() + before = copy.deepcopy(provider.state) + kwargs = {'action': action, 'group': 'activity-core-operators', 'user': user} + preview = change(provider, **kwargs) + assert provider.writes == [] + result = change(provider, **kwargs, apply=True, expected_digest=preview['snapshot_sha256']) + assert result['result'] == 'verified' + assert (user in provider.state['groups']['activity-core-operators']['members']) == (action == 'add') + for name in set(before['groups']) - {'activity-core-operators'}: + assert provider.state['groups'][name] == before['groups'][name] + provider.writes.clear() + preview = change(provider, **kwargs) + change(provider, **kwargs, apply=True, expected_digest=preview['snapshot_sha256']) + assert provider.writes == [] + + +@pytest.mark.parametrize('group', ['net-kingdom-admins', 'net-kingdom-users', 'netkingdom-suspended', 'tenant-one-users', 'lldap_admin']) +@pytest.mark.parametrize('action', ['add', 'remove', 'list']) +def test_reserved_groups_refuse_before_login(group, action): + provider = Directory() + with pytest.raises(Exception): + change(provider, action=action, group=group, user=None if action == 'list' else 'agent-codex') + assert provider.login == 0 and provider.writes == [] + + +@pytest.mark.parametrize('kind', ['changed_state', 'changed_action', 'changed_origin', 'second_read_drift', 'missing_group', 'missing_user']) +def test_stale_or_ambiguous_requests_never_mutate(kind): + provider = Directory() + kwargs = {'action': 'add', 'group': 'activity-core-operators', 'user': 'agent-codex'} + preview = change(provider, **kwargs) + base = 'https://lldap.coulomb.social' + if kind == 'changed_state': provider.state['groups']['tenant:one:users']['members'] = [] + if kind == 'changed_action': kwargs['action'] = 'remove' + if kind == 'changed_origin': base = 'http://127.0.0.1:10000' + if kind == 'second_read_drift': provider.drift_at = 3 + if kind == 'missing_group': kwargs['group'] = 'missing-group' + if kind == 'missing_user': kwargs['user'] = 'missing-user' + with pytest.raises(Exception): + admin.maintain(provider, base, 'membership', **kwargs, apply=True, expected_digest=preview['snapshot_sha256']) + assert provider.writes == [] + + +@pytest.mark.parametrize('failure', ['drop_write', 'extra_effect']) +def test_failed_or_broader_write_cannot_report_success(failure): + provider = Directory() + kwargs = {'action': 'add', 'group': 'activity-core-operators', 'user': 'agent-codex'} + preview = change(provider, **kwargs) + setattr(provider, failure, True) + with pytest.raises(admin.Refused): + change(provider, **kwargs, apply=True, expected_digest=preview['snapshot_sha256']) + assert len(provider.writes) == 1 # No blind rollback/retry. + + +def test_list_is_nonmutating_and_verification_does_not_claim_acceptance(): + provider = Directory() + listing = change(provider, action='list', group='activity-core-operators') + assert listing['members'] == ['tegwick'] + result = admin.maintain(provider, 'https://lldap.coulomb.social', 'verify') + assert result['native_acceptance'] == 'unverified' + assert result['baseline_groups_present']['net-kingdom-users'] is False + assert 'independent_recovery' in result['remaining'] + assert provider.writes == [] + + +@pytest.mark.parametrize('payload', [{}, {'users': [], 'groups': None}, {'users': [{'id': 'same'}, {'id': 'same'}], 'groups': []}, + {'users': [], 'groups': [{'id': True, 'displayName': 'a', 'users': []}]}, + {'users': [], 'groups': [{'id': 1, 'displayName': 'a', 'users': [{'id': 'unknown'}]}]}, + {'users': [], 'groups': [{'id': 1, 'displayName': 'a', 'users': []}, {'id': 1, 'displayName': 'b', 'users': []}]}]) +def test_malformed_directory_snapshot_refused(payload): + provider = Directory() + provider.malformed = payload + with pytest.raises(admin.Refused): + admin.maintain(provider, 'https://lldap.coulomb.social', 'bootstrap') + assert provider.writes == [] + + +@pytest.mark.parametrize('url', ['http://lldap.coulomb.social', 'https://evil.test', 'http://localhost:17170', + 'https://lldap.coulomb.social@evil.test', 'http://127.0.0.1:80/path', + 'http://127.0.0.1:80?secret=marker', 'https://lldap.coulomb.social/#marker']) +def test_unapproved_origin_refused_before_private_input(url): + with patch.object(admin, 'private_password') as private, contextlib.redirect_stderr(io.StringIO()): + assert admin.main(['bootstrap', '--root', '/not-read', '--lldap-url', url]) == 1 + private.assert_not_called() + + +def test_verification_cli_always_returns_incomplete_for_structure_only(): + with patch.object(admin, 'private_password', return_value='fixture'), patch.object(admin, 'LLDAPProvisioner', return_value=Directory()), contextlib.redirect_stdout(io.StringIO()): + assert admin.main(['verify', '--root', '/fixture']) == 2 + + +def test_private_env_is_literal_not_executable(tmp_path): + root = tmp_path / 'private' + root.mkdir(mode=0o700) + (root / 'lldap').mkdir(mode=0o700) + marker = tmp_path / 'executed' + value = f'$(touch {marker})' + source = root / 'lldap/secrets.env' + source.write_text('LLDAP_LDAP_USER_PASS=' + value + '\n') + source.chmod(0o600) + assert admin.private_password(root) == value + assert not marker.exists() + source.chmod(0o644) + with pytest.raises(Exception): admin.private_password(root) + + +def test_provider_errors_are_not_printed(): + output = io.StringIO() + with patch.object(admin, 'private_password', side_effect=RuntimeError('synthetic-secret-marker')), contextlib.redirect_stderr(output): + assert admin.main(['bootstrap', '--root', '/fixture']) == 1 + assert 'synthetic-secret-marker' not in output.getvalue() + + +@pytest.fixture +def http_directory(): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import threading + + state = {'mode': 'ok', 'calls': [], 'redirect_hits': 0, 'directory': Directory()} + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def do_POST(self): + payload = json.loads(self.rfile.read(int(self.headers['Content-Length']))) + state['calls'].append((self.path, payload)) + if self.path == '/redirected': + state['redirect_hits'] += 1 + if state['mode'] == 'redirect': + self.send_response(307) + self.send_header('Location', '/redirected') + self.end_headers() + return + if state['mode'] == 'denied': + self.send_response(403) + self.end_headers() + self.wfile.write(b'upstream-secret-marker') + return + if state['mode'] == 'oversize': raw = b' ' * (admin.MAX_BODY + 1) + elif state['mode'] == 'duplicate': raw = b'{"token":"first", "token":"second"}' + elif state['mode'] == 'invalid': raw = b'upstream-secret-marker' + elif self.path == '/auth/simple/login': raw = b'{"token":"synthetic-token"}' + else: + assert self.headers['Authorization'] == 'Bearer synthetic-token' + if state['mode'] == 'graphql_error': result = {'errors': [{'message': 'upstream-secret-marker'}]} + else: result = {'data': state['directory']._gql('', payload['query'], payload.get('variables'))} + raw = json.dumps(result).encode() + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(raw))) + self.end_headers() + try: self.wfile.write(raw) + except (BrokenPipeError, ConnectionResetError): pass + + server = ThreadingHTTPServer(('127.0.0.1', 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f'http://127.0.0.1:{server.server_port}', state + finally: + server.shutdown() + server.server_close() + thread.join() + + +def private_root(tmp_path): + root = tmp_path / 'private' + root.mkdir(mode=0o700) + (root / 'lldap').mkdir(mode=0o700) + source = root / 'lldap/secrets.env' + source.write_text('LLDAP_LDAP_USER_PASS=synthetic-private-marker\n') + source.chmod(0o600) + return root + + +@pytest.mark.parametrize('mode', ['redirect', 'denied', 'oversize', 'duplicate', 'invalid', 'graphql_error']) +def test_http_failure_does_not_forward_credentials_or_render_provider_body(tmp_path, http_directory, mode): + base, state = http_directory + state['mode'] = mode + output, error = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(error): + code = admin.main(['bootstrap', '--root', str(private_root(tmp_path)), '--lldap-url', base]) + assert code == 1 + assert state['redirect_hits'] == 0 + assert 'synthetic-private-marker' not in output.getvalue() + error.getvalue() + assert 'upstream-secret-marker' not in output.getvalue() + error.getvalue() + assert json.loads(error.getvalue())['result'] == 'unverified' + + +def test_loopback_http_uses_explicit_origin_and_ignores_ambient_proxy(tmp_path, http_directory): + base, state = http_directory + output = io.StringIO() + with patch.dict(os.environ, {'http_proxy': 'http://127.0.0.1:1', 'HTTP_PROXY': 'http://127.0.0.1:1', 'no_proxy': '', 'NO_PROXY': ''}), contextlib.redirect_stdout(output): + code = admin.main(['bootstrap', '--root', str(private_root(tmp_path)), '--lldap-url', base]) + assert code == 0 + assert json.loads(output.getvalue())['result'] == 'preview' + assert len(state['calls']) == 2 + assert state['directory'].writes == [] + + +@pytest.mark.parametrize('defect', ['symlink_file', 'hardlink_file', 'public_parent', 'duplicate_field', 'git_root']) +def test_private_input_boundary_refuses_unsafe_custody(tmp_path, defect): + root = private_root(tmp_path) + source = root / 'lldap/secrets.env' + if defect == 'symlink_file': + original = root / 'lldap/original' + source.rename(original) + source.symlink_to(original) + elif defect == 'hardlink_file': + os.link(source, root / 'lldap/alias') + elif defect == 'public_parent': + root.chmod(0o755) + elif defect == 'duplicate_field': + source.write_text(source.read_text() + 'LLDAP_LDAP_USER_PASS=second\n') + elif defect == 'git_root': + (root / '.git').mkdir() + (root / '.git/HEAD').write_text('ref: refs/heads/main\n') + with patch.object(admin, 'LLDAPProvisioner') as provider, contextlib.redirect_stderr(io.StringIO()): + assert admin.main(['bootstrap', '--root', str(root)]) == 1 + provider.assert_not_called() + + +@pytest.mark.parametrize('extra', [['--apply'], ['--expected-digest', '0' * 64], ['--apply', '--expected-digest', 'not-a-digest']]) +def test_apply_binding_errors_refuse_before_credential_read(extra): + with patch.object(admin, 'private_password') as private, contextlib.redirect_stderr(io.StringIO()): + assert admin.main(['bootstrap', '--root', '/not-read', *extra]) == 1 + private.assert_not_called() 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 61713a5..8cb216e 100644 --- a/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md +++ b/workplans/NK-WP-0049-bootstrap-secret-free-recovery-handoff.md @@ -330,6 +330,37 @@ exploitation. The existing service-group behavior and tenant lifecycle remain covered by their tests. Deploy/admit this source through the existing owner path before treating the live boundary as fixed; native enforcement remains in T03. +2026-10-14 LDAP group callers reconciled: `bootstrap-users.sh`, +`manage-group-members.sh` and `verify-t07.sh` now delegate to `lldap_admin.py`. +The adapter reuses identity-provisioner operations/service-group validation and +the existing literal private-custody reader. No shell sourcing, automatic +credential discovery, curl/kubectl invocation, credential argv or KeyCape config +Secret read remains in these entry points. Fixed-origin/system-trust transport +refuses redirects/ambient proxies, duplicate/oversized/malformed responses and +raw diagnostics. Bootstrap creates only the three missing empty baseline groups; +service membership uses an existing exact subject/group and excludes reserved +namespaces. No new account/password lifecycle or credential broker is introduced. + +Default preview binds the origin/operation/target and observed directory state. +Apply needs that exact digest, a fresh prewrite observation and exact final +readback preserving unrelated state. LLDAP has no atomic cross-query CAS here: +owner-controlled exclusive maintenance remains required, and partial writes need +reconciliation rather than automatic rollback. A digest grants no authority. +T07 emits structural observations with native acceptance unverified and exit 2; +authentication/protocol/input failure exits 1. Object presence cannot close +lifecycle/login/MFA/revocation/recovery requirements. + +Three regressions reproduced executable credential sourcing in the old callers. +All 200 affected tests pass with no skips. An actual local immutable LLDAP image +passed preview, fixed bootstrap/preservation/idempotence, exact add/remove, +reserved-group and stale-preview refusal, and T07's incomplete exit without +tracing disclosure. Its synthetic credentials/storage/container were removed. +Current procedure: `sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md`; evidence: Custodian +`docs/evidence/2026-10-14-netkingdom-security/lldap-group-maintenance.json`. +No live estate credentials or directory state were read or changed. Incident +cutover helpers, full caller/root-output inventory and owner-approved native +recovery/estate acceptance keep T02 progress and T03 wait. + ## Verify owner-approved recovery and current native callers ```task