Files
microfish/backend/app/security/policy.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
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.
2026-08-31 13:05:21 +07:00

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")