Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,13 @@ All notable changes to this project are documented in this file. The format is b
`tenants` resolver it selects among the user's own memberships.

### Fixed
- `file_storage` browse page: a caller without `file_storage.manage` sees their
own usage and no tenant quota, instead of the tenant-wide total; the
authenticated download now carries the same sandbox CSP as public serving.
- Settings editor: saving a field's default (clearing an optional field
included) removes its override row instead of storing it, so the field
reads "default" again; a field's validation error sits on its own row below
the control instead of wrapping beside it.
- A runtime permission source that raises is no longer cached as empty until
invalidated: it contributes nothing for 30 seconds (one logged traceback),
then is retried; `invalidate_source` retries it at once (#517).
Expand Down
3 changes: 2 additions & 1 deletion modules/file_storage/file_storage/endpoints/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
StoredFileNotFoundError,
StreamDownload,
)
from file_storage.serving import etag_for, not_found, thumbnail_response
from file_storage.serving import etag_for, not_found, sandbox_headers, thumbnail_response

router = APIRouter()

Expand Down Expand Up @@ -221,6 +221,7 @@ async def download_file(
headers={
"Content-Disposition": content_disposition(row.filename, attachment=True),
"ETag": etag,
**sandbox_headers(),
},
etag=etag,
)
Expand Down
8 changes: 6 additions & 2 deletions modules/file_storage/file_storage/endpoints/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,12 @@ async def browse(
# stays None until an operator says what the ceiling is — the
# screen reports usage rather than inventing a total.
"backend": settings.backend,
"used_bytes": aggregates.used_bytes,
"quota_bytes": settings.quota_bytes,
# The quota measures the tenant, so only callers who see the whole
# tenant get it; everyone else sees their own usage, no ceiling.
"used_bytes": (
aggregates.used_bytes if owner is None else await service.used_bytes_by(owner)
),
"quota_bytes": settings.quota_bytes if owner is None else None,
"max_file_size_bytes": settings.max_file_size_bytes,
# Above this the page uploads in resumable parts; null when the
# backend cannot (a third-party one without multipart support).
Expand Down
8 changes: 8 additions & 0 deletions modules/file_storage/file_storage/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,14 @@ async def content_type_facets(db: AsyncSession, *, created_by: str | None = None
return [{"value": str(row[0]), "count": int(row[1])} for row in rows]


async def used_bytes_by(db: AsyncSession, owner: str) -> int:
"""Total size of one uploader's files, summed in the database."""
query = select(func.coalesce(func.sum(StoredFile.size_bytes), 0)).where(
StoredFile.created_by == owner
)
return int((await db.execute(query)).scalar_one())


def public_url_for(file_id: object, filename: str) -> str:
"""The anonymous URL of a public file, with its name as a trailing segment."""
name = quote(filename, safe="")
Expand Down
4 changes: 4 additions & 0 deletions modules/file_storage/file_storage/reads.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,5 +99,9 @@ async def content_type_facets(self, *, created_by: str | None = None) -> list[di
async def uploader_facets(self) -> list[dict]:
return [facet.as_dict() for facet in (await self.storage_aggregates()).uploaders]

async def used_bytes_by(self, owner: str) -> int:
"""Bytes held by one uploader's files (not cached, not tenant-wide)."""
return await queries.used_bytes_by(self.db, owner)

async def used_bytes(self) -> int:
return (await self.storage_aggregates()).used_bytes
9 changes: 6 additions & 3 deletions modules/file_storage/file_storage/serving.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,18 @@ def not_found(t) -> HTTPException:
)


def _headers(etag: str, cache_control: str) -> dict[str, str]:
def sandbox_headers() -> dict[str, str]:
"""Headers that stop served bytes from running as the app's origin."""
return {
"ETag": etag,
"Cache-Control": cache_control,
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy": constants.PUBLIC_CSP,
}


def _headers(etag: str, cache_control: str) -> dict[str, str]:
return {"ETag": etag, "Cache-Control": cache_control, **sandbox_headers()}


def _not_modified(request: Request, etag: str) -> bool:
sent = request.headers.get("if-none-match", "")
return etag in {part.strip().removeprefix("W/") for part in sent.split(",")}
Expand Down
34 changes: 34 additions & 0 deletions modules/file_storage/tests/test_file_storage_p3_download_csp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""Authenticated downloads carry the sandbox CSP."""

from __future__ import annotations

from file_storage import constants
from file_storage_uploads_helpers import API, upload_text


class TestDownloadSandbox:
async def test_streamed_download_is_sandboxed(self, tenant_client):
async with tenant_client("member") as a:
file_id = await upload_text(a.client, "a.txt")
resp = await a.client.get(f"{API}/files/{file_id}/download")

assert resp.status_code == 200
assert resp.headers["content-security-policy"] == constants.PUBLIC_CSP
assert resp.headers["x-content-type-options"] == "nosniff"
assert "attachment" in resp.headers["content-disposition"]

async def test_thumbnail_keeps_the_sandbox_csp(self, tenant_client):
async with tenant_client("member") as a:
import io

from PIL import Image

buf = io.BytesIO()
Image.new("RGB", (4, 4)).save(buf, "PNG")
resp = await a.client.post(
f"{API}/upload", files={"file": ("p.png", buf.getvalue(), "image/png")}
)
file_id = resp.json()["id"]
thumb = await a.client.get(f"{API}/files/{file_id}/thumbnail")
assert thumb.status_code == 200
assert thumb.headers["content-security-policy"] == constants.PUBLIC_CSP
46 changes: 46 additions & 0 deletions modules/file_storage/tests/test_file_storage_p3_scope_usage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Owner-scoped callers see their own usage and no tenant quota; downloads are sandboxed."""

from __future__ import annotations

from file_storage import constants
from file_storage_uploads_helpers import API, INERTIA, upload_text

VIEW = f"{constants.ROUTE_PREFIX_VIEW}/"


async def _props(client) -> dict:
resp = await client.get(VIEW, headers=INERTIA)
assert resp.status_code == 200, resp.text
return resp.json()["props"]


class TestUsageScope:
async def test_member_sees_own_usage_and_no_quota(self, tenant_client):
async with (
tenant_client("member") as a,
tenant_client("member", tenant_id=a.tenant_id) as b,
tenant_client("member", tenant_id=a.tenant_id) as empty,
):
a.client._transport.app.state.file_storage.settings.quota_bytes = 1024**3
await upload_text(a.client, "a.txt") # 2 bytes
await b.client.post(
f"{API}/upload", files={"file": ("b.txt", b"x" * 100, "text/plain")}
)

assert (await _props(a.client))["used_bytes"] == 2
assert (await _props(b.client))["used_bytes"] == 100
assert (await _props(empty.client))["used_bytes"] == 0
for who in (a, b, empty):
assert (await _props(who.client))["quota_bytes"] is None

async def test_manager_keeps_tenant_wide_usage_and_quota(self, tenant_client):
async with (
tenant_client("member") as a,
tenant_client("admin", tenant_id=a.tenant_id) as boss,
):
boss.client._transport.app.state.file_storage.settings.quota_bytes = 1024**3
await upload_text(a.client, "a.txt")

props = await _props(boss.client)
assert props["used_bytes"] == 2
assert props["quota_bytes"] == 1024**3
13 changes: 12 additions & 1 deletion modules/settings/settings/pages/components/ModuleFieldRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,23 @@ export function ModuleFieldRow({
<fieldset disabled={!!field.env_set} className="min-w-0 border-0 p-0">
<FieldInput id={id} field={field} value={value} onChange={onChange} />
</fieldset>
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
</div>

<div className="sm:col-span-2 lg:col-span-1 lg:justify-self-start">
<FieldSource field={field} onReset={onReset} />
</div>

{/* Its own grid row, spanning the control and trailing columns: inside
the control's cell it shared that cell with the input and wrapped a
word per line whenever the cell was narrow. */}
{error && (
<p
data-testid={`field-error-${field.name}`}
className="break-words text-xs text-destructive sm:col-span-1 sm:col-start-2 lg:col-span-2"
>
{error}
</p>
)}
</div>
);
}
29 changes: 29 additions & 0 deletions modules/settings/settings/reload.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ def _relocate(exc: ValidationError, field_name: str) -> ValidationError:
return ValidationError.from_exception_data(exc.title, lines)


def _is_default(cls: type[BaseSettings], field_name: str, value: Any) -> bool:
"""Whether the validated ``value`` is what the field defaults to."""
info = cls.model_fields[field_name]
if info.is_required():
return False
default = info.get_default(call_default_factory=True)
return to_jsonable_python(_unwrap_secrets(default)) == to_jsonable_python(
_unwrap_secrets(value)
)


async def apply_changes_and_reload(
app: FastAPI,
bus: EventBus,
Expand Down Expand Up @@ -95,6 +106,17 @@ async def apply_changes_and_reload(
# rewritten to the store on every save.
merged = current.model_dump()
diff = {k: v for k, v in changes.items() if merged.get(k) != v}

# A row that already holds the default (stored before saving a default
# cleared the row) is not a change, so the diff never reaches it; without
# this the field stays "overridden in DB" however often it is saved.
unchanged = set(changes) - set(diff) - env_supplied_fields(cls)
if unchanged:
stored = await store.get_overrides(package)
for name in sorted(unchanged & set(stored)):
if _is_default(cls, name, merged.get(name)):
await store.clear_override(package, name)

if not diff:
return current

Expand All @@ -106,6 +128,7 @@ async def apply_changes_and_reload(

merged.update(diff)
validated = cls(**merged)
dumped_all = validated.model_dump()

# A json field stores what validated, not what was sent: a field validator
# may have turned the editor's text into the declared type (``json.loads``
Expand All @@ -116,6 +139,12 @@ async def apply_changes_and_reload(
# are unwrapped before the validated value is made JSON-able.
for field_name, raw_value in diff.items():
vtype = value_type_for_field(cls, field_name)
if _is_default(cls, field_name, dumped_all.get(field_name, raw_value)):
# Saving the default is clearing the override: a stored row would
# keep the field labelled "overridden in DB" while holding exactly
# what the code would have supplied anyway.
await store.clear_override(package, field_name)
continue
if vtype == "json":
dumped = validated.model_dump(include={field_name}).get(field_name, raw_value)
raw_value = to_jsonable_python(_unwrap_secrets(dumped))
Expand Down
5 changes: 5 additions & 0 deletions modules/settings/tests-js/ModuleForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ describe('ModuleForm', () => {
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => expect(screen.getByText(/item rejected/)).toBeVisible());
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
// The error is a row of its own beneath the control, not a child of the
// control's cell, where it wrapped a word per line.
const error = screen.getByText(/item rejected/);
expect(error.contains(screen.getByLabelText('retention_days'))).toBe(false);
expect(error.className).toContain('lg:col-span-2');
});

test('shows an error whose loc names no field at form level', async () => {
Expand Down
80 changes: 80 additions & 0 deletions modules/settings/tests/test_settings_default_clears_override.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
"""Saving a field's default removes its override row instead of storing it."""

from __future__ import annotations

import pytest
from background_tasks.settings import DEFAULT_RETENTION_DAYS
from settings.service import SettingService
from settings.store import SettingsStore

URL = "/api/settings/modules"


async def _overrides(app, package: str) -> dict:
async with app.state.sm.db.session_factory() as db:
return await SettingsStore(SettingService(db)).get_overrides(package)


async def _field(client, package: str, name: str) -> dict:
resp = await client.get(URL)
module = next(m for m in resp.json()["modules"] if m["package"] == package)
return next(f for f in module["fields"] if f["name"] == name)


@pytest.mark.asyncio
async def test_clearing_optional_field_removes_row_and_reports_default(authenticated_client, app):
put = authenticated_client.put
assert (await put(f"{URL}/file_storage", json={"quota_bytes": 1000})).status_code == 200
assert "quota_bytes" in await _overrides(app, "file_storage")

resp = await put(f"{URL}/file_storage", json={"quota_bytes": None})

assert resp.status_code == 200, resp.text
assert "quota_bytes" not in await _overrides(app, "file_storage")
field = await _field(authenticated_client, "file_storage", "quota_bytes")
assert field["source"] == "default"
assert not field["db_override"]
assert app.state.file_storage.settings.quota_bytes is None


@pytest.mark.asyncio
async def test_non_default_value_is_still_stored(authenticated_client, app):
resp = await authenticated_client.put(f"{URL}/file_storage", json={"quota_bytes": 1000})

assert resp.status_code == 200, resp.text
assert "quota_bytes" in await _overrides(app, "file_storage")
field = await _field(authenticated_client, "file_storage", "quota_bytes")
assert field["source"] == "db"


@pytest.mark.asyncio
async def test_value_equal_to_non_none_default_removes_row(authenticated_client, app):
put = authenticated_client.put
assert (await put(f"{URL}/background_tasks", json={"retention_days": 7})).status_code == 200
assert "retention_days" in await _overrides(app, "background_tasks")

resp = await put(f"{URL}/background_tasks", json={"retention_days": DEFAULT_RETENTION_DAYS})

assert resp.status_code == 200, resp.text
assert "retention_days" not in await _overrides(app, "background_tasks")
field = await _field(authenticated_client, "background_tasks", "retention_days")
assert field["source"] == "default"


@pytest.mark.asyncio
async def test_noop_save_removes_a_legacy_null_row(authenticated_client, app):
async with app.state.sm.db.session_factory() as db:
await SettingsStore(SettingService(db)).set_override(
"file_storage", "quota_bytes", "null", "json"
)
await db.commit()
field = await _field(authenticated_client, "file_storage", "quota_bytes")
assert field["db_override"]

resp = await authenticated_client.put(f"{URL}/file_storage", json={"quota_bytes": None})

assert resp.status_code == 200, resp.text
assert "quota_bytes" not in await _overrides(app, "file_storage")
field = await _field(authenticated_client, "file_storage", "quota_bytes")
assert field["source"] == "default"
assert not field["db_override"]
Loading