Controller (FastAPI)¶
The controller (security-review-controller) serves the REST API /api/v1, the web interface
on /ui/, the migrations and the administration commands. It is a FastAPI application built by
web_api.create_app, wrapped by http_server (Bearer tokens, sessions, TLS). As in the MCP
server, the routes stay thin: the business logic is in the modules (reports/services.py,
auth/access_control.py, auth/token_store.py…).
uv run security-review-controller db upgrade # migrate the database
uv run security-review-controller serve --port 8080 # API and web interface
uv run pytest tests/test_web_api.py # REST API tests
Layout of web_api/¶
| File | Role |
|---|---|
__init__.py |
create_app: assembly of the routers under /api/v1, static files of /ui |
schemas.py |
Request and response models of every route |
deps.py |
Identity of the caller, permissions (require), project scope |
*_routes.py |
One router per domain (report, rule, token, access, connector…) |
Routes¶
@router.get("/project-tokens")
async def project_tokens(identity: TokensManager) -> list[ProjectToken]:
"""List project-token metadata without returning hashes or token values; only the tokens
whose projects the caller reads."""
tokens = await run_in_threadpool(token_store.list_project_tokens)
return [token for token in tokens if _reaches(identity, token["projects"])]
- Response model: every JSON route declares its return type with a model of
schemas.py, never-> dict. FastAPI documents it in OpenAPI and drops any field that is not declared (no hash nor secret leaks by mistake);test_openapi_documents_the_response_of_every_json_routefails on a baredict. - Permissions: the identity comes from a dependency of
deps.py, usually an alias built withrequire(Permission.…)(TokensManager,Triager,RulesManager…), which answers403without the permission. Restrict the results to the projects of the caller (permission_scope,scoped_project,check_report_scope). - Sessions only: an action reserved to a person (risk acceptance, false positive, token or
project creation) calls
web_user_id(request), which refuses any Bearer token (403). - Synchronous calls: the ORM is synchronous; call it through
run_in_threadpool. - Errors:
HTTPExceptionwith the right code;unprocessable(exc)turns aValueErrorof a module into a422. - No write route without a token: every route of
/api/v1requires an authenticated identity, and the controller exposes no MCP endpoint.
Settings¶
A new parameter goes into config.Settings (prefix SECREVIEW_ added automatically), with a
comment, then into .env.example and Configuration. Read it
with get_settings(), never with os.environ. A setting that an administrator changes in the
interface (language, proxy, OIDC) is stored in the database through app_settings.
Command line¶
The controller commands are in cli.py (CONTROLLER_COMMANDS). Exit codes are the same
everywhere: 0 success, 1 blocked (gate, findings above the threshold), 2 usage or data
error (_fail writes the error as JSON on stderr). Only the CLI writes on stdout. Document any
command or option in the command line reference.
Schema migrations¶
Every change of a table model (models.py) comes with an Alembic revision in
src/security_review_mcp/storage/migrations/versions/. The
test_upgrade_creates_the_schema_of_the_models test (tests/test_database.py) fails as long
as the migrations do not produce exactly the schema of the models. To generate a draft
revision to review:
uv run python - <<'PY'
from alembic import command
from security_review_mcp.storage import database
with database.get_engine().begin() as connection:
command.revision(
database.alembic_config(connection), "describe the change", autogenerate=True, rev_id="0033"
)
PY
Run it with SECREVIEW_DATABASE_URL pointing to a throwaway SQLite database already up to date
(security-review-controller db upgrade). Number the revisions in sequence after the last one
of versions/, review the generated file and never change a revision already published. The
SQL goes through the ORM only (SQLModel, bound parameters), never through a string.
Connectors¶
connectors/api.py is the contract of the third-party connectors: do not break it without
incrementing API_VERSION. A connector only goes out through connectors.transport (HTTPS,
configured URL, proxy, no redirect, bounded response). See
third-party connectors.
Tests¶
tests/test_web_api.py provides the fixtures of the API:
| Fixture | Caller |
|---|---|
client |
An administrator, without the token lookup |
secured |
The controller protected by one token per role; auth("<role>") gives the header |
web_sessions |
secured with logged-in web users (reader, triager), for the routes reserved to sessions |
Check the status code and the body, and at least one refusal (401, 403 or 404 outside
the scope) for a new route. See also the web interface and the
roles and permissions as users see them.