TerdutTeam: mint and surface a real invite link (spec.invite)
The actual fix for the human-onboarding gap niklas/terdut-server#23 found -- not a terdut-server change at all. A team-scoped credential is already owner-equivalent for POST/GET/DELETE /api/teams/{teamID}/invites (requireTeamOwner's synthetic-membership mechanism, ratified not accidental per that repo's SERVICE-ACCOUNTS.md), and invite redemption bypasses signup_mode entirely -- this TerdutTeam controller just never grew a feature to use either fact. New spec.invite{enabled, role (member|owner, default member), maxUses (1-100, default 1)} and status.inviteSecretRef. The Secret lives in the TerdutTeam's OWN namespace, not the operator's: unlike status.credentialsSecretRef (a durable, high-privilege credential, kept operator-side per DESIGN.md §6), an invite is bounded and limited-use, meant for this namespace's own human operators to read and hand out -- same precedent as TerdutAlertSource's status.webhookURLSecretRef, same- namespace and OwnerReference'd so deleting the TerdutTeam garbage-collects it automatically. internal/controller/terdutteam_invite.go: mints on first spec.invite.enabled, refreshes a day ahead of terdut-server's fixed 7-day TTL (reading the Secret's own stored expiresAt, no extra server round-trip per reconcile), revokes server-side and deletes the Secret when flipped back to false. A lost invite Secret is silently re-minted rather than treated as unrecoverable the way TerdutAlertSource's webhook key is -- nothing external holds a durable dependency on one specific invite link staying stable, it's read once by one human and handed out. New tdclient.Invite/CreateInvite/RevokeInvite. New envtest coverage: mint into the team's own namespace, refresh-before-expiry, revoke-on-disable (internal/controller/terdutteam_controller_test.go's new "spec.invite" Describe block), plus the fake server growing invite support (terdutserver_controller_test.go) -- its handleTeamSubPath dispatcher was split further (deadman switches into their own handleDeadmanSubPath, matching the existing handleIntegrationSubPath precedent) to stay under golangci-lint's gocyclo threshold with the new route added. examples/demo updated to prove this end to end: 02-team-platform.yaml turns on spec.invite; run-demo.sh's bootstrap_login/join_demo_teams (the psql signup_mode flip + a direct team_members INSERT) are replaced by redeem_platform_invite (reads status.inviteSecretRef, a real POST /api/signup with the invite token) and join_payments_team (POST /api/teams/{teamID}/members using Payments' own credential and alice's user id resolved via GET /api/users, deliberately not given its own spec.invite, so the demo shows both onboarding paths this feature unlocks) -- zero kubectl exec/psql calls remain anywhere in the script. README.md's "First login" section rewritten to match; it no longer documents the admin-token curl call that 403s against current terdut-server (niklas/terdut-server#23). Depends on niklas/terdut-server#24 (the callerMayManageServiceAccount fix for terdut-operator#3) being released before this is deployed for real -- not required to build or test this change itself, since the envtest fake never modeled that authorization gap to begin with.
This commit is contained in:
@@ -15,3 +15,9 @@ spec:
|
||||
name: terdut-operator-demo
|
||||
displayName: Platform
|
||||
# No oidc block: this demo is password-login only (01-server.yaml).
|
||||
# A real invite link, minted via this team's own credential, is how
|
||||
# run-demo.sh's alice actually gets in -- no signup_mode flip, no admin
|
||||
# token (see niklas/terdut-server#23's resolution). Role/maxUses left at
|
||||
# their defaults (member, 1): one link for one person.
|
||||
invite:
|
||||
enabled: true
|
||||
|
||||
@@ -6,3 +6,9 @@ spec:
|
||||
serverRef:
|
||||
name: terdut-operator-demo
|
||||
displayName: Payments
|
||||
# No spec.invite here, deliberately: run-demo.sh joins alice to this team
|
||||
# through POST /api/teams/{teamID}/members instead (this team's own
|
||||
# credential, same owner-equivalent reach spec.invite relies on, plus her
|
||||
# user id resolved via GET /api/users), once she already has an account
|
||||
# from Platform's invite -- showing both onboarding paths this feature
|
||||
# unlocks, not just the one.
|
||||
|
||||
+32
-20
@@ -10,6 +10,14 @@ This is a demo kit, not a reference deployment: `00-postgres.yaml` runs
|
||||
Postgres with `emptyDir` storage and a password committed in this
|
||||
directory. Throw the whole namespace away when you're done.
|
||||
|
||||
**Want this fully automated instead of walking through it by hand?**
|
||||
`./run-demo.sh` does everything below itself, against a fresh (or
|
||||
already-set-up) `kind` cluster — creates the cluster, installs the
|
||||
operator, applies every CR here, signs `alice` in for real, and fires a
|
||||
few alerts. `./run-demo.sh --help` for the knobs, `./run-demo.sh
|
||||
--teardown` to tear it back down. The rest of this file is the manual
|
||||
walkthrough it automates.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- The operator and its CRDs installed and running (`make install
|
||||
@@ -74,30 +82,34 @@ exposing it for real (Gateway API, Istio, or a plain `Ingress`) instead.
|
||||
|
||||
The operator's own bootstrap (DESIGN.md §6) creates the first user through
|
||||
`/api/bootstrap` and immediately mints itself a service-account token from
|
||||
it — that account has no password, so there's nothing to sign in with yet.
|
||||
`signup_mode` also defaults to `invite_only`, so open signup needs turning
|
||||
on first, using the admin token the operator generated for itself:
|
||||
it, then discards the bootstrap user's own key — nobody ever signs in as
|
||||
that account, and `signup_mode` stays `invite_only` by default. **Don't try
|
||||
to flip it via the operator's own token**: that token is a service account,
|
||||
and `/api/admin/settings` is deliberately human-only on terdut-server
|
||||
(`niklas/terdut-server#23` has the full reasoning — widening that gate was
|
||||
the wrong fix).
|
||||
|
||||
The real path in: `02-team-platform.yaml` turns on `spec.invite`, so
|
||||
Platform's own `TerdutTeam` mints a real invite link with its own
|
||||
already-working team-scoped credential (the same reach that lets it manage
|
||||
its own escalation policy, dead man's switches and integrations — owner-
|
||||
equivalent, confirmed in terdut-server's `SERVICE-ACCOUNTS.md`). Invite
|
||||
redemption bypasses `signup_mode` entirely, so this needs no admin
|
||||
credential at all:
|
||||
|
||||
```sh
|
||||
# Which namespace the operator itself runs in:
|
||||
kubectl get deploy -A -l control-plane=controller-manager
|
||||
|
||||
# The Secret holding the operator's own admin token for this TerdutServer
|
||||
# (cross-namespace from terdut-operator-demo, per DESIGN.md §7):
|
||||
secretname=$(kubectl -n terdut-operator-demo get terdutserver terdut-operator-demo \
|
||||
-o jsonpath='{.status.credentialsSecretRef.name}')
|
||||
token=$(kubectl -n <operator-namespace-from-above> get secret "$secretname" \
|
||||
-o jsonpath='{.data.token}' | base64 -d)
|
||||
|
||||
curl -X PUT http://localhost:8080/api/admin/settings \
|
||||
-H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
|
||||
-d '{"signup_mode":"open"}'
|
||||
secretname=$(kubectl -n terdut-operator-demo get terdutteam terdutteam-platform \
|
||||
-o jsonpath='{.status.inviteSecretRef.name}')
|
||||
url=$(kubectl -n terdut-operator-demo get secret "$secretname" -o jsonpath='{.data.url}' | base64 -d)
|
||||
echo "$url" # open this, or POST /api/signup with {"invite": "<the token after invite=>", ...}
|
||||
```
|
||||
|
||||
Then sign up through the UI as a normal human account. `04-escalation-platform.yaml`
|
||||
names a user `alice` at its first escalation level — sign up as `alice` if
|
||||
you want that level to mean something rather than falling through to
|
||||
on-call after 5 minutes.
|
||||
`04-escalation-platform.yaml` names a user `alice` at its first escalation
|
||||
level — sign up as `alice` if you want that level to mean something rather
|
||||
than falling through to on-call after 5 minutes. `run-demo.sh` does exactly
|
||||
this automatically (and also joins `alice` to Payments, which deliberately
|
||||
has no `spec.invite` of its own — see that file's comment for the second
|
||||
onboarding path this demonstrates).
|
||||
|
||||
## Fire some alerts
|
||||
|
||||
|
||||
+58
-49
@@ -22,7 +22,6 @@ RELEASE_NAME="${RELEASE_NAME:-terdut-operator}"
|
||||
|
||||
ALICE_USERNAME="${ALICE_USERNAME:-alice}"
|
||||
ALICE_EMAIL="${ALICE_EMAIL:-alice@terdut-demo.local}"
|
||||
ALICE_TEAM_NAME="${ALICE_TEAM_NAME:-alice-demo}"
|
||||
DEMO_PASSWORD="${DEMO_PASSWORD:-terdut-demo-1234}"
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:8080}"
|
||||
@@ -159,69 +158,79 @@ start_port_forward() {
|
||||
log "port-forward ready (pid $STARTED_PF_PID, log $PF_LOGFILE)"
|
||||
}
|
||||
|
||||
# Flips signup_mode to 'open' directly in Postgres, then uses the ordinary
|
||||
# unauthenticated signup endpoint to create a real local account with a
|
||||
# server-computed bcrypt hash. See this repo's run-demo.sh header / the
|
||||
# plan this was built from for why this bypasses the demo README's own
|
||||
# (currently broken) admin-token approach: the operator's service-account
|
||||
# credential cannot call /api/admin/settings or POST /api/users -- AdminOnly
|
||||
# only recognizes a human session/API-key caller, confirmed against
|
||||
# terdut-server's internal/api/middleware.go.
|
||||
bootstrap_login() {
|
||||
log "flipping signup_mode to open directly in Postgres"
|
||||
local pg_pod
|
||||
pg_pod="$(kubectl -n "$NAMESPACE" get pod -l app=terdut-operator-demo-postgres \
|
||||
-o jsonpath='{.items[0].metadata.name}')"
|
||||
[ -n "$pg_pod" ] || die "could not find the demo Postgres pod in $NAMESPACE"
|
||||
# Redeems Platform's own invite link -- minted by its TerdutTeam
|
||||
# (02-team-platform.yaml's spec.invite.enabled, reconciled through that
|
||||
# team's own already-working team-scoped credential, which requireTeamOwner
|
||||
# already treats as owner-equivalent for /invites -- ratified, not a
|
||||
# workaround, in terdut-server's SERVICE-ACCOUNTS.md) and redeemed through
|
||||
# the ordinary signup endpoint. Invite redemption bypasses signup_mode
|
||||
# entirely (terdut-server's internal/api/signup.go), so this needs no admin
|
||||
# credential, no signup_mode flip, and no direct Postgres access at all --
|
||||
# unlike an earlier version of this script, before terdut-operator grew
|
||||
# this feature (see niklas/terdut-server#23).
|
||||
redeem_platform_invite() {
|
||||
log "reading Platform's invite link"
|
||||
local secret_name invite_url invite_token tries=0
|
||||
until secret_name="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-platform \
|
||||
-o jsonpath='{.status.inviteSecretRef.name}' 2>/dev/null)" && [ -n "$secret_name" ]; do
|
||||
tries=$((tries + 1))
|
||||
[ "$tries" -lt 30 ] || die "terdutteam-platform never reported status.inviteSecretRef -- check spec.invite.enabled and kubectl describe it"
|
||||
sleep 1
|
||||
done
|
||||
invite_url="$(kubectl -n "$NAMESPACE" get secret "$secret_name" -o jsonpath='{.data.url}' | base64 -d)"
|
||||
invite_token="${invite_url##*invite=}"
|
||||
[ -n "$invite_token" ] || die "could not parse an invite token out of $invite_url"
|
||||
|
||||
kubectl -n "$NAMESPACE" exec "$pg_pod" -- psql -U terdut -d terdut -c \
|
||||
"INSERT INTO settings (key, value) VALUES ('signup_mode', 'open') ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value;" \
|
||||
>/dev/null || die "could not set signup_mode=open in Postgres"
|
||||
|
||||
log "signing up ${ALICE_USERNAME}"
|
||||
log "signing up ${ALICE_USERNAME} via Platform's invite"
|
||||
local body resp_file code
|
||||
body="$(jq -n \
|
||||
--arg u "$ALICE_USERNAME" --arg e "$ALICE_EMAIL" \
|
||||
--arg p "$DEMO_PASSWORD" --arg t "$ALICE_TEAM_NAME" \
|
||||
'{username: $u, email: $e, password: $p, team_name: $t}')"
|
||||
--arg p "$DEMO_PASSWORD" --arg i "$invite_token" \
|
||||
'{username: $u, email: $e, password: $p, invite: $i}')"
|
||||
resp_file="$(mktemp)"
|
||||
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
|
||||
-X POST "${BASE_URL}/api/signup" -H 'Content-Type: application/json' -d "$body")"
|
||||
|
||||
case "$code" in
|
||||
201) log "created local account ${ALICE_USERNAME}" ;;
|
||||
201) log "created local account ${ALICE_USERNAME} (joined Platform)" ;;
|
||||
409) log "account ${ALICE_USERNAME} already exists, skipping (re-run detected)" ;;
|
||||
*) die "signup failed (HTTP $code): $(cat "$resp_file")" ;;
|
||||
esac
|
||||
rm -f "$resp_file"
|
||||
}
|
||||
|
||||
# Open signup always creates a brand-new team owned by the signer (it never
|
||||
# joins an existing team by name -- confirmed against terdut-server's
|
||||
# internal/api/signup.go), so without this step alice would have a working
|
||||
# login that can't see a single incident this demo fires: /api/incidents
|
||||
# and /api/alerts are scoped to the caller's own team memberships. Join her
|
||||
# to both demo teams directly, the same way bootstrap_login already reaches
|
||||
# into Postgres for signup_mode -- there is no API path for this either
|
||||
# (adding a member to a team you don't already belong to is an owner/admin
|
||||
# action, and alice is neither of those for Platform/Payments).
|
||||
join_demo_teams() {
|
||||
log "adding ${ALICE_USERNAME} to the Platform and Payments teams"
|
||||
local platform_id payments_id pg_pod
|
||||
platform_id="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-platform -o jsonpath='{.status.teamID}')"
|
||||
# redeem_platform_invite's signup already used up alice's one signup -- a
|
||||
# second POST /api/signup would just 409 on the taken username, it doesn't
|
||||
# join an existing account to another team. Payments is joined through the
|
||||
# ordinary team-scoped member endpoint instead, using that team's own
|
||||
# already-working team-scoped credential (owner-equivalent, same reach the
|
||||
# invite route above relies on) and alice's user id resolved via
|
||||
# GET /api/users -- the same lookup tdclient.GetUserByUsername does
|
||||
# operator-side. The endpoint upserts on (team_id, user_id), so this is
|
||||
# naturally idempotent across re-runs with no separate conflict handling
|
||||
# needed.
|
||||
join_payments_team() {
|
||||
log "adding ${ALICE_USERNAME} to Payments"
|
||||
local payments_id payments_secret payments_key alice_id resp_file code
|
||||
payments_id="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments -o jsonpath='{.status.teamID}')"
|
||||
[ -n "$platform_id" ] && [ -n "$payments_id" ] \
|
||||
|| die "could not read status.teamID off terdutteam-platform/terdutteam-payments"
|
||||
[ -n "$payments_id" ] || die "could not read status.teamID off terdutteam-payments"
|
||||
payments_secret="$(kubectl -n "$NAMESPACE" get terdutteam terdutteam-payments \
|
||||
-o jsonpath='{.status.credentialsSecretRef.name}')"
|
||||
[ -n "$payments_secret" ] || die "terdutteam-payments has no status.credentialsSecretRef yet"
|
||||
payments_key="$(kubectl -n "$OPERATOR_NAMESPACE" get secret "$payments_secret" -o jsonpath='{.data.token}' | base64 -d)"
|
||||
|
||||
pg_pod="$(kubectl -n "$NAMESPACE" get pod -l app=terdut-operator-demo-postgres \
|
||||
-o jsonpath='{.items[0].metadata.name}')"
|
||||
kubectl -n "$NAMESPACE" exec "$pg_pod" -- psql -U terdut -d terdut -c "
|
||||
INSERT INTO team_members (team_id, user_id, role)
|
||||
VALUES
|
||||
(${platform_id}, (SELECT id FROM users WHERE username = '${ALICE_USERNAME}'), 'member'),
|
||||
(${payments_id}, (SELECT id FROM users WHERE username = '${ALICE_USERNAME}'), 'member')
|
||||
ON CONFLICT (team_id, user_id) DO NOTHING;" \
|
||||
>/dev/null || die "could not add ${ALICE_USERNAME} to the demo teams"
|
||||
alice_id="$(curl -sS -H "Authorization: Bearer $payments_key" "${BASE_URL}/api/users" \
|
||||
| jq -r --arg u "$ALICE_USERNAME" '.[] | select(.username == $u) | .id')"
|
||||
[ -n "$alice_id" ] || die "could not resolve ${ALICE_USERNAME}'s user id via GET /api/users"
|
||||
|
||||
resp_file="$(mktemp)"
|
||||
code="$(curl -sS -o "$resp_file" -w '%{http_code}' \
|
||||
-X POST "${BASE_URL}/api/teams/${payments_id}/members" \
|
||||
-H "Authorization: Bearer $payments_key" -H 'Content-Type: application/json' \
|
||||
-d "$(jq -n --argjson id "$alice_id" '{user_id: $id, role: "member"}')")"
|
||||
[ "$code" = "204" ] || die "adding ${ALICE_USERNAME} to Payments failed (HTTP $code): $(cat "$resp_file")"
|
||||
log "added ${ALICE_USERNAME} to Payments"
|
||||
rm -f "$resp_file"
|
||||
}
|
||||
|
||||
fire_demo_alerts() {
|
||||
@@ -312,8 +321,8 @@ main() {
|
||||
apply_demo
|
||||
wait_for_ready
|
||||
start_port_forward
|
||||
bootstrap_login
|
||||
join_demo_teams
|
||||
redeem_platform_invite
|
||||
join_payments_team
|
||||
fire_demo_alerts
|
||||
print_summary
|
||||
|
||||
|
||||
Reference in New Issue
Block a user