diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py index 79cf9a537..239221eef 100644 --- a/backend/app/gateway/auth/models.py +++ b/backend/app/gateway/auth/models.py @@ -40,3 +40,7 @@ class UserResponse(BaseModel): system_role: Literal["admin", "user"] needs_setup: bool = False oauth_provider: str | None = Field(None, description="OAuth/SSO provider ID if the user logged in via SSO (e.g. 'keycloak')") + permissions: list[str] | None = Field( + None, + description=("Effective route permissions granted to this credential (RFC #4063 Phase 4). Only GET /api/v1/auth/me resolves them; credential-creation responses leave it None."), + ) diff --git a/backend/app/gateway/authz.py b/backend/app/gateway/authz.py index 269fbbb90..a3f7da407 100644 --- a/backend/app/gateway/authz.py +++ b/backend/app/gateway/authz.py @@ -288,6 +288,17 @@ async def resolve_route_permissions(user: User, *, is_internal: bool) -> list[st return [p for p in results if p is not None] +async def resolve_route_permissions_for_request(request: Request, user: Any) -> list[str]: + """Resolve the effective route permissions for a request's authenticated user. + + Public wrapper pairing ``resolve_route_permissions`` with the internal-caller + heuristics of ``_is_internal_caller`` (auth source, synthetic internal role, + internal auth header), so middleware-less consumers resolve exactly what + ``_authenticate`` resolves and the two cannot drift apart. + """ + return await resolve_route_permissions(user, is_internal=_is_internal_caller(request, user)) + + class _AuthorizationUnavailable(Exception): """Raised internally when the provider cannot be resolved for a route check. @@ -497,8 +508,7 @@ async def _authenticate(request: Request) -> AuthContext: if user is None: return AuthContext(user=None, permissions=[]) - is_internal = _is_internal_caller(request, user) - permissions = await resolve_route_permissions(user, is_internal=is_internal) + permissions = await resolve_route_permissions_for_request(request, user) return AuthContext(user=user, permissions=permissions) diff --git a/backend/app/gateway/routers/auth.py b/backend/app/gateway/routers/auth.py index 2b953f1d0..06c9e41fe 100644 --- a/backend/app/gateway/routers/auth.py +++ b/backend/app/gateway/routers/auth.py @@ -560,14 +560,26 @@ async def change_password(request: Request, response: Response, body: ChangePass @router.get("/me", response_model=UserResponse) async def get_me(request: Request): - """Get current authenticated user info.""" + """Get current authenticated user info, including effective permissions.""" user = await get_current_user_from_request(request) + auth = getattr(getattr(request, "state", None), "auth", None) + if auth is not None: + # AuthMiddleware already resolved the per-request permission set (with + # PAT-scope intersection and internal-caller semantics applied) — reuse + # it instead of evaluating the provider a second time. + permissions: list[str] = list(auth.permissions) + else: + # Middleware-less composition: resolve exactly as _authenticate does. + from app.gateway.authz import resolve_route_permissions_for_request + + permissions = await resolve_route_permissions_for_request(request, user) return UserResponse( id=str(user.id), email=user.email, system_role=user.system_role, needs_setup=user.needs_setup, oauth_provider=user.oauth_provider, + permissions=permissions, ) diff --git a/backend/tests/test_auth_me_permissions.py b/backend/tests/test_auth_me_permissions.py new file mode 100644 index 000000000..2622e3024 --- /dev/null +++ b/backend/tests/test_auth_me_permissions.py @@ -0,0 +1,215 @@ +"""GET /api/v1/auth/me effective-permissions tests (RFC #4063 Phase 4). + +Pins the /me wiring only: the field is populated from the AuthContext that +AuthMiddleware already resolves per request, and a middleware-less call +falls back to a fresh resolution with the same semantics ``_authenticate`` +uses. The permission-derivation semantics themselves (disabled / fail-closed +/ fail-open / per-action requests) are pinned by +test_authorization_route_permissions.py and are not re-tested here. +""" + +import asyncio +import os +from types import SimpleNamespace + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("AUTH_JWT_SECRET", "test-secret-key-auth-me-permissions-min-32") + +from app.gateway.authz import Permissions # noqa: E402 +from deerflow.authz.provider import AuthzDecision, AuthzReason # noqa: E402 +from deerflow.config.authorization_config import AuthorizationConfig # noqa: E402 + +_TEST_SECRET = "test-secret-key-auth-me-permissions-min-32" + +_ALL_PERMISSIONS = [ + Permissions.THREADS_READ, + Permissions.THREADS_WRITE, + Permissions.THREADS_DELETE, + Permissions.RUNS_CREATE, + Permissions.RUNS_READ, + Permissions.RUNS_CANCEL, +] + + +class _RecordingProvider: + """Async-only provider recording every decision request it serves.""" + + name = "recording" + + def __init__(self, *, denied: set[str] | None = None) -> None: + self.denied = denied or set() + self.requests = [] + + def authorize(self, request): + raise AssertionError("route authorization must use the async provider API") + + async def aauthorize(self, request): + self.requests.append(request) + allowed = request.target not in self.denied + return AuthzDecision( + allow=allowed, + reasons=[AuthzReason(code="authz.allowed" if allowed else "authz.denied")], + ) + + def filter_resources(self, principal, resource_type, candidates): + raise AssertionError("route authorization must preserve per-action requests") + + +@pytest.fixture(autouse=True) +def _setup_auth(tmp_path): + """Fresh SQLite engine + auth config per test (test_initialize_admin pattern).""" + from app.gateway import deps + from app.gateway.auth.config import AuthConfig, set_auth_config + from app.gateway.routers.auth import _SETUP_STATUS_CACHE, _SETUP_STATUS_INFLIGHT + from deerflow.persistence.engine import close_engine, init_engine + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + url = f"sqlite+aiosqlite:///{tmp_path}/auth_me.db" + asyncio.run(init_engine("sqlite", url=url, sqlite_dir=str(tmp_path))) + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_CACHE.clear() + _SETUP_STATUS_INFLIGHT.clear() + try: + yield + finally: + deps._cached_local_provider = None + deps._cached_repo = None + _SETUP_STATUS_CACHE.clear() + _SETUP_STATUS_INFLIGHT.clear() + asyncio.run(close_engine()) + + +@pytest.fixture(autouse=True) +def _default_route_authorization_config(monkeypatch): + """Keep /me independent of a repository config.yaml (disabled by default).""" + monkeypatch.setattr( + "app.gateway.authz._get_route_authorization_config", + lambda: AuthorizationConfig(), + ) + + +@pytest.fixture() +def client(_setup_auth): + from app.gateway.app import create_app + from app.gateway.auth.config import AuthConfig, set_auth_config + + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + app = create_app() + # No context manager: the full lifespan requires config.yaml, the auth + # routes work without it (the persistence engine is set up by _setup_auth). + yield TestClient(app) + + +def _initialize_admin(client: TestClient): + resp = client.post( + "/api/v1/auth/initialize", + json={"email": "admin@example.com", "password": "Str0ng!Pass99"}, + ) + assert resp.status_code == 201 + return resp + + +def _enable_authorization(monkeypatch, provider) -> None: + config = AuthorizationConfig(enabled=True, fail_closed=True, default_role="user") + monkeypatch.setattr("app.gateway.authz._get_route_authorization_config", lambda: config) + monkeypatch.setattr("app.gateway.authz._get_cached_route_provider", lambda c: provider) + + +# ── Route-level wiring ──────────────────────────────────────────────────── + + +def test_me_lists_all_route_permissions_when_authorization_disabled(client): + _initialize_admin(client) + res = client.get("/api/v1/auth/me") + assert res.status_code == 200 + assert res.json()["permissions"] == _ALL_PERMISSIONS + + +def test_me_reuses_middleware_resolved_permissions(client, monkeypatch): + """One provider decision per registered permission — /me reads the + AuthContext AuthMiddleware already stamped instead of re-resolving.""" + denied = {Permissions.THREADS_DELETE, Permissions.RUNS_CANCEL} + provider = _RecordingProvider(denied=denied) + _enable_authorization(monkeypatch, provider) + _initialize_admin(client) + + res = client.get("/api/v1/auth/me") + + assert res.status_code == 200 + assert res.json()["permissions"] == [p for p in _ALL_PERMISSIONS if p not in denied] + assert len(provider.requests) == len(_ALL_PERMISSIONS) + + +def test_initialize_response_leaves_permissions_unresolved(client): + """Credential-creation responses do not resolve permissions (None), so + they never advertise a misleading empty grant set.""" + resp = _initialize_admin(client) + assert resp.json()["permissions"] is None + + +def test_auth_disabled_me_includes_default_admin_permissions(monkeypatch, _setup_auth): + from app.gateway.app import create_app + from app.gateway.auth.config import AuthConfig, set_auth_config + + monkeypatch.setenv("DEER_FLOW_AUTH_DISABLED", "1") + set_auth_config(AuthConfig(jwt_secret=_TEST_SECRET)) + client = TestClient(create_app()) + + res = client.get("/api/v1/auth/me") + + assert res.status_code == 200 + assert res.json()["permissions"] == _ALL_PERMISSIONS + + +# ── Middleware-less fallback ────────────────────────────────────────────── + + +def _fallback_request(user: SimpleNamespace, auth_source: str) -> SimpleNamespace: + return SimpleNamespace( + state=SimpleNamespace(user=user, auth_source=auth_source), + cookies={}, + headers={}, + ) + + +def _stub_user() -> SimpleNamespace: + return SimpleNamespace( + id="user-123", + email="user@example.test", + system_role="user", + needs_setup=False, + oauth_provider=None, + ) + + +@pytest.mark.asyncio +async def test_me_falls_back_to_fresh_resolution_without_middleware_context(): + """Direct handler invocation (no AuthMiddleware) resolves permissions the + same way ``_authenticate`` would instead of reporting an empty grant.""" + from app.gateway.routers.auth import get_me + + response = await get_me(_fallback_request(_stub_user(), "session")) + + assert response.permissions == _ALL_PERMISSIONS + + +@pytest.mark.asyncio +async def test_me_fallback_mirrors_authenticate_internal_caller_semantics(monkeypatch): + from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL + from app.gateway.routers.auth import get_me + + captured = {} + + async def fake_resolve(user, *, is_internal): + captured["is_internal"] = is_internal + return ["threads:read"] + + monkeypatch.setattr("app.gateway.authz.resolve_route_permissions", fake_resolve) + + response = await get_me(_fallback_request(_stub_user(), AUTH_SOURCE_INTERNAL)) + + assert response.permissions == ["threads:read"] + assert captured["is_internal"] is True diff --git a/frontend/tests/e2e-real-backend/auth-disabled-contract.spec.ts b/frontend/tests/e2e-real-backend/auth-disabled-contract.spec.ts index 5b826e1f0..33ac90989 100644 --- a/frontend/tests/e2e-real-backend/auth-disabled-contract.spec.ts +++ b/frontend/tests/e2e-real-backend/auth-disabled-contract.spec.ts @@ -6,6 +6,18 @@ const APP = process.env.E2E_APP_URL ?? `http://localhost:${process.env.E2E_FRONTEND_PORT ?? "3000"}`; +// /me also returns the caller's effective route permissions (RFC #4063 +// Phase 4). Auth-disabled mode grants the full registered set, in the order +// of backend _ALL_PERMISSIONS (backend/app/gateway/authz.py). +const AUTH_DISABLED_PERMISSIONS = [ + "threads:read", + "threads:write", + "threads:delete", + "runs:create", + "runs:read", + "runs:cancel", +]; + test.describe("auth-disabled contract (real backend)", () => { test("gateway /auth/me returns the frontend synthetic user without a cookie", async ({ context, @@ -13,6 +25,9 @@ test.describe("auth-disabled contract (real backend)", () => { const resp = await context.request.get(`${APP}/api/v1/auth/me`); expect(resp.status(), await resp.text()).toBe(200); - await expect(resp.json()).resolves.toEqual(AUTH_DISABLED_USER); + await expect(resp.json()).resolves.toEqual({ + ...AUTH_DISABLED_USER, + permissions: AUTH_DISABLED_PERMISSIONS, + }); }); });