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