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