Files
siftlode/backend/app/routes/tokens.py
T
peter b2d34e6e6c feat(files): API tokens — a machine reaches the library folder by folder
A user can make a token for a machine of theirs (`/api/tokens`, signed-in only): `sfl_` + 32
random bytes + a checksum, only its SHA-256 kept, shown once. Sent as a Bearer header it is
honoured on six `/api/files` routes only — folders, list, file, hash, upload, delete — and there
only in the folders its grants name, with the rights they give (list / read / write / delete).
One check, `FilesCaller.may`, in every route; a folder or file out of reach answers like a
missing one; the files shared with the user are never in reach. A token beats a cookie on the
same request.

The Hub preset makes the user's one Hub folder (a new folder kind) and grants it every right.
Refused tokens are slowed down per client, a runaway machine per token; last use is stamped at
most once a minute; making and revoking a token is audited, and a token's uploads and deletes
name it in their audit rows.
2026-10-14 18:40:23 +02:00

178 lines
6.1 KiB
Python

"""A user's API tokens for their Files library (`app.apitokens`): list, make, revoke.
Signed-in only — `current_user`, never a token: a token cannot make itself a wider one. A new
token's value is in the answer that made it and nowhere after; only its hash is kept.
The Hub preset is the one machine a user swaps files with: it makes (or reuses) their Hub folder
and gives the token every right in it, so the hub both drops files there and picks up the ones put
there for it.
"""
from datetime import datetime, timedelta, timezone
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app import apitokens, audit
from app.apitokens import GRANT_COLUMNS, Right
from app.audit import AuditAction
from app.auth import current_user
from app.db import get_db
from app.downloads import folders
from app.models import ApiToken, ApiTokenGrant, FileFolder, User
from app.refusal import refused
from app.routes._common import owned_or_404
from app.security import hash_token
from app.utils import iso
router = APIRouter(prefix="/api/tokens", tags=["tokens"])
# Far more than one person's machines; a ceiling, so a script gone wrong cannot mint without end.
MAX_TOKENS = 20
_MAX_GRANTS = 50
_MAX_EXPIRY_DAYS = 3650
class GrantIn(BaseModel):
folder_id: int
rights: list[Right] = Field(min_length=1, max_length=len(Right))
class TokenIn(BaseModel):
name: str = Field(min_length=1, max_length=80)
# None = never expires.
expires_days: int | None = Field(default=None, ge=1, le=_MAX_EXPIRY_DAYS)
preset: Literal["hub"] | None = None
grants: list[GrantIn] = Field(default_factory=list, max_length=_MAX_GRANTS)
def _serialize(db: Session, token: ApiToken) -> dict:
rows = db.execute(
select(ApiTokenGrant, FileFolder)
.join(FileFolder, FileFolder.id == ApiTokenGrant.folder_id)
.where(ApiTokenGrant.token_id == token.id)
.order_by(FileFolder.id)
).all()
now = datetime.now(timezone.utc)
return {
"id": token.id,
"name": token.name,
"prefix": token.prefix,
"created_at": iso(token.created_at),
"expires_at": iso(token.expires_at),
"expired": token.expires_at is not None and token.expires_at <= now,
"last_used_at": iso(token.last_used_at),
"grants": [
{
"folder_id": folder.id,
"kind": folder.kind,
"source_key": folder.source_key,
"name": folder.name,
"rights": sorted(apitokens.grant_rights(grant)),
}
for grant, folder in rows
],
}
@router.get("")
def list_tokens(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
tokens = (
db.execute(select(ApiToken).where(ApiToken.user_id == user.id).order_by(ApiToken.id))
.scalars()
.all()
)
return [_serialize(db, t) for t in tokens]
def _wanted_grants(db: Session, user: User, body: TokenIn) -> dict[int, set[Right]]:
"""Folder id → the rights the new token gets there; every folder checked to be the user's, and
writing only where an upload may land (`folders.upload_target`)."""
if body.preset == "hub":
if body.grants:
raise refused(400, "api_token_preset_grants")
return {folders.hub_folder(db, user.id).id: set(Right)}
if not body.grants:
raise refused(400, "api_token_no_grants")
wanted: dict[int, set[Right]] = {}
for grant in body.grants:
wanted.setdefault(grant.folder_id, set()).update(grant.rights)
for folder_id, rights in wanted.items():
try:
if Right.WRITE in rights:
folders.upload_target(db, user.id, folder_id, hold=False)
else:
folders.own_folder(db, user.id, folder_id, hold=False)
except folders.FolderError as e:
raise refused(409, e.reason) from e
return wanted
@router.post("")
def create_token(
body: TokenIn, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Make a token; its value is in this answer only."""
name = body.name.strip()
if not name:
raise refused(400, "api_token_name_required")
# The user's row, locked: two requests at once cannot both slip under the ceiling.
db.execute(select(User.id).where(User.id == user.id).with_for_update())
count = db.execute(
select(func.count(ApiToken.id)).where(ApiToken.user_id == user.id)
).scalar_one()
if count >= MAX_TOKENS:
raise refused(409, "api_token_limit", limit=MAX_TOKENS)
wanted = _wanted_grants(db, user, body)
raw = apitokens.mint()
token = ApiToken(
user_id=user.id,
name=name,
prefix=raw[: apitokens.SHOWN_LEN],
token_hash=hash_token(raw),
expires_at=None
if body.expires_days is None
else datetime.now(timezone.utc) + timedelta(days=body.expires_days),
)
db.add(token)
db.flush()
for folder_id, rights in wanted.items():
db.add(
ApiTokenGrant(
token_id=token.id,
folder_id=folder_id,
**{col: right in rights for right, col in GRANT_COLUMNS.items()},
)
)
db.flush()
audit.record(
db,
user.id,
AuditAction.API_TOKEN_CREATE,
target_type="api_token",
target_id=token.id,
after={
"grants": {str(f): sorted(r) for f, r in wanted.items()},
"expires_at": iso(token.expires_at),
},
)
db.commit()
return {"ok": True, **_serialize(db, token), "token": raw}
@router.delete("/{token_id}")
def revoke_token(
token_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
) -> dict:
"""Revoke a token: it stops working at its next request (its grants go with it)."""
token = owned_or_404(db, ApiToken, token_id, user, what="api_token")
db.delete(token)
audit.record(
db, user.id, AuditAction.API_TOKEN_REVOKE, target_type="api_token", target_id=token_id
)
db.commit()
return {"ok": True, "deleted": token_id}