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>
74 lines
4.1 KiB
Markdown
74 lines
4.1 KiB
Markdown
# 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.
|