Files
API/docs/admin-roles.md
PaddyandClaude Opus 5.5 bfb8f2ff9c Add a read-only role per app for calendar, feedback and tickets
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>
2026-10-29 23:24:28 +01:00

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:

  • access and read on the same app are mutually exclusive. toPermissions returns null for a list holding both, so PUT /admin/users/:id/permissions and POST /admin/invitations answer 400. There is no "higher one wins".
  • admin (user management) has no read role. 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 access and 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). A read-only user gets 403 {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/me and GET /feedback/admin/me return readOnly: boolean alongside email and fullName. Frontends hide write controls when it is true; the API refuses the writes regardless.
  • GET /admin/me returns the full permissions list; isReadOnly(permissions, app) in admin.schema.ts is 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.