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
+6 -10
View File
@@ -301,17 +301,13 @@ def check_permission(
role_name: str, module: str, action: str, permissions: dict | None = None
) -> bool:
"""Check if a role has permission for a module+action.
Built-in roles: admin (all), editor (read+write), viewer (read only).
Custom roles use the permissions dict.
⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
Built-in role strings (admin/editor/viewer) no longer grant permissions directly.
All permission checks must go through the RBAC system in app.core.permissions.
This function is kept for backward compatibility but no longer bypasses checks
based on role_name alone.
"""
if role_name == "admin":
return True
if role_name == "editor":
if action in ("read", "write", "create", "update"):
return True
return False
if role_name == "viewer":
return action == "read"
# Custom role — check permissions dict
if permissions:
module_perms = permissions.get(module, {})
+4 -29
View File
@@ -245,35 +245,10 @@ async def resolve_permissions(
if role.field_permissions:
_merge_field_permissions(field_perms, role.field_permissions)
# Also check built-in role string on UserTenant for backward compatibility
if user_tenant is not None and user_tenant.role_id is None:
legacy_role = user_tenant.role
if legacy_role == "admin":
allowed.add("*:*")
elif legacy_role == "editor":
allowed |= {
"contacts:read", "contacts:write",
"users:read", "roles:read", "audit:read",
"attachments:read", "attachments:write",
"workflows:read", "workflows:write",
"sequences:read", "sequences:write",
"addresses:read", "addresses:write",
"taxes:read", "taxes:write",
"currencies:read", "currencies:write",
"notifications:read", "notifications:write",
"import_export:read", "import_export:write",
"user_preferences:read", "user_preferences:write",
}
elif legacy_role == "viewer":
allowed |= {
"contacts:read", "users:read", "roles:read",
"audit:read", "attachments:read", "workflows:read",
"sequences:read", "addresses:read", "taxes:read",
"currencies:read", "notifications:read",
"import_export:read",
"user_preferences:read", "user_preferences:write",
}
# ⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
# Built-in role strings (admin/editor/viewer) no longer grant permissions directly.
# All permissions must come through the Role-based RBAC system (role_id → Role.permissions).
# Migration 0112 creates Role records for existing users and links role_id.
# Load group permissions
async with db.begin_nested():
+27 -60
View File
@@ -14,8 +14,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.auth import get_redis, get_session_data, refresh_session_ttl
from app.core.db import get_db, set_tenant_context, set_user_context
from app.models.guest_user import GuestUser
logger = logging.getLogger(__name__)
# Known write-permission modules — used by require_write() to check
@@ -43,39 +41,6 @@ async def get_redis_dep() -> aioredis.Redis:
return get_redis()
async def get_current_guest(
request: Request,
redis: aioredis.Redis = Depends(get_redis_dep),
) -> dict[str, Any]:
"""Get the current guest user from guest session cookie.
Returns session data dict with guest_user_id, tenant_id, email, name.
Used for guest-specific endpoints (guest login, guest contacts).
"""
session_id = request.cookies.get("guest_session")
if not session_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Not authenticated", "code": "not_authenticated"},
)
import json
raw = await redis.get(f"guest_session:{session_id}")
if raw is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
)
session_data = json.loads(raw)
# Extend TTL on each request (sliding session)
await redis.expire(f"guest_session:{session_id}", 1800)
return session_data
async def get_current_user(
request: Request,
db: AsyncSession = Depends(get_db),
@@ -231,23 +196,15 @@ async def get_current_user_or_bearer(
async def require_admin(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require admin role (legacy + new permission system).
"""Require admin access via is_system_admin or *:* permission.
Legacy role string 'admin' is deprecated — log a warning when used.
New system uses is_system_admin or *:* permission.
⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
Legacy role string 'admin' no longer grants access. Users must have
is_system_admin=True or *:* permission through the RBAC system.
"""
if current_user.get("is_system_admin"):
return current_user
# Legacy role string fallback — deprecated
if current_user.get("role") == "admin":
logger.warning(
"Legacy role string 'admin' used for user=%s — deprecated, "
"migrate to is_system_admin or *:* permission",
current_user.get("user_id"),
)
return current_user
# New permission system check
from app.core.permissions import check_permission
@@ -263,24 +220,15 @@ async def require_admin(
async def require_write(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
"""Require write permission (admin, editor, or custom role with write perms).
"""Require write permission via is_system_admin or specific module:write permissions.
Legacy role strings 'admin'/'editor' are deprecated — log a warning when used.
New system checks specific module:write permissions instead of broad wildcards.
⚠️ Legacy Role Bypass entfernt — alle Admins müssen echte role_id haben
Legacy role strings 'admin'/'editor' no longer grant write access. Users must
have is_system_admin=True or specific module:write permissions through RBAC.
"""
if current_user.get("is_system_admin"):
return current_user
# Legacy role string fallback — deprecated
role = current_user.get("role", "viewer")
if role in ("admin", "editor"):
logger.warning(
"Legacy role string '%s' used for user=%s in require_write — deprecated, "
"migrate to specific module:write permissions",
role, current_user.get("user_id"),
)
return current_user
# Check via permission system for specific write permissions
from app.core.permissions import check_permission
@@ -297,12 +245,31 @@ async def require_write(
def require_permission(permission: str):
"""FastAPI dependency factory: require a specific permission.
Enforces API token scopes (Problem 2 fix): when the request is authenticated
via a Bearer API token, ``_token_scopes`` is set on the user context. The
required permission must be present in the scopes (wildcard match supported).
Session-auth requests (no ``_token_scopes``) use the normal permission check.
Usage:
@router.get("/contacts", dependencies=[Depends(require_permission("contacts:read"))])
"""
async def _check(
current_user: dict[str, Any] = Depends(get_current_user),
) -> dict[str, Any]:
# API token scope enforcement (Problem 2 fix)
token_scopes = current_user.get("_token_scopes")
if token_scopes is not None:
from app.core.permissions import _permission_matches_any
if not _permission_matches_any(set(token_scopes), permission):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"detail": f"Token scope '{permission}' required",
"code": "insufficient_scope",
},
)
return current_user
if current_user.get("is_system_admin"):
return current_user
from app.core.permissions import check_permission
+1 -3
View File
@@ -68,7 +68,6 @@ from app.routes import (
permission_templates,
delegations,
policies,
guest_auth,
guests,
outbox,
api_tokens,
@@ -448,8 +447,7 @@ def create_app() -> FastAPI:
app.include_router(delegations.router)
app.include_router(policies.router)
app.include_router(errors.router)
app.include_router(guest_auth.router)
app.include_router(guests.router)
app.include_router(guests.router) # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
app.include_router(workspaces.router)
app.include_router(outbox.router)
app.include_router(api_tokens.router)
+2 -4
View File
@@ -11,10 +11,10 @@ from app.models.contact_folder import ContactFolder
from app.models.contact_folder_permission import ContactFolderPermission
from app.models.contact_merge import ContactMergeHistory
from app.models.entity_permission import EntityPermission
from app.models.guest_user import GuestUser
from app.models.consumer_inbox import ConsumerInbox
from app.models.outbox_delivery import OutboxDelivery
from app.models.guest_invitation import GuestInvitation
# ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
# GuestUser and GuestInvitation models removed — guests are now regular users
from app.models.entity_policy import EntityPolicy
from app.models.permission_template import PermissionTemplate
from app.models.permission_delegation import PermissionDelegation
@@ -58,9 +58,7 @@ __all__ = [
"ContactFolderPermission",
"ContactMergeHistory",
"EntityPermission",
"GuestInvitation",
"ConsumerInbox",
"GuestUser",
"PermissionDelegation",
"PermissionTemplate",
"EntityPolicy",
+1 -2
View File
@@ -24,6 +24,5 @@ from app.routes import (
users, # noqa: F401
user_preferences, # noqa: F401
workflows, # noqa: F401
guest_auth, # noqa: F401
guests, # noqa: F401
guests, # noqa: F401 # ⚠️ Guest-System umgebaut — Guests sind jetzt reguläre User mit role=guest
)
+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"}
+12
View File
@@ -178,6 +178,7 @@ class AuthService:
return None
user_id = uuid.UUID(session_data["user_id"])
old_tenant_id = uuid.UUID(session_data["tenant_id"])
# Verify user has an ACTIVE membership in target tenant
ut_q = select(UserTenant).where(
@@ -194,6 +195,17 @@ class AuthService:
if updated is None:
return None
# Invalidate permission cache for old tenant (Problem 5 fix)
# Permissions are tenant-scoped — stale cache from old tenant must not leak
try:
from app.core.permissions import invalidate_permission_cache
await invalidate_permission_cache(redis, user_id, old_tenant_id)
except Exception:
logger.warning(
"Failed to invalidate permission cache for user=%s old_tenant=%s on tenant switch",
user_id, old_tenant_id, exc_info=True,
)
# Fetch tenant name
tenant_q = select(Tenant).where(Tenant.id == new_tenant_id)
tenant_result = await db.execute(tenant_q)