diff --git a/app/bootstrap/application.py b/app/bootstrap/application.py index 27d381f..9cf940b 100644 --- a/app/bootstrap/application.py +++ b/app/bootstrap/application.py @@ -6,6 +6,12 @@ ) from app.core.errors import install_error_handlers from app.core.request_context import RequestContextMiddleware +from app.core.security_headers import SecurityHeadersMiddleware +from app.modules.identity.router import router as identity_router +from app.modules.sessions.middleware import ( + CookieCsrfMiddleware, + SessionWorkspaceContextMiddleware, +) def create_app(*, dependencies: ApplicationDependencies | None = None) -> FastAPI: @@ -18,6 +24,10 @@ def create_app(*, dependencies: ApplicationDependencies | None = None) -> FastAP app.state.dependencies = ( dependencies if dependencies is not None else create_application_dependencies() ) + app.add_middleware(SessionWorkspaceContextMiddleware) app.add_middleware(RequestContextMiddleware) + app.add_middleware(CookieCsrfMiddleware) + app.add_middleware(SecurityHeadersMiddleware) + app.include_router(identity_router) install_error_handlers(app) return app diff --git a/app/bootstrap/dependencies.py b/app/bootstrap/dependencies.py index 8c46c19..4cc622f 100644 --- a/app/bootstrap/dependencies.py +++ b/app/bootstrap/dependencies.py @@ -14,6 +14,9 @@ create_database_runtime, ) from app.db.schema import check_schema_compatibility +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.sessions.service import PostgresSessionService +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer from app.providers.protocols import ( DirectClientFactory, MetrikaClientFactory, @@ -44,6 +47,16 @@ class ApplicationDependencies: metrika_client_factory: MetrikaClientFactory wordstat_client_factory: WordstatClientFactory database_runtime: DatabaseRuntime | None = None + identity_repository: PostgresIdentityRepository | None = None + session_service: PostgresSessionService | None = None + workspace_authorizer: PostgresWorkspaceAuthorizer | None = None + fake_auth_enabled: bool = False + + +def _fake_auth_is_enabled(app_env: str) -> bool: + return app_env in {"local", "test"} and os.environ.get( + "DIRECTPILOT_ENABLE_FAKE_AUTH", "" + ).lower() == "1" def create_application_dependencies() -> ApplicationDependencies: @@ -64,6 +77,10 @@ def create_application_dependencies() -> ApplicationDependencies: metrika_client_factory=DefaultMetrikaClientFactory(), wordstat_client_factory=DefaultWordstatClientFactory(), database_runtime=runtime, + identity_repository=PostgresIdentityRepository(runtime.sessions), + session_service=PostgresSessionService(runtime.sessions), + workspace_authorizer=PostgresWorkspaceAuthorizer(runtime.sessions), + fake_auth_enabled=_fake_auth_is_enabled(app_env), ) if app_env in {"production", "staging"}: DatabaseSettings.from_mapping({}) diff --git a/app/core/request_context.py b/app/core/request_context.py index b32cb2f..570cffb 100644 --- a/app/core/request_context.py +++ b/app/core/request_context.py @@ -3,7 +3,7 @@ import re from contextvars import ContextVar, Token from dataclasses import dataclass -from uuid import uuid4 +from uuid import UUID, uuid4 from fastapi import Request from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint @@ -12,7 +12,9 @@ from app.bootstrap.dependencies import get_repository from app.core.errors import is_api_v1_path, safe_error_response from app.core.logging import log_request_completed, log_request_failed +from app.modules.tenancy.models import MembershipRole from app.repositories.context import bind_request_repository, reset_request_repository +from app.repositories.protocols import LegacyStoreRepository _REQUEST_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$") _REQUEST_CONTEXT: ContextVar[RequestContext | None] = ContextVar( @@ -20,9 +22,13 @@ ) -@dataclass(frozen=True, slots=True) +@dataclass(slots=True) class RequestContext: request_id: str + user_id: UUID | None = None + session_id: UUID | None = None + workspace_id: UUID | None = None + membership_role: MembershipRole | None = None def create_request_context(request_id: str | None) -> RequestContext: @@ -39,6 +45,32 @@ def get_request_context() -> RequestContext: return context +def bind_authenticated_request_context( + *, + user_id: UUID, + session_id: UUID, + workspace_id: UUID, + membership_role: MembershipRole, +) -> RequestContext: + """Attach freshly re-authorized tenant authority to this request only.""" + + context = get_request_context() + context.user_id = user_id + context.session_id = session_id + context.workspace_id = workspace_id + context.membership_role = membership_role + return context + + +def bind_authorized_request_repository( + repository: LegacyStoreRepository, +) -> Token[LegacyStoreRepository | None]: + """Bind a repository only after server-side workspace authorization.""" + + get_request_context() + return bind_request_repository(repository) + + class RequestContextMiddleware(BaseHTTPMiddleware): async def dispatch( self, request: Request, call_next: RequestResponseEndpoint diff --git a/app/core/security_headers.py b/app/core/security_headers.py new file mode 100644 index 0000000..9049b31 --- /dev/null +++ b/app/core/security_headers.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import Response + +_CONTENT_SECURITY_POLICY = ( + "default-src 'self'; " + "base-uri 'self'; " + "form-action 'self'; " + "frame-ancestors 'none'; " + "object-src 'none'; " + "script-src 'self'; " + "style-src 'self'; " + "img-src 'self' data:; " + "connect-src 'self'" +) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Attach the P3 browser hardening baseline to every response.""" + + async def dispatch( + self, + request: Request, + call_next: RequestResponseEndpoint, + ) -> Response: + response = await call_next(request) + response.headers["Content-Security-Policy"] = _CONTENT_SECURITY_POLICY + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()" + if request.url.scheme == "https": + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response diff --git a/app/db/migrations/versions/20260904_0004_identity_tenancy.py b/app/db/migrations/versions/20260904_0004_identity_tenancy.py new file mode 100644 index 0000000..23092d9 --- /dev/null +++ b/app/db/migrations/versions/20260904_0004_identity_tenancy.py @@ -0,0 +1,122 @@ +"""P3 identity and workspace membership foundation.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260904_0004" +down_revision = "20260904_0003" +branch_labels = None +depends_on = None + + +_USER_STATUSES = "status IN ('active', 'suspended', 'deleted')" +_WORKSPACE_KINDS = "kind IN ('personal', 'team')" +_WORKSPACE_STATUSES = "status IN ('active', 'suspended', 'deleted')" +_MEMBERSHIP_ROLES = "role IN ('viewer', 'operator', 'approver', 'admin', 'owner')" +_MEMBERSHIP_STATUSES = "status IN ('active', 'revoked')" + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("yandex_subject", sa.String(length=255), nullable=True), + sa.Column("email_normalized", sa.String(length=320), nullable=True), + sa.Column("display_name", sa.String(length=255), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column("last_login_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint(_USER_STATUSES, name="user_status"), + sa.PrimaryKeyConstraint("id", name=op.f("pk_users")), + sa.UniqueConstraint("yandex_subject", name=op.f("uq_users_yandex_subject")), + ) + op.create_table( + "workspaces", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("kind", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column("created_by_user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.CheckConstraint(_WORKSPACE_KINDS, name="workspace_kind"), + sa.CheckConstraint(_WORKSPACE_STATUSES, name="workspace_status"), + sa.ForeignKeyConstraint( + ["created_by_user_id"], + ["users.id"], + name=op.f("fk_workspaces_created_by_user_id_users"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_workspaces")), + ) + op.create_table( + "memberships", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("role", sa.String(length=16), nullable=False), + sa.Column("status", sa.String(length=16), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("CURRENT_TIMESTAMP"), + nullable=False, + ), + sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint(_MEMBERSHIP_ROLES, name="membership_role"), + sa.CheckConstraint(_MEMBERSHIP_STATUSES, name="membership_status"), + sa.ForeignKeyConstraint( + ["workspace_id"], + ["workspaces.id"], + name=op.f("fk_memberships_workspace_id_workspaces"), + ), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("fk_memberships_user_id_users"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_memberships")), + sa.UniqueConstraint( + "workspace_id", + "user_id", + name=op.f("uq_memberships_workspace_id"), + ), + ) + op.create_index(op.f("ix_memberships_workspace_id"), "memberships", ["workspace_id"]) + op.create_index(op.f("ix_memberships_user_id"), "memberships", ["user_id"]) + + for table_name in ("users", "workspaces", "memberships"): + op.execute(f"REVOKE ALL ON TABLE {table_name} FROM PUBLIC") + op.execute(f"GRANT SELECT, INSERT, UPDATE ON TABLE {table_name} TO directpilot_app") + + +def downgrade() -> None: + op.drop_index(op.f("ix_memberships_user_id"), table_name="memberships") + op.drop_index(op.f("ix_memberships_workspace_id"), table_name="memberships") + op.drop_table("memberships") + op.drop_table("workspaces") + op.drop_table("users") diff --git a/app/db/migrations/versions/20260904_0005_sessions.py b/app/db/migrations/versions/20260904_0005_sessions.py new file mode 100644 index 0000000..fbae607 --- /dev/null +++ b/app/db/migrations/versions/20260904_0005_sessions.py @@ -0,0 +1,57 @@ +"""P3 opaque server-side session storage.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260904_0005" +down_revision = "20260904_0004" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "sessions", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("token_hash", sa.String(length=64), nullable=False), + sa.Column("csrf_secret_hash", sa.String(length=64), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("active_workspace_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("idle_expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("idle_expires_at <= expires_at", name="session_idle_before_absolute"), + sa.ForeignKeyConstraint( + ["user_id"], + ["users.id"], + name=op.f("fk_sessions_user_id_users"), + ), + sa.ForeignKeyConstraint( + ["active_workspace_id"], + ["workspaces.id"], + name=op.f("fk_sessions_active_workspace_id_workspaces"), + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_sessions")), + sa.UniqueConstraint("token_hash", name=op.f("uq_sessions_token_hash")), + ) + op.create_index(op.f("ix_sessions_user_id"), "sessions", ["user_id"]) + op.create_index( + op.f("ix_sessions_active_token_hash"), + "sessions", + ["token_hash"], + unique=False, + postgresql_where=sa.text("revoked_at IS NULL"), + ) + op.execute("REVOKE ALL ON TABLE sessions FROM PUBLIC") + op.execute("GRANT SELECT, INSERT, UPDATE ON TABLE sessions TO directpilot_app") + + +def downgrade() -> None: + op.drop_index(op.f("ix_sessions_active_token_hash"), table_name="sessions") + op.drop_index(op.f("ix_sessions_user_id"), table_name="sessions") + op.drop_table("sessions") diff --git a/app/db/migrations/versions/20260904_0006_tenancy_rls.py b/app/db/migrations/versions/20260904_0006_tenancy_rls.py new file mode 100644 index 0000000..bc65cad --- /dev/null +++ b/app/db/migrations/versions/20260904_0006_tenancy_rls.py @@ -0,0 +1,312 @@ +"""P3 workspace tenancy, legacy backfill, and PostgreSQL RLS.""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects import postgresql + +revision = "20260904_0006" +down_revision = "20260904_0005" +branch_labels = None +depends_on = None + +_LEGACY_USER_ID = "00000000-0000-4000-8000-000000000301" +_LEGACY_WORKSPACE_ID = "00000000-0000-4000-8000-000000000302" +_LEGACY_MEMBERSHIP_ID = "00000000-0000-4000-8000-000000000303" +_WORKSPACE_SETTING = "NULLIF(current_setting('app.current_workspace_id', true), '')::uuid" +_USER_SETTING = "NULLIF(current_setting('app.current_user_id', true), '')::uuid" +_MEMBERSHIP_CHECK = f"public.directpilot_has_active_membership({_WORKSPACE_SETTING}, {_USER_SETTING})" + + +def _create_legacy_operator_workspace() -> None: + op.execute( + f""" + INSERT INTO users (id, yandex_subject, display_name, status, created_at, updated_at) + VALUES ('{_LEGACY_USER_ID}', 'legacy-operator', 'Legacy operator', 'active', + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO NOTHING + """ + ) + op.execute( + f""" + INSERT INTO workspaces (id, name, kind, status, created_by_user_id, created_at, updated_at) + VALUES ('{_LEGACY_WORKSPACE_ID}', 'Legacy operator workspace', 'personal', 'active', + '{_LEGACY_USER_ID}', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (id) DO NOTHING + """ + ) + op.execute( + f""" + INSERT INTO memberships (id, workspace_id, user_id, role, status, created_at, accepted_at) + VALUES ('{_LEGACY_MEMBERSHIP_ID}', '{_LEGACY_WORKSPACE_ID}', '{_LEGACY_USER_ID}', + 'owner', 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (workspace_id, user_id) DO NOTHING + """ + ) + + +def _add_workspace_column(table_name: str, *, nullable: bool) -> None: + op.add_column( + table_name, + sa.Column("workspace_id", postgresql.UUID(as_uuid=True), nullable=True), + ) + op.execute( + f"UPDATE {table_name} SET workspace_id = '{_LEGACY_WORKSPACE_ID}' " + "WHERE workspace_id IS NULL" + ) + if not nullable: + op.alter_column(table_name, "workspace_id", nullable=False) + op.create_foreign_key( + op.f(f"fk_{table_name}_workspace_id_workspaces"), + table_name, + "workspaces", + ["workspace_id"], + ["id"], + ) + op.create_index(op.f(f"ix_{table_name}_workspace_id"), table_name, ["workspace_id"]) + + +def _create_membership_helper() -> None: + op.execute("GRANT USAGE ON SCHEMA public TO directpilot_rls_helper") + op.execute("GRANT SELECT ON TABLE memberships TO directpilot_rls_helper") + op.execute( + """ + CREATE OR REPLACE FUNCTION public.directpilot_has_active_membership( + workspace_id uuid, + user_id uuid + ) RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = public, pg_temp + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.memberships AS m + WHERE m.workspace_id = $1 + AND m.user_id = $2 + AND m.status = 'active' + ) + $$ + """ + ) + op.execute("ALTER FUNCTION public.directpilot_has_active_membership(uuid, uuid) OWNER TO directpilot_rls_helper") + op.execute("REVOKE ALL ON FUNCTION public.directpilot_has_active_membership(uuid, uuid) FROM PUBLIC") + op.execute("GRANT EXECUTE ON FUNCTION public.directpilot_has_active_membership(uuid, uuid) TO directpilot_app") + + +def _bootstrap_workspace_check() -> str: + return ( + f"id = {_WORKSPACE_SETTING} AND created_by_user_id = {_USER_SETTING} AND " + "NOT public.directpilot_workspace_has_memberships(id)" + ) + + +def _bootstrap_membership_check() -> str: + return ( + f"workspace_id = {_WORKSPACE_SETTING} AND user_id = {_USER_SETTING} AND " + "role = 'owner' AND status = 'active' AND " + "public.directpilot_workspace_owned_by(workspace_id, user_id) AND " + "NOT public.directpilot_workspace_has_memberships(workspace_id)" + ) + + +def _create_bootstrap_helpers() -> None: + op.execute("GRANT SELECT ON TABLE workspaces TO directpilot_rls_helper") + op.execute( + """ + CREATE OR REPLACE FUNCTION public.directpilot_workspace_has_memberships( + workspace_id uuid + ) RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = public, pg_temp + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.memberships AS m + WHERE m.workspace_id = $1 + ) + $$ + """ + ) + op.execute("ALTER FUNCTION public.directpilot_workspace_has_memberships(uuid) OWNER TO directpilot_rls_helper") + op.execute("REVOKE ALL ON FUNCTION public.directpilot_workspace_has_memberships(uuid) FROM PUBLIC") + op.execute("GRANT EXECUTE ON FUNCTION public.directpilot_workspace_has_memberships(uuid) TO directpilot_app") + op.execute( + """ + CREATE OR REPLACE FUNCTION public.directpilot_workspace_owned_by( + workspace_id uuid, + user_id uuid + ) RETURNS boolean + LANGUAGE sql + STABLE + SECURITY DEFINER + SET search_path = public, pg_temp + AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.workspaces AS w + WHERE w.id = $1 + AND w.created_by_user_id = $2 + ) + $$ + """ + ) + op.execute("ALTER FUNCTION public.directpilot_workspace_owned_by(uuid, uuid) OWNER TO directpilot_rls_helper") + op.execute("REVOKE ALL ON FUNCTION public.directpilot_workspace_owned_by(uuid, uuid) FROM PUBLIC") + op.execute("GRANT EXECUTE ON FUNCTION public.directpilot_workspace_owned_by(uuid, uuid) TO directpilot_app") + + +def _enable_workspace_rls( + table_name: str, + *, + tenant_column: str = "workspace_id", + platform_auth_events: bool = False, + extra_check: str | None = None, +) -> None: + workspace_clause = f"{tenant_column} = {_WORKSPACE_SETTING}" + using_clause = f"{workspace_clause} AND {_MEMBERSHIP_CHECK}" + check_clause = using_clause if extra_check is None else f"({using_clause}) OR ({extra_check})" + if platform_auth_events: + check_clause = ( + f"({using_clause}) OR " + "(workspace_id IS NULL AND action LIKE 'platform_auth.%')" + ) + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} FORCE ROW LEVEL SECURITY") + op.execute( + f"CREATE POLICY {table_name}_workspace_isolation ON {table_name} " + f"FOR ALL TO directpilot_app USING ({using_clause}) WITH CHECK ({check_clause})" + ) + + +def upgrade() -> None: + _create_legacy_operator_workspace() + _create_membership_helper() + _create_bootstrap_helpers() + + _add_workspace_column("audit_events", nullable=True) + op.create_check_constraint( + "audit_workspace_or_platform_auth", + "audit_events", + "workspace_id IS NOT NULL OR action LIKE 'platform_auth.%'", + ) + + _add_workspace_column("idempotency_records", nullable=False) + op.drop_constraint( + "uq_idempotency_records_namespace", + "idempotency_records", + type_="unique", + ) + op.create_unique_constraint( + "uq_idempotency_records_workspace_namespace_key", + "idempotency_records", + ["workspace_id", "namespace", "key_hash"], + ) + + _add_workspace_column("campaign_drafts", nullable=False) + op.drop_constraint("pk_campaign_drafts", "campaign_drafts", type_="primary") + op.create_primary_key( + "pk_campaign_drafts", + "campaign_drafts", + ["id", "workspace_id"], + ) + + _add_workspace_column("semantic_change_packages", nullable=False) + op.drop_constraint( + "pk_semantic_change_packages", + "semantic_change_packages", + type_="primary", + ) + op.create_primary_key( + "pk_semantic_change_packages", + "semantic_change_packages", + ["package_id", "workspace_id"], + ) + + _enable_workspace_rls("workspaces", tenant_column="id", extra_check=_bootstrap_workspace_check()) + _enable_workspace_rls("memberships", extra_check=_bootstrap_membership_check()) + _enable_workspace_rls("audit_events", platform_auth_events=True) + _enable_workspace_rls("idempotency_records") + _enable_workspace_rls("campaign_drafts") + _enable_workspace_rls("semantic_change_packages") + + +def downgrade() -> None: + for table_name in ( + "semantic_change_packages", + "campaign_drafts", + "idempotency_records", + "audit_events", + "memberships", + "workspaces", + ): + op.execute(f"DROP POLICY IF EXISTS {table_name}_workspace_isolation ON {table_name}") + op.execute(f"ALTER TABLE {table_name} NO FORCE ROW LEVEL SECURITY") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") + + op.execute("DROP FUNCTION IF EXISTS public.directpilot_workspace_owned_by(uuid, uuid)") + op.execute("DROP FUNCTION IF EXISTS public.directpilot_workspace_has_memberships(uuid)") + op.execute("DROP FUNCTION IF EXISTS public.directpilot_has_active_membership(uuid, uuid)") + + op.drop_constraint( + "pk_semantic_change_packages", + "semantic_change_packages", + type_="primary", + ) + op.create_primary_key( + "pk_semantic_change_packages", + "semantic_change_packages", + ["package_id"], + ) + op.drop_index(op.f("ix_semantic_change_packages_workspace_id"), table_name="semantic_change_packages") + op.drop_constraint( + op.f("fk_semantic_change_packages_workspace_id_workspaces"), + "semantic_change_packages", + type_="foreignkey", + ) + op.drop_column("semantic_change_packages", "workspace_id") + + op.drop_constraint("pk_campaign_drafts", "campaign_drafts", type_="primary") + op.create_primary_key("pk_campaign_drafts", "campaign_drafts", ["id"]) + op.drop_index(op.f("ix_campaign_drafts_workspace_id"), table_name="campaign_drafts") + op.drop_constraint( + op.f("fk_campaign_drafts_workspace_id_workspaces"), + "campaign_drafts", + type_="foreignkey", + ) + op.drop_column("campaign_drafts", "workspace_id") + + op.drop_constraint( + "uq_idempotency_records_workspace_namespace_key", + "idempotency_records", + type_="unique", + ) + op.create_unique_constraint( + "uq_idempotency_records_namespace", + "idempotency_records", + ["namespace", "key_hash"], + ) + op.drop_index(op.f("ix_idempotency_records_workspace_id"), table_name="idempotency_records") + op.drop_constraint( + op.f("fk_idempotency_records_workspace_id_workspaces"), + "idempotency_records", + type_="foreignkey", + ) + op.drop_column("idempotency_records", "workspace_id") + + op.drop_constraint( + op.f("ck_audit_events_audit_workspace_or_platform_auth"), + "audit_events", + type_="check", + ) + op.drop_index(op.f("ix_audit_events_workspace_id"), table_name="audit_events") + op.drop_constraint( + op.f("fk_audit_events_workspace_id_workspaces"), + "audit_events", + type_="foreignkey", + ) + op.drop_column("audit_events", "workspace_id") diff --git a/app/db/models.py b/app/db/models.py index 2b6c310..5b4113c 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -4,11 +4,28 @@ from typing import Any from uuid import UUID -from sqlalchemy import Boolean, DateTime, MetaData, String, UniqueConstraint, text +from sqlalchemy import ( + Boolean, + DateTime, + ForeignKey, + MetaData, + String, + UniqueConstraint, + text, +) +from sqlalchemy import Enum as SqlEnum from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import UUID as PostgreSQLUUID from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from app.modules.tenancy.models import ( + MembershipRole, + MembershipStatus, + UserStatus, + WorkspaceKind, + WorkspaceStatus, +) + NAMING_CONVENTION = { "ix": "ix_%(column_0_label)s", "uq": "uq_%(table_name)s_%(column_0_name)s", @@ -22,10 +39,124 @@ class Base(DeclarativeBase): metadata = MetaData(naming_convention=NAMING_CONVENTION) +class UserRecord(Base): + __tablename__ = "users" + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + yandex_subject: Mapped[str | None] = mapped_column(String(255), nullable=True, unique=True) + email_normalized: Mapped[str | None] = mapped_column(String(320), nullable=True) + display_name: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[UserStatus] = mapped_column( + SqlEnum( + UserStatus, + native_enum=False, + values_callable=lambda values: [value.value for value in values], + ), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_login_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class WorkspaceRecord(Base): + __tablename__ = "workspaces" + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + kind: Mapped[WorkspaceKind] = mapped_column( + SqlEnum( + WorkspaceKind, + native_enum=False, + values_callable=lambda values: [value.value for value in values], + ), + nullable=False, + ) + status: Mapped[WorkspaceStatus] = mapped_column( + SqlEnum( + WorkspaceStatus, + native_enum=False, + values_callable=lambda values: [value.value for value in values], + ), + nullable=False, + ) + created_by_user_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + +class MembershipRecord(Base): + __tablename__ = "memberships" + __table_args__ = (UniqueConstraint("workspace_id", "user_id"),) + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + workspace_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + nullable=False, + ) + user_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False, + ) + role: Mapped[MembershipRole] = mapped_column( + SqlEnum( + MembershipRole, + native_enum=False, + values_callable=lambda values: [value.value for value in values], + ), + nullable=False, + ) + status: Mapped[MembershipStatus] = mapped_column( + SqlEnum( + MembershipStatus, + native_enum=False, + values_callable=lambda values: [value.value for value in values], + ), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class SessionRecord(Base): + __tablename__ = "sessions" + + id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True) + csrf_secret_hash: Mapped[str] = mapped_column(String(64), nullable=False) + user_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("users.id"), + nullable=False, + ) + active_workspace_id: Mapped[UUID | None] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + nullable=True, + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + idle_expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class AuditEventRecord(Base): __tablename__ = "audit_events" id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + workspace_id: Mapped[UUID | None] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + nullable=True, + ) actor: Mapped[str] = mapped_column(String(128), nullable=False) action: Mapped[str] = mapped_column(String(128), nullable=False) entity: Mapped[str] = mapped_column(String(255), nullable=False) @@ -41,10 +172,15 @@ class AuditEventRecord(Base): class IdempotencyRecord(Base): __tablename__ = "idempotency_records" __table_args__ = ( - UniqueConstraint("namespace", "key_hash", name="uq_idempotency_records_namespace"), + UniqueConstraint("workspace_id", "namespace", "key_hash"), ) id: Mapped[UUID] = mapped_column(PostgreSQLUUID(as_uuid=True), primary_key=True) + workspace_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + nullable=False, + ) namespace: Mapped[str] = mapped_column(String(128), nullable=False) key_hash: Mapped[str] = mapped_column(String(64), nullable=False) request_hash: Mapped[str] = mapped_column(String(64), nullable=False) @@ -62,6 +198,12 @@ class CampaignDraftRecord(Base): __tablename__ = "campaign_drafts" id: Mapped[str] = mapped_column(String(64), primary_key=True) + workspace_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + primary_key=True, + nullable=False, + ) payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), @@ -79,6 +221,12 @@ class SemanticChangePackageRecord(Base): __tablename__ = "semantic_change_packages" package_id: Mapped[str] = mapped_column(String(64), primary_key=True) + workspace_id: Mapped[UUID] = mapped_column( + PostgreSQLUUID(as_uuid=True), + ForeignKey("workspaces.id"), + primary_key=True, + nullable=False, + ) payload: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), diff --git a/app/db/rls.py b/app/db/rls.py new file mode 100644 index 0000000..108d47e --- /dev/null +++ b/app/db/rls.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from collections.abc import Generator +from contextlib import contextmanager +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import text +from sqlalchemy.orm import Session, sessionmaker + +LEGACY_OPERATOR_USER_ID = UUID("00000000-0000-4000-8000-000000000301") +LEGACY_OPERATOR_WORKSPACE_ID = UUID("00000000-0000-4000-8000-000000000302") + + +class WorkspaceContextError(ValueError): + """Raised before any tenant query when a workspace context is invalid.""" + + +@dataclass(frozen=True, slots=True) +class TenantContext: + workspace_id: UUID + user_id: UUID | None = None + + +def require_uuid(value: UUID | str, *, field_name: str) -> UUID: + if isinstance(value, UUID): + return value + try: + return UUID(value) + except (AttributeError, TypeError, ValueError): + raise WorkspaceContextError(f"{field_name} is invalid") from None + + +def set_local_tenant_context(session: Session, context: TenantContext) -> None: + """Bind only transaction-local PostgreSQL GUCs for a tenant operation. + + ``set_config(..., true)`` is PostgreSQL's parameter-safe equivalent of + ``SET LOCAL`` and resets automatically at transaction end. + """ + + session.execute( + text("SELECT set_config('app.current_workspace_id', :workspace_id, true)"), + {"workspace_id": str(context.workspace_id)}, + ) + session.execute( + text("SELECT set_config('app.current_user_id', :user_id, true)"), + {"user_id": "" if context.user_id is None else str(context.user_id)}, + ) + + +@contextmanager +def tenant_transaction( + sessions: sessionmaker[Session], + *, + workspace_id: UUID | str, + user_id: UUID | str | None = None, +) -> Generator[Session, None, None]: + """Yield one transaction that cannot leak tenant GUCs into a pool.""" + + context = TenantContext( + workspace_id=require_uuid(workspace_id, field_name="workspace id"), + user_id=(None if user_id is None else require_uuid(user_id, field_name="user id")), + ) + with sessions() as session: + with session.begin(): + set_local_tenant_context(session, context) + yield session diff --git a/app/db/roles.py b/app/db/roles.py index 968e900..1b67d39 100644 --- a/app/db/roles.py +++ b/app/db/roles.py @@ -37,6 +37,12 @@ def bootstrap_database_roles(runtime: DatabaseRuntime, *, app_password: str) -> """ DO $$ BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'directpilot_rls_helper' + ) THEN + CREATE ROLE directpilot_rls_helper + NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION BYPASSRLS NOINHERIT; + END IF; IF NOT EXISTS ( SELECT 1 FROM pg_roles WHERE rolname = 'directpilot_app' ) THEN @@ -57,6 +63,8 @@ def bootstrap_database_roles(runtime: DatabaseRuntime, *, app_password: str) -> """ DO $$ BEGIN + EXECUTE 'ALTER ROLE directpilot_rls_helper WITH NOLOGIN NOSUPERUSER NOCREATEDB ' + 'NOCREATEROLE NOREPLICATION BYPASSRLS NOINHERIT'; EXECUTE format( 'ALTER ROLE directpilot_app WITH LOGIN NOSUPERUSER NOCREATEDB ' 'NOCREATEROLE NOREPLICATION NOBYPASSRLS NOINHERIT PASSWORD %L', diff --git a/app/modules/actions/idempotency.py b/app/modules/actions/idempotency.py index aa0cda2..4404606 100644 --- a/app/modules/actions/idempotency.py +++ b/app/modules/actions/idempotency.py @@ -13,6 +13,11 @@ from sqlalchemy.orm import Session, sessionmaker from app.db.models import IdempotencyRecord +from app.db.rls import ( + LEGACY_OPERATOR_USER_ID, + LEGACY_OPERATOR_WORKSPACE_ID, + tenant_transaction, +) class IdempotencyError(RuntimeError): @@ -20,7 +25,7 @@ class IdempotencyError(RuntimeError): class IdempotencyConflictError(IdempotencyError): - """Raised when one namespace/key is reused with a different request.""" + """Raised when one workspace namespace/key is reused with different input.""" class IdempotencyStateError(IdempotencyError): @@ -30,6 +35,7 @@ class IdempotencyStateError(IdempotencyError): @dataclass(frozen=True, slots=True) class IdempotencyClaim: record_id: UUID + workspace_id: UUID namespace: str key_hash: str request_hash: str @@ -89,10 +95,18 @@ def _sanitize_result(value: Any) -> Any: class PostgresIdempotencyRepository: - """PostgreSQL uniqueness and row-locking implementation for P2 idempotency.""" + """P2 idempotency storage with an explicit RLS-protected workspace scope.""" - def __init__(self, sessions: sessionmaker[Session]) -> None: + def __init__( + self, + sessions: sessionmaker[Session], + *, + workspace_id: UUID = LEGACY_OPERATOR_WORKSPACE_ID, + user_id: UUID = LEGACY_OPERATOR_USER_ID, + ) -> None: self._sessions = sessions + self._workspace_id = workspace_id + self._user_id = user_id def claim( self, @@ -107,42 +121,50 @@ def claim( request_hash = canonical_request_hash(payload) candidate = IdempotencyRecord( id=uuid4(), + workspace_id=self._workspace_id, namespace=namespace, key_hash=key_hash, request_hash=request_hash, status="pending", result=None, ) - with self._sessions() as session: - with session.begin(): - inserted = session.execute( - insert(IdempotencyRecord) - .values( - id=candidate.id, - namespace=candidate.namespace, - key_hash=candidate.key_hash, - request_hash=candidate.request_hash, - status=candidate.status, - ) - .on_conflict_do_nothing(index_elements=["namespace", "key_hash"]) - .returning(IdempotencyRecord) - ).scalar_one_or_none() - if inserted is not None: - return self._to_claim(inserted, is_owner=True) - - existing = session.scalar( - select(IdempotencyRecord) - .where( - IdempotencyRecord.namespace == namespace, - IdempotencyRecord.key_hash == key_hash, - ) - .with_for_update() + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: + inserted = session.execute( + insert(IdempotencyRecord) + .values( + id=candidate.id, + workspace_id=candidate.workspace_id, + namespace=candidate.namespace, + key_hash=candidate.key_hash, + request_hash=candidate.request_hash, + status=candidate.status, + ) + .on_conflict_do_nothing( + index_elements=["workspace_id", "namespace", "key_hash"] + ) + .returning(IdempotencyRecord) + ).scalar_one_or_none() + if inserted is not None: + return self._to_claim(inserted, is_owner=True) + + existing = session.scalar( + select(IdempotencyRecord) + .where( + IdempotencyRecord.workspace_id == self._workspace_id, + IdempotencyRecord.namespace == namespace, + IdempotencyRecord.key_hash == key_hash, ) - if existing is None: - raise IdempotencyError("idempotency record is unavailable") - if existing.request_hash != request_hash: - raise IdempotencyConflictError("idempotency key conflicts with a prior request") - return self._to_claim(existing, is_owner=False) + .with_for_update() + ) + if existing is None: + raise IdempotencyError("idempotency record is unavailable") + if existing.request_hash != request_hash: + raise IdempotencyConflictError("idempotency key conflicts with a prior request") + return self._to_claim(existing, is_owner=False) def complete( self, @@ -151,7 +173,7 @@ def complete( status: str, result: Mapping[str, Any], ) -> IdempotencyClaim: - if not claim.is_owner: + if not claim.is_owner or claim.workspace_id != self._workspace_id: raise IdempotencyStateError("only the original idempotency claimant can finalize") if status not in _FINAL_STATUSES: raise IdempotencyStateError("idempotency status is invalid") @@ -160,27 +182,34 @@ def complete( if not isinstance(safe_result, dict): raise IdempotencyError("idempotency result is not an object") - with self._sessions() as session: - with session.begin(): - record = session.scalar( - select(IdempotencyRecord) - .where(IdempotencyRecord.id == claim.record_id) - .with_for_update() + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: + record = session.scalar( + select(IdempotencyRecord) + .where( + IdempotencyRecord.id == claim.record_id, + IdempotencyRecord.workspace_id == self._workspace_id, ) - if record is None or record.request_hash != claim.request_hash: - raise IdempotencyStateError("idempotency claim is unavailable") - if record.status != "pending": - raise IdempotencyStateError("idempotency claim was already finalized") - record.status = status - record.result = safe_result - record.completed_at = datetime.now(timezone.utc) - session.flush() - return self._to_claim(record, is_owner=True) + .with_for_update() + ) + if record is None or record.request_hash != claim.request_hash: + raise IdempotencyStateError("idempotency claim is unavailable") + if record.status != "pending": + raise IdempotencyStateError("idempotency claim was already finalized") + record.status = status + record.result = safe_result + record.completed_at = datetime.now(timezone.utc) + session.flush() + return self._to_claim(record, is_owner=True) @staticmethod def _to_claim(record: IdempotencyRecord, *, is_owner: bool) -> IdempotencyClaim: return IdempotencyClaim( record_id=record.id, + workspace_id=record.workspace_id, namespace=record.namespace, key_hash=record.key_hash, request_hash=record.request_hash, diff --git a/app/modules/audit/repository.py b/app/modules/audit/repository.py index 389bbe4..5ddf09f 100644 --- a/app/modules/audit/repository.py +++ b/app/modules/audit/repository.py @@ -3,12 +3,17 @@ import re from collections.abc import Mapping from typing import Any -from uuid import uuid4 +from uuid import UUID, uuid4 from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from app.db.models import AuditEventRecord +from app.db.rls import ( + LEGACY_OPERATOR_USER_ID, + LEGACY_OPERATOR_WORKSPACE_ID, + tenant_transaction, +) from app.models import AuditEvent _SAFE_METADATA_KEYS = frozenset( @@ -45,10 +50,18 @@ def sanitize_audit_metadata(details: Mapping[str, Any] | None) -> dict[str, str class PostgresAuditRepository: - """Append-only PostgreSQL implementation of the P1 audit seam.""" + """Append-only PostgreSQL audit records scoped to one explicit workspace.""" - def __init__(self, sessions: sessionmaker[Session]) -> None: + def __init__( + self, + sessions: sessionmaker[Session], + *, + workspace_id: UUID | None = LEGACY_OPERATOR_WORKSPACE_ID, + user_id: UUID = LEGACY_OPERATOR_USER_ID, + ) -> None: self._sessions = sessions + self._workspace_id = workspace_id + self._user_id = user_id def append_audit( self, @@ -59,25 +72,44 @@ def append_audit( dry_run: bool = True, details: Mapping[str, Any] | None = None, ) -> AuditEvent: + if self._workspace_id is None and not action.startswith("platform_auth."): + raise ValueError("audit workspace is required") record = AuditEventRecord( id=uuid4(), + workspace_id=self._workspace_id, actor=actor, action=action, entity=entity, dry_run=dry_run, details=sanitize_audit_metadata(details) or None, ) - with self._sessions() as session: - with session.begin(): + if self._workspace_id is None: + with self._sessions() as session: + with session.begin(): + session.add(record) + else: + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: session.add(record) return self._to_model(record) @property def audit_events(self) -> list[AuditEvent]: - with self._sessions() as session: + if self._workspace_id is None: + return [] + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: records = session.scalars( - select(AuditEventRecord).order_by( + select(AuditEventRecord) + .where(AuditEventRecord.workspace_id == self._workspace_id) + .order_by( AuditEventRecord.occurred_at, AuditEventRecord.id, ) diff --git a/app/modules/identity/__init__.py b/app/modules/identity/__init__.py new file mode 100644 index 0000000..0e2adf2 --- /dev/null +++ b/app/modules/identity/__init__.py @@ -0,0 +1 @@ +"""Identity lifecycle primitives for local/test and future provider adapters.""" diff --git a/app/modules/identity/repository.py b/app/modules/identity/repository.py new file mode 100644 index 0000000..5187cd8 --- /dev/null +++ b/app/modules/identity/repository.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import MembershipRecord, UserRecord, WorkspaceRecord +from app.db.rls import TenantContext, set_local_tenant_context, tenant_transaction +from app.modules.tenancy.models import ( + MembershipRole, + MembershipStatus, + UserStatus, + WorkspaceKind, + WorkspaceStatus, +) + + +@dataclass(frozen=True, slots=True) +class PersonalWorkspace: + """The three records that must succeed or fail in one transaction.""" + + user: UserRecord + workspace: WorkspaceRecord + membership: MembershipRecord + + +class PostgresIdentityRepository: + """Persist P3 identity records without provider/OAuth behavior.""" + + def __init__(self, sessions: sessionmaker[Session]) -> None: + self._sessions = sessions + + def create_personal_workspace( + self, + *, + display_name: str, + workspace_name: str, + yandex_subject: str | None = None, + email_normalized: str | None = None, + ) -> PersonalWorkspace: + if not display_name.strip() or not workspace_name.strip(): + raise ValueError("display name and workspace name are required") + + now = datetime.now(timezone.utc) + user = UserRecord( + id=uuid4(), + yandex_subject=yandex_subject, + email_normalized=email_normalized, + display_name=display_name.strip(), + status=UserStatus.ACTIVE, + created_at=now, + updated_at=now, + last_login_at=now, + ) + workspace = WorkspaceRecord( + id=uuid4(), + name=workspace_name.strip(), + kind=WorkspaceKind.PERSONAL, + status=WorkspaceStatus.ACTIVE, + created_by_user_id=user.id, + created_at=now, + updated_at=now, + ) + membership = MembershipRecord( + id=uuid4(), + workspace_id=workspace.id, + user_id=user.id, + role=MembershipRole.OWNER, + status=MembershipStatus.ACTIVE, + created_at=now, + accepted_at=now, + revoked_at=None, + ) + + with self._sessions() as session: + with session.begin(): + session.add(user) + session.flush() + set_local_tenant_context( + session, + TenantContext(workspace_id=workspace.id, user_id=user.id), + ) + session.add(workspace) + session.flush() + session.add(membership) + session.flush() + + return PersonalWorkspace(user=user, workspace=workspace, membership=membership) + + def update_membership( + self, + *, + workspace_id: UUID | str, + user_id: UUID | str, + role: MembershipRole, + status: MembershipStatus, + ) -> None: + """Persist role/revocation changes that future authorizers re-read.""" + + now = datetime.now(timezone.utc) + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + record = session.scalar( + select(MembershipRecord) + .where( + MembershipRecord.workspace_id == workspace_id, + MembershipRecord.user_id == user_id, + ) + .with_for_update() + ) + if record is None: + raise KeyError("workspace membership is unavailable") + record.role = role + record.status = status + record.revoked_at = now if status is MembershipStatus.REVOKED else None + if status is MembershipStatus.ACTIVE and record.accepted_at is None: + record.accepted_at = now diff --git a/app/modules/identity/router.py b/app/modules/identity/router.py new file mode 100644 index 0000000..2d7f135 --- /dev/null +++ b/app/modules/identity/router.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel, Field +from starlette.responses import JSONResponse, Response + +from app.bootstrap.dependencies import get_application_dependencies +from app.core.request_context import bind_authenticated_request_context +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.sessions.cookies import ( + SESSION_COOKIE_NAME, + clear_session_cookie, + set_session_cookie, +) +from app.modules.sessions.service import AuthenticatedSession, PostgresSessionService +from app.modules.tenancy.authorization import ( + AuthorizedMembership, + PostgresWorkspaceAuthorizer, +) +from app.modules.tenancy.policy import AuthorizationDenied, Capability + +router = APIRouter() + + +class _FakeLoginPayload(BaseModel): + display_name: Annotated[str, Field(min_length=1, max_length=128)] + workspace_name: Annotated[str | None, Field(min_length=1, max_length=128)] = None + + +def _fake_auth_services( + request: Request, +) -> tuple[PostgresIdentityRepository, PostgresSessionService, PostgresWorkspaceAuthorizer]: + dependencies = get_application_dependencies(request) + if ( + not dependencies.fake_auth_enabled + or dependencies.identity_repository is None + or dependencies.session_service is None + or dependencies.workspace_authorizer is None + ): + raise HTTPException(status_code=404, detail="not found") + return ( + dependencies.identity_repository, + dependencies.session_service, + dependencies.workspace_authorizer, + ) + + +def _authorized_session( + request: Request, + *, + capability: Capability, +) -> tuple[AuthenticatedSession, AuthorizedMembership]: + _, service, authorizer = _fake_auth_services(request) + token = request.cookies.get(SESSION_COOKIE_NAME) + if token is None: + raise HTTPException(status_code=401, detail="authentication required") + principal = service.authenticate(token, touch=False) + if principal is None or principal.active_workspace_id is None: + raise HTTPException(status_code=401, detail="authentication required") + try: + membership = authorizer.authorize( + user_id=principal.user_id, + workspace_id=principal.active_workspace_id, + capability=capability, + ) + except AuthorizationDenied as exc: + raise HTTPException(status_code=403, detail="workspace access denied") from exc + bind_authenticated_request_context( + user_id=principal.user_id, + session_id=principal.id, + workspace_id=membership.workspace_id, + membership_role=membership.role, + ) + return principal, membership + + +@router.post("/api/v1/_test/identity/login", include_in_schema=False) +def fake_login(payload: _FakeLoginPayload, request: Request) -> JSONResponse: + identity_repository, session_service, _ = _fake_auth_services(request) + workspace_name = payload.workspace_name or f"{payload.display_name.strip()} workspace" + created = identity_repository.create_personal_workspace( + display_name=payload.display_name, + workspace_name=workspace_name, + ) + issued = session_service.issue( + user_id=created.user.id, + active_workspace_id=created.workspace.id, + ) + response = JSONResponse( + { + "user_id": str(created.user.id), + "workspace_id": str(created.workspace.id), + "csrf_token": issued.csrf_token, + } + ) + set_session_cookie(response, token=issued.token, expires_at=issued.expires_at) + return response + + +@router.get("/api/v1/_test/identity/session", include_in_schema=False) +def current_fake_session(request: Request) -> dict[str, str]: + principal, membership = _authorized_session( + request, + capability=Capability.READ_WORKSPACE_DATA, + ) + return { + "user_id": str(principal.user_id), + "workspace_id": str(membership.workspace_id), + } + + +@router.post("/api/v1/_test/identity/logout", include_in_schema=False) +def fake_logout(request: Request) -> Response: + _, session_service, _ = _fake_auth_services(request) + token = request.cookies.get(SESSION_COOKIE_NAME) + if token is None or not session_service.logout(token): + raise HTTPException(status_code=401, detail="authentication required") + response = Response(status_code=204) + clear_session_cookie(response) + return response diff --git a/app/modules/sessions/__init__.py b/app/modules/sessions/__init__.py new file mode 100644 index 0000000..9d1c927 --- /dev/null +++ b/app/modules/sessions/__init__.py @@ -0,0 +1 @@ +"""Opaque session persistence and HTTP cookie contracts.""" diff --git a/app/modules/sessions/cookies.py b/app/modules/sessions/cookies.py new file mode 100644 index 0000000..1e5ad67 --- /dev/null +++ b/app/modules/sessions/cookies.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from starlette.responses import Response + +SESSION_COOKIE_NAME = "__Host-directpilot_session" + + +def set_session_cookie(response: Response, *, token: str, expires_at: datetime) -> None: + """Set the non-negotiable host-only opaque-session cookie attributes.""" + + now = datetime.now(timezone.utc) + max_age = max(0, int((expires_at - now).total_seconds())) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + max_age=max_age, + path="/", + domain=None, + secure=True, + httponly=True, + samesite="lax", + ) + + +def clear_session_cookie(response: Response) -> None: + response.delete_cookie( + key=SESSION_COOKIE_NAME, + path="/", + domain=None, + secure=True, + httponly=True, + samesite="lax", + ) diff --git a/app/modules/sessions/middleware.py b/app/modules/sessions/middleware.py new file mode 100644 index 0000000..05457af --- /dev/null +++ b/app/modules/sessions/middleware.py @@ -0,0 +1,116 @@ +from __future__ import annotations + +from urllib.parse import urlsplit + +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from starlette.responses import JSONResponse, Response + +from app.core.request_context import ( + bind_authenticated_request_context, + bind_authorized_request_repository, +) +from app.modules.sessions.cookies import SESSION_COOKIE_NAME +from app.modules.tenancy.policy import AuthorizationDenied, Capability +from app.repositories.context import reset_request_repository +from app.repositories.postgres_store import PostgresLegacyStoreRepository + +_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"}) + + +def _same_origin(request: Request, origin: str) -> bool: + parsed = urlsplit(origin) + if parsed.path or parsed.query or parsed.fragment: + return False + return parsed.scheme == request.url.scheme and parsed.netloc == request.headers.get("host") + + +def _csrf_request_is_same_site(request: Request) -> bool: + origin = request.headers.get("origin") + if origin is not None and not _same_origin(request, origin): + return False + return request.headers.get("sec-fetch-site", "").lower() != "cross-site" + + +class SessionWorkspaceContextMiddleware(BaseHTTPMiddleware): + """Bind a repository only from the opaque session's authorized workspace.""" + + async def dispatch( + self, + request: Request, + call_next: RequestResponseEndpoint, + ) -> Response: + session_token = request.cookies.get(SESSION_COOKIE_NAME) + if session_token is None: + return await call_next(request) + + dependencies = request.app.state.dependencies + service = getattr(dependencies, "session_service", None) + authorizer = getattr(dependencies, "workspace_authorizer", None) + repository = getattr(dependencies, "repository", None) + if service is None or authorizer is None: + return JSONResponse( + status_code=401, + content={"error": {"code": "authentication_required"}}, + ) + + principal = service.authenticate(session_token, touch=False) + if principal is None or principal.active_workspace_id is None: + return JSONResponse( + status_code=401, + content={"error": {"code": "authentication_required"}}, + ) + try: + membership = authorizer.authorize( + user_id=principal.user_id, + workspace_id=principal.active_workspace_id, + capability=Capability.READ_WORKSPACE_DATA, + ) + except AuthorizationDenied: + return JSONResponse( + status_code=403, + content={"error": {"code": "workspace_access_denied"}}, + ) + + bind_authenticated_request_context( + user_id=principal.user_id, + session_id=principal.id, + workspace_id=membership.workspace_id, + membership_role=membership.role, + ) + repository_token = None + if isinstance(repository, PostgresLegacyStoreRepository): + repository_token = bind_authorized_request_repository( + repository.for_workspace(membership.workspace_id, membership.user_id) + ) + try: + return await call_next(request) + finally: + if repository_token is not None: + reset_request_repository(repository_token) + + +class CookieCsrfMiddleware(BaseHTTPMiddleware): + """Require a server-validated synchronizer value for unsafe cookie requests.""" + + async def dispatch( + self, + request: Request, + call_next: RequestResponseEndpoint, + ) -> Response: + if request.method not in _SAFE_METHODS: + session_token = request.cookies.get(SESSION_COOKIE_NAME) + if session_token is not None: + service = getattr(request.app.state.dependencies, "session_service", None) + csrf_token = request.headers.get("X-CSRF-Token") + if ( + service is None + or csrf_token is None + or not _csrf_request_is_same_site(request) + or not service.verify_csrf(session_token, csrf_token) + ): + return JSONResponse( + status_code=403, + content={"error": {"code": "csrf_validation_failed"}}, + ) + return await call_next(request) diff --git a/app/modules/sessions/service.py b/app/modules/sessions/service.py new file mode 100644 index 0000000..aa99aef --- /dev/null +++ b/app/modules/sessions/service.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +from dataclasses import dataclass, field +from datetime import datetime, timedelta, timezone +from uuid import UUID, uuid4 + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import SessionRecord + + +@dataclass(frozen=True, slots=True) +class SessionLifetime: + idle: timedelta = timedelta(hours=8) + absolute: timedelta = timedelta(days=7) + + def __post_init__(self) -> None: + if self.idle <= timedelta() or self.absolute <= timedelta(): + raise ValueError("session lifetimes must be positive") + + +@dataclass(frozen=True, slots=True) +class IssuedSession: + id: UUID + user_id: UUID + active_workspace_id: UUID | None + expires_at: datetime + token: str = field(repr=False) + csrf_token: str = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class AuthenticatedSession: + id: UUID + user_id: UUID + active_workspace_id: UUID | None + expires_at: datetime + + +def hash_session_secret(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _utc_now(value: datetime | None) -> datetime: + now = datetime.now(timezone.utc) if value is None else value + if now.tzinfo is None: + raise ValueError("session time must be timezone-aware") + return now.astimezone(timezone.utc) + + +class PostgresSessionService: + """Opaque browser sessions: only irreversible token hashes reach PostgreSQL.""" + + def __init__( + self, + sessions: sessionmaker[Session], + *, + lifetime: SessionLifetime | None = None, + ) -> None: + self._sessions = sessions + self._lifetime = lifetime or SessionLifetime() + + def issue( + self, + *, + user_id: UUID, + active_workspace_id: UUID | None = None, + now: datetime | None = None, + ) -> IssuedSession: + issued_at = _utc_now(now) + token = secrets.token_urlsafe(32) + csrf_token = secrets.token_urlsafe(32) + expires_at = issued_at + self._lifetime.absolute + record = SessionRecord( + id=uuid4(), + token_hash=hash_session_secret(token), + csrf_secret_hash=hash_session_secret(csrf_token), + user_id=user_id, + active_workspace_id=active_workspace_id, + created_at=issued_at, + last_seen_at=issued_at, + idle_expires_at=min(issued_at + self._lifetime.idle, expires_at), + expires_at=expires_at, + revoked_at=None, + ) + with self._sessions() as session: + with session.begin(): + session.add(record) + session.flush() + return self._to_issued(record, token=token, csrf_token=csrf_token) + + def authenticate( + self, + token: str, + *, + now: datetime | None = None, + touch: bool = True, + ) -> AuthenticatedSession | None: + observed_at = _utc_now(now) + token_hash = hash_session_secret(token) + with self._sessions() as session: + with session.begin(): + record = session.scalar( + select(SessionRecord).where(SessionRecord.token_hash == token_hash) + ) + if record is None or not self._is_active(record, observed_at): + if record is not None and record.revoked_at is None: + record.revoked_at = observed_at + return None + if touch: + record.last_seen_at = observed_at + record.idle_expires_at = min( + observed_at + self._lifetime.idle, + record.expires_at, + ) + return self._to_authenticated(record) + + def verify_csrf( + self, + token: str, + csrf_token: str, + *, + now: datetime | None = None, + ) -> bool: + principal = self.authenticate(token, now=now) + if principal is None: + return False + csrf_hash = hash_session_secret(csrf_token) + with self._sessions() as session: + record = session.scalar(select(SessionRecord).where(SessionRecord.id == principal.id)) + return record is not None and hmac.compare_digest(record.csrf_secret_hash, csrf_hash) + + def rotate(self, token: str, *, now: datetime | None = None) -> IssuedSession | None: + rotated_at = _utc_now(now) + token_hash = hash_session_secret(token) + next_token = secrets.token_urlsafe(32) + next_csrf_token = secrets.token_urlsafe(32) + with self._sessions() as session: + with session.begin(): + current = session.scalar( + select(SessionRecord) + .where(SessionRecord.token_hash == token_hash) + .with_for_update() + ) + if current is None or not self._is_active(current, rotated_at): + if current is not None and current.revoked_at is None: + current.revoked_at = rotated_at + return None + current.revoked_at = rotated_at + replacement = SessionRecord( + id=uuid4(), + token_hash=hash_session_secret(next_token), + csrf_secret_hash=hash_session_secret(next_csrf_token), + user_id=current.user_id, + active_workspace_id=current.active_workspace_id, + created_at=rotated_at, + last_seen_at=rotated_at, + idle_expires_at=min( + rotated_at + self._lifetime.idle, + current.expires_at, + ), + expires_at=current.expires_at, + revoked_at=None, + ) + session.add(replacement) + session.flush() + return self._to_issued(replacement, token=next_token, csrf_token=next_csrf_token) + + def revoke(self, token: str, *, now: datetime | None = None) -> bool: + revoked_at = _utc_now(now) + token_hash = hash_session_secret(token) + with self._sessions() as session: + with session.begin(): + record = session.scalar( + select(SessionRecord) + .where(SessionRecord.token_hash == token_hash) + .with_for_update() + ) + if record is None or record.revoked_at is not None: + return False + record.revoked_at = revoked_at + return True + + def logout(self, token: str, *, now: datetime | None = None) -> bool: + return self.revoke(token, now=now) + + @staticmethod + def _is_active(record: SessionRecord, observed_at: datetime) -> bool: + return ( + record.revoked_at is None + and observed_at < record.idle_expires_at + and observed_at < record.expires_at + ) + + @staticmethod + def _to_authenticated(record: SessionRecord) -> AuthenticatedSession: + return AuthenticatedSession( + id=record.id, + user_id=record.user_id, + active_workspace_id=record.active_workspace_id, + expires_at=record.expires_at, + ) + + @staticmethod + def _to_issued( + record: SessionRecord, + *, + token: str, + csrf_token: str, + ) -> IssuedSession: + return IssuedSession( + id=record.id, + user_id=record.user_id, + active_workspace_id=record.active_workspace_id, + expires_at=record.expires_at, + token=token, + csrf_token=csrf_token, + ) diff --git a/app/modules/tenancy/__init__.py b/app/modules/tenancy/__init__.py new file mode 100644 index 0000000..2c9fe63 --- /dev/null +++ b/app/modules/tenancy/__init__.py @@ -0,0 +1 @@ +"""Workspace tenancy domain types and authorization policies.""" diff --git a/app/modules/tenancy/authorization.py b/app/modules/tenancy/authorization.py new file mode 100644 index 0000000..27a0558 --- /dev/null +++ b/app/modules/tenancy/authorization.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from app.db.models import MembershipRecord, WorkspaceRecord +from app.db.rls import tenant_transaction +from app.modules.tenancy.models import MembershipRole, MembershipStatus, WorkspaceStatus +from app.modules.tenancy.policy import AuthorizationDenied, Capability, RbacPolicy + + +@dataclass(frozen=True, slots=True) +class AuthorizedMembership: + user_id: UUID + workspace_id: UUID + role: MembershipRole + + +class PostgresWorkspaceAuthorizer: + """Load current membership authority on each operation; sessions hold none.""" + + def __init__( + self, + sessions: sessionmaker[Session], + *, + policy: RbacPolicy | None = None, + ) -> None: + self._sessions = sessions + self._policy = policy or RbacPolicy() + + def authorize( + self, + *, + user_id: UUID, + workspace_id: UUID, + capability: Capability, + ) -> AuthorizedMembership: + with tenant_transaction( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) as session: + workspace = session.scalar( + select(WorkspaceRecord).where( + WorkspaceRecord.id == workspace_id, + WorkspaceRecord.status == WorkspaceStatus.ACTIVE, + ) + ) + membership = session.scalar( + select(MembershipRecord).where( + MembershipRecord.workspace_id == workspace_id, + MembershipRecord.user_id == user_id, + ) + ) + if workspace is None or membership is None or membership.status is not MembershipStatus.ACTIVE: + raise AuthorizationDenied("workspace membership is inactive") + self._policy.require(membership.role, capability) + return AuthorizedMembership( + user_id=user_id, + workspace_id=workspace_id, + role=membership.role, + ) diff --git a/app/modules/tenancy/models.py b/app/modules/tenancy/models.py new file mode 100644 index 0000000..9fc4c86 --- /dev/null +++ b/app/modules/tenancy/models.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +from enum import Enum + + +class UserStatus(str, Enum): + ACTIVE = "active" + SUSPENDED = "suspended" + DELETED = "deleted" + + +class WorkspaceKind(str, Enum): + PERSONAL = "personal" + TEAM = "team" + + +class WorkspaceStatus(str, Enum): + ACTIVE = "active" + SUSPENDED = "suspended" + DELETED = "deleted" + + +class MembershipRole(str, Enum): + """Roles accepted for an active workspace membership.""" + + VIEWER = "viewer" + OPERATOR = "operator" + APPROVER = "approver" + ADMIN = "admin" + OWNER = "owner" + + +class MembershipStatus(str, Enum): + ACTIVE = "active" + REVOKED = "revoked" + + +def parse_membership_role(value: str) -> MembershipRole: + """Validate external role input against the closed P3 role set.""" + + try: + return MembershipRole(value) + except ValueError as exc: + raise ValueError("membership role is invalid") from exc diff --git a/app/modules/tenancy/policy.py b/app/modules/tenancy/policy.py new file mode 100644 index 0000000..1049f16 --- /dev/null +++ b/app/modules/tenancy/policy.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from enum import Enum + +from app.modules.tenancy.models import MembershipRole + + +class Capability(str, Enum): + READ_WORKSPACE_DATA = "read_workspace_data" + CREATE_PROPOSAL = "create_proposal" + APPROVE_PROPOSAL = "approve_proposal" + EXECUTE_APPROVED_ACTION = "execute_approved_action" + MANAGE_PROVIDER_CONNECTIONS = "manage_provider_connections" + MANAGE_MEMBERS = "manage_members" + DELETE_OR_TRANSFER_WORKSPACE = "delete_or_transfer_workspace" + + +class AuthorizationDenied(PermissionError): + """Raised when the current membership does not grant a capability.""" + + +_ALLOWED_ROLES: dict[Capability, frozenset[MembershipRole]] = { + Capability.READ_WORKSPACE_DATA: frozenset(MembershipRole), + Capability.CREATE_PROPOSAL: frozenset( + { + MembershipRole.OPERATOR, + MembershipRole.APPROVER, + MembershipRole.ADMIN, + MembershipRole.OWNER, + } + ), + Capability.APPROVE_PROPOSAL: frozenset( + { + MembershipRole.APPROVER, + MembershipRole.ADMIN, + MembershipRole.OWNER, + } + ), + Capability.EXECUTE_APPROVED_ACTION: frozenset( + {MembershipRole.ADMIN, MembershipRole.OWNER} + ), + Capability.MANAGE_PROVIDER_CONNECTIONS: frozenset( + {MembershipRole.ADMIN, MembershipRole.OWNER} + ), + Capability.MANAGE_MEMBERS: frozenset({MembershipRole.ADMIN, MembershipRole.OWNER}), + Capability.DELETE_OR_TRANSFER_WORKSPACE: frozenset({MembershipRole.OWNER}), +} + + +class RbacPolicy: + """The single P3 role-to-capability authority for workspace operations.""" + + def allows(self, role: MembershipRole, capability: Capability) -> bool: + return role in _ALLOWED_ROLES[capability] + + def require(self, role: MembershipRole, capability: Capability) -> None: + if not self.allows(role, capability): + raise AuthorizationDenied("workspace access is denied") diff --git a/app/repositories/postgres_store.py b/app/repositories/postgres_store.py index 5808066..898491a 100644 --- a/app/repositories/postgres_store.py +++ b/app/repositories/postgres_store.py @@ -3,13 +3,20 @@ import hashlib from collections.abc import Mapping from datetime import datetime, timezone +from itertools import count from types import MappingProxyType from typing import Any, Callable, Literal +from uuid import UUID from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker from app.db.models import CampaignDraftRecord, SemanticChangePackageRecord +from app.db.rls import ( + LEGACY_OPERATOR_USER_ID, + LEGACY_OPERATOR_WORKSPACE_ID, + tenant_transaction, +) from app.models import ( AuditEvent, Campaign, @@ -34,6 +41,17 @@ def __init__(self, audit: PostgresAuditRepository) -> None: super().__init__() self._audit = audit + def hydrate_drafts(self, drafts: dict[str, CampaignDraft]) -> None: + """Restore tenant-local draft state without reusing durable identifiers.""" + + self.drafts = drafts + persisted_numbers = ( + int(draft_id.removeprefix("draft_")) + for draft_id in drafts + if draft_id.startswith("draft_") and draft_id.removeprefix("draft_").isdigit() + ) + self._draft_counter = count(max(persisted_numbers, default=0) + 1) + def append_audit( self, action: str, @@ -60,12 +78,37 @@ class PostgresLegacyStoreRepository: for every persistent P2 operation. """ - def __init__(self, sessions: sessionmaker[Session]) -> None: + def __init__( + self, + sessions: sessionmaker[Session], + *, + workspace_id: UUID = LEGACY_OPERATOR_WORKSPACE_ID, + user_id: UUID = LEGACY_OPERATOR_USER_ID, + ) -> None: self._sessions = sessions - self._audit = PostgresAuditRepository(sessions) - self._idempotency = PostgresIdempotencyRepository(sessions) + self._workspace_id = workspace_id + self._user_id = user_id + self._audit = PostgresAuditRepository( + sessions, + workspace_id=workspace_id, + user_id=user_id, + ) + self._idempotency = PostgresIdempotencyRepository( + sessions, + workspace_id=workspace_id, + user_id=user_id, + ) self._legacy = _AuditDelegatingMockStore(self._audit) + def for_workspace(self, workspace_id: UUID, user_id: UUID) -> PostgresLegacyStoreRepository: + """Create a request-scoped adapter only after server-side authorization.""" + + return PostgresLegacyStoreRepository( + self._sessions, + workspace_id=workspace_id, + user_id=user_id, + ) + @property def campaigns(self) -> Mapping[str, Campaign]: return { @@ -152,20 +195,41 @@ def preview_draft(self, draft_id: str) -> Any: def save_semantic_package(self, package: SemanticChangePackage) -> None: payload = package.model_dump(mode="json") - with self._sessions() as session: - with session.begin(): - record = session.get(SemanticChangePackageRecord, package.package_id) - if record is None: - session.add( - SemanticChangePackageRecord(package_id=package.package_id, payload=payload) + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: + record = session.scalar( + select(SemanticChangePackageRecord).where( + SemanticChangePackageRecord.package_id == package.package_id, + SemanticChangePackageRecord.workspace_id == self._workspace_id, + ) + ) + if record is None: + session.add( + SemanticChangePackageRecord( + package_id=package.package_id, + workspace_id=self._workspace_id, + payload=payload, ) - else: - record.payload = payload - record.updated_at = datetime.now(timezone.utc) + ) + else: + record.payload = payload + record.updated_at = datetime.now(timezone.utc) def get_semantic_package(self, package_id: str) -> SemanticChangePackage: - with self._sessions() as session: - record = session.get(SemanticChangePackageRecord, package_id) + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: + record = session.scalar( + select(SemanticChangePackageRecord).where( + SemanticChangePackageRecord.package_id == package_id, + SemanticChangePackageRecord.workspace_id == self._workspace_id, + ) + ) if record is None: raise KeyError("semantic_change_package_not_found") return SemanticChangePackage.model_validate(record.payload) @@ -191,26 +255,59 @@ def _mutate_draft(self, method: str, *args: Any) -> CampaignDraft: return draft def _hydrate_drafts(self) -> None: - with self._sessions() as session: - records = session.scalars(select(CampaignDraftRecord)).all() - self._legacy.drafts = { - record.id: CampaignDraft.model_validate(record.payload) for record in records - } + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: + records = session.scalars( + select(CampaignDraftRecord).where( + CampaignDraftRecord.workspace_id == self._workspace_id + ) + ).all() + self._legacy.hydrate_drafts( + { + record.id: CampaignDraft.model_validate(record.payload) + for record in records + } + ) def _save_draft(self, draft: CampaignDraft) -> None: payload = draft.model_dump(mode="json") - with self._sessions() as session: - with session.begin(): - record = session.get(CampaignDraftRecord, draft.id) - if record is None: - session.add(CampaignDraftRecord(id=draft.id, payload=payload)) - else: - record.payload = payload - record.updated_at = datetime.now(timezone.utc) + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: + record = session.scalar( + select(CampaignDraftRecord).where( + CampaignDraftRecord.id == draft.id, + CampaignDraftRecord.workspace_id == self._workspace_id, + ) + ) + if record is None: + session.add( + CampaignDraftRecord( + id=draft.id, + workspace_id=self._workspace_id, + payload=payload, + ) + ) + else: + record.payload = payload + record.updated_at = datetime.now(timezone.utc) def _hydrate_packages(self) -> None: - with self._sessions() as session: - records = session.scalars(select(SemanticChangePackageRecord)).all() + with tenant_transaction( + self._sessions, + workspace_id=self._workspace_id, + user_id=self._user_id, + ) as session: + records = session.scalars( + select(SemanticChangePackageRecord).where( + SemanticChangePackageRecord.workspace_id == self._workspace_id + ) + ).all() self._legacy.semantic_packages_by_id = { record.package_id: SemanticChangePackage.model_validate(record.payload) for record in records diff --git a/tests/integration/test_migrations_and_roles.py b/tests/integration/test_migrations_and_roles.py index 9012c2a..5b8382c 100644 --- a/tests/integration/test_migrations_and_roles.py +++ b/tests/integration/test_migrations_and_roles.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from sqlalchemy import inspect from app.db.engine import DatabaseSettings, create_database_runtime from app.db.migrations.runner import downgrade_database, upgrade_database @@ -77,3 +78,70 @@ def test_owner_upgrades_from_prior_p2_revision_to_current_head( finally: owner_runtime.close() app_runtime.close() + + +@pytest.mark.integration +def test_owner_round_trips_exact_p2_revision_through_p3_tenant_keys( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime, revision="20260904_0003") + with owner_runtime.engine.connect() as connection: + p2_inspector = inspect(connection) + assert p2_inspector.get_pk_constraint("campaign_drafts")["constrained_columns"] == [ + "id" + ] + assert p2_inspector.get_pk_constraint("semantic_change_packages")[ + "constrained_columns" + ] == ["package_id"] + + upgrade_database(owner_runtime) + with owner_runtime.engine.connect() as connection: + p3_inspector = inspect(connection) + assert p3_inspector.get_pk_constraint("campaign_drafts")["constrained_columns"] == [ + "id", + "workspace_id", + ] + assert p3_inspector.get_pk_constraint("semantic_change_packages")[ + "constrained_columns" + ] == ["package_id", "workspace_id"] + + downgrade_database(owner_runtime, revision="20260904_0003") + with owner_runtime.engine.connect() as connection: + restored_inspector = inspect(connection) + assert restored_inspector.get_pk_constraint("campaign_drafts")[ + "constrained_columns" + ] == ["id"] + assert restored_inspector.get_pk_constraint("semantic_change_packages")[ + "constrained_columns" + ] == ["package_id"] + assert "workspace_id" not in { + column["name"] + for column in restored_inspector.get_columns("campaign_drafts") + } + assert "workspace_id" not in { + column["name"] + for column in restored_inspector.get_columns("semantic_change_packages") + } + + upgrade_database(owner_runtime) + with owner_runtime.engine.connect() as connection: + final_inspector = inspect(connection) + assert final_inspector.get_pk_constraint("campaign_drafts")["constrained_columns"] == [ + "id", + "workspace_id", + ] + assert final_inspector.get_pk_constraint("semantic_change_packages")[ + "constrained_columns" + ] == ["package_id", "workspace_id"] + finally: + owner_runtime.close() diff --git a/tests/integration/test_p3_authorization.py b/tests/integration/test_p3_authorization.py new file mode 100644 index 0000000..57ffa1a --- /dev/null +++ b/tests/integration/test_p3_authorization.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import pytest + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.modules.tenancy.models import MembershipRole, MembershipStatus +from app.modules.tenancy.policy import AuthorizationDenied, Capability + + +@pytest.mark.integration +def test_authorization_reloads_role_and_revocation_from_membership_storage( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions) + created = identity.create_personal_workspace( + display_name="Synthetic authorization owner", + workspace_name="Synthetic authorization workspace", + yandex_subject="synthetic-authorization-owner-001", + ) + authorizer = PostgresWorkspaceAuthorizer(app_runtime.sessions) + + allowed = authorizer.authorize( + user_id=created.user.id, + workspace_id=created.workspace.id, + capability=Capability.MANAGE_MEMBERS, + ) + assert allowed.role is MembershipRole.OWNER + + identity.update_membership( + workspace_id=created.workspace.id, + user_id=created.user.id, + role=MembershipRole.VIEWER, + status=MembershipStatus.ACTIVE, + ) + with pytest.raises(AuthorizationDenied, match="workspace access is denied"): + authorizer.authorize( + user_id=created.user.id, + workspace_id=created.workspace.id, + capability=Capability.MANAGE_MEMBERS, + ) + + identity.update_membership( + workspace_id=created.workspace.id, + user_id=created.user.id, + role=MembershipRole.VIEWER, + status=MembershipStatus.REVOKED, + ) + with pytest.raises(AuthorizationDenied, match="workspace membership is inactive"): + authorizer.authorize( + user_id=created.user.id, + workspace_id=created.workspace.id, + capability=Capability.READ_WORKSPACE_DATA, + ) + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_http_security_integration.py b/tests/integration/test_p3_http_security_integration.py new file mode 100644 index 0000000..21afe6a --- /dev/null +++ b/tests/integration/test_p3_http_security_integration.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from dataclasses import replace +from http.cookies import SimpleCookie + +import pytest +from fastapi.testclient import TestClient + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import create_application_dependencies +from app.modules.sessions.cookies import SESSION_COOKIE_NAME + + +def _cookie_attributes(response: object) -> dict[str, str]: + parsed = SimpleCookie() + parsed.load(getattr(response, "headers")["set-cookie"]) + morsel = parsed[SESSION_COOKIE_NAME] + return {attribute: morsel[attribute] for attribute in morsel.keys()} + + +@pytest.mark.integration +def test_cookie_auth_rejects_csrf_and_cross_origin_requests_and_sets_security_headers( + postgres_service: object, +) -> None: + from app.db.engine import DatabaseSettings, create_database_runtime + from app.db.migrations.runner import upgrade_database + from app.db.roles import bootstrap_database_roles + from app.modules.identity.repository import PostgresIdentityRepository + from app.modules.sessions.service import PostgresSessionService + from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer + + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + dependencies = replace( + create_application_dependencies(), + identity_repository=PostgresIdentityRepository(app_runtime.sessions), + session_service=PostgresSessionService(app_runtime.sessions), + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + ) + client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") + + login = client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic HTTP user"}, + ) + assert login.status_code == 200 + attributes = _cookie_attributes(login) + assert attributes["path"] == "/" + assert attributes["domain"] == "" + assert attributes["secure"] is True + assert attributes["httponly"] is True + assert attributes["samesite"].lower() == "lax" + assert login.headers["content-security-policy"].startswith("default-src 'self'") + assert login.headers["x-content-type-options"] == "nosniff" + assert login.headers["x-frame-options"] == "DENY" + assert "strict-transport-security" in login.headers + + csrf_token = login.json()["csrf_token"] + assert client.post("/api/v1/_test/identity/logout").status_code == 403 + assert ( + client.post( + "/api/v1/_test/identity/logout", + headers={ + "X-CSRF-Token": csrf_token, + "Origin": "https://attacker.invalid", + }, + ).status_code + == 403 + ) + + session_view = client.get("/api/v1/_test/identity/session") + assert session_view.status_code == 200 + assert ( + client.post( + "/api/v1/_test/identity/logout", + headers={ + "X-CSRF-Token": csrf_token, + "Origin": "https://testserver", + }, + ).status_code + == 204 + ) + assert client.get("/api/v1/_test/identity/session").status_code == 401 + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_identity_tenancy.py b/tests/integration/test_p3_identity_tenancy.py new file mode 100644 index 0000000..1ecec8b --- /dev/null +++ b/tests/integration/test_p3_identity_tenancy.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import pytest +from sqlalchemy import func, select, text +from sqlalchemy.exc import IntegrityError + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import UserRecord +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.tenancy.models import MembershipRole + + +@pytest.mark.integration +def test_personal_workspace_and_owner_membership_are_atomic( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + repository = PostgresIdentityRepository(app_runtime.sessions) + + created = repository.create_personal_workspace( + display_name="Synthetic owner", + workspace_name="Synthetic workspace", + yandex_subject="synthetic-owner-001", + ) + + assert created.membership.role is MembershipRole.OWNER + assert created.membership.user_id == created.user.id + assert created.membership.workspace_id == created.workspace.id + + with owner_runtime.engine.begin() as connection: + connection.execute( + text( + "ALTER TABLE memberships " + "ADD CONSTRAINT ck_p3_test_reject_membership " + "CHECK (false) NOT VALID" + ) + ) + + with pytest.raises(IntegrityError): + repository.create_personal_workspace( + display_name="Synthetic rollback", + workspace_name="Synthetic rollback workspace", + yandex_subject="synthetic-rollback-001", + ) + + with app_runtime.sessions() as session: + remaining = session.scalar( + select(func.count()) + .select_from(UserRecord) + .where(UserRecord.yandex_subject == "synthetic-rollback-001") + ) + assert remaining == 0 + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_request_authorization.py b/tests/integration/test_p3_request_authorization.py new file mode 100644 index 0000000..5d0728b --- /dev/null +++ b/tests/integration/test_p3_request_authorization.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest +from fastapi.testclient import TestClient + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import create_application_dependencies +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.sessions.service import PostgresSessionService +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.modules.tenancy.models import MembershipRole, MembershipStatus + + +@pytest.mark.integration +def test_session_route_authorizes_the_current_membership_not_cookie_authority( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions) + dependencies = replace( + create_application_dependencies(), + identity_repository=identity, + session_service=PostgresSessionService(app_runtime.sessions), + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + ) + client = TestClient(create_app(dependencies=dependencies), base_url="https://testserver") + + login = client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic dynamic authorization user"}, + ) + payload = login.json() + assert login.status_code == 200 + + identity.update_membership( + workspace_id=payload["workspace_id"], + user_id=payload["user_id"], + role=MembershipRole.VIEWER, + status=MembershipStatus.REVOKED, + ) + + assert client.get("/api/v1/_test/identity/session").status_code == 403 + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_rls.py b/tests/integration/test_p3_rls.py new file mode 100644 index 0000000..740b965 --- /dev/null +++ b/tests/integration/test_p3_rls.py @@ -0,0 +1,214 @@ +from __future__ import annotations + +import pytest +from uuid import uuid4 + +from sqlalchemy import select, text +from sqlalchemy.exc import DBAPIError, IntegrityError + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import CampaignDraftRecord, WorkspaceRecord +from app.db.rls import TenantContext, set_local_tenant_context, tenant_transaction +from app.db.roles import bootstrap_database_roles, get_role_attributes, get_table_owner +from app.modules.identity.repository import PostgresIdentityRepository + + +@pytest.mark.integration +def test_rls_requires_transaction_local_workspace_context_and_cannot_leak_or_be_disabled( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions) + first = identity.create_personal_workspace( + display_name="Synthetic RLS first user", + workspace_name="Synthetic RLS first workspace", + yandex_subject="synthetic-rls-first-001", + ) + second = identity.create_personal_workspace( + display_name="Synthetic RLS second user", + workspace_name="Synthetic RLS second workspace", + yandex_subject="synthetic-rls-second-001", + ) + + with tenant_transaction( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) as session: + session.add( + CampaignDraftRecord( + id="p3-rls-first-draft", + workspace_id=first.workspace.id, + payload={"kind": "synthetic"}, + ) + ) + + with app_runtime.sessions() as session: + with session.begin(): + assert session.scalars(select(CampaignDraftRecord)).all() == [] + assert session.scalars(select(WorkspaceRecord)).all() == [] + + with tenant_transaction( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) as session: + assert ( + session.scalar( + select(CampaignDraftRecord).where( + CampaignDraftRecord.id == "p3-rls-first-draft", + CampaignDraftRecord.workspace_id == second.workspace.id, + ) + ) + is None + ) + + with app_runtime.sessions() as session: + with session.begin(): + set_local_tenant_context( + session, + TenantContext(workspace_id=first.workspace.id, user_id=first.user.id), + ) + assert ( + session.execute( + text("SELECT current_setting('app.current_workspace_id', true)") + ).scalar_one() + == str(first.workspace.id) + ) + with session.begin(): + assert ( + session.execute( + text("SELECT current_setting('app.current_workspace_id', true)") + ).scalar_one() + in (None, "") + ) + + with app_runtime.engine.begin() as connection: + with pytest.raises(DBAPIError): + connection.execute(text("ALTER TABLE campaign_drafts DISABLE ROW LEVEL SECURITY")) + + with tenant_transaction( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) as session: + session.add( + CampaignDraftRecord( + id="p3-rls-second-draft", + workspace_id=second.workspace.id, + payload={"kind": "synthetic", "owner": "second"}, + ) + ) + + with tenant_transaction( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=first.user.id, + ) as session: + leaked = session.execute( + text("SELECT id FROM campaign_drafts WHERE id = 'p3-rls-second-draft'") + ).all() + assert leaked == [] + with pytest.raises(DBAPIError): + session.execute( + text( + """ + INSERT INTO campaign_drafts (id, workspace_id, payload) + VALUES ('p3-rls-cross-tenant-insert', :workspace_id, $${"kind": "cross"}$$::jsonb) + ON CONFLICT DO NOTHING + """ + ), + {"workspace_id": second.workspace.id}, + ) + + with tenant_transaction( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=first.user.id, + ) as session: + updated = session.execute( + text( + """ + UPDATE campaign_drafts + SET payload = $${"kind": "cross-update"}$$::jsonb + WHERE id = 'p3-rls-second-draft' AND workspace_id = :workspace_id + """ + ), + {"workspace_id": second.workspace.id}, + ) + assert updated.rowcount == 0 + with pytest.raises(DBAPIError): + session.execute( + text( + """ + DELETE FROM campaign_drafts + WHERE id = 'p3-rls-second-draft' AND workspace_id = :workspace_id + """ + ), + {"workspace_id": second.workspace.id}, + ) + + with app_runtime.sessions() as session: + with session.begin(): + set_local_tenant_context( + session, + TenantContext(workspace_id=second.workspace.id, user_id=first.user.id), + ) + with pytest.raises((DBAPIError, IntegrityError)): + session.execute( + text( + """ + INSERT INTO memberships ( + id, workspace_id, user_id, role, status, created_at, accepted_at + ) VALUES ( + :id, :workspace_id, :user_id, 'owner', 'active', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + """ + ), + { + "id": uuid4(), + "workspace_id": second.workspace.id, + "user_id": first.user.id, + }, + ) + + with tenant_transaction( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) as session: + assert ( + session.execute( + text("SELECT payload->>'kind' FROM campaign_drafts WHERE id = 'p3-rls-second-draft'") + ).scalar_one() + == "synthetic" + ) + + with app_runtime.engine.begin() as connection: + connection.execute(text("SET LOCAL row_security = off")) + with pytest.raises(DBAPIError): + connection.execute(text("SELECT id FROM campaign_drafts")) + + attributes = get_role_attributes(owner_runtime) + assert attributes.is_superuser is False + assert attributes.can_bypass_rls is False + assert get_table_owner(owner_runtime, "campaign_drafts") == "directpilot_owner" + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_sessions.py b/tests/integration/test_p3_sessions.py new file mode 100644 index 0000000..086422c --- /dev/null +++ b/tests/integration/test_p3_sessions.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from sqlalchemy import select + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.models import SessionRecord +from app.db.roles import bootstrap_database_roles +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.sessions.service import PostgresSessionService, SessionLifetime + + +@pytest.mark.integration +def test_opaque_session_rotates_revokes_and_enforces_both_expiries( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + now = datetime(2026, 9, 4, tzinfo=timezone.utc) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions).create_personal_workspace( + display_name="Synthetic session owner", + workspace_name="Synthetic session workspace", + yandex_subject="synthetic-session-owner-001", + ) + sessions = PostgresSessionService( + app_runtime.sessions, + lifetime=SessionLifetime(idle=timedelta(minutes=10), absolute=timedelta(hours=1)), + ) + + issued = sessions.issue( + user_id=identity.user.id, + active_workspace_id=identity.workspace.id, + now=now, + ) + assert len(issued.token) >= 40 + assert len(issued.csrf_token) >= 40 + + with app_runtime.sessions() as session: + stored = session.scalar(select(SessionRecord).where(SessionRecord.id == issued.id)) + assert stored is not None + assert stored.token_hash != issued.token + assert stored.csrf_secret_hash != issued.csrf_token + assert sessions.verify_csrf(issued.token, issued.csrf_token, now=now) is True + + rotated = sessions.rotate(issued.token, now=now + timedelta(minutes=1)) + assert rotated is not None + assert rotated.token != issued.token + assert sessions.authenticate(issued.token, now=now + timedelta(minutes=1)) is None + assert sessions.authenticate(rotated.token, now=now + timedelta(minutes=2)) is not None + + sessions.revoke(rotated.token, now=now + timedelta(minutes=3)) + assert sessions.authenticate(rotated.token, now=now + timedelta(minutes=3)) is None + + idle = sessions.issue(user_id=identity.user.id, now=now) + assert sessions.authenticate(idle.token, now=now + timedelta(minutes=11)) is None + + absolute = sessions.issue(user_id=identity.user.id, now=now) + assert sessions.authenticate(absolute.token, now=now + timedelta(hours=1, seconds=1)) is None + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_tenant_isolation.py b/tests/integration/test_p3_tenant_isolation.py new file mode 100644 index 0000000..cdc5ea0 --- /dev/null +++ b/tests/integration/test_p3_tenant_isolation.py @@ -0,0 +1,241 @@ +from __future__ import annotations + +from uuid import uuid4 + +import pytest +from sqlalchemy import text +from sqlalchemy.exc import DBAPIError + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.rls import WorkspaceContextError, tenant_transaction +from app.db.roles import bootstrap_database_roles +from app.models import CampaignDraftRequest, SemanticChangePackage, SemanticChangePreview +from app.modules.actions.idempotency import PostgresIdempotencyRepository +from app.modules.audit.repository import PostgresAuditRepository +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.modules.tenancy.policy import AuthorizationDenied, Capability +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +_TENANT_TABLES = ( + "audit_events", + "idempotency_records", + "campaign_drafts", + "semantic_change_packages", +) + + +@pytest.mark.integration +def test_two_users_are_denied_cross_tenant_service_repository_and_raw_sql_access( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions) + first = identity.create_personal_workspace( + display_name="Synthetic isolation first user", + workspace_name="Synthetic isolation first workspace", + yandex_subject="synthetic-isolation-first-001", + ) + second = identity.create_personal_workspace( + display_name="Synthetic isolation second user", + workspace_name="Synthetic isolation second workspace", + yandex_subject="synthetic-isolation-second-001", + ) + + authorizer = PostgresWorkspaceAuthorizer(app_runtime.sessions) + for capability in (Capability.READ_WORKSPACE_DATA, Capability.CREATE_PROPOSAL): + with pytest.raises(AuthorizationDenied): + authorizer.authorize( + user_id=first.user.id, + workspace_id=second.workspace.id, + capability=capability, + ) + + first_store = PostgresLegacyStoreRepository( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) + second_store = PostgresLegacyStoreRepository( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) + first_draft = first_store.create_draft( + CampaignDraftRequest( + business_type="isolation-first", + region="Synthetic isolation first region", + monthly_budget=101, + landing_url="https://example.invalid/isolation-first", + ) + ) + second_draft = second_store.create_draft( + CampaignDraftRequest( + business_type="isolation-second", + region="Synthetic isolation second region", + monthly_budget=202, + landing_url="https://example.invalid/isolation-second", + ) + ) + assert first_draft.id == second_draft.id + assert first_store.get_draft(first_draft.id).business_type == "isolation-first" + assert second_store.get_draft(second_draft.id).business_type == "isolation-second" + + first_package = SemanticChangePackage( + package_id="synthetic-isolation-package", + campaign_id="synthetic-first-campaign", + mode="mock", + created_at="2026-09-04T00:00:00Z", + preview=SemanticChangePreview(operations=[]), + ) + second_package = first_package.model_copy( + update={"campaign_id": "synthetic-second-campaign"} + ) + first_store.save_semantic_package(first_package) + second_store.save_semantic_package(second_package) + assert first_store.get_semantic_package(first_package.package_id) == first_package + assert second_store.get_semantic_package(second_package.package_id) == second_package + + first_audit = PostgresAuditRepository( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) + second_audit = PostgresAuditRepository( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) + first_audit.append_audit("synthetic.isolation.first", "first") + second_audit.append_audit("synthetic.isolation.second", "second") + assert "synthetic.isolation.second" not in { + event.action for event in first_audit.audit_events + } + assert "synthetic.isolation.first" not in { + event.action for event in second_audit.audit_events + } + + first_idempotency = PostgresIdempotencyRepository( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) + second_idempotency = PostgresIdempotencyRepository( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) + assert first_idempotency.claim("synthetic-isolation", "same-key", {"tenant": 1}).is_owner + assert second_idempotency.claim("synthetic-isolation", "same-key", {"tenant": 2}).is_owner + + with app_runtime.sessions() as session: + with session.begin(): + for table_name in _TENANT_TABLES: + assert session.execute( + text(f"SELECT workspace_id FROM {table_name}") + ).all() == [] + + with tenant_transaction( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) as session: + for table_name in _TENANT_TABLES: + assert session.execute( + text( + f"SELECT workspace_id FROM {table_name} " + "WHERE workspace_id = CAST(:workspace_id AS uuid)" + ), + {"workspace_id": str(second.workspace.id)}, + ).all() == [] + + forbidden_inserts = ( + ( + text( + "INSERT INTO audit_events " + "(id, workspace_id, actor, action, entity, dry_run) " + "VALUES (:id, CAST(:workspace_id AS uuid), " + "'synthetic', 'synthetic.raw', 'synthetic', true)" + ), + {"id": uuid4(), "workspace_id": str(second.workspace.id)}, + ), + ( + text( + "INSERT INTO idempotency_records " + "(id, workspace_id, namespace, key_hash, request_hash, status) " + "VALUES (:id, CAST(:workspace_id AS uuid), " + "'synthetic.raw', 'a'::text, 'b'::text, 'pending')" + ), + {"id": uuid4(), "workspace_id": str(second.workspace.id)}, + ), + ( + text( + "INSERT INTO campaign_drafts (id, workspace_id, payload) " + "VALUES ('synthetic-raw-draft', CAST(:workspace_id AS uuid), " + "CAST('{\"synthetic\": true}' AS jsonb))" + ), + {"workspace_id": str(second.workspace.id)}, + ), + ( + text( + "INSERT INTO semantic_change_packages (package_id, workspace_id, payload) " + "VALUES ('synthetic-raw-package', CAST(:workspace_id AS uuid), " + "CAST('{\"synthetic\": true}' AS jsonb))" + ), + {"workspace_id": str(second.workspace.id)}, + ), + ) + for statement, parameters in forbidden_inserts: + with pytest.raises(DBAPIError): + with session.begin_nested(): + session.execute(statement, parameters) + + with pytest.raises(WorkspaceContextError): + with tenant_transaction(app_runtime.sessions, workspace_id="not-a-workspace"): + pass + + rollback_id = "synthetic-rollback-draft" + with pytest.raises(RuntimeError, match="rollback"): + with tenant_transaction( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) as session: + session.execute( + text( + "INSERT INTO campaign_drafts (id, workspace_id, payload) " + "VALUES (:id, CAST(:workspace_id AS uuid), " + "CAST('{\"synthetic\": true}' AS jsonb))" + ), + {"id": rollback_id, "workspace_id": str(first.workspace.id)}, + ) + raise RuntimeError("rollback") + with tenant_transaction( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) as session: + assert session.execute( + text("SELECT id FROM campaign_drafts WHERE id = :id"), + {"id": rollback_id}, + ).all() == [] + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_tenant_repositories.py b/tests/integration/test_p3_tenant_repositories.py new file mode 100644 index 0000000..92824c2 --- /dev/null +++ b/tests/integration/test_p3_tenant_repositories.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import pytest + +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.models import CampaignDraftRequest, SemanticChangePackage, SemanticChangePreview +from app.modules.actions.idempotency import PostgresIdempotencyRepository +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.audit.repository import PostgresAuditRepository +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +@pytest.mark.integration +def test_tenant_repositories_scope_p2_rows_and_idempotency_by_workspace( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + identity = PostgresIdentityRepository(app_runtime.sessions) + first = identity.create_personal_workspace( + display_name="Synthetic first repository user", + workspace_name="Synthetic first repository workspace", + yandex_subject="synthetic-repository-first-001", + ) + second = identity.create_personal_workspace( + display_name="Synthetic second repository user", + workspace_name="Synthetic second repository workspace", + yandex_subject="synthetic-repository-second-001", + ) + first_store = PostgresLegacyStoreRepository( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) + second_store = PostgresLegacyStoreRepository( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) + + first_draft = first_store.create_draft( + CampaignDraftRequest( + business_type="first", + region="Synthetic first region", + monthly_budget=101, + landing_url="https://example.invalid/first", + ) + ) + second_draft = second_store.create_draft( + CampaignDraftRequest( + business_type="second", + region="Synthetic second region", + monthly_budget=202, + landing_url="https://example.invalid/second", + ) + ) + assert first_draft.id == second_draft.id + assert first_store.drafts[first_draft.id].business_type == "first" + assert second_store.drafts[second_draft.id].business_type == "second" + + first_package = SemanticChangePackage( + package_id="same-synthetic-package", + campaign_id="first-campaign", + mode="mock", + created_at="2026-09-04T00:00:00Z", + preview=SemanticChangePreview(operations=[]), + ) + second_package = first_package.model_copy(update={"campaign_id": "second-campaign"}) + first_store.save_semantic_package(first_package) + second_store.save_semantic_package(second_package) + assert first_store.get_semantic_package(first_package.package_id) == first_package + assert second_store.get_semantic_package(second_package.package_id) == second_package + + first_audit = PostgresAuditRepository( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) + second_audit = PostgresAuditRepository( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) + first_audit.append_audit("synthetic.first", "first-row") + second_audit.append_audit("synthetic.second", "second-row") + first_actions = [event.action for event in first_audit.audit_events] + second_actions = [event.action for event in second_audit.audit_events] + assert "synthetic.first" in first_actions + assert "synthetic.second" not in first_actions + assert "synthetic.second" in second_actions + assert "synthetic.first" not in second_actions + + first_idempotency = PostgresIdempotencyRepository( + app_runtime.sessions, + workspace_id=first.workspace.id, + user_id=first.user.id, + ) + second_idempotency = PostgresIdempotencyRepository( + app_runtime.sessions, + workspace_id=second.workspace.id, + user_id=second.user.id, + ) + assert first_idempotency.claim("synthetic.operation", "same-key", {"value": 1}).is_owner + assert second_idempotency.claim("synthetic.operation", "same-key", {"value": 1}).is_owner + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/integration/test_p3_workspace_binding.py b/tests/integration/test_p3_workspace_binding.py new file mode 100644 index 0000000..e9c6355 --- /dev/null +++ b/tests/integration/test_p3_workspace_binding.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest +from fastapi import HTTPException +from fastapi.testclient import TestClient + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import create_application_dependencies, legacy_store +from app.core.request_context import get_request_context +from app.db.engine import DatabaseSettings, create_database_runtime +from app.db.migrations.runner import upgrade_database +from app.db.roles import bootstrap_database_roles +from app.models import CampaignDraftRequest +from app.modules.identity.repository import PostgresIdentityRepository +from app.modules.sessions.service import PostgresSessionService +from app.modules.tenancy.authorization import PostgresWorkspaceAuthorizer +from app.repositories.postgres_store import PostgresLegacyStoreRepository + + +@pytest.mark.integration +def test_cookie_session_binds_the_server_authorized_workspace_for_repository_access( + postgres_service: object, +) -> None: + owner_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "owner_url")} + ) + ) + app_runtime = create_database_runtime( + DatabaseSettings.from_mapping( + {"DIRECTPILOT_DATABASE_URL": getattr(postgres_service, "app_url")} + ) + ) + try: + bootstrap_database_roles( + owner_runtime, + app_password=getattr(postgres_service, "app_password"), + ) + upgrade_database(owner_runtime) + dependencies = replace( + create_application_dependencies(), + repository=PostgresLegacyStoreRepository(app_runtime.sessions), + identity_repository=PostgresIdentityRepository(app_runtime.sessions), + session_service=PostgresSessionService(app_runtime.sessions), + workspace_authorizer=PostgresWorkspaceAuthorizer(app_runtime.sessions), + fake_auth_enabled=True, + ) + app = create_app(dependencies=dependencies) + + @app.get("/api/v1/_test/workspace-binding") + def workspace_binding() -> dict[str, object]: + context = get_request_context() + if context.workspace_id is None: + raise HTTPException(status_code=401, detail="authentication required") + return { + "workspace_id": str(context.workspace_id), + "business_types": sorted( + draft.business_type for draft in legacy_store.drafts.values() + ), + } + + @app.post("/api/v1/_test/workspace-binding/draft") + def create_workspace_bound_draft() -> dict[str, str]: + context = get_request_context() + if context.workspace_id is None: + raise HTTPException(status_code=401, detail="authentication required") + draft = legacy_store.create_draft( + CampaignDraftRequest( + business_type="first-http-write", + region="Synthetic HTTP region", + monthly_budget=303, + landing_url="https://example.invalid/http-write", + ) + ) + return {"draft_id": draft.id, "workspace_id": str(context.workspace_id)} + + first_client = TestClient(app, base_url="https://testserver") + second_client = TestClient(app, base_url="https://testserver") + first_login = first_client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic HTTP first user"}, + ) + second_login = second_client.post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic HTTP second user"}, + ) + assert first_login.status_code == 200 + assert second_login.status_code == 200 + first_workspace_id = first_login.json()["workspace_id"] + second_workspace_id = second_login.json()["workspace_id"] + + first_repository = PostgresLegacyStoreRepository( + app_runtime.sessions, + workspace_id=first_workspace_id, + user_id=first_login.json()["user_id"], + ) + second_repository = PostgresLegacyStoreRepository( + app_runtime.sessions, + workspace_id=second_workspace_id, + user_id=second_login.json()["user_id"], + ) + first_repository.create_draft( + CampaignDraftRequest( + business_type="first-http-read", + region="Synthetic HTTP first region", + monthly_budget=101, + landing_url="https://example.invalid/http-first", + ) + ) + second_repository.create_draft( + CampaignDraftRequest( + business_type="second-http-read", + region="Synthetic HTTP second region", + monthly_budget=202, + landing_url="https://example.invalid/http-second", + ) + ) + + forged_workspace_header = {"X-Workspace-ID": second_workspace_id} + first_read = first_client.get( + "/api/v1/_test/workspace-binding", + headers=forged_workspace_header, + ) + assert first_read.status_code == 200 + assert first_read.json() == { + "workspace_id": first_workspace_id, + "business_types": ["first-http-read"], + } + assert second_client.get("/api/v1/_test/workspace-binding").json() == { + "workspace_id": second_workspace_id, + "business_types": ["second-http-read"], + } + + first_write = first_client.post( + "/api/v1/_test/workspace-binding/draft", + headers={ + "Origin": "https://testserver", + "X-CSRF-Token": first_login.json()["csrf_token"], + "X-Workspace-ID": second_workspace_id, + }, + ) + assert first_write.status_code == 200 + assert first_write.json()["workspace_id"] == first_workspace_id + assert second_client.get("/api/v1/_test/workspace-binding").json() == { + "workspace_id": second_workspace_id, + "business_types": ["second-http-read"], + } + assert first_client.get("/api/v1/_test/workspace-binding").json()[ + "business_types" + ] == ["first-http-read", "first-http-write"] + assert TestClient(app, base_url="https://testserver").get( + "/api/v1/_test/workspace-binding" + ).status_code == 401 + finally: + owner_runtime.close() + app_runtime.close() diff --git a/tests/test_identity_tenancy.py b/tests/test_identity_tenancy.py new file mode 100644 index 0000000..194a2f1 --- /dev/null +++ b/tests/test_identity_tenancy.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from app.modules.tenancy.models import MembershipRole + + +def test_membership_roles_are_limited_to_the_p3_policy_roles() -> None: + assert {role.value for role in MembershipRole} == { + "viewer", + "operator", + "approver", + "admin", + "owner", + } diff --git a/tests/test_p3_http_security.py b/tests/test_p3_http_security.py new file mode 100644 index 0000000..e643f23 --- /dev/null +++ b/tests/test_p3_http_security.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from dataclasses import replace + +import pytest +from fastapi.testclient import TestClient + +from app.bootstrap.application import create_app +from app.bootstrap.dependencies import _fake_auth_is_enabled, create_application_dependencies + + +@pytest.mark.parametrize( + ("app_env", "enabled", "expected"), + ( + ("local", "", False), + ("test", "0", False), + ("local", "1", True), + ("test", "1", True), + ("production", "1", False), + ("staging", "1", False), + ), +) +def test_fake_auth_requires_explicit_local_or_test_enablement( + monkeypatch: pytest.MonkeyPatch, + app_env: str, + enabled: str, + expected: bool, +) -> None: + monkeypatch.setenv("DIRECTPILOT_ENABLE_FAKE_AUTH", enabled) + + assert _fake_auth_is_enabled(app_env) is expected + + +@pytest.mark.parametrize("app_env", ("production", "staging")) +def test_fake_auth_route_is_fail_closed_for_production_like_environments( + monkeypatch: pytest.MonkeyPatch, + app_env: str, +) -> None: + monkeypatch.setenv("DIRECTPILOT_ENABLE_FAKE_AUTH", "1") + dependencies = replace( + create_application_dependencies(), + fake_auth_enabled=_fake_auth_is_enabled(app_env), + ) + + response = TestClient(create_app(dependencies=dependencies), base_url="https://testserver").post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic production-like user"}, + ) + + assert response.status_code == 404 + + +def test_fake_auth_is_not_available_without_explicit_local_test_enablement() -> None: + app = create_app() + + response = TestClient(app, base_url="https://testserver").post( + "/api/v1/_test/identity/login", + json={"display_name": "Synthetic user"}, + ) + + assert response.status_code == 404 diff --git a/tests/test_postgres_store_composition.py b/tests/test_postgres_store_composition.py index bfca6a8..9f2bc34 100644 --- a/tests/test_postgres_store_composition.py +++ b/tests/test_postgres_store_composition.py @@ -1,6 +1,7 @@ from __future__ import annotations from typing import Any, cast +from uuid import UUID import pytest from sqlalchemy.orm import Session, sessionmaker @@ -9,7 +10,9 @@ class RecordingAuditRepository: - def __init__(self, _sessions: object) -> None: + def __init__(self, _sessions: object, *, workspace_id: UUID, user_id: UUID) -> None: + self.workspace_id = workspace_id + self.user_id = user_id self.calls: list[tuple[str, str, dict[str, Any]]] = [] def append_audit(self, action: str, entity: str, **kwargs: Any) -> str: @@ -25,6 +28,9 @@ def test_postgres_store_legacy_audit_uses_explicit_delegate( repository = postgres_store.PostgresLegacyStoreRepository(sessions) audit = cast(RecordingAuditRepository, repository._audit) + assert audit.workspace_id == repository._workspace_id + assert audit.user_id == repository._user_id + legacy_append_audit = repository._legacy.append_audit assert legacy_append_audit.__self__ is repository._legacy diff --git a/tests/test_tenancy_policy.py b/tests/test_tenancy_policy.py new file mode 100644 index 0000000..40aec71 --- /dev/null +++ b/tests/test_tenancy_policy.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import pytest + +from app.modules.tenancy.models import MembershipRole +from app.modules.tenancy.policy import Capability, AuthorizationDenied, RbacPolicy + + +@pytest.mark.parametrize( + ("capability", "allowed_roles"), + [ + (Capability.READ_WORKSPACE_DATA, set(MembershipRole)), + ( + Capability.CREATE_PROPOSAL, + { + MembershipRole.OPERATOR, + MembershipRole.APPROVER, + MembershipRole.ADMIN, + MembershipRole.OWNER, + }, + ), + ( + Capability.APPROVE_PROPOSAL, + { + MembershipRole.APPROVER, + MembershipRole.ADMIN, + MembershipRole.OWNER, + }, + ), + (Capability.EXECUTE_APPROVED_ACTION, {MembershipRole.ADMIN, MembershipRole.OWNER}), + (Capability.MANAGE_PROVIDER_CONNECTIONS, {MembershipRole.ADMIN, MembershipRole.OWNER}), + (Capability.MANAGE_MEMBERS, {MembershipRole.ADMIN, MembershipRole.OWNER}), + (Capability.DELETE_OR_TRANSFER_WORKSPACE, {MembershipRole.OWNER}), + ], +) +def test_rbac_policy_enforces_the_p3_role_capability_matrix( + capability: Capability, + allowed_roles: set[MembershipRole], +) -> None: + policy = RbacPolicy() + + for role in MembershipRole: + assert policy.allows(role, capability) is (role in allowed_roles) + + +def test_rbac_policy_raises_a_stable_denial_for_disallowed_capability() -> None: + with pytest.raises(AuthorizationDenied, match="workspace access is denied"): + RbacPolicy().require(MembershipRole.VIEWER, Capability.MANAGE_MEMBERS)