From a0ea13955e8ce109f4b9487d8f5471c64ddcc57d Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Fri, 2 Oct 2026 22:04:56 +0200 Subject: [PATCH 1/2] TerdutTeam: mint and surface a real invite link (spec.invite) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- api/v1alpha1/terdutteam_types.go | 63 ++++++++ api/v1alpha1/zz_generated.deepcopy.go | 21 +++ .../crd/terdutteams.terdut.ryuvia.com.yaml | 59 +++++++ .../bases/terdut.ryuvia.com_terdutteams.yaml | 59 +++++++ examples/demo/02-team-platform.yaml | 6 + examples/demo/03-team-payments.yaml | 6 + examples/demo/README.md | 52 +++--- examples/demo/run-demo.sh | 107 +++++++------ .../terdutserver_controller_test.go | 84 +++++++++- internal/controller/terdutteam_controller.go | 3 + .../controller/terdutteam_controller_test.go | 83 ++++++++++ internal/controller/terdutteam_invite.go | 149 ++++++++++++++++++ internal/tdclient/client.go | 47 ++++++ 13 files changed, 666 insertions(+), 73 deletions(-) create mode 100644 internal/controller/terdutteam_invite.go diff --git a/api/v1alpha1/terdutteam_types.go b/api/v1alpha1/terdutteam_types.go index 84ed991..7942ea6 100644 --- a/api/v1alpha1/terdutteam_types.go +++ b/api/v1alpha1/terdutteam_types.go @@ -27,6 +27,42 @@ type TerdutTeamOIDC struct { OwnerGroup string `json:"ownerGroup,omitempty"` } +// TerdutTeamInvite requests a standing invite link into this team, minted +// with the team's own team-scoped credential — requireTeamOwner already +// treats that credential as owner-equivalent for every /invites route +// (ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is +// the real answer to "how does a human ever get a first login on a +// password-only, operator-managed install" (terdut-server#23): no signup_mode +// flip, no admin token, just a link redeemed the same way anyone else's +// invite would be. +type TerdutTeamInvite struct { + // enabled mints (and keeps refreshed ahead of terdut-server's own fixed + // 7-day TTL) an invite link while true. Flipping it back to false + // revokes the current one server-side rather than leaving it to expire + // on its own. + // +optional + Enabled bool `json:"enabled,omitempty"` + + // role is what the invite grants: member or owner. Defaults to member — + // owner by default would make every invite link a standing + // administrative credential for the team, a much bigger blast radius + // than "let a human see the queue". + // +optional + // +kubebuilder:validation:Enum=member;owner + // +kubebuilder:default=member + Role string `json:"role,omitempty"` + + // maxUses bounds how many times this link may be redeemed before it + // stops working, mirroring terdut-server's own 1-100 range + // (POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for + // one specific person, not a standing door. + // +optional + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Maximum=100 + // +kubebuilder:default=1 + MaxUses int64 `json:"maxUses,omitempty"` +} + // TerdutTeamSpec defines the desired state of TerdutTeam. type TerdutTeamSpec struct { // serverRef names the TerdutServer this team belongs to. @@ -43,6 +79,9 @@ type TerdutTeamSpec struct { // +optional OIDC TerdutTeamOIDC `json:"oidc,omitempty"` + + // +optional + Invite TerdutTeamInvite `json:"invite,omitempty"` } // Condition reasons this controller sets. @@ -62,6 +101,19 @@ const ( ReasonTeamAdopted = "Adopted" ) +// Condition reasons for spec.invite reconciliation (TerdutTeamInvite). Not +// surfaced on the Ready condition itself — an invite is a convenience, not +// a dependency anything else in this team's own readiness waits on — but +// recorded as Events and readable via `kubectl describe`. +const ( + // ReasonInviteMinted: spec.invite.enabled is true and status.inviteSecretRef + // is populated and live. + ReasonInviteMinted = "InviteMinted" + // ReasonInviteRevoked: spec.invite.enabled flipped back to false and the + // server-side invite was revoked (or there was nothing to revoke). + ReasonInviteRevoked = "InviteRevoked" +) + // TerdutTeamStatus defines the observed state of TerdutTeam. type TerdutTeamStatus struct { // +listType=map @@ -87,6 +139,17 @@ type TerdutTeamStatus struct { // +optional ServerEndpoint string `json:"serverEndpoint,omitempty"` + // inviteSecretRef is this team's current invite link, if spec.invite.enabled. + // Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN + // namespace, not the operator's: an invite is bounded, limited-use, and + // meant for this namespace's own human operators to read and hand out, + // not a durable high-privilege credential — same shape as + // TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's + // cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled + // is false or unset. + // +optional + InviteSecretRef *LocalSecretRef `json:"inviteSecretRef,omitempty"` + // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 82ab42f..bd6ed80 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -834,6 +834,21 @@ func (in *TerdutTeam) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *TerdutTeamInvite) DeepCopyInto(out *TerdutTeamInvite) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamInvite. +func (in *TerdutTeamInvite) DeepCopy() *TerdutTeamInvite { + if in == nil { + return nil + } + out := new(TerdutTeamInvite) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) { *out = *in @@ -901,6 +916,7 @@ func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) { *out = *in out.ServerRef = in.ServerRef out.OIDC = in.OIDC + out.Invite = in.Invite } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec. @@ -928,6 +944,11 @@ func (in *TerdutTeamStatus) DeepCopyInto(out *TerdutTeamStatus) { *out = new(SecretKeyRef) **out = **in } + if in.InviteSecretRef != nil { + in, out := &in.InviteSecretRef, &out.InviteSecretRef + *out = new(LocalSecretRef) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus. diff --git a/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml b/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml index 3272061..d5ad8fc 100644 --- a/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml +++ b/charts/terdut-operator/templates/crd/terdutteams.terdut.ryuvia.com.yaml @@ -63,6 +63,47 @@ spec: create rule, via TEAM-LOOKUP.md). minLength: 1 type: string + invite: + description: |- + TerdutTeamInvite requests a standing invite link into this team, minted + with the team's own team-scoped credential — requireTeamOwner already + treats that credential as owner-equivalent for every /invites route + (ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is + the real answer to "how does a human ever get a first login on a + password-only, operator-managed install" (terdut-server#23): no signup_mode + flip, no admin token, just a link redeemed the same way anyone else's + invite would be. + properties: + enabled: + description: |- + enabled mints (and keeps refreshed ahead of terdut-server's own fixed + 7-day TTL) an invite link while true. Flipping it back to false + revokes the current one server-side rather than leaving it to expire + on its own. + type: boolean + maxUses: + default: 1 + description: |- + maxUses bounds how many times this link may be redeemed before it + stops working, mirroring terdut-server's own 1-100 range + (POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for + one specific person, not a standing door. + format: int64 + maximum: 100 + minimum: 1 + type: integer + role: + default: member + description: |- + role is what the invite grants: member or owner. Defaults to member — + owner by default would make every invite link a standing + administrative credential for the team, a much bigger blast radius + than "let a human see the queue". + enum: + - member + - owner + type: string + type: object oidc: description: |- TerdutTeamOIDC binds which identity-provider groups grant membership and @@ -171,6 +212,24 @@ spec: - key - name type: object + inviteSecretRef: + description: |- + inviteSecretRef is this team's current invite link, if spec.invite.enabled. + Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN + namespace, not the operator's: an invite is bounded, limited-use, and + meant for this namespace's own human operators to read and hand out, + not a durable high-privilege credential — same shape as + TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's + cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled + is false or unset. + properties: + name: + description: name is the Secret's name. + minLength: 1 + type: string + required: + - name + type: object observedGeneration: format: int64 type: integer diff --git a/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml b/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml index 3417462..40ff116 100644 --- a/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml +++ b/config/crd/bases/terdut.ryuvia.com_terdutteams.yaml @@ -60,6 +60,47 @@ spec: create rule, via TEAM-LOOKUP.md). minLength: 1 type: string + invite: + description: |- + TerdutTeamInvite requests a standing invite link into this team, minted + with the team's own team-scoped credential — requireTeamOwner already + treats that credential as owner-equivalent for every /invites route + (ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is + the real answer to "how does a human ever get a first login on a + password-only, operator-managed install" (terdut-server#23): no signup_mode + flip, no admin token, just a link redeemed the same way anyone else's + invite would be. + properties: + enabled: + description: |- + enabled mints (and keeps refreshed ahead of terdut-server's own fixed + 7-day TTL) an invite link while true. Flipping it back to false + revokes the current one server-side rather than leaving it to expire + on its own. + type: boolean + maxUses: + default: 1 + description: |- + maxUses bounds how many times this link may be redeemed before it + stops working, mirroring terdut-server's own 1-100 range + (POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for + one specific person, not a standing door. + format: int64 + maximum: 100 + minimum: 1 + type: integer + role: + default: member + description: |- + role is what the invite grants: member or owner. Defaults to member — + owner by default would make every invite link a standing + administrative credential for the team, a much bigger blast radius + than "let a human see the queue". + enum: + - member + - owner + type: string + type: object oidc: description: |- TerdutTeamOIDC binds which identity-provider groups grant membership and @@ -168,6 +209,24 @@ spec: - key - name type: object + inviteSecretRef: + description: |- + inviteSecretRef is this team's current invite link, if spec.invite.enabled. + Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN + namespace, not the operator's: an invite is bounded, limited-use, and + meant for this namespace's own human operators to read and hand out, + not a durable high-privilege credential — same shape as + TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's + cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled + is false or unset. + properties: + name: + description: name is the Secret's name. + minLength: 1 + type: string + required: + - name + type: object observedGeneration: format: int64 type: integer diff --git a/examples/demo/02-team-platform.yaml b/examples/demo/02-team-platform.yaml index 39c1063..a18cf7e 100644 --- a/examples/demo/02-team-platform.yaml +++ b/examples/demo/02-team-platform.yaml @@ -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 diff --git a/examples/demo/03-team-payments.yaml b/examples/demo/03-team-payments.yaml index 21f1b8a..42f470e 100644 --- a/examples/demo/03-team-payments.yaml +++ b/examples/demo/03-team-payments.yaml @@ -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. diff --git a/examples/demo/README.md b/examples/demo/README.md index 14a289f..0829e5f 100644 --- a/examples/demo/README.md +++ b/examples/demo/README.md @@ -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 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": "", ...} ``` -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 diff --git a/examples/demo/run-demo.sh b/examples/demo/run-demo.sh index f19795f..e589f13 100755 --- a/examples/demo/run-demo.sh +++ b/examples/demo/run-demo.sh @@ -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 diff --git a/internal/controller/terdutserver_controller_test.go b/internal/controller/terdutserver_controller_test.go index 75bdfc7..b94bb1e 100644 --- a/internal/controller/terdutserver_controller_test.go +++ b/internal/controller/terdutserver_controller_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "sync" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -35,6 +36,11 @@ import ( const ( fakeVersionString = "test" errJSONKey = "error" + // deadmanSwitchesPath is the literal path (not Printf'd like the others + // below) shared by the exact-match collection route and the dispatcher + // that routes into it -- goconst flags three occurrences of the same + // string, so this is that string, once. + deadmanSwitchesPath = "/deadman/switches" ) // fakeTerdutServer reproduces the exact stateful semantics of @@ -83,6 +89,14 @@ type fakeTerdutServer struct { nextIntegrationID int64 integrations map[int64]map[int64]tdclient.Integration // teamID -> integrationID -> integration integrationDelete map[int64]bool // integrationID -> true once DELETEd, for 404-on-redelete + + // invites/nextInviteID/inviteDelete back TerdutTeam's own invite-minting + // feature -- no unique constraint on an invite server-side either (every + // POST mints a brand new row, confirmed against source), same keyed-by-id + // shape as switches/integrations. + nextInviteID int64 + invites map[int64]map[int64]tdclient.Invite // teamID -> inviteID -> invite + inviteDelete map[int64]bool // inviteID -> true once DELETEd, for 404-on-redelete } func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) { @@ -100,6 +114,9 @@ func newFakeTerdutServer() (*fakeTerdutServer, *httptest.Server) { integrations: map[int64]map[int64]tdclient.Integration{}, integrationDelete: map[int64]bool{}, + + invites: map[int64]map[int64]tdclient.Invite{}, + inviteDelete: map[int64]bool{}, } return f, httptest.NewServer(f) } @@ -265,7 +282,26 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ f.escalation[id] = req w.WriteHeader(http.StatusNoContent) - case rest == "/deadman/switches" && r.Method == http.MethodGet: + case rest == deadmanSwitchesPath || strings.HasPrefix(rest, deadmanSwitchesPath+"/"): + f.handleDeadmanSubPath(w, r, id, rest) + + case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"): + f.handleIntegrationSubPath(w, r, id, rest) + + case rest == "/invites" || strings.HasPrefix(rest, "/invites/"): + f.handleInviteSubPath(w, r, id, rest) + + default: + w.WriteHeader(http.StatusNotFound) + } +} + +// handleDeadmanSubPath answers GET/POST /api/teams/{id}/deadman/switches and +// PUT/DELETE .../deadman/switches/{switchID} -- split out of +// handleTeamSubPath for the same gocyclo reason as handleIntegrationSubPath. +func (f *fakeTerdutServer) handleDeadmanSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) { + switch { + case rest == deadmanSwitchesPath && r.Method == http.MethodGet: existing := f.switches[id] out := make([]tdclient.DeadmanSwitch, 0, len(existing)) for _, s := range existing { @@ -273,7 +309,7 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ } writeJSON(w, http.StatusOK, out) - case rest == "/deadman/switches" && r.Method == http.MethodPost: + case rest == deadmanSwitchesPath && r.Method == http.MethodPost: var req deadmanSwitchFakeRequest _ = json.NewDecoder(r.Body).Decode(&req) f.nextSwitchID++ @@ -328,8 +364,48 @@ func (f *fakeTerdutServer) handleTeamSubPath(w http.ResponseWriter, r *http.Requ f.switchDelete[switchID] = true w.WriteHeader(http.StatusNoContent) - case rest == "/integrations" || strings.HasPrefix(rest, "/integrations/"): - f.handleIntegrationSubPath(w, r, id, rest) + default: + w.WriteHeader(http.StatusNotFound) + } +} + +// handleInviteSubPath answers POST /api/teams/{id}/invites and +// DELETE .../invites/{inviteID} -- split out for the same gocyclo reason as +// handleIntegrationSubPath. +func (f *fakeTerdutServer) handleInviteSubPath(w http.ResponseWriter, r *http.Request, id int64, rest string) { + switch { + case rest == "/invites" && r.Method == http.MethodPost: + var req struct { + Role string `json:"role"` + MaxUses int64 `json:"max_uses"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + f.nextInviteID++ + inviteID := f.nextInviteID + inv := tdclient.Invite{ + ID: inviteID, TeamID: id, Role: req.Role, MaxUses: req.MaxUses, + ExpiresAt: time.Now().Add(7 * 24 * time.Hour), + URL: fmt.Sprintf("https://terdut.example.invalid/signup?invite=invite-token-%d", inviteID), + } + if f.invites[id] == nil { + f.invites[id] = map[int64]tdclient.Invite{} + } + f.invites[id][inviteID] = inv + writeJSON(w, http.StatusCreated, inv) + + case strings.HasPrefix(rest, "/invites/") && r.Method == http.MethodDelete: + inviteID, ok := parseTrailingID(rest, "/invites/") + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + if _, exists := f.invites[id][inviteID]; !exists { + w.WriteHeader(http.StatusNotFound) + return + } + delete(f.invites[id], inviteID) + f.inviteDelete[inviteID] = true + w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusNotFound) diff --git a/internal/controller/terdutteam_controller.go b/internal/controller/terdutteam_controller.go index c5010e8..3f87373 100644 --- a/internal/controller/terdutteam_controller.go +++ b/internal/controller/terdutteam_controller.go @@ -131,6 +131,9 @@ func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) if err := teamClient.SetTeamOIDCGroups(ctx, team.Status.TeamID, team.Spec.OIDC.MemberGroup, team.Spec.OIDC.OwnerGroup); err != nil { return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d/oidc-groups: %w", team.Status.TeamID, err) } + if err := r.reconcileInvite(ctx, &team, teamClient); err != nil { + return ctrl.Result{}, err + } meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{ Type: terdutv1alpha1.ConditionReady, diff --git a/internal/controller/terdutteam_controller_test.go b/internal/controller/terdutteam_controller_test.go index faf5dbd..cd99da2 100644 --- a/internal/controller/terdutteam_controller_test.go +++ b/internal/controller/terdutteam_controller_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http/httptest" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -251,6 +252,88 @@ var _ = Describe("TerdutTeam Controller", func() { }) }) + Describe("spec.invite", func() { + It("mints a link into the TerdutTeam's own namespace, not the operator's", func(ctx SpecContext) { + createTeam(ctx, "platform", sameNSRef()) + reconcileOnce(ctx) // finalizer + reconcileOnce(ctx) // create+mint+apply + + team := &terdutv1alpha1.TerdutTeam{} + Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed()) + team.Spec.Invite.Enabled = true + Expect(k8sClient.Update(ctx, team)).To(Succeed()) + reconcileOnce(ctx) + + Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed()) + Expect(team.Status.InviteSecretRef).NotTo(BeNil()) + + var secret corev1.Secret + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: team.Status.InviteSecretRef.Name, Namespace: team.Namespace, + }, &secret)).To(Succeed()) + Expect(string(secret.Data[inviteSecretURLKey])).To(ContainSubstring("invite=")) + Expect(string(secret.Data[inviteSecretInviteIDKey])).To(Equal("1")) + + inv := fake.invites[team.Status.TeamID][1] + Expect(inv.Role).To(Equal("member"), "default role") + Expect(inv.MaxUses).To(Equal(int64(1)), "default max uses") + }) + + It("refreshes a link that's within a day of terdut-server's 7-day TTL", func(ctx SpecContext) { + createTeam(ctx, "platform", sameNSRef()) + reconcileOnce(ctx) + reconcileOnce(ctx) + + team := &terdutv1alpha1.TerdutTeam{} + Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed()) + team.Spec.Invite.Enabled = true + Expect(k8sClient.Update(ctx, team)).To(Succeed()) + reconcileOnce(ctx) + + Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed()) + firstSecretName := team.Status.InviteSecretRef.Name + var secret corev1.Secret + Expect(k8sClient.Get(ctx, types.NamespacedName{Name: firstSecretName, Namespace: team.Namespace}, &secret)).To(Succeed()) + + // Simulate the stored link being within the refresh window of + // expiry, the way it genuinely would be six days from now, + // without the test waiting six days. + secret.Data[inviteSecretExpiresAtKey] = []byte(time.Now().Add(12 * time.Hour).Format(time.RFC3339)) // inside inviteRefreshWindow + Expect(k8sClient.Update(ctx, &secret)).To(Succeed()) + + reconcileOnce(ctx) + + Expect(fake.inviteDelete[1]).To(BeTrue(), "the stale invite should have been revoked") + Expect(fake.invites[team.Status.TeamID]).To(HaveKey(int64(2)), "a replacement should have been minted") + }) + + It("revokes the invite when spec.invite.enabled flips back to false", func(ctx SpecContext) { + createTeam(ctx, "platform", sameNSRef()) + reconcileOnce(ctx) + reconcileOnce(ctx) + + team := &terdutv1alpha1.TerdutTeam{} + Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed()) + team.Spec.Invite.Enabled = true + Expect(k8sClient.Update(ctx, team)).To(Succeed()) + reconcileOnce(ctx) + + Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed()) + secretName := team.Status.InviteSecretRef.Name + team.Spec.Invite.Enabled = false + Expect(k8sClient.Update(ctx, team)).To(Succeed()) + reconcileOnce(ctx) + + Expect(fake.inviteDelete[1]).To(BeTrue()) + Expect(k8sClient.Get(ctx, teamKey, team)).To(Succeed()) + Expect(team.Status.InviteSecretRef).To(BeNil()) + + var secret corev1.Secret + err := k8sClient.Get(ctx, types.NamespacedName{Name: secretName, Namespace: team.Namespace}, &secret) + Expect(err).To(HaveOccurred(), "the invite Secret should have been deleted") + }) + }) + Describe("deletion", func() { It("deletes the team server-side and removes the credentials Secret", func(ctx SpecContext) { createTeam(ctx, "to-delete", sameNSRef()) diff --git a/internal/controller/terdutteam_invite.go b/internal/controller/terdutteam_invite.go new file mode 100644 index 0000000..be80be0 --- /dev/null +++ b/internal/controller/terdutteam_invite.go @@ -0,0 +1,149 @@ +package controller + +import ( + "context" + "fmt" + "strconv" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1" + "git.ryuvia.com/niklas/terdut-operator/internal/tdclient" +) + +// Data keys inside the generated invite Secret, following the same naming +// shape as TerdutAlertSource's webhookSecret*Key constants. +const ( + inviteSecretURLKey = "url" + inviteSecretInviteIDKey = "inviteID" + inviteSecretExpiresAtKey = "expiresAt" +) + +// inviteRefreshWindow is how far ahead of expiry this controller mints a +// replacement link, so a human reading status.inviteSecretRef never finds a +// dead link mid-use. terdut-server's invite TTL is a fixed, unconfigurable +// 7 days (internal/api/signup.go's inviteTTL) -- refreshing a full day +// ahead of that leaves comfortable margin against this controller's own +// 5-minute resync interval ever being delayed. +const inviteRefreshWindow = 24 * time.Hour + +func inviteSecretName(team *terdutv1alpha1.TerdutTeam) string { + return team.Name + "-terdut-invite" +} + +// reconcileInvite applies spec.invite against teamClient -- this team's own +// team-scoped credential, already owner-equivalent for every /invites route +// (terdut-server's SERVICE-ACCOUNTS.md, ratified not accidental). Mints, +// refreshes ahead of expiry, or revokes, entirely independent of this +// team's own Ready condition: an invite is a convenience for onboarding a +// human, never something anything else in this reconcile waits on. +func (r *TerdutTeamReconciler) reconcileInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error { + if !team.Spec.Invite.Enabled { + return r.revokeInvite(ctx, team, teamClient) + } + + secretName := inviteSecretName(team) + var secret corev1.Secret + err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: secretName}, &secret) + switch { + case err == nil: + expiresAt, parseErr := time.Parse(time.RFC3339, string(secret.Data[inviteSecretExpiresAtKey])) + if parseErr == nil && time.Until(expiresAt) > inviteRefreshWindow { + team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName} + return nil // still fresh, nothing to do this reconcile + } + // Expired, about to expire, or unreadable: mint a replacement. + // Revoke the old row by id first (best-effort) so a leaked old link + // stops working immediately rather than lingering unrevoked until + // its own TTL -- failure here is not fatal, since the replacement + // below is what actually matters. + if oldID, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil { + _ = teamClient.RevokeInvite(ctx, team.Status.TeamID, oldID) + } + return r.mintInvite(ctx, team, teamClient, secretName) + case apierrors.IsNotFound(err): + // Low stakes, unlike TerdutAlertSource's webhook URL: nothing + // external holds a durable dependency on one specific invite link + // staying stable the way an Alertmanager config depends on a + // webhook URL -- it's read once by one human and handed out. So + // this silently re-mints rather than failing closed the way + // TerdutAlertSource's ReasonWebhookSecretLost does for its Secret. + return r.mintInvite(ctx, team, teamClient, secretName) + default: + return err + } +} + +func (r *TerdutTeamReconciler) mintInvite( + ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client, secretName string, +) error { + role := team.Spec.Invite.Role + if role == "" { + role = "member" + } + maxUses := team.Spec.Invite.MaxUses + if maxUses == 0 { + maxUses = 1 + } + inv, err := teamClient.CreateInvite(ctx, team.Status.TeamID, role, maxUses) + if err != nil { + return fmt.Errorf("POST /api/teams/%d/invites: %w", team.Status.TeamID, err) + } + + secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: team.Namespace}} + if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, secret, func() error { + secret.Data = map[string][]byte{ + inviteSecretURLKey: []byte(inv.URL), + inviteSecretInviteIDKey: []byte(strconv.FormatInt(inv.ID, 10)), + inviteSecretExpiresAtKey: []byte(inv.ExpiresAt.Format(time.RFC3339)), + } + return controllerutil.SetControllerReference(team, secret, r.Scheme) + }); err != nil { + return err + } + team.Status.InviteSecretRef = &terdutv1alpha1.LocalSecretRef{Name: secretName} + if r.Recorder != nil { + r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteMinted, terdutv1alpha1.ReasonInviteMinted, + "invite link minted into Secret %q", secretName) + } + return nil +} + +// revokeInvite tears down spec.invite's Secret and server-side row when +// spec.invite.enabled is false (or was never set). Same-namespace and +// OwnerReference'd, so deleting the TerdutTeam itself already garbage- +// collects this Secret -- this path exists for the narrower case of +// flipping enabled back to false on an otherwise-live TerdutTeam. +func (r *TerdutTeamReconciler) revokeInvite(ctx context.Context, team *terdutv1alpha1.TerdutTeam, teamClient *tdclient.Client) error { + if team.Status.InviteSecretRef == nil { + return nil + } + name := team.Status.InviteSecretRef.Name + var secret corev1.Secret + err := r.Get(ctx, client.ObjectKey{Namespace: team.Namespace, Name: name}, &secret) + switch { + case err == nil: + if id, idErr := strconv.ParseInt(string(secret.Data[inviteSecretInviteIDKey]), 10, 64); idErr == nil { + if err := teamClient.RevokeInvite(ctx, team.Status.TeamID, id); err != nil { + return fmt.Errorf("DELETE /api/teams/%d/invites/%d: %w", team.Status.TeamID, id, err) + } + } + if err := r.Delete(ctx, &secret); err != nil && !apierrors.IsNotFound(err) { + return err + } + case !apierrors.IsNotFound(err): + return err + } + + team.Status.InviteSecretRef = nil + if r.Recorder != nil { + r.Recorder.Eventf(team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonInviteRevoked, terdutv1alpha1.ReasonInviteRevoked, + "invite link revoked") + } + return nil +} diff --git a/internal/tdclient/client.go b/internal/tdclient/client.go index 56d81ae..6cdf0ff 100644 --- a/internal/tdclient/client.go +++ b/internal/tdclient/client.go @@ -566,3 +566,50 @@ func (c *Client) DeleteIntegration(ctx context.Context, teamID, integrationID in } return c.do(req, nil) } + +// Invite is a standing link into a team (POST /api/teams/{teamID}/invites' +// own response shape). URL carries the raw token exactly once, at creation +// -- terdut-server never shows it again (same one-time-shown shape as an +// integration's webhook key) -- so a caller that needs it later has to have +// kept this response, not re-fetched it. +type Invite struct { + ID int64 `json:"id"` + TeamID int64 `json:"team_id"` + Role string `json:"role"` + ExpiresAt time.Time `json:"expires_at"` + MaxUses int64 `json:"max_uses"` + URL string `json:"url,omitempty"` +} + +// CreateInvite calls POST /api/teams/{teamID}/invites -- owner-gated +// (requireTeamOwner), so c must hold this team's own team-scoped +// credential, which already satisfies that check via its synthetic owner +// membership (terdut-server's SERVICE-ACCOUNTS.md). No conflict handling +// needed: unlike a team or a service account, an invite has no unique name +// to collide on -- every call mints a brand new row. +func (c *Client) CreateInvite(ctx context.Context, teamID int64, role string, maxUses int64) (*Invite, error) { + req, err := c.newRequest(ctx, http.MethodPost, fmt.Sprintf("/api/teams/%d/invites", teamID), + map[string]any{"role": role, "max_uses": maxUses}) + if err != nil { + return nil, err + } + var inv Invite + if err := c.do(req, &inv); err != nil { + return nil, err + } + return &inv, nil +} + +// RevokeInvite calls DELETE /api/teams/{teamID}/invites/{inviteID} -- same +// credential requirement as CreateInvite. A 404 (already revoked, or never +// existed) is the caller's to treat as success if it wants to, the same way +// DeleteTeam's own 404 handling works -- this method itself just reports +// whatever terdut-server said. +func (c *Client) RevokeInvite(ctx context.Context, teamID, inviteID int64) error { + req, err := c.newRequest(ctx, http.MethodDelete, + fmt.Sprintf("/api/teams/%d/invites/%d", teamID, inviteID), nil) + if err != nil { + return err + } + return c.do(req, nil) +} -- 2.52.0 From 4aa4f17c42efab36b0d3b2be9af84a4c7495ba91 Mon Sep 17 00:00:00 2001 From: Niklas Ye Date: Fri, 2 Oct 2026 22:13:26 +0200 Subject: [PATCH 2/2] examples/demo: bump terdut-server to v0.34.0 (the service-account fix) Required for this demo to actually exercise the fix for niklas/terdut-operator#3 -- v0.33.2 still has the authorization gap this demo hit live (callerMayManageServiceAccount had no branch letting an instance-scoped account adopt a team-scoped account's key). --- examples/demo/01-server.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/demo/01-server.yaml b/examples/demo/01-server.yaml index b7df709..901784d 100644 --- a/examples/demo/01-server.yaml +++ b/examples/demo/01-server.yaml @@ -14,7 +14,12 @@ metadata: spec: image: repository: git.ryuvia.com/niklas/terdut-server - tag: v0.33.2 + # v0.34.0: fixes callerMayManageServiceAccount so an instance-scoped + # service account can adopt/rotate a key on a team-scoped account it + # didn't just create in the same call -- without this, terdutteam-* + # can wedge permanently on exactly the crash-window race this demo + # hit live (niklas/terdut-operator#3). + tag: v0.34.0 replicas: 1 networking: hostname: terdut-operator-demo.example -- 2.52.0