Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
122 lines
3.8 KiB
Python
122 lines
3.8 KiB
Python
"""Fail-closed authorization primitives for tenant-scoped resource services.
|
|
|
|
This module deliberately has no Flask or database dependency. Route handlers and
|
|
repositories can use the same policy contract without importing the whole app.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from enum import Enum
|
|
from typing import Any
|
|
|
|
|
|
class Role(str, Enum):
|
|
SUPER_ADMIN = "super_admin"
|
|
ADMIN = "admin"
|
|
USER = "user"
|
|
|
|
|
|
class AuthorizationError(PermissionError):
|
|
"""Raised when an actor cannot perform a requested operation."""
|
|
|
|
def __init__(self, code: str = "forbidden"):
|
|
self.code = code
|
|
super().__init__(code)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Actor:
|
|
"""Minimal authenticated identity required by policy checks."""
|
|
|
|
user_id: str
|
|
organization_id: str
|
|
role: Role | str
|
|
|
|
|
|
def _role(actor: Actor) -> Role:
|
|
try:
|
|
return actor.role if isinstance(actor.role, Role) else Role(actor.role)
|
|
except (TypeError, ValueError) as exc:
|
|
raise AuthorizationError("invalid_actor_role") from exc
|
|
|
|
|
|
def _require_non_empty(value: Any, code: str) -> str:
|
|
if not isinstance(value, str) or not value.strip():
|
|
raise AuthorizationError(code)
|
|
return value
|
|
|
|
|
|
def assert_can_access_resource(
|
|
actor: Actor,
|
|
*,
|
|
resource_organization_id: str,
|
|
owner_user_id: str | None,
|
|
action: str = "read",
|
|
platform_scope: bool = False,
|
|
) -> None:
|
|
"""Raise unless ``actor`` may access a tenant-owned resource.
|
|
|
|
``platform_scope`` is explicit even for super admins so a caller cannot
|
|
accidentally turn every ordinary resource lookup into a cross-tenant path.
|
|
A regular user must have an exact owner marker; missing/falsey ownership is
|
|
denied rather than treated as public.
|
|
"""
|
|
del action # The first contract is scope; action-specific rules layer on it.
|
|
role = _role(actor)
|
|
resource_org = _require_non_empty(resource_organization_id, "invalid_resource_scope")
|
|
actor_org = _require_non_empty(actor.organization_id, "invalid_actor_scope")
|
|
|
|
if role is Role.SUPER_ADMIN:
|
|
if platform_scope or actor_org == resource_org:
|
|
return
|
|
raise AuthorizationError("cross_tenant_scope_required")
|
|
|
|
if actor_org != resource_org:
|
|
raise AuthorizationError("cross_tenant_forbidden")
|
|
|
|
if role is Role.ADMIN:
|
|
return
|
|
|
|
if role is Role.USER and owner_user_id == actor.user_id and actor.user_id:
|
|
return
|
|
|
|
raise AuthorizationError("resource_owner_required")
|
|
|
|
|
|
def assert_can_manage_user(
|
|
actor: Actor,
|
|
*,
|
|
target_organization_id: str,
|
|
target_role: Role | str,
|
|
platform_scope: bool = False,
|
|
) -> None:
|
|
"""Raise unless ``actor`` may manage a target account/membership."""
|
|
role = _role(actor)
|
|
target_org = _require_non_empty(target_organization_id, "invalid_target_scope")
|
|
try:
|
|
requested_role = target_role if isinstance(target_role, Role) else Role(target_role)
|
|
except (TypeError, ValueError) as exc:
|
|
raise AuthorizationError("invalid_target_role") from exc
|
|
|
|
if role is Role.SUPER_ADMIN:
|
|
if platform_scope or actor.organization_id == target_org:
|
|
return
|
|
raise AuthorizationError("cross_tenant_scope_required")
|
|
|
|
if role is Role.ADMIN:
|
|
if actor.organization_id != target_org:
|
|
raise AuthorizationError("cross_tenant_forbidden")
|
|
if requested_role is Role.USER:
|
|
return
|
|
raise AuthorizationError("admin_role_grant_forbidden")
|
|
|
|
raise AuthorizationError("user_management_forbidden")
|
|
|
|
|
|
def assert_can_manage_llm_settings(actor: Actor, *, platform_scope: bool = False) -> None:
|
|
"""Only a super admin with explicit platform scope may mutate LLM settings."""
|
|
if _role(actor) is Role.SUPER_ADMIN and platform_scope:
|
|
return
|
|
raise AuthorizationError("llm_settings_forbidden")
|