security: repair LDAP group callers and fail incomplete T07 acceptance
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a1346b-72a4-7d40-b8bd-8b8ca346cfa2
This commit is contained in:
parent
e861f63513
commit
ab25dde80d
15 changed files with 961 additions and 613 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
121
sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md
Normal file
121
sso-mfa/k8s/lldap/GROUP-MAINTENANCE.md
Normal 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.
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
269
sso-mfa/k8s/lldap/lldap_admin.py
Normal file
269
sso-mfa/k8s/lldap/lldap_admin.py
Normal 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())
|
||||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
|
|
@ -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 "$@"
|
||||
|
|
|
|||
141
tests/lldap_groups_native_fixture.py
Normal file
141
tests/lldap_groups_native_fixture.py
Normal 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())
|
||||
351
tests/test_lldap_group_maintenance.py
Normal file
351
tests/test_lldap_group_maintenance.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue