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.
This commit is contained in:
2026-10-14 18:40:23 +02:00
parent c335d54929
commit b2d34e6e6c
18 changed files with 1080 additions and 48 deletions
+9 -1
View File
@@ -27,7 +27,7 @@ from starlette.requests import HTTPConnection
import app.models # register every model on Base.metadata
import app.state as app_state
from app import security
from app import apitokens, security
from app.auth import current_user
from app.config import settings
from app.db import Base, SessionLocal, engine, get_db
@@ -242,8 +242,16 @@ def as_user(db, override_dep, _configured):
raise HTTPException(status_code=401, detail="Not authenticated")
return registry[uid]
def _session_or_token(request: Request) -> User | None:
# The Files routes' session half (`apitokens.session_caller`): a request carrying a token is
# left to the real token check, exactly as in production.
if apitokens.bearer_token(request) is not None:
return None
return _resolver(request)
override_dep(get_db, db.info["request"])
override_dep(current_user, _resolver)
override_dep(apitokens.session_caller, _session_or_token)
def _as(user: User) -> TestClient:
registry[user.id] = user