security: repair LDAP group callers and fail incomplete T07 acceptance
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
tegwick 2026-10-14 01:17:35 +02:00
parent e861f63513
commit ab25dde80d
15 changed files with 961 additions and 613 deletions

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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.
```
---

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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
```

View file

@ -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]
#
# <lldap-url> default: https://lldap.coulomb.social
# <secrets-dir> 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 <query> <variables-json>
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 "$@"

View file

@ -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())

View file

@ -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 <uid> <group> [lldap-url] [secrets-dir]
# ./manage-group-members.sh remove <uid> <group> [lldap-url] [secrets-dir]
# ./manage-group-members.sh list <group> [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 "$@"

View file

@ -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]
#
# <lldap-url> default: https://lldap.coulomb.social
# <secrets-dir> 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 "$@"

View file

@ -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())

View file

@ -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()

View file

@ -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