04d6562f5b
Problem 1: Remove legacy role bypass - Remove role="admin" string bypass in permissions.py resolve_permissions() - Remove role="admin"/"editor" bypass in auth.py check_permission() - Remove legacy role string fallback in deps.py require_admin/require_write - Add migration 0112: Create Role records for built-in roles and link role_id - KI-Kommentar: Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben Problem 2: Enforce API token scopes - Add _token_scopes check in require_permission() in deps.py - When _token_scopes is set (API token auth), required permission must be in scopes - When _token_scopes not set (session auth), normal permission check applies Problem 3: Migration chain verification - Chain is already linear: 0027→0028_rls_force→0028_user_preferences→0029 - user_preferences table confirmed exists in DB - No duplicate revision IDs found Problem 4: RLS for remaining tenant tables - Add migration 0111: Dynamic RLS activation for any remaining tables with tenant_id - Login tables and global tables explicitly excluded - DB check shows 0 tables currently missing RLS (safety net migration) Problem 5: Permission cache invalidation on tenant switch - Add invalidate_permission_cache() call in switch_tenant() for old tenant - Stale cached permissions from old tenant no longer leak Problem 6+7: Guest system removal - Remove get_current_guest() from deps.py - Remove guest_auth.py router from main.py and routes/__init__.py - Rewrite guests.py to use regular User/UserTenant with role=guest - Remove GuestUser/GuestInvitation from models/__init__.py - Add migration 0113: Migrate guest_users to regular users, drop guest tables - Update frontend GuestLogin/GuestContacts to redirect to normal pages - KI-Kommentar: Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
205 lines
6.3 KiB
Python
205 lines
6.3 KiB
Python
"""Guest management routes — invite, list, delete guests (admin only).
|
|
|
|
⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
|
|
|
Guests are now regular users with role='guest' in user_tenants. They authenticate
|
|
via the normal login flow and are managed through the standard user system.
|
|
This router provides admin endpoints for inviting and managing guest users.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import secrets
|
|
import uuid
|
|
from datetime import UTC, datetime, timedelta
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
from sqlalchemy import select, update
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.config import get_settings
|
|
from app.core.auth import get_redis, hash_password
|
|
from app.core.db import get_db
|
|
from app.deps import get_current_user, require_admin
|
|
from app.models.user import User, UserTenant
|
|
from app.models.tenant import Tenant
|
|
|
|
router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
|
|
settings = get_settings()
|
|
|
|
|
|
def _hash_token(token: str) -> str:
|
|
"""Hash a token using SHA-256."""
|
|
return hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
|
|
@router.post("/invite")
|
|
async def invite_guest(
|
|
request: Request,
|
|
body: dict,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_admin),
|
|
):
|
|
"""Invite a guest user. Admin only. Creates a regular user with role='guest'.
|
|
|
|
⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
|
|
"""
|
|
email = body.get("email", "")
|
|
name = body.get("name", "")
|
|
expires_in_hours = body.get("expires_in_hours", 72)
|
|
|
|
if not email or not name:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"detail": "Email and name required", "code": "missing_fields"},
|
|
)
|
|
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
user_id = uuid.UUID(current_user["user_id"])
|
|
|
|
# Check if user already exists by email
|
|
user_q = await db.execute(
|
|
select(User).where(User.email == email)
|
|
)
|
|
existing_user = user_q.scalar_one_or_none()
|
|
|
|
if existing_user:
|
|
# Check if already a member of this tenant
|
|
ut_q = await db.execute(
|
|
select(UserTenant).where(
|
|
UserTenant.user_id == existing_user.id,
|
|
UserTenant.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
existing_ut = ut_q.scalar_one_or_none()
|
|
if existing_ut:
|
|
if existing_ut.status == "active" and existing_ut.role == "guest":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail={"detail": "Guest already active", "code": "guest_exists"},
|
|
)
|
|
# Re-invite: update existing membership
|
|
existing_ut.role = "guest"
|
|
existing_ut.status = "invited"
|
|
await db.flush()
|
|
else:
|
|
# Create new tenant membership with guest role
|
|
ut = UserTenant(
|
|
user_id=existing_user.id,
|
|
tenant_id=tenant_id,
|
|
is_default=False,
|
|
role="guest",
|
|
status="invited",
|
|
)
|
|
db.add(ut)
|
|
await db.flush()
|
|
else:
|
|
# Create new user with a random password (will be set on acceptance)
|
|
raw_token = secrets.token_urlsafe(32)
|
|
new_user = User(
|
|
email=email,
|
|
name=name,
|
|
password_hash=hash_password(raw_token), # Temporary password
|
|
is_active=True,
|
|
)
|
|
db.add(new_user)
|
|
await db.flush()
|
|
|
|
# Create tenant membership with guest role
|
|
ut = UserTenant(
|
|
user_id=new_user.id,
|
|
tenant_id=tenant_id,
|
|
is_default=False,
|
|
role="guest",
|
|
status="invited",
|
|
)
|
|
db.add(ut)
|
|
await db.commit()
|
|
await db.refresh(new_user)
|
|
|
|
return {
|
|
"email": email,
|
|
"name": name,
|
|
"status": "invited",
|
|
"role": "guest",
|
|
"message": "Guest invited — they can now log in via the normal login flow",
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
async def list_guests(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_admin),
|
|
):
|
|
"""List all guest users for the current tenant."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
|
|
# Query user_tenants with role='guest' and join users
|
|
result = await db.execute(
|
|
select(UserTenant, User)
|
|
.join(User, UserTenant.user_id == User.id)
|
|
.where(UserTenant.tenant_id == tenant_id)
|
|
.where(UserTenant.role == "guest")
|
|
.order_by(UserTenant.created_at.desc())
|
|
)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{
|
|
"id": str(ut.user_id),
|
|
"email": user.email,
|
|
"name": user.name,
|
|
"status": ut.status,
|
|
"role": "guest",
|
|
"created_at": ut.created_at.isoformat() if ut.created_at else None,
|
|
}
|
|
for ut, user in rows
|
|
]
|
|
|
|
|
|
@router.delete("/{guest_id}")
|
|
async def delete_guest(
|
|
guest_id: str,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: dict = Depends(require_admin),
|
|
):
|
|
"""Revoke a guest user's tenant membership and invalidate sessions."""
|
|
tenant_id = uuid.UUID(current_user["tenant_id"])
|
|
try:
|
|
gid = uuid.UUID(guest_id)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail={"detail": "Invalid guest ID", "code": "invalid_id"},
|
|
)
|
|
|
|
# Find the user_tenants entry for this guest
|
|
ut_q = await db.execute(
|
|
select(UserTenant)
|
|
.where(UserTenant.user_id == gid)
|
|
.where(UserTenant.tenant_id == tenant_id)
|
|
.where(UserTenant.role == "guest")
|
|
)
|
|
ut = ut_q.scalar_one_or_none()
|
|
if not ut:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail={"detail": "Guest not found", "code": "not_found"},
|
|
)
|
|
|
|
# Revoke: set status to disabled
|
|
ut.status = "disabled"
|
|
await db.commit()
|
|
|
|
# Invalidate all active sessions for this user
|
|
redis = get_redis()
|
|
if redis:
|
|
try:
|
|
from app.core.auth import invalidate_all_user_sessions
|
|
await invalidate_all_user_sessions(redis, gid)
|
|
except Exception:
|
|
pass
|
|
|
|
return {"message": "Guest revoked, all sessions invalidated", "status": "disabled"}
|