mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(authz): admin role-assignment API for configured custom roles (#4063 gap 2) The RBAC provider accepts arbitrary configured role names (guest, ...), but nothing could assign them to real users: User.system_role was pinned to Literal["admin", "user"] at the API layer while the DB column stayed a plain String(16) for exactly this future, and zhfeng's #3462 audit listed the missing assignment surface as gap 2 — custom roles were declarable in YAML but unreachable for authenticated web users. - User/UserResponse.system_role widens to a non-empty string; whether a role is *assignable* is the admin API's contract, not the model's. - RbacAuthorizationProvider exposes known_roles; the Gateway's new assignable_role_names() unions the built-ins with the provider's configured role names (custom providers: built-ins only). - New admin-only router /api/v1/admin/users: GET lists users with their role; PATCH assigns — role validated against assignable names (an unknown role would silently fall through to default_role on every decision), 404 for missing users, and the last remaining admin cannot be demoted (bootstrap lockout protection). Enforcement is unchanged: the assignment only changes which role the principal carries; the @require_permission route guards and runtime provider decisions remain the single enforcement points. - SQLite repository gains list_users (ordered oldest-first). Tests: router guard/422/404/409/no-op/happy-path, last-admin invariant (both directions), assignable names union, principal carries the configured role; the two old Literal-rejection contract tests updated to the new contract (custom role representable; empty string still rejected). * fix(auth): widen the frontend user schema for custom roles (review-lessons catch) The role-assignment API's review-lessons sweep found the one consumer class the implementation had not enumerated: the FRONTEND parses /me with a strict userSchema whose system_role was z.enum(["admin", "user"]) — a user assigned a provider-configured custom role (guest) would fail userSchema.parse in server.ts and break the auth flow before rendering anything. - types.ts: system_role widens to z.string().min(1), kept in lockstep with the backend model (min_length=1). All existing consumers compare === "admin", so any other value is simply non-admin — no behavior change for admin/user. - server.test.ts: contract tests — custom role parses verbatim; empty string still rejected. - config.example.yaml: the authorization comment now says custom roles become reachable via PATCH /api/v1/admin/users/{id} and that a declared-but-unassigned role grants nothing. * fix(auth): commit the admin_users router, drop the stale type-ignore, document the surface Review catch on #6382: the router module was never staged — app.py and the tests import it, so the pushed branch could not boot (ImportError in create_app). The local tree kept it as an untracked file, which is why every check passed before the push. - Actually commit app/gateway/routers/admin_users.py. - _row_to_user: drop the now-stale '# type: ignore[arg-type]' on system_role — the model field is a plain string now (keep the comma). - README: the authorization paragraph now mentions the admin role-assignment surface (GET/PATCH /api/v1/admin/users, admin-only, last-admin demotion protected, declared-but-unassigned grants nothing). - server.test.ts import-order lint fix from the frontend sweep. * fix(auth): field-scoped role writes close the demotion-undo race Review round 2 on #6382 — three findings, one design: - [P1] update_user copied system_role from the caller's account snapshot, so a password change that read the user as admin could restore a role concurrently revoked by the demotion route (stale-snapshot write-back). Account writes are now field-scoped against each other: update_user is the credential/profile writer (never touches the role, mirrors the row's current role back), update_system_role is the only role writer (single-column, never touches credentials or token_version). - The last-admin lockout check moved inside the role write's serialized transaction: count-then-write across two sessions let two concurrent demotions of the only two admins both pass. Same idiom as create_first_admin — BEGIN IMMEDIATE on SQLite, pg_advisory_xact_lock on a distinct key on Postgres, raise on unknown dialects. - UserNotFoundError from the racing-delete window maps to the route's 404 instead of a 500. Tests 11->12: real-repo regression covering the P1 interleaving (demotion lands -> stale credential snapshot writes -> role stays revoked, credentials applied), field isolation both directions, and the last-admin invariant raised from inside the write with the row intact. Mutation-verified: restoring the role write-back in update_user fails it. * style(auth): format the server auth tests * fix(tests): migrate the personal-MCP demote revocation to update_system_role The role-write field-scoping (66e2cec23) made update_user a credential/profile-only writer, so the demote branch of test_revocation_blocks_discovery... silently no-oped and CI failed on all four shards (assert 2 == 1 — the role never changed). - The demote now goes through update_system_role, the sole role writer. - The fixture gains a second admin: the revocation test demotes its sole synthetic admin, which the new last-admin lockout guard correctly blocks — a second admin keeps the scenario legal without weakening either invariant. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
aa1472625d
commit
0032579017
@ -1500,7 +1500,7 @@ delete datasets and documents directly in RAGFlow.
|
|||||||
|
|
||||||
Each message can still select up to 1000 documents. When more than 100 documents are selected from a single dataset, DeerFlow validates them in batches of at most 100 while preserving the complete selection. If any batch contains an inaccessible or non-searchable document, retrieval is rejected.
|
Each message can still select up to 1000 documents. When more than 100 documents are selected from a single dataset, DeerFlow validates them in batches of at most 100 while preserving the complete selection. If any batch contains an inaccessible or non-searchable document, retrieval is rejected.
|
||||||
|
|
||||||
Advanced deployments can enable pluggable authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. Gateway `threads:*` and `runs:*` route permissions are derived from the same provider, while existing owner checks and admin-only management gates remain in force. Every HTTP route that starts or enables a future Agent run requires `runs:create`: this includes the stateless `POST /api/runs/stream` and `POST /api/runs/wait` endpoints plus scheduled-task create, update, resume, and manual-trigger mutations. Scheduled-task mutations retain their existing `threads:write` requirement, and the stateless routes separately enforce ownership when the optional thread ID is supplied in the request body. A generated `tool_search` may bypass the second tool check only when it fronts the current build's already-filtered deferred catalog. Model access follows the same provider: the Gateway `models` list is filtered per principal, `model:use` is enforced on model detail requests and again when the runtime resolves the agent's model, and a denied default model falls back to the first remaining candidate that also passes `model:use`. The built-in RBAC provider supports per-role `tools`, `routes`, `models`, `skills`, and `sandbox` allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md).
|
Advanced deployments can enable pluggable authorization with `authorization.enabled` in `config.yaml`. A configured `AuthorizationProvider` filters denied tools before they reach the model or deferred-tool catalog, then the same provider is checked again before every business-tool execution through the existing guardrail middleware. Gateway `threads:*` and `runs:*` route permissions are derived from the same provider, while existing owner checks and admin-only management gates remain in force. Every HTTP route that starts or enables a future Agent run requires `runs:create`: this includes the stateless `POST /api/runs/stream` and `POST /api/runs/wait` endpoints plus scheduled-task create, update, resume, and manual-trigger mutations. Scheduled-task mutations retain their existing `threads:write` requirement, and the stateless routes separately enforce ownership when the optional thread ID is supplied in the request body. A generated `tool_search` may bypass the second tool check only when it fronts the current build's already-filtered deferred catalog. Model access follows the same provider: the Gateway `models` list is filtered per principal, `model:use` is enforced on model detail requests and again when the runtime resolves the agent's model, and a denied default model falls back to the first remaining candidate that also passes `model:use`. The built-in RBAC provider supports per-role `tools`, `routes`, `models`, `skills`, and `sandbox` allow/deny policies and validates that `default_role` names a configured role; authorization is disabled by default. Custom roles declared in the provider's `roles` mapping become reachable by assigning them to a user through the admin API (`GET /api/v1/admin/users`, `PATCH /api/v1/admin/users/{id}`; admin-only, last-admin demotion protected) — a declared role nobody holds grants nothing. See `config.example.yaml` and the [authorization RFC](docs/plans/2026-07-10-pluggable-authorization-rfc.md).
|
||||||
|
|
||||||
Follow-up suggestions also check `model:use` before calling the selected model, including the default model when no name is supplied. A denied model returns HTTP 403 without an LLM call; authorization-provider failures follow the configured `fail_closed` policy.
|
Follow-up suggestions also check `model:use` before calling the selected model, including the default model when no name is supplied. A denied model returns HTTP 403 without an LLM call; authorization-provider failures follow the configured `fail_closed` policy.
|
||||||
|
|
||||||
|
|||||||
@ -1188,6 +1188,13 @@ This gateway provides runtime endpoints for agent runs plus custom endpoints for
|
|||||||
# Console API (cross-thread observability) is mounted at /api/console
|
# Console API (cross-thread observability) is mounted at /api/console
|
||||||
app.include_router(console.router)
|
app.include_router(console.router)
|
||||||
|
|
||||||
|
# Admin user management (list + role assignment; RFC #4063 / #3462 gap 2).
|
||||||
|
# Registered only when auth is enabled — the surface is meaningless (and
|
||||||
|
# the guards unreachable) without authenticated callers.
|
||||||
|
from app.gateway.routers import admin_users
|
||||||
|
|
||||||
|
app.include_router(admin_users.router)
|
||||||
|
|
||||||
# MCP API is mounted at /api/mcp
|
# MCP API is mounted at /api/mcp
|
||||||
app.include_router(capabilities.router)
|
app.include_router(capabilities.router)
|
||||||
app.include_router(mcp.router)
|
app.include_router(mcp.router)
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
"""User Pydantic models for authentication."""
|
"""User Pydantic models for authentication."""
|
||||||
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Literal
|
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
@ -20,7 +19,12 @@ class User(BaseModel):
|
|||||||
id: UUID = Field(default_factory=uuid4, description="Primary key")
|
id: UUID = Field(default_factory=uuid4, description="Primary key")
|
||||||
email: EmailStr = Field(..., description="Unique email address")
|
email: EmailStr = Field(..., description="Unique email address")
|
||||||
password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users")
|
password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users")
|
||||||
system_role: Literal["admin", "user"] = Field(default="user")
|
# Plain string on purpose: the DB column is String(16) and the RBAC
|
||||||
|
# provider accepts arbitrary configured role names (RFC #4063 gap 2 —
|
||||||
|
# custom roles must be assignable to authenticated users). "admin"/"user"
|
||||||
|
# remain the built-ins; the admin role-assignment API validates any other
|
||||||
|
# value against the provider's configured roles.
|
||||||
|
system_role: str = Field(default="user", min_length=1)
|
||||||
created_at: datetime = Field(default_factory=_utc_now)
|
created_at: datetime = Field(default_factory=_utc_now)
|
||||||
|
|
||||||
# OAuth linkage (optional)
|
# OAuth linkage (optional)
|
||||||
@ -37,7 +41,7 @@ class UserResponse(BaseModel):
|
|||||||
|
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
system_role: Literal["admin", "user"]
|
system_role: str = Field(..., min_length=1)
|
||||||
needs_setup: bool = False
|
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')")
|
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(
|
permissions: list[str] | None = Field(
|
||||||
|
|||||||
@ -5,6 +5,15 @@ from abc import ABC, abstractmethod
|
|||||||
from app.gateway.auth.models import User
|
from app.gateway.auth.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class LastAdminRemainsError(RuntimeError):
|
||||||
|
"""Raised when a role write would demote the last remaining admin.
|
||||||
|
|
||||||
|
Bootstrap lockout protection: raised inside the same serialized
|
||||||
|
transaction that would perform the write, so the check-then-act window
|
||||||
|
that two concurrent demotions could race through is closed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
class UserNotFoundError(LookupError):
|
class UserNotFoundError(LookupError):
|
||||||
"""Raised when a user repository operation targets a non-existent row.
|
"""Raised when a user repository operation targets a non-existent row.
|
||||||
|
|
||||||
@ -81,6 +90,23 @@ class UserRepository(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
async def update_system_role(self, user_id: str, system_role: str) -> User:
|
||||||
|
"""Assign a user's system role as a single-column, serialized write.
|
||||||
|
|
||||||
|
Origin: admin role-assignment API (RFC #4063 / #3462 gap 2). Distinct
|
||||||
|
from :meth:`update_user` on purpose — role mutations and credential
|
||||||
|
writes are field-scoped against each other, so a password change
|
||||||
|
holding a stale account snapshot can never restore a revoked role,
|
||||||
|
and a role change can never overwrite credentials.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
UserNotFoundError: the target row does not exist.
|
||||||
|
LastAdminRemainsError: the write would demote the only remaining
|
||||||
|
admin (the count and the write share one serialized
|
||||||
|
transaction — two concurrent demotions cannot both pass).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
async def update_user(self, user: User) -> User:
|
async def update_user(self, user: User) -> User:
|
||||||
"""Update an existing user.
|
"""Update an existing user.
|
||||||
|
|
||||||
@ -110,6 +136,15 @@ class UserRepository(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
|
async def list_users(self) -> list[User]:
|
||||||
|
"""Return all users ordered by creation (oldest first).
|
||||||
|
|
||||||
|
Origin: admin user-list surface (RFC #4063 / #3462 gap 2). Volume is
|
||||||
|
bounded by the deployment's user base; pagination can be added when a
|
||||||
|
real deployment needs it.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
async def count_admin_users(self) -> int:
|
async def count_admin_users(self) -> int:
|
||||||
"""Return number of users with system_role == 'admin'."""
|
"""Return number of users with system_role == 'admin'."""
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@ -21,7 +21,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from app.gateway.auth.models import User
|
from app.gateway.auth.models import User
|
||||||
from app.gateway.auth.repositories.base import UserNotFoundError, UserRepository
|
from app.gateway.auth.repositories.base import LastAdminRemainsError, UserNotFoundError, UserRepository
|
||||||
from deerflow.persistence.user.model import OAUTH_IDENTITY_INDEX_NAME, UserRow
|
from deerflow.persistence.user.model import OAUTH_IDENTITY_INDEX_NAME, UserRow
|
||||||
|
|
||||||
# ``email`` is ``mapped_column(unique=True, index=True)``, which SQLAlchemy
|
# ``email`` is ``mapped_column(unique=True, index=True)``, which SQLAlchemy
|
||||||
@ -136,6 +136,7 @@ def _normalize_email(email: str) -> str:
|
|||||||
# Fixed 63-bit key for pg_advisory_xact_lock(bigint); scopes the first-admin
|
# Fixed 63-bit key for pg_advisory_xact_lock(bigint); scopes the first-admin
|
||||||
# claim without colliding with other advisory-lock users in this database.
|
# claim without colliding with other advisory-lock users in this database.
|
||||||
_FIRST_ADMIN_LOCK_KEY = int.from_bytes(hashlib.sha256(b"deerflow:auth:first-admin-claim").digest()[:8], "big") & 0x7FFFFFFFFFFFFFFF
|
_FIRST_ADMIN_LOCK_KEY = int.from_bytes(hashlib.sha256(b"deerflow:auth:first-admin-claim").digest()[:8], "big") & 0x7FFFFFFFFFFFFFFF
|
||||||
|
_ROLE_MUTATION_LOCK_KEY = int.from_bytes(hashlib.sha256(b"deerflow:auth:role-mutation").digest()[:8], "big") & 0x7FFFFFFFFFFFFFFF
|
||||||
|
|
||||||
|
|
||||||
class SQLiteUserRepository(UserRepository):
|
class SQLiteUserRepository(UserRepository):
|
||||||
@ -152,7 +153,7 @@ class SQLiteUserRepository(UserRepository):
|
|||||||
id=UUID(row.id),
|
id=UUID(row.id),
|
||||||
email=row.email,
|
email=row.email,
|
||||||
password_hash=row.password_hash,
|
password_hash=row.password_hash,
|
||||||
system_role=row.system_role, # type: ignore[arg-type]
|
system_role=row.system_role,
|
||||||
# SQLite loses tzinfo on read; reattach UTC so downstream
|
# SQLite loses tzinfo on read; reattach UTC so downstream
|
||||||
# code can compare timestamps reliably.
|
# code can compare timestamps reliably.
|
||||||
created_at=row.created_at if row.created_at.tzinfo else row.created_at.replace(tzinfo=UTC),
|
created_at=row.created_at if row.created_at.tzinfo else row.created_at.replace(tzinfo=UTC),
|
||||||
@ -299,6 +300,45 @@ class SQLiteUserRepository(UserRepository):
|
|||||||
row = result.scalars().first()
|
row = result.scalars().first()
|
||||||
return self._row_to_user(row) if row is not None else None
|
return self._row_to_user(row) if row is not None else None
|
||||||
|
|
||||||
|
async def update_system_role(self, user_id: str, system_role: str) -> User:
|
||||||
|
"""Serialized single-column role write (see base class contract)."""
|
||||||
|
async with self._sf() as session:
|
||||||
|
# Same read-then-write serialization idiom create_first_admin
|
||||||
|
# uses: SQLite takes the write lock up front (BEGIN IMMEDIATE),
|
||||||
|
# Postgres takes a transaction-scoped advisory lock on a distinct
|
||||||
|
# key. The lock must precede every other statement so the admin
|
||||||
|
# count and the column write share one serialized transaction —
|
||||||
|
# two concurrent demotions of the only two admins cannot both
|
||||||
|
# pass the count.
|
||||||
|
await self._serialize_account_role_mutation(session)
|
||||||
|
row = await session.get(UserRow, user_id)
|
||||||
|
if row is None:
|
||||||
|
raise UserNotFoundError(f"User {user_id} no longer exists")
|
||||||
|
if row.system_role == "admin" and system_role != "admin":
|
||||||
|
admin_count = await session.scalar(select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin"))
|
||||||
|
if admin_count is None or admin_count <= 1:
|
||||||
|
raise LastAdminRemainsError("cannot demote the last remaining admin")
|
||||||
|
row.system_role = system_role
|
||||||
|
await session.commit()
|
||||||
|
return self._row_to_user(row)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _serialize_account_role_mutation(session: AsyncSession) -> None:
|
||||||
|
"""Serialize role mutations before the admin count is read.
|
||||||
|
|
||||||
|
Distinct lock key from the first-admin claim: different invariant,
|
||||||
|
so no cross-coupling — but the same dialect strategy (see
|
||||||
|
``_serialize_first_admin_claim``). A dialect with neither strategy
|
||||||
|
raises rather than silently falling back to check-then-act.
|
||||||
|
"""
|
||||||
|
dialect = session.get_bind().dialect.name
|
||||||
|
if dialect == "sqlite":
|
||||||
|
await session.execute(text("BEGIN IMMEDIATE"))
|
||||||
|
elif dialect == "postgresql":
|
||||||
|
await session.execute(text("SELECT pg_advisory_xact_lock(:lock_key)"), {"lock_key": _ROLE_MUTATION_LOCK_KEY})
|
||||||
|
else:
|
||||||
|
raise RuntimeError(f"no role-mutation serialization strategy for dialect {dialect!r}")
|
||||||
|
|
||||||
async def update_user(self, user: User) -> User:
|
async def update_user(self, user: User) -> User:
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
row = await session.get(UserRow, str(user.id))
|
row = await session.get(UserRow, str(user.id))
|
||||||
@ -329,7 +369,12 @@ class SQLiteUserRepository(UserRepository):
|
|||||||
row.email = canonical_email
|
row.email = canonical_email
|
||||||
user.email = row.email
|
user.email = row.email
|
||||||
row.password_hash = user.password_hash
|
row.password_hash = user.password_hash
|
||||||
row.system_role = user.system_role
|
# Field-scoped on purpose (review P1): credential/profile writes
|
||||||
|
# never touch the role — a password change holding a stale
|
||||||
|
# account snapshot must not restore a concurrently revoked role.
|
||||||
|
# Role changes go through update_system_role, which likewise
|
||||||
|
# never touches credentials.
|
||||||
|
user.system_role = row.system_role
|
||||||
row.oauth_provider = user.oauth_provider
|
row.oauth_provider = user.oauth_provider
|
||||||
row.oauth_id = user.oauth_id
|
row.oauth_id = user.oauth_id
|
||||||
row.needs_setup = user.needs_setup
|
row.needs_setup = user.needs_setup
|
||||||
@ -348,6 +393,12 @@ class SQLiteUserRepository(UserRepository):
|
|||||||
result = await session.scalars(stmt)
|
result = await session.scalars(stmt)
|
||||||
return list(result)
|
return list(result)
|
||||||
|
|
||||||
|
async def list_users(self) -> list[User]:
|
||||||
|
stmt = select(UserRow).order_by(UserRow.created_at, UserRow.id)
|
||||||
|
async with self._sf() as session:
|
||||||
|
result = await session.scalars(stmt)
|
||||||
|
return [self._row_to_user(row) for row in result]
|
||||||
|
|
||||||
async def count_admin_users(self) -> int:
|
async def count_admin_users(self) -> int:
|
||||||
stmt = select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin")
|
stmt = select(func.count()).select_from(UserRow).where(UserRow.system_role == "admin")
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
|
|||||||
@ -206,6 +206,31 @@ _route_provider_config_id: int | None = None
|
|||||||
_route_provider_config_sig: str | None = None
|
_route_provider_config_sig: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def assignable_role_names() -> set[str]:
|
||||||
|
"""Role names an operator may assign via the admin role-assignment API.
|
||||||
|
|
||||||
|
Always includes the built-ins ("admin", "user"); when the built-in RBAC
|
||||||
|
provider is configured, its declared ``roles`` keys join the set — that is
|
||||||
|
what makes custom roles like ``guest`` reachable for real users (RFC
|
||||||
|
#4063 / issue #3462 gap 2). Custom providers have no enumerable role
|
||||||
|
surface, so only the built-ins are assignable under them; the Gateway
|
||||||
|
route guards remain the enforcement point either way.
|
||||||
|
"""
|
||||||
|
from app.gateway.internal_auth import INTERNAL_SYSTEM_ROLE
|
||||||
|
|
||||||
|
names = {"admin", "user"}
|
||||||
|
try:
|
||||||
|
config = _get_route_authorization_config()
|
||||||
|
if config.enabled is True:
|
||||||
|
provider = _get_cached_route_provider(config)
|
||||||
|
known = getattr(provider, "known_roles", None)
|
||||||
|
if isinstance(known, frozenset):
|
||||||
|
names.update(r for r in known if isinstance(r, str) and r and r != INTERNAL_SYSTEM_ROLE)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to resolve configured role names for assignment", exc_info=True)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
def _get_cached_route_provider(config: AuthorizationConfig) -> AuthorizationProvider | None:
|
def _get_cached_route_provider(config: AuthorizationConfig) -> AuthorizationProvider | None:
|
||||||
"""Resolve (or reuse) the authorization provider for route permissions.
|
"""Resolve (or reuse) the authorization provider for route permissions.
|
||||||
|
|
||||||
|
|||||||
@ -940,6 +940,18 @@ _cached_local_provider: LocalAuthProvider | None = None
|
|||||||
_cached_repo: SQLiteUserRepository | None = None
|
_cached_repo: SQLiteUserRepository | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_repository() -> SQLiteUserRepository:
|
||||||
|
"""Return the cached user repository (created on first use).
|
||||||
|
|
||||||
|
Origin: admin user-management surface (RFC #4063 / #3462 gap 2). Shares
|
||||||
|
the ``get_local_provider`` cache so both surfaces see one store.
|
||||||
|
"""
|
||||||
|
|
||||||
|
get_local_provider()
|
||||||
|
assert _cached_repo is not None
|
||||||
|
return _cached_repo
|
||||||
|
|
||||||
|
|
||||||
def get_local_provider() -> LocalAuthProvider:
|
def get_local_provider() -> LocalAuthProvider:
|
||||||
"""Get or create the cached LocalAuthProvider singleton.
|
"""Get or create the cached LocalAuthProvider singleton.
|
||||||
|
|
||||||
|
|||||||
95
backend/app/gateway/routers/admin_users.py
Normal file
95
backend/app/gateway/routers/admin_users.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
"""Admin user-management surface: list users and assign system roles.
|
||||||
|
|
||||||
|
RFC #4063 / issue #3462 gap 2: the RBAC provider accepts arbitrary configured
|
||||||
|
role names (``guest``, ...), but no surface could assign them to real users —
|
||||||
|
``User.system_role`` was pinned to admin/user at the API layer while the DB
|
||||||
|
column stayed a plain string for exactly this future. This router is that
|
||||||
|
surface: admin-only (§12 Q6 — management surfaces stay admin-gated), role
|
||||||
|
names validated against the provider's configured roles, and the last
|
||||||
|
remaining admin cannot be demoted (bootstrap lockout protection).
|
||||||
|
|
||||||
|
Enforcement is unchanged: the assignment only changes which role the
|
||||||
|
principal carries; the Gateway's ``@require_permission`` route guards and the
|
||||||
|
runtime provider decisions remain the single enforcement points.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.gateway.auth.repositories.base import LastAdminRemainsError, UserNotFoundError
|
||||||
|
from app.gateway.authz import assignable_role_names
|
||||||
|
from app.gateway.deps import get_user_repository, require_admin_user
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||||
|
|
||||||
|
from app.gateway.auth.models import UserResponse
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/admin/users", tags=["admin-users"])
|
||||||
|
|
||||||
|
|
||||||
|
class UserRoleUpdate(BaseModel):
|
||||||
|
"""Assignment payload: a built-in or provider-configured role name."""
|
||||||
|
|
||||||
|
system_role: str = Field(..., min_length=1, max_length=16, description="Built-in (admin/user) or a role configured in the authorization provider's roles mapping")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_response(user) -> UserResponse:
|
||||||
|
return UserResponse(
|
||||||
|
id=str(user.id),
|
||||||
|
email=str(user.email),
|
||||||
|
system_role=str(user.system_role),
|
||||||
|
needs_setup=bool(getattr(user, "needs_setup", False)),
|
||||||
|
oauth_provider=getattr(user, "oauth_provider", None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_users(request: Request, repository: SQLiteUserRepository = Depends(get_user_repository)) -> list[UserResponse]:
|
||||||
|
"""List all users with their system role (admin-only)."""
|
||||||
|
await require_admin_user(request, detail="admin user listing requires an admin credential")
|
||||||
|
users = await repository.list_users()
|
||||||
|
return [_to_response(user) for user in users]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{user_id}")
|
||||||
|
async def update_user_role(
|
||||||
|
request: Request,
|
||||||
|
user_id: UUID,
|
||||||
|
update: UserRoleUpdate,
|
||||||
|
repository: SQLiteUserRepository = Depends(get_user_repository),
|
||||||
|
) -> UserResponse:
|
||||||
|
"""Assign a user's system role (admin-only).
|
||||||
|
|
||||||
|
The role must be a built-in ("admin"/"user") or declared in the
|
||||||
|
authorization provider's roles configuration — an unknown name would make
|
||||||
|
the user's principal fall through to ``default_role`` on every decision,
|
||||||
|
which is never what an operator intended. Demoting the last remaining
|
||||||
|
admin is rejected so a deployment cannot lock itself out of management.
|
||||||
|
"""
|
||||||
|
await require_admin_user(request, detail="role assignment requires an admin credential")
|
||||||
|
|
||||||
|
assignable = assignable_role_names()
|
||||||
|
if update.system_role not in assignable:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"unknown role '{update.system_role}'; assignable roles: {sorted(assignable)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Single serialized column write: the last-admin count and the write
|
||||||
|
# share one transaction (two concurrent demotions cannot both pass),
|
||||||
|
# and credential writers can never race this — see
|
||||||
|
# SQLiteUserRepository.update_system_role / update_user.
|
||||||
|
user = await repository.update_system_role(str(user_id), update.system_role)
|
||||||
|
except UserNotFoundError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from None
|
||||||
|
except LastAdminRemainsError:
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="cannot demote the last remaining admin") from None
|
||||||
|
|
||||||
|
return _to_response(user)
|
||||||
@ -130,6 +130,16 @@ class RbacAuthorizationProvider:
|
|||||||
compiled = self._compile_resource_policy(role_name, resource_key, resource_policy)
|
compiled = self._compile_resource_policy(role_name, resource_key, resource_policy)
|
||||||
self._policies[(role_name, resource_key)] = compiled
|
self._policies[(role_name, resource_key)] = compiled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def known_roles(self) -> frozenset[str]:
|
||||||
|
"""Role names declared in the ``roles`` configuration.
|
||||||
|
|
||||||
|
Management surfaces (e.g. the Gateway's admin role-assignment API)
|
||||||
|
use this to validate that an operator only assigns roles the provider
|
||||||
|
actually has policies for.
|
||||||
|
"""
|
||||||
|
return self._known_roles
|
||||||
|
|
||||||
def validate_role(self, role: str, *, field: str = "role") -> None:
|
def validate_role(self, role: str, *, field: str = "role") -> None:
|
||||||
"""Fail fast when an operator-configured role is not defined."""
|
"""Fail fast when an operator-configured role is not defined."""
|
||||||
role = _require_non_empty_string(role, field=field)
|
role = _require_non_empty_string(role, field=field)
|
||||||
|
|||||||
305
backend/tests/test_admin_users_router.py
Normal file
305
backend/tests/test_admin_users_router.py
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
"""Admin user-management router tests (RFC #4063 / issue #3462 gap 2).
|
||||||
|
|
||||||
|
Covers the assignment surface that makes provider-configured custom roles
|
||||||
|
(``guest``, ...) reachable for real users: the admin-only guard, role-name
|
||||||
|
validation against the provider's configured roles, the last-admin lockout
|
||||||
|
invariant, and the assignment→principal flow (a custom-role user's effective
|
||||||
|
permissions change accordingly).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from _router_auth_helpers import make_authed_test_app
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.gateway.auth.models import User
|
||||||
|
from app.gateway.authz import assignable_role_names
|
||||||
|
from app.gateway.deps import get_user_repository
|
||||||
|
from app.gateway.routers import admin_users
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user(*, system_role: str = "user", email: str = "u@example.com") -> User:
|
||||||
|
return User(
|
||||||
|
id=uuid4(),
|
||||||
|
email=email,
|
||||||
|
system_role=system_role,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeRepo:
|
||||||
|
"""In-memory stand-in for the user repository."""
|
||||||
|
|
||||||
|
def __init__(self, users: list[User]) -> None:
|
||||||
|
self.users = {str(u.id): u for u in users}
|
||||||
|
self.updates: list[tuple[str, str]] = []
|
||||||
|
|
||||||
|
async def list_users(self) -> list[User]:
|
||||||
|
return list(self.users.values())
|
||||||
|
|
||||||
|
async def get_user_by_id(self, user_id: str) -> User | None:
|
||||||
|
return self.users.get(user_id)
|
||||||
|
|
||||||
|
async def count_admin_users(self) -> int:
|
||||||
|
return sum(1 for u in self.users.values() if u.system_role == "admin")
|
||||||
|
|
||||||
|
async def update_user(self, user: User) -> User:
|
||||||
|
self.updates.append((str(user.id), user.system_role))
|
||||||
|
self.users[str(user.id)] = user
|
||||||
|
return user
|
||||||
|
|
||||||
|
async def update_system_role(self, user_id: str, system_role: str) -> User:
|
||||||
|
from app.gateway.auth.repositories.base import LastAdminRemainsError, UserNotFoundError
|
||||||
|
|
||||||
|
user = self.users.get(user_id)
|
||||||
|
if user is None:
|
||||||
|
raise UserNotFoundError(f"User {user_id} no longer exists")
|
||||||
|
if user.system_role == "admin" and system_role != "admin":
|
||||||
|
if await self.count_admin_users() <= 1:
|
||||||
|
raise LastAdminRemainsError("cannot demote the last remaining admin")
|
||||||
|
user.system_role = system_role
|
||||||
|
self.updates.append((user_id, system_role))
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(monkeypatch, *, caller: User, repo: _FakeRepo, roles: set[str] | None = None) -> TestClient:
|
||||||
|
app: FastAPI = make_authed_test_app(user_factory=lambda: caller)
|
||||||
|
app.include_router(admin_users.router)
|
||||||
|
app.dependency_overrides[get_user_repository] = lambda: repo
|
||||||
|
if roles is not None:
|
||||||
|
# Lives for the whole test: the routes call the helper at request time.
|
||||||
|
monkeypatch.setattr(admin_users, "assignable_role_names", lambda: roles)
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch(client: TestClient, user: User, role: str):
|
||||||
|
return client.patch(f"/api/v1/admin/users/{user.id}", json={"system_role": role})
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_admin_cannot_list_or_assign(monkeypatch):
|
||||||
|
repo = _FakeRepo([_make_user()])
|
||||||
|
client = _make_client(monkeypatch, caller=_make_user(), repo=repo)
|
||||||
|
|
||||||
|
assert client.get("/api/v1/admin/users").status_code == 403
|
||||||
|
assert _patch(client, _make_user(), "guest").status_code == 403
|
||||||
|
assert repo.updates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_lists_users_with_roles(monkeypatch):
|
||||||
|
admin = _make_user(system_role="admin", email="a@example.com")
|
||||||
|
guest = _make_user(system_role="guest", email="g@example.com")
|
||||||
|
client = _make_client(monkeypatch, caller=admin, repo=_FakeRepo([admin, guest]))
|
||||||
|
|
||||||
|
response = client.get("/api/v1/admin/users")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert {row["system_role"] for row in response.json()} == {"admin", "guest"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_assigns_configured_custom_role(monkeypatch):
|
||||||
|
admin = _make_user(system_role="admin")
|
||||||
|
target = _make_user()
|
||||||
|
repo = _FakeRepo([admin, target])
|
||||||
|
client = _make_client(monkeypatch, caller=admin, repo=repo, roles={"admin", "user", "guest"})
|
||||||
|
|
||||||
|
response = _patch(client, target, "guest")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["system_role"] == "guest"
|
||||||
|
assert repo.updates == [(str(target.id), "guest")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_role_rejected(monkeypatch):
|
||||||
|
admin = _make_user(system_role="admin")
|
||||||
|
target = _make_user()
|
||||||
|
repo = _FakeRepo([admin, target])
|
||||||
|
client = _make_client(monkeypatch, caller=admin, repo=repo, roles={"admin", "user"})
|
||||||
|
|
||||||
|
response = _patch(client, target, "guest")
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert "guest" in response.json()["detail"]
|
||||||
|
assert repo.updates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_user_404(monkeypatch):
|
||||||
|
admin = _make_user(system_role="admin")
|
||||||
|
client = _make_client(monkeypatch, caller=admin, repo=_FakeRepo([admin]), roles={"admin", "user", "guest"})
|
||||||
|
|
||||||
|
# The vanished-row race now surfaces as UserNotFoundError from the
|
||||||
|
# single serialized write, mapped to the same 404 the route always meant.
|
||||||
|
assert _patch(client, _make_user(), "guest").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_last_admin_cannot_be_demoted(monkeypatch):
|
||||||
|
admin = _make_user(system_role="admin")
|
||||||
|
repo = _FakeRepo([admin])
|
||||||
|
client = _make_client(monkeypatch, caller=admin, repo=repo, roles={"admin", "user", "guest"})
|
||||||
|
|
||||||
|
response = _patch(client, admin, "user")
|
||||||
|
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert repo.updates == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_demotable_when_another_admin_remains(monkeypatch):
|
||||||
|
first = _make_user(system_role="admin", email="a@example.com")
|
||||||
|
second = _make_user(system_role="admin", email="b@example.com")
|
||||||
|
repo = _FakeRepo([first, second])
|
||||||
|
client = _make_client(monkeypatch, caller=first, repo=repo, roles={"admin", "user"})
|
||||||
|
|
||||||
|
response = _patch(client, first, "user")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["system_role"] == "user"
|
||||||
|
assert repo.updates == [(str(first.id), "user")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_role_assignment_is_idempotent(monkeypatch):
|
||||||
|
"""Re-assigning the role a user already holds succeeds and changes
|
||||||
|
nothing (the serialized single-column write is idempotent)."""
|
||||||
|
admin = _make_user(system_role="admin")
|
||||||
|
target = _make_user(system_role="guest")
|
||||||
|
repo = _FakeRepo([admin, target])
|
||||||
|
client = _make_client(monkeypatch, caller=admin, repo=repo, roles={"admin", "user", "guest"})
|
||||||
|
|
||||||
|
response = _patch(client, target, "guest")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["system_role"] == "guest"
|
||||||
|
assert repo.users[str(target.id)].system_role == "guest"
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignable_role_names_union_of_builtins_and_configured():
|
||||||
|
from deerflow.authz.rbac import RbacAuthorizationProvider
|
||||||
|
from deerflow.config.authorization_config import AuthorizationConfig
|
||||||
|
|
||||||
|
provider = RbacAuthorizationProvider(
|
||||||
|
roles={
|
||||||
|
"user": {"tools": {"allow": "*"}},
|
||||||
|
"guest": {"tools": {"allow": ["web_search"]}},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
config = AuthorizationConfig(enabled=True, provider={"use": "x", "config": {}})
|
||||||
|
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def _fake_cached(config_arg):
|
||||||
|
captured["called"] = True
|
||||||
|
return provider
|
||||||
|
|
||||||
|
import app.gateway.authz as authz_module
|
||||||
|
|
||||||
|
original = authz_module._get_cached_route_provider
|
||||||
|
original_config = authz_module._get_route_authorization_config
|
||||||
|
authz_module._get_cached_route_provider = _fake_cached
|
||||||
|
authz_module._get_route_authorization_config = lambda: config
|
||||||
|
try:
|
||||||
|
names = assignable_role_names()
|
||||||
|
finally:
|
||||||
|
authz_module._get_cached_route_provider = original
|
||||||
|
authz_module._get_route_authorization_config = original_config
|
||||||
|
|
||||||
|
assert captured["called"] is True
|
||||||
|
assert names == {"admin", "user", "guest"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_repo_list_users_orders_and_maps(monkeypatch):
|
||||||
|
"""The SQLite repository's list_users maps rows and orders oldest-first."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
first = _make_user(email="first@example.com")
|
||||||
|
second = _make_user(email="second@example.com")
|
||||||
|
repo = _FakeRepo([first, second])
|
||||||
|
assert asyncio.run(repo.list_users()) == [first, second]
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sqlite_repo(tmpdir):
|
||||||
|
"""Real SQLiteUserRepository on a scratch database (async setup)."""
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from app.gateway.auth.repositories.sqlite import SQLiteUserRepository
|
||||||
|
from deerflow.persistence.engine import get_session_factory, init_engine
|
||||||
|
|
||||||
|
async def _setup():
|
||||||
|
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmpdir}/users.db", sqlite_dir=tmpdir)
|
||||||
|
return SQLiteUserRepository(get_session_factory())
|
||||||
|
|
||||||
|
return asyncio.run(_setup())
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_credential_snapshot_cannot_restore_revoked_role(tmp_path):
|
||||||
|
"""[P1 regression] A password change holding a stale account snapshot
|
||||||
|
(role=admin read before the demotion) must not restore the revoked role:
|
||||||
|
update_user is field-scoped and preserves the row's current role, while
|
||||||
|
update_system_role is the only role writer — and never touches
|
||||||
|
credentials."""
|
||||||
|
import asyncio
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from app.gateway.auth.repositories.base import LastAdminRemainsError
|
||||||
|
from deerflow.persistence.engine import close_engine
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
repo = _make_sqlite_repo(tmpdir)
|
||||||
|
|
||||||
|
async def _run():
|
||||||
|
admin = User(email="admin@example.com", system_role="admin")
|
||||||
|
other = User(email="other@example.com", system_role="admin")
|
||||||
|
admin = await repo.create_user(admin)
|
||||||
|
other = await repo.create_user(other)
|
||||||
|
stale_snapshot = User(
|
||||||
|
id=admin.id,
|
||||||
|
email=admin.email,
|
||||||
|
password_hash="new-hash",
|
||||||
|
system_role="admin", # read BEFORE the demotion
|
||||||
|
token_version=admin.token_version + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The demotion lands first (single-column write).
|
||||||
|
demoted = await repo.update_system_role(str(admin.id), "user")
|
||||||
|
assert demoted.system_role == "user"
|
||||||
|
|
||||||
|
# The password change resumes with its stale snapshot: credentials
|
||||||
|
# are written, the revoked role is NOT restored (and the returned
|
||||||
|
# object mirrors the row, not the snapshot).
|
||||||
|
after = await repo.update_user(stale_snapshot)
|
||||||
|
assert after.password_hash == "new-hash"
|
||||||
|
assert after.system_role == "user"
|
||||||
|
assert (await repo.get_user_by_id(str(admin.id))).system_role == "user"
|
||||||
|
|
||||||
|
# Field isolation the other way: a role change never touches
|
||||||
|
# credentials or token_version.
|
||||||
|
await repo.update_system_role(str(admin.id), "guest")
|
||||||
|
row = await repo.get_user_by_id(str(admin.id))
|
||||||
|
assert row.password_hash == "new-hash"
|
||||||
|
assert row.system_role == "guest"
|
||||||
|
|
||||||
|
# The last-admin invariant lives inside the serialized write:
|
||||||
|
# demoting the only remaining admin raises, and the row is intact.
|
||||||
|
try:
|
||||||
|
await repo.update_system_role(str(other.id), "user")
|
||||||
|
except LastAdminRemainsError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("demoting the last admin must raise")
|
||||||
|
assert (await repo.get_user_by_id(str(other.id))).system_role == "admin"
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(_run())
|
||||||
|
finally:
|
||||||
|
asyncio.run(close_engine())
|
||||||
|
|
||||||
|
|
||||||
|
def test_assignment_reaches_principal_permissions():
|
||||||
|
"""End-to-end intent: the assigned role changes the caller's effective
|
||||||
|
route permissions — a guest role sees the guest policy's routes, not the
|
||||||
|
admin surface. The router is the assignment; this pins the semantics the
|
||||||
|
assignment is for (principal carries the configured role)."""
|
||||||
|
from app.gateway.authz import build_principal_from_context
|
||||||
|
|
||||||
|
principal = build_principal_from_context({"user_id": "u1", "user_role": "guest"}, default_role="user")
|
||||||
|
|
||||||
|
assert principal.role == "guest"
|
||||||
@ -242,12 +242,17 @@ def test_user_response_system_role_literal():
|
|||||||
assert resp.system_role == "user"
|
assert resp.system_role == "user"
|
||||||
|
|
||||||
|
|
||||||
def test_user_response_rejects_invalid_role():
|
def test_user_response_accepts_configured_custom_role():
|
||||||
"""UserResponse should reject invalid system_role values."""
|
"""Custom roles are representable end-to-end (RFC #4063 gap 2): the DB
|
||||||
|
column is a plain string and the RBAC provider accepts arbitrary
|
||||||
|
configured role names, so the API model must not pin admin/user. Whether
|
||||||
|
a role is assignable is the admin API's contract (validated against the
|
||||||
|
provider's configured roles), not the model's."""
|
||||||
from app.gateway.auth.models import UserResponse
|
from app.gateway.auth.models import UserResponse
|
||||||
|
|
||||||
with pytest.raises(ValidationError):
|
response = UserResponse(id="1", email="a@b.com", system_role="guest")
|
||||||
UserResponse(id="1", email="a@b.com", system_role="superadmin")
|
|
||||||
|
assert response.system_role == "guest"
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@ -32,6 +32,9 @@ async def accounts(tmp_path, monkeypatch):
|
|||||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
repository = SQLiteUserRepository(sessions)
|
repository = SQLiteUserRepository(sessions)
|
||||||
user = await repository.create_user(User(email="admin@example.com", system_role="admin"))
|
user = await repository.create_user(User(email="admin@example.com", system_role="admin"))
|
||||||
|
# A second admin so the revocation test's demote path cannot trip the
|
||||||
|
# last-admin lockout guard (demoting the only remaining admin raises).
|
||||||
|
await repository.create_user(User(email="second-admin@example.com", system_role="admin"))
|
||||||
monkeypatch.setattr(gateway_access, "get_local_provider", lambda: LocalAuthProvider(repository))
|
monkeypatch.setattr(gateway_access, "get_local_provider", lambda: LocalAuthProvider(repository))
|
||||||
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
|
monkeypatch.delenv("DEER_FLOW_AUTH_DISABLED", raising=False)
|
||||||
monkeypatch.setattr("deerflow.mcp.user_config.get_paths", lambda: Paths(base_dir=tmp_path))
|
monkeypatch.setattr("deerflow.mcp.user_config.get_paths", lambda: Paths(base_dir=tmp_path))
|
||||||
@ -99,8 +102,9 @@ async def test_revocation_blocks_discovery_existing_tools_and_cached_durable_cal
|
|||||||
oauth.reset_mock()
|
oauth.reset_mock()
|
||||||
|
|
||||||
if revocation == "demote":
|
if revocation == "demote":
|
||||||
user.system_role = "user"
|
# update_user is field-scoped (roles never written there);
|
||||||
await repository.update_user(user)
|
# update_system_role is the sole role writer.
|
||||||
|
await repository.update_system_role(str(user.id), "user")
|
||||||
elif revocation == "delete":
|
elif revocation == "delete":
|
||||||
async with sessions() as session:
|
async with sessions() as session:
|
||||||
await session.execute(delete(UserRow).where(UserRow.id == owner))
|
await session.execute(delete(UserRow).where(UserRow.id == owner))
|
||||||
|
|||||||
@ -3223,6 +3223,10 @@ run_ownership:
|
|||||||
# # resource unrestricted for the role**, so list it explicitly to
|
# # resource unrestricted for the role**, so list it explicitly to
|
||||||
# # constrain it. Declared plugin pages are not gated yet: their
|
# # constrain it. Declared plugin pages are not gated yet: their
|
||||||
# # `plugin_pages` row ships with the page-authorization slice.
|
# # `plugin_pages` row ships with the page-authorization slice.
|
||||||
|
# #
|
||||||
|
# # Role names beyond admin/user become reachable by assigning them to
|
||||||
|
# # a user via the admin API (PATCH /api/v1/admin/users/{id}) — a role
|
||||||
|
# # declared here but never assigned grants nothing.
|
||||||
# roles:
|
# roles:
|
||||||
# admin:
|
# admin:
|
||||||
# tools: {allow: "*"}
|
# tools: {allow: "*"}
|
||||||
|
|||||||
@ -5,7 +5,12 @@ import { z } from "zod";
|
|||||||
export const userSchema = z.object({
|
export const userSchema = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
system_role: z.enum(["admin", "user"]),
|
// Plain string, kept in lockstep with the backend User model (RFC #4063
|
||||||
|
// gap 2): custom roles configured in the authorization provider are
|
||||||
|
// assignable via the admin API and arrive here verbatim. Empty is invalid
|
||||||
|
// backend-side (min_length=1); admin checks compare === "admin" so any
|
||||||
|
// other value is simply non-admin.
|
||||||
|
system_role: z.string().min(1),
|
||||||
needs_setup: z.boolean().optional().default(false),
|
needs_setup: z.boolean().optional().default(false),
|
||||||
oauth_provider: z.string().nullable().optional().default(null),
|
oauth_provider: z.string().nullable().optional().default(null),
|
||||||
// Effective route permissions (RFC #4063 Phase 4). Optional + nullable:
|
// Effective route permissions (RFC #4063 Phase 4). Optional + nullable:
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
|
import { AUTH_DISABLED_USER } from "@/core/auth/auth-disabled-user";
|
||||||
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
|
import { STATIC_WEBSITE_USER } from "@/core/auth/static-user";
|
||||||
|
import { userSchema } from "@/core/auth/types";
|
||||||
|
|
||||||
rs.mock("next/headers", () => ({
|
rs.mock("next/headers", () => ({
|
||||||
cookies: rs.fn(() => {
|
cookies: rs.fn(() => {
|
||||||
@ -175,3 +176,33 @@ describe("getServerSideUser — gateway_unavailable contract (issue #3493)", ()
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── system_role contract (RFC #4063 gap 2) ─────────────────────────
|
||||||
|
|
||||||
|
describe("userSchema system_role", () => {
|
||||||
|
test("accepts a provider-configured custom role verbatim", () => {
|
||||||
|
const parsed = userSchema.parse({
|
||||||
|
id: "1",
|
||||||
|
email: "g@example.com",
|
||||||
|
system_role: "guest",
|
||||||
|
needs_setup: false,
|
||||||
|
oauth_provider: null,
|
||||||
|
permissions: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.system_role).toBe("guest");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("still rejects an empty role string", () => {
|
||||||
|
expect(() =>
|
||||||
|
userSchema.parse({
|
||||||
|
id: "1",
|
||||||
|
email: "g@example.com",
|
||||||
|
system_role: "",
|
||||||
|
needs_setup: false,
|
||||||
|
oauth_provider: null,
|
||||||
|
permissions: null,
|
||||||
|
}),
|
||||||
|
).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user