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:
rayhpeng
2026-04-22 11:31:42 +08:00
parent a0ab3a3dd4
commit 0f82f8a3a2
47 changed files with 5516 additions and 0 deletions
@@ -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"]