diff --git a/auth.py b/auth.py index f604ba1..7e2a06e 100644 --- a/auth.py +++ b/auth.py @@ -10,7 +10,7 @@ from fastapi_users import BaseUserManager, FastAPIUsers, UUIDIDMixin, schemas from fastapi_users.authentication import AuthenticationBackend, CookieTransport, JWTStrategy 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 @@ -19,7 +19,10 @@ SESSION_SECRET = os.environ.get("KUNDENPLATTFORM_SESSION_SECRET", "") COOKIE_NAME = "kundenplattform_session" 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 @@ -30,16 +33,21 @@ class User: organization_id: uuid.UUID is_active: bool = True is_superuser: bool = False + ist_organisationsadmin: bool = False is_verified: bool = True class UserCreate(schemas.BaseUserCreate): organization_id: uuid.UUID + ist_organisationsadmin: Optional[bool] = False is_verified: Optional[bool] = True 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. if isinstance(hashed_password, bytes): @@ -54,6 +62,7 @@ def _row_to_user(row) -> User: hashed_password=hashed_password, is_active=is_active, is_superuser=is_superuser, + ist_organisationsadmin=ist_organisationsadmin, is_verified=is_verified, ) @@ -86,8 +95,9 @@ class UserDatabase(BaseUserDatabase[User, uuid.UUID]): await cur.execute( """ INSERT INTO benutzer - (id, organization_id, email, hashed_password, is_active, is_superuser, is_verified) - VALUES (%s, %s, %s, %s, %s, %s, %s) + (id, organization_id, email, hashed_password, is_active, is_superuser, + ist_organisationsadmin, is_verified) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, ( user_id, @@ -96,6 +106,7 @@ class UserDatabase(BaseUserDatabase[User, uuid.UUID]): create_dict["hashed_password"], create_dict.get("is_active", True), create_dict.get("is_superuser", False), + create_dict.get("ist_organisationsadmin", False), create_dict.get("is_verified", True), ), ) @@ -168,6 +179,50 @@ async def require_admin(user: User = Depends(get_current_user)) -> 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() diff --git a/migrations/0005_organisationsadmin_rolle.sql b/migrations/0005_organisationsadmin_rolle.sql new file mode 100644 index 0000000..01c40a7 --- /dev/null +++ b/migrations/0005_organisationsadmin_rolle.sql @@ -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; diff --git a/routers/admin_kunden.py b/routers/admin_kunden.py index fba182d..d47706a 100644 --- a/routers/admin_kunden.py +++ b/routers/admin_kunden.py @@ -2,27 +2,13 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse from anode_client import anode_request -from auth import UserCreate, UserManager, get_user_manager, require_admin -from db import get_database_connection +from auth import UserCreate, UserManager, fetch_benutzer_fuer_organisation, get_user_manager, require_admin from templating import templates 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("") def index(request: Request, user: dict = Depends(require_admin)): 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") 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}) @@ -215,6 +228,7 @@ async def konto_anlegen( email: str = Form(...), password: str = Form(...), is_superuser: str = Form(default=""), + ist_organisationsadmin: str = Form(default=""), user: dict = Depends(require_admin), user_manager: UserManager = Depends(get_user_manager), ): @@ -224,6 +238,7 @@ async def konto_anlegen( password=password, organization_id=organization_id, is_superuser=is_superuser == "on", + ist_organisationsadmin=ist_organisationsadmin == "on", ) ) diff --git a/routers/organisation.py b/routers/organisation.py index 927c214..af3439b 100644 --- a/routers/organisation.py +++ b/routers/organisation.py @@ -2,7 +2,14 @@ from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import RedirectResponse 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 @@ -16,13 +23,85 @@ def organisation_ansicht(request: Request, user=Depends(get_current_user)): if not result.get("success"): raise HTTPException(status_code=404, detail="Organisation wurde nicht gefunden.") + benutzer = fetch_benutzer_fuer_organisation(str(user.organization_id)) + return templates.TemplateResponse( - request, "organisation.html", {"user": user, "organization": result["organization"]} + request, + "organisation.html", + {"user": user, "organization": result["organization"], "benutzer": benutzer}, ) @router.post("/organisation") -def organisation_speichern(name: str = Form(...), user=Depends(get_current_user)): - anode_request("PATCH", f"/api/v1/organizations/{user.organization_id}", json={"name": name}) +def organisation_speichern( + 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) diff --git a/templates/admin/kunden_detail.html b/templates/admin/kunden_detail.html index 0975775..ee8ee4c 100644 --- a/templates/admin/kunden_detail.html +++ b/templates/admin/kunden_detail.html @@ -4,6 +4,31 @@

← Kunden

{{ organization.name if organization else organization_id }}

+
+

Organisationsdaten

+
+ + + + + + + + + + + + + + + + + + + +
+
+

Aktivierungscodes

@@ -90,12 +115,13 @@

Kundenkonten

- + {% for b in benutzer %} + {% endfor %} @@ -111,7 +137,11 @@ + diff --git a/templates/organisation.html b/templates/organisation.html index 8ab6fd3..1ab50c4 100644 --- a/templates/organisation.html +++ b/templates/organisation.html @@ -6,6 +6,70 @@ + + + + + + + + + + + + + + + + + +
+

Kolleg:innen

+ {% if not benutzer %} +

Keine Konten gefunden.

+ {% else %} +
+
E-MailAdminAngelegt
E-MailTuxflotte-AdminOrg-AdminAngelegt
{{ b.email }} {{ "ja" if b.is_superuser else "nein" }}{{ "ja" if b.ist_organisationsadmin else "nein" }} {{ b.created_at }}
+ {% if user.is_superuser or user.ist_organisationsadmin %}{% endif %} + + {% for b in benutzer %} + + + + + {% if user.is_superuser or user.ist_organisationsadmin %} + + {% endif %} + + {% endfor %} + +
E-MailOrg-AdminAngelegt
{{ b.email }}{{ "ja" if b.ist_organisationsadmin else "nein" }}{{ b.created_at }} + {% if b.id != user.id|string %} +
+ +
+ {% endif %} +
+
+ {% endif %} + + {% if user.is_superuser or user.ist_organisationsadmin %} +
+ Neues Konto anlegen +
+ + + + + + +
+
+ {% endif %} +
{% endblock %}