Files
terdut-operator/internal/controller/terdutteam_controller.go
T
Niklas Ye a0ea13955e
CI / chart (pull_request) Successful in 1s
CI / security (pull_request) Successful in 52s
CI / test (pull_request) Successful in 2m43s
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.
2026-10-02 22:04:56 +02:00

321 lines
12 KiB
Go

package controller
import (
"context"
"errors"
"fmt"
"net/http"
"time"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/recorder"
terdutv1alpha1 "git.ryuvia.com/niklas/terdut-operator/api/v1alpha1"
"git.ryuvia.com/niklas/terdut-operator/internal/tdclient"
)
// teamFinalizerName cleans up the team-scoped credentials Secret this
// controller generates in the operator's own namespace on delete.
const teamFinalizerName = "terdut.ryuvia.com/terdutteam"
// TerdutTeamReconciler reconciles a TerdutTeam object.
//
// Every child resolves its own teamRef/serverRef independently and never
// chains up through another controller (DESIGN.md §5) — this one talks
// directly to the TerdutServer it references and to terdut-server's API,
// never to TerdutServerReconciler.
type TerdutTeamReconciler struct {
client.Client
Scheme *runtime.Scheme
// OperatorNamespace is where every credentials Secret this controller
// reads (the referenced TerdutServer's) or writes (this team's own)
// lives (DESIGN.md §6) — never a TerdutTeam's or TerdutServer's own
// namespace.
OperatorNamespace string
Recorder recorder.EventRecorder
NewClient func(endpoint string) *tdclient.Client
}
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutteams/finalizers,verbs=update
// +kubebuilder:rbac:groups=terdut.ryuvia.com,resources=terdutservers,verbs=get;list;watch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
// +kubebuilder:rbac:groups=events.k8s.io,resources=events,verbs=create;patch
func (r *TerdutTeamReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := logf.FromContext(ctx)
var team terdutv1alpha1.TerdutTeam
if err := r.Get(ctx, req.NamespacedName, &team); err != nil {
if apierrors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}
if !team.DeletionTimestamp.IsZero() {
return r.reconcileTeamDelete(ctx, &team)
}
if !controllerutil.ContainsFinalizer(&team, teamFinalizerName) {
controllerutil.AddFinalizer(&team, teamFinalizerName)
if err := r.Update(ctx, &team); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
srv, resolveErr := r.resolveServerRef(ctx, &team)
if resolveErr != nil {
return r.setTeamNotReady(ctx, &team, resolveErr.reason, resolveErr.message, waitInterval)
}
if srv.Status.CredentialsSecretRef == nil {
return r.setTeamNotReady(ctx, &team, terdutv1alpha1.ReasonWaitingForServer,
fmt.Sprintf("TerdutServer %q is not Bootstrapped yet", srv.Name), waitInterval)
}
newClient := r.NewClient
if newClient == nil {
newClient = tdclient.New
}
instanceKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, srv.Status.CredentialsSecretRef)
if err != nil {
return ctrl.Result{}, err
}
endpoint := serviceURL(srv)
team.Status.ServerEndpoint = endpoint
instanceClient := newClient(endpoint).WithToken(instanceKey)
if team.Status.TeamID == 0 {
if err := r.createOrAdoptTeam(ctx, &team, instanceClient); err != nil {
return ctrl.Result{}, err
}
}
if team.Status.CredentialsSecretRef == nil {
if err := r.mintTeamCredential(ctx, &team, instanceClient); err != nil {
return ctrl.Result{}, err
}
}
teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef)
if err != nil {
return ctrl.Result{}, err
}
teamClient := newClient(endpoint).WithToken(teamKey)
// Owner-gated on terdut-server, so this always runs with the
// team-scoped credential just minted above, never the instance-scoped
// one used to create the team (internal/api/teams.go's requireTeamOwner
// has no branch for an instance-scoped service account, confirmed
// against source). Applied unconditionally rather than diffed against a
// stored "last-applied" value: both calls are idempotent PUTs of the
// whole resource, the same "cheap because it's small" reasoning §5
// already applies to the escalation policy's whole-policy PUT.
if err := teamClient.RenameTeam(ctx, team.Status.TeamID, team.Spec.DisplayName); err != nil {
return ctrl.Result{}, fmt.Errorf("PUT /api/teams/%d: %w", team.Status.TeamID, err)
}
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,
Status: metav1.ConditionTrue,
Reason: terdutv1alpha1.ReasonTeamAdopted,
Message: fmt.Sprintf("team %d ready, credentials in Secret %q", team.Status.TeamID, team.Status.CredentialsSecretRef.Name),
})
team.Status.ObservedGeneration = team.Generation
if err := r.Status().Update(ctx, &team); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(&team, nil, corev1.EventTypeNormal, terdutv1alpha1.ReasonTeamAdopted, terdutv1alpha1.ReasonTeamAdopted,
"team ready")
}
log.Info("TerdutTeam ready", "name", team.Name, "teamID", team.Status.TeamID)
return ctrl.Result{RequeueAfter: resyncInterval}, nil
}
// teamError carries a condition reason/message, the same role databaseError
// plays for TerdutServer: an expected, requeue-and-retry outcome, not a
// reconcile failure.
type teamError struct {
reason string
message string
}
func (e *teamError) Error() string { return e.message }
// resolveServerRef resolves spec.serverRef, including the cross-namespace
// consent check (DESIGN.md §4.6) when serverRef.namespace differs from this
// TerdutTeam's own.
func (r *TerdutTeamReconciler) resolveServerRef(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (*terdutv1alpha1.TerdutServer, *teamError) {
ns := team.Spec.ServerRef.Namespace
if ns == "" {
ns = team.Namespace
}
var srv terdutv1alpha1.TerdutServer
if err := r.Get(ctx, client.ObjectKey{Namespace: ns, Name: team.Spec.ServerRef.Name}, &srv); err != nil {
if apierrors.IsNotFound(err) {
return nil, &teamError{
reason: terdutv1alpha1.ReasonServerRefNotFound,
message: fmt.Sprintf("TerdutServer %q not found in namespace %q", team.Spec.ServerRef.Name, ns),
}
}
return nil, &teamError{reason: terdutv1alpha1.ReasonServerRefNotFound, message: err.Error()}
}
if ns == team.Namespace {
return &srv, nil
}
var ownNamespace corev1.Namespace
if err := r.Get(ctx, client.ObjectKey{Name: team.Namespace}, &ownNamespace); err != nil {
return nil, &teamError{
reason: terdutv1alpha1.ReasonRefNotPermitted,
message: fmt.Sprintf("could not read this TerdutTeam's own namespace %q labels: %v", team.Namespace, err),
}
}
permitted, err := allowedTeamsPermits(srv.Spec.AllowedTeams.Namespaces, ownNamespace.Labels)
if err != nil {
return nil, &teamError{reason: terdutv1alpha1.ReasonRefNotPermitted, message: err.Error()}
}
if !permitted {
return nil, &teamError{
reason: terdutv1alpha1.ReasonRefNotPermitted,
message: fmt.Sprintf("TerdutServer %q/%q's spec.allowedTeams does not admit namespace %q",
ns, team.Spec.ServerRef.Name, team.Namespace),
}
}
return &srv, nil
}
func (r *TerdutTeamReconciler) setTeamNotReady(
ctx context.Context, team *terdutv1alpha1.TerdutTeam, reason, message string, d time.Duration,
) (ctrl.Result, error) {
meta.SetStatusCondition(&team.Status.Conditions, metav1.Condition{
Type: terdutv1alpha1.ConditionReady,
Status: metav1.ConditionFalse,
Reason: reason,
Message: message,
})
team.Status.ObservedGeneration = team.Generation
if err := r.Status().Update(ctx, team); err != nil {
return ctrl.Result{}, err
}
if r.Recorder != nil {
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, reason, reason, message)
}
return ctrl.Result{RequeueAfter: d}, nil
}
// teamCredentialsSecretName/teamServiceAccountName follow the same
// <namespace>.<name>-suffix convention TerdutServer's secrets use
// (DESIGN.md §6 point 3), keyed on the TerdutTeam CR's own identity rather
// than its (mutable) displayName -- a service account's own name is
// globally unique across the whole install (internal/db/migrations/
// 014_service_accounts.sql's UNIQUE constraint, confirmed against source),
// so this has to be collision-safe the same way the credentials Secret
// names already are.
func teamCredentialsSecretName(team *terdutv1alpha1.TerdutTeam) string {
return fmt.Sprintf("%s.%s-team-credentials", team.Namespace, team.Name)
}
func teamServiceAccountName(team *terdutv1alpha1.TerdutTeam) string {
return fmt.Sprintf("terdut-team.%s.%s", team.Namespace, team.Name)
}
// reconcileTeamDelete cleans up the team-scoped credentials Secret and, if
// one was ever minted, deletes the team server-side first. If
// status.teamID or status.credentialsSecretRef was never set (the CR was
// deleted before reconciliation ever got that far), there is deliberately
// no attempt to clean up server-side: terdut-server's DELETE /api/teams/
// {teamID} is owner-gated (requireTeamOwner), and an instance-scoped
// credential -- the only one this controller would otherwise hold -- does
// not satisfy that check (confirmed against source, same finding as
// TEAM-LOOKUP.md's). A team created but never fully reconciled to Ready is
// left orphaned server-side for a human with real owner/admin access to
// clean up -- a known, documented limitation, not a silent gap.
func (r *TerdutTeamReconciler) reconcileTeamDelete(ctx context.Context, team *terdutv1alpha1.TerdutTeam) (ctrl.Result, error) {
if !controllerutil.ContainsFinalizer(team, teamFinalizerName) {
return ctrl.Result{}, nil
}
if team.Status.TeamID != 0 && team.Status.CredentialsSecretRef != nil {
if err := r.deleteTeamServerSide(ctx, team); err != nil {
if r.Recorder != nil {
r.Recorder.Eventf(team, nil, corev1.EventTypeWarning, "DeleteFailed", "DeleteFailed", err.Error())
}
return ctrl.Result{}, err
}
}
secret := &corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: teamCredentialsSecretName(team), Namespace: r.OperatorNamespace}}
if err := r.Delete(ctx, secret); err != nil && !apierrors.IsNotFound(err) {
return ctrl.Result{}, err
}
controllerutil.RemoveFinalizer(team, teamFinalizerName)
return ctrl.Result{}, r.Update(ctx, team)
}
func (r *TerdutTeamReconciler) deleteTeamServerSide(ctx context.Context, team *terdutv1alpha1.TerdutTeam) error {
srv, resolveErr := r.resolveServerRef(ctx, team)
if resolveErr != nil {
// The TerdutServer (or the namespace consent for it) is gone too --
// most likely the whole install is being torn down together.
// Nothing to delete against; proceed rather than block forever on
// a parent that no longer exists.
return nil
}
teamKey, err := readOperatorSecret(ctx, r.Client, r.OperatorNamespace, team.Status.CredentialsSecretRef)
if err != nil {
if apierrors.IsNotFound(err) {
return nil
}
return err
}
newClient := r.NewClient
if newClient == nil {
newClient = tdclient.New
}
err = newClient(serviceURL(srv)).WithToken(teamKey).DeleteTeam(ctx, team.Status.TeamID)
if statusErr, ok := errors.AsType[*tdclient.StatusError](err); ok && statusErr.Code == http.StatusNotFound {
return nil
}
return err
}
// SetupWithManager sets up the controller with the Manager.
func (r *TerdutTeamReconciler) SetupWithManager(mgr ctrl.Manager) error {
if r.NewClient == nil {
r.NewClient = tdclient.New
}
if r.Recorder == nil {
r.Recorder = mgr.GetEventRecorder("terdutteam-controller")
}
return ctrl.NewControllerManagedBy(mgr).
For(&terdutv1alpha1.TerdutTeam{}).
Named("terdutteam").
Complete(r)
}