fix(security): Fix critical permission system issues

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
This commit is contained in:
Agent Zero
2026-08-06 11:32:14 +02:00
parent 67015ef82b
commit 04d6562f5b
13 changed files with 635 additions and 632 deletions
+102 -148
View File
@@ -1,10 +1,10 @@
"""Guest management routes — invite, list, delete guests (admin only).
Uses secure invitation tokens (P1.6 fix):
- Token is a random 32-byte URL-safe string (secrets.token_urlsafe)
- Only the SHA-256 hash is stored in the database
- One-time use: used_at is set on acceptance
- Session revocation via Redis on guest deletion
⚠️ 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
@@ -15,15 +15,15 @@ import uuid
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sqlalchemy import select
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.guest_user import GuestUser
from app.models.guest_invitation import GuestInvitation
from app.models.user import User, UserTenant
from app.models.tenant import Tenant
router = APIRouter(prefix="/api/v1/guests", tags=["guests"])
settings = get_settings()
@@ -41,7 +41,10 @@ async def invite_guest(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Invite a guest user. Admin only. Returns a secure invitation token."""
"""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)
@@ -54,120 +57,76 @@ async def invite_guest(
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
expires_at = datetime.now(UTC) + timedelta(hours=expires_in_hours)
# Check if guest already exists for this tenant
existing_q = await db.execute(
select(GuestUser)
.where(GuestUser.email == email)
.where(GuestUser.tenant_id == tenant_id)
# Check if user already exists by email
user_q = await db.execute(
select(User).where(User.email == email)
)
existing = existing_q.scalar_one_or_none()
if existing:
if existing.status == "active":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail={"detail": "Guest already active", "code": "guest_exists"},
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,
)
# Re-invite: update existing record and create new token
existing.name = name
existing.status = "invited"
existing.invited_by = user_id
existing.expires_at = expires_at
existing.password_hash = None
await db.flush()
guest = existing
)
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:
guest = GuestUser(
# 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,
tenant_id=tenant_id,
invited_by=user_id,
status="invited",
expires_at=expires_at,
password_hash=hash_password(raw_token), # Temporary password
is_active=True,
)
db.add(guest)
db.add(new_user)
await db.flush()
# Generate secure invitation token
raw_token = secrets.token_urlsafe(32)
token_hash = _hash_token(raw_token)
invitation = GuestInvitation(
guest_user_id=guest.id,
token_hash=token_hash,
expires_at=expires_at,
created_by=user_id,
)
db.add(invitation)
await db.commit()
await db.refresh(guest)
# 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 {
"id": str(guest.id),
"email": guest.email,
"name": guest.name,
"status": guest.status,
"expires_at": guest.expires_at.isoformat() if guest.expires_at else None,
"invitation_token": raw_token, # Only returned once — not stored in plaintext
"email": email,
"name": name,
"status": "invited",
"role": "guest",
"message": "Guest invited — they can now log in via the normal login flow",
}
@router.post("/accept/{token}")
async def accept_invitation(
token: str,
body: dict,
db: AsyncSession = Depends(get_db),
):
"""Guest accepts invitation and sets password. Token is one-time use."""
password = body.get("password", "")
if not password or len(password) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"detail": "Password must be at least 8 characters", "code": "weak_password"},
)
# Hash the token and look up the invitation
token_hash = _hash_token(token)
inv_q = await db.execute(
select(GuestInvitation)
.where(GuestInvitation.token_hash == token_hash)
.where(GuestInvitation.used_at.is_(None))
.where(GuestInvitation.revoked_at.is_(None))
)
invitation = inv_q.scalar_one_or_none()
if not invitation:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": "Invitation not found, already used, or revoked", "code": "invitation_not_found"},
)
# Check expiration
if invitation.expires_at < datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail={"detail": "Invitation expired", "code": "invitation_expired"},
)
# Load guest
guest_q = await db.execute(
select(GuestUser).where(GuestUser.id == invitation.guest_user_id)
)
guest = guest_q.scalar_one_or_none()
if not guest or guest.status != "invited":
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"detail": "Guest account not found or already active", "code": "guest_not_found"},
)
# Set password and activate
guest.password_hash = hash_password(password)
guest.status = "active"
invitation.used_at = datetime.now(UTC) # One-time use
await db.commit()
return {"message": "Invitation accepted", "status": "active"}
@router.get("")
async def list_guests(
db: AsyncSession = Depends(get_db),
@@ -175,22 +134,27 @@ async def list_guests(
):
"""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(GuestUser)
.where(GuestUser.tenant_id == tenant_id)
.order_by(GuestUser.created_at.desc())
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())
)
guests = result.scalars().all()
rows = result.all()
return [
{
"id": str(g.id),
"email": g.email,
"name": g.name,
"status": g.status,
"expires_at": g.expires_at.isoformat() if g.expires_at else None,
"created_at": g.created_at.isoformat() if g.created_at else None,
"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 g in guests
for ut, user in rows
]
@@ -200,7 +164,7 @@ async def delete_guest(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_admin),
):
"""Delete/revoke a guest user and invalidate all sessions."""
"""Revoke a guest user's tenant membership and invalidate sessions."""
tenant_id = uuid.UUID(current_user["tenant_id"])
try:
gid = uuid.UUID(guest_id)
@@ -210,41 +174,31 @@ async def delete_guest(
detail={"detail": "Invalid guest ID", "code": "invalid_id"},
)
guest_q = await db.execute(
select(GuestUser).where(GuestUser.id == gid).where(GuestUser.tenant_id == tenant_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")
)
guest = guest_q.scalar_one_or_none()
if not 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: mark as revoked and clear password
guest.status = "revoked"
guest.password_hash = None
# Revoke all pending invitations
from sqlalchemy import update
await db.execute(
update(GuestInvitation)
.where(GuestInvitation.guest_user_id == gid)
.where(GuestInvitation.revoked_at.is_(None))
.values(revoked_at=datetime.now(UTC))
)
# Revoke: set status to disabled
ut.status = "disabled"
await db.commit()
# Invalidate all active guest sessions via Redis
# Invalidate all active sessions for this user
redis = get_redis()
if redis:
# Find and delete all guest sessions for this user
# Session keys are stored as session:{session_id} with guest_user_id inside
# We use a Redis index: guest_sessions:{guest_user_id} → set of session_ids
session_key = f"guest_sessions:{gid}"
session_ids = await redis.smembers(session_key)
if session_ids:
for sid in session_ids:
await redis.delete(f"session:{sid}")
await redis.delete(session_key)
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": "revoked"}
return {"message": "Guest revoked, all sessions invalidated", "status": "disabled"}