From b2d34e6e6c87fa3325cb70d365b6ab413ddbb183 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 14 Oct 2026 18:40:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(files):=20API=20tokens=20=E2=80=94=20a=20m?= =?UTF-8?q?achine=20reaches=20the=20library=20folder=20by=20folder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 2 +- backend/alembic/versions/0106_api_tokens.py | 94 +++++ backend/app/README.md | 3 +- backend/app/apitokens.py | 209 ++++++++++ backend/app/audit.py | 4 + backend/app/downloads/folders.py | 24 ++ backend/app/downloads/upload.py | 13 +- backend/app/main.py | 2 + backend/app/models.py | 47 ++- backend/app/routes/files.py | 125 ++++-- backend/app/routes/tokens.py | 177 ++++++++ backend/tests/conftest.py | 10 +- backend/tests/test_api_tokens.py | 378 ++++++++++++++++++ backend/tests/test_download_job_locks.py | 8 +- .../src/i18n/locales/en/auditActions.json | 4 +- frontend/src/i18n/locales/en/errors.json | 12 +- .../src/i18n/locales/hu/auditActions.json | 4 +- frontend/src/i18n/locales/hu/errors.json | 12 +- 18 files changed, 1080 insertions(+), 48 deletions(-) create mode 100644 backend/alembic/versions/0106_api_tokens.py create mode 100644 backend/app/apitokens.py create mode 100644 backend/app/routes/tokens.py create mode 100644 backend/tests/test_api_tokens.py diff --git a/CLAUDE.md b/CLAUDE.md index 347e1f3a..7aa7203b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,7 @@ how it gets built. Concretely: ## Architecture shape (don't fight it) `routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`, -`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`, +`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `apitokens`, `quota`, `sysconfig`, `state`, `audit`, `notifications`, `userscope`, `ratelimit`, `bodylimit`, `progress`, `collation`, `security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.** Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own diff --git a/backend/alembic/versions/0106_api_tokens.py b/backend/alembic/versions/0106_api_tokens.py new file mode 100644 index 00000000..019872a4 --- /dev/null +++ b/backend/alembic/versions/0106_api_tokens.py @@ -0,0 +1,94 @@ +"""Machine access to the Files library: API tokens bound to folders, and the Hub folder + +Revision ID: 0106_api_tokens +Revises: 0105_job_source_key +Create Date: 2026-10-14 + +`api_tokens` is a user's token for a machine (a script, the hub): only its SHA-256 is kept, plus the +first characters to recognise it by. `api_token_grants` says what it may do in which folder — list, +read, write, delete — so a token reaches nothing it was not given, folder by folder. + +A third folder kind, `hub`: the one folder a user exchanges files with their hub machine through. +Like a source folder its name is the client's to render; like an own folder a file may be moved in. +One per user. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0106_api_tokens" +down_revision: str | None = "0105_job_source_key" +branch_labels: str | None = None +depends_on: str | None = None + +_KIND_OLD = ( + "(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)" + " OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)" +) +_KIND_NEW = _KIND_OLD + " OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)" + + +def upgrade() -> None: + op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check") + op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_NEW) + op.create_index( + "uq_file_folder_hub", + "file_folders", + ["user_id"], + unique=True, + postgresql_where=sa.text("kind = 'hub'"), + ) + op.create_table( + "api_tokens", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("name", sa.String(80), nullable=False), + sa.Column("prefix", sa.String(16), nullable=False), + sa.Column("token_hash", sa.String(64), nullable=False, unique=True), + sa.Column("expires_at", sa.DateTime(timezone=True)), + sa.Column("last_used_at", sa.DateTime(timezone=True)), + sa.Column( + "created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False + ), + ) + op.create_table( + "api_token_grants", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column( + "token_id", + sa.Integer(), + sa.ForeignKey("api_tokens.id", ondelete="CASCADE"), + nullable=False, + ), + sa.Column( + "folder_id", + sa.Integer(), + sa.ForeignKey("file_folders.id", ondelete="CASCADE"), + nullable=False, + index=True, + ), + sa.Column("can_list", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("can_read", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("can_write", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("can_delete", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"), + sa.CheckConstraint( + "can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some" + ), + ) + + +def downgrade() -> None: + op.drop_table("api_token_grants") + op.drop_table("api_tokens") + op.drop_index("uq_file_folder_hub", table_name="file_folders") + # A Hub folder may hold files (their foreign key RESTRICTs a delete): it becomes an own folder. + op.execute("UPDATE file_folders SET kind = 'own', name = 'Hub ' || id WHERE kind = 'hub'") + op.drop_constraint("ck_file_folder_kind", "file_folders", type_="check") + op.create_check_constraint("ck_file_folder_kind", "file_folders", _KIND_OLD) diff --git a/backend/app/README.md b/backend/app/README.md index b8cd114c..6ff33ab1 100644 --- a/backend/app/README.md +++ b/backend/app/README.md @@ -20,7 +20,8 @@ Three layers, by *who* runs the code — not by feature: run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline; `issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker. -3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`, +3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth` (with `apitokens`, a machine's + folder-scoped way into the Files library), `sysconfig`, `quota`, `audit`, `notifications`, `userscope`, `ratelimit`, `progress`, `logs` (the one logging configuration), `logsink` (its `log_events` table), `diagnostics` (a user's consented log level), `debugsession` (a recording that tags its user's lines, E16) and `collation` (A→Z in the diff --git a/backend/app/apitokens.py b/backend/app/apitokens.py new file mode 100644 index 00000000..12e43b52 --- /dev/null +++ b/backend/app/apitokens.py @@ -0,0 +1,209 @@ +"""Machine access to the Files library: API tokens, and what each one may do where. + +A user makes a token for a machine of theirs — their hub, a script — and it reaches their library as +`Authorization: Bearer sfl_…`. Only on `/api/files`: the routes there take `files_caller` instead of +`current_user`, and nothing else does (a test holds the route table to that), so a token can never +manage the account, the tokens, or anything outside the library. And only in the folders its grants +name, with the rights they give: list, read, write, delete. Every route asks the one question, +`FilesCaller.may(folder_id, right)`; a signed-in user may everything in their own folders. + +The token is `sfl_` + 32 random bytes in base62 + a CRC32 of them: a mistyped or cut-off token is +refused without a database read, and a secret scanner can tell one by its shape. Only its SHA-256 is +kept (`security.hash_token`) — the user sees it once, when it is made — with its first characters to +tell tokens apart by. It is looked up by that hash on every request: no signed claims, so a deleted +token stops working at once. +""" + +import secrets +import zlib +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from enum import StrEnum + +from fastapi import Depends, Request +from sqlalchemy import or_, select, update +from sqlalchemy.orm import Session + +from app import debugsession, logs +from app.auth import current_user +from app.db import get_db +from app.models import ApiToken, ApiTokenGrant, User +from app.ratelimit import RateLimiter, client_key +from app.refusal import refused +from app.security import hash_token + +log = logs.get_logger(__name__) + +PREFIX = "sfl_" +_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +_BODY_BYTES = 32 +_BODY_LEN = 43 # 62**43 > 2**256: every 32-byte value fits +_CHECK_LEN = 6 # 62**6 > 2**32: every CRC32 fits +TOKEN_LEN = len(PREFIX) + _BODY_LEN + _CHECK_LEN +# What a token is shown by once it is made: enough to tell a user's tokens apart, far too little to +# narrow a guess (8 of its 43 random characters). +SHOWN_LEN = len(PREFIX) + 8 + +# A machine that runs away (a loop in a script) is held back per token; a caller trying tokens is +# held back per client, counting only the refusals. +_calls = RateLimiter(600, 60) +_bad = RateLimiter(20, 300) +# How stale `last_used_at` may get before a request writes it again: a busy script costs one write a +# minute, not one per request. +_TOUCH_EVERY = timedelta(minutes=1) + + +class Right(StrEnum): + LIST = "list" + READ = "read" + WRITE = "write" + DELETE = "delete" + + +# Each right's column on a grant. A dict over the enum, so a new right without a column fails here. +GRANT_COLUMNS = { + Right.LIST: "can_list", + Right.READ: "can_read", + Right.WRITE: "can_write", + Right.DELETE: "can_delete", +} + + +def _b62(value: int, width: int) -> str: + out = [] + for _ in range(width): + value, digit = divmod(value, 62) + out.append(_ALPHABET[digit]) + return "".join(reversed(out)) + + +def _check(body: str) -> str: + return _b62(zlib.crc32(body.encode()), _CHECK_LEN) + + +def mint() -> str: + """A new token, in full. The caller stores its hash and shows it to the user, once.""" + body = _b62(int.from_bytes(secrets.token_bytes(_BODY_BYTES)), _BODY_LEN) + return PREFIX + body + _check(body) + + +def well_formed(raw: str) -> bool: + """Whether `raw` has a token's shape and its checksum adds up.""" + if len(raw) != TOKEN_LEN or not raw.startswith(PREFIX): + return False + body, check = raw[len(PREFIX) : -_CHECK_LEN], raw[-_CHECK_LEN:] + if any(c not in _ALPHABET for c in body): + return False + return secrets.compare_digest(_check(body), check) + + +def grant_rights(grant: ApiTokenGrant) -> frozenset[Right]: + return frozenset(r for r, col in GRANT_COLUMNS.items() if getattr(grant, col)) + + +@dataclass(frozen=True) +class FilesCaller: + """Who is asking the Files library, and where they may do what. `grants` is None for a signed-in + user (everything in their own folders); for a token, folder id → its rights there.""" + + user: User + token_id: int | None = None + token_prefix: str | None = None + grants: Mapping[int, frozenset[Right]] | None = None + + def may(self, folder_id: int | None, right: Right) -> bool: + if self.grants is None: + return True + return folder_id is not None and right in self.grants.get(folder_id, frozenset()) + + def folders_with(self, right: Right) -> set[int] | None: + """The folders `right` is granted in; None = any of the user's own.""" + if self.grants is None: + return None + return {f for f, rights in self.grants.items() if right in rights} + + @property + def via(self) -> str | None: + """How an audit row says a token did it, None when the user did.""" + return None if self.token_prefix is None else f"api token {self.token_prefix}…" + + +def bearer_token(request: Request) -> str | None: + """The token of an `Authorization: Bearer` header; None when the request carries none (another + scheme counts as none — it is not ours to judge).""" + scheme, _, value = request.headers.get("authorization", "").partition(" ") + if scheme.lower() != "bearer": + return None + return value.strip() + + +def session_caller(request: Request, db: Session = Depends(get_db)) -> User | None: + """The signed-in user, or None when the request brings a token instead: a token wins over a + cookie, so a machine's request is judged by its grants alone, whatever else it carries.""" + if bearer_token(request) is not None: + return None + return current_user(request, db) + + +def files_caller( + request: Request, + db: Session = Depends(get_db), + user: User | None = Depends(session_caller), +) -> FilesCaller: + """The `/api/files` caller: a signed-in user, or a valid token's user with its grants.""" + if user is not None: + return FilesCaller(user=user) + return _token_caller(request, db, bearer_token(request) or "") + + +def _refuse_token(key: str, reason: str) -> Exception: + _bad.allow(key) + log.info( + "a request brought an API token that does not hold", + extra=logs.event("apitoken.refused", reason=reason), + ) + return refused(401, "not_authenticated") + + +def _token_caller(request: Request, db: Session, raw: str) -> FilesCaller: + key = client_key(request) + if _bad.exhausted(key): + raise refused(429, "api_token_rate_limited") + if not well_formed(raw): + raise _refuse_token(key, "malformed") + token = db.execute( + select(ApiToken).where(ApiToken.token_hash == hash_token(raw)) + ).scalar_one_or_none() + if token is None: + raise _refuse_token(key, "unknown") + now = datetime.now(timezone.utc) + if token.expires_at is not None and token.expires_at <= now: + raise _refuse_token(key, "expired") + user = db.get(User, token.user_id) + if user is None or not user.is_active or user.is_suspended: + raise _refuse_token(key, "account") + if not _calls.allow(str(token.id)): + raise refused(429, "api_token_rate_limited") + grants = { + g.folder_id: grant_rights(g) + for g in db.execute(select(ApiTokenGrant).where(ApiTokenGrant.token_id == token.id)) + .scalars() + .all() + } + caller = FilesCaller(user=user, token_id=token.id, token_prefix=token.prefix, grants=grants) + db.execute( + update(ApiToken) + .where( + ApiToken.id == token.id, + or_(ApiToken.last_used_at.is_(None), ApiToken.last_used_at < now - _TOUCH_EVERY), + ) + .values(last_used_at=now) + .execution_options(synchronize_session=False) + ) + # Committed here, ahead of the route: a read-only route never commits, and a write route must + # not carry this along into a rollback of its own. + db.commit() + logs.set_user(user.id, *debugsession.level_for(user, None)) + request.state.user_id = user.id + return caller diff --git a/backend/app/audit.py b/backend/app/audit.py index c7180113..6a5f5170 100644 --- a/backend/app/audit.py +++ b/backend/app/audit.py @@ -69,6 +69,10 @@ class AuditAction: FILE_FOLDER_RENAME = "file_folder_rename" FILE_FOLDER_RETENTION = "file_folder_retention" FILE_FOLDER_DELETE = "file_folder_delete" + # A machine's token to the library (`routes.tokens`): made (its folders and rights) or revoked. + # What a token then did is the file rows above, their summary naming it. + API_TOKEN_CREATE = "api_token_create" + API_TOKEN_REVOKE = "api_token_revoke" def record( diff --git a/backend/app/downloads/folders.py b/backend/app/downloads/folders.py index 6908f06c..46aa3682 100644 --- a/backend/app/downloads/folders.py +++ b/backend/app/downloads/folders.py @@ -7,6 +7,9 @@ shared asset can be held by several users), so a rename or a move is a column up uploads, one per other site). Its `source_key` names the origin; the client renders its name from the key, in the reader's language. - an OWN folder is the user's, with the name they gave it. +- the HUB folder (one per user, made when they first give a machine a token for it, `hub_folder`) is + where they swap files with that machine. Its name, like a source folder's, is the client's; a file + may be moved in like into an own folder, and it keeps a file until it is deleted. Every file of a user is in exactly one folder. The folder a new file lands in is decided here (`source_key_for` + `source_folder`), at every place a job is created, so the rule lives once. A file @@ -35,6 +38,7 @@ from app.models import DownloadJob, FileFolder, MediaAsset class FolderKind(StrEnum): SOURCE = "source" OWN = "own" + HUB = "hub" class SourceKey(StrEnum): @@ -123,6 +127,26 @@ def source_folder(db: Session, user_id: int, key: str) -> FileFolder: return folder +def hub_folder(db: Session, user_id: int) -> FileFolder: + """The user's Hub folder, made on first use; a race on its one-per-user index is settled like + `source_folder`'s.""" + stmt = select(FileFolder).where( + FileFolder.user_id == user_id, FileFolder.kind == FolderKind.HUB + ) + folder = db.execute(stmt).scalar_one_or_none() + if folder is not None: + return folder + folder = FileFolder(user_id=user_id, kind=FolderKind.HUB) + try: + with db.begin_nested(): + db.add(folder) + db.flush() + # silent-ok: another request made the Hub folder first; re-selecting it is the whole handling. + except IntegrityError: + folder = db.execute(stmt).scalar_one() + return folder + + def place(db: Session, job: DownloadJob) -> None: """Put a new job's file in its source folder, and record that origin on it. Called wherever a job is created; the job must carry its `user_id`, `source_kind` and source refs already.""" diff --git a/backend/app/downloads/upload.py b/backend/app/downloads/upload.py index 7e382a19..49291c39 100644 --- a/backend/app/downloads/upload.py +++ b/backend/app/downloads/upload.py @@ -57,12 +57,14 @@ def ingest_upload( display_name: str | None = None, sha256: str | None = None, folder_id: int | None = None, + via: str | None = None, ) -> DownloadJob: """Ingest an already-uploaded file at `src_path` into `user_id`'s library and return its job — `done`, or `queued` for the worker's phone-safe conversion (see the module docstring). `src_path` is MOVED into place on success (the caller stages it to a temp file first). `sha256` is the hash of those bytes, when the caller took it on the way in; `folder_id` the folder it asked for - (`folders.upload_target`), else its Uploads folder. + (`folders.upload_target`), else its Uploads folder; `via` what the audit row names as the doer + when it was not the user themselves (an API token). Raises folders.FolderError when that folder is not one an upload may land in (or is gone). @@ -145,7 +147,14 @@ def ingest_upload( folders.stamp_done(db, job) service.incr_ref_count(db, asset.id) db.flush() - audit.record(db, user_id, AuditAction.FILE_UPLOAD, target_type="file", target_id=job.id) + audit.record( + db, + user_id, + AuditAction.FILE_UPLOAD, + target_type="file", + target_id=job.id, + summary=via, + ) db.commit() except Exception: db.rollback() diff --git a/backend/app/main.py b/backend/app/main.py index 795456b7..9fd2f132 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -58,6 +58,7 @@ from app.routes import ( # noqa: E402 — logging is configured first setup as setup_routes, sync, tags, + tokens as tokens_routes, version, ) from app.scheduler import shutdown_scheduler, start_scheduler # noqa: E402 — logging is configured first @@ -337,6 +338,7 @@ app.include_router(plex_routes.router) app.include_router(downloads.router) app.include_router(downloads.admin_router) app.include_router(files_routes.router) +app.include_router(tokens_routes.router) app.include_router(public_routes.router) app.include_router(admin.router) app.include_router(config_routes.router) diff --git a/backend/app/models.py b/backend/app/models.py index 6fe9726f..af2224ac 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -919,7 +919,8 @@ class FileFolder(Base, TimestampMixin): """A folder of the Files library (`app.downloads.folders`): a row, never a directory. A SOURCE folder (`source_key` set: youtube / plex / upload / site:) is made the first time a - user gets a file from that origin; an OWN folder (`name` set) is one the user made. Every file — + user gets a file from that origin; an OWN folder (`name` set) is one the user made; the HUB folder + (neither set, one per user) is where they swap files with a machine of theirs. Every file — a `DownloadJob` — sits in exactly one (`DownloadJob.folder_id`). `retention_days` is how long a file stays once done (NULL = until deleted); a file's own `expires_at` is stamped from it.""" @@ -933,9 +934,12 @@ class FileFolder(Base, TimestampMixin): unique=True, postgresql_where=text("name IS NOT NULL"), ), + # One Hub folder per user. + Index("uq_file_folder_hub", "user_id", unique=True, postgresql_where=text("kind = 'hub'")), CheckConstraint( "(kind = 'source' AND source_key IS NOT NULL AND name IS NULL)" - " OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)", + " OR (kind = 'own' AND name IS NOT NULL AND source_key IS NULL)" + " OR (kind = 'hub' AND name IS NULL AND source_key IS NULL)", name="ck_file_folder_kind", ), ) @@ -949,6 +953,45 @@ class FileFolder(Base, TimestampMixin): retention_days: Mapped[int | None] = mapped_column(Integer) +class ApiToken(Base, TimestampMixin): + """A user's token for a machine to reach their Files library with (`app.apitokens`): sent as + `Authorization: Bearer sfl_…`, honoured on `/api/files` only, and only in the folders its + grants name. Only the SHA-256 of the token is kept — it is shown once, when it is made — plus + its first characters (`prefix`), so the user can tell their tokens apart.""" + + __tablename__ = "api_tokens" + + id: Mapped[int] = mapped_column(primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True) + name: Mapped[str] = mapped_column(String(80)) + prefix: Mapped[str] = mapped_column(String(16)) + token_hash: Mapped[str] = mapped_column(String(64), unique=True) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) # NULL = never + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ApiTokenGrant(Base): + """What one token may do in one folder. A folder with no grant is out of the token's reach.""" + + __tablename__ = "api_token_grants" + __table_args__ = ( + UniqueConstraint("token_id", "folder_id", name="uq_api_token_grant"), + CheckConstraint( + "can_list OR can_read OR can_write OR can_delete", name="ck_api_token_grant_some" + ), + ) + + id: Mapped[int] = mapped_column(primary_key=True) + token_id: Mapped[int] = mapped_column(ForeignKey("api_tokens.id", ondelete="CASCADE")) + folder_id: Mapped[int] = mapped_column( + ForeignKey("file_folders.id", ondelete="CASCADE"), index=True + ) + can_list: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + can_read: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + can_write: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + can_delete: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false") + + def _ready_on_insert(context) -> datetime | None: """A job inserted already done (an upload kept as it came) was ready the moment it was made; one that becomes done later is stamped then (`folders.stamp_done`, `service.finalize_if_running`).""" diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index 25be682b..109b4607 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -7,6 +7,11 @@ bytes. Folders are rows (`downloads.folders`), so a rename or a move never touch In-flight jobs are not here: they are the download tray's (`routes.downloads`). The helpers above the routes are shared with that module — serialization, the ownership locks, the upload spool — because a download and a library file are one row at two moments. + +A machine with an API token (`app.apitokens`) may use the routes that take `files_caller`: list the +folders and files, fetch a file and its hash, upload, delete — each only in a folder its token was +granted that right in, and never the files shared with the user. A folder or file out of its reach +answers like a missing one. Everything else here is the signed-in user's alone (`current_user`). """ import base64 @@ -29,6 +34,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app import audit, sysconfig +from app.apitokens import FilesCaller, Right, files_caller from app.audit import AuditAction from app.auth import current_user from app.collation import collated @@ -208,7 +214,7 @@ UPLOAD_FOLDER_HEADER = "x-upload-folder" @dataclass(frozen=True) class SpooledUpload: """What the body dependency hands the handler: the bytes already on disk and their hash, plus - the names and the folder that came alongside them.""" + the names and the folder that came alongside them, and who sent them.""" path: Path size: int @@ -217,6 +223,7 @@ class SpooledUpload: filename: str display_name: str | None folder_id: int | None + via: str | None = None def _header_text(request: Request, name: str) -> str: @@ -233,15 +240,21 @@ def _header_text(request: Request, name: str) -> str: return "" -def _upload_folder(db: Session, user_id: int, raw: str) -> int | None: +def _upload_folder(db: Session, caller: FilesCaller, raw: str) -> int | None: """The folder id an upload asked for (`UPLOAD_FOLDER_HEADER`), checked; None when it asked for - none.""" + none — which a token may not: it writes only where it was granted to.""" if not raw.strip(): + if caller.grants is not None: + raise refused(400, "api_token_folder_required") return None try: - return folders.upload_target(db, user_id, int(raw), hold=False).id + folder_id = int(raw) except ValueError as e: raise refused(409, "file_folder_gone") from e + if not caller.may(folder_id, Right.WRITE): + raise not_found("folder") + try: + return folders.upload_target(db, caller.user.id, folder_id, hold=False).id except folders.FolderError as e: raise refused(409, e.reason) from e @@ -260,24 +273,26 @@ class _HashingSink: async def spool_upload( request: Request, - user: User = Depends(current_user), + caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db), ) -> AsyncIterator[SpooledUpload]: """Stream the request body to a staging temp file, enforcing the per-file cap as it arrives. - `user` is taken but unused: it is the whole point. Dependencies resolve in declaration order, so - naming `current_user` here makes the auth check a precondition of reading the body at all — a - stranger is refused before a byte moves. The `yield` shape owns the temp file's life: ingest - MOVES it into place on success (the unlink is then a no-op), and on any other exit — cap hit, - empty, quota, probe or move error, a client that vanished mid-upload — it is removed.""" + `caller` comes first on purpose. Dependencies resolve in declaration order, so naming + `files_caller` here makes the auth check a precondition of reading the body at all — a stranger, + or a token without the right to write in that folder, is refused before a byte moves. The + `yield` shape owns the temp file's life: ingest MOVES it into place on success (the unlink is + then a no-op), and on any other exit — cap hit, empty, quota, probe or move error, a client that + vanished mid-upload — it is removed.""" cap = sysconfig.get_int(db, "download_upload_max_bytes") - user_id = user.id + user_id = caller.user.id + via = caller.via # A folder the file may not go in is refused before the transfer, not after it. - folder_id = _upload_folder(db, user_id, request.headers.get(UPLOAD_FOLDER_HEADER, "")) + folder_id = _upload_folder(db, caller, request.headers.get(UPLOAD_FOLDER_HEADER, "")) # Hand the pooled connection back BEFORE the transfer starts — see `db.release_read_connection` # for why, and for the precondition it enforces. Everything this dependency still needs is - # already an int or a header in hand: touching an ORM attribute below would reload `user` and - # check a connection straight back out. + # already an int, a string or a header in hand: touching an ORM attribute below (`caller.user`) + # would reload it and check a connection straight back out. release_read_connection(db) staging = Path(settings.files_root) / ".staging" / "uploads" staging.mkdir(parents=True, exist_ok=True) @@ -300,6 +315,7 @@ async def spool_upload( filename=_header_text(request, UPLOAD_FILENAME_HEADER) or "upload", display_name=display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER)), folder_id=folder_id, + via=via, ) finally: tmp_path.unlink(missing_ok=True) @@ -322,6 +338,17 @@ def accessible_job(db: Session, user: User, job_id: int) -> DownloadJob: return job +def readable_job(db: Session, caller: FilesCaller, job_id: int) -> DownloadJob: + """A file `caller` may read: as `accessible_job` for the user; for a token, one of the user's + own in a folder it may read.""" + if caller.grants is None: + return accessible_job(db, caller.user, job_id) + job = db.get(DownloadJob, job_id) + if job is None or job.user_id != caller.user.id or not caller.may(job.folder_id, Right.READ): + raise not_found("download") + return job + + # --- folders ------------------------------------------------------------------------------- @@ -340,19 +367,24 @@ def _serialize_folder(folder: FileFolder, count: int, size: int) -> dict: @router.get("/folders") -def list_folders(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: - """The caller's folders with how many files each holds and their size. A source folder its - files have all left stays (count 0): the client decides whether to show it.""" +def list_folders( + caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db) +) -> list[dict]: + """The caller's folders with how many files each holds and their size (a token: the ones it may + list). A source folder its files have all left stays (count 0): the client decides whether to + show it.""" done = (DownloadJob.folder_id == FileFolder.id) & (DownloadJob.status == JobStatus.DONE) + stmt = select( + FileFolder, + func.count(DownloadJob.id), + func.coalesce(func.sum(MediaAsset.size_bytes), 0), + ).where(FileFolder.user_id == caller.user.id) + listable = caller.folders_with(Right.LIST) + if listable is not None: + stmt = stmt.where(FileFolder.id.in_(listable)) rows = db.execute( - select( - FileFolder, - func.count(DownloadJob.id), - func.coalesce(func.sum(MediaAsset.size_bytes), 0), - ) - .outerjoin(DownloadJob, done) + stmt.outerjoin(DownloadJob, done) .outerjoin(MediaAsset, MediaAsset.id == DownloadJob.asset_id) - .where(FileFolder.user_id == user.id) .group_by(FileFolder.id) .order_by(FileFolder.kind.desc(), FileFolder.source_key, FileFolder.name, FileFolder.id) ).all() @@ -520,12 +552,20 @@ def list_files( sort: Literal["added", "name", "size"] = "added", order: Literal["asc", "desc"] = "desc", scope: Literal["mine", "shared"] = "mine", - user: User = Depends(current_user), + caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db), ) -> list[dict]: """The caller's done files (`scope=mine`, optionally one folder's), or the ones shared with them (`scope=shared`); searched by name and channel, sorted by when they were added, by name - (in the reader's alphabet) or by size.""" + (in the reader's alphabet) or by size. A token lists only in the folders it may list, and never + the shared ones.""" + user = caller.user + listable = caller.folders_with(Right.LIST) + if listable is not None: + if scope == "shared": + raise refused(403, "api_token_scope") + if folder_id is not None and folder_id not in listable: + raise not_found("folder") stmt = ( select(DownloadJob) .join(MediaAsset, MediaAsset.id == DownloadJob.asset_id) @@ -540,6 +580,8 @@ def list_files( stmt = stmt.where(DownloadJob.user_id == user.id) if folder_id is not None: stmt = stmt.where(DownloadJob.folder_id == folder_id) + elif listable is not None: + stmt = stmt.where(DownloadJob.folder_id.in_(listable)) name = func.coalesce(DownloadJob.display_name, MediaAsset.title, "") term = q.strip() if term: @@ -693,11 +735,23 @@ def update_file( @router.delete("/{job_id}") def delete_file( - job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) + job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db) ) -> dict: - """Delete a file from the caller's library; the bytes go with their last holder.""" - job = own_job_for_update(db, user, job_id) - audit.record(db, user.id, AuditAction.FILE_DELETE, target_type="file", target_id=job_id) + """Delete a file from the caller's library; the bytes go with their last holder. A token + deletes only in a folder it may delete in — judged on the row as locked, so a file moved out of + that folder meanwhile is out of its reach.""" + job = own_job_for_update(db, caller.user, job_id) + if not caller.may(job.folder_id, Right.DELETE): + db.rollback() # lets go of the locks; nothing was written + raise not_found("download") + audit.record( + db, + caller.user.id, + AuditAction.FILE_DELETE, + target_type="file", + target_id=job_id, + summary=caller.via, + ) service.delete(db, job) # commits, the audit row with it return {"ok": True, "deleted": job_id} @@ -732,6 +786,7 @@ def ingest_spooled(db: Session, upload: SpooledUpload) -> DownloadJob: display_name=upload.display_name, sha256=upload.sha256, folder_id=upload.folder_id, + via=upload.via, ) except quota.QuotaExceeded as e: raise HTTPException(status_code=422, detail=quota_detail(e)) from e @@ -782,8 +837,10 @@ def serve_file(db: Session, job: DownloadJob) -> FileResponse: @router.get("/{job_id}/file") -def get_file(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): - response = serve_file(db, accessible_job(db, user, job_id)) +def get_file( + job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db) +): + response = serve_file(db, readable_job(db, caller, job_id)) db.commit() return response @@ -840,13 +897,13 @@ def preview_file( @router.get("/{job_id}/sha256") def get_sha256( - job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) + job_id: int, caller: FilesCaller = Depends(files_caller), db: Session = Depends(get_db) ) -> dict: """The file's SHA-256 (hex), hashed now if it never was and kept on the asset for every holder. A sync handler: hashing a large file is blocking disk work, so it runs in the threadpool — and every other sync route shares that pool, so only `_HASHERS` files are hashed at a time (a caller past that is told to come back), and none of them holds a database connection while it reads.""" - asset, path = _ready_file(db, accessible_job(db, user, job_id)) + asset, path = _ready_file(db, readable_job(db, caller, job_id)) if asset.sha256: return {"sha256": asset.sha256} asset_id = asset.id diff --git a/backend/app/routes/tokens.py b/backend/app/routes/tokens.py new file mode 100644 index 00000000..6e0bbfee --- /dev/null +++ b/backend/app/routes/tokens.py @@ -0,0 +1,177 @@ +"""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} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f2e1b127..32e614ae 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -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 diff --git a/backend/tests/test_api_tokens.py b/backend/tests/test_api_tokens.py new file mode 100644 index 00000000..4ff8e29c --- /dev/null +++ b/backend/tests/test_api_tokens.py @@ -0,0 +1,378 @@ +"""API tokens for the Files library (`app.apitokens`, `routes.tokens`), DB-lane. + +A token is made, listed and revoked by its signed-in user only; its value is in the answer that made +it and nowhere after. With it a machine reaches `/api/files` and nothing else, and there only the +folders its grants name, with the rights they give — every right checked in a folder it has and in +one it has not. A token that is malformed, unknown, expired, revoked, or whose account is suspended +holds nothing, and a client trying tokens is slowed down. +""" + +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import select + +from app import apitokens +from app.apitokens import files_caller +from app.downloads import folders, service +from app.downloads import upload as uploadmod +from app.downloads.status import JobStatus +from app.config import settings +from app.main import app +from app.models import ApiToken, ApiTokenGrant, AuditLog, DownloadJob, FileFolder, MediaAsset +from app.ratelimit import RateLimiter +from app.routes import tokens as tokens_routes + + +@pytest.fixture +def _root(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + return tmp_path + + +@pytest.fixture +def _no_probe(monkeypatch): + # A file ffprobe cannot read: a generic file, done at once (no conversion). + monkeypatch.setattr(uploadmod.probe, "probe_json", lambda _p: None) + + +def _own(db, user_id, name): + folder = FileFolder(user_id=user_id, kind=folders.FolderKind.OWN, name=name) + db.add(folder) + db.commit() + return folder + + +def _file_in(db, root, user_id, folder, name): + """A done file of `user_id`'s with bytes on disk, sitting in `folder`.""" + asset = MediaAsset( + source_kind="upload", + source_ref=name, + format_sig=f"sig-{name}", + status="ready", + rel_path=f"{name}.bin", + size_bytes=4, + ref_count=0, + title=name, + media_kind="file", + container="bin", + ) + db.add(asset) + db.flush() + job = DownloadJob( + user_id=user_id, + asset_id=asset.id, + source_kind="upload", + source_ref=name, + profile_snapshot={}, + status=JobStatus.DONE, + ) + db.add(job) + db.flush() + folders.place(db, job) + job.folder_id = folder.id + folders.stamp_done(db, job) + service.incr_ref_count(db, asset.id) + db.commit() + (root / f"{name}.bin").write_bytes(b"data") + return job + + +def _bearer(raw: str) -> TestClient: + return TestClient(app, headers={"authorization": f"Bearer {raw}"}) + + +def _make(c, **body) -> dict: + r = c.post("/api/tokens", json={"name": "a machine", **body}) + assert r.status_code == 200, r.text + return r.json() + + +def _audits(db, action): + return db.execute(select(AuditLog).where(AuditLog.action == action)).scalars().all() + + +# --- the token itself ---------------------------------------------------------------------------- + + +def test_a_token_has_its_shape_and_a_checksum_that_refuses_a_typo(): + raw = apitokens.mint() + assert raw.startswith("sfl_") and len(raw) == apitokens.TOKEN_LEN + assert apitokens.well_formed(raw) + assert apitokens.mint() != raw + flipped = raw[:10] + ("A" if raw[10] != "A" else "B") + raw[11:] + assert not apitokens.well_formed(flipped) + assert not apitokens.well_formed(raw[:-1]) + assert not apitokens.well_formed("xxx_" + raw[4:]) + + +def test_the_hub_preset_makes_one_hub_folder_with_every_right_and_shows_the_token_once( + db, make_user, as_user +): + alice = make_user() + c = as_user(alice) + made = _make(c, preset="hub") + raw = made["token"] + assert apitokens.well_formed(raw) and made["prefix"] == raw[: apitokens.SHOWN_LEN] + [grant] = made["grants"] + assert grant["kind"] == "hub" and grant["rights"] == ["delete", "list", "read", "write"] + # Only the hash is kept, and the value is not in the list. + stored = db.execute(select(ApiToken)).scalar_one() + assert stored.token_hash != raw and raw not in str(c.get("/api/tokens").json()) + # A second hub token reuses the one Hub folder. + again = _make(c, preset="hub") + assert again["grants"][0]["folder_id"] == grant["folder_id"] + hubs = db.execute(select(FileFolder).where(FileFolder.kind == "hub")).scalars().all() + assert len(hubs) == 1 + assert len(_audits(db, "api_token_create")) == 2 + + +def test_a_token_is_granted_only_the_users_own_folders_and_writes_only_where_uploads_land( + db, make_user, as_user +): + alice, bob = make_user(), make_user() + theirs = _own(db, bob.id, "Bob's") + c = as_user(alice) + r = c.post( + "/api/tokens", + json={"name": "x", "grants": [{"folder_id": theirs.id, "rights": ["read"]}]}, + ) + assert r.status_code == 409 and r.json()["detail"]["reason"] == "file_folder_gone" + youtube = folders.source_folder(db, alice.id, folders.SourceKey.YOUTUBE) + db.commit() + r = c.post( + "/api/tokens", + json={"name": "x", "grants": [{"folder_id": youtube.id, "rights": ["write"]}]}, + ) + assert r.json()["detail"]["reason"] == "file_folder_not_its_source" + assert c.post("/api/tokens", json={"name": "x"}).json()["detail"]["reason"] == ( + "api_token_no_grants" + ) + assert db.execute(select(ApiToken)).all() == [] + + +def test_a_user_has_at_most_the_ceiling_of_tokens(db, make_user, as_user, monkeypatch): + monkeypatch.setattr(tokens_routes, "MAX_TOKENS", 2) + c = as_user(make_user()) + _make(c, preset="hub") + _make(c, preset="hub") + r = c.post("/api/tokens", json={"name": "third", "preset": "hub"}) + assert r.status_code == 409 and r.json()["detail"]["reason"] == "api_token_limit" + + +# --- what a token reaches ------------------------------------------------------------------------ + + +@pytest.mark.parametrize("right", ["list", "read", "write", "delete"]) +def test_each_right_holds_in_its_folder_only( + db, make_user, as_user, _root, _no_probe, upload_headers, right +): + """One right in folder A; folder B has none. Every Files route a token may use, in both.""" + alice = make_user() + a, b = _own(db, alice.id, "A"), _own(db, alice.id, "B") + in_a, in_b = _file_in(db, _root, alice.id, a, "fa"), _file_in(db, _root, alice.id, b, "fb") + raw = _make(as_user(alice), grants=[{"folder_id": a.id, "rights": [right]}])["token"] + m = _bearer(raw) + + folders_seen = [f["id"] for f in m.get("/api/files/folders").json()] + assert folders_seen == ([a.id] if right == "list" else []) + listed = m.get("/api/files", params={"folder_id": a.id}) + assert listed.status_code == (200 if right == "list" else 404) + if right == "list": + assert [f["id"] for f in listed.json()] == [in_a.id] + assert [f["id"] for f in m.get("/api/files").json()] == [in_a.id] + else: + assert m.get("/api/files").json() == [] + assert m.get("/api/files", params={"folder_id": b.id}).status_code == 404 + + read_a = m.get(f"/api/files/{in_a.id}/file") + assert read_a.status_code == (200 if right == "read" else 404) + assert m.get(f"/api/files/{in_a.id}/sha256").status_code == (200 if right == "read" else 404) + assert m.get(f"/api/files/{in_b.id}/file").status_code == 404 + + up_a = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt", folder=a.id)) + assert up_a.status_code == (200 if right == "write" else 404) + if right == "write": + assert up_a.json()["folder_id"] == a.id + up_b = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt", folder=b.id)) + assert up_b.status_code == 404 + no_folder = m.post("/api/files/upload", content=b"hi", headers=upload_headers("n.txt")) + assert no_folder.status_code == 400 + assert no_folder.json()["detail"]["reason"] == "api_token_folder_required" + + assert m.delete(f"/api/files/{in_b.id}").status_code == 404 + assert db.get(DownloadJob, in_b.id) is not None + del_a = m.delete(f"/api/files/{in_a.id}") + assert del_a.status_code == (200 if right == "delete" else 404) + db.expire_all() + assert (db.get(DownloadJob, in_a.id) is None) == (right == "delete") + + +def test_a_token_never_reaches_another_users_files_or_the_shared_ones( + db, make_user, as_user, _root +): + alice, bob = make_user(), make_user() + hub_raw = _make(as_user(alice), preset="hub")["token"] + bobs = _file_in(db, _root, bob.id, _own(db, bob.id, "B"), "bf") + m = _bearer(hub_raw) + assert m.get(f"/api/files/{bobs.id}/file").status_code == 404 + assert m.delete(f"/api/files/{bobs.id}").status_code == 404 + shared = m.get("/api/files", params={"scope": "shared"}) + assert shared.status_code == 403 and shared.json()["detail"]["reason"] == "api_token_scope" + + +def test_a_token_is_judged_by_its_grants_even_beside_a_signed_in_cookie(db, make_user, client): + """Through the real session stack (`client`, a real sign-in): the `as_user` fixture stands in + for the very dependency this is about.""" + alice = make_user(email="alice@example.com") + a = _own(db, alice.id, "A") + c = client + signed_in = c.post( + "/auth/password-login", + json={"email": "alice@example.com", "password": "correct horse battery"}, + ) + assert signed_in.status_code == 200 + raw = _make(c, grants=[{"folder_id": a.id, "rights": ["read"]}])["token"] + assert len(c.get("/api/files/folders").json()) == 1 # the user sees every folder + both = c.get("/api/files/folders", headers={"authorization": f"Bearer {raw}"}) + assert both.json() == [] # the token may not list: its grants decide + + +def test_a_token_works_on_the_files_routes_only(db, make_user, as_user): + alice = make_user() + raw = _make(as_user(alice), preset="hub")["token"] + m = _bearer(raw) + assert m.get("/api/files/folders").status_code == 200 + # Making a wider token, or reading the account, takes the user's session. + assert m.get("/api/tokens").status_code == 401 + assert m.post("/api/tokens", json={"name": "wider", "preset": "hub"}).status_code == 401 + assert m.get("/api/files/usage").status_code == 401 + assert m.patch("/api/files/1", json={"name": "x"}).status_code == 401 + + +def test_only_the_files_routes_take_a_token(): + """The route table, not a list kept by hand: every route that resolves `files_caller` is under + /api/files, and these are all of them. The walk recurses into included routers (see + `test_body_limit._declared_routes` for why `app.routes` alone sees none of ours).""" + takers, nodes, routes = set(), [app], [] + while nodes: + for route in getattr(nodes.pop(), "routes", ()) or (): + inner = getattr(route, "original_router", None) + if inner is not None: + nodes.append(inner) + else: + routes.append(route) + assert len(routes) > 100 # the walk found the app's routes, not just the shell's + for route in routes: + dependant = getattr(route, "dependant", None) + if dependant is None: + continue + stack, calls = [dependant], set() + while stack: + d = stack.pop() + calls.add(d.call) + stack.extend(d.dependencies) + if files_caller in calls: + for method in route.methods: + takers.add((method, route.path)) + assert all(path.startswith("/api/files") for _, path in takers) + assert takers == { + ("GET", "/api/files/folders"), + ("GET", "/api/files"), + ("GET", "/api/files/{job_id}/file"), + ("GET", "/api/files/{job_id}/sha256"), + ("POST", "/api/files/upload"), + ("DELETE", "/api/files/{job_id}"), + } + + +# --- a token that does not hold ------------------------------------------------------------------ + + +def _refused(m: TestClient) -> int: + return m.get("/api/files/folders").status_code + + +def test_a_malformed_unknown_expired_revoked_or_suspended_token_holds_nothing( + db, make_user, as_user +): + alice = make_user() + c = as_user(alice) + raw = _make(c, preset="hub")["token"] + assert _refused(_bearer(raw)) == 200 + assert _refused(_bearer("sfl_short")) == 401 + assert _refused(_bearer(apitokens.mint())) == 401 # well formed, never made + + expiring = _make(c, preset="hub", expires_days=1) + token = db.get(ApiToken, expiring["id"]) + token.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + db.commit() + assert _refused(_bearer(expiring["token"])) == 401 + assert c.get("/api/tokens").json()[1]["expired"] is True + + assert c.delete(f"/api/tokens/{expiring['id']}").json() == { + "ok": True, + "deleted": expiring["id"], + } + assert ( + db.execute(select(ApiTokenGrant).where(ApiTokenGrant.token_id == expiring["id"])).all() + == [] + ) + assert len(_audits(db, "api_token_revoke")) == 1 + revoked = _make(c, preset="hub") + assert c.delete(f"/api/tokens/{revoked['id']}").status_code == 200 + assert _refused(_bearer(revoked["token"])) == 401 + + alice.is_suspended = True + db.commit() + assert _refused(_bearer(raw)) == 401 + + +def test_another_users_token_cannot_be_revoked(db, make_user, as_user): + alice, bob = make_user(), make_user() + made = _make(as_user(alice), preset="hub") + assert as_user(bob).delete(f"/api/tokens/{made['id']}").status_code == 404 + assert db.get(ApiToken, made["id"]) is not None + + +def test_a_client_trying_tokens_is_slowed_down(db, make_user, as_user, monkeypatch): + alice = make_user() + raw = _make(as_user(alice), preset="hub")["token"] + monkeypatch.setattr(apitokens, "_bad", RateLimiter(3, 300)) + for _ in range(3): + assert _refused(_bearer(apitokens.mint())) == 401 + slowed = _bearer(apitokens.mint()).get("/api/files/folders") + assert slowed.status_code == 429 + assert slowed.json()["detail"]["reason"] == "api_token_rate_limited" + # Even a good token from that client waits out the window: a guesser cannot tell a hit. + assert _refused(_bearer(raw)) == 429 + + +def test_a_runaway_machine_is_held_back_per_token(db, make_user, as_user, monkeypatch): + c = as_user(make_user()) + first, second = _make(c, preset="hub")["token"], _make(c, preset="hub")["token"] + monkeypatch.setattr(apitokens, "_calls", RateLimiter(2, 60)) + assert [_refused(_bearer(first)) for _ in range(3)] == [200, 200, 429] + assert _refused(_bearer(second)) == 200 + + +# --- what a token leaves behind ------------------------------------------------------------------ + + +def test_a_tokens_use_is_stamped_and_its_uploads_and_deletes_name_it_in_the_audit( + db, make_user, as_user, _root, _no_probe, upload_headers +): + alice = make_user() + made = _make(as_user(alice), preset="hub") + hub = made["grants"][0]["folder_id"] + assert db.get(ApiToken, made["id"]).last_used_at is None + m = _bearer(made["token"]) + up = m.post("/api/files/upload", content=b"hi", headers=upload_headers("h.txt", folder=hub)) + assert up.status_code == 200 + db.expire_all() + assert db.get(ApiToken, made["id"]).last_used_at is not None + assert m.delete(f"/api/files/{up.json()['id']}").status_code == 200 + via = f"api token {made['prefix']}…" + assert [a.summary for a in _audits(db, "file_upload")] == [via] + assert [a.summary for a in _audits(db, "file_delete")] == [via] diff --git a/backend/tests/test_download_job_locks.py b/backend/tests/test_download_job_locks.py index ada7e477..0d69faf5 100644 --- a/backend/tests/test_download_job_locks.py +++ b/backend/tests/test_download_job_locks.py @@ -15,6 +15,7 @@ import pytest from fastapi import HTTPException from sqlalchemy import select +from app.apitokens import FilesCaller from app.db import SessionLocal from app.downloads import service from app.models import DownloadJob, MediaAsset, User @@ -50,11 +51,16 @@ def _asset_held_by_two_jobs(db, status="error"): return mine, asset, jobs[0] +def delete_file_as(job_id, *, user, db): + """`delete_file` as the user's own request (it takes the Files caller, not the user).""" + return delete_file(job_id, caller=FilesCaller(user=user), db=db) + + @pytest.mark.parametrize( ("status", "transition", "route", "left"), [ ("error", service.cancel, cancel_download, "canceled"), - ("error", service.delete, delete_file, None), + ("error", service.delete, delete_file_as, None), ("queued", service.cancel, pause_download, "canceled"), ], ) diff --git a/frontend/src/i18n/locales/en/auditActions.json b/frontend/src/i18n/locales/en/auditActions.json index c12f7b0a..9d702ee3 100644 --- a/frontend/src/i18n/locales/en/auditActions.json +++ b/frontend/src/i18n/locales/en/auditActions.json @@ -31,5 +31,7 @@ "file_folder_create": "Folder created", "file_folder_rename": "Folder renamed", "file_folder_retention": "Folder retention changed", - "file_folder_delete": "Folder deleted" + "file_folder_delete": "Folder deleted", + "api_token_create": "API token created", + "api_token_revoke": "API token revoked" } diff --git a/frontend/src/i18n/locales/en/errors.json b/frontend/src/i18n/locales/en/errors.json index 9da019e3..55224226 100644 --- a/frontend/src/i18n/locales/en/errors.json +++ b/frontend/src/i18n/locales/en/errors.json @@ -38,7 +38,8 @@ "profile": "This download profile doesn't exist any more", "season": "This season isn't in the library any more", "show": "This show isn't in the library any more", - "folder": "That folder no longer exists" + "folder": "That folder no longer exists", + "api_token": "That API token no longer exists" }, "admin": { "not_allowed": "Their address isn't approved any more. Add it to the whitelist first.", @@ -239,6 +240,13 @@ "file_folder_gone": "That folder no longer exists", "file_hash_busy": "Too many files are being checked right now — try again in a moment", "file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from", - "file_no_preview": "There is no preview for this file" + "file_no_preview": "There is no preview for this file", + "api_token_rate_limited": "Too many requests with API tokens — wait a few minutes", + "api_token_folder_required": "An API token must name the folder it uploads into", + "api_token_scope": "An API token cannot reach the files shared with you", + "api_token_preset_grants": "The Hub token gets its folder by itself — leave the folders out", + "api_token_no_grants": "Choose at least one folder the token may use", + "api_token_name_required": "Give the token a name", + "api_token_limit": "You already have {{limit}} tokens — revoke one first" } } diff --git a/frontend/src/i18n/locales/hu/auditActions.json b/frontend/src/i18n/locales/hu/auditActions.json index 3ef023cc..0f4f8081 100644 --- a/frontend/src/i18n/locales/hu/auditActions.json +++ b/frontend/src/i18n/locales/hu/auditActions.json @@ -31,5 +31,7 @@ "file_folder_create": "Mappa létrehozva", "file_folder_rename": "Mappa átnevezve", "file_folder_retention": "Mappa megőrzési ideje módosítva", - "file_folder_delete": "Mappa törölve" + "file_folder_delete": "Mappa törölve", + "api_token_create": "API-token létrehozva", + "api_token_revoke": "API-token visszavonva" } diff --git a/frontend/src/i18n/locales/hu/errors.json b/frontend/src/i18n/locales/hu/errors.json index 97d11cac..1e9c15aa 100644 --- a/frontend/src/i18n/locales/hu/errors.json +++ b/frontend/src/i18n/locales/hu/errors.json @@ -38,7 +38,8 @@ "profile": "Ez a letöltési profil már nem létezik", "season": "Ez az évad már nincs a könyvtárban", "show": "Ez a sorozat már nincs a könyvtárban", - "folder": "Ez a mappa már nem létezik" + "folder": "Ez a mappa már nem létezik", + "api_token": "Ez az API-token már nem létezik" }, "admin": { "not_allowed": "A címe már nincs jóváhagyva. Előbb vedd fel a fehérlistára.", @@ -239,6 +240,13 @@ "file_folder_gone": "Ez a mappa már nem létezik", "file_hash_busy": "Most túl sok fájl ellenőrzése fut — próbáld újra kicsit később", "file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet", - "file_no_preview": "Ehhez a fájlhoz nincs előnézet" + "file_no_preview": "Ehhez a fájlhoz nincs előnézet", + "api_token_rate_limited": "Túl sok kérés érkezett API-tokennel — várj néhány percet", + "api_token_folder_required": "Az API-tokennek meg kell neveznie a mappát, amelybe feltölt", + "api_token_scope": "API-tokennel nem érhetők el a veled megosztott fájlok", + "api_token_preset_grants": "A Hub-token magától kapja a mappáját — ne adj meg mappát", + "api_token_no_grants": "Válassz legalább egy mappát, amelyet a token használhat", + "api_token_name_required": "Adj nevet a tokennek", + "api_token_limit": "Már {{limit}} tokened van — előbb vonj vissza egyet" } }