2026-06-29 00:10:10 +02:00
|
|
|
"""FastAPI dependencies: auth, db, tenant context, RBAC."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-07-25 21:03:46 +02:00
|
|
|
import logging
|
2026-06-29 00:10:10 +02:00
|
|
|
import uuid
|
|
|
|
|
from typing import Any
|
|
|
|
|
|
|
|
|
|
import redis.asyncio as aioredis
|
|
|
|
|
from fastapi import Depends, HTTPException, Request, status
|
2026-07-29 01:30:25 +02:00
|
|
|
from sqlalchemy import select
|
2026-06-29 00:10:10 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
|
|
|
|
from app.config import get_settings
|
2026-07-18 18:48:21 +02:00
|
|
|
from app.core.auth import get_redis, get_session_data, refresh_session_ttl
|
2026-07-29 01:30:25 +02:00
|
|
|
from app.core.db import get_db, set_tenant_context, set_user_context
|
2026-07-25 21:03:46 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
# Known write-permission modules — used by require_write() to check
|
|
|
|
|
# specific permissions instead of broad wildcards like *:write
|
|
|
|
|
_WRITE_PERMISSIONS = [
|
|
|
|
|
"contacts:write",
|
|
|
|
|
"contacts:create",
|
|
|
|
|
"users:write",
|
|
|
|
|
"roles:write",
|
|
|
|
|
"audit:write",
|
|
|
|
|
"attachments:write",
|
|
|
|
|
"workflows:write",
|
|
|
|
|
"sequences:write",
|
|
|
|
|
"addresses:write",
|
|
|
|
|
"taxes:write",
|
|
|
|
|
"currencies:write",
|
|
|
|
|
"notifications:write",
|
|
|
|
|
"import_export:write",
|
|
|
|
|
"user_preferences:write",
|
|
|
|
|
]
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
|
|
|
|
|
async def get_redis_dep() -> aioredis.Redis:
|
|
|
|
|
"""FastAPI dependency for Redis client."""
|
|
|
|
|
return get_redis()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_user(
|
|
|
|
|
request: Request,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
redis: aioredis.Redis = Depends(get_redis_dep),
|
|
|
|
|
) -> dict[str, Any]:
|
2026-07-25 21:03:46 +02:00
|
|
|
"""Get the current authenticated user from session cookie.
|
2026-07-15 21:59:45 +02:00
|
|
|
|
|
|
|
|
Returns session data dict with user_id, tenant_id, email, name, role,
|
|
|
|
|
and resolved permissions from Redis cache.
|
2026-06-29 00:10:10 +02:00
|
|
|
"""
|
|
|
|
|
settings = get_settings()
|
2026-07-25 02:31:33 +02:00
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
session_id = request.cookies.get(settings.session_cookie_name)
|
|
|
|
|
if not session_id:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail={"detail": "Not authenticated", "code": "not_authenticated"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
session_data = await get_session_data(redis, session_id)
|
|
|
|
|
if session_data is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail={"detail": "Session expired or invalid", "code": "session_invalid"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-07-18 18:48:21 +02:00
|
|
|
# Sliding session: extend TTL on each authenticated request
|
2026-08-04 14:34:06 +02:00
|
|
|
# Best-effort during Redis outage — session still valid from DB fallback
|
|
|
|
|
try:
|
|
|
|
|
await refresh_session_ttl(redis, session_id)
|
|
|
|
|
except Exception:
|
|
|
|
|
logger.debug("refresh_session_ttl failed (Redis may be down) — continuing")
|
2026-07-18 18:48:21 +02:00
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
if not session_data.get("is_active", True):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail={"detail": "User account deactivated", "code": "user_inactive"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Set RLS tenant context
|
|
|
|
|
tenant_id = uuid.UUID(session_data["tenant_id"])
|
|
|
|
|
await set_tenant_context(db, tenant_id)
|
|
|
|
|
|
2026-07-29 01:30:25 +02:00
|
|
|
# Set RLS user context for row-level security
|
|
|
|
|
user_id = uuid.UUID(session_data["user_id"])
|
|
|
|
|
from app.models.group import UserGroup
|
|
|
|
|
groups_q = await db.execute(
|
|
|
|
|
select(UserGroup.group_id)
|
|
|
|
|
.where(UserGroup.user_id == user_id)
|
|
|
|
|
.where(UserGroup.tenant_id == tenant_id)
|
|
|
|
|
)
|
|
|
|
|
group_ids = [row[0] for row in groups_q]
|
|
|
|
|
is_admin = session_data.get("is_system_admin", False)
|
|
|
|
|
await set_user_context(db, user_id, group_ids, is_admin)
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
# Check membership status and load role_id (P1.7: suspended membership should not be usable)
|
2026-07-29 12:28:08 +02:00
|
|
|
from app.models.user import UserTenant
|
|
|
|
|
membership_q = await db.execute(
|
2026-08-06 22:05:15 +02:00
|
|
|
select(UserTenant.status, UserTenant.role_id)
|
2026-07-29 12:28:08 +02:00
|
|
|
.where(UserTenant.user_id == user_id)
|
|
|
|
|
.where(UserTenant.tenant_id == tenant_id)
|
|
|
|
|
)
|
2026-08-06 22:05:15 +02:00
|
|
|
membership_row = membership_q.first()
|
|
|
|
|
membership_status = membership_row[0] if membership_row else None
|
|
|
|
|
role_id = membership_row[1] if membership_row else None
|
2026-07-29 12:28:08 +02:00
|
|
|
if membership_status is not None and membership_status != "active":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail={"detail": f"Mitgliedschaft ist {membership_status}, Zugriff verweigert", "code": "membership_suspended"},
|
|
|
|
|
)
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
# Cache user principals for this request — avoids N+1 queries in visibility.py
|
|
|
|
|
from app.core.principals import UserPrincipals, set_principals
|
|
|
|
|
set_principals(UserPrincipals(
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
group_ids=group_ids,
|
|
|
|
|
role_id=role_id,
|
|
|
|
|
))
|
|
|
|
|
|
2026-07-15 21:59:45 +02:00
|
|
|
# Load resolved permissions from cache (or DB on miss)
|
|
|
|
|
from app.core.permissions import get_cached_permissions
|
|
|
|
|
|
|
|
|
|
user_id = uuid.UUID(session_data["user_id"])
|
|
|
|
|
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
|
|
|
|
session_data["permissions"] = resolved.get("permissions", [])
|
|
|
|
|
session_data["denied_permissions"] = resolved.get("denied", [])
|
|
|
|
|
session_data["field_permissions"] = resolved.get("field_permissions", {})
|
|
|
|
|
session_data["is_system_admin"] = resolved.get("is_system_admin", False)
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
return session_data
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 14:06:55 +02:00
|
|
|
async def get_current_user_bearer(
|
|
|
|
|
request: Request,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Get the current user from a Bearer API token.
|
|
|
|
|
|
|
|
|
|
Alternative to session-based auth for programmatic access (MCP, API clients).
|
|
|
|
|
Returns the same dict shape as get_current_user.
|
|
|
|
|
"""
|
|
|
|
|
auth_header = request.headers.get("Authorization", "")
|
|
|
|
|
if not auth_header.startswith("Bearer "):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail={"detail": "Bearer token required", "code": "not_authenticated"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
token = auth_header[7:] # Strip "Bearer "
|
|
|
|
|
from app.core.api_token import verify_api_token
|
|
|
|
|
|
|
|
|
|
user_data = await verify_api_token(db, token)
|
|
|
|
|
if user_data is None:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
|
|
|
detail={"detail": "Invalid or expired token", "code": "token_invalid"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Set RLS tenant context
|
|
|
|
|
tenant_id = uuid.UUID(user_data["tenant_id"])
|
|
|
|
|
await set_tenant_context(db, tenant_id)
|
|
|
|
|
|
|
|
|
|
# Set RLS user context
|
|
|
|
|
user_id = uuid.UUID(user_data["user_id"])
|
|
|
|
|
from app.models.group import UserGroup
|
|
|
|
|
groups_q = await db.execute(
|
|
|
|
|
select(UserGroup.group_id)
|
|
|
|
|
.where(UserGroup.user_id == user_id)
|
|
|
|
|
.where(UserGroup.tenant_id == tenant_id)
|
|
|
|
|
)
|
|
|
|
|
group_ids = [row[0] for row in groups_q]
|
|
|
|
|
is_admin = user_data.get("is_system_admin", False)
|
|
|
|
|
await set_user_context(db, user_id, group_ids, is_admin)
|
|
|
|
|
|
2026-08-06 22:05:15 +02:00
|
|
|
# Load role_id and cache user principals for this request
|
|
|
|
|
from app.models.user import UserTenant
|
|
|
|
|
membership_q = await db.execute(
|
|
|
|
|
select(UserTenant.role_id)
|
|
|
|
|
.where(UserTenant.user_id == user_id)
|
|
|
|
|
.where(UserTenant.tenant_id == tenant_id)
|
|
|
|
|
)
|
|
|
|
|
role_id = membership_q.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
from app.core.principals import UserPrincipals, set_principals
|
|
|
|
|
set_principals(UserPrincipals(
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
tenant_id=tenant_id,
|
|
|
|
|
group_ids=group_ids,
|
|
|
|
|
role_id=role_id,
|
|
|
|
|
))
|
|
|
|
|
|
2026-08-03 14:06:55 +02:00
|
|
|
# Load resolved permissions
|
|
|
|
|
from app.core.permissions import get_cached_permissions
|
|
|
|
|
redis = get_redis()
|
|
|
|
|
resolved = await get_cached_permissions(db, redis, user_id, tenant_id)
|
|
|
|
|
user_data["permissions"] = resolved.get("permissions", [])
|
|
|
|
|
user_data["denied_permissions"] = resolved.get("denied", [])
|
|
|
|
|
user_data["field_permissions"] = resolved.get("field_permissions", {})
|
|
|
|
|
user_data["is_system_admin"] = resolved.get("is_system_admin", False)
|
|
|
|
|
|
|
|
|
|
return user_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_user_or_bearer(
|
|
|
|
|
request: Request,
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
redis: aioredis.Redis = Depends(get_redis_dep),
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
"""Get current user from session cookie OR Bearer token.
|
|
|
|
|
|
|
|
|
|
Tries session auth first, falls back to Bearer token.
|
|
|
|
|
Used by MCP routes that accept both auth methods.
|
|
|
|
|
"""
|
|
|
|
|
auth_header = request.headers.get("Authorization", "")
|
|
|
|
|
if auth_header.startswith("Bearer "):
|
|
|
|
|
return await get_current_user_bearer(request, db)
|
|
|
|
|
return await get_current_user(request, db, redis)
|
|
|
|
|
|
|
|
|
|
|
2026-06-29 00:10:10 +02:00
|
|
|
async def require_admin(
|
|
|
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
|
|
|
) -> dict[str, Any]:
|
2026-08-06 11:32:14 +02:00
|
|
|
"""Require admin access via is_system_admin or *:* permission.
|
2026-07-25 21:03:46 +02:00
|
|
|
|
2026-08-06 11:32:14 +02:00
|
|
|
⚠️ 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.
|
2026-07-25 21:03:46 +02:00
|
|
|
"""
|
|
|
|
|
if current_user.get("is_system_admin"):
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
# New permission system check
|
2026-07-15 21:59:45 +02:00
|
|
|
from app.core.permissions import check_permission
|
|
|
|
|
|
|
|
|
|
if check_permission(current_user, "*:*"):
|
|
|
|
|
return current_user
|
2026-07-25 21:03:46 +02:00
|
|
|
|
2026-07-15 21:59:45 +02:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail={"detail": "Admin access required", "code": "forbidden"},
|
|
|
|
|
)
|
2026-06-29 00:10:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def require_write(
|
|
|
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
|
|
|
) -> dict[str, Any]:
|
2026-08-06 11:32:14 +02:00
|
|
|
"""Require write permission via is_system_admin or specific module:write permissions.
|
2026-07-25 21:03:46 +02:00
|
|
|
|
2026-08-06 11:32:14 +02:00
|
|
|
⚠️ 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.
|
2026-07-25 21:03:46 +02:00
|
|
|
"""
|
2026-07-15 21:59:45 +02:00
|
|
|
if current_user.get("is_system_admin"):
|
|
|
|
|
return current_user
|
2026-07-25 21:03:46 +02:00
|
|
|
|
|
|
|
|
# Check via permission system for specific write permissions
|
2026-07-15 21:59:45 +02:00
|
|
|
from app.core.permissions import check_permission
|
|
|
|
|
|
2026-07-25 21:03:46 +02:00
|
|
|
for perm in _WRITE_PERMISSIONS:
|
|
|
|
|
if check_permission(current_user, perm):
|
|
|
|
|
return current_user
|
|
|
|
|
|
2026-07-15 21:59:45 +02:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail={"detail": "Write access required", "code": "forbidden"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_permission(permission: str):
|
|
|
|
|
"""FastAPI dependency factory: require a specific permission.
|
|
|
|
|
|
2026-08-06 11:32:14 +02:00
|
|
|
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.
|
|
|
|
|
|
2026-07-15 21:59:45 +02:00
|
|
|
Usage:
|
2026-07-23 17:17:32 +02:00
|
|
|
@router.get("/contacts", dependencies=[Depends(require_permission("contacts:read"))])
|
2026-07-15 21:59:45 +02:00
|
|
|
"""
|
|
|
|
|
async def _check(
|
|
|
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
|
|
|
) -> dict[str, Any]:
|
2026-08-06 11:32:14 +02:00
|
|
|
# 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
|
|
|
|
|
|
2026-07-15 21:59:45 +02:00
|
|
|
if current_user.get("is_system_admin"):
|
|
|
|
|
return current_user
|
|
|
|
|
from app.core.permissions import check_permission
|
|
|
|
|
|
|
|
|
|
if check_permission(current_user, permission):
|
|
|
|
|
return current_user
|
2026-06-29 00:10:10 +02:00
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
2026-07-15 21:59:45 +02:00
|
|
|
detail={
|
|
|
|
|
"detail": f"Permission '{permission}' required",
|
|
|
|
|
"code": "forbidden",
|
|
|
|
|
},
|
2026-06-29 00:10:10 +02:00
|
|
|
)
|
2026-07-15 21:59:45 +02:00
|
|
|
|
|
|
|
|
return _check
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_field_access(module: str, field: str, default: str = "read"):
|
|
|
|
|
"""FastAPI dependency factory: require field-level access.
|
|
|
|
|
|
|
|
|
|
Usage:
|
|
|
|
|
@router.get("/contacts/{id}", dependencies=[Depends(require_field_access("contacts", "annual_revenue"))])
|
|
|
|
|
"""
|
|
|
|
|
async def _check(
|
|
|
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
|
|
|
) -> dict[str, Any]:
|
|
|
|
|
if current_user.get("is_system_admin"):
|
|
|
|
|
return current_user
|
|
|
|
|
from app.core.permissions import check_field_access
|
|
|
|
|
|
|
|
|
|
access = check_field_access(current_user, module, field, default)
|
|
|
|
|
if access == "hidden":
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail={
|
|
|
|
|
"detail": f"Field '{field}' is hidden",
|
|
|
|
|
"code": "field_hidden",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return current_user
|
|
|
|
|
|
|
|
|
|
return _check
|
2026-06-29 00:10:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_tenant_id(
|
|
|
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
|
|
|
) -> uuid.UUID:
|
|
|
|
|
"""Extract tenant_id from current user session."""
|
|
|
|
|
return uuid.UUID(current_user["tenant_id"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def get_current_user_id(
|
|
|
|
|
current_user: dict[str, Any] = Depends(get_current_user),
|
|
|
|
|
) -> uuid.UUID:
|
|
|
|
|
"""Extract user_id from current user session."""
|
|
|
|
|
return uuid.UUID(current_user["user_id"])
|
2026-07-26 20:45:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_active_plugin(plugin_name: str):
|
|
|
|
|
"""FastAPI dependency factory: require that a plugin is active.
|
|
|
|
|
|
2026-07-29 13:02:33 +02:00
|
|
|
Checks both global activation (permission registry) and per-tenant
|
|
|
|
|
activation (tenant_plugin_activation table).
|
2026-07-26 21:46:02 +02:00
|
|
|
|
2026-08-03 15:20:06 +02:00
|
|
|
Uses the current_user dependency to get tenant_id — does NOT guess
|
|
|
|
|
the tenant from a new DB session via current_setting().
|
|
|
|
|
|
2026-07-29 17:40:40 +02:00
|
|
|
Uses Redis cache for per-tenant check to avoid DB query on every request.
|
|
|
|
|
Cache key: plugin-activation:{tenant_id}:{plugin_name}
|
|
|
|
|
TTL: 60 seconds. Invalidated on activate/deactivate.
|
|
|
|
|
|
2026-07-29 13:02:33 +02:00
|
|
|
Returns 403 if the plugin is not active.
|
2026-07-29 17:40:40 +02:00
|
|
|
Fails closed (503) on errors.
|
2026-07-26 20:45:42 +02:00
|
|
|
"""
|
2026-08-03 15:20:06 +02:00
|
|
|
async def _check(
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
) -> None:
|
2026-07-26 20:45:42 +02:00
|
|
|
from app.core.permission_registry import get_permission_registry
|
|
|
|
|
try:
|
|
|
|
|
registry = get_permission_registry()
|
|
|
|
|
if not registry.is_plugin_active(plugin_name):
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail={
|
|
|
|
|
"detail": f"Plugin '{plugin_name}' is not active",
|
|
|
|
|
"code": "plugin_inactive",
|
|
|
|
|
},
|
|
|
|
|
)
|
2026-08-03 16:26:06 +02:00
|
|
|
# Get tenant_id from existing db session (NOT a new session)
|
|
|
|
|
# The tenant context is set by middleware/get_current_user on this same session
|
|
|
|
|
from sqlalchemy import text as sa_text
|
2026-08-03 16:23:21 +02:00
|
|
|
|
2026-08-03 16:26:06 +02:00
|
|
|
result = await db.execute(
|
|
|
|
|
sa_text("SELECT current_setting('app.current_tenant_id', true)::uuid")
|
|
|
|
|
)
|
|
|
|
|
tenant_id = result.scalar()
|
2026-08-03 16:23:21 +02:00
|
|
|
|
2026-08-03 16:26:06 +02:00
|
|
|
if tenant_id is None:
|
|
|
|
|
# No tenant context — plugin is active by default (backward compatible)
|
|
|
|
|
return
|
2026-08-03 15:20:06 +02:00
|
|
|
|
2026-07-29 17:40:40 +02:00
|
|
|
# Per-tenant activation check with Redis cache
|
|
|
|
|
from app.core.redis import get_redis
|
2026-07-29 13:02:33 +02:00
|
|
|
from sqlalchemy import text
|
2026-07-29 17:40:40 +02:00
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
redis = get_redis()
|
2026-08-03 15:20:06 +02:00
|
|
|
if redis is not None:
|
2026-07-29 17:40:40 +02:00
|
|
|
cache_key = f"plugin-activation:{tenant_id}:{plugin_name}"
|
|
|
|
|
cached = await redis.get(cache_key)
|
|
|
|
|
if cached is not None:
|
|
|
|
|
is_active = json.loads(cached)
|
|
|
|
|
if not is_active:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail={
|
|
|
|
|
"detail": f"Plugin '{plugin_name}' is not active for this tenant",
|
|
|
|
|
"code": "plugin_inactive_tenant",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
return # Cache hit — plugin is active for this tenant
|
|
|
|
|
|
2026-08-03 15:20:06 +02:00
|
|
|
# Cache miss — query DB using the existing db session (tenant context already set)
|
|
|
|
|
result = await db.execute(
|
|
|
|
|
text("""
|
|
|
|
|
SELECT is_active FROM tenant_plugin_activation
|
|
|
|
|
WHERE plugin_name = :name
|
|
|
|
|
AND tenant_id = :tid
|
|
|
|
|
"""),
|
|
|
|
|
{"name": plugin_name, "tid": tenant_id},
|
|
|
|
|
)
|
|
|
|
|
row = result.first()
|
|
|
|
|
if row is not None:
|
|
|
|
|
is_active = row[0]
|
|
|
|
|
if redis is not None:
|
|
|
|
|
await redis.setex(cache_key, 60, json.dumps(is_active))
|
|
|
|
|
if not is_active:
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
|
|
|
detail={
|
|
|
|
|
"detail": f"Plugin '{plugin_name}' is not active for this tenant",
|
|
|
|
|
"code": "plugin_inactive_tenant",
|
|
|
|
|
},
|
2026-07-29 17:40:40 +02:00
|
|
|
)
|
|
|
|
|
else:
|
2026-08-03 15:20:06 +02:00
|
|
|
# No entry = default active (backward compatible)
|
|
|
|
|
if redis is not None:
|
|
|
|
|
await redis.setex(cache_key, 60, json.dumps(True))
|
2026-07-26 20:45:42 +02:00
|
|
|
except HTTPException:
|
|
|
|
|
raise
|
2026-07-29 12:33:46 +02:00
|
|
|
except Exception as exc:
|
|
|
|
|
# Fail-closed: if registry check fails, deny access (P1.2 fix)
|
|
|
|
|
logger.error("Plugin activation check failed for '%s': %s", plugin_name, exc)
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
|
|
|
detail={"detail": f"Plugin activation check failed", "code": "plugin_check_error"},
|
|
|
|
|
)
|
2026-07-26 20:45:42 +02:00
|
|
|
|
|
|
|
|
return _check
|