-
Notifications
You must be signed in to change notification settings - Fork 0
fix(plugins): enforce the per-user plugin access gate #589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a9a271c
26be0c2
be0272c
a514111
7d9f7f8
aca30f6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,28 +2,64 @@ | |
|
|
||
| from fastapi import Request, Response, status | ||
| from fastapi.responses import JSONResponse | ||
| from fastapi.routing import iter_route_contexts | ||
| from sqlalchemy.exc import DatabaseError, OperationalError | ||
| from sqlmodel import Session, select | ||
| from sqlmodel.ext.asyncio.session import AsyncSession | ||
| from starlette.middleware.base import BaseHTTPMiddleware | ||
| from starlette.routing import Match | ||
|
|
||
| from sparkth.core.models.plugin import Plugin, UserPlugin | ||
| from sparkth.core.plugins.constants import BEARER_SCHEME | ||
| from sparkth.core.routes import get_route_plugin_name | ||
| from sparkth.lib.db import get_async_session | ||
| from sparkth.lib.auth import decode_token_username, get_user_by_username | ||
| from sparkth.lib.db import session_scope | ||
| from sparkth.lib.log import get_logger | ||
|
|
||
| logger = get_logger(__name__) | ||
|
|
||
|
|
||
| def _bearer_token_username(request: Request) -> str | None: | ||
| """Username carried by the request's bearer token, or None when it carries no readable one. | ||
|
|
||
| Reads the header directly rather than through the ``HTTPBearer`` security scheme, which | ||
| is a FastAPI dependency and so only resolves once routing has picked a handler — after | ||
| this middleware has already run. | ||
| """ | ||
| header = request.headers.get("authorization") | ||
| if header is None: | ||
| return None | ||
|
|
||
| scheme, _, token = header.partition(" ") | ||
| if scheme.lower() != BEARER_SCHEME or not token: | ||
| return None | ||
|
|
||
| return decode_token_username(token) | ||
|
|
||
|
|
||
| class PluginAccessMiddleware(BaseHTTPMiddleware): | ||
| """Reject requests to plugins the caller has turned off, before they reach the route. | ||
|
|
||
| Runs ahead of routing, so it resolves both facts it needs itself: which plugin owns the | ||
| requested URL (from the name ``register_router`` stamps on plugin endpoints) and who is | ||
| asking (from the request's bearer token, via the helpers in ``sparkth.lib.auth`` that | ||
| ``get_current_user`` is built from). | ||
|
|
||
| Anonymous requests fail open. Plugin routers carry unauthenticated endpoints — Slack's | ||
| OAuth callback is called by Slack itself, with no token — and a per-user preference is | ||
| meaningless without a user; endpoints that do require a caller are still rejected by | ||
| their own auth dependency. | ||
| """ | ||
|
|
||
| def __init__(self, app: Any, exclude_paths: list[str] | None = None) -> None: | ||
| super().__init__(app) | ||
| # Entries are matched with startswith, so every one of them must be a real path | ||
| # prefix: a bare "/" would exclude every path there is and leave the gate | ||
| # enforcing nothing. | ||
| self.exclude_paths = exclude_paths or [ | ||
| "/docs", | ||
| "/redoc", | ||
| "/openapi.json", | ||
| "/", | ||
| "/api/v1/auth", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Plain |
||
| ] | ||
|
|
||
|
|
@@ -37,15 +73,15 @@ async def dispatch(self, request: Request, call_next: Callable[[Request], Awaita | |
| response = await call_next(request) | ||
| return response | ||
|
|
||
| user = getattr(request.state, "user", None) | ||
| if not user: | ||
| username = _bearer_token_username(request) | ||
| if username is None: | ||
| response = await call_next(request) | ||
| return response | ||
|
|
||
| has_access = await self._check_plugin_access(user.id, plugin_name) | ||
| has_access = await self._user_may_use_plugin(username, plugin_name) | ||
| if not has_access: | ||
| logger.warning( | ||
| f"User {user.id} attempted to access disabled plugin '{plugin_name}' at path {request.url.path}" | ||
| f"User '{username}' attempted to access disabled plugin '{plugin_name}' at path {request.url.path}" | ||
| ) | ||
| return JSONResponse( | ||
| status_code=status.HTTP_403_FORBIDDEN, | ||
|
|
@@ -65,19 +101,36 @@ def _is_excluded_path(self, path: str) -> bool: | |
| return False | ||
|
|
||
| def _get_route_plugin_name(self, request: Request) -> str | None: | ||
| for route in request.app.routes: | ||
| match, _ = route.matches(request.scope) | ||
| """Name of the plugin owning the route this request targets, or None for a core route. | ||
|
|
||
| Since FastAPI 0.137 include_router() no longer copies the sub-routes into | ||
| app.routes: it appends a single lazy _IncludedRouter branch, which has no .endpoint | ||
| carrying the plugin name. iter_route_contexts flattens those branches into contexts | ||
| that match on the *prefixed* path — the underlying route's own .path is unprefixed | ||
| and would never match the request — while original_route is the real route whose | ||
| endpoint holds the stamp. | ||
| """ | ||
| for context in iter_route_contexts(request.app.routes): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This re-flattens and scans the whole route table on every request, and Starlette then repeats the same matching to dispatch. Routes are static after startup, so building the flattened list (or a path to plugin-name cache) once in |
||
| match, _ = context.matches(request.scope) | ||
| if match == Match.FULL: | ||
| return get_route_plugin_name(route) | ||
| return get_route_plugin_name(context.original_route) | ||
| return None | ||
|
|
||
| async def _check_plugin_access(self, user_id: int, plugin_name: str) -> bool: | ||
| async def _user_may_use_plugin(self, username: str, plugin_name: str) -> bool: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Heads up that an authenticated plugin request now costs 3 sequential queries here (user, plugin, user_plugin), and then |
||
| """Whether the user this token names still has the plugin enabled. | ||
|
|
||
| A token naming a user that no longer exists passes: there is no preference to | ||
| enforce, and the route's own auth dependency rejects the request anyway. A database | ||
| failure blocks — the gate cannot confirm access, so it must not grant it. | ||
| """ | ||
| try: | ||
| async for session in get_async_session(): | ||
| return await _check_plugin_access_async(user_id, plugin_name, session, check_system_enabled=True) | ||
| return False # Fallback if session generator doesn't yield | ||
| async with session_scope() as session: | ||
| user = await get_user_by_username(username, session) | ||
| if user is None or user.id is None: | ||
| return True | ||
| return await _check_plugin_access_async(user.id, plugin_name, session, check_system_enabled=True) | ||
| except (DatabaseError, OperationalError) as e: | ||
| logger.error(f"Database error checking plugin access for user {user_id} and plugin '{plugin_name}': {e}") | ||
| logger.error(f"Database error checking plugin access for user '{username}' and plugin '{plugin_name}': {e}") | ||
| return False | ||
|
Comment on lines
132
to
134
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium — this fail-closed branch is the most security-critical path in the gate and has no test. On a DB error the gate returns Note the docstring/PR wording: a DB error is documented as "failing open" but the code correctly fails closed here — worth aligning the wording so the intent is unambiguous.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both branches now have a test, and both were verified to fail when the branch they cover is inverted (flip On the wording: the docstring already says the DB-error path blocks, not that it fails open —
The PR body's "failing open" refers to the anonymous path in the test-coverage bullet, not to database errors. Neither describes a DB error as failing open, so there's nothing to align here. |
||
|
|
||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This section spells out the gate's holes but leaves out the biggest one: a plugin's MCP tools are not gated at all.
register_plugin_toolsregisters every plugin's tools on the shared FastMCP server, and the/ai/mcpmount is a Mount with no endpoint, so_get_route_plugin_namereturns None and the gate always passes. An admin who disables a plugin system-wide, expecting it off, still has all of its tools callable over MCP. Worth stating here so nobody reads "disabled" as covering the plugin's whole surface.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and documented in
aca30f6./ai/mcpis not excluded and_get_route_plugin_namereturnsNonefor it, so the gate passes every MCP request through — andregister_plugin_toolsregisters every tool unconditionally.Worth splitting the gap in two, because the halves are not equally fixable: the MCP surface has no authenticated caller at all (
FastMCPtakes no auth provider, the mount has no dependency, handlers take no user id), so per-user enforcement has nothing to check against. System-wide enforcement needs no identity and is doable now — which is the half your admin example lands on.The new paragraph says both, and points at the tool itself as the place to enforce rather than the plugin switch.
Fix tracked in #591 with a proposed approach: a call-time FastMCP middleware beside
ToolCallAuditMiddleware, not registration-time filtering — tools register once at startup whileenabledflips at runtime. Keeping it out of this PR so the HTTP fix stays reviewable on its own.One thing that turned up while checking:
check_user_plugin_access,get_user_enabled_pluginsandget_user_disabled_pluginsinmiddleware.pyhave no callers anywhere. They look like the tool-side gate that was never wired up. Noted in #591 as either its implementation or a deletion.