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,17 @@
|
||||
"""Config-driven route authorization injection for the auth plugin."""
|
||||
|
||||
from app.plugins.auth.injection.registry_loader import (
|
||||
RoutePolicyRegistry,
|
||||
RoutePolicySpec,
|
||||
load_route_policy_registry,
|
||||
)
|
||||
from app.plugins.auth.injection.route_injector import install_route_guards
|
||||
from app.plugins.auth.injection.validation import validate_route_policy_registry
|
||||
|
||||
__all__ = [
|
||||
"RoutePolicyRegistry",
|
||||
"RoutePolicySpec",
|
||||
"install_route_guards",
|
||||
"load_route_policy_registry",
|
||||
"validate_route_policy_registry",
|
||||
]
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Load auth route policies from the plugin's YAML registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from starlette.routing import compile_path
|
||||
import yaml
|
||||
|
||||
_POLICY_FILE = Path(__file__).resolve().parents[1] / "route_policies.yaml"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutePolicySpec:
|
||||
public: bool = False
|
||||
capability: str | None = None
|
||||
policies: tuple[str, ...] = ()
|
||||
require_existing: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RoutePolicyEntry:
|
||||
method: str
|
||||
path: str
|
||||
spec: RoutePolicySpec
|
||||
path_regex: object = field(repr=False)
|
||||
|
||||
def matches_request(self, method: str, path: str) -> bool:
|
||||
if self.method != method.upper():
|
||||
return False
|
||||
return self.path_regex.match(path) is not None
|
||||
|
||||
|
||||
class RoutePolicyRegistry:
|
||||
def __init__(self, entries: list[RoutePolicyEntry]) -> None:
|
||||
self._entries = entries
|
||||
self._specs = {(entry.method, entry.path): entry.spec for entry in entries}
|
||||
|
||||
def get(self, method: str, path_template: str) -> RoutePolicySpec | None:
|
||||
return self._specs.get((method.upper(), path_template))
|
||||
|
||||
def has(self, method: str, path_template: str) -> bool:
|
||||
return (method.upper(), path_template) in self._specs
|
||||
|
||||
def match_request(self, method: str, path: str) -> RoutePolicySpec | None:
|
||||
normalized_method = method.upper()
|
||||
for entry in self._entries:
|
||||
if entry.matches_request(normalized_method, path):
|
||||
return entry.spec
|
||||
return None
|
||||
|
||||
def is_public_request(self, method: str, path: str) -> bool:
|
||||
spec = self.match_request(method, path)
|
||||
return bool(spec and spec.public)
|
||||
|
||||
@property
|
||||
def keys(self) -> set[tuple[str, str]]:
|
||||
return set(self._specs)
|
||||
|
||||
|
||||
def _normalize_methods(item: dict) -> tuple[str, ...]:
|
||||
methods = item.get("methods")
|
||||
if methods is None:
|
||||
methods = [item["method"]]
|
||||
if isinstance(methods, str):
|
||||
methods = [methods]
|
||||
return tuple(str(method).upper() for method in methods)
|
||||
|
||||
|
||||
def _build_spec(item: dict) -> RoutePolicySpec:
|
||||
return RoutePolicySpec(
|
||||
public=bool(item.get("public", False)),
|
||||
capability=item.get("capability"),
|
||||
policies=tuple(item.get("policies", [])),
|
||||
require_existing=bool(item.get("require_existing", True)),
|
||||
)
|
||||
|
||||
|
||||
def load_route_policy_registry() -> RoutePolicyRegistry:
|
||||
payload = yaml.safe_load(_POLICY_FILE.read_text(encoding="utf-8")) or {}
|
||||
raw_routes: list[dict] = []
|
||||
for section, entries in payload.items():
|
||||
if section == "routes":
|
||||
if isinstance(entries, list):
|
||||
raw_routes.extend(entries)
|
||||
continue
|
||||
if not isinstance(entries, list):
|
||||
continue
|
||||
for item in entries:
|
||||
normalized = dict(item)
|
||||
if section == "public":
|
||||
normalized["public"] = True
|
||||
raw_routes.append(normalized)
|
||||
entries: list[RoutePolicyEntry] = []
|
||||
for item in raw_routes:
|
||||
path = str(item["path"])
|
||||
spec = _build_spec(item)
|
||||
path_regex, _, _ = compile_path(path)
|
||||
for method in _normalize_methods(item):
|
||||
entries.append(
|
||||
RoutePolicyEntry(
|
||||
method=method,
|
||||
path=path,
|
||||
spec=spec,
|
||||
path_regex=path_regex,
|
||||
)
|
||||
)
|
||||
return RoutePolicyRegistry(entries)
|
||||
|
||||
|
||||
__all__ = ["RoutePolicyRegistry", "RoutePolicySpec", "load_route_policy_registry"]
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Runtime route guard backed by the auth plugin's route policy registry."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from app.plugins.auth.authorization.authentication import (
|
||||
authenticate_request,
|
||||
get_auth_context,
|
||||
set_auth_context,
|
||||
)
|
||||
from app.plugins.auth.authorization.authorization import ensure_authenticated
|
||||
from app.plugins.auth.authorization.hooks import get_authz_hooks
|
||||
from app.plugins.auth.authorization.policies import require_run_owner, require_thread_owner
|
||||
from app.plugins.auth.injection.registry_loader import RoutePolicyRegistry, RoutePolicySpec
|
||||
|
||||
PolicyGuard = Callable[[Request, RoutePolicySpec], Awaitable[None]]
|
||||
|
||||
|
||||
async def _check_capability(request: Request, spec: RoutePolicySpec) -> None:
|
||||
if not spec.capability:
|
||||
return
|
||||
|
||||
auth = get_auth_context(request)
|
||||
if auth is None:
|
||||
raise HTTPException(status_code=500, detail="Missing auth context")
|
||||
|
||||
if ":" not in spec.capability:
|
||||
raise RuntimeError(f"Invalid capability format: {spec.capability}")
|
||||
resource, action = spec.capability.split(":", 1)
|
||||
if not auth.has_permission(resource, action):
|
||||
raise HTTPException(status_code=403, detail=f"Permission denied: {spec.capability}")
|
||||
|
||||
|
||||
async def _guard_thread_owner(request: Request, spec: RoutePolicySpec) -> None:
|
||||
auth = get_auth_context(request)
|
||||
if auth is None:
|
||||
raise HTTPException(status_code=500, detail="Missing auth context")
|
||||
thread_id = request.path_params.get("thread_id")
|
||||
if not isinstance(thread_id, str):
|
||||
raise RuntimeError("owner:thread policy requires thread_id path parameter")
|
||||
await require_thread_owner(request, auth, thread_id=thread_id, require_existing=spec.require_existing)
|
||||
|
||||
|
||||
async def _guard_run_owner(request: Request, spec: RoutePolicySpec) -> None:
|
||||
auth = get_auth_context(request)
|
||||
if auth is None:
|
||||
raise HTTPException(status_code=500, detail="Missing auth context")
|
||||
thread_id = request.path_params.get("thread_id")
|
||||
run_id = request.path_params.get("run_id")
|
||||
if not isinstance(thread_id, str) or not isinstance(run_id, str):
|
||||
raise RuntimeError("owner:run policy requires thread_id and run_id path parameters")
|
||||
await require_run_owner(
|
||||
request,
|
||||
auth,
|
||||
thread_id=thread_id,
|
||||
run_id=run_id,
|
||||
require_existing=spec.require_existing,
|
||||
)
|
||||
|
||||
|
||||
_POLICY_GUARDS: dict[str, PolicyGuard] = {
|
||||
"owner:thread": _guard_thread_owner,
|
||||
"owner:run": _guard_run_owner,
|
||||
}
|
||||
|
||||
|
||||
async def enforce_route_policy(request: Request) -> None:
|
||||
registry = getattr(request.app.state, "auth_route_policy_registry", None)
|
||||
if not isinstance(registry, RoutePolicyRegistry):
|
||||
raise RuntimeError("Auth route policy registry is not configured")
|
||||
|
||||
route = request.scope.get("route")
|
||||
path_template = getattr(route, "path", None)
|
||||
if not isinstance(path_template, str):
|
||||
raise RuntimeError("Unable to resolve route path for authorization")
|
||||
|
||||
spec = registry.get(request.method, path_template)
|
||||
if spec is None:
|
||||
raise RuntimeError(f"Missing auth route policy for {request.method} {path_template}")
|
||||
if spec.public:
|
||||
return
|
||||
|
||||
auth = get_auth_context(request)
|
||||
if auth is None:
|
||||
hooks = get_authz_hooks(request)
|
||||
auth = await authenticate_request(request, permission_provider=hooks.permission_provider)
|
||||
set_auth_context(request, auth)
|
||||
|
||||
ensure_authenticated(auth)
|
||||
await _check_capability(request, spec)
|
||||
|
||||
for policy_name in spec.policies:
|
||||
guard = _POLICY_GUARDS.get(policy_name)
|
||||
if guard is None:
|
||||
raise RuntimeError(f"Unknown route policy guard: {policy_name}")
|
||||
await guard(request, spec)
|
||||
|
||||
|
||||
__all__ = ["enforce_route_policy"]
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Inject config-driven auth guards into FastAPI routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
from fastapi.dependencies.utils import get_dependant, get_flat_dependant, get_parameterless_sub_dependant
|
||||
from fastapi.routing import APIRoute, _should_embed_body_fields, get_body_field, request_response
|
||||
|
||||
from app.plugins.auth.injection.route_guard import enforce_route_policy
|
||||
|
||||
|
||||
def _rebuild_route(route: APIRoute) -> None:
|
||||
route.dependant = get_dependant(path=route.path_format, call=route.endpoint, scope="function")
|
||||
for depends in route.dependencies[::-1]:
|
||||
route.dependant.dependencies.insert(
|
||||
0,
|
||||
get_parameterless_sub_dependant(depends=depends, path=route.path_format),
|
||||
)
|
||||
route._flat_dependant = get_flat_dependant(route.dependant)
|
||||
route._embed_body_fields = _should_embed_body_fields(route._flat_dependant.body_params)
|
||||
route.body_field = get_body_field(
|
||||
flat_dependant=route._flat_dependant,
|
||||
name=route.unique_id,
|
||||
embed_body_fields=route._embed_body_fields,
|
||||
)
|
||||
route.app = request_response(route.get_route_handler())
|
||||
|
||||
|
||||
def install_route_guards(app: FastAPI) -> None:
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
if any(getattr(dependency, "dependency", None) is enforce_route_policy for dependency in route.dependencies):
|
||||
continue
|
||||
route.dependencies.append(Depends(enforce_route_policy))
|
||||
_rebuild_route(route)
|
||||
|
||||
|
||||
__all__ = ["install_route_guards"]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Validation helpers for config-driven auth route policies."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
|
||||
from app.plugins.auth.injection.registry_loader import RoutePolicyRegistry
|
||||
|
||||
_IGNORED_METHODS = frozenset({"HEAD", "OPTIONS"})
|
||||
|
||||
|
||||
def _iter_route_keys(app: FastAPI) -> set[tuple[str, str]]:
|
||||
keys: set[tuple[str, str]] = set()
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
for method in route.methods:
|
||||
if method in _IGNORED_METHODS:
|
||||
continue
|
||||
keys.add((method, route.path))
|
||||
return keys
|
||||
|
||||
|
||||
def validate_route_policy_registry(app: FastAPI, registry: RoutePolicyRegistry) -> None:
|
||||
route_keys = _iter_route_keys(app)
|
||||
missing = sorted(route_keys - registry.keys)
|
||||
extra = sorted(registry.keys - route_keys)
|
||||
problems: list[str] = []
|
||||
if missing:
|
||||
problems.append("Missing route policy entries:\n" + "\n".join(f" - {method} {path}" for method, path in missing))
|
||||
if extra:
|
||||
problems.append("Unknown route policy entries:\n" + "\n".join(f" - {method} {path}" for method, path in extra))
|
||||
if problems:
|
||||
raise RuntimeError("\n\n".join(problems))
|
||||
|
||||
|
||||
__all__ = ["validate_route_policy_registry"]
|
||||
Reference in New Issue
Block a user