Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/bootstrap/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
17 changes: 17 additions & 0 deletions app/bootstrap/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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({})
Expand Down
36 changes: 34 additions & 2 deletions app/core/request_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -12,17 +12,23 @@
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(
"request_context", default=None
)


@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:
Expand All @@ -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
Expand Down
36 changes: 36 additions & 0 deletions app/core/security_headers.py
Original file line number Diff line number Diff line change
@@ -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
122 changes: 122 additions & 0 deletions app/db/migrations/versions/20260904_0004_identity_tenancy.py
Original file line number Diff line number Diff line change
@@ -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")
57 changes: 57 additions & 0 deletions app/db/migrations/versions/20260904_0005_sessions.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading