A grant can now be `read` instead of `access`: requireAppAccess lets it through for GET/HEAD/OPTIONS and answers 403 to every other method, so new write routes are covered without being marked. `read` and `access` on the same app are rejected together; user management has no read role. /tickets/admin/me and /feedback/admin/me report `readOnly`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4.1 KiB
Admin roles: full access and read-only
Added 2026-10-29. A user's permission is a list of (app, role) grants in
user_app_permissions (see src/models/admin/admin.schema.ts). Each app can now be granted at
one of two levels:
| Role | UI label (admin app) | Apps | Meaning |
|---|---|---|---|
access |
Vollzugriff | calendar, feedback, tickets, admin | Everything in that app, as before. |
read |
Nur lesen | calendar, feedback, tickets | See everything, change nothing. |
Rules:
accessandreadon the same app are mutually exclusive.toPermissionsreturnsnullfor a list holding both, soPUT /admin/users/:id/permissionsandPOST /admin/invitationsanswer 400. There is no "higher one wins".admin(user management) has noreadrole. Viewing it means viewing other people's email addresses, IPs and sessions; that is a different trust level from viewing concerts.- Existing grants were not migrated: every pre-existing row is
accessand still means full access.
Enforcement
requireAppAccess(app) (no role argument - which is how every app router uses it) does, after
the existing session/disabled/has-any-grant checks:
GET,HEAD,OPTIONS- allowed for any grant on the app.- anything else - allowed only with
access(canWrite). Aread-only user gets403 {status: 'FORBIDDEN', message: 'Du hast für diesen Bereich nur Lesezugriff.'}.
This is by method on purpose: a write route added later is covered without being marked. It relies on one convention that must stay true: admin routes read with GET and change with POST/PUT/PATCH/DELETE. When this was built, every GET handler in tickets, feedback and calendar was checked to call only read functions (no SQL writes, no mail). A GET with a side effect, or a POST that only reads, breaks the read-only role in one direction or the other.
Consequences worth knowing:
- "Resend confirmation email" (tickets) is a POST and therefore not available read-only - by decision: it has an effect outside the system.
- CSV exports and reports are GETs and therefore available read-only - also by decision. Read-only means all data, including personal data. A restricted variant would be a new role.
requireAppAccess(app, role)with an explicit role does not apply the method rule: the caller has already named exactly the grant it needs.
Telling the frontend
GET /tickets/admin/meandGET /feedback/admin/mereturnreadOnly: booleanalongsideemailandfullName. Frontends hide write controls when it istrue; the API refuses the writes regardless.GET /admin/mereturns the fullpermissionslist;isReadOnly(permissions, app)inadmin.schema.tsis the same rule.
Calendar
The API side is done: calendar writes sit behind requireAppAccess('calendar') and so refuse a
read-only user, and the optional-cookie reads (/:calendar/json, /ical) check
access.apps.includes('calendar'), which a read grant satisfies - read-only users see the
restricted calendars.
The current Angular frontend (Calendar_Frontend) was deliberately not changed, because it
is being rewritten in Next.js. Until then a read-only calendar user sees the edit buttons and
gets the 403 above when using them. Contract for the rewrite: there is no /calendar/.../me
with readOnly; read GET /admin/me and compute isReadOnly(permissions, 'calendar') - or add
a calendar /me mirroring tickets/feedback.
Adding a finer role later
canWrite checks for access specifically, so a new role (e.g. tickets:redeem) grants no
writes through the method rule. Watch out: tickets and feedback apply the role-less
requireAdminAuth once at the top of their admin router, so that gate would already refuse the
new role's POST before any route-level requireAppAccess(app, 'redeem') runs. A finer role
therefore needs canWrite (or the top-level gate) taught about it, not just a route-level check.