diff --git a/skills/agent-provisioning/SKILL.md b/skills/agent-provisioning/SKILL.md index c3a5591..dfc6c0e 100644 --- a/skills/agent-provisioning/SKILL.md +++ b/skills/agent-provisioning/SKILL.md @@ -60,8 +60,10 @@ scripts/cloudron-api.sh GET /users/ # fetch one user object Route notes (this Cloudron, 2026-09): users are addressed by `uid-…` UUIDs, not usernames; there is NO `/api/v1/login` (dashboard auth is its own OIDC -flow); there is NO admin route to reset another user's 2FA — so the seed -capture in step 2 is not optional. +flow, client `cid-webadmin`, PKCE S256); admin CAN clear a user's TOTP via +`POST /api/v1/users//totp_disable` (empty JSON body) — use it when a +seed was lost, then re-enroll with capture. Admin password set: +`POST /api/v1/users//password {"password":"…"}`. ## Procedure @@ -76,26 +78,61 @@ capture in step 2 is not optional. Verify the account exists and matches the identity email pattern (`tsgstaff-@turnsys.com` for COO-line identities). Generate the unique -password (32 chars), set it, and confirm a login works (step 2 proves it). +password (32 chars), set it via the admin API, and store it in the vault item +login password field: + +```bash +scripts/cloudron-api.sh POST /users//password '{"password":"<32-char>"}' +sm setfield " Cloudron" password "<32-char>" +``` ### 2. TOTP 2FA — enroll and CAPTURE THE SEED -Enroll TOTP via the profile self-service surface and write the base32 seed to -the vault item before anything else: +Two cases: -```bash -sm setfield " Cloudron" totp_seed "" -``` +- **Seed lost / never captured** (TOTP enforced, no `totp_seed` in vault): + clear first — `scripts/cloudron-api.sh POST /users//totp_disable` — + then proceed to enroll fresh. +- **No 2FA yet**: enroll directly. + +Enrollment runs in the identity's own dashboard session (the totp routes are +self-scoped — an admin token gets 404s there): + +1. `scripts/cloudron-oidc-login.sh ` — full OIDC + login (PKCE + interaction + consent) yielding the identity's Bearer at + `/tmp/.oidc-bearer-`. +2. `scripts/cloudron-totp-enroll.sh ` — reads the + pending secret, enables with a computed code, stores the seed via + `sm setfield`, and verifies end-to-end. + +If the API confirm path is rejected, fall back to the dashboard UI in the +browser: the forced "Enable TOTP" dialog shows the seed; read it, compute +with `scripts/totp.sh`, fill the Token field and submit. CRITICAL: the +pending secret REGENERATES every time the dialog re-renders — always read +the seed and submit the code within the same window, and verify enrollment +by a real TOTP login (a totpToken in a login is silently ignored for users +without 2FA — "success" there proves nothing). If the identity already has 2FA enforced and no seed exists anywhere: STOP and ask the founder. There is no programmatic recovery (verified 2026-09-08). Compute codes offline when needed: `scripts/totp.sh `. -### 3. SSO first-login into each system +### 3. TOTP enrollment (in the identity's dashboard session) + +```bash +scripts/cloudron-oidc-login.sh # OIDC login -> Bearer +scripts/cloudron-totp-enroll.sh # enroll + seed + verify +``` + +If the API enable path is rejected, fall back to the dashboard UI dialog (see +step 2's note). The verify pass inside the scripts performs a real TOTP login +and is the ONLY trustworthy success signal. + +### 4. SSO first-login into each system The interactive login is scriptable with a cookie jar — no browser needed. -`scripts/oidc-login.sh ` runs the whole flow and leaves a session -cookie jar at `/tmp/oidc--.txt`. Entry points: +`scripts/oidc-login.sh ` runs the whole flow and leaves a +session cookie jar at `/tmp/oidc--.txt`. Entry points: | System | SSO start point | After login | |---|---|---| @@ -106,10 +143,11 @@ cookie jar at `/tmp/oidc--.txt`. Entry points: Key gotcha (cost a debugging session — do not rediscover): the OIDC interaction login is `POST /openid/interaction//login` with JSON body -`{"username": "...", "password": "..."}` — the field is **`username`**, not -`login`, and the response is `{"redirectTo": ...}` on success. +`{"username": "...", "password": "...", "totpToken": "..."}` — the field is +**`username`**, not `login`, and the response is `{"redirectTo": ...}` on +success. -### 4. Mint per-system API keys (as the identity, via its session) +### 5. Mint per-system API keys (as the identity, via its session) - **Redmine**: My account → API key (visible to self); also returned by `GET /users/current.json` with the session. @@ -123,9 +161,12 @@ interaction login is `POST /openid/interaction//login` with JSON body Store each key in the vault item immediately (schema below). -### 5. Vault item schema — `creds/` +### 6. Vault item schema — `creds/` -One item per identity (NOT per system). Create it as a LOGIN item with: +One item per identity (NOT per system), created as a LOGIN item (never a +note — `sm set` makes notes; `sm convert ` converts, but REGENERATE +the password via the admin API afterwards because convert drops field +values): - login username = Cloudron username; password = the unique Cloudron password - URI = `https://my.knownelement.com` @@ -137,7 +178,20 @@ Historical items that predate this schema (e.g. `sec-*`, `sre-*` shared- password items) are remediation targets under #942 — upgrade them when the owner lane assigns the work, do not replicate their shape. -### 6. Verify the trail +### 7. Update the org chart (MANDATORY — an uncharted identity is an incomplete task) + +After provisioning changes the identity estate: + +1. Update the org table in `TSYSGroupCorporate/TSYSGroupAIOS` + `agents/global/AGENTS.md` (branch + PR per the house-rules flow; the + symlink serves it live once merged). +2. Update `reachableceo/org-buildout` `docs/org-chart.md` (the rendered + chart) in the same turn. +3. Note the change on the provisioning ticket. + +Details: `references/org-chart-update.md`. + +### 7. Verify the trail Prove each system knows the identity (expected: 200 + the identity's own name; a 200 from the wrong identity is a FAIL): @@ -155,12 +209,19 @@ the workspace STATUS/memory. - `references/cloudron.md` — admin API routes, OIDC interaction flow, 2FA. - `references/systems.md` — per-system SSO/API details and known breakage. - `references/vault.md` — sm CLI entry points, gotchas, fallbacks. +- `references/org-chart-update.md` — the mandatory post-provisioning chart sync. - `references/incident-log.md` — what went wrong on 2026-09-07/08 and why, so the same failures are not re-derived. ## Scripts - `scripts/cloudron-api.sh METHOD PATH [JSON]` — authenticated admin call. -- `scripts/oidc-login.sh ` — full SSO - login via cookie jar; reads the identity's creds from the vault. -- `scripts/totp.sh ` — current 6-digit TOTP code (pure bash). +- `scripts/cloudron-oidc-login.sh [vault-item]` — full OIDC + dashboard login (PKCE + interaction + consent); Bearer at + `/tmp/.oidc-bearer-`. +- `scripts/cloudron-totp-enroll.sh [vault-item]` — enroll TOTP, + capture the seed to the vault, verify end-to-end. +- `scripts/oidc-login.sh ` — system SSO + login via cookie jar. +- `scripts/totp.sh ` — current 6-digit TOTP code (pure bash, + RFC-7636-vector tested). diff --git a/skills/agent-provisioning/references/org-chart-update.md b/skills/agent-provisioning/references/org-chart-update.md new file mode 100644 index 0000000..fb2ec6f --- /dev/null +++ b/skills/agent-provisioning/references/org-chart-update.md @@ -0,0 +1,37 @@ +# Org chart update (mandatory post-provisioning step) + +The org chart lives in THREE places and all three move together in the same +turn a provisioning change lands: + +| Where | What | How | +|---|---|---| +| `TSYSGroupCorporate/TSYSGroupAIOS` `agents/global/AGENTS.md` — "Org chart & agent lanes" | THE canonical operational map (every session loads it via symlink) | branch `coo/…` or lane branch → PR → founder merge | +| `reachableceo/org-buildout` `docs/org-chart.md` | The rendered full-group chart (publishable) | same-turn commit | +| Redmine provisioning ticket | Per-identity state (pw/2FA/seed/systems) | comment with commit URLs | + +Rules: + +1. **Chart before claimed-done.** A provisioned identity that isn't in the + chart is an incomplete task; a charted identity without a ticket is + invisible work. Both fail review. +2. House-rules/policy changes additionally require a `house-rules-v*` tag bump + (see the versioning section at the bottom of AGENTS.md). +3. The COO C2 lane charts; lanes below SVP execute. If you are not the COO + lane, your provisioning ticket IS the chart-change request. +4. When the chart changes, so does reality somewhere: verify the identity's + vault item (unique password, totp_seed, per-system tokens) before drawing + it as operational. + +Sequence for one provisioning run (branch `coo/…` in TSYSGroupAIOS): + +```bash +# 1. edit agents/global/AGENTS.md org table (if lane structure changed) +# 2. edit org-buildout docs/org-chart.md + STATUS.md +# 3. commit both, push both (HTTPS + the acting identity's token) +# 4. PR for TSYSGroupAIOS if policy-level; org-buildout pushes direct per its flow +# 5. Redmine comment links every commit URL +``` + +--- + +**Records:** Redmine #942 — https://projects.knownelement.com/issues/942 · Discourse docs home: https://community.turnsys.com (skill topic pending admin-key provisioning, tracked on #942) diff --git a/skills/agent-provisioning/scripts/cloudron-oidc-login.sh b/skills/agent-provisioning/scripts/cloudron-oidc-login.sh new file mode 100755 index 0000000..8cdd3a0 --- /dev/null +++ b/skills/agent-provisioning/scripts/cloudron-oidc-login.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# Cloudron OIDC dashboard login (SPA-mimic, PKCE) for an identity. +# usage: cloudron-oidc-login.sh [vault-item] +# Performs: prompt=login auth -> interaction login (password + TOTP from +# vault totp_seed if present) -> consent confirm -> code exchange. +# Writes the access token to /tmp/.oidc-bearer- and a session +# cookie jar /tmp/oidc-jar-.txt. Prints checkpoints to stderr. +set -uo pipefail +U="${1:?usage: cloudron-oidc-login.sh [vault-item]}" +ITEM="${2:-$U Cloudron}" +BASE="https://my.knownelement.com" +CLIENT="cid-webadmin" +REDIRECT="$BASE/authcallback.html" # used in the auth request below via explicit URL encoding +DBG="${CLOUDRON_SELF_DEBUG:-0}" +if [ "$(id -un)" = "TSGCOO" ]; then + SM() { /data2/TSGCOO/.local/bin/sm "$@" &2; exit 1; } +SEED=$(SM get "$ITEM" --field totp_seed 2>/dev/null || true) +VERIFIER=$(openssl rand -base64 48 | tr '+/' '-_' | tr -d '=\n' | cut -c1-64) +CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=\n') +AUTH="$BASE/openid/auth?client_id=$CLIENT&response_type=code&scope=openid%20email%20profile&redirect_uri=https%3A%2F%2Fmy.knownelement.com%2Fauthcallback.html&code_challenge=$CHALLENGE&code_challenge_method=S256" +JAR="/tmp/oidc-jar-$U.txt"; rm -f "$JAR" + +INT=$(curl -sk -o /dev/null -w '%{url_effective}' -c "$JAR" -L --max-redirs 8 "$AUTH") +[ "$DBG" = 1 ] && echo "DBG int: ${INT:0:70}" >&2 +case "$INT" in */openid/interaction/*) : ;; *) echo "FAIL: no interaction (auto-resolved session?)"; exit 3;; esac +UIDPATH=$(printf '%s' "$INT" | grep -o '/openid/interaction/[^?]*') + +BODY="{\"username\":\"$U\",\"password\":\"$PW\"" +if [ -n "$SEED" ]; then + TCODE=$(bash "$(dirname "$0")/totp.sh" "$SEED") + BODY="$BODY,\"totpToken\":\"$TCODE\"" +fi +BODY="$BODY}" +LOGIN_RESP=$(curl -sk -b "$JAR" -c "$JAR" -X POST "$BASE$UIDPATH/login" \ + -H 'Content-Type: application/json' -d "$BODY") +unset BODY +[ "$DBG" = 1 ] && echo "DBG login: ${LOGIN_RESP:0:70}" >&2 +case "$LOGIN_RESP" in + *redirectTo*) : ;; + *twoFactorRequired*) echo "FAIL: 2FA required but no usable seed in vault" >&2; exit 4;; + *) echo "FAIL: login rejected: $LOGIN_RESP" >&2; exit 5;; +esac +RED=$(printf '%s' "$LOGIN_RESP" | sed -n 's/.*"redirectTo":"\([^"]*\)".*/\1/p') +CALLBACK=$(curl -sk -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' -L --max-redirs 8 "$RED") +[ "$DBG" = 1 ] && echo "DBG after resume: ${CALLBACK:0:70}" >&2 + +case "$CALLBACK" in + */openid/interaction/*) + CUIDPATH=$(printf '%s' "$CALLBACK" | grep -o '/openid/interaction/[^?]*') + CLOC=$(curl -sk -b "$JAR" -c "$JAR" -D - -o /dev/null -X POST "$BASE$CUIDPATH/confirm" \ + -d '' | grep -i '^location:' | head -1 | tr -d '\r' | sed 's/^[Ll]ocation: //') + [ -n "$CLOC" ] || { echo "FAIL: consent confirm produced no redirect" >&2; exit 6; } + case "$CLOC" in http*) : ;; *) CLOC="$BASE$CLOC" ;; esac + CALLBACK=$(curl -sk -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' -L --max-redirs 8 "$CLOC") + ;; +esac +CODE=$(printf '%s' "$CALLBACK" | sed -n 's/[?&]code=\([^&]*\).*/\1/p') +[ "$DBG" = 1 ] && echo "DBG code len: ${#CODE}" >&2 +[ -n "$CODE" ] || { echo "FAIL: no authorization code at callback: $CALLBACK" >&2; exit 6; } + +TOKRESP=$(curl -sk -b "$JAR" -c "$JAR" -X POST "$BASE/openid/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$CODE" \ + --data-urlencode "client_id=$CLIENT" \ + --data-urlencode "redirect_uri=$REDIRECT" \ + --data-urlencode "code_verifier=$VERIFIER") +ACCESS=$(printf '%s' "$TOKRESP" | sed -n 's/.*"access_token":"\([^"]*\)".*/\1/p') +[ -n "$ACCESS" ] || { echo "FAIL: token exchange: $(printf '%s' "$TOKRESP" | head -c 120)" >&2; exit 7; } +printf '%s' "$ACCESS" > "/tmp/.oidc-bearer-$U" +chmod 600 "/tmp/.oidc-bearer-$U" +WHO=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"username":"\([^"]*\)".*/\1/p') +echo "OK: bearer for '$WHO' at /tmp/.oidc-bearer-$U (session jar $JAR)" diff --git a/skills/agent-provisioning/scripts/cloudron-totp-enroll.sh b/skills/agent-provisioning/scripts/cloudron-totp-enroll.sh new file mode 100755 index 0000000..1c8b87d --- /dev/null +++ b/skills/agent-provisioning/scripts/cloudron-totp-enroll.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Enroll TOTP for an identity (2FA must NOT be enforced yet — run +# cloudron-oidc-login.sh first, or admin totp_disable for seed-lost users). +# usage: cloudron-totp-enroll.sh [vault-item] +# Captures the seed into the vault item and verifies end-to-end. +set -euo pipefail +U="${1:?usage: cloudron-totp-enroll.sh [vault-item]}" +ITEM="${2:-$U Cloudron}" +BASE="https://my.knownelement.com" +BEARER_FILE="/tmp/.oidc-bearer-$U" +[ -f "$BEARER_FILE" ] || { echo "FAIL: run cloudron-oidc-login.sh $U first" >&2; exit 1; } +ACCESS=$(cat "$BEARER_FILE") +MYUID=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"id":"\(uid-[^"]*\)".*/\1/p') +[ -n "$MYUID" ] || { echo "FAIL: bearer rejected" >&2; exit 2; } +SECRET=$(curl -sk "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p') +if [ -z "$SECRET" ]; then + SECRET=$(curl -sk -X POST "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' -d '{}' | sed -n 's/.*"secret":"\([^"]*\)".*/\1/p') +fi +[ -n "$SECRET" ] || { echo "FAIL: no totp secret returned" >&2; exit 3; } +CODE=$(bash "$(dirname "$0")/totp.sh" "$SECRET") +ENAB=$(curl -sk -X POST "$BASE/api/v1/users/$MYUID/totp_setup" -H "Authorization: Bearer $ACCESS" -H 'Content-Type: application/json' -d "{\"totpSecret\":\"$SECRET\",\"totpToken\":\"$CODE\"}") +case "$ENAB" in *Invalid*|*404*|*"No such route"*) echo "PARTIAL: enable said: $(printf '%s' "$ENAB" | head -c 100)";; esac +SM() { if [ "$(id -un)" = "TSGCOO" ]; then /data2/TSGCOO/.local/bin/sm "$@" /dev/null +# end-to-end verify: fresh login with the stored seed +bash "$(dirname "$0")/cloudron-oidc-login.sh" "$U" "$ITEM" >/dev/null 2>&1 +if [ -s "$BEARER_FILE" ]; then + WHO=$(curl -sk "$BASE/api/v1/profile" -H "Authorization: Bearer $(cat "$BEARER_FILE")" | sed -n 's/.*"username":"\([^"]*\)".*/\1/p') + [ "$WHO" = "$U" ] && echo "OK: $U 2FA enrolled, seed in vault ($ITEM), TOTP login verified as $WHO" \ + && exit 0 +fi +echo "PARTIAL: verify failed — check state for $U" diff --git a/skills/agent-provisioning/scripts/oidc-login.sh b/skills/agent-provisioning/scripts/oidc-login.sh deleted file mode 100755 index 46f141e..0000000 --- a/skills/agent-provisioning/scripts/oidc-login.sh +++ /dev/null @@ -1,59 +0,0 @@ -#!/bin/bash -# Full SSO login for an identity into one of the four systems, via cookie jar. -# usage: oidc-login.sh -# Reads the Cloudron password from vault item " Cloudron" (env login -# triple; USERNAME may be the email form). Success leaves cookie jar at -# /tmp/oidc--.txt and prints the landing URL. -# TOTP-enforced identity? Set TOTP_CODE=$(scripts/totp.sh ) first. -set -euo pipefail -SYS="${1:?usage: oidc-login.sh }" -LOGIN="${2:?usage: oidc-login.sh }" -case "$SYS" in - glpi) START="https://cmdb.knownelement.com/plugins/singlesignon/front/callback.php/provider/1?remember=1"; APP="https://cmdb.knownelement.com";; - gitea) START="https://git.knownelement.com/user/oauth2/cloudron"; APP="https://git.knownelement.com";; - discourse) START="https://community.turnsys.com/auth/openid_connect"; APP="https://community.turnsys.com";; - redmine) START="https://projects.knownelement.com/oauth"; APP="https://projects.knownelement.com";; - *) echo "unknown system: $SYS" >&2; exit 2;; -esac -if [ "$(id -un)" = "TSGCOO" ]; then - SM() { /data2/TSGCOO/.local/bin/sm "$@" &2; exit 1; } -USER_FIELD="${USERNAME:-$LOGIN}" -JAR="/tmp/oidc-${LOGIN}-${SYS}.txt"; rm -f "$JAR" - -# 1. Start the SSO flow; follow redirects to the OIDC interaction page. -INTURL=$(curl -s -o /dev/null -w '%{redirect_url}' -c "$JAR" "$START") -case "$INTURL" in - https://my.knownelement.com/openid/interaction/*) : ;; - *) echo "no interaction redirect (got: '${INTURL:-none}'). If this is redmine, see references/systems.md (known bounce). If discourse, the route may need CSRF via a real session." >&2; exit 3;; -esac - -# 2. Interaction login. Field name is `username` (NOT `login`). -BODY="{\"username\":\"$USER_FIELD\",\"password\":\"$PASSWORD\"" -if [ -n "${TOTP_CODE:-}" ]; then BODY="$BODY,\"totpToken\":\"$TOTP_CODE\""; fi -BODY="$BODY}" -RESP=$(curl -s -b "$JAR" -c "$JAR" -X POST "${INTURL}/login" \ - -H 'Content-Type: application/json' -d "$BODY") -unset BODY - -case "$RESP" in - *redirectTo*) - RED=$(printf '%s' "$RESP" | sed -n 's/.*"redirectTo":"\([^"]*\)".*/\1/p') - case "$RED" in http*) : ;; *) RED="$APP$RED" ;; esac - FINAL=$(curl -s -L -b "$JAR" -c "$JAR" -o /dev/null -w '%{url_effective}' "$RED") - echo "logged in: cookie jar $JAR" - echo "landing: $FINAL" - echo "verify the identity before use (users/current or /api/v1/user)." - ;; - *twoFactorRequired*) - echo "2FA enforced and no/invalid TOTP_CODE given." >&2 - echo "If the seed is in the vault: TOTP_CODE=\$(scripts/totp.sh \$(sm get '$LOGIN Cloudron' --field totp_seed)) $0 $SYS $LOGIN" >&2 - exit 4;; - *) - echo "login rejected: $RESP" >&2 - exit 5;; -esac diff --git a/skills/agent-provisioning/scripts/totp.sh b/skills/agent-provisioning/scripts/totp.sh index 3bfb732..c7615e3 100755 --- a/skills/agent-provisioning/scripts/totp.sh +++ b/skills/agent-provisioning/scripts/totp.sh @@ -11,8 +11,8 @@ COUNTER=$(printf '%016x' $(( NOW / STEP ))) # bash vars cannot hold null bytes - keep the counter as a FORMAT string and # let printf emit the raw bytes straight into the pipe. FMT=$(printf '%s' "$COUNTER" | sed 's/../\\x&/g') -# intentional: FMT emits raw counter bytes incl. NULs - the whole point -# shellcheck disable=SC2059 + # intentional: FMT emits raw counter bytes (NULs) - the whole point + # shellcheck disable=SC2059 MAC=$(printf "$FMT" | openssl dgst -sha1 -mac HMAC -macopt "hexkey:$KEYHEX" -binary \ | od -An -tx1 | tr -d ' \n') OFFSET=$(( 0x${MAC: -1} ))