- rbac.py: PortalCaller dataclass + get_portal_caller dependency (header-based)
- rbac.py: require_platform_admin (403 for non-platform_admin)
- rbac.py: require_tenant_admin (platform_admin bypasses; customer_admin
checks UserTenantRole; operator always rejected)
- rbac.py: require_tenant_member (platform_admin bypasses; all roles
checked against UserTenantRole)
- invite_token.py: generate_invite_token (HMAC-SHA256, base64url, 48h TTL)
- invite_token.py: validate_invite_token (timing-safe compare_digest, TTL check)
- invite_token.py: token_to_hash (SHA-256 for DB storage)
- email.py: send_invite_email (sync smtplib, skips if smtp_host empty)
- invitations.py: POST /api/portal/invitations (create, requires tenant admin)
- invitations.py: POST /api/portal/invitations/accept (accept invitation)
- invitations.py: POST /api/portal/invitations/{id}/resend (regenerate token)
- invitations.py: GET /api/portal/invitations (list pending)
- portal.py: AuthVerifyResponse now returns role+tenant_ids+active_tenant_id
- portal.py: auth/register gated behind require_platform_admin
- tasks.py: send_invite_email_task Celery task (fire-and-forget)
- gateway/main.py: invitations_router mounted
174 lines
5.3 KiB
Python
174 lines
5.3 KiB
Python
"""
|
|
FastAPI RBAC guard dependencies for portal API endpoints.
|
|
|
|
Usage pattern:
|
|
@router.get("/tenants/{tenant_id}/agents")
|
|
async def list_agents(
|
|
tenant_id: UUID,
|
|
caller: PortalCaller = Depends(get_portal_caller),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> ...:
|
|
await require_tenant_member(tenant_id, caller, session)
|
|
...
|
|
|
|
Headers consumed (set by portal frontend / gateway middleware):
|
|
X-Portal-User-Id — UUID of the authenticated portal user
|
|
X-Portal-User-Role — Role string (platform_admin | customer_admin | customer_operator)
|
|
X-Portal-Tenant-Id — UUID of the caller's currently-selected tenant (optional)
|
|
|
|
These headers are populated by the Auth.js session forwarded through the portal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
|
|
from fastapi import Depends, Header, HTTPException, status
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from shared.db import get_session
|
|
from shared.models.auth import UserTenantRole
|
|
|
|
|
|
@dataclass
|
|
class PortalCaller:
|
|
"""Resolved caller identity from portal request headers."""
|
|
|
|
user_id: uuid.UUID
|
|
role: str
|
|
tenant_id: uuid.UUID | None = None
|
|
|
|
|
|
async def get_portal_caller(
|
|
x_portal_user_id: str = Header(..., alias="X-Portal-User-Id"),
|
|
x_portal_user_role: str = Header(..., alias="X-Portal-User-Role"),
|
|
x_portal_tenant_id: str | None = Header(default=None, alias="X-Portal-Tenant-Id"),
|
|
) -> PortalCaller:
|
|
"""
|
|
FastAPI dependency: parse and validate portal identity headers.
|
|
|
|
Returns PortalCaller with typed fields.
|
|
Raises 401 if X-Portal-User-Id is not a valid UUID.
|
|
"""
|
|
try:
|
|
user_id = uuid.UUID(x_portal_user_id)
|
|
except (ValueError, AttributeError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid X-Portal-User-Id header",
|
|
) from exc
|
|
|
|
tenant_id: uuid.UUID | None = None
|
|
if x_portal_tenant_id:
|
|
try:
|
|
tenant_id = uuid.UUID(x_portal_tenant_id)
|
|
except (ValueError, AttributeError) as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid X-Portal-Tenant-Id header",
|
|
) from exc
|
|
|
|
return PortalCaller(
|
|
user_id=user_id,
|
|
role=x_portal_user_role,
|
|
tenant_id=tenant_id,
|
|
)
|
|
|
|
|
|
def require_platform_admin(
|
|
caller: PortalCaller = Depends(get_portal_caller),
|
|
) -> PortalCaller:
|
|
"""
|
|
FastAPI dependency: ensure the caller is a platform admin.
|
|
|
|
Returns the caller if role == 'platform_admin'.
|
|
Raises 403 for any other role.
|
|
"""
|
|
if caller.role != "platform_admin":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Platform admin access required",
|
|
)
|
|
return caller
|
|
|
|
|
|
async def require_tenant_admin(
|
|
tenant_id: uuid.UUID,
|
|
caller: PortalCaller = Depends(get_portal_caller),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> PortalCaller:
|
|
"""
|
|
FastAPI dependency: ensure the caller is an admin for the given tenant.
|
|
|
|
- platform_admin: always passes (bypasses membership check)
|
|
- customer_admin: must have a UserTenantRole row for the tenant
|
|
- customer_operator: always rejected (403)
|
|
- unknown roles: always rejected (403)
|
|
|
|
Returns the caller on success.
|
|
"""
|
|
if caller.role == "platform_admin":
|
|
return caller
|
|
|
|
if caller.role != "customer_admin":
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Tenant admin access required",
|
|
)
|
|
|
|
# customer_admin: verify membership in this specific tenant
|
|
result = await session.execute(
|
|
select(UserTenantRole).where(
|
|
UserTenantRole.user_id == caller.user_id,
|
|
UserTenantRole.tenant_id == tenant_id,
|
|
UserTenantRole.role == "customer_admin",
|
|
)
|
|
)
|
|
membership = result.scalar_one_or_none()
|
|
if membership is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You do not have admin access to this tenant",
|
|
)
|
|
return caller
|
|
|
|
|
|
async def require_tenant_member(
|
|
tenant_id: uuid.UUID,
|
|
caller: PortalCaller = Depends(get_portal_caller),
|
|
session: AsyncSession = Depends(get_session),
|
|
) -> PortalCaller:
|
|
"""
|
|
FastAPI dependency: ensure the caller is a member of the given tenant.
|
|
|
|
- platform_admin: always passes (bypasses membership check)
|
|
- customer_admin or customer_operator: must have a UserTenantRole row for the tenant
|
|
- unknown roles: always rejected (403)
|
|
|
|
Returns the caller on success.
|
|
"""
|
|
if caller.role == "platform_admin":
|
|
return caller
|
|
|
|
if caller.role not in ("customer_admin", "customer_operator"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="Tenant member access required",
|
|
)
|
|
|
|
result = await session.execute(
|
|
select(UserTenantRole).where(
|
|
UserTenantRole.user_id == caller.user_id,
|
|
UserTenantRole.tenant_id == tenant_id,
|
|
)
|
|
)
|
|
membership = result.scalar_one_or_none()
|
|
if membership is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="You are not a member of this tenant",
|
|
)
|
|
return caller
|