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:
@@ -27,6 +27,42 @@ type TerdutTeamOIDC struct {
|
||||
OwnerGroup string `json:"ownerGroup,omitempty"`
|
||||
}
|
||||
|
||||
// TerdutTeamInvite requests a standing invite link into this team, minted
|
||||
// with the team's own team-scoped credential — requireTeamOwner already
|
||||
// treats that credential as owner-equivalent for every /invites route
|
||||
// (ratified, not a gap, as of terdut-server's SERVICE-ACCOUNTS.md). This is
|
||||
// the real answer to "how does a human ever get a first login on a
|
||||
// password-only, operator-managed install" (terdut-server#23): no signup_mode
|
||||
// flip, no admin token, just a link redeemed the same way anyone else's
|
||||
// invite would be.
|
||||
type TerdutTeamInvite struct {
|
||||
// enabled mints (and keeps refreshed ahead of terdut-server's own fixed
|
||||
// 7-day TTL) an invite link while true. Flipping it back to false
|
||||
// revokes the current one server-side rather than leaving it to expire
|
||||
// on its own.
|
||||
// +optional
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
|
||||
// role is what the invite grants: member or owner. Defaults to member —
|
||||
// owner by default would make every invite link a standing
|
||||
// administrative credential for the team, a much bigger blast radius
|
||||
// than "let a human see the queue".
|
||||
// +optional
|
||||
// +kubebuilder:validation:Enum=member;owner
|
||||
// +kubebuilder:default=member
|
||||
Role string `json:"role,omitempty"`
|
||||
|
||||
// maxUses bounds how many times this link may be redeemed before it
|
||||
// stops working, mirroring terdut-server's own 1-100 range
|
||||
// (POST /api/teams/{teamID}/invites). Defaults to 1: a link meant for
|
||||
// one specific person, not a standing door.
|
||||
// +optional
|
||||
// +kubebuilder:validation:Minimum=1
|
||||
// +kubebuilder:validation:Maximum=100
|
||||
// +kubebuilder:default=1
|
||||
MaxUses int64 `json:"maxUses,omitempty"`
|
||||
}
|
||||
|
||||
// TerdutTeamSpec defines the desired state of TerdutTeam.
|
||||
type TerdutTeamSpec struct {
|
||||
// serverRef names the TerdutServer this team belongs to.
|
||||
@@ -43,6 +79,9 @@ type TerdutTeamSpec struct {
|
||||
|
||||
// +optional
|
||||
OIDC TerdutTeamOIDC `json:"oidc,omitempty"`
|
||||
|
||||
// +optional
|
||||
Invite TerdutTeamInvite `json:"invite,omitempty"`
|
||||
}
|
||||
|
||||
// Condition reasons this controller sets.
|
||||
@@ -62,6 +101,19 @@ const (
|
||||
ReasonTeamAdopted = "Adopted"
|
||||
)
|
||||
|
||||
// Condition reasons for spec.invite reconciliation (TerdutTeamInvite). Not
|
||||
// surfaced on the Ready condition itself — an invite is a convenience, not
|
||||
// a dependency anything else in this team's own readiness waits on — but
|
||||
// recorded as Events and readable via `kubectl describe`.
|
||||
const (
|
||||
// ReasonInviteMinted: spec.invite.enabled is true and status.inviteSecretRef
|
||||
// is populated and live.
|
||||
ReasonInviteMinted = "InviteMinted"
|
||||
// ReasonInviteRevoked: spec.invite.enabled flipped back to false and the
|
||||
// server-side invite was revoked (or there was nothing to revoke).
|
||||
ReasonInviteRevoked = "InviteRevoked"
|
||||
)
|
||||
|
||||
// TerdutTeamStatus defines the observed state of TerdutTeam.
|
||||
type TerdutTeamStatus struct {
|
||||
// +listType=map
|
||||
@@ -87,6 +139,17 @@ type TerdutTeamStatus struct {
|
||||
// +optional
|
||||
ServerEndpoint string `json:"serverEndpoint,omitempty"`
|
||||
|
||||
// inviteSecretRef is this team's current invite link, if spec.invite.enabled.
|
||||
// Unlike credentialsSecretRef, this lives in the TerdutTeam's OWN
|
||||
// namespace, not the operator's: an invite is bounded, limited-use, and
|
||||
// meant for this namespace's own human operators to read and hand out,
|
||||
// not a durable high-privilege credential — same shape as
|
||||
// TerdutAlertSource's status.webhookURLSecretRef, not TerdutServer's
|
||||
// cross-namespace credentialsSecretRef. Nil whenever spec.invite.enabled
|
||||
// is false or unset.
|
||||
// +optional
|
||||
InviteSecretRef *LocalSecretRef `json:"inviteSecretRef,omitempty"`
|
||||
|
||||
// +optional
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
}
|
||||
|
||||
@@ -834,6 +834,21 @@ func (in *TerdutTeam) DeepCopyObject() runtime.Object {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutTeamInvite) DeepCopyInto(out *TerdutTeamInvite) {
|
||||
*out = *in
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamInvite.
|
||||
func (in *TerdutTeamInvite) DeepCopy() *TerdutTeamInvite {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(TerdutTeamInvite)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *TerdutTeamList) DeepCopyInto(out *TerdutTeamList) {
|
||||
*out = *in
|
||||
@@ -901,6 +916,7 @@ func (in *TerdutTeamSpec) DeepCopyInto(out *TerdutTeamSpec) {
|
||||
*out = *in
|
||||
out.ServerRef = in.ServerRef
|
||||
out.OIDC = in.OIDC
|
||||
out.Invite = in.Invite
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamSpec.
|
||||
@@ -928,6 +944,11 @@ func (in *TerdutTeamStatus) DeepCopyInto(out *TerdutTeamStatus) {
|
||||
*out = new(SecretKeyRef)
|
||||
**out = **in
|
||||
}
|
||||
if in.InviteSecretRef != nil {
|
||||
in, out := &in.InviteSecretRef, &out.InviteSecretRef
|
||||
*out = new(LocalSecretRef)
|
||||
**out = **in
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerdutTeamStatus.
|
||||
|
||||
Reference in New Issue
Block a user