terp-cap-files 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- terp_cap_files-0.1.0/.gitignore +47 -0
- terp_cap_files-0.1.0/PKG-INFO +8 -0
- terp_cap_files-0.1.0/escape-hatch-budget.json +3 -0
- terp_cap_files-0.1.0/pyproject.toml +32 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/__init__.py +101 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/migrations/versions/ceffeb4b0fc2_create_file_table.py +54 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/migrations/versions/e3a9c47d51b8_add_file_storage_profile.py +40 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/models.py +66 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/py.typed +0 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/references.py +82 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/router.py +221 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/schemas.py +70 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/service.py +423 -0
- terp_cap_files-0.1.0/src/terp/capabilities/files/storage.py +195 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
.eggs/
|
|
6
|
+
build/
|
|
7
|
+
dist/
|
|
8
|
+
.venv/
|
|
9
|
+
.venv-*/
|
|
10
|
+
venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.mypy_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
.coverage
|
|
15
|
+
htmlcov/
|
|
16
|
+
|
|
17
|
+
# uv
|
|
18
|
+
uv.lock
|
|
19
|
+
|
|
20
|
+
# Node
|
|
21
|
+
node_modules/
|
|
22
|
+
.pnpm-store/
|
|
23
|
+
*.tsbuildinfo
|
|
24
|
+
|
|
25
|
+
# Playwright (conformance e2e) artifacts
|
|
26
|
+
test-results/
|
|
27
|
+
playwright-report/
|
|
28
|
+
blob-report/
|
|
29
|
+
playwright/.cache/
|
|
30
|
+
.last-run.json
|
|
31
|
+
|
|
32
|
+
# Local frontend template render checks
|
|
33
|
+
apps/example/_frontend_tpl_check/
|
|
34
|
+
|
|
35
|
+
# Editor / OS
|
|
36
|
+
.DS_Store
|
|
37
|
+
.idea/
|
|
38
|
+
*.local
|
|
39
|
+
|
|
40
|
+
# Local environment overrides — never commit (a real .env may hold SECRET_KEY).
|
|
41
|
+
# The tracked template is `.env.example`.
|
|
42
|
+
.env
|
|
43
|
+
.env.*
|
|
44
|
+
!.env.example
|
|
45
|
+
!.env.example.jinja
|
|
46
|
+
# Rendered app-declared variables (environment.schema.json) — may hold secrets.
|
|
47
|
+
.app.env
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: terp-cap-files
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terp files capability — owner-scoped file objects with metadata in the platform database and bytes behind a pluggable storage backend.
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Requires-Python: >=3.13
|
|
7
|
+
Requires-Dist: python-multipart>=0.0.30
|
|
8
|
+
Requires-Dist: terp-core==0.1.0
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "terp-cap-files"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Terp files capability — owner-scoped file objects with metadata in the platform database and bytes behind a pluggable storage backend."
|
|
9
|
+
requires-python = ">=3.13"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"terp-core==0.1.0",
|
|
13
|
+
# FastAPI parses the multipart upload body through this (UploadFile); the byte
|
|
14
|
+
# stream never touches app code — it flows metadata->DB, bytes->StorageBackend.
|
|
15
|
+
"python-multipart>=0.0.30",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
# Self-registering: the kernel discovers this ModuleSpec via the entry point, mounting the
|
|
19
|
+
# admin files router at `/api/v1/files` without any composition-root edit.
|
|
20
|
+
[project.entry-points."terp.capabilities"]
|
|
21
|
+
files = "terp.capabilities.files:module"
|
|
22
|
+
|
|
23
|
+
# Owns the `file_object` metadata table, so it ships an independent, linear Alembic
|
|
24
|
+
# history (its own `alembic_version_files` table). `terp migrate` discovers this via
|
|
25
|
+
# the `terp.migrations` group (ADR 0027).
|
|
26
|
+
[project.entry-points."terp.migrations"]
|
|
27
|
+
files = "terp.capabilities.files"
|
|
28
|
+
|
|
29
|
+
# PEP 420 namespace package: this distribution owns only `terp.capabilities.files`.
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
sources = ["src"]
|
|
32
|
+
only-include = ["src/terp/capabilities/files"]
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""terp.capabilities.files — owner-scoped file objects on a pluggable storage backend.
|
|
2
|
+
|
|
3
|
+
The last of the planned foundation capabilities (design §3.1, §6): an app stores and
|
|
4
|
+
retrieves **file objects** through a maintained, secure-by-default surface, with all
|
|
5
|
+
*metadata* owned in the platform database and the *bytes* behind a tiny storage port.
|
|
6
|
+
|
|
7
|
+
* :class:`File` (``BaseTable`` + ``OwnedMixin``) is the metadata row — name, type, size,
|
|
8
|
+
digest, the server-generated ``storage_key`` addressing the bytes, and the
|
|
9
|
+
``storage_profile`` naming the backend that holds them. Both are server-side-only
|
|
10
|
+
material: no Read DTO ever serializes them.
|
|
11
|
+
* :class:`StorageBackend` is the pluggable byte-store seam (``put`` / ``open`` /
|
|
12
|
+
``delete``, streamed through readable binary objects — ADR 0066) behind a
|
|
13
|
+
**named-profile registry** (ADR 0057): the ``"default"`` profile
|
|
14
|
+
is the shipped :class:`LocalFilesystemStorage` reference adapter, and a deployment
|
|
15
|
+
installs any provider — another root, S3, Azure Blob, a NAS mount — under one or many
|
|
16
|
+
profiles (one per container / module use) with one :func:`register_storage_backend`
|
|
17
|
+
line each; resolution (:func:`resolve_storage_backend`) is fail-closed
|
|
18
|
+
(:class:`UnknownStorageProfileError`). A service or call selects a store by profile
|
|
19
|
+
name; a client never does.
|
|
20
|
+
* :func:`FileRef` declares a model column that references a stored file, and
|
|
21
|
+
:meth:`FileService.load_for` is the serve-through delegation read: a module serves a
|
|
22
|
+
file through its **own**, already-authorized row, fail-closed on any undeclared
|
|
23
|
+
reference (:class:`UndeclaredFileReferenceError`; build-time twin: the
|
|
24
|
+
``no_raw_file_references`` rule).
|
|
25
|
+
* The discovered, admin-only router at ``/api/v1/files`` uploads, downloads, lists
|
|
26
|
+
(``Page[T]``), renames, and deletes; ``OwnedMixin`` makes edit / delete owner-gated
|
|
27
|
+
centrally in ``BaseService`` with zero module code.
|
|
28
|
+
|
|
29
|
+
It depends only on ``terp-core`` (plus the multipart parser FastAPI needs for uploads) —
|
|
30
|
+
never a sibling capability or a storage engine SDK.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from terp.capabilities.files.models import File
|
|
36
|
+
from terp.capabilities.files.references import (
|
|
37
|
+
FileRef,
|
|
38
|
+
UndeclaredFileReferenceError,
|
|
39
|
+
is_file_reference,
|
|
40
|
+
)
|
|
41
|
+
from terp.capabilities.files.router import (
|
|
42
|
+
MAX_UPLOAD_BYTES,
|
|
43
|
+
active_upload_limit,
|
|
44
|
+
configure_upload_limit,
|
|
45
|
+
module,
|
|
46
|
+
reset_upload_limit,
|
|
47
|
+
router,
|
|
48
|
+
)
|
|
49
|
+
from terp.capabilities.files.schemas import FileCreate, FileRead, FileUpdate
|
|
50
|
+
from terp.capabilities.files.service import (
|
|
51
|
+
ContentTypeMismatchError,
|
|
52
|
+
FileService,
|
|
53
|
+
UnsupportedContentTypeError,
|
|
54
|
+
active_allowed_content_types,
|
|
55
|
+
configure_allowed_content_types,
|
|
56
|
+
reset_allowed_content_types,
|
|
57
|
+
)
|
|
58
|
+
from terp.capabilities.files.storage import (
|
|
59
|
+
DEFAULT_STORAGE_PROFILE,
|
|
60
|
+
FileStorageError,
|
|
61
|
+
LocalFilesystemStorage,
|
|
62
|
+
StorageBackend,
|
|
63
|
+
UnknownStorageProfileError,
|
|
64
|
+
active_storage_backend,
|
|
65
|
+
register_storage_backend,
|
|
66
|
+
reset_storage_backend,
|
|
67
|
+
resolve_storage_backend,
|
|
68
|
+
set_storage_backend,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
__all__ = [
|
|
72
|
+
"DEFAULT_STORAGE_PROFILE",
|
|
73
|
+
"MAX_UPLOAD_BYTES",
|
|
74
|
+
"ContentTypeMismatchError",
|
|
75
|
+
"File",
|
|
76
|
+
"FileCreate",
|
|
77
|
+
"FileRead",
|
|
78
|
+
"FileRef",
|
|
79
|
+
"FileService",
|
|
80
|
+
"FileStorageError",
|
|
81
|
+
"FileUpdate",
|
|
82
|
+
"LocalFilesystemStorage",
|
|
83
|
+
"StorageBackend",
|
|
84
|
+
"UndeclaredFileReferenceError",
|
|
85
|
+
"UnknownStorageProfileError",
|
|
86
|
+
"UnsupportedContentTypeError",
|
|
87
|
+
"active_allowed_content_types",
|
|
88
|
+
"active_storage_backend",
|
|
89
|
+
"active_upload_limit",
|
|
90
|
+
"configure_allowed_content_types",
|
|
91
|
+
"configure_upload_limit",
|
|
92
|
+
"is_file_reference",
|
|
93
|
+
"module",
|
|
94
|
+
"register_storage_backend",
|
|
95
|
+
"reset_allowed_content_types",
|
|
96
|
+
"reset_storage_backend",
|
|
97
|
+
"reset_upload_limit",
|
|
98
|
+
"resolve_storage_backend",
|
|
99
|
+
"router",
|
|
100
|
+
"set_storage_backend",
|
|
101
|
+
]
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""create file table
|
|
2
|
+
|
|
3
|
+
Revision ID: ceffeb4b0fc2
|
|
4
|
+
Revises:
|
|
5
|
+
Create Date: 2026-07-02 11:20:59.258321
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = 'ceffeb4b0fc2'
|
|
19
|
+
down_revision: str | None = None
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
26
|
+
op.create_table('file_object',
|
|
27
|
+
sa.Column('owner_id', sa.Uuid(), nullable=True),
|
|
28
|
+
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
|
29
|
+
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
|
30
|
+
sa.Column('id', sa.Uuid(), nullable=False),
|
|
31
|
+
sa.Column('version', sa.Integer(), nullable=False),
|
|
32
|
+
sa.Column('filename', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
|
|
33
|
+
sa.Column('content_type', sqlmodel.sql.sqltypes.AutoString(length=255), nullable=False),
|
|
34
|
+
sa.Column('size', sa.Integer(), nullable=False),
|
|
35
|
+
sa.Column('sha256', sqlmodel.sql.sqltypes.AutoString(length=64), nullable=False),
|
|
36
|
+
sa.Column('storage_key', sqlmodel.sql.sqltypes.AutoString(length=512), nullable=False),
|
|
37
|
+
sa.PrimaryKeyConstraint('id', name=op.f('pk_file_object')),
|
|
38
|
+
sa.UniqueConstraint('storage_key', name=op.f('uq_file_object_storage_key'))
|
|
39
|
+
)
|
|
40
|
+
with op.batch_alter_table('file_object', schema=None) as batch_op:
|
|
41
|
+
batch_op.create_index(batch_op.f('ix_file_object_filename'), ['filename'], unique=False)
|
|
42
|
+
batch_op.create_index(batch_op.f('ix_file_object_owner_id'), ['owner_id'], unique=False)
|
|
43
|
+
|
|
44
|
+
# ### end Alembic commands ###
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def downgrade() -> None:
|
|
48
|
+
# ### commands auto generated by Alembic - please adjust! ###
|
|
49
|
+
with op.batch_alter_table('file_object', schema=None) as batch_op:
|
|
50
|
+
batch_op.drop_index(batch_op.f('ix_file_object_owner_id'))
|
|
51
|
+
batch_op.drop_index(batch_op.f('ix_file_object_filename'))
|
|
52
|
+
|
|
53
|
+
op.drop_table('file_object')
|
|
54
|
+
# ### end Alembic commands ###
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""add file storage profile
|
|
2
|
+
|
|
3
|
+
Revision ID: e3a9c47d51b8
|
|
4
|
+
Revises: ceffeb4b0fc2
|
|
5
|
+
Create Date: 2026-07-02 12:05:00.000000
|
|
6
|
+
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
|
|
12
|
+
from alembic import op
|
|
13
|
+
import sqlalchemy as sa
|
|
14
|
+
import sqlmodel
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# revision identifiers, used by Alembic.
|
|
18
|
+
revision: str = 'e3a9c47d51b8'
|
|
19
|
+
down_revision: str | None = 'ceffeb4b0fc2'
|
|
20
|
+
branch_labels: str | Sequence[str] | None = None
|
|
21
|
+
depends_on: str | Sequence[str] | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def upgrade() -> None:
|
|
25
|
+
# Every pre-existing row was stored through the original single (default) backend,
|
|
26
|
+
# so the backfill value is exactly the store that holds its bytes.
|
|
27
|
+
with op.batch_alter_table('file_object', schema=None) as batch_op:
|
|
28
|
+
batch_op.add_column(
|
|
29
|
+
sa.Column(
|
|
30
|
+
'storage_profile',
|
|
31
|
+
sqlmodel.sql.sqltypes.AutoString(length=64),
|
|
32
|
+
nullable=False,
|
|
33
|
+
server_default='default',
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def downgrade() -> None:
|
|
39
|
+
with op.batch_alter_table('file_object', schema=None) as batch_op:
|
|
40
|
+
batch_op.drop_column('storage_profile')
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The ``file_object`` metadata table: an owner-scoped file record.
|
|
2
|
+
|
|
3
|
+
``File`` is a normal owner-scoped resource (``BaseTable`` + ``OwnedMixin``): its creator
|
|
4
|
+
owns it and only the owner may edit / delete it — the per-row write gate (ADR 0029),
|
|
5
|
+
enforced centrally by ``BaseService`` with zero module code (never a hand-rolled
|
|
6
|
+
``owner_id`` check — ``terp guide ownership``). Only the *metadata* lives here; the bytes
|
|
7
|
+
live behind the pluggable :class:`~terp.capabilities.files.StorageBackend`, addressed by
|
|
8
|
+
the server-generated ``storage_key``.
|
|
9
|
+
|
|
10
|
+
Mutability is decided per field:
|
|
11
|
+
|
|
12
|
+
* ``filename`` — **mutable** (a rename is a metadata edit; the bytes are untouched), via
|
|
13
|
+
the OCC-bearing ``update`` path.
|
|
14
|
+
* ``content_type`` / ``size`` / ``sha256`` / ``storage_key`` / ``storage_profile`` —
|
|
15
|
+
**append-only**: set once from the uploaded bytes at create time and never patched
|
|
16
|
+
(``FileUpdate`` carries no field for them; replacing content is a new upload).
|
|
17
|
+
``storage_key`` and ``storage_profile`` in particular are server-side-only material
|
|
18
|
+
(the raw storage address and the named backend holding it): both are generated /
|
|
19
|
+
selected by the service, never accepted from a client, and never serialized out of the
|
|
20
|
+
API boundary (no ``*Read`` DTO carries them — enforced by a runtime test alongside the
|
|
21
|
+
``schemas_exclude_sensitive_fields`` posture). ``storage_profile`` being append-only is
|
|
22
|
+
what keeps a row pointing at the store that actually holds its bytes: re-homing a blob
|
|
23
|
+
is an explicit migration, never a patch.
|
|
24
|
+
|
|
25
|
+
Every caller-influenceable ``str`` column caps its length so a hostile or oversized value
|
|
26
|
+
can never break the INSERT.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from typing import Final
|
|
32
|
+
|
|
33
|
+
from sqlmodel import Field
|
|
34
|
+
|
|
35
|
+
from terp.core import BaseTable, OwnedMixin
|
|
36
|
+
|
|
37
|
+
from terp.capabilities.files.storage import STORAGE_PROFILE_MAX
|
|
38
|
+
|
|
39
|
+
# Hard caps so a hostile / oversized value can never break the INSERT.
|
|
40
|
+
FILENAME_MAX: Final[int] = 255
|
|
41
|
+
CONTENT_TYPE_MAX: Final[int] = 255
|
|
42
|
+
_SHA256_MAX: Final[int] = 64
|
|
43
|
+
_STORAGE_KEY_MAX: Final[int] = 512
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class File(BaseTable, OwnedMixin, table=True):
|
|
47
|
+
"""One stored file's metadata: name, type, size, digest, and its storage address.
|
|
48
|
+
|
|
49
|
+
``id`` / ``created_at`` / ``updated_at`` / ``version`` are inherited from ``BaseTable``;
|
|
50
|
+
``owner_id`` from ``OwnedMixin`` (stamped from the request actor on create, then enforced
|
|
51
|
+
as the per-row write gate). ``storage_key`` addresses the bytes inside the storage
|
|
52
|
+
backend registered under ``storage_profile`` (ADR 0057); neither is **ever**
|
|
53
|
+
serialized in a Read DTO.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
__tablename__ = "file_object"
|
|
57
|
+
|
|
58
|
+
filename: str = Field(max_length=FILENAME_MAX, index=True)
|
|
59
|
+
content_type: str = Field(max_length=CONTENT_TYPE_MAX)
|
|
60
|
+
size: int = Field(ge=0)
|
|
61
|
+
sha256: str = Field(max_length=_SHA256_MAX)
|
|
62
|
+
storage_key: str = Field(max_length=_STORAGE_KEY_MAX, unique=True)
|
|
63
|
+
storage_profile: str = Field(max_length=STORAGE_PROFILE_MAX)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = ["CONTENT_TYPE_MAX", "FILENAME_MAX", "File"]
|
|
File without changes
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Declared file references: ``FileRef`` + the fail-closed delegation check (ADR 0057).
|
|
2
|
+
|
|
3
|
+
A module that stores a pointer to a :class:`~terp.capabilities.files.File` (an invoice's
|
|
4
|
+
attachment, a report's export) must **declare** the reference — a bare
|
|
5
|
+
``file_id: uuid.UUID`` column carries no authorization semantics, and implicit "can see
|
|
6
|
+
the record ⇒ can see the file" propagation is exactly how object-level (BOLA) leaks
|
|
7
|
+
happen. The posture is **default-deny + explicit delegation**:
|
|
8
|
+
|
|
9
|
+
* :func:`FileRef` declares the column: it returns a normal indexed ``uuid`` field whose
|
|
10
|
+
``FieldInfo`` carries a machine-readable marker, making the reference greppable and
|
|
11
|
+
verifiable at both build time (the ``no_raw_file_references`` rule flags a bare
|
|
12
|
+
``*file_id`` table column) and runtime (:func:`is_file_reference`).
|
|
13
|
+
* Delegated access is **serve-through**: the referencing module loads its *own* row
|
|
14
|
+
through its *own* service (so that row's policy + row scope + per-row write gate
|
|
15
|
+
already decided visibility), then serves the bytes with
|
|
16
|
+
:meth:`~terp.capabilities.files.FileService.load_for` — which fail-closes on any
|
|
17
|
+
column not declared with :func:`FileRef` (:class:`UndeclaredFileReferenceError`).
|
|
18
|
+
Access to the file thus provably follows the referencing record's access, the raw
|
|
19
|
+
``/api/v1/files`` surface stays ADMIN-only, and delegation widens to exactly one
|
|
20
|
+
already-authorized row — never by an implicit, registry-wide grant.
|
|
21
|
+
|
|
22
|
+
The kernel's predicate seams (scope / object-authz) compose with **AND** semantics — they
|
|
23
|
+
can only ever *narrow* access. That is deliberate, and it is why delegation is a
|
|
24
|
+
serve-through helper rather than a registered predicate: a predicate cannot (and must
|
|
25
|
+
never) silently *widen* who may reach a file.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from sqlmodel import Field, SQLModel
|
|
33
|
+
|
|
34
|
+
from terp.core import AppError
|
|
35
|
+
|
|
36
|
+
# The machine-readable marker a FileRef-declared column carries in its FieldInfo.
|
|
37
|
+
_FILE_REF_MARKER = "terp_file_ref"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class UndeclaredFileReferenceError(AppError):
|
|
41
|
+
"""500 — a serve-through read named a column not declared with ``FileRef``.
|
|
42
|
+
|
|
43
|
+
Fail-closed: delegated file access flows only through a **declared** reference, so a
|
|
44
|
+
typo'd column name or an undeclared bare ``uuid`` column refuses instead of serving
|
|
45
|
+
bytes. This is a module wiring error (declare the column with :func:`FileRef`),
|
|
46
|
+
never a caller mistake.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
status_code = 500
|
|
50
|
+
code = "file_reference_undeclared"
|
|
51
|
+
default_message = "The record's file reference is not declared with FileRef."
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def FileRef(*, index: bool = True) -> Any: # noqa: N802 - a Field-style factory, named like the trait it declares
|
|
55
|
+
"""Declare a model column as a reference to a stored ``File``.
|
|
56
|
+
|
|
57
|
+
Use it instead of a bare ``uuid`` field::
|
|
58
|
+
|
|
59
|
+
class Invoice(BaseTable, table=True):
|
|
60
|
+
attachment_file_id: uuid.UUID | None = FileRef()
|
|
61
|
+
|
|
62
|
+
The column is a normal nullable, indexed ``uuid`` — no database FK is imposed (the
|
|
63
|
+
``File`` row may live in another package's migration history) — but its ``FieldInfo``
|
|
64
|
+
carries the declaration marker :func:`is_file_reference` and
|
|
65
|
+
:meth:`~terp.capabilities.files.FileService.load_for` verify, and the
|
|
66
|
+
``no_raw_file_references`` rule enforces at build time.
|
|
67
|
+
"""
|
|
68
|
+
field = Field(default=None, index=index)
|
|
69
|
+
field.json_schema_extra = {_FILE_REF_MARKER: True}
|
|
70
|
+
return field
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_file_reference(model: type[SQLModel], column: str) -> bool:
|
|
74
|
+
"""Whether *model*.*column* is declared as a file reference (via :func:`FileRef`)."""
|
|
75
|
+
field = model.model_fields.get(column)
|
|
76
|
+
if field is None:
|
|
77
|
+
return False
|
|
78
|
+
extra = field.json_schema_extra
|
|
79
|
+
return isinstance(extra, dict) and bool(extra.get(_FILE_REF_MARKER))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
__all__ = ["FileRef", "UndeclaredFileReferenceError", "is_file_reference"]
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Owner-scoped admin router for files: upload, download, list, rename, delete.
|
|
2
|
+
|
|
3
|
+
Self-registering (``module``): the kernel's entry-point discovery mounts it at
|
|
4
|
+
``/api/v1/files`` with no composition-root edit. File storage is a privileged,
|
|
5
|
+
disk/backend-consuming capability, so the policy requires ``ADMIN``; ``File`` also
|
|
6
|
+
composes ``OwnedMixin``, so the **per-row** write gate (an admin may rename / delete only
|
|
7
|
+
their *own* file) is enforced centrally by ``BaseService`` — the routes carry no ownership
|
|
8
|
+
logic. The raw ``storage_key`` never leaves the boundary (``FileRead`` omits it); the
|
|
9
|
+
download route **streams** the bytes back through a ``StreamingResponse`` (read from the
|
|
10
|
+
backend in chunks, so a large file never lands in memory whole) with a sanitized
|
|
11
|
+
``Content-Disposition`` (RFC 5987 encoding, so a hostile stored filename can never inject
|
|
12
|
+
response headers). The upload is **streamed** straight from the parsed part's spooled file
|
|
13
|
+
into the storage backend, hashing and size-capping the bytes in flight (ADR 0066): the
|
|
14
|
+
service refuses the instant the running total crosses ``MAX_UPLOAD_BYTES`` and compensates
|
|
15
|
+
the partial blob, so the write path never buffers the whole upload — and the kernel's
|
|
16
|
+
``RequestSizeLimitMiddleware`` still bounds the raw request body up front.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import uuid
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
from typing import BinaryIO, Final
|
|
25
|
+
|
|
26
|
+
from fastapi import APIRouter, Request
|
|
27
|
+
from fastapi.responses import StreamingResponse
|
|
28
|
+
from starlette.datastructures import UploadFile
|
|
29
|
+
|
|
30
|
+
from terp.core import (
|
|
31
|
+
ADMIN,
|
|
32
|
+
ModuleSpec,
|
|
33
|
+
Page,
|
|
34
|
+
PaginationDep,
|
|
35
|
+
Policy,
|
|
36
|
+
SessionDep,
|
|
37
|
+
ValidationFailedError,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
from terp.capabilities.files.models import CONTENT_TYPE_MAX, FILENAME_MAX
|
|
41
|
+
from terp.capabilities.files.schemas import FileRead, FileUpdate
|
|
42
|
+
from terp.capabilities.files.service import FileService
|
|
43
|
+
|
|
44
|
+
# The default cap on one upload's stored byte size (a DoS guard enforced mid-stream by the
|
|
45
|
+
# service, which compensates the partial blob on overrun — the request path never buffers the
|
|
46
|
+
# whole upload). Deliberately conservative; a deployment retunes it with one composition-root
|
|
47
|
+
# line (configure_upload_limit) paired with create_app(request_size_overrides={"files": ...})
|
|
48
|
+
# when the new ceiling exceeds the module's declared request allowance (ADR 0067).
|
|
49
|
+
MAX_UPLOAD_BYTES: Final[int] = 25 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
# Headroom the module's declared request-body allowance adds over the stored-bytes cap:
|
|
52
|
+
# a multipart body carries boundary lines + part headers around the file bytes, so the
|
|
53
|
+
# request cap must sit slightly above the stored cap or a maximum-size file would be
|
|
54
|
+
# refused at the socket before the streamed cap ever measured it.
|
|
55
|
+
_MULTIPART_HEADROOM_BYTES: Final[int] = 64 * 1024
|
|
56
|
+
|
|
57
|
+
# The active stored-bytes cap (module-level, composition-root-configured — the same seam
|
|
58
|
+
# shape as the storage registry). Never client data.
|
|
59
|
+
_upload_limit: int = MAX_UPLOAD_BYTES
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def configure_upload_limit(max_bytes: int) -> None:
|
|
63
|
+
"""Set the stored-bytes cap for uploads (a composition-root line, ADR 0067).
|
|
64
|
+
|
|
65
|
+
Validated eagerly (positive) so a mis-wired root fails at boot, not at the first
|
|
66
|
+
upload. A ceiling above the module's declared request allowance
|
|
67
|
+
(``MAX_UPLOAD_BYTES + 64 KiB``) must be paired with
|
|
68
|
+
``create_app(request_size_overrides={"files": <ceiling + headroom>})`` — otherwise
|
|
69
|
+
the kernel's request-size middleware still refuses the larger body first
|
|
70
|
+
(fail-closed, never silently wider).
|
|
71
|
+
"""
|
|
72
|
+
global _upload_limit
|
|
73
|
+
if max_bytes <= 0:
|
|
74
|
+
raise ValueError("the upload limit must be a positive byte count")
|
|
75
|
+
_upload_limit = max_bytes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def active_upload_limit() -> int:
|
|
79
|
+
"""The stored-bytes cap uploads are currently held to."""
|
|
80
|
+
return _upload_limit
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def reset_upload_limit() -> None:
|
|
84
|
+
"""Restore the default cap (the test-isolation reset, like the storage registry's)."""
|
|
85
|
+
global _upload_limit
|
|
86
|
+
_upload_limit = MAX_UPLOAD_BYTES
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# The chunk size the download route pulls from the backend stream (bounded memory per read).
|
|
90
|
+
_DOWNLOAD_CHUNK_BYTES: Final[int] = 64 * 1024
|
|
91
|
+
|
|
92
|
+
_DEFAULT_FILENAME: Final[str] = "upload"
|
|
93
|
+
_DEFAULT_CONTENT_TYPE: Final[str] = "application/octet-stream"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _iter_blob(stream: BinaryIO) -> Iterator[bytes]:
|
|
97
|
+
"""Yield a backend blob stream in fixed-size chunks, closing it when exhausted.
|
|
98
|
+
|
|
99
|
+
Feeds ``StreamingResponse`` so a download never materializes the whole file in memory;
|
|
100
|
+
the ``finally`` guarantees the backend handle is released even if the client disconnects
|
|
101
|
+
mid-stream.
|
|
102
|
+
"""
|
|
103
|
+
try:
|
|
104
|
+
while chunk := stream.read(_DOWNLOAD_CHUNK_BYTES):
|
|
105
|
+
yield chunk
|
|
106
|
+
finally:
|
|
107
|
+
stream.close()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
router = APIRouter(tags=["files"])
|
|
111
|
+
_service = FileService()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _content_disposition(filename: str) -> str:
|
|
115
|
+
"""A header-safe attachment disposition for *filename* (RFC 5987 / RFC 6266).
|
|
116
|
+
|
|
117
|
+
The stored filename is caller-supplied, so it is never emitted raw: the primary
|
|
118
|
+
``filename*`` value is fully percent-encoded (no CR/LF/quote can survive), and the
|
|
119
|
+
ASCII ``filename`` fallback keeps only a conservative character set.
|
|
120
|
+
"""
|
|
121
|
+
fallback = "".join(
|
|
122
|
+
ch if ch.isascii() and (ch.isalnum() or ch in "._-") else "_" for ch in filename
|
|
123
|
+
)
|
|
124
|
+
encoded = urllib.parse.quote(filename, safe="")
|
|
125
|
+
return f'attachment; filename="{fallback or _DEFAULT_FILENAME}"; filename*=UTF-8\'\'{encoded}'
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@router.post("/", response_model=FileRead, status_code=201)
|
|
129
|
+
async def upload_file(request: Request, session: SessionDep) -> FileRead:
|
|
130
|
+
form = await request.form(max_files=1, max_fields=0, max_part_size=1024)
|
|
131
|
+
uploaded = form.get("file")
|
|
132
|
+
if not isinstance(uploaded, UploadFile):
|
|
133
|
+
raise ValidationFailedError("Upload a file part named 'file'.")
|
|
134
|
+
file = uploaded
|
|
135
|
+
filename = file.filename or _DEFAULT_FILENAME
|
|
136
|
+
if len(filename) > FILENAME_MAX:
|
|
137
|
+
raise ValidationFailedError(
|
|
138
|
+
f"The filename exceeds the {FILENAME_MAX}-character limit."
|
|
139
|
+
)
|
|
140
|
+
content_type = file.content_type or _DEFAULT_CONTENT_TYPE
|
|
141
|
+
if len(content_type) > CONTENT_TYPE_MAX:
|
|
142
|
+
raise ValidationFailedError(
|
|
143
|
+
f"The content type exceeds the {CONTENT_TYPE_MAX}-character limit."
|
|
144
|
+
)
|
|
145
|
+
# Stream the parsed part's spooled file straight into the backend: the service hashes
|
|
146
|
+
# and size-caps the bytes in flight (refusing + compensating past the active limit), so
|
|
147
|
+
# the write path never holds the whole upload in memory. A sync handler runs in the
|
|
148
|
+
# threadpool, so the blocking copy never stalls the event loop.
|
|
149
|
+
file.file.seek(0)
|
|
150
|
+
return FileRead.model_validate(
|
|
151
|
+
_service.store(
|
|
152
|
+
session,
|
|
153
|
+
filename=filename,
|
|
154
|
+
content_type=content_type,
|
|
155
|
+
source=file.file,
|
|
156
|
+
max_bytes=active_upload_limit(),
|
|
157
|
+
)
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@router.get("/", response_model=Page[FileRead])
|
|
162
|
+
def list_files(session: SessionDep, pagination: PaginationDep) -> Page[FileRead]:
|
|
163
|
+
rows, total = _service.list(session, skip=pagination.skip, limit=pagination.limit)
|
|
164
|
+
return Page[FileRead].of(
|
|
165
|
+
[FileRead.model_validate(row) for row in rows], total, pagination
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@router.get("/{file_id}", response_model=FileRead)
|
|
170
|
+
def get_file(file_id: uuid.UUID, session: SessionDep) -> FileRead:
|
|
171
|
+
return FileRead.model_validate(_service.get(session, file_id))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@router.get("/{file_id}/content") # arch-allow-routes-declare-response-model: binary download — the bytes stream out through a StreamingResponse (stored media type + sanitized attachment disposition), never a serialized ORM object
|
|
175
|
+
def download_file(file_id: uuid.UUID, session: SessionDep) -> StreamingResponse:
|
|
176
|
+
row, stream = _service.open_stream(session, file_id)
|
|
177
|
+
return StreamingResponse(
|
|
178
|
+
_iter_blob(stream),
|
|
179
|
+
media_type=row.content_type,
|
|
180
|
+
headers={
|
|
181
|
+
"Content-Disposition": _content_disposition(row.filename),
|
|
182
|
+
# The stored size is append-only and derived from the streamed bytes at create
|
|
183
|
+
# time, so it is authoritative: declaring it keeps download progress / resume
|
|
184
|
+
# working, and a blob truncated out-of-band surfaces as a protocol error
|
|
185
|
+
# instead of a silently short body.
|
|
186
|
+
"Content-Length": str(row.size),
|
|
187
|
+
},
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@router.patch("/{file_id}", response_model=FileRead)
|
|
192
|
+
def update_file(
|
|
193
|
+
file_id: uuid.UUID, payload: FileUpdate, session: SessionDep
|
|
194
|
+
) -> FileRead:
|
|
195
|
+
return FileRead.model_validate(_service.update(session, file_id, payload))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@router.delete("/{file_id}", status_code=204)
|
|
199
|
+
def delete_file(file_id: uuid.UUID, session: SessionDep) -> None:
|
|
200
|
+
_service.remove(session, file_id)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
module = ModuleSpec(
|
|
204
|
+
name="files",
|
|
205
|
+
router=router,
|
|
206
|
+
policy=Policy(read=ADMIN, write=ADMIN),
|
|
207
|
+
# The declared request-body allowance for /api/v1/files (ADR 0067): the default
|
|
208
|
+
# stored-bytes cap plus multipart framing headroom, so a maximum-size upload fits
|
|
209
|
+
# through the kernel's request-size middleware without widening the global cap.
|
|
210
|
+
max_request_bytes=MAX_UPLOAD_BYTES + _MULTIPART_HEADROOM_BYTES,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
__all__ = [
|
|
215
|
+
"MAX_UPLOAD_BYTES",
|
|
216
|
+
"active_upload_limit",
|
|
217
|
+
"configure_upload_limit",
|
|
218
|
+
"module",
|
|
219
|
+
"reset_upload_limit",
|
|
220
|
+
"router",
|
|
221
|
+
]
|