Files
terdut-server/charts/terdut-server/templates/deployment.yaml
T
Niklas Ye a27ff49171 Sign in through an OpenID Connect provider, and from a terminal
terdut can now sign people in through any OIDC provider (written against
Authentik), and let groups at the provider decide who may sign in, which
teams they belong to and whether they administer the install. Password
login keeps working alongside it; TERDUT_PASSWORD_LOGIN=false turns it off,
and is refused at startup unless SSO is configured. With no TERDUT_OIDC_*
setting nothing changes, so every existing install behaves as before.

Identity is (issuer, subject), never email or username: those are mutable
at the provider and a recycled address must not inherit an account. An
existing user is linked by email only when the provider marks it verified,
or TERDUT_OIDC_TRUST_EMAIL is set, which Authentik needs.

Group grants are marked source='oidc' on team_members and users, and the
sync changes only those rows. Hand-made memberships and administrators
are left alone, and the sync bypasses the last-owner and last-admin guards
because the provider is the source of truth for what it grants. Editing
managed access by hand is refused with 409, since the next sign-in would
undo it. The web UI badges it as SSO and disables the controls.

Groups are read only at sign-in, so an SSO session carries a hard ceiling
(sessions.max_expires_at, 12h by default) that sliding never extends.
There is no refresh token, which means API keys of somebody removed at the
provider stay valid until an administrator disables the user. That is
accepted and documented, not fixed.

A client with no browser, the TUI over SSH, signs in with a device code
run by terdut itself (POST /api/oidc/device and /device/token), so the
terminal never talks to the provider and ends up with the ordinary
terdut_session cookie. Only a browser session can approve a code; an API
key cannot. /device?code= sends a signed-out visitor through sign-in and
back, which is what oidc_logins.next is for.

oauth2 is pinned to v0.36.0: v0.37 needs Go 1.26 and the Dockerfile
builds on 1.25.

Migrations 011 and 012 add tables and defaulted columns only.
2026-09-26 21:37:40 +02:00

127 lines
5.4 KiB
YAML

---
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "terdut-server.fullname" . }}
labels:
{{- include "terdut-server.labels" . | nindent 4 }}
spec:
replicas: 1
selector:
matchLabels:
{{- include "terdut-server.selectorLabels" . | nindent 6 }}
# Recreate, not RollingUpdate, even though the PVC that forced it is gone: the
# sweeper and the notifier are unsynchronised singletons, and two replicas
# overlapping during a rollout would both page for the same incident.
strategy:
type: Recreate
template:
metadata:
labels:
{{- include "terdut-server.selectorLabels" . | nindent 8 }}
spec:
enableServiceLinks: false
containers:
- name: terdut-server
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
env:
- name: TERDUT_ADDR
value: ":{{ .Values.service.port }}"
- name: TERDUT_DB_DSN
value: {{ required "database.dsn is required" .Values.database.dsn | quote }}
{{- if .Values.database.passwordSecret.name }}
# The password reaches pgx through libpq's environment variable
# rather than through the DSN, so it stays out of the rendered
# manifest. pgx fills in from PG* whatever the DSN leaves out.
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.database.passwordSecret.name }}
key: {{ .Values.database.passwordSecret.key }}
{{- end }}
- name: TERDUT_STALE_AFTER
value: "{{ .Values.sweeper.staleAfter }}"
- name: TERDUT_ARCHIVE_AFTER
value: "{{ .Values.sweeper.archiveAfter }}"
- name: TERDUT_DEADMAN_MATCHERS
value: "{{ .Values.deadman.matchers }}"
- name: TERDUT_DEADMAN_TIMEOUT
value: "{{ .Values.deadman.timeout }}"
- name: TERDUT_DEADMAN_SEVERITY
value: "{{ .Values.deadman.severity }}"
{{- if .Values.notify.ntfyUrl }}
- name: TERDUT_NTFY_URL
value: "{{ .Values.notify.ntfyUrl }}"
- name: TERDUT_NTFY_FALLBACK_TOPIC
value: "{{ .Values.notify.fallbackTopic }}"
- name: TERDUT_NOTIFY_REPEAT
value: "{{ .Values.notify.repeatEvery }}"
{{- if .Values.notify.tokenSecret.name }}
- name: TERDUT_NTFY_TOKEN
valueFrom:
secretKeyRef:
name: {{ .Values.notify.tokenSecret.name }}
key: {{ .Values.notify.tokenSecret.key }}
{{- end }}
{{- end }}
# Set whether or not ntfy is: single sign-on builds its redirect URI
# from it, and sessions use it to decide the cookie's Secure flag.
- name: TERDUT_PUBLIC_URL
value: "{{ .Values.notify.publicUrl | default (printf "https://%s" .Values.networking.hostname) }}"
- name: TERDUT_PASSWORD_LOGIN
value: {{ .Values.passwordLogin | quote }}
{{- if .Values.oidc.enabled }}
- name: TERDUT_OIDC_ISSUER
value: {{ required "oidc.issuer is required when oidc.enabled" .Values.oidc.issuer | quote }}
- name: TERDUT_OIDC_CLIENT_ID
value: {{ required "oidc.clientId is required when oidc.enabled" .Values.oidc.clientId | quote }}
- name: TERDUT_OIDC_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: {{ required "oidc.clientSecret.name is required when oidc.enabled" .Values.oidc.clientSecret.name }}
key: {{ .Values.oidc.clientSecret.key }}
- name: TERDUT_OIDC_NAME
value: {{ .Values.oidc.name | quote }}
- name: TERDUT_OIDC_SCOPES
value: {{ .Values.oidc.scopes | quote }}
- name: TERDUT_OIDC_USERNAME_CLAIM
value: {{ .Values.oidc.usernameClaim | quote }}
- name: TERDUT_OIDC_EMAIL_CLAIM
value: {{ .Values.oidc.emailClaim | quote }}
- name: TERDUT_OIDC_GROUPS_CLAIM
value: {{ .Values.oidc.groupsClaim | quote }}
- name: TERDUT_OIDC_TRUST_EMAIL
value: {{ .Values.oidc.trustEmail | quote }}
- name: TERDUT_OIDC_SESSION_MAX_AGE
value: {{ .Values.oidc.sessionMaxAge | quote }}
{{- if .Values.oidc.allowedGroups }}
- name: TERDUT_OIDC_ALLOWED_GROUPS
value: {{ join "," .Values.oidc.allowedGroups | quote }}
{{- end }}
{{- if .Values.oidc.adminGroup }}
- name: TERDUT_OIDC_ADMIN_GROUP
value: {{ .Values.oidc.adminGroup | quote }}
{{- end }}
{{- if .Values.oidc.groupMappings }}
- name: TERDUT_OIDC_GROUP_MAPPINGS
value: {{ .Values.oidc.groupMappings | toJson | quote }}
{{- end }}
{{- end }}
livenessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5
readinessProbe:
httpGet:
path: /healthz
port: http
initialDelaySeconds: 5