mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
32c76499fa
Mint engine: guard the credential-rotation persist so a storage blip cannot escape the classified-result contract mid-mint (and cannot brick the user's other obo servers on strict-rotation IdPs); stop borrowing the login flow's httpx client across event loops — mints use a transient per-request client (obo_http_client remains as a test seam); retry OIDC discovery at runtime (cooldown-gated, single-flight) so a node that booted during an IdP outage can mint again without a restart; key the under-lock force-refresh reuse gate on created, which delete+create makes the mint time (obo rows never set last_refreshed, so the copied oauth_user gate never fired and serialized waiters each re-redeemed). Cross-node consent badges: the cleared-pairs set becomes a TTL map with bounded growth, so a badge written by another node after this node's last clear self-heals within one TTL window instead of surviving until a restart. Admin lifecycle: purge the mint cache when oauth_scopes changes on an obo row (an rfc8693 privilege reduction now applies immediately, like audience changes); normalize no-op scope/audience re-sends out of updates — the admin form re-submits pre-filled fields on every save, which both re-triggered purges and made entra-profile rows with legacy scopes un-editable; make flip-into-obo scope handling grant-profile aware (entra clears the carry-over, rfc8693 honors the request); clear obo-era audience/scopes when flipping back to oauth_user (the IdP-side app identifier is not a resource indicator); mirror the same column policy in the create handler. Revocation honesty: hide obo mint-cache rows from the user connections list and refuse the per-server disconnect with 409 — deleting the row returned 204, audited token_revoked, and then session-start priming silently re-minted from the surviving captured credential. Console form: keep the audience-from-URL autofill off for sign-in passthrough (the audience there is an IdP application identifier, and the prefilled URL passed every validation layer then failed every mint); clear the autofill artifact when switching modes; omit unchanged scopes from submissions. Dispatchers: route tool/resource/prompt through one shared lookup-error mapping and an auth-model-aware 401-exhausted detail (obo users are no longer pointed at a consent flow that does not exist). The consent-url audit count drops 13 → 7: the three per-dispatcher mapping copies collapsed into _pool_lookup_error. Priming: skip all obo servers for users with no captured credential via one existence SELECT (previously three reads per server per session). Also: USER_SCOPED_AUTH_TYPES now lives in storage._protocol so the backend SQL predicates share the application layer's set; docs describe the actual purge-on-transition behavior (the orphan-and-reactivate claims were wrong); the entra e2e setup script no longer aborts silently under set -e with suppressed stderr.
135 lines
5.4 KiB
Bash
Executable File
135 lines
5.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Entra spike setup for entra_spike.py (#551 re-scope boundary spike).
|
|
# Manual test tooling — not run in CI. Creates throwaway Entra app registrations.
|
|
#
|
|
# ./entra_setup.sh setup create app registrations + consent + .env
|
|
# ./entra_setup.sh cleanup delete everything it created (incl. .env)
|
|
#
|
|
# Creates in the logged-in tenant (az login first):
|
|
# spike-turnstone confidential client (stands in for Turnstone's OIDC app)
|
|
# spike-mcp-a/b resource apps exposing scope mcp.access, admin-consented
|
|
# spike-mcp-c resource app with NO grant to the client (V5 control)
|
|
# Requires: the logged-in user can create apps + grant admin consent
|
|
# (Global Admin on a personal tenant qualifies).
|
|
|
|
set -euo pipefail
|
|
cd "$(dirname "$0")"
|
|
ENV_FILE=".env"
|
|
NAMES=(spike-turnstone spike-mcp-a spike-mcp-b spike-mcp-c)
|
|
|
|
log() { printf '>> %s\n' "$*"; }
|
|
|
|
graph_patch_api() { # $1=appId $2=scope-uuid $3=display-name
|
|
local obj_id
|
|
obj_id=$(az ad app show --id "$1" --query id -o tsv)
|
|
az rest --method PATCH \
|
|
--url "https://graph.microsoft.com/v1.0/applications/${obj_id}" \
|
|
--headers 'Content-Type=application/json' \
|
|
--body "{
|
|
\"identifierUris\": [\"api://$1\"],
|
|
\"api\": {
|
|
\"requestedAccessTokenVersion\": 2,
|
|
\"oauth2PermissionScopes\": [{
|
|
\"id\": \"$2\",
|
|
\"value\": \"mcp.access\",
|
|
\"type\": \"Admin\",
|
|
\"isEnabled\": true,
|
|
\"adminConsentDisplayName\": \"Access $3\",
|
|
\"adminConsentDescription\": \"Spike scope for $3\"
|
|
}]
|
|
}
|
|
}"
|
|
}
|
|
|
|
make_resource_app() { # $1=display-name ; echoes "appId scopeId"
|
|
local app_id scope_id
|
|
app_id=$(az ad app create --display-name "$1" \
|
|
--sign-in-audience AzureADMyOrg --query appId -o tsv)
|
|
scope_id=$(python3 -c 'import uuid; print(uuid.uuid4())')
|
|
graph_patch_api "$app_id" "$scope_id" "$1" >/dev/null
|
|
az ad sp create --id "$app_id" >/dev/null 2>&1 || true
|
|
echo "$app_id $scope_id"
|
|
}
|
|
|
|
cmd_setup() {
|
|
local tenant_id
|
|
tenant_id=$(az account show --query tenantId -o tsv)
|
|
log "tenant: ${tenant_id}"
|
|
|
|
log "creating resource apps (a, b, c)..."
|
|
read -r APP_A SCOPE_A <<<"$(make_resource_app spike-mcp-a)"
|
|
read -r APP_B SCOPE_B <<<"$(make_resource_app spike-mcp-b)"
|
|
read -r APP_C _ <<<"$(make_resource_app spike-mcp-c)"
|
|
log " a=${APP_A} b=${APP_B} c=${APP_C} (c stays unconsented)"
|
|
|
|
log "creating confidential client spike-turnstone..."
|
|
CLIENT_ID=$(az ad app create --display-name spike-turnstone \
|
|
--sign-in-audience AzureADMyOrg \
|
|
--web-redirect-uris "http://localhost:8765/callback" \
|
|
--query appId -o tsv)
|
|
az ad sp create --id "$CLIENT_ID" >/dev/null 2>&1 || true
|
|
# No stderr suppression here: the secret is load-bearing (it lands in .env),
|
|
# so under `set -e` a reset failure must abort LOUDLY, not silently.
|
|
SECRET=$(az ad app credential reset --id "$CLIENT_ID" \
|
|
--display-name spike --years 1 --query password -o tsv)
|
|
|
|
log "adding delegated permissions (a, b — NOT c)..."
|
|
# Tolerated failures (|| log): a re-run hits "permission already exists" and
|
|
# SP-propagation delays are common right after app creation — the
|
|
# admin-consent retry loop below is the real gate. `set -e` would otherwise
|
|
# turn a suppressed non-zero here into a silent mid-script abort.
|
|
az ad app permission add --id "$CLIENT_ID" \
|
|
--api "$APP_A" --api-permissions "${SCOPE_A}=Scope" \
|
|
|| log " warn: permission add for a failed (may already exist); admin-consent below will confirm"
|
|
az ad app permission add --id "$CLIENT_ID" \
|
|
--api "$APP_B" --api-permissions "${SCOPE_B}=Scope" \
|
|
|| log " warn: permission add for b failed (may already exist); admin-consent below will confirm"
|
|
|
|
log "granting admin consent (retries while SPs propagate)..."
|
|
local ok=""
|
|
for i in 1 2 3 4 5; do
|
|
if az ad app permission admin-consent --id "$CLIENT_ID" 2>/dev/null; then
|
|
ok=1; break
|
|
fi
|
|
log " not yet (attempt $i) — waiting 15s"
|
|
sleep 15
|
|
done
|
|
[ -n "$ok" ] || { log "admin-consent failed after retries — grant manually in the portal (API permissions blade) and re-run the spike"; }
|
|
|
|
# Single-quote the values in the generated .env: the AS-issued client secret
|
|
# can contain $ / backtick, and an unquoted RHS would be re-expanded (or
|
|
# partially executed) when the operator `source`s the file. The heredoc still
|
|
# interpolates ${...} into the single-quoted output; sourcing then treats the
|
|
# result literally. (Azure secrets are base64-ish — no single quotes to escape.)
|
|
umask 177
|
|
cat > "$ENV_FILE" <<EOF
|
|
export ENTRA_TENANT_ID='${tenant_id}'
|
|
export ENTRA_CLIENT_ID='${CLIENT_ID}'
|
|
export ENTRA_CLIENT_SECRET='${SECRET}'
|
|
export SPIKE_AUDIENCE_A='api://${APP_A}'
|
|
export SPIKE_AUDIENCE_B='api://${APP_B}'
|
|
export SPIKE_AUDIENCE_UNCONSENTED='api://${APP_C}'
|
|
export SPIKE_RUN_OBO=1
|
|
EOF
|
|
log "wrote ${ENV_FILE} (chmod 600). Next:"
|
|
log " source scripts/obo-e2e/.env && uv run python scripts/obo-e2e/entra_spike.py"
|
|
log "cleanup later with: ./entra_setup.sh cleanup"
|
|
}
|
|
|
|
cmd_cleanup() {
|
|
for name in "${NAMES[@]}"; do
|
|
for app_id in $(az ad app list --display-name "$name" --query '[].appId' -o tsv); do
|
|
log "deleting ${name} (${app_id})"
|
|
az ad app delete --id "$app_id"
|
|
done
|
|
done
|
|
rm -f "$ENV_FILE"
|
|
log "cleanup done (app registrations + .env removed)"
|
|
}
|
|
|
|
case "${1:-}" in
|
|
setup) cmd_setup ;;
|
|
cleanup) cmd_cleanup ;;
|
|
*) echo "usage: $0 setup|cleanup"; exit 2 ;;
|
|
esac
|