Skip to content

Commit 77e799c

Browse files
authored
fix: QA follow-ups for #551 (owner-scoped usage, download CSP, settings defaults, inline errors) (#553)
* fix(file_storage): show owner-scoped callers their own usage, not the tenant's Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(file_storage): sandbox CSP on streamed authenticated downloads Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(settings): show a field's validation error on its own row below the control Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(settings): saving a field's default removes its override row Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(settings): a no-op save of a default removes its stale override row Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * docs: changelog for the #551 QA follow-ups Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
1 parent 1dd1bc1 commit 77e799c

12 files changed

Lines changed: 239 additions & 7 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,13 @@ All notable changes to this project are documented in this file. The format is b
336336
`tenants` resolver it selects among the user's own memberships.
337337

338338
### Fixed
339+
- `file_storage` browse page: a caller without `file_storage.manage` sees their
340+
own usage and no tenant quota, instead of the tenant-wide total; the
341+
authenticated download now carries the same sandbox CSP as public serving.
342+
- Settings editor: saving a field's default (clearing an optional field
343+
included) removes its override row instead of storing it, so the field
344+
reads "default" again; a field's validation error sits on its own row below
345+
the control instead of wrapping beside it.
339346
- A runtime permission source that raises is no longer cached as empty until
340347
invalidated: it contributes nothing for 30 seconds (one logged traceback),
341348
then is retried; `invalidate_source` retries it at once (#517).

‎modules/file_storage/file_storage/endpoints/api.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
StoredFileNotFoundError,
4545
StreamDownload,
4646
)
47-
from file_storage.serving import etag_for, not_found, thumbnail_response
47+
from file_storage.serving import etag_for, not_found, sandbox_headers, thumbnail_response
4848

4949
router = APIRouter()
5050

@@ -221,6 +221,7 @@ async def download_file(
221221
headers={
222222
"Content-Disposition": content_disposition(row.filename, attachment=True),
223223
"ETag": etag,
224+
**sandbox_headers(),
224225
},
225226
etag=etag,
226227
)

