feat(app): add plugin system with auth plugin and static assets
Add new application structure: - app/main.py - application entry point - app/plugins/ - plugin system with auth plugin: - api/ - REST API endpoints and schemas - authorization/ - auth policies, providers, hooks - domain/ - business logic (service, models, jwt, password) - injection/ - route injection and guards - ops/ - operational utilities - runtime/ - runtime configuration - security/ - middleware, CSRF, dependencies - storage/ - user repositories and models - app/static/ - static assets (scalar.js for API docs) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"""Authorization layer for the auth plugin."""
|
||||
|
||||
from app.plugins.auth.authorization.authentication import get_auth_context
|
||||
from app.plugins.auth.authorization.hooks import (
|
||||
AuthzHooks,
|
||||
build_authz_hooks,
|
||||
build_permission_provider,
|
||||
build_policy_chain_builder,
|
||||
get_authz_hooks,
|
||||
get_default_authz_hooks,
|
||||
)
|
||||
from app.plugins.auth.authorization.types import (
|
||||
AuthContext,
|
||||
Permissions,
|
||||
ALL_PERMISSIONS,
|
||||
)
|
||||
|
||||
_ALL_PERMISSIONS = ALL_PERMISSIONS
|
||||
|
||||
__all__ = [
|
||||
"AuthContext",
|
||||
"AuthzHooks",
|
||||
"Permissions",
|
||||
"_ALL_PERMISSIONS",
|
||||
"build_authz_hooks",
|
||||
"build_permission_provider",
|
||||
"build_policy_chain_builder",
|
||||
"get_auth_context",
|
||||
"get_authz_hooks",
|
||||
"get_default_authz_hooks",
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Authentication helpers used by auth-plugin authorization decorators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.plugins.auth.authorization.providers import PermissionProvider, default_permission_provider
|
||||
from app.plugins.auth.authorization.types import AuthContext
|
||||
|
||||
|
||||
def get_auth_context(request: Request) -> AuthContext | None:
|
||||
"""Get AuthContext, preferring Starlette-style request.auth."""
|
||||
|
||||
auth = request.scope.get("auth")
|
||||
if isinstance(auth, AuthContext):
|
||||
return auth
|
||||
return getattr(request.state, "auth", None)
|
||||
|
||||
|
||||
def set_auth_context(request: Request, auth_context: AuthContext) -> AuthContext:
|
||||
"""Persist AuthContext on the standard request surfaces."""
|
||||
|
||||
request.scope["auth"] = auth_context
|
||||
request.state.auth = auth_context
|
||||
return auth_context
|
||||
|
||||
|
||||
async def authenticate_request(
|
||||
request: Request,
|
||||
*,
|
||||
permission_provider: PermissionProvider = default_permission_provider,
|
||||
) -> AuthContext:
|
||||
"""Authenticate request and build AuthContext."""
|
||||
|
||||
from app.plugins.auth.security.dependencies import get_optional_user_from_request
|
||||
|
||||
user = await get_optional_user_from_request(request)
|
||||
if user is None:
|
||||
return AuthContext(user=None, permissions=[])
|
||||
return AuthContext(user=user, permissions=permission_provider(user))
|
||||
|
||||
|
||||
__all__ = ["authenticate_request", "get_auth_context", "set_auth_context"]
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Authorization requirement and policy evaluation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.plugins.auth.authorization.policies import require_thread_owner
|
||||
from app.plugins.auth.authorization.types import AuthContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PermissionRequirement:
|
||||
"""Authorization requirement for a single route action."""
|
||||
|
||||
resource: str
|
||||
action: str
|
||||
owner_check: bool = False
|
||||
require_existing: bool = False
|
||||
|
||||
@property
|
||||
def permission(self) -> str:
|
||||
return f"{self.resource}:{self.action}"
|
||||
|
||||
|
||||
PolicyEvaluator = Callable[[Request, AuthContext, PermissionRequirement, Mapping[str, Any]], Awaitable[None]]
|
||||
|
||||
|
||||
def ensure_authenticated(auth: AuthContext) -> None:
|
||||
if not auth.is_authenticated:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
|
||||
def ensure_capability(auth: AuthContext, requirement: PermissionRequirement) -> None:
|
||||
if not auth.has_permission(requirement.resource, requirement.action):
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {requirement.permission}")
|
||||
|
||||
|
||||
async def evaluate_owner_policy(
|
||||
request: Request,
|
||||
auth: AuthContext,
|
||||
requirement: PermissionRequirement,
|
||||
route_params: Mapping[str, Any],
|
||||
) -> None:
|
||||
if not requirement.owner_check:
|
||||
return
|
||||
|
||||
thread_id = route_params.get("thread_id")
|
||||
if thread_id is None:
|
||||
raise ValueError("require_permission with owner_check=True requires 'thread_id' parameter")
|
||||
|
||||
await require_thread_owner(
|
||||
request,
|
||||
auth,
|
||||
thread_id=thread_id,
|
||||
require_existing=requirement.require_existing,
|
||||
)
|
||||
|
||||
|
||||
async def evaluate_requirement(
|
||||
request: Request,
|
||||
auth: AuthContext,
|
||||
requirement: PermissionRequirement,
|
||||
route_params: Mapping[str, Any],
|
||||
*,
|
||||
policy_evaluators: tuple[PolicyEvaluator, ...],
|
||||
) -> None:
|
||||
ensure_authenticated(auth)
|
||||
ensure_capability(auth, requirement)
|
||||
for evaluator in policy_evaluators:
|
||||
await evaluator(request, auth, requirement, route_params)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PermissionRequirement",
|
||||
"PolicyEvaluator",
|
||||
"ensure_authenticated",
|
||||
"ensure_capability",
|
||||
"evaluate_owner_policy",
|
||||
"evaluate_requirement",
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Auth-plugin authz extension hooks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.plugins.auth.authorization.providers import PermissionProvider, default_permission_provider
|
||||
from app.plugins.auth.authorization.registry import PolicyChainBuilder, build_default_policy_evaluators
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthzHooks:
|
||||
"""Extension hooks for permission and policy resolution."""
|
||||
|
||||
permission_provider: PermissionProvider = default_permission_provider
|
||||
policy_chain_builder: PolicyChainBuilder = build_default_policy_evaluators
|
||||
|
||||
|
||||
DEFAULT_AUTHZ_HOOKS = AuthzHooks()
|
||||
|
||||
|
||||
def get_default_authz_hooks() -> AuthzHooks:
|
||||
return DEFAULT_AUTHZ_HOOKS
|
||||
|
||||
|
||||
def get_authz_hooks(request: Request | Any | None = None) -> AuthzHooks:
|
||||
if request is not None:
|
||||
app = getattr(request, "app", None)
|
||||
state = getattr(app, "state", None)
|
||||
hooks = getattr(state, "authz_hooks", None)
|
||||
if isinstance(hooks, AuthzHooks):
|
||||
return hooks
|
||||
return DEFAULT_AUTHZ_HOOKS
|
||||
|
||||
|
||||
def build_permission_provider() -> PermissionProvider:
|
||||
return default_permission_provider
|
||||
|
||||
|
||||
def build_policy_chain_builder() -> PolicyChainBuilder:
|
||||
return build_default_policy_evaluators
|
||||
|
||||
|
||||
def build_authz_hooks() -> AuthzHooks:
|
||||
return AuthzHooks(
|
||||
permission_provider=build_permission_provider(),
|
||||
policy_chain_builder=build_policy_chain_builder(),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AuthzHooks",
|
||||
"DEFAULT_AUTHZ_HOOKS",
|
||||
"build_authz_hooks",
|
||||
"build_permission_provider",
|
||||
"build_policy_chain_builder",
|
||||
"get_authz_hooks",
|
||||
"get_default_authz_hooks",
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Authorization policies for resource ownership and access checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.plugins.auth.authorization.types import AuthContext
|
||||
|
||||
|
||||
def _get_thread_owner_id(thread_meta: Any) -> str | None:
|
||||
owner_id = getattr(thread_meta, "user_id", None)
|
||||
if owner_id is not None:
|
||||
return str(owner_id)
|
||||
|
||||
metadata = getattr(thread_meta, "metadata", None) or {}
|
||||
metadata_owner_id = metadata.get("user_id")
|
||||
if metadata_owner_id is not None:
|
||||
return str(metadata_owner_id)
|
||||
return None
|
||||
|
||||
|
||||
async def _thread_exists_via_legacy_sources(request: Request, auth: AuthContext, *, thread_id: str) -> bool:
|
||||
from app.gateway.dependencies.repositories import get_run_repository
|
||||
|
||||
principal_id = auth.principal_id
|
||||
run_store = get_run_repository(request)
|
||||
runs = await run_store.list_by_thread(
|
||||
thread_id,
|
||||
limit=1,
|
||||
user_id=principal_id,
|
||||
)
|
||||
if runs:
|
||||
return True
|
||||
|
||||
checkpointer = getattr(request.app.state, "checkpointer", None)
|
||||
if checkpointer is None:
|
||||
return False
|
||||
|
||||
checkpoint_tuple = await checkpointer.aget_tuple(
|
||||
{"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||
)
|
||||
return checkpoint_tuple is not None
|
||||
|
||||
|
||||
async def require_thread_owner(
|
||||
request: Request,
|
||||
auth: AuthContext,
|
||||
*,
|
||||
thread_id: str,
|
||||
require_existing: bool,
|
||||
) -> None:
|
||||
"""Ensure the current user owns the thread referenced by ``thread_id``."""
|
||||
|
||||
from app.gateway.dependencies.repositories import get_thread_meta_repository
|
||||
|
||||
thread_repo = get_thread_meta_repository(request)
|
||||
thread_meta = await thread_repo.get_thread_meta(thread_id)
|
||||
if thread_meta is None:
|
||||
allowed = not require_existing
|
||||
if not allowed:
|
||||
allowed = await _thread_exists_via_legacy_sources(request, auth, thread_id=thread_id)
|
||||
else:
|
||||
owner_id = _get_thread_owner_id(thread_meta)
|
||||
allowed = owner_id in (None, str(auth.user.id))
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Thread {thread_id} not found",
|
||||
)
|
||||
|
||||
|
||||
async def require_run_owner(
|
||||
request: Request,
|
||||
auth: AuthContext,
|
||||
*,
|
||||
thread_id: str,
|
||||
run_id: str,
|
||||
require_existing: bool,
|
||||
) -> None:
|
||||
"""Ensure the current user owns the run referenced by ``run_id``."""
|
||||
|
||||
from app.gateway.dependencies import get_run_repository
|
||||
|
||||
run_store = get_run_repository(request)
|
||||
run = await run_store.get(run_id)
|
||||
if run is None:
|
||||
allowed = not require_existing
|
||||
else:
|
||||
allowed = run.get("thread_id") == thread_id
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Run {run_id} not found",
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["require_run_owner", "require_thread_owner"]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Default permission provider hooks for auth-plugin authorization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from app.plugins.auth.authorization.types import ALL_PERMISSIONS
|
||||
|
||||
PermissionProvider = Callable[[object], list[str]]
|
||||
|
||||
|
||||
def default_permission_provider(user: object) -> list[str]:
|
||||
"""Return the current static permission set for an authenticated user."""
|
||||
|
||||
return list(ALL_PERMISSIONS)
|
||||
|
||||
|
||||
__all__ = ["PermissionProvider", "default_permission_provider"]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Registry/build helpers for default authorization evaluators."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plugins.auth.authorization.authorization import PolicyEvaluator
|
||||
|
||||
|
||||
PolicyChainBuilder = Callable[[], tuple["PolicyEvaluator", ...]]
|
||||
|
||||
|
||||
def build_default_policy_evaluators() -> tuple["PolicyEvaluator", ...]:
|
||||
"""Return the default policy chain for auth-plugin authorization."""
|
||||
|
||||
from app.plugins.auth.authorization.authorization import evaluate_owner_policy
|
||||
|
||||
return (evaluate_owner_policy,)
|
||||
|
||||
|
||||
__all__ = ["PolicyChainBuilder", "build_default_policy_evaluators"]
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Authorization context and capability constants for the auth plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.plugins.auth.domain.models import User
|
||||
|
||||
|
||||
class Permissions:
|
||||
"""Permission constants for resource:action format."""
|
||||
|
||||
THREADS_READ = "threads:read"
|
||||
THREADS_WRITE = "threads:write"
|
||||
THREADS_DELETE = "threads:delete"
|
||||
|
||||
RUNS_CREATE = "runs:create"
|
||||
RUNS_READ = "runs:read"
|
||||
RUNS_CANCEL = "runs:cancel"
|
||||
|
||||
|
||||
class AuthContext:
|
||||
"""Authentication context for the current request."""
|
||||
|
||||
__slots__ = ("user", "permissions")
|
||||
|
||||
def __init__(self, user: User | None = None, permissions: list[str] | None = None):
|
||||
self.user = user
|
||||
self.permissions = permissions or []
|
||||
|
||||
@property
|
||||
def is_authenticated(self) -> bool:
|
||||
return self.user is not None
|
||||
|
||||
@property
|
||||
def principal_id(self) -> str | None:
|
||||
if self.user is None:
|
||||
return None
|
||||
return str(self.user.id)
|
||||
|
||||
@property
|
||||
def capabilities(self) -> tuple[str, ...]:
|
||||
return tuple(self.permissions)
|
||||
|
||||
def has_permission(self, resource: str, action: str) -> bool:
|
||||
return f"{resource}:{action}" in self.permissions
|
||||
|
||||
def require_user(self) -> User:
|
||||
if not self.user:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
return self.user
|
||||
|
||||
|
||||
ALL_PERMISSIONS: list[str] = [
|
||||
Permissions.THREADS_READ,
|
||||
Permissions.THREADS_WRITE,
|
||||
Permissions.THREADS_DELETE,
|
||||
Permissions.RUNS_CREATE,
|
||||
Permissions.RUNS_READ,
|
||||
Permissions.RUNS_CANCEL,
|
||||
]
|
||||
|
||||
|
||||
__all__ = ["ALL_PERMISSIONS", "AuthContext", "Permissions"]
|
||||
Reference in New Issue
Block a user