a0ea13955e
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.
150 lines
6.0 KiB
Go
150 lines
6.0 KiB
Go
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
|
|
}
|