Files
weircon-front/authentik.py
agw 52b1a2bb6c feat(admin): invitationslinks med gruppe og udløbstid
Udrullet direkte til prod 2026-09-29; importeret her så repo == prod.
Bruger Authentik-flowet weircon-invitation (grupper og engangs via
fixed_data og politikker i flowet).

Claude-Session: https://claude.ai/code/session_012LCTmU2ewo8tnM1dByYAoG
2026-09-29 17:13:43 +02:00

339 lines
14 KiB
Python

"""Authentik Core-API: apps, brugere, grupper. Læsninger er cachet (cache.py);
skrivninger rydder cachen via cache.clear() i kaldsstedet (main.py)."""
import asyncio
import httpx
import cache
from config import API_TOKEN, AUTHENTIK, GROUP_ORDER
def _h():
return {"Authorization": f"Bearer {API_TOKEN}"}
def _client(timeout=15):
return httpx.AsyncClient(base_url=AUTHENTIK, headers=_h(), timeout=timeout)
async def fetch_user_apps(username: str):
"""Apps brugeren har adgang til. Cachet 45s."""
ck = f"apps:{username}"
hit = cache.get(ck)
if hit is not None:
return hit
async with _client(10) as c:
u = await c.get("/api/v3/core/users/", params={"username": username})
results = u.json().get("results", [])
if not results:
return []
pk = results[0]["pk"]
a = await c.get("/api/v3/core/applications/",
params={"for_user": pk, "superuser_full_list": "false", "page_size": 100})
apps = a.json().get("results", [])
out = []
for app_ in apps:
if app_.get("slug") == "weircon-portal":
continue
url = app_.get("launch_url") or app_.get("meta_launch_url")
if not url:
continue
host = url.split("//", 1)[-1].split("/", 1)[0]
out.append({"slug": app_.get("slug"), "name": app_.get("name"), "url": url, "host": host,
"group": (app_.get("group") or "").strip(),
"desc": (app_.get("meta_description") or "").strip(),
"icon": app_.get("meta_icon") or ""})
out.sort(key=lambda x: (x["name"] or "").lower())
cache.put(ck, out, 45)
return out
def group_apps(apps):
groups = {}
for a in apps:
groups.setdefault(a.get("group") or "Øvrige", []).append(a)
def gkey(name):
return (999, "") if name == "Øvrige" else (GROUP_ORDER.get(name, 50), name.lower())
return [{"name": g, "apps": sorted(groups[g], key=lambda x: (x["name"] or "").lower())}
for g in sorted(groups, key=gkey)]
async def fetch_all_apps_admin():
hit = cache.get("apps_admin")
if hit is not None:
return hit
async with _client(15) as c:
r = await c.get("/api/v3/core/applications/", params={"page_size": 100, "superuser_full_list": "true"})
apps = r.json().get("results", [])
out = [{"slug": a["slug"], "name": a.get("name"), "group": a.get("group") or "",
"desc": (a.get("meta_description") or "").strip()}
for a in apps if a.get("slug") != "weircon-portal"]
out.sort(key=lambda x: ((x["group"] or "~~").lower(), (x["name"] or "").lower()))
cache.put("apps_admin", out, 45)
return out
async def patch_apps(rows):
async with _client(20) as c:
async def one(r):
try:
resp = await c.patch(f"/api/v3/core/applications/{r['slug']}/",
json={"meta_description": r["desc"], "group": r["group"]})
return resp.status_code < 300
except Exception:
return False
results = await asyncio.gather(*[one(r) for r in rows])
return sum(1 for ok in results if ok)
async def list_groups():
hit = cache.get("groups")
if hit is not None:
return hit
async with _client(15) as c:
gs = (await c.get("/api/v3/core/groups/", params={"page_size": 100, "ordering": "name"})).json().get("results", [])
res = [{"pk": g["pk"], "name": g["name"], "members": len(g.get("users") or [])} for g in gs]
cache.put("groups", res, 30)
return res
async def list_users():
hit = cache.get("users")
if hit is not None:
return hit
async with _client(20) as c:
us = (await c.get("/api/v3/core/users/", params={"page_size": 300, "ordering": "username"})).json().get("results", [])
out = [{"pk": u["pk"], "username": u["username"], "name": u.get("name"), "email": u.get("email"),
"active": u.get("is_active"), "groups": [str(x) for x in (u.get("groups") or [])],
"group_names": [g.get("name") for g in (u.get("groups_obj") or [])], "type": u.get("type")}
for u in us]
cache.put("users", out, 30)
return out
async def get_user(pk):
async with _client(15) as c:
u = (await c.get(f"/api/v3/core/users/{pk}/")).json()
return {"pk": u["pk"], "username": u["username"], "name": u.get("name"), "email": u.get("email"),
"active": u.get("is_active"), "groups": [str(x) for x in (u.get("groups") or [])]}
async def create_user(username, name, email, password, group_pks):
async with _client(20) as c:
r = await c.post("/api/v3/core/users/", json={
"username": username, "name": name or username, "email": email or "",
"is_active": True, "type": "internal", "path": "users", "groups": group_pks})
if r.status_code >= 300:
return False, r.text[:300]
pk = r.json()["pk"]
if password:
await c.post(f"/api/v3/core/users/{pk}/set_password/", json={"password": password})
return True, pk
async def update_user(pk, group_pks, is_active, password=""):
async with _client(20) as c:
r = await c.patch(f"/api/v3/core/users/{pk}/", json={"groups": group_pks, "is_active": is_active})
if password:
await c.post(f"/api/v3/core/users/{pk}/set_password/", json={"password": password})
return r.status_code < 300
async def create_group(name):
async with _client(15) as c:
r = await c.post("/api/v3/core/groups/", json={"name": name})
return r.status_code < 300
async def group_members(gpk):
async with _client(15) as c:
g = (await c.get(f"/api/v3/core/groups/{gpk}/")).json()
return {"pk": g["pk"], "name": g["name"], "users": [str(x) for x in (g.get("users") or [])]}
async def set_group_members(gpk, user_pks):
desired = set(str(x) for x in user_pks)
async with _client(25) as c:
g = (await c.get(f"/api/v3/core/groups/{gpk}/")).json()
cur = set(str(x) for x in (g.get("users") or []))
ops = [c.post(f"/api/v3/core/groups/{gpk}/add_user/", json={"pk": int(p)}) for p in desired - cur]
ops += [c.post(f"/api/v3/core/groups/{gpk}/remove_user/", json={"pk": int(p)}) for p in cur - desired]
await asyncio.gather(*ops)
return True
async def admin_group_pk(name):
"""UUID for ADMIN_GROUP-navnet (til guardrails). Cachet via list_groups()."""
for g in await list_groups():
if g["name"] == name:
return g["pk"]
return None
async def user_by_username(username):
"""Slå nuværende bruger op (pk + gruppe-uuids) til guardrail-tjek."""
for u in await list_users():
if u["username"] == username:
return {"pk": str(u["pk"]), "groups": set(u["groups"])}
return None
# ---------------- per-app adgang (Authentik policy-bindings) ----------------
# Adgang til en app styres af bindings på app'en: ingen bindings = åben for alle
# autentificerede; ≥1 binding = kun de bundne grupper/brugere. Vi administrerer
# KUN simple (negate=false) gruppe-/bruger-bindings og lader policy-/negate-
# bindings være urørte (de styres bevidst i Authentik selv).
async def _all_app_bindings(c):
out, page = {}, 1
while True:
r = (await c.get("/api/v3/policies/bindings/",
params={"page_size": 100, "page": page})).json()
for b in r.get("results", []):
out.setdefault(b.get("target"), []).append(b)
if not (r.get("pagination") or {}).get("next"):
break
page += 1
return out
def _split_bindings(blist):
"""→ (gruppe-uuids, bruger-pk-strenge, antal andre bindings vi ikke rører)."""
groups, users, other = set(), set(), 0
for b in blist or []:
if b.get("negate") or b.get("policy"):
other += 1
elif b.get("group"):
groups.add(str(b["group"]))
elif b.get("user") is not None:
users.add(str(b["user"]))
else:
other += 1
return groups, users, other
async def list_apps_access():
"""Alle apps + adgangs-resumé (åben / antal principaler). Cachet 30s."""
hit = cache.get("access:list")
if hit is not None:
return hit
async with _client(20) as c:
r = await c.get("/api/v3/core/applications/",
params={"page_size": 100, "superuser_full_list": "true"})
apps = r.json().get("results", [])
bindings = await _all_app_bindings(c)
out = []
for a in apps:
if a.get("slug") == "weircon-portal":
continue
blist = bindings.get(a["pk"], [])
g, u, other = _split_bindings(blist)
out.append({"slug": a["slug"], "name": a.get("name"), "pk": a["pk"],
"group": a.get("group") or "", "icon": a.get("meta_icon") or "",
"open": not blist, "n_groups": len(g), "n_users": len(u),
"n_other": other})
out.sort(key=lambda x: ((x["group"] or "~~").lower(), (x["name"] or "").lower()))
cache.put("access:list", out, 30)
return out
async def app_access(slug):
"""Bundne grupper/brugere for én app (til redigering)."""
async with _client(20) as c:
r = (await c.get("/api/v3/core/applications/",
params={"slug": slug, "superuser_full_list": "true"})).json()
results = r.get("results", [])
if not results:
return None
app_ = results[0]
b = (await c.get("/api/v3/policies/bindings/",
params={"target": app_["pk"], "page_size": 100})).json().get("results", [])
g, u, other = _split_bindings(b)
return {"slug": app_["slug"], "name": app_.get("name"), "pk": app_["pk"],
"icon": app_.get("meta_icon") or "", "groups": g, "users": u, "n_other": other}
async def set_app_access(app_pk, want_groups, want_users):
"""Diff & anvend simple gruppe-/bruger-bindings for én app.
Returnerer (antal_tilføjet, antal_fjernet). Rører ikke policy-/negate-bindings."""
want_g = set(str(x) for x in want_groups)
want_u = set(str(x) for x in want_users)
async with _client(25) as c:
cur = (await c.get("/api/v3/policies/bindings/",
params={"target": app_pk, "page_size": 100})).json().get("results", [])
cur_g = {str(b["group"]): b["pk"] for b in cur if b.get("group") and not b.get("negate")}
cur_u = {str(b["user"]): b["pk"] for b in cur if b.get("user") is not None and not b.get("negate")}
orders = [b.get("order") or 0 for b in cur]
nxt = (max(orders) + 1) if orders else 0
ops = []
for gid in want_g - set(cur_g):
ops.append(c.post("/api/v3/policies/bindings/",
json={"target": app_pk, "group": gid, "order": nxt, "enabled": True}))
nxt += 1
for uid in want_u - set(cur_u):
ops.append(c.post("/api/v3/policies/bindings/",
json={"target": app_pk, "user": int(uid), "order": nxt, "enabled": True}))
nxt += 1
dels = [cur_g[g] for g in set(cur_g) - want_g] + [cur_u[u] for u in set(cur_u) - want_u]
for pk in dels:
ops.append(c.delete(f"/api/v3/policies/bindings/{pk}/"))
await asyncio.gather(*ops)
return len(want_g - set(cur_g)) + len(want_u - set(cur_u)), len(dels)
# --- Invitationer (enrollment-flowet "weircon-invitation" i Authentik) -------
# Modtageren vælger selv brugernavn, navn, e-mail og kode. Gruppe(r) og
# engangs-flag ligger i invitationens fixed_data og håndteres af politikker i
# flowet: "weircon-invitation-grupper" (lægger brugeren i grupperne lige før
# oprettelse) og "weircon-invitation-forbrug" (sletter en engangs-invitation
# FØRST når brugeren er oprettet; Authentiks eget single_use sletter allerede
# når linket åbnes, så et link der bare blev kigget på, ville være brændt).
INV_FLOW = "weircon-invitation"
# Grupper der aldrig giver mening at invitere til (service-/systemgrupper).
INV_SKJUL = {"authentik Read-only", "authentik Agent-Users", "ldapservice", "sonderlund-avatar-læs"}
def invitation_url(pk):
return f"{AUTHENTIK}/if/flow/{INV_FLOW}/?itoken={pk}"
async def list_invitations():
async with _client(15) as c:
rows = (await c.get("/api/v3/stages/invitation/invitations/",
params={"page_size": 100, "ordering": "expires"})).json().get("results", [])
out = []
for r in rows:
if (r.get("flow_obj") or {}).get("slug") != INV_FLOW:
continue
fd = r.get("fixed_data") or {}
out.append({"pk": r["pk"], "name": r.get("name"), "expires": r.get("expires"),
"grupper": fd.get("weircon_grupper") or [], "engangs": bool(fd.get("weircon_engangs")),
"af": (r.get("created_by") or {}).get("username") if isinstance(r.get("created_by"), dict) else None,
"url": invitation_url(r["pk"])})
return out
async def create_invitation(label, group_pks, minutes, engangs):
"""Opret en invitation. Returnerer (ok, pk eller fejltekst)."""
import re, secrets
from datetime import datetime, timedelta, timezone
slug = re.sub(r"[^a-z0-9_-]+", "-", (label or "invitation").lower()).strip("-")[:40] or "invitation"
slug = f"{slug}-{secrets.token_hex(3)}"
udloeb = (datetime.now(timezone.utc) + timedelta(minutes=int(minutes))).isoformat()
async with _client(20) as c:
flow = (await c.get(f"/api/v3/flows/instances/{INV_FLOW}/")).json()
r = await c.post("/api/v3/stages/invitation/invitations/", json={
"name": slug, "expires": udloeb, "single_use": False, "flow": flow["pk"],
"fixed_data": {"weircon_grupper": [str(g) for g in group_pks], "weircon_engangs": bool(engangs)}})
if r.status_code >= 300:
return False, r.text[:300]
return True, r.json()["pk"]
async def delete_invitation(pk):
async with _client(15) as c:
r = await c.delete(f"/api/v3/stages/invitation/invitations/{pk}/")
return r.status_code < 300