fix(ui): hide write/edit controls from READ_ALL (read-only) users #696

Closed
opened 2026-05-31 10:04:18 +02:00 by marcel · 9 comments
Owner

Context

Users in a group without WRITE_ALL can read everything but cannot create, edit, upload, or delete. The backend enforces this (@RequirePermission(WRITE_ALL) on every mutating endpoint + server-side route redirects), so this is not a privilege-escalation hole — but the UI still shows them controls they cannot use, which is confusing friction on the main read journey (a reader clicks "Hochladen" and lands on a form that redirects them away).

The frontend already derives three gating booleans in frontend/src/routes/+layout.server.ts and drills them down as page data:

canWrite:    groups.some(g => g.permissions.includes('WRITE_ALL'))
canAnnotate: groups.some(g => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL'))
canBlogWrite:groups.some(g => g.permissions.includes('BLOG_WRITE'))

This issue audits the whole UI for write/edit controls and closes the one confirmed leak. (The complex transcription read-only case is split into #697.)


Audit result — current gating state

Already correctly gated (no change needed)

Control File Gate
Documents "Neu" routes/documents/+page.svelte:332 canWrite
Documents "Alle bearbeiten" (bulk) routes/documents/+page.svelte:316 canWrite
Bulk selection bar lib/document/BulkSelectionBar.svelte canWrite
Home drop zone routes/+page.svelte:92 canWrite
Document "Bearbeiten" lib/document/DocumentTopBarActions.svelte:73 canWrite
Document "Transkribieren" lib/document/DocumentTopBarActions.svelte:25 canWrite (becomes readable in #697)
Persons "Neu" routes/persons/+page.svelte:105 canWrite
Persons "Überprüfung" link routes/persons/+page.svelte:95 canWrite
PersonCard "Bearbeiten" routes/persons/[id]/PersonCard.svelte:120 canWrite
Name-history add/delete alias routes/persons/[id]/edit/NameHistoryEditCard.svelte:75,103 canWrite
Enrich checkbox + bulk bar routes/enrich/+page.svelte:73,106 canWrite
Stammbaum relationship editing routes/stammbaum/+page.svelte:158,166 canWrite
Briefwechsel "Neues Dokument" routes/briefwechsel/ConversationTimeline.svelte:92 canWrite
Comment compose / reply routes/documents/[id]/+page.svelte:348 (canComment={canWrite}) hidden for non-writers
Geschichten "Neu" / edit / delete routes/geschichten/+page.svelte:57, [id]/+page.svelte:125 canBlogWrite
Home drafts module routes/+page.svelte:43 canBlogWrite
Admin nav link routes/+layout.svelte:42 (AppNav) ADMIN

Protected by server-side route redirect (not rendered for non-writers)

documents/[id]/edit, persons/[id]/edit, persons/new, persons/review, documents/bulk-edit, enrich/[id], all of admin/*. Their +page.server.ts redirects a non-writer before the page renders. The transcription edit controls are also unreachable today because the only entry point — the "Transkribieren" button — is canWrite-gated.

The leak — fix in this issue

Header "Hochladen" (Upload) buttonfrontend/src/routes/+layout.svelte:78-102

{#if data?.user}
    <a href="/documents/new" aria-label={m.upload_action()} ...>...</a>
{/if}

The gate is {#if data?.user} (any authenticated user) instead of also requiring data.canWrite. A non-writer sees the button, clicks it, hits /documents/new, and is bounced by the server-side redirect in documents/new/+page.server.ts:18 (if (!hasWriteAll(locals)) throw redirect(303, '/')). This is the only confirmed write control visible to a non-writer in the global chrome.


Change

frontend/src/routes/+layout.svelte

{#if data?.user && data.canWrite}
    <a href="/documents/new" ...>...</a>
{/if}

canWrite is already on data — no new plumbing. Nothing else in the global layout needs changing (bell, theme toggle, language switcher, user menu are read-only and correctly shown to all authenticated users).

Defensive note — DropZone.svelte

routes/DropZone.svelte has no internal permission guard; it relies on its mount site. Today its only mount (routes/+page.svelte:92) is already {#if data.canWrite}. Do not add an internal guard; any future mount must wrap it in canWrite.


Tests

  • Component test frontend/src/routes/layout.svelte.spec.ts (exists — extend): with data.canWrite = false → no element with aria-label = upload_action(); with canWrite = true → present with href="/documents/new". Use a makeData() factory.
  • Component test (breadth): an ANNOTATE_ALL-only user (canWrite = false) → no upload button. Documents that the gate is "lacks WRITE_ALL", not "is READ_ALL".
  • Backend permission-boundary (the test that actually matters): confirm/add that POST to the document-create and /api/documents/quick-upload endpoints returns 403 for a READ_ALL-only principal (and 401 unauthenticated). Hiding the button is not the control; the endpoint authz is.

Per project convention, run only the specific spec locally (npx vitest run src/routes/layout.svelte.spec.ts); leave the full sweep + browser/E2E to CI. No new READ_ALL E2E seed user is needed for this; if one is later added, wire creds via Gitea secrets.


Acceptance criteria

Scenario: A user without WRITE_ALL does not see the header upload button
  Given I am logged in with a group that lacks WRITE_ALL (e.g. READ_ALL or ANNOTATE_ALL only)
  When I load any page
  Then the header "Hochladen" button is not rendered
  And no other create/edit/delete/upload control is visible in the global chrome

Scenario: A WRITE_ALL user still sees the upload button
  Given I am logged in with the WRITE_ALL permission
  When I load any page
  Then the header "Hochladen" button is rendered and links to /documents/new

Scenario: The upload endpoint rejects a non-writer regardless of UI
  Given a READ_ALL-only user
  When they POST to the document-create / quick-upload endpoint directly
  Then the backend returns 403
  • No regression for WRITE_ALL / ADMIN users.
  • No backend model change, no i18n change, no API regeneration (upload_action already exists).

  • #697 — read-only transcription for non-writers (the complex half of the same goal).
  • ANNOTATE_ALL users cannot comment/annotate: comment & annotation controls are gated on canWrite (routes/documents/[id]/+page.svelte:348), but the intent of ANNOTATE_ALL is "read + annotate/comment". canAnnotate is already derived in +layout.server.ts but unused. Re-wiring those gates from canWrite to canAnnotate grants more, not less — a separate follow-up, not part of this issue.
## Context Users in a group **without `WRITE_ALL`** can read everything but cannot create, edit, upload, or delete. The backend enforces this (`@RequirePermission(WRITE_ALL)` on every mutating endpoint + server-side route redirects), so this is **not a privilege-escalation hole** — but the **UI still shows them controls they cannot use**, which is confusing friction on the main read journey (a reader clicks "Hochladen" and lands on a form that redirects them away). The frontend already derives three gating booleans in `frontend/src/routes/+layout.server.ts` and drills them down as page data: ```ts canWrite: groups.some(g => g.permissions.includes('WRITE_ALL')) canAnnotate: groups.some(g => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')) canBlogWrite:groups.some(g => g.permissions.includes('BLOG_WRITE')) ``` This issue audits the whole UI for write/edit controls and closes the one confirmed leak. (The complex transcription read-only case is split into #697.) --- ## Audit result — current gating state ### ✅ Already correctly gated (no change needed) | Control | File | Gate | |---|---|---| | Documents "Neu" | `routes/documents/+page.svelte:332` | `canWrite` | | Documents "Alle bearbeiten" (bulk) | `routes/documents/+page.svelte:316` | `canWrite` | | Bulk selection bar | `lib/document/BulkSelectionBar.svelte` | `canWrite` | | Home drop zone | `routes/+page.svelte:92` | `canWrite` | | Document "Bearbeiten" | `lib/document/DocumentTopBarActions.svelte:73` | `canWrite` | | Document "Transkribieren" | `lib/document/DocumentTopBarActions.svelte:25` | `canWrite` (becomes readable in #697) | | Persons "Neu" | `routes/persons/+page.svelte:105` | `canWrite` | | Persons "Überprüfung" link | `routes/persons/+page.svelte:95` | `canWrite` | | PersonCard "Bearbeiten" | `routes/persons/[id]/PersonCard.svelte:120` | `canWrite` | | Name-history add/delete alias | `routes/persons/[id]/edit/NameHistoryEditCard.svelte:75,103` | `canWrite` | | Enrich checkbox + bulk bar | `routes/enrich/+page.svelte:73,106` | `canWrite` | | Stammbaum relationship editing | `routes/stammbaum/+page.svelte:158,166` | `canWrite` | | Briefwechsel "Neues Dokument" | `routes/briefwechsel/ConversationTimeline.svelte:92` | `canWrite` | | Comment compose / reply | `routes/documents/[id]/+page.svelte:348` (`canComment={canWrite}`) | hidden for non-writers | | Geschichten "Neu" / edit / delete | `routes/geschichten/+page.svelte:57`, `[id]/+page.svelte:125` | `canBlogWrite` | | Home drafts module | `routes/+page.svelte:43` | `canBlogWrite` | | Admin nav link | `routes/+layout.svelte:42` (`AppNav`) | `ADMIN` | ### ✅ Protected by server-side route redirect (not rendered for non-writers) `documents/[id]/edit`, `persons/[id]/edit`, `persons/new`, `persons/review`, `documents/bulk-edit`, `enrich/[id]`, all of `admin/*`. Their `+page.server.ts` redirects a non-writer before the page renders. The transcription *edit* controls are also unreachable today because the only entry point — the "Transkribieren" button — is `canWrite`-gated. ### ❌ The leak — fix in this issue **Header "Hochladen" (Upload) button** — `frontend/src/routes/+layout.svelte:78-102` ```svelte {#if data?.user} <a href="/documents/new" aria-label={m.upload_action()} ...>...</a> {/if} ``` The gate is `{#if data?.user}` (any authenticated user) instead of also requiring `data.canWrite`. A non-writer sees the button, clicks it, hits `/documents/new`, and is bounced by the server-side redirect in `documents/new/+page.server.ts:18` (`if (!hasWriteAll(locals)) throw redirect(303, '/')`). This is the only confirmed write control visible to a non-writer in the global chrome. --- ## Change ### `frontend/src/routes/+layout.svelte` ```svelte {#if data?.user && data.canWrite} <a href="/documents/new" ...>...</a> {/if} ``` `canWrite` is already on `data` — no new plumbing. Nothing else in the global layout needs changing (bell, theme toggle, language switcher, user menu are read-only and correctly shown to all authenticated users). ### Defensive note — `DropZone.svelte` `routes/DropZone.svelte` has no internal permission guard; it relies on its mount site. Today its only mount (`routes/+page.svelte:92`) is already `{#if data.canWrite}`. Do **not** add an internal guard; any future mount must wrap it in `canWrite`. --- ## Tests - **Component test** `frontend/src/routes/layout.svelte.spec.ts` (exists — extend): with `data.canWrite = false` → no element with `aria-label = upload_action()`; with `canWrite = true` → present with `href="/documents/new"`. Use a `makeData()` factory. - **Component test (breadth):** an `ANNOTATE_ALL`-only user (`canWrite = false`) → no upload button. Documents that the gate is "lacks WRITE_ALL", not "is READ_ALL". - **Backend permission-boundary (the test that actually matters):** confirm/add that `POST` to the document-create and `/api/documents/quick-upload` endpoints returns **403** for a `READ_ALL`-only principal (and **401** unauthenticated). Hiding the button is not the control; the endpoint authz is. > Per project convention, run only the specific spec locally (`npx vitest run src/routes/layout.svelte.spec.ts`); leave the full sweep + browser/E2E to CI. No new READ_ALL E2E seed user is needed for this; if one is later added, wire creds via Gitea secrets. --- ## Acceptance criteria ```gherkin Scenario: A user without WRITE_ALL does not see the header upload button Given I am logged in with a group that lacks WRITE_ALL (e.g. READ_ALL or ANNOTATE_ALL only) When I load any page Then the header "Hochladen" button is not rendered And no other create/edit/delete/upload control is visible in the global chrome Scenario: A WRITE_ALL user still sees the upload button Given I am logged in with the WRITE_ALL permission When I load any page Then the header "Hochladen" button is rendered and links to /documents/new Scenario: The upload endpoint rejects a non-writer regardless of UI Given a READ_ALL-only user When they POST to the document-create / quick-upload endpoint directly Then the backend returns 403 ``` - No regression for WRITE_ALL / ADMIN users. - No backend model change, no i18n change, no API regeneration (`upload_action` already exists). --- ## Out of scope / Related - **#697** — read-only transcription for non-writers (the complex half of the same goal). - **`ANNOTATE_ALL` users cannot comment/annotate**: comment & annotation controls are gated on `canWrite` (`routes/documents/[id]/+page.svelte:348`), but the intent of `ANNOTATE_ALL` is "read + annotate/comment". `canAnnotate` is already derived in `+layout.server.ts` but unused. Re-wiring those gates from `canWrite` to `canAnnotate` grants *more*, not less — a separate follow-up, not part of this issue.
marcel added the P1-highbugui labels 2026-05-31 10:05:03 +02:00
Author
Owner

Implemented on feat/issue-696-hide-write-controls

The confirmed leak is closed and the boundary is documented. Three atomic, TDD commits:

Commit What
c3652f5b fix(ui)+layout.svelte upload-button gate changed from {#if data?.user}{#if data?.user && data.canWrite}. Drove it red-first with a canWrite: false → no-upload-link test.
97274beb test(layout) — breadth guard: an ANNOTATE_ALL-only user (canWrite: false, canAnnotate: true) still sees no upload link, proving the gate is "lacks WRITE_ALL", not "is READ_ALL".
5edefdd0 test(document) — explicit @WithMockUser(authorities = "READ_ALL") 403 boundary tests for POST /api/documents and POST /api/documents/quick-upload — the control that actually matters.

Acceptance criteria

  • Non-writer (canWrite=false, incl. ANNOTATE_ALL-only) — header upload button not rendered. No other write control in the global chrome.
  • WRITE_ALL user — upload button still rendered, links to /documents/new (existing tests stay green).
  • Upload endpoints reject a READ_ALL-only principal with 403 (and 401 unauthenticated — already covered).

Verification

  • layout.svelte.spec.ts (client project): 11 passed — new canWrite=false test confirmed red before the gate, green after.
  • DocumentControllerTest: 104 passed (102 + 2 new READ_ALL boundary tests).
  • svelte-check: no errors in the touched files (+layout.svelte, layout.svelte.spec.ts). The 32 repo-wide errors are pre-existing on main (users/[id], admin/users) and untouched here.

Scope honored

  • No backend model change, no i18n change, no API regeneration (upload_action already exists), no doc updates (no new route/permission/ErrorCode).
  • DropZone.svelte left without an internal guard (per the defensive note).
  • canAnnotate rewiring and #697 (read-only transcription) left out of scope.

"Writer sees it" note

I did not add a third "writer sees the button" test — that contract is already locked by the two existing upload-link tests (has aria-label, navigates to /documents/new), both of which run with canWrite: true. Adding a duplicate would violate DRY.

Branch pushed. Next: open a PR / run /review-pr.

## Implemented on `feat/issue-696-hide-write-controls` The confirmed leak is closed and the boundary is documented. Three atomic, TDD commits: | Commit | What | |---|---| | `c3652f5b` | **fix(ui)** — `+layout.svelte` upload-button gate changed from `{#if data?.user}` → `{#if data?.user && data.canWrite}`. Drove it red-first with a `canWrite: false` → no-upload-link test. | | `97274beb` | **test(layout)** — breadth guard: an `ANNOTATE_ALL`-only user (`canWrite: false, canAnnotate: true`) still sees no upload link, proving the gate is "lacks WRITE_ALL", not "is READ_ALL". | | `5edefdd0` | **test(document)** — explicit `@WithMockUser(authorities = "READ_ALL")` 403 boundary tests for `POST /api/documents` and `POST /api/documents/quick-upload` — the control that actually matters. | ### Acceptance criteria - ✅ Non-writer (`canWrite=false`, incl. `ANNOTATE_ALL`-only) — header upload button not rendered. No other write control in the global chrome. - ✅ `WRITE_ALL` user — upload button still rendered, links to `/documents/new` (existing tests stay green). - ✅ Upload endpoints reject a `READ_ALL`-only principal with **403** (and **401** unauthenticated — already covered). ### Verification - `layout.svelte.spec.ts` (client project): **11 passed** — new `canWrite=false` test confirmed red before the gate, green after. - `DocumentControllerTest`: **104 passed** (102 + 2 new READ_ALL boundary tests). - `svelte-check`: no errors in the touched files (`+layout.svelte`, `layout.svelte.spec.ts`). The 32 repo-wide errors are pre-existing on `main` (`users/[id]`, `admin/users`) and untouched here. ### Scope honored - No backend model change, no i18n change, no API regeneration (`upload_action` already exists), no doc updates (no new route/permission/ErrorCode). - `DropZone.svelte` left without an internal guard (per the defensive note). - `canAnnotate` rewiring and #697 (read-only transcription) left out of scope. ### "Writer sees it" note I did **not** add a third "writer sees the button" test — that contract is already locked by the two existing upload-link tests (`has aria-label`, `navigates to /documents/new`), both of which run with `canWrite: true`. Adding a duplicate would violate DRY. Branch pushed. Next: open a PR / run `/review-pr`.
Author
Owner

🏛️ Markus Keller — Application Architect

Observations

  • The layering is exactly right: the issue states up front that backend authz (@RequirePermission(WRITE_ALL) + server-side route redirects) is the real control and the UI button is friction reduction only. That framing keeps the security boundary at the lowest trustworthy layer and treats the UI gate as cosmetic — correct.
  • The three gating booleans are derived once in +layout.server.ts (server-side, from locals.user.groups) and drilled down as page data. Single source of truth, computed where the session is authoritative. No client-side permission inference. Good.
  • The canUpload derived (+layout.svelte:46, extracted in 944370dc) pulls the boolean out of the template. That's the kind of small, named boundary I want — the template reads a flag, the script owns the rule.

Recommendations

  • DropZone defensive note — accept it, but make the coupling visible. A component whose safety depends on its mount site is an invisible coupling. Since DropZone is not the security boundary (the server enforces upload authz), pushing a guard into it would be misleading defense-in-depth theater. The issue's choice (no internal guard + a written note) is the right KISS call — but the note belongs in the code, not just the issue. Add a one-line comment at both the DropZone mount (routes/+page.svelte:92) and the component top: <!-- No internal permission guard: caller MUST wrap in {#if canWrite}. Server enforces upload authz. -->. The issue text evaporates; a code comment survives.
  • No documentation update is triggered. I checked my doc-impact table: this is a pure UI-visibility change — no Flyway migration, no new route, no new Permission/ErrorCode, and the actual auth/upload flow is unchanged (backend untouched). seq-document-upload.puml describes the server flow, which does not change. Confirm: nothing to update. Don't over-document a {#if}.

Open Decisions

  • None. The structural choices here are sound and within existing boundaries.
## 🏛️ Markus Keller — Application Architect ### Observations - The layering is exactly right: the issue states up front that backend authz (`@RequirePermission(WRITE_ALL)` + server-side route redirects) is the real control and the UI button is *friction reduction only*. That framing keeps the security boundary at the lowest trustworthy layer and treats the UI gate as cosmetic — correct. - The three gating booleans are derived once in `+layout.server.ts` (server-side, from `locals.user.groups`) and drilled down as page data. Single source of truth, computed where the session is authoritative. No client-side permission inference. Good. - The `canUpload` derived (`+layout.svelte:46`, extracted in `944370dc`) pulls the boolean out of the template. That's the kind of small, named boundary I want — the template reads a flag, the script owns the rule. ### Recommendations - **DropZone defensive note — accept it, but make the coupling visible.** A component whose safety depends on its mount site is an invisible coupling. Since DropZone is *not* the security boundary (the server enforces upload authz), pushing a guard *into* it would be misleading defense-in-depth theater. The issue's choice (no internal guard + a written note) is the right KISS call — but the note belongs **in the code**, not just the issue. Add a one-line comment at both the DropZone mount (`routes/+page.svelte:92`) and the component top: `<!-- No internal permission guard: caller MUST wrap in {#if canWrite}. Server enforces upload authz. -->`. The issue text evaporates; a code comment survives. - **No documentation update is triggered.** I checked my doc-impact table: this is a pure UI-visibility change — no Flyway migration, no new route, no new `Permission`/`ErrorCode`, and the *actual* auth/upload flow is unchanged (backend untouched). `seq-document-upload.puml` describes the server flow, which does not change. Confirm: nothing to update. Don't over-document a `{#if}`. ### Open Decisions - _None._ The structural choices here are sound and within existing boundaries.
Author
Owner

👨‍💻 Felix Brandt — Senior Fullstack Developer

Observations

  • Clean implementation. The gate became {#if canUpload} backed by const canUpload = $derived(Boolean(data?.user && data.canWrite)) (+layout.svelte:46,80). Business logic out of the template, intent-revealing name, single $derived — this is the rule I'd flag a violation for, applied correctly.
  • TDD evidence is real: the canWrite: false → no upload link test was driven red-first before the gate change (c3652f5b), and the makeData() factory was reused with a one-key override (makeData({ canWrite: false })). Exactly the factory pattern I want — no 10-line builder copied per test.
  • The "I didn't add a third writer-sees-it test" decision is correct. has aria-label and navigates to /documents/new both already run under canWrite: true (the makeData() default). A duplicate positive test would be DRY noise.

Recommendations

  • Keep Boolean(...) wrapping the && — it normalizes undefined (no user) to a real false so the {#if} and any future prop pass-through stay boolean-clean. Don't drop it.
  • Nothing else to change. The diff is minimal, the tests precede the implementation, and the naming reads itself. Ship it.

Open Decisions

  • None.
## 👨‍💻 Felix Brandt — Senior Fullstack Developer ### Observations - Clean implementation. The gate became `{#if canUpload}` backed by `const canUpload = $derived(Boolean(data?.user && data.canWrite))` (`+layout.svelte:46,80`). Business logic out of the template, intent-revealing name, single `$derived` — this is the rule I'd flag a violation for, applied correctly. - TDD evidence is real: the `canWrite: false → no upload link` test was driven red-first before the gate change (`c3652f5b`), and the `makeData()` factory was reused with a one-key override (`makeData({ canWrite: false })`). Exactly the factory pattern I want — no 10-line builder copied per test. - The "I didn't add a third *writer-sees-it* test" decision is correct. `has aria-label` and `navigates to /documents/new` both already run under `canWrite: true` (the `makeData()` default). A duplicate positive test would be DRY noise. ### Recommendations - Keep `Boolean(...)` wrapping the `&&` — it normalizes `undefined` (no user) to a real `false` so the `{#if}` and any future prop pass-through stay boolean-clean. Don't drop it. - Nothing else to change. The diff is minimal, the tests precede the implementation, and the naming reads itself. Ship it. ### Open Decisions - _None._
Author
Owner

🛡️ Nora "NullX" Steiner — Application Security Engineer

Observations

  • This issue gets the threat model right, and says so explicitly: hiding the button is not the control — the endpoint authz is. That sentence is the most important line in the whole issue. A hidden <a> stops zero attackers; a curl -X POST ignores the DOM entirely.
  • The boundary tests that actually matter are present and green: DocumentControllerTest has @WithMockUser(authorities = "READ_ALL")403 for both POST /api/documents and POST /api/documents/quick-upload, plus the unauthenticated 401 path. That's the correct pair — 401 (not authenticated) and 403 (authenticated but unauthorized) are different failures and both are covered.
  • I verified the controller: every mutating endpoint (POST, PUT /{id}, PUT /{id}/file, DELETE /{id}, PATCH /bulk, training-labels) carries @RequirePermission(WRITE_ALL). No write path leaks to a reader.

Recommendations

  • Out-of-scope finding worth a separate ticket: POST /api/documents/batch-metadata (DocumentController.java:330) is gated @RequirePermission(Permission.READ_ALL), not WRITE_ALL. CLAUDE.md states WRITE_ALL is required on every POST. If batch-metadata is a read-shaped query (fetch metadata for many IDs) the READ_ALL gate is intentional and fine — but a POST with only READ_ALL is exactly the kind of thing that silently becomes a write leak after a future refactor. Confirm it is non-mutating, and if so add a one-line comment explaining why a POST is READ_ALL-gated. Not a blocker for #696; do not expand this issue's scope.
  • Treat the existing READ_ALL → 403 upload tests as permanent regression fixtures. If someone ever widens a gate to READ_ALL, these tests must scream. They will.

Open Decisions

  • None for #696. The security approach is correct and the regression tests hold the line.
## 🛡️ Nora "NullX" Steiner — Application Security Engineer ### Observations - This issue gets the threat model right, and says so explicitly: **hiding the button is not the control — the endpoint authz is.** That sentence is the most important line in the whole issue. A hidden `<a>` stops zero attackers; a `curl -X POST` ignores the DOM entirely. - The boundary tests that *actually* matter are present and green: `DocumentControllerTest` has `@WithMockUser(authorities = "READ_ALL")` → `403` for both `POST /api/documents` and `POST /api/documents/quick-upload`, plus the unauthenticated `401` path. That's the correct pair — 401 (not authenticated) and 403 (authenticated but unauthorized) are different failures and both are covered. - I verified the controller: every mutating endpoint (`POST`, `PUT /{id}`, `PUT /{id}/file`, `DELETE /{id}`, `PATCH /bulk`, training-labels) carries `@RequirePermission(WRITE_ALL)`. No write path leaks to a reader. ### Recommendations - **Out-of-scope finding worth a separate ticket:** `POST /api/documents/batch-metadata` (`DocumentController.java:330`) is gated `@RequirePermission(Permission.READ_ALL)`, not `WRITE_ALL`. CLAUDE.md states WRITE_ALL is *required* on every `POST`. If `batch-metadata` is a read-shaped query (fetch metadata for many IDs) the `READ_ALL` gate is intentional and fine — but a `POST` with only `READ_ALL` is exactly the kind of thing that silently becomes a write leak after a future refactor. **Confirm it is non-mutating, and if so add a one-line comment explaining why a `POST` is `READ_ALL`-gated.** Not a blocker for #696; do not expand this issue's scope. - Treat the existing `READ_ALL → 403` upload tests as permanent regression fixtures. If someone ever widens a gate to `READ_ALL`, these tests must scream. They will. ### Open Decisions - _None for #696._ The security approach is correct and the regression tests hold the line.
Author
Owner

🧪 Sara Holt — QA Engineer & Test Strategist

Observations

  • Test placement on the pyramid is correct. UI visibility → component test (layout.svelte.spec.ts, vitest-browser-svelte against real DOM); the authorization boundary → controller-slice test (DocumentControllerTest). No reaching for E2E to verify a {#if}. That's the right cost/coverage trade.
  • The breadth test is the smart one: an ANNOTATE_ALL-only user (canWrite:false, canAnnotate:true) → no upload link. It documents that the gate means "lacks WRITE_ALL", not "is READ_ALL" — which prevents a future dev from re-deriving the gate as permissions.includes('READ_ALL') and accidentally showing the button to annotators.
  • Absence is asserted with .not.toBeInTheDocument() (DOM removal), not "not visible" — correct for a {#if}, and it also confirms the control is gone from the a11y tree, not just hidden.

Recommendations

  • Decide where the "no other control in global chrome" AC lives. AC scenario 1 asserts "and no other create/edit/delete/upload control is visible in the global chrome", but the tests only exercise the upload button. That clause is currently verified by the audit table in the issue body, not by a test — which is fine, as long as everyone knows it's a manual audit, not an automated guarantee. Either (a) narrow the tested AC to the upload button and keep the "no other control" claim as a documented audit, or (b) accept it as audit-backed. Do not write a test that enumerates "all possible controls" — that's unmaintainable. I lean (a): test what you can assert deterministically.
  • A user with both WRITE_ALL + ANNOTATE_ALL (→ canWrite:true) sees the button — already covered by the makeData() default in the two positive tests. No new test needed.
  • Run scope per project convention: the single spec locally, full sweep + browser project in CI. The note in the issue already says this — good.

Open Decisions

  • None. The test strategy is sound; the only choice (AC wording vs. test coverage) is a documentation call, not a tradeoff.
## 🧪 Sara Holt — QA Engineer & Test Strategist ### Observations - Test placement on the pyramid is correct. UI visibility → component test (`layout.svelte.spec.ts`, vitest-browser-svelte against real DOM); the authorization boundary → controller-slice test (`DocumentControllerTest`). No reaching for E2E to verify a `{#if}`. That's the right cost/coverage trade. - The breadth test is the smart one: an `ANNOTATE_ALL`-only user (`canWrite:false, canAnnotate:true`) → no upload link. It documents that the gate means "**lacks WRITE_ALL**", not "is READ_ALL" — which prevents a future dev from re-deriving the gate as `permissions.includes('READ_ALL')` and accidentally showing the button to annotators. - Absence is asserted with `.not.toBeInTheDocument()` (DOM removal), not "not visible" — correct for a `{#if}`, and it also confirms the control is gone from the a11y tree, not just hidden. ### Recommendations - **Decide where the "no *other* control in global chrome" AC lives.** AC scenario 1 asserts "*and no other create/edit/delete/upload control is visible in the global chrome*", but the tests only exercise the upload button. That clause is currently verified by the **audit table in the issue body**, not by a test — which is fine, *as long as everyone knows it's a manual audit, not an automated guarantee*. Either (a) narrow the tested AC to the upload button and keep the "no other control" claim as a documented audit, or (b) accept it as audit-backed. Do **not** write a test that enumerates "all possible controls" — that's unmaintainable. I lean (a): test what you can assert deterministically. - A user with both `WRITE_ALL` + `ANNOTATE_ALL` (→ `canWrite:true`) sees the button — already covered by the `makeData()` default in the two positive tests. No new test needed. - Run scope per project convention: the single spec locally, full sweep + browser project in CI. The note in the issue already says this — good. ### Open Decisions - _None._ The test strategy is sound; the only choice (AC wording vs. test coverage) is a documentation call, not a tradeoff.
Author
Owner

🎨 Leonie Voss — UX Designer & Accessibility Strategist

Observations

  • This is a genuine UX defect, not just a security tidy-up. A reader clicking "Hochladen" and getting silently bounced to / violates Nielsen #1 (visibility of system status) and #5 (error prevention) — the interface offered an affordance it couldn't honor. Removing the false affordance is the correct fix for the read journey.
  • Hiding via {#if canUpload} removes the link from the accessibility tree entirely, not just visually — so screen-reader and keyboard users on the read path never land on a dead control. That's the accessible way to gate, and it's what the implementation does.
  • The aria-label={m.upload_action()} and the xl:-responsive label are preserved for writers — i18n and a11y on the button itself are intact.

Recommendations

  • Verify the header at 320px with the button absent. The right-side cluster is flex items-center gap-3; removing the first child just collapses one gap, so no orphaned spacing — but confirm visually at 320px that the bell + theme + avatar cluster still sits balanced and nothing reflows oddly. This is the senior-on-a-small-phone reader, our hardest constraint, and they're exactly the population that no longer sees the button.
  • Keep the gate purely additive in the chrome — do not replace the hidden button with a disabled/greyed version. A disabled upload button would re-introduce the false affordance (recognition of a thing they can't use) and fail the very heuristic we're fixing. Full removal is correct.

Open Decisions

  • Should a read-only user get any path to request upload/annotate access? Right now the capability simply vanishes with no explanation. For our family audience that may be perfectly fine (access is provisioned by an admin out-of-band) — but if a reader is expected to sometimes want to contribute, silent absence could read as "the feature is broken / missing." Options: (a) nothing — provisioning is out-of-band and understood (lowest effort, fits the family-archive frame); (b) a small "request access" hint somewhere in the profile/help. This is a product call, not a blocker for #696.
## 🎨 Leonie Voss — UX Designer & Accessibility Strategist ### Observations - This is a genuine UX defect, not just a security tidy-up. A reader clicking "Hochladen" and getting silently bounced to `/` violates **Nielsen #1 (visibility of system status)** and **#5 (error prevention)** — the interface offered an affordance it couldn't honor. Removing the false affordance is the correct fix for the read journey. - Hiding via `{#if canUpload}` removes the link from the **accessibility tree entirely**, not just visually — so screen-reader and keyboard users on the read path never land on a dead control. That's the accessible way to gate, and it's what the implementation does. - The `aria-label={m.upload_action()}` and the `xl:`-responsive label are preserved for writers — i18n and a11y on the button itself are intact. ### Recommendations - **Verify the header at 320px with the button absent.** The right-side cluster is `flex items-center gap-3`; removing the first child just collapses one gap, so no orphaned spacing — but confirm visually at 320px that the bell + theme + avatar cluster still sits balanced and nothing reflows oddly. This is the senior-on-a-small-phone reader, our hardest constraint, and they're *exactly* the population that no longer sees the button. - Keep the gate purely additive in the chrome — do **not** replace the hidden button with a disabled/greyed version. A disabled upload button would re-introduce the false affordance (recognition of a thing they can't use) and fail the very heuristic we're fixing. Full removal is correct. ### Open Decisions - **Should a read-only user get any path to *request* upload/annotate access?** Right now the capability simply vanishes with no explanation. For our family audience that may be perfectly fine (access is provisioned by an admin out-of-band) — but if a reader is *expected* to sometimes want to contribute, silent absence could read as "the feature is broken / missing." Options: (a) nothing — provisioning is out-of-band and understood _(lowest effort, fits the family-archive frame)_; (b) a small "request access" hint somewhere in the profile/help. This is a product call, not a blocker for #696.
Author
Owner

⚙️ Tobias Wendt — DevOps & Platform Engineer

Observations

  • Zero infrastructure surface. No Docker service, no env var, no Caddy rule, no CI workflow change. A {#if} gate plus tests — nothing for me to size or operate.
  • The issue correctly defers E2E: no new READ_ALL seed user is created, and it explicitly notes that if one is later added, creds go via Gitea secrets (${{ secrets.* }}), never hardcoded in workflow YAML. That's the right call and the right pattern.

Recommendations

  • Honor the stated local/CI split: run npx vitest run src/routes/layout.svelte.spec.ts locally; let the full sweep + the Chromium browser project run in CI. No pipeline change required.

Open Decisions

  • None from my angle — I checked for new services, ports, secrets, and CI steps; there are none.
## ⚙️ Tobias Wendt — DevOps & Platform Engineer ### Observations - Zero infrastructure surface. No Docker service, no env var, no Caddy rule, no CI workflow change. A `{#if}` gate plus tests — nothing for me to size or operate. - The issue correctly defers E2E: no new `READ_ALL` seed user is created, and it explicitly notes that *if* one is later added, creds go via Gitea secrets (`${{ secrets.* }}`), never hardcoded in workflow YAML. That's the right call and the right pattern. ### Recommendations - Honor the stated local/CI split: run `npx vitest run src/routes/layout.svelte.spec.ts` locally; let the full sweep + the Chromium browser project run in CI. No pipeline change required. ### Open Decisions - _None from my angle_ — I checked for new services, ports, secrets, and CI steps; there are none.
Author
Owner

📋 Elicit — Requirements Engineer & Business Analyst

Observations

  • Strong issue hygiene. The acceptance criteria are in testable Given-When-Then Gherkin, scope boundaries are explicit (#697 split out; canAnnotate rewiring named as a separate follow-up), and the audit table makes the as-is state traceable. This is the spec-dense style this project relies on.
  • The non-goals are stated clearly ("No backend model change, no i18n change, no API regeneration"), which prevents scope creep during implementation.

Recommendations

  • Tighten one AC clause against what's actually testable. Scenario 1's "and no other create/edit/delete/upload control is visible in the global chrome" is a broad, audit-backed claim — but only the upload button is verified by a test. Either reword it to scope to the upload button (the deterministic, testable assertion) and keep the broader sweep as the documented audit table, or explicitly label that clause "verified by audit, not automated." Right now there's a small traceability gap between the AC wording and test coverage.
  • The ANNOTATE_ALL users-cannot-comment note in "Out of scope" is correctly parked — but it's a real requirement gap (the intent of ANNOTATE_ALL is "read + annotate/comment", yet comment controls are gated on canWrite, and canAnnotate is derived-but-unused). Make sure a follow-up issue actually exists so this doesn't live only as a paragraph in a closed issue. A deferred requirement with no ticket is a lost requirement.

Open Decisions

  • What is the intended experience for a read-only user who wants to contribute? The issue removes the control but doesn't state whether read-only users are expected to ever need write/annotate access, or how they'd obtain it. This determines whether silent removal is the complete answer or whether a "request access" affordance is a (separate) requirement. Likely out-of-band/admin-provisioned for a family archive — but it should be a stated assumption, not an implicit one.
## 📋 Elicit — Requirements Engineer & Business Analyst ### Observations - Strong issue hygiene. The acceptance criteria are in testable Given-When-Then Gherkin, scope boundaries are explicit (#697 split out; `canAnnotate` rewiring named as a separate follow-up), and the audit table makes the as-is state traceable. This is the spec-dense style this project relies on. - The non-goals are stated clearly ("No backend model change, no i18n change, no API regeneration"), which prevents scope creep during implementation. ### Recommendations - **Tighten one AC clause against what's actually testable.** Scenario 1's "*and no other create/edit/delete/upload control is visible in the global chrome*" is a broad, audit-backed claim — but only the upload button is verified by a test. Either reword it to scope to the upload button (the deterministic, testable assertion) and keep the broader sweep as the documented audit table, or explicitly label that clause "verified by audit, not automated." Right now there's a small traceability gap between the AC wording and test coverage. - The `ANNOTATE_ALL` users-cannot-comment note in "Out of scope" is correctly parked — but it's a real requirement gap (the *intent* of `ANNOTATE_ALL` is "read + annotate/comment", yet comment controls are gated on `canWrite`, and `canAnnotate` is derived-but-unused). **Make sure a follow-up issue actually exists** so this doesn't live only as a paragraph in a closed issue. A deferred requirement with no ticket is a lost requirement. ### Open Decisions - **What is the intended experience for a read-only user who *wants* to contribute?** The issue removes the control but doesn't state whether read-only users are expected to ever need write/annotate access, or how they'd obtain it. This determines whether silent removal is the complete answer or whether a "request access" affordance is a (separate) requirement. Likely out-of-band/admin-provisioned for a family archive — but it should be a stated assumption, not an implicit one.
Author
Owner

🗳️ Decision Queue — Action Required

1 decision needs your input — and it is a follow-up, not a blocker for #696. The upload-button fix can ship as-is.

Product / UX

  • Do read-only users need a path to request upload/annotate access? Today the capability simply disappears with no explanation. For a family archive where access is provisioned by an admin out-of-band, silent removal is likely the complete and correct answer — but that should be a stated assumption, not an implicit one. Options:
    • (a) Nothing — provisioning is out-of-band and understood. Lowest effort, fits the family-archive frame; just record it as an assumption.
    • (b) A small "request access" hint in the profile/help area. More work, only worth it if readers are actually expected to want to contribute.
      (Raised by: Leonie, Elicit)

Everything else from the personas was a concrete recommendation, not a decision — captured inline above:

  • Nora: confirm POST /api/documents/batch-metadata (READ_ALL-gated) is non-mutating → separate ticket if needed.
  • Sara / Elicit: tighten AC scenario 1's "no other control" clause to match what's actually tested (audit-backed vs. automated).
  • Markus: move the DropZone "no internal guard" note from the issue into a code comment.
  • Elicit: ensure a real follow-up issue exists for ANNOTATE_ALL-can't-comment (canAnnotate rewiring).
## 🗳️ Decision Queue — Action Required _1 decision needs your input — and it is a **follow-up**, not a blocker for #696. The upload-button fix can ship as-is._ ### Product / UX - **Do read-only users need a path to *request* upload/annotate access?** Today the capability simply disappears with no explanation. For a family archive where access is provisioned by an admin out-of-band, **silent removal is likely the complete and correct answer** — but that should be a *stated assumption*, not an implicit one. Options: - **(a) Nothing** — provisioning is out-of-band and understood. _Lowest effort, fits the family-archive frame; just record it as an assumption._ - **(b) A small "request access" hint** in the profile/help area. _More work, only worth it if readers are actually expected to want to contribute._ _(Raised by: Leonie, Elicit)_ --- _Everything else from the personas was a concrete recommendation, not a decision — captured inline above:_ - _Nora:_ confirm `POST /api/documents/batch-metadata` (`READ_ALL`-gated) is non-mutating → separate ticket if needed. - _Sara / Elicit:_ tighten AC scenario 1's "no other control" clause to match what's actually tested (audit-backed vs. automated). - _Markus:_ move the DropZone "no internal guard" note from the issue into a code comment. - _Elicit:_ ensure a real follow-up issue exists for `ANNOTATE_ALL`-can't-comment (`canAnnotate` rewiring).
Sign in to join this conversation.
No Label P1-high bug ui
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#696