‎modules/file_storage/file_storage/endpoints/views.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,12 @@ async def browse(
9191
# stays None until an operator says what the ceiling is — the
9292
# screen reports usage rather than inventing a total.
9393
"backend": settings.backend,
94-
"used_bytes": aggregates.used_bytes,
95-
"quota_bytes": settings.quota_bytes,
94+
# The quota measures the tenant, so only callers who see the whole
95+
# tenant get it; everyone else sees their own usage, no ceiling.
96+
"used_bytes": (
97+
aggregates.used_bytes if owner is None else await service.used_bytes_by(owner)
98+
),
99+
"quota_bytes": settings.quota_bytes if owner is None else None,
96100
"max_file_size_bytes": settings.max_file_size_bytes,
97101
# Above this the page uploads in resumable parts; null when the
98102
# backend cannot (a third-party one without multipart support).

‎modules/file_storage/file_storage/queries.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,14 @@ async def content_type_facets(db: AsyncSession, *, created_by: str | None = None
161161
return [{"value": str(row[0]), "count": int(row[1])} for row in rows]
162162

163163

164+
async def used_bytes_by(db: AsyncSession, owner: str) -> int:
165+
"""Total size of one uploader's files, summed in the database."""
166+
query = select(func.coalesce(func.sum(StoredFile.size_bytes), 0)).where(
167+
StoredFile.created_by == owner
168+
)
169+
return int((await db.execute(query)).scalar_one())
170+
171+
164172
def public_url_for(file_id: object, filename: str) -> str:
165173
"""The anonymous URL of a public file, with its name as a trailing segment."""
166174
name = quote(filename, safe="")

‎modules/file_storage/file_storage/reads.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,5 +99,9 @@ async def content_type_facets(self, *, created_by: str | None = None) -> list[di
9999
async def uploader_facets(self) -> list[dict]:
100100
return [facet.as_dict() for facet in (await self.storage_aggregates()).uploaders]
101101

102+
async def used_bytes_by(self, owner: str) -> int:
103+
"""Bytes held by one uploader's files (not cached, not tenant-wide)."""
104+
return await queries.used_bytes_by(self.db, owner)
105+
102106
async def used_bytes(self) -> int:
103107
return (await self.storage_aggregates()).used_bytes

‎modules/file_storage/file_storage/serving.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,18 @@ def not_found(t) -> HTTPException:
3333
)
3434

3535

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

4443

44+
def _headers(etag: str, cache_control: str) -> dict[str, str]:
45+
return {"ETag": etag, "Cache-Control": cache_control, **sandbox_headers()}
46+
47+
4548
def _not_modified(request: Request, etag: str) -> bool:
4649
sent = request.headers.get("if-none-match", "")
4750
return etag in {part.strip().removeprefix("W/") for part in sent.split(",")}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Authenticated downloads carry the sandbox CSP."""
2+
3+
from __future__ import annotations
4+
5+
from file_storage import constants
6+
from file_storage_uploads_helpers import API, upload_text
7+
8+
9+
class TestDownloadSandbox:
10+
async def test_streamed_download_is_sandboxed(self, tenant_client):
11+
async with tenant_client("member") as a:
12+
file_id = await upload_text(a.client, "a.txt")
13+
resp = await a.client.get(f"{API}/files/{file_id}/download")
14+
15+
assert resp.status_code == 200
16+
assert resp.headers["content-security-policy"] == constants.PUBLIC_CSP
17+
assert resp.headers["x-content-type-options"] == "nosniff"
18+
assert "attachment" in resp.headers["content-disposition"]
19+
20+
async def test_thumbnail_keeps_the_sandbox_csp(self, tenant_client):
21+
async with tenant_client("member") as a:
22+
import io
23+
24+
from PIL import Image
25+
26+
buf = io.BytesIO()
27+
Image.new("RGB", (4, 4)).save(buf, "PNG")
28+
resp = await a.client.post(
29+
f"{API}/upload", files={"file": ("p.png", buf.getvalue(), "image/png")}
30+
)
31+
file_id = resp.json()["id"]
32+
thumb = await a.client.get(f"{API}/files/{file_id}/thumbnail")
33+
assert thumb.status_code == 200
34+
assert thumb.headers["content-security-policy"] == constants.PUBLIC_CSP
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Owner-scoped callers see their own usage and no tenant quota; downloads are sandboxed."""
2+
3+
from __future__ import annotations
4+
5+
from file_storage import constants
6+
from file_storage_uploads_helpers import API, INERTIA, upload_text
7+
8+
VIEW = f"{constants.ROUTE_PREFIX_VIEW}/"
9+
10+
11+
async def _props(client) -> dict:
12+
resp = await client.get(VIEW, headers=INERTIA)
13+
assert resp.status_code == 200, resp.text
14+
return resp.json()["props"]
15+
16+
17+
class TestUsageScope:
18+
async def test_member_sees_own_usage_and_no_quota(self, tenant_client):
19+
async with (
20+
tenant_client("member") as a,
21+
tenant_client("member", tenant_id=a.tenant_id) as b,
22+
tenant_client("member", tenant_id=a.tenant_id) as empty,
23+
):
24+
a.client._transport.app.state.file_storage.settings.quota_bytes = 1024**3
25+
await upload_text(a.client, "a.txt") # 2 bytes
26+
await b.client.post(
27+
f"{API}/upload", files={"file": ("b.txt", b"x" * 100, "text/plain")}
28+
)
29+
30+
assert (await _props(a.client))["used_bytes"] == 2
31+
assert (await _props(b.client))["used_bytes"] == 100
32+
assert (await _props(empty.client))["used_bytes"] == 0
33+
for who in (a, b, empty):
34+
assert (await _props(who.client))["quota_bytes"] is None
35+
36+
async def test_manager_keeps_tenant_wide_usage_and_quota(self, tenant_client):
37+
async with (
38+
tenant_client("member") as a,
39+
tenant_client("admin", tenant_id=a.tenant_id) as boss,
40+
):
41+
boss.client._transport.app.state.file_storage.settings.quota_bytes = 1024**3
42+
await upload_text(a.client, "a.txt")
43+
44+
props = await _props(boss.client)
45+
assert props["used_bytes"] == 2
46+
assert props["quota_bytes"] == 1024**3

‎modules/settings/settings/pages/components/ModuleFieldRow.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,23 @@ export function ModuleFieldRow({
6363
<fieldset disabled={!!field.env_set} className="min-w-0 border-0 p-0">
6464
<FieldInput id={id} field={field} value={value} onChange={onChange} />
6565
</fieldset>
66-
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
6766
</div>
6867

6968
<div className="sm:col-span-2 lg:col-span-1 lg:justify-self-start">
7069
<FieldSource field={field} onReset={onReset} />
7170
</div>
71+
72+
{/* Its own grid row, spanning the control and trailing columns: inside
73+
the control's cell it shared that cell with the input and wrapped a
74+
word per line whenever the cell was narrow. */}
75+
{error && (
76+
<p
77+
data-testid={`field-error-${field.name}`}
78+
className="break-words text-xs text-destructive sm:col-span-1 sm:col-start-2 lg:col-span-2"
79+
>
80+
{error}
81+
</p>
82+
)}
7283
</div>
7384
);
7485
}

‎modules/settings/settings/reload.py‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ def _relocate(exc: ValidationError, field_name: str) -> ValidationError:
6868
return ValidationError.from_exception_data(exc.title, lines)
6969

7070

71+
def _is_default(cls: type[BaseSettings], field_name: str, value: Any) -> bool:
72+
"""Whether the validated ``value`` is what the field defaults to."""
73+
info = cls.model_fields[field_name]
74+
if info.is_required():
75+
return False
76+
default = info.get_default(call_default_factory=True)
77+
return to_jsonable_python(_unwrap_secrets(default)) == to_jsonable_python(
78+
_unwrap_secrets(value)
79+
)
80+
81+
7182
async def apply_changes_and_reload(
7283
app: FastAPI,
7384
bus: EventBus,
@@ -95,6 +106,17 @@ async def apply_changes_and_reload(
95106
# rewritten to the store on every save.
96107
merged = current.model_dump()
97108
diff = {k: v for k, v in changes.items() if merged.get(k) != v}
109+
110+
# A row that already holds the default (stored before saving a default
111+
# cleared the row) is not a change, so the diff never reaches it; without
112+
# this the field stays "overridden in DB" however often it is saved.
113+
unchanged = set(changes) - set(diff) - env_supplied_fields(cls)
114+
if unchanged:
115+
stored = await store.get_overrides(package)
116+
for name in sorted(unchanged & set(stored)):
117+
if _is_default(cls, name, merged.get(name)):
118+
await store.clear_override(package, name)
119+
98120
if not diff:
99121
return current
100122

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

107129
merged.update(diff)
108130
validated = cls(**merged)
131+
dumped_all = validated.model_dump()
109132

110133
# A json field stores what validated, not what was sent: a field validator
111134
# may have turned the editor's text into the declared type (``json.loads``
@@ -116,6 +139,12 @@ async def apply_changes_and_reload(
116139
# are unwrapped before the validated value is made JSON-able.
117140
for field_name, raw_value in diff.items():
118141
vtype = value_type_for_field(cls, field_name)
142+
if _is_default(cls, field_name, dumped_all.get(field_name, raw_value)):
143+
# Saving the default is clearing the override: a stored row would
144+
# keep the field labelled "overridden in DB" while holding exactly
145+
# what the code would have supplied anyway.
146+
await store.clear_override(package, field_name)
147+
continue
119148
if vtype == "json":
120149
dumped = validated.model_dump(include={field_name}).get(field_name, raw_value)
121150
raw_value = to_jsonable_python(_unwrap_secrets(dumped))

0 commit comments

Comments
 (0)