Capture's routes (downloads, files, public watch links) and the token list no longer build SQL, call the session or set a model's fields: reads are downloads/queries, writes the unit's service (service, folders, links, quota), and each write flushes for the handler to commit. Whose a row is goes through userscope (owned_by, owned_row), which owned_or_404 now uses too. The fence holds Capture below its routes as well: it reads only Catalog's and Plex's tables, writes no other unit's, filters owners through userscope and commits only in its jobs and the upload ingest. A test per service use case, among them that the tray lists only the caller's files.
190 lines
6.8 KiB
Python
190 lines
6.8 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, queries
|
|
from app.models import ApiToken, ApiTokenGrant, 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:
|
|
grants = list(
|
|
db.execute(
|
|
select(ApiTokenGrant)
|
|
.where(ApiTokenGrant.token_id == token.id)
|
|
.order_by(ApiTokenGrant.folder_id)
|
|
).scalars()
|
|
)
|
|
# The folders are Capture's: read through its queries, not its table.
|
|
by_id = queries.folders_by_id(db, {g.folder_id for g in grants})
|
|
rows = [(g, by_id[g.folder_id]) for g in grants if g.folder_id in by_id]
|
|
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,
|
|
"expiring": apitokens.expiring(token, 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)
|
|
else:
|
|
folders.own_folder(db, user.id, folder_id)
|
|
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, and a
|
|
# session ended since `current_user` read the epoch (unlocked) cannot mint a token that outlives
|
|
# it — `bump_session_epoch` takes this lock before it revokes them all. No key update: a row that
|
|
# only refers to the user (a folder delete's audit line) must not wait on it, or the two deadlock.
|
|
epoch = db.execute(
|
|
select(User.session_epoch).where(User.id == user.id).with_for_update(key_share=True)
|
|
).scalar_one()
|
|
if (epoch or 0) != (user.session_epoch or 0):
|
|
raise refused(401, "not_authenticated")
|
|
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}
|