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:
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user