Files
konstruct/packages/shared/shared/api/rbac.py
Adolfo Delorenzo d59f85cd87 feat(04-rbac-01): RBAC guards + invite token + email + invitation API
- 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
2026-03-24 13:52:45 -06:00

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