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.
254 lines
11 KiB
YAML
254 lines
11 KiB
YAML
---
|
|
apiVersion: apiextensions.k8s.io/v1
|
|
kind: CustomResourceDefinition
|
|
metadata:
|
|
annotations:
|
|
controller-gen.kubebuilder.io/version: v0.22.0
|
|
name: terdutteams.terdut.ryuvia.com
|
|
spec:
|
|
group: terdut.ryuvia.com
|
|
names:
|
|
kind: TerdutTeam
|
|
listKind: TerdutTeamList
|
|
plural: terdutteams
|
|
singular: terdutteam
|
|
scope: Namespaced
|
|
versions:
|
|
- additionalPrinterColumns:
|
|
- jsonPath: .spec.serverRef.name
|
|
name: Server
|
|
type: string
|
|
- jsonPath: .status.teamID
|
|
name: TeamID
|
|
type: integer
|
|
- jsonPath: .status.conditions[?(@.type=="Ready")].status
|
|
name: Ready
|
|
type: string
|
|
- jsonPath: .status.conditions[?(@.type=="Ready")].reason
|
|
name: Reason
|
|
type: string
|
|
name: v1alpha1
|
|
schema:
|
|
openAPIV3Schema:
|
|
description: TerdutTeam is the Schema for the terdutteams API
|
|
properties:
|
|
apiVersion:
|
|
description: |-
|
|
APIVersion defines the versioned schema of this representation of an object.
|
|
Servers should convert recognized schemas to the latest internal value, and
|
|
may reject unrecognized values.
|
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
|
type: string
|
|
kind:
|
|
description: |-
|
|
Kind is a string value representing the REST resource this object represents.
|
|
Servers may infer this from the endpoint the client submits requests to.
|
|
Cannot be updated.
|
|
In CamelCase.
|
|
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
|
type: string
|
|
metadata:
|
|
type: object
|
|
spec:
|
|
description: spec defines the desired state of TerdutTeam
|
|
properties:
|
|
displayName:
|
|
description: |-
|
|
displayName is this team's name, both in terdut-server's own data
|
|
(POST /api/teams {"name": ...}) and as the identity POST /api/teams
|
|
and GET /api/teams?name= correlate on (DESIGN.md §5's idempotent-
|
|
create rule, via TEAM-LOOKUP.md).
|
|
minLength: 1
|
|
type: string
|
|
invite:
|
|
description: |-
|
|
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.
|
|
properties:
|
|
enabled:
|
|
description: |-
|
|
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.
|
|
type: boolean
|
|
maxUses:
|
|
default: 1
|
|
description: |-
|
|
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.
|
|
format: int64
|
|
maximum: 100
|
|
minimum: 1
|
|
type: integer
|
|
role:
|
|
default: member
|
|
description: |-
|
|
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".
|
|
enum:
|
|
- member
|
|
- owner
|
|
type: string
|
|
type: object
|
|
oidc:
|
|
description: |-
|
|
TerdutTeamOIDC binds which identity-provider groups grant membership and
|
|
ownership of this team (DESIGN.md §4.2). Both empty means no group grants
|
|
either role here — matches terdut-server's own NULLIF-on-empty-string
|
|
handling (internal/api/oidc_teams.go).
|
|
properties:
|
|
memberGroup:
|
|
type: string
|
|
ownerGroup:
|
|
type: string
|
|
type: object
|
|
serverRef:
|
|
description: serverRef names the TerdutServer this team belongs to.
|
|
properties:
|
|
name:
|
|
minLength: 1
|
|
type: string
|
|
namespace:
|
|
type: string
|
|
required:
|
|
- name
|
|
type: object
|
|
required:
|
|
- displayName
|
|
- serverRef
|
|
type: object
|
|
status:
|
|
description: status defines the observed state of TerdutTeam
|
|
properties:
|
|
conditions:
|
|
items:
|
|
description: Condition contains details for one aspect of the current
|
|
state of this API Resource.
|
|
properties:
|
|
lastTransitionTime:
|
|
description: |-
|
|
lastTransitionTime is the last time the condition transitioned from one status to another.
|
|
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
|
format: date-time
|
|
type: string
|
|
message:
|
|
description: |-
|
|
message is a human readable message indicating details about the transition.
|
|
This may be an empty string.
|
|
maxLength: 32768
|
|
type: string
|
|
observedGeneration:
|
|
description: |-
|
|
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
|
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
|
with respect to the current state of the instance.
|
|
format: int64
|
|
minimum: 0
|
|
type: integer
|
|
reason:
|
|
description: |-
|
|
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
|
Producers of specific condition types may define expected values and meanings for this field,
|
|
and whether the values are considered a guaranteed API.
|
|
The value should be a CamelCase string.
|
|
This field may not be empty.
|
|
maxLength: 1024
|
|
minLength: 1
|
|
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
|
type: string
|
|
status:
|
|
description: status of the condition, one of True, False, Unknown.
|
|
enum:
|
|
- "True"
|
|
- "False"
|
|
- Unknown
|
|
type: string
|
|
type:
|
|
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
|
maxLength: 316
|
|
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
|
type: string
|
|
required:
|
|
- lastTransitionTime
|
|
- message
|
|
- reason
|
|
- status
|
|
- type
|
|
type: object
|
|
type: array
|
|
x-kubernetes-list-map-keys:
|
|
- type
|
|
x-kubernetes-list-type: map
|
|
credentialsSecretRef:
|
|
description: |-
|
|
credentialsSecretRef is this team's own scoped credential
|
|
(DESIGN.md §6 point 3) -- pure output, always in the operator's own
|
|
namespace, under a fixed data key ("token").
|
|
properties:
|
|
key:
|
|
description: key is the data key inside the Secret holding the
|
|
raw value.
|
|
minLength: 1
|
|
type: string
|
|
name:
|
|
description: name is the Secret's name.
|
|
minLength: 1
|
|
type: string
|
|
required:
|
|
- key
|
|
- name
|
|
type: object
|
|
inviteSecretRef:
|
|
description: |-
|
|
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.
|
|
properties:
|
|
name:
|
|
description: name is the Secret's name.
|
|
minLength: 1
|
|
type: string
|
|
required:
|
|
- name
|
|
type: object
|
|
observedGeneration:
|
|
format: int64
|
|
type: integer
|
|
serverEndpoint:
|
|
description: |-
|
|
serverEndpoint is the resolved TerdutServer's base URL, resolved once
|
|
here so no child controller (TerdutEscalationRule, TerdutDeadmanSwitch,
|
|
TerdutAlertSource) ever needs its own RBAC on terdutservers just to
|
|
find out where to send a request (DESIGN.md §5).
|
|
type: string
|
|
teamID:
|
|
description: |-
|
|
teamID is the server-side id -- needed by every child object's
|
|
controller (DESIGN.md §4.2).
|
|
format: int64
|
|
type: integer
|
|
type: object
|
|
required:
|
|
- spec
|
|
type: object
|
|
served: true
|
|
storage: true
|
|
subresources:
|
|
status: {}
|