Skip to content

The REST API and authentication

Hussein Jarrar edited this page Sep 12, 2026 · 2 revisions

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.

Base path and schema

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.

Two credentials

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.

Creating a personal token

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.

Service accounts and scoped keys

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.

Errors

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.

Pagination

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.

The SPA catch-all and unknown API paths

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.

Route declaration order is significant

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.

Calling the API

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.

Clone this wiki locally