feat: Self-Service-Kontenverwaltung + Organisationsdaten unter /organisation

Neue Rolle ist_organisationsadmin (auth.py), bewusst getrennt von
is_superuser: is_superuser bleibt exklusiv internes, organisationsuebergreifendes
Tuxflotte-Personal mit Zugriff auf /admin/* - ist_organisationsadmin ist
auf die eigene Organisation beschränkt und kann nie über Self-Service
gesetzt werden (organization_id/is_superuser kommen bei
POST /organisation/konten nie aus dem Formular, sondern immer aus der
Session).

/organisation zeigt jetzt Kontakt-/Stammdaten (bearbeitbar) und die
Kolleg:innen-Liste der eigenen Organisation (für alle sichtbar,
Verwaltung nur für Org-Admins/internes Personal via require_org_admin).
Selbstlöschung des eigenen Kontos wird verweigert.

fetch_benutzer_fuer_organisation() von admin_kunden.py nach auth.py
verschoben (jetzt von beiden Routern importiert, kein Duplikat) -
liefert zusätzlich id + ist_organisationsadmin. admin_kunden.py bekommt
eine neue Organisationsdaten-Sektion (nutzt denselben PATCH-Endpoint)
und die neue Checkbox beim Konto-Anlegen, um den ersten Org-Admin einer
neuen Organisation zu bootstrappen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Thomas Stallinger 2026-08-20 07:40:05 +02:00
parent 36d0346172
commit ec11de1ee9
6 changed files with 280 additions and 26 deletions

65
auth.py
View File

@ -10,7 +10,7 @@ from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, schemas
from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy
from fastapi_users.db import BaseUserDatabase from fastapi_users.db import BaseUserDatabase
from db import get_async_database_connection from db import get_async_database_connection, get_database_connection
from templating import templates from templating import templates
@ -19,7 +19,10 @@ SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "")
COOKIE_NAME = "kundenplattform_session" COOKIE_NAME = "kundenplattform_session"
COOKIE_MAX_AGE = 14 * 24 * 60 * 60 # 14 Tage, wie zuvor Starlettes SessionMiddleware-Default COOKIE_MAX_AGE = 14 * 24 * 60 * 60 # 14 Tage, wie zuvor Starlettes SessionMiddleware-Default
BENUTZER_SPALTEN = "id, organization_id, email, hashed_password, is_active, is_superuser, is_verified" BENUTZER_SPALTEN = (
"id, organization_id, email, hashed_password, is_active, is_superuser, "
"ist_organisationsadmin, is_verified"
)
@dataclass @dataclass
@ -30,16 +33,21 @@ class User:
organization_id: uuid.UUID organization_id: uuid.UUID
is_active: bool = True is_active: bool = True
is_superuser: bool = False is_superuser: bool = False
ist_organisationsadmin: bool = False
is_verified: bool = True is_verified: bool = True
class UserCreate(schemas.BaseUserCreate): class UserCreate(schemas.BaseUserCreate):
organization_id: uuid.UUID organization_id: uuid.UUID
ist_organisationsadmin: Optional[bool] = False
is_verified: Optional[bool] = True is_verified: Optional[bool] = True
def _row_to_user(row) -> User: def _row_to_user(row) -> User:
user_id, organization_id, email, hashed_password, is_active, is_superuser, is_verified = row (
user_id, organization_id, email, hashed_password, is_active,
is_superuser, ist_organisationsadmin, is_verified,
) = row
# psycopg liefert TEXT-Spalten hier teils als bytes zurück, nicht als str. # psycopg liefert TEXT-Spalten hier teils als bytes zurück, nicht als str.
if isinstance(hashed_password, bytes): if isinstance(hashed_password, bytes):
@ -54,6 +62,7 @@ def _row_to_user(row) -> User:
hashed_password=hashed_password, hashed_password=hashed_password,
is_active=is_active, is_active=is_active,
is_superuser=is_superuser, is_superuser=is_superuser,
ist_organisationsadmin=ist_organisationsadmin,
is_verified=is_verified, is_verified=is_verified,
) )
@ -86,8 +95,9 @@ class UserDatabase(BaseUserDatabase[User, uuid.UUID]):
await cur.execute( await cur.execute(
""" """
INSERT INTO benutzer INSERT INTO benutzer
(id, organization_id, email, hashed_password, is_active, is_superuser, is_verified) (id, organization_id, email, hashed_password, is_active, is_superuser,
VALUES (%s, %s, %s, %s, %s, %s, %s) ist_organisationsadmin, is_verified)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", """,
( (
user_id, user_id,
@ -96,6 +106,7 @@ class UserDatabase(BaseUserDatabase[User, uuid.UUID]):
create_dict["hashed_password"], create_dict["hashed_password"],
create_dict.get("is_active", True), create_dict.get("is_active", True),
create_dict.get("is_superuser", False), create_dict.get("is_superuser", False),
create_dict.get("ist_organisationsadmin", False),
create_dict.get("is_verified", True), create_dict.get("is_verified", True),
), ),
) )
@ -168,6 +179,50 @@ async def require_admin(user: User = Depends(get_current_user)) -> User:
return user return user
async def require_org_admin(user: User = Depends(get_current_user)) -> User:
"""
Für Self-Service-Kontenverwaltung (/organisation/konten): erlaubt
entweder is_superuser (internes Tuxflotte-Personal, soll nicht
ausgesperrt sein, falls Support nötig ist) oder ist_organisationsadmin
(die neue, auf die eigene Organisation beschränkte Rolle). Bewusst kein
Ersatz für require_admin - is_superuser bleibt exklusiv für /admin/*.
"""
if not (user.is_superuser or user.ist_organisationsadmin):
raise HTTPException(status_code=403, detail="Kein Admin-Zugriff für diese Organisation.")
return user
def fetch_benutzer_fuer_organisation(organization_id: str) -> list[dict]:
"""
Kontenliste einer Organisation - gemeinsam genutzt vom internen
Admin-Bereich (admin_kunden.py) und der Self-Service-Seite
(routers/organisation.py), um die SQL-Abfrage nicht zu duplizieren
(siehe ADR-0017 fetch_gruppen()-Reuse-Pattern).
"""
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id, email, is_superuser, ist_organisationsadmin, created_at
FROM benutzer WHERE organization_id = %s ORDER BY email
""",
(organization_id,),
)
return [
{
"id": str(benutzer_id),
"email": email,
"is_superuser": is_superuser,
"ist_organisationsadmin": ist_organisationsadmin,
"created_at": created_at,
}
for benutzer_id, email, is_superuser, ist_organisationsadmin, created_at in cur.fetchall()
]
router = APIRouter() router = APIRouter()

View File

@ -0,0 +1,11 @@
BEGIN;
-- Organisationsgebundene Admin-Rolle fuer Self-Service-Kontenverwaltung
-- (/organisation/konten) - bewusst getrennt von is_superuser, das
-- weiterhin ausschliesslich organisationsuebergreifendes, internes
-- Tuxflotte-Personal mit Zugriff auf /admin/* bedeutet. Eine Kundin darf
-- sich selbst nie is_superuser setzen koennen, nur dieses neue,
-- auf die eigene Organisation beschraenkte Flag.
ALTER TABLE benutzer ADD COLUMN ist_organisationsadmin BOOLEAN NOT NULL DEFAULT FALSE;
COMMIT;

View File

@ -2,27 +2,13 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from anode_client import anode_request from anode_client import anode_request
from auth import UserCreate, UserManager, get_user_manager, require_admin from auth import UserCreate, UserManager, fetch_benutzer_fuer_organisation, get_user_manager, require_admin
from db import get_database_connection
from templating import templates from templating import templates
router = APIRouter(prefix="/admin/kunden") router = APIRouter(prefix="/admin/kunden")
def fetch_benutzer_fuer_organisation(organization_id: str) -> list[dict]:
with get_database_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT email, is_superuser, created_at FROM benutzer WHERE organization_id = %s ORDER BY email",
(organization_id,),
)
return [
{"email": email, "is_superuser": is_superuser, "created_at": created_at}
for email, is_superuser, created_at in cur.fetchall()
]
@router.get("") @router.get("")
def index(request: Request, user: dict = Depends(require_admin)): def index(request: Request, user: dict = Depends(require_admin)):
result = anode_request("GET", "/api/v1/organizations") result = anode_request("GET", "/api/v1/organizations")
@ -80,6 +66,33 @@ def detail(request: Request, organization_id: str, user: dict = Depends(require_
) )
@router.post("/{organization_id}/organisationsdaten")
def organisationsdaten_speichern(
organization_id: str,
name: str = Form(...),
kontakt_name: str = Form(default=""),
adresse: str = Form(default=""),
telefon: str = Form(default=""),
kontakt_email: str = Form(default=""),
notizen: str = Form(default=""),
user: dict = Depends(require_admin),
):
anode_request(
"PATCH",
f"/api/v1/organizations/{organization_id}",
json={
"name": name,
"kontakt_name": kontakt_name or None,
"adresse": adresse or None,
"telefon": telefon or None,
"kontakt_email": kontakt_email or None,
"notizen": notizen or None,
},
)
return RedirectResponse(f"/admin/kunden/{organization_id}", status_code=303)
@router.post("/{organization_id}/gruppen") @router.post("/{organization_id}/gruppen")
def gruppe_anlegen(organization_id: str, name: str = Form(...), user: dict = Depends(require_admin)): def gruppe_anlegen(organization_id: str, name: str = Form(...), user: dict = Depends(require_admin)):
anode_request("POST", f"/api/v1/organizations/{organization_id}/gruppen", json={"name": name}) anode_request("POST", f"/api/v1/organizations/{organization_id}/gruppen", json={"name": name})
@ -215,6 +228,7 @@ async def konto_anlegen(
email: str = Form(...), email: str = Form(...),
password: str = Form(...), password: str = Form(...),
is_superuser: str = Form(default=""), is_superuser: str = Form(default=""),
ist_organisationsadmin: str = Form(default=""),
user: dict = Depends(require_admin), user: dict = Depends(require_admin),
user_manager: UserManager = Depends(get_user_manager), user_manager: UserManager = Depends(get_user_manager),
): ):
@ -224,6 +238,7 @@ async def konto_anlegen(
password=password, password=password,
organization_id=organization_id, organization_id=organization_id,
is_superuser=is_superuser == "on", is_superuser=is_superuser == "on",
ist_organisationsadmin=ist_organisationsadmin == "on",
) )
) )

View File

@ -2,7 +2,14 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from anode_client import anode_request from anode_client import anode_request
from auth import get_current_user from auth import (
UserCreate,
UserManager,
fetch_benutzer_fuer_organisation,
get_current_user,
get_user_manager,
require_org_admin,
)
from templating import templates from templating import templates
@ -16,13 +23,85 @@ def organisation_ansicht(request: Request, user=Depends(get_current_user)):
if not result.get("success"): if not result.get("success"):
raise HTTPException(status_code=404, detail="Organisation wurde nicht gefunden.") raise HTTPException(status_code=404, detail="Organisation wurde nicht gefunden.")
benutzer = fetch_benutzer_fuer_organisation(str(user.organization_id))
return templates.TemplateResponse( return templates.TemplateResponse(
request, "organisation.html", {"user": user, "organization": result["organization"]} request,
"organisation.html",
{"user": user, "organization": result["organization"], "benutzer": benutzer},
) )
@router.post("/organisation") @router.post("/organisation")
def organisation_speichern(name: str = Form(...), user=Depends(get_current_user)): def organisation_speichern(
anode_request("PATCH", f"/api/v1/organizations/{user.organization_id}", json={"name": name}) name: str = Form(...),
kontakt_name: str = Form(default=""),
adresse: str = Form(default=""),
telefon: str = Form(default=""),
kontakt_email: str = Form(default=""),
notizen: str = Form(default=""),
user=Depends(get_current_user),
):
anode_request(
"PATCH",
f"/api/v1/organizations/{user.organization_id}",
json={
"name": name,
"kontakt_name": kontakt_name or None,
"adresse": adresse or None,
"telefon": telefon or None,
"kontakt_email": kontakt_email or None,
"notizen": notizen or None,
},
)
return RedirectResponse("/organisation", status_code=303)
@router.post("/organisation/konten")
async def konto_anlegen(
email: str = Form(...),
password: str = Form(...),
ist_organisationsadmin: str = Form(default=""),
user=Depends(require_org_admin),
user_manager: UserManager = Depends(get_user_manager),
):
# organization_id kommt bewusst nie aus dem Formular, sondern immer aus
# der Session des anfragenden Nutzers - verhindert, dass jemand per
# manipuliertem Request ein Konto in einer fremden Organisation anlegt.
# is_superuser wird beim Self-Service-Aufruf nie gesetzt (Default False)
# - das bleibt exklusiv dem internen Admin-Bereich vorbehalten (siehe
# ADR zu is_superuser vs. ist_organisationsadmin).
await user_manager.create(
UserCreate(
email=email,
password=password,
organization_id=user.organization_id,
ist_organisationsadmin=ist_organisationsadmin == "on",
)
)
return RedirectResponse("/organisation", status_code=303)
@router.post("/organisation/konten/{konto_id}/loeschen")
async def konto_loeschen(
konto_id: str,
user=Depends(require_org_admin),
user_manager: UserManager = Depends(get_user_manager),
):
if str(user.id) == konto_id:
raise HTTPException(status_code=400, detail="Das eigene Konto kann nicht gelöscht werden.")
ziel = next(
(b for b in fetch_benutzer_fuer_organisation(str(user.organization_id)) if b["id"] == konto_id),
None,
)
if ziel is None:
raise HTTPException(status_code=404, detail="Konto wurde nicht gefunden.")
ziel_user = await user_manager.get_by_email(ziel["email"])
await user_manager.delete(ziel_user)
return RedirectResponse("/organisation", status_code=303) return RedirectResponse("/organisation", status_code=303)

View File

@ -4,6 +4,31 @@
<p><a href="/admin/kunden">← Kunden</a></p> <p><a href="/admin/kunden">← Kunden</a></p>
<h2>{{ organization.name if organization else organization_id }}</h2> <h2>{{ organization.name if organization else organization_id }}</h2>
<section>
<h3>Organisationsdaten</h3>
<form method="post" action="/admin/kunden/{{ organization_id }}/organisationsdaten">
<label for="org_name">Name</label>
<input type="text" id="org_name" name="name" value="{{ organization.name if organization else '' }}" required>
<label for="org_kontakt_name">Ansprechpartner:in</label>
<input type="text" id="org_kontakt_name" name="kontakt_name" value="{{ organization.kontakt_name or '' if organization else '' }}">
<label for="org_adresse">Adresse</label>
<input type="text" id="org_adresse" name="adresse" value="{{ organization.adresse or '' if organization else '' }}">
<label for="org_telefon">Telefon</label>
<input type="text" id="org_telefon" name="telefon" value="{{ organization.telefon or '' if organization else '' }}">
<label for="org_kontakt_email">Kontakt-E-Mail</label>
<input type="email" id="org_kontakt_email" name="kontakt_email" value="{{ organization.kontakt_email or '' if organization else '' }}">
<label for="org_notizen">Notizen</label>
<textarea id="org_notizen" name="notizen" rows="3">{{ organization.notizen or '' if organization else '' }}</textarea>
<button type="submit">Speichern</button>
</form>
</section>
<section> <section>
<h3>Aktivierungscodes</h3> <h3>Aktivierungscodes</h3>
<figure> <figure>
@ -90,12 +115,13 @@
<h3>Kundenkonten</h3> <h3>Kundenkonten</h3>
<figure> <figure>
<table> <table>
<thead><tr><th>E-Mail</th><th>Admin</th><th>Angelegt</th></tr></thead> <thead><tr><th>E-Mail</th><th>Tuxflotte-Admin</th><th>Org-Admin</th><th>Angelegt</th></tr></thead>
<tbody> <tbody>
{% for b in benutzer %} {% for b in benutzer %}
<tr> <tr>
<td>{{ b.email }}</td> <td>{{ b.email }}</td>
<td>{{ "ja" if b.is_superuser else "nein" }}</td> <td>{{ "ja" if b.is_superuser else "nein" }}</td>
<td>{{ "ja" if b.ist_organisationsadmin else "nein" }}</td>
<td>{{ b.created_at }}</td> <td>{{ b.created_at }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -111,7 +137,11 @@
<input type="password" id="password" name="password" required> <input type="password" id="password" name="password" required>
<label> <label>
<input type="checkbox" name="is_superuser"> <input type="checkbox" name="is_superuser">
Admin-Zugriff (organisationsübergreifend) Admin-Zugriff (organisationsübergreifend, internes Tuxflotte-Personal)
</label>
<label>
<input type="checkbox" name="ist_organisationsadmin">
Admin dieser Organisation (darf eigene Kolleg:innen-Konten verwalten)
</label> </label>
<button type="submit">Anlegen</button> <button type="submit">Anlegen</button>
</form> </form>

View File

@ -6,6 +6,70 @@
<form method="post" action="/organisation"> <form method="post" action="/organisation">
<label for="name">Name</label> <label for="name">Name</label>
<input type="text" id="name" name="name" value="{{ organization.name }}" required> <input type="text" id="name" name="name" value="{{ organization.name }}" required>
<label for="kontakt_name">Ansprechpartner:in</label>
<input type="text" id="kontakt_name" name="kontakt_name" value="{{ organization.kontakt_name or '' }}">
<label for="adresse">Adresse</label>
<input type="text" id="adresse" name="adresse" value="{{ organization.adresse or '' }}">
<label for="telefon">Telefon</label>
<input type="text" id="telefon" name="telefon" value="{{ organization.telefon or '' }}">
<label for="kontakt_email">Kontakt-E-Mail</label>
<input type="email" id="kontakt_email" name="kontakt_email" value="{{ organization.kontakt_email or '' }}">
<label for="notizen">Notizen</label>
<textarea id="notizen" name="notizen" rows="3">{{ organization.notizen or '' }}</textarea>
<button type="submit">Speichern</button> <button type="submit">Speichern</button>
</form> </form>
<section>
<h3>Kolleg:innen</h3>
{% if not benutzer %}
<p>Keine Konten gefunden.</p>
{% else %}
<figure>
<table>
<thead><tr><th>E-Mail</th><th>Org-Admin</th><th>Angelegt</th>{% if user.is_superuser or user.ist_organisationsadmin %}<th></th>{% endif %}</tr></thead>
<tbody>
{% for b in benutzer %}
<tr>
<td>{{ b.email }}</td>
<td>{{ "ja" if b.ist_organisationsadmin else "nein" }}</td>
<td>{{ b.created_at }}</td>
{% if user.is_superuser or user.ist_organisationsadmin %}
<td>
{% if b.id != user.id|string %}
<form method="post" action="/organisation/konten/{{ b.id }}/loeschen">
<button type="submit" class="outline secondary">Löschen</button>
</form>
{% endif %}
</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
</figure>
{% endif %}
{% if user.is_superuser or user.ist_organisationsadmin %}
<details>
<summary role="button" class="secondary outline">Neues Konto anlegen</summary>
<form method="post" action="/organisation/konten">
<label for="konto_email">E-Mail</label>
<input type="email" id="konto_email" name="email" required>
<label for="konto_password">Passwort</label>
<input type="password" id="konto_password" name="password" required>
<label>
<input type="checkbox" name="ist_organisationsadmin">
Admin dieser Organisation (darf Kolleg:innen-Konten verwalten)
</label>
<button type="submit">Anlegen</button>
</form>
</details>
{% endif %}
</section>
{% endblock %} {% endblock %}