Settings gains an API tokens tab: a row per token (name and prefix; each folder with its rights; last use and expiry, "expires soon" marked), revoked from its menu behind a confirm. A new token is the Hub exchange in one click, or folders and rights picked per folder (no Write where an upload cannot land); expiry 30/90 days, 1 year or never (warned). The token shows once in the same dialog, with Copy and a ready curl line. The Hub folder closes the Sources in the Files rail, takes uploads and moved-in files. A token about to run out warns its user once, a week before (the Files GC's sixth pass); the notice opens the tokens tab.
179 lines
6.2 KiB
Python
179 lines
6.2 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,
|
|
"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, 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}
|