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
This commit is contained in:
2026-03-24 13:52:45 -06:00
parent f710c9c5fe
commit d59f85cd87
8 changed files with 831 additions and 7 deletions

View File

@@ -170,6 +170,36 @@ async def _embed_and_store_async(
current_tenant_id.reset(token)
@app.task(
name="orchestrator.tasks.send_invite_email_task",
bind=False,
max_retries=2,
default_retry_delay=30,
ignore_result=True, # Fire-and-forget — callers don't await the result
)
def send_invite_email_task(
to_email: str,
invitee_name: str,
tenant_name: str,
invite_url: str,
) -> None:
"""
Asynchronously send an invitation email via SMTP.
Dispatched fire-and-forget by the invitation API after creating an invitation.
If SMTP is not configured, logs a warning and returns silently.
Args:
to_email: Recipient email address.
invitee_name: Recipient display name.
tenant_name: Name of the tenant being joined.
invite_url: Full invitation acceptance URL.
"""
from shared.email import send_invite_email
send_invite_email(to_email, invitee_name, tenant_name, invite_url)
@app.task(
name="orchestrator.tasks.handle_message",
bind=True,