-
-
Notifications
You must be signed in to change notification settings - Fork 0
The REST API and authentication
Every module mounts its router under one API prefix. This page covers how to authenticate, what an error looks like, and a rule about route order that has already caused one production bug.
Every route lives under /api/v1:
api_prefix: str = "/api/v1"— server/src/radd/config.py
FastAPI generates the OpenAPI schema from the live route table. It always
matches what the app has mounted, including plugin-contributed routers and
the custom-field shapes those plugins add. Interactive docs are at /docs
(Swagger UI) and the raw schema at /openapi.json, both unauthenticated.
Radd accepts two forms of credential on a request: a session cookie (browser
sign-in) or a bearer token (radd_pat_…, a personal or service-account API
key). Resolution tries the cookie first, then falls back to the header:
async def optional_user(
request: Request, session: Annotated[AsyncSession, Depends(get_session)]
) -> User | None:
"""Resolve the session cookie, else a `Bearer radd_pat_…` header. None if anonymous."""
...
cookie = request.cookies.get(SESSION_COOKIE_NAME)
if cookie:
resolved = await service.resolve_session_users(session, cookie)
if resolved is not None:
...
authorization = request.headers.get("Authorization", "")
if authorization.startswith(_BEARER_PREFIX):
token = authorization[len(_BEARER_PREFIX) :].strip()
if token.startswith(PAT_PREFIX):
return await service.user_for_api_token(session, token)
return None— server/src/radd/modules/auth/deps.py
The server ignores a token that does not start with radd_pat_, rather than
rejecting it. It treats the header as absent, and the request falls through
to "unauthenticated" (401) unless a session cookie also resolves.
curl -s -X POST https://your-instance/api/v1/tokens \
-H "content-type: application/json" -b "<session cookie>" \
-d '{"name": "my agent"}'@token_router.post("", response_model=TokenCreated, status_code=201)
async def create_token(data: TokenCreate, session: Session, user: CurrentUser) -> TokenCreated:
try:
token, raw = await service.create_api_token(session, user, data)
except ValueError as exc: # an unknown atom in the scope
raise HTTPException(status_code=422, detail=str(exc)) from exc
return TokenCreated(
token=raw,
id=token.id,
name=token.name,
prefix_display=token.prefix_display,
expires_at=token.expires_at,
scopes=token.scopes,
)— server/src/radd/modules/auth/router.py
The response's token field is the full radd_pat_… value, shown exactly
once — the server stores only its hash. The same endpoint accepts an optional
scopes object (see below); an omitted scopes gives the token the full
authority of the account that created it. The interface's own version of this
endpoint is Settings → Personal API tokens.
A service account is a User row with source = service. It holds work
and appears as an author, and it accrues history like anyone, but it cannot
log in:
account = User(
email=email,
name=data.name,
# No password hash at all: there is nothing to verify against, and
# `create_session` refuses the source outright, so both doors are shut.
password_hash="",
instance_role=InstanceRole.MEMBER.value,
source=UserSource.SERVICE.value,
)— server/src/radd/modules/auth/service_accounts.py
Its authority comes entirely from grants an administrator gives it. It is
reached only through a key an administrator mints for it (POST under
Settings → Service accounts).
A key — personal or service-account — can carry a scope. A scope is a JSON object naming the raw permission atoms the key may use, globally and per project. The scope can only narrow what the underlying account already holds, never widen it:
@dataclass(frozen=True)
class TokenScope:
"""What one key is allowed to do, before the account's own limits apply."""
global_atoms: frozenset[Permission] = frozenset()
project_atoms: Mapping[uuid.UUID, frozenset[Permission]] = field(default_factory=dict)— server/src/radd/modules/auth/scopes.py
Enforcement is one intersection, applied at the single seam every permission check already passes through:
def _narrow_to_key_scope(
user: User, permissions: frozenset[Permission], project_id: uuid.UUID | None
) -> frozenset[Permission]:
"""Spec 113: intersect with the scope of the API key that authenticated this
request, if any. Applied to EVERY resolution — including the admin shortcut,
because a scoped key held by an admin is the whole point."""
scope = getattr(user, "token_scope", None)
if scope is None:
return permissions
return scope.narrow(permissions, project_id)— server/src/radd/modules/auth/authz_core.py
There is no second policy engine and no separate scoped-key code path. A key can never exceed the account behind it. Demoting the account narrows every key it holds on the next request, with no key edit required.
A domain error is a plain JSON body with a detail string and the matching
HTTP status:
@app.exception_handler(NotFoundError)
async def not_found_handler(request: Request, exc: NotFoundError) -> JSONResponse:
return JSONResponse(status_code=404, content={"detail": str(exc)})
@app.exception_handler(ConflictError)
async def conflict_handler(request: Request, exc: ConflictError) -> JSONResponse:
return JSONResponse(status_code=409, content={"detail": str(exc)})
@app.exception_handler(UnauthorizedError)
async def unauthorized_handler(request: Request, exc: UnauthorizedError) -> JSONResponse:
return JSONResponse(status_code=401, content={"detail": str(exc)})
@app.exception_handler(ForbiddenError)
async def forbidden_handler(request: Request, exc: ForbiddenError) -> JSONResponse:
return JSONResponse(status_code=403, content={"detail": str(exc)})— server/src/radd/app.py
An SLQ query error (GET /items?q=..., saved views, the timesheet's q)
carries one extra field: the character offset of the bad token. An editor can
use it to put the caret at the mistake:
async def _slq_handler(request: Request, exc: SlqError) -> JSONResponse:
# Spec 10 error contract: the message + the character offset of the bad token.
return JSONResponse(
status_code=422, content={"detail": str(exc), "position": exc.position}
)— server/src/radd/modules/items/init.py
A request body that fails pydantic validation gets FastAPI's own 422 shape
(a detail list of per-field errors), not this one.
Most filtered listings (GET /items, GET /events, GET /webhooks,
GET /users/directory) take limit/offset query parameters. Each returns
a plain JSON array, with no wrapper envelope:
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),— server/src/radd/modules/items/router.py
Some of these set an X-Total-Count response header when limit is given, so
a caller can page without a second counting request:
rows = await service.list_users(session, q=q, limit=limit, offset=offset)
if limit is not None:
response.headers[TOTAL_COUNT_HEADER] = str(await service.count_users(session, q=q))— server/src/radd/modules/auth/router.py
Simple CRUD collections generated by the kernel's EntitySpec (a plugin's
own entity, e.g. milestones) have no limit/offset at all — GET /milestones returns every visible row.
TODO(verify): confirm whether every kernel
EntitySpec-generated list route should stay unpaginated long-term. It may be a known gap for a plugin with a large table.
A catch-all route, mounted after every API router, serves the built web app. It refuses to answer for anything that looks like an API path:
@app.get("/{path:path}", include_in_schema=False)
async def spa(path: str) -> FileResponse:
# An unknown API path must 404, never serve the SPA shell: a disabled
# plugin's endpoints have to look ABSENT (the spec-46 dormant
# convention), and a 200 text/html "response" masks real client bugs.
if path.startswith(api_root):
raise HTTPException(status_code=404)
...— server/src/radd/app.py
So GET /api/v1/whatever-does-not-exist answers a JSON 404, not the SPA's
index.html. This matters for a disabled plugin: its endpoints must look
absent, not present-but-broken. A caller that mistypes a path also gets an
honest 404, instead of a page of HTML to parse by mistake.
Starlette matches routes in the order they were declared and mounted, not
by specificity. A literal path segment registered after a parameterized route
of the same shape is unreachable. It still appears in the OpenAPI schema and
at /docs, and it still imports and runs as ordinary code. Only calling it
reveals the problem, because the earlier, more general route intercepts every
request first and answers with its own error, usually the wrong one.
This has already happened once, live. The code declared /pages/search 190
lines below /pages/{page_id}. Every search request went to the single-page
lookup instead, and got a 422 claiming "search" could not be parsed as a
UUID. A docstring against a second route that could have made the same
mistake states both the fix and the general rule directly:
@user_router.get("/directory", response_model=list[UserDirectoryEntry])
async def list_user_directory(
...
) -> list[UserDirectoryEntry]:
"""Who exists, for anyone with an account (RADD-769).
...
**Declared above `/users/{user_id}` on purpose (RADD-761):** Starlette
matches in declaration order, so a literal segment written after a `{uuid}`
route is answered by that route — this would 422 about parsing "directory"
as a UUID while appearing, correctly, in the schema and at /docs.
`tests/test_route_shadowing.py` asserts it for the whole app.
"""— server/src/radd/modules/auth/router.py
tests/test_route_shadowing.py asserts this for the whole assembled app,
not one router at a time. The order that decides matching is the order
plugins mount their routers in at startup, not the order routes are written
in any one source file:
def test_no_route_is_shadowed_by_an_earlier_pattern(monkeypatch):
"""No literal path may sit behind a parameter route of the same shape."""
monkeypatch.setattr(settings, "backup_tools_optional", True)
flat = _flatten(create_app().routes)
assert len(flat) > 400, f"route walk found only {len(flat)} routes"
assert not shadowed(flat), "\n".join(shadowed(flat))— server/tests/test_route_shadowing.py
If you add a literal-segment route next to a parameterized one on the same router, declare the literal one first. Rely on this test to catch the mistake if you forget.
curl -s https://your-instance/api/v1/auth/me \
-H "Authorization: Bearer radd_pat_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"GET /auth/me returns the signed-in principal — id, email, role, and the flat
permission-atom union it currently holds ("permissions": [...]). It is a
reasonable first call when you wire up a new client. A 401 means the token is
wrong or expired. A narrower permissions list than expected means the key
carries a scope.
Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.
-
Developer guide
- Architecture: the kernel and plugins
- Develop, test and deploy
- Events and consumers
- Permissions and access control
- The MCP server
- The query language for developers
- The REST API and authentication
- Write a backend plugin
- Write a page editor extension
- Write a plugin user interface
- Write an automation node
-
Release notes
- 0.36.4
- 0.36.3
- 0.36.2
- 0.36.1
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.0
- 0.32.0
- 0.31.1
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.1
- 0.24.0
- 0.23.1
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.1
- 0.18.0
- 0.17.2
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.1
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.6
- 0.6.5
- 0.6.4
- 0.6.3
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.0
- 0.2.0
- 0.1.0
-
User guide
- AI features
- Attachments
- Automations
- Cycles and releases
- Instance settings
- Intake forms and the portal
- Notifications and the inbox
- Personal settings
- Project settings
- Projects
- Reports and dashboards
- Search and the query language
- Start here
- The application window
- The card designer
- The roadmap
- The service desk
- The wiki
- Time logging and the timesheet
- Views
- Work items