feat: add the concierge bot for expiring guest invitations

Turns guest onboarding from an admin-only click in the Authentik UI into a traceable command a defined circle can run: !einladen creates a single-use invitation valid for three days, !verlaengern extends it twice at most, !freischalten makes it permanent, and expired accounts are deactivated automatically.

Authorisation is deliberately twofold - the Authentik group decides, the invite room makes it visible. A group alone leaves no trace of who invited whom; a room alone would authorise anyone who gets in.

Two deployment details matter: exactly one replica with Recreate, because a second instance would execute every command twice; and the script ConfigMap keeps its name hash so a change actually restarts the pod, avoiding the trap described in #50.

Endpoints and field names were taken from the running Authentik OpenAPI schema, not guessed. Refs axion1337.chat/axion1337.chat-gitops#48
This commit is contained in:
Thore Cimbal
2026-08-09 12:00:00 +00:00
parent f7412817c3
commit f6d2761025
4 changed files with 515 additions and 0 deletions
+313
View File
@@ -0,0 +1,313 @@
#!/usr/bin/env python3
# @concierge - Gaeste-Einladungen mit Ablauf, Freischaltung und begrenzter
# Verlaengerung (gitops#48, Design von sorb am 2026-08-01 festgezurrt).
#
# WARUM EIN EIGENER BOT UND NICHT DRAUPNIR
# Draupnir ist ein Moderationsbot ohne Lebenszyklus-Funktionen. Er kann einen
# Gast policy-seitig einschraenken, aber Links erzeugen, Ablaeufe verwalten und
# Konten deaktivieren kann er nicht. Ihn dafuer zu verbiegen hiesse, Upstream-
# Code zu forken, den wir sonst unveraendert mitziehen.
#
# WARUM AUTHENTIK UND NICHT SYNAPSE-REGISTRATION-TOKENS
# In diesem Stack laeuft Registrierung ausschliesslich ueber Authentik (MAS-OIDC).
# Synapse kennt gar keinen offenen Registrierungsweg - ein Registration-Token
# waere wirkungslos. Der natuerliche Einladungslink ist deshalb ein
# Authentik-Invitation-Token: einmalig verwendbar, mit eigenem Ablaufdatum.
#
# BERECHTIGUNG = GRUPPE **UND** RAUM
# Autoritativ ist die Mitgliedschaft in der Authentik-Gruppe (INVITE_GROUP).
# Zusaetzlich nimmt der Bot Kommandos nur im Einladungsraum an. Die Gruppe ist
# die Kontrolle, der Raum die Transparenz: Jede Einladung hinterlaesst einen
# nachlesbaren Eintrag, wer wen eingeladen hat. Beides zusammen, weil eine
# Gruppe allein unsichtbar ist und ein Raum allein nicht autorisiert.
#
# ⚠️ ZUORDNUNG MATRIX -> AUTHENTIK
# Der Bot nimmt an, dass der Matrix-Localpart dem Authentik-Benutzernamen
# entspricht (@gast:axion1337.chat -> "gast"). Das gilt in diesem Stack, weil
# MAS die Konten aus Authentik provisioniert. Stimmt das einmal nicht, findet
# der Bot den Nutzer nicht und sagt das - er raet nicht.
#
# FEHLERVERHALTEN, BEWUSST ASYMMETRISCH
# - Einladen/Freischalten scheitert LAUT: lieber keine Einladung als eine, von
# der niemand weiss.
# - Die Ablaufpruefung deaktiviert NUR, wenn Authentik sauber geantwortet hat.
# Ein API-Fehler darf nicht dazu fuehren, dass Konten reihenweise abgeschaltet
# werden - im Zweifel bleibt ein Gast einen Durchlauf laenger aktiv.
#
# Stdlib only, wie die uebrigen Bots dieses Verbunds.
import json
import logging
import os
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
log = logging.getLogger("concierge")
MATRIX = os.environ["MATRIX_HOMESERVER"].rstrip("/")
ROOM = os.environ["MATRIX_ROOM_ID"]
AUTHENTIK = os.environ["AUTHENTIK_URL"].rstrip("/")
INVITE_GROUP = os.environ.get("INVITE_GROUP", "invite-berechtigt")
MEMBER_GROUP = os.environ.get("MEMBER_GROUP", "members")
ADMIN_GROUP = os.environ.get("ADMIN_GROUP", "authentik Admins")
INVITE_FLOW = os.environ.get("INVITE_FLOW_SLUG", "matrix-invitation")
GUEST_DAYS = int(os.environ.get("GUEST_DAYS", "3"))
MAX_RENEWALS = int(os.environ.get("MAX_RENEWALS", "2"))
SWEEP_SECONDS = int(os.environ.get("SWEEP_SECONDS", "900"))
# Attribute am Authentik-Nutzer. Praefix, damit sie nicht mit Feldern anderer
# Werkzeuge kollidieren, die sich denselben attributes-Topf teilen.
ATTR_EXPIRES = "threadnet_guest_expires_at"
ATTR_RENEWALS = "threadnet_guest_renewals"
ATTR_INVITED_BY = "threadnet_invited_by"
def _read(path_env, direct_env):
"""Token entweder aus einer Datei (Secret-Mount) oder direkt. Dateien sind
der Normalfall - ein Wert in der Umgebung steht in jedem Prozess-Dump."""
p = os.environ.get(path_env)
if p:
with open(p) as f:
return f.read().strip()
return os.environ[direct_env]
MATRIX_TOKEN = _read("MATRIX_TOKEN_FILE", "MATRIX_TOKEN")
AUTHENTIK_TOKEN = _read("AUTHENTIK_TOKEN_FILE", "AUTHENTIK_TOKEN")
def _call(url, token, method="GET", body=None, scheme="Bearer"):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method=method)
req.add_header("Authorization", f"{scheme} {token}")
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req, timeout=60) as r:
raw = r.read()
return json.loads(raw) if raw else {}
def ak(path, method="GET", body=None):
return _call(f"{AUTHENTIK}/api/v3{path}", AUTHENTIK_TOKEN, method, body)
def mx(path, method="GET", body=None):
return _call(f"{MATRIX}/_matrix/client/v3{path}", MATRIX_TOKEN, method, body)
def say(text):
txn = str(int(time.time() * 1000))
room = urllib.parse.quote(ROOM)
mx(f"/rooms/{room}/send/m.room.message/{txn}", "PUT",
{"msgtype": "m.notice", "body": text})
# --- Authentik ---------------------------------------------------------------
def find_user(username):
r = ak(f"/core/users/?username={urllib.parse.quote(username)}")
for u in r.get("results", []):
if u["username"] == username:
return u
return None
def group_uuid(name):
r = ak(f"/core/groups/?name={urllib.parse.quote(name)}")
for g in r.get("results", []):
if g["name"] == name:
return g["pk"]
return None
def in_group(user, name):
return any(g.get("name") == name for g in user.get("groups_obj", []))
def set_attrs(user, **changes):
"""attributes ist ein einzelnes JSON-Feld: PATCH ersetzt es komplett. Wer nur
einen Schluessel schickt, loescht alle anderen - deshalb immer mischen."""
attrs = dict(user.get("attributes") or {})
for k, v in changes.items():
if v is None:
attrs.pop(k, None)
else:
attrs[k] = v
return ak(f"/core/users/{user['pk']}/", "PATCH", {"attributes": attrs})
def localpart(mxid):
return mxid.lstrip("@").split(":")[0]
# --- Kommandos ---------------------------------------------------------------
def darf_einladen(sender):
u = find_user(localpart(sender))
return u is not None and in_group(u, INVITE_GROUP), u
def ist_admin(sender):
u = find_user(localpart(sender))
return u is not None and in_group(u, ADMIN_GROUP)
def cmd_einladen(sender, args):
ok, _ = darf_einladen(sender)
if not ok:
say(f"{sender}: du bist nicht in der Gruppe '{INVITE_GROUP}'.")
return
name = (args or "gast").strip().replace(" ", "-")[:40]
expires = datetime.now(timezone.utc) + timedelta(days=GUEST_DAYS)
inv = ak("/stages/invitation/invitations/", "POST", {
"name": f"gast-{name}-{int(time.time())}",
"expires": expires.isoformat(),
"single_use": True,
"fixed_data": {ATTR_INVITED_BY: sender},
})
link = f"{AUTHENTIK}/if/flow/{INVITE_FLOW}/?itoken={inv['pk']}"
say(f"Einladung von {sender} fuer '{name}':\n{link}\n"
f"Einmalig verwendbar, verfaellt {expires:%d.%m.%Y %H:%M} UTC.")
def cmd_freischalten(sender, args):
if not ist_admin(sender):
say(f"{sender}: Freischalten darf nur die Gruppe '{ADMIN_GROUP}'.")
return
u = find_user(localpart(args.strip()))
if not u:
say(f"Kein Authentik-Konto zu '{args.strip()}' gefunden.")
return
set_attrs(u, **{ATTR_EXPIRES: None, ATTR_RENEWALS: None})
gid = group_uuid(MEMBER_GROUP)
if gid:
ak(f"/core/groups/{gid}/add_user/", "POST", {"pk": u["pk"]})
say(f"{u['username']} ist dauerhaft freigeschaltet (von {sender}).")
def cmd_verlaengern(sender, args):
ok, _ = darf_einladen(sender)
if not ok:
say(f"{sender}: du bist nicht in der Gruppe '{INVITE_GROUP}'.")
return
u = find_user(localpart(args.strip()))
if not u:
say(f"Kein Authentik-Konto zu '{args.strip()}' gefunden.")
return
used = int((u.get("attributes") or {}).get(ATTR_RENEWALS, 0))
if used >= MAX_RENEWALS:
say(f"{u['username']}: {MAX_RENEWALS} Verlaengerungen sind aufgebraucht. "
f"Jetzt muss ein Admin freischalten.")
return
neu = datetime.now(timezone.utc) + timedelta(days=1)
set_attrs(u, **{ATTR_EXPIRES: neu.isoformat(), ATTR_RENEWALS: used + 1})
if not u.get("is_active"):
ak(f"/core/users/{u['pk']}/", "PATCH", {"is_active": True})
say(f"{u['username']} um einen Tag verlaengert ({used + 1}/{MAX_RENEWALS}), "
f"laeuft {neu:%d.%m.%Y %H:%M} UTC ab.")
def cmd_status(_sender, _args):
r = ak("/core/users/?page_size=200")
zeilen = []
for u in r.get("results", []):
exp = (u.get("attributes") or {}).get(ATTR_EXPIRES)
if exp:
used = (u.get("attributes") or {}).get(ATTR_RENEWALS, 0)
zustand = "aktiv" if u.get("is_active") else "deaktiviert"
zeilen.append(f" {u['username']}: laeuft {exp[:16]} ab, "
f"{used}/{MAX_RENEWALS} verlaengert, {zustand}")
say("Gaeste:\n" + ("\n".join(zeilen) if zeilen else " keine offenen Gastkonten"))
def cmd_hilfe(_sender, _args):
say("!einladen <name> - Einladungslink erzeugen\n"
"!verlaengern @nutzer - um einen Tag verlaengern (begrenzt)\n"
"!freischalten @nutzer - dauerhaft freischalten (nur Admins)\n"
"!status - offene Gastkonten anzeigen")
BEFEHLE = {
"!einladen": cmd_einladen,
"!verlaengern": cmd_verlaengern,
"!freischalten": cmd_freischalten,
"!status": cmd_status,
"!hilfe": cmd_hilfe,
}
# --- Ablaufpruefung ----------------------------------------------------------
def sweep():
try:
r = ak("/core/users/?page_size=200")
except Exception as e:
# KEIN Deaktivieren bei API-Fehlern - siehe Kopfkommentar.
log.warning("Ablaufpruefung uebersprungen, Authentik nicht erreichbar: %s", e)
return
jetzt = datetime.now(timezone.utc)
for u in r.get("results", []):
exp = (u.get("attributes") or {}).get(ATTR_EXPIRES)
if not exp or not u.get("is_active"):
continue
try:
faellig = datetime.fromisoformat(exp)
except ValueError:
log.warning("%s: unlesbares Ablaufdatum %r", u["username"], exp)
continue
if faellig.tzinfo is None:
faellig = faellig.replace(tzinfo=timezone.utc)
if faellig <= jetzt:
ak(f"/core/users/{u['pk']}/", "PATCH", {"is_active": False})
say(f"Gastkonto {u['username']} ist abgelaufen und wurde deaktiviert. "
f"'!verlaengern @{u['username']}' oder Admin-Freischaltung.")
# --- Hauptschleife -----------------------------------------------------------
def main():
logging.basicConfig(level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s")
mx(f"/rooms/{urllib.parse.quote(ROOM)}/join", "POST", {})
# Ab jetzt, nicht die Raumhistorie: ein Neustart soll keine alten Kommandos
# erneut ausfuehren.
since = mx("/sync?timeout=0").get("next_batch")
log.info("bereit, Raum %s", ROOM)
letzter_sweep = 0.0
while True:
try:
if time.time() - letzter_sweep > SWEEP_SECONDS:
sweep()
letzter_sweep = time.time()
r = mx(f"/sync?since={urllib.parse.quote(since)}&timeout=30000")
since = r.get("next_batch", since)
raum = r.get("rooms", {}).get("join", {}).get(ROOM, {})
for ev in raum.get("timeline", {}).get("events", []):
if ev.get("type") != "m.room.message":
continue
c = ev.get("content", {})
if c.get("msgtype") != "m.text":
continue
text = (c.get("body") or "").strip()
wort = text.split(" ", 1)[0].lower()
if wort not in BEFEHLE:
continue
rest = text[len(wort):].strip()
try:
BEFEHLE[wort](ev["sender"], rest)
except Exception as e:
log.exception("Kommando %s fehlgeschlagen", wort)
say(f"'{wort}' fehlgeschlagen: {e}")
except urllib.error.HTTPError as e:
log.warning("HTTP %s bei /sync - warte", e.code)
time.sleep(10)
except Exception:
log.exception("Schleifenfehler")
time.sleep(10)
if __name__ == "__main__":
main()
+85
View File
@@ -0,0 +1,85 @@
# @concierge - Gaeste-Einladungen (gitops#48). Skript: concierge-bot.py,
# als ConfigMap ueber den configMapGenerator in kustomization.yaml.
apiVersion: apps/v1
kind: Deployment
metadata:
name: concierge-bot
namespace: matrix
spec:
# ⚠️ Genau EINE Instanz. Der Bot haelt eine /sync-Schleife und verarbeitet
# Kommandos; zwei Instanzen wuerden jedes Kommando doppelt ausfuehren und
# jede Meldung doppelt posten. Deshalb replicas: 1 UND Recreate - bei
# RollingUpdate liefen waehrend eines Deploys kurzzeitig zwei.
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: concierge-bot
template:
metadata:
labels:
app: concierge-bot
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: bot
image: python:3.12-alpine
command: ["python3", "/app/concierge-bot.py"]
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
env:
- name: MATRIX_HOMESERVER
value: "https://matrix.axion1337.chat"
# Der Einladungsraum. ⚠️ Muss invite-only sein - der Bot prueft zwar
# zusaetzlich die Authentik-Gruppe, aber ein offener Raum macht
# sichtbar, wer eingeladen wurde, und das ist der halbe Zweck.
- name: MATRIX_ROOM_ID
valueFrom:
secretKeyRef:
name: concierge-credentials
key: matrix-room-id
# In-Cluster, nicht ueber die oeffentliche Adresse: spart den Umweg
# ueber Traefik und funktioniert auch, wenn extern etwas klemmt.
- name: AUTHENTIK_URL
value: "http://authentik-server.authentik.svc.cluster.local"
- name: MATRIX_TOKEN_FILE
value: /secrets/matrix-token
- name: AUTHENTIK_TOKEN_FILE
value: /secrets/authentik-token
- name: GUEST_DAYS
value: "3"
- name: MAX_RENEWALS
value: "2"
volumeMounts:
- name: script
mountPath: /app
readOnly: true
- name: creds
mountPath: /secrets
readOnly: true
- name: tmp
mountPath: /tmp
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 128Mi
volumes:
- name: script
configMap:
name: concierge-bot-script
- name: creds
secret:
secretName: concierge-credentials
- name: tmp
emptyDir: {}
+10
View File
@@ -52,11 +52,21 @@ resources:
- clamav.yaml - clamav.yaml
# Client-seitiger Scan-Dienst für verschlüsselte Räume (Issue #19-Erweiterung) # Client-seitiger Scan-Dienst für verschlüsselte Räume (Issue #19-Erweiterung)
- clamav-http-scanner.yaml - clamav-http-scanner.yaml
- concierge-bot.yaml
# Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted - # Synapse-Modul als eigene Datei gepflegt (lintbar/testbar), aber als ConfigMap gemounted -
# disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml # disableNameSuffixHash, da der Name in synapse-values.yaml's eingebettetem values.yaml
# referenziert wird (kustomize kann Referenzen nicht in opaken YAML-Strings umschreiben). # referenziert wird (kustomize kann Referenzen nicht in opaken YAML-Strings umschreiben).
configMapGenerator: configMapGenerator:
# ⚠️ Bewusst OHNE disableNameSuffixHash: Der Hash im ConfigMap-Namen aendert
# sich mit dem Skript, kustomize zieht die Referenz im Deployment nach, und
# der Pod startet dadurch von selbst neu. Ohne das haetten wir wieder den
# Fall aus gitops#50 - geaenderte Datei im Repo, alter Stand im laufenden
# Prozess, und niemand merkt es.
- name: concierge-bot-script
namespace: matrix
files:
- concierge-bot.py
- name: synapse-clamav-module - name: synapse-clamav-module
namespace: matrix namespace: matrix
files: files:
@@ -0,0 +1,107 @@
# @concierge — Gäste-Einladungen mit Ablauf
**Status**: gebaut, wartet auf Zugangsdaten · Issue [#48](https://git.lab/axion1337.chat/axion1337.chat-gitops/-/issues/48)
Ein kleiner Bot, der Einladungslinks erzeugt, Gastkonten nach drei Tagen ablaufen
lässt und die dauerhafte Freischaltung an eine bewusste Admin-Handlung bindet.
## Warum es diesen Bot gibt
Registrierung läuft in diesem Stack **ausschließlich über Authentik**. Bis jetzt
hieß das: Wer jemanden einladen will, klickt in der Authentik-Oberfläche einen
Invitation-Token zusammen. Das können nur Admins, es hinterlässt keine Spur, wer
wen eingeladen hat, und ein Gastkonto bleibt für immer bestehen.
Der Bot macht daraus einen Vorgang, den ein festgelegter Kreis selbst auslösen
kann — nachvollziehbar und mit eingebautem Ablauf.
## Wie es funktioniert
```
!einladen <name> → Authentik-Invitation (einmalig, 3 Tage) + Link im Raum
Gast registriert sich → Konto trägt threadnet_guest_expires_at
!verlaengern @gast → +1 Tag, höchstens 2×
!freischalten @gast → Ablauf entfernen, in members-Gruppe (nur Admins)
(nichts davon) → Bot deaktiviert das Konto nach Ablauf
```
### Berechtigung ist zweiteilig — und das ist Absicht
**Authentik-Gruppe UND Einladungsraum.** Die Gruppe entscheidet, der Raum macht
sichtbar. Eine Gruppe allein ist unsichtbar: Niemand sähe, wer eingeladen hat.
Ein Raum allein autorisiert nicht: Wer hineinkommt, dürfte alles. Zusammen ergibt
sich beides, und jede Einladung hinterlässt einen nachlesbaren Eintrag.
### Zwei Dinge, die beim Umbauen leicht kaputtgehen
⚠️ **Genau eine Instanz.** `replicas: 1` **und** `strategy: Recreate`. Der Bot
hält eine `/sync`-Schleife; zwei Instanzen führen jedes Kommando doppelt aus. Bei
`RollingUpdate` liefen während eines Deploys kurzzeitig zwei.
⚠️ **Die ConfigMap trägt bewusst einen Namens-Hash.** Anders als beim
ClamAV-Modul steht hier **kein** `disableNameSuffixHash: true`. Dadurch ändert
sich der ConfigMap-Name mit dem Skript, kustomize zieht die Referenz nach, und
der Pod startet von selbst neu. Ohne das hätten wir den Fall aus gitops#50:
geänderte Datei im Repo, alter Stand im laufenden Prozess.
### Fehlerverhalten ist absichtlich unsymmetrisch
- **Einladen und Freischalten scheitern laut.** Lieber keine Einladung als eine,
von der niemand weiß.
- **Die Ablaufprüfung deaktiviert nur, wenn Authentik sauber geantwortet hat.**
Ein API-Fehler darf nicht dazu führen, dass Konten reihenweise abgeschaltet
werden; im Zweifel bleibt ein Gast einen Durchlauf länger aktiv.
## Was zur Inbetriebnahme fehlt
Der Bot ist ausgerollt, **startet aber nicht**, solange das Secret fehlt — der Pod
meldet `secret "concierge-credentials" not found`. Das ist gewollt sichtbar; ein
Bot, der still nichts tut, wäre schlechter.
### 1. Matrix-Konto anlegen
```bash
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage register-user concierge --yes
kubectl exec -it -n matrix deploy/matrix-stack-matrix-authentication-service -- \
mas-cli manage issue-compatibility-token concierge
```
### 2. Authentik-Token
*Admin → Verzeichnis → Tokens*. Braucht Schreibrechte auf Nutzer, Gruppen und
Invitations. Ein eigenes Dienstkonto ist sauberer als ein Admin-Token.
### 3. Einladungsraum
Invite-only anlegen, `@concierge` einladen. Die Raum-ID ist Teil des Secrets, weil
sie zusammen mit den Token gepflegt wird und sich beim Neuanlegen ändert.
### 4. Gruppen in Authentik
`invite-berechtigt` (wer einladen darf) und `members` (wohin Freigeschaltete
kommen). Namen sind über `INVITE_GROUP` / `MEMBER_GROUP` änderbar.
### 5. Secret
```bash
kubectl create secret generic concierge-credentials -n matrix \
--from-literal=matrix-token='…' \
--from-literal=authentik-token='…' \
--from-literal=matrix-room-id='!….:axion1337.chat' \
--dry-run=client -o yaml > /tmp/s.yaml
sops -e /tmp/s.yaml > apps/production/concierge-secret.yaml
```
⚠️ Über SOPS ins Repo, nicht mit `kubectl apply` von Hand — sonst kennt Flux das
Secret nicht und es fehlt nach einem Wiederaufbau des Clusters.
## Grenzen
- **Matrix-Localpart = Authentik-Benutzername.** Gilt hier, weil MAS aus
Authentik provisioniert. Stimmt es einmal nicht, findet der Bot den Nutzer
nicht und sagt das — er rät nicht.
- **Ein deaktiviertes Konto ist nicht gelöscht.** Räume und Nachrichten bleiben.
Löschen ist bewusst nicht Sache des Bots.
- **Draupnir-Verzahnung** (Gast-Label → eingeschränkte Räume) ist Stufe 2 und
nicht Teil dieser Fassung.