Compare commits

..
Author SHA1 Message Date
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
Paddy bc865813cb Merge pull request 'Fix issue where email would still be required for personalized vouchers in the backend' (#19) from feature/optional-personalized-voucher-email into master
Jenkins Production Deployment
Reviewed-on: #19
2026-09-21 21:01:40 +00:00
Paddy e8f971b54e Fix issue where email would still be required for personalized vouchers in the backend 2026-09-21 23:01:22 +02:00
17 changed files with 391 additions and 370 deletions

No files matched your search

+3 -2
View File
@@ -47,8 +47,9 @@ and `/admin` for the JSON routes. Sessions are httpOnly cookies scoped to
`.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public `.nachklang.art`, so one sign-in covers every app. Accounts are **invite-only** — public
sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users. sign-up is disabled, and `invitations.plugin.ts` is the only code that creates users.
A permission is **(app, role)** in `user_app_permissions`, keyed on A permission is **(app, role)** in `user_app_permissions`, keyed on
`(user_id, app, role)` so one user can hold several roles per app. `access` is the only role `(user_id, app, role)` so one user can hold several roles per app. Two roles exist: `access`
today and means "may use this app at all"; `APP_ROLES` in `admin.schema.ts` is the contract, (full access) and `read` (read-only, not for `admin`) - see `docs/admin-roles.md`, including
how `requireAppAccess` enforces `read` by HTTP method; `APP_ROLES` in `admin.schema.ts` is the contract,
and a role not listed there is rejected rather than written. `requireAppAccess(app)` in and a role not listed there is rejected rather than written. `requireAppAccess(app)` in
`admin.middleware.ts` is the single authenticator - it takes an optional second argument to `admin.middleware.ts` is the single authenticator - it takes an optional second argument to
narrow to one role, and queries the database on every request (no cookie cache) so disabling narrow to one role, and queries the database on every request (no cookie cache) so disabling
+73
View File
@@ -0,0 +1,73 @@
# 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.
-65
View File
@@ -1,65 +0,0 @@
# Calendar iCal export and subscriptions
How `GET /calendar/events/{calendar}/ical` builds its file, what changed on 2026-09-28, and the
endpoint the calendar app uses to hand editors ready-made subscription URLs.
## The export
`icalgenerator.service.ts` turns a calendar's `PUBLIC` events into one `VCALENDAR`:
- Timed events: `DTSTART;TZID=Europe/Berlin:` / `DTEND;TZID=Europe/Berlin:` formatted with the
**API process's local** `getHours()` etc. The times are therefore only right if the process runs
in Berlin time (`TZ=Europe/Berlin`, or a server in that zone). Nothing enforces this. Inferred
from the code; not checked against the production server.
- Whole-day events: `VALUE=DATE`, and the stored end date is treated as the *inclusive* last day -
the generator adds one day for `DTEND`. The calendar app stores whole-day events as 00:00 to
23:59 Berlin time for this reason.
- `repeatFrequency` is written verbatim as `RRULE:FREQ=<value>` (no interval, count or end).
- `public` needs no credential; the other calendars take `?password=` because a calendar client
cannot send the session cookie (see `calendar-auth-migration.md`).
## RFC 5545 conformance (2026-09-28)
Before this date the generator wrote text values raw. A line break in a description ended the
`DESCRIPTION` property early and the rest was read as a garbage property; unescaped `,` and `;`
are invalid in text values. The new calendar frontend has a multi-line description field, so this
went from theoretical to certain. Now:
- `SUMMARY`, `DESCRIPTION` and `LOCATION` go through `escapeText` (`\` `;` `,` and line breaks,
backslash first).
- `URL` is a URI value and is not escaped; line breaks are stripped from it.
- Every content line is folded at 75 octets (UTF-8 aware - a multi-byte character is never split)
and the file uses CRLF line endings throughout (`toContentLines`).
- `formatDate` no longer shifts the caller's `Date` when it adds the whole-day end's extra day.
- The response is `Content-Type: text/calendar; charset=utf-8`. Before, Express inferred
`text/html` from the string body; calendar apps coped, but it was wrong.
Pinned by `test/calendar/icalgenerator.service.test.ts`.
Not changed, worth knowing: `ORGANIZER` is written as a bare display name (`ORGANIZER:Anna`),
while RFC 5545 expects a cal-address (`ORGANIZER;CN=Anna:mailto:…`). Calendar apps have tolerated
it so far.
## `GET /calendar/events/subscriptions`
For the calendar app's "Abonnieren" dialog. Behind `requireAppAccess('calendar')`; the shared
password is **not** accepted here (one calendar's password must not reveal the others).
Returns `[{calendar, icalUrl}]` in `calendarNames` order, built from `API_BASE_URL`:
- `public` without a password;
- every other calendar with `?password=<its shared password>`, URL-encoded;
- a calendar whose credential env var is unset is left out rather than listed with a URL that
cannot work.
`choir` and `birthdays` share `CHOIR_CREDENTIAL`, so rotating it changes both URLs - and breaks
every existing subscription to either. The response is sent with `Cache-Control: no-store`
because it carries the passwords.
Signed-in editors can read every event of every calendar anyway, so handing them the passwords
exposes nothing they could not already see.
## Found while doing this
- Only the admin pool reads `DB_PORT`; `Calendar.db.ts` (and the feedback/tickets pools) always
connect to 3306. A local test database for the calendar has to listen there.
+15 -1
View File
@@ -2,9 +2,14 @@ import express from 'express';
import {fromNodeHeaders} from 'better-auth/node'; import {fromNodeHeaders} from 'better-auth/node';
import {auth} from './admin.auth.js'; import {auth} from './admin.auth.js';
import * as UsersService from './users/users.admin.service.js'; import * as UsersService from './users/users.admin.service.js';
import {AppName, AppPermission, AppRole} from './admin.schema.js'; import {AppName, AppPermission, AppRole, canWrite} from './admin.schema.js';
import {sendServerError} from './admin.errors.js'; import {sendServerError} from './admin.errors.js';
// What a read-only grant may do. Every admin route reads with GET and changes
// with POST/PUT/PATCH/DELETE - keep it that way, or a read-only user either
// loses a view or gains a write.
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/** /**
* The one authenticator for every admin area in this API. It replaces * The one authenticator for every admin area in this API. It replaces
* feedback.auth.ts and tickets.auth.ts, which each re-implemented the same * feedback.auth.ts and tickets.auth.ts, which each re-implemented the same
@@ -131,6 +136,15 @@ export const requireAppAccess = (app: AppName, role?: AppRole): express.RequestH
return; return;
} }
// The read-only level, enforced by method rather than per route so a
// write route added later is covered without being marked. Only the
// role-less form applies it: a caller asking for a specific role has
// already said exactly what is required.
if (role === undefined && !READ_METHODS.has(req.method) && !canWrite(access.permissions, app)) {
forbidden(res, 'Du hast für diesen Bereich nur Lesezugriff.');
return;
}
res.locals.admin = access; res.locals.admin = access;
next(); next();
} catch (e: any) { } catch (e: any) {
+36 -4
View File
@@ -35,13 +35,26 @@ export const isAppName = (value: unknown): value is AppName => {
*/ */
export const ACCESS_ROLE = 'access'; export const ACCESS_ROLE = 'access';
/**
* Read-only access to an app: everything `access` may see, nothing it may
* change. requireAppAccess enforces it by HTTP method - a user holding only
* this role gets GET/HEAD/OPTIONS and a 403 for anything else - so a new write
* route is covered without anyone remembering to mark it.
*
* It and `access` are mutually exclusive on one app (toPermissions rejects a
* list holding both), because "full access and also read-only" has no sensible
* reading. The `admin` app deliberately has no read role: viewing user
* management means viewing other people's addresses, IPs and sessions.
*/
export const READ_ROLE = 'read';
export type AppRole = string; export type AppRole = string;
/** Every role that exists, per app, in display order. Extend to add one. */ /** Every role that exists, per app, in display order. Extend to add one. */
export const APP_ROLES: Record<AppName, readonly AppRole[]> = { export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
calendar: [ACCESS_ROLE], calendar: [ACCESS_ROLE, READ_ROLE],
feedback: [ACCESS_ROLE], feedback: [ACCESS_ROLE, READ_ROLE],
tickets: [ACCESS_ROLE], tickets: [ACCESS_ROLE, READ_ROLE],
admin: [ACCESS_ROLE] admin: [ACCESS_ROLE]
}; };
@@ -85,7 +98,7 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
} }
const seen = new Set<string>(); const seen = new Set<string>();
return permissions.filter(permission => { const unique = permissions.filter(permission => {
const key = `${permission.app}:${permission.role}`; const key = `${permission.app}:${permission.role}`;
if (seen.has(key)) { if (seen.has(key)) {
return false; return false;
@@ -93,6 +106,11 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
seen.add(key); seen.add(key);
return true; return true;
}); });
// Rejected rather than resolved to either level: whichever one we picked,
// the caller asked for something else.
const contradictory = APP_NAMES.some(app => seen.has(`${app}:${ACCESS_ROLE}`) && seen.has(`${app}:${READ_ROLE}`));
return contradictory ? null : unique;
}; };
/** The distinct apps a permission list grants any access to. */ /** The distinct apps a permission list grants any access to. */
@@ -100,6 +118,20 @@ export const appsOf = (permissions: AppPermission[]): AppName[] => {
return APP_NAMES.filter(app => permissions.some(permission => permission.app === app)); return APP_NAMES.filter(app => permissions.some(permission => permission.app === app));
}; };
/**
* Whether these grants allow changing anything in `app`. Only `access` does:
* `read` is the read-only level, and a finer role added later grants exactly
* what its own check says, not writes in general.
*/
export const canWrite = (permissions: AppPermission[], app: AppName): boolean => {
return permissions.some(permission => permission.app === app && permission.role === ACCESS_ROLE);
};
/** Holds some grant on `app`, but none that allows changes. */
export const isReadOnly = (permissions: AppPermission[], app: AppName): boolean => {
return appsOf(permissions).includes(app) && !canWrite(permissions, app);
};
export interface UserTable { export interface UserTable {
id: string; id: string;
name: string; name: string;
@@ -21,13 +21,7 @@ dotenv.config();
* test/calendar/credentials.service.test.ts. * test/calendar/credentials.service.test.ts.
*/ */
/** const credentialFor = (calendarName: string): string | undefined => {
* The shared password for a calendar, or undefined for `public` (which needs
* none), an unknown calendar, or one whose credential is not configured.
* Exported for the subscriptions endpoint, which hands signed-in editors the
* ready-made iCal URLs - see GET /calendar/events/subscriptions.
*/
export const credentialFor = (calendarName: string): string | undefined => {
switch (calendarName) { switch (calendarName) {
case 'members': case 'members':
return process.env.MEMBER_CREDENTIAL; return process.env.MEMBER_CREDENTIAL;
+1 -60
View File
@@ -8,7 +8,6 @@ import * as EventService from './events.service.js';
import * as iCalService from './icalgenerator.service.js'; import * as iCalService from './icalgenerator.service.js';
import * as CredentialService from './credentials.service.js'; import * as CredentialService from './credentials.service.js';
import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js'; import {requireAppAccess, resolveAccess, AdminAccess} from '../../admin/admin.middleware.js';
import {API_BASE_URL} from '../../admin/admin.config.js';
import {Guid} from 'guid-typescript'; import {Guid} from 'guid-typescript';
import logger from '../../../middleware/logger.js'; import logger from '../../../middleware/logger.js';
@@ -424,12 +423,7 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
let file = await iCalService.convertToIcal(events); let file = await iCalService.convertToIcal(events);
// Send the ical file back // Send the ical file back
// text/calendar, not the text/html that send() infers from a string - calendar apps res.set({'Content-Disposition': 'attachment; filename=' + fileName + '.ics'});
// coped with the wrong type, but it was wrong (RFC 5545 §8.1).
res.set({
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'attachment; filename=' + fileName + '.ics'
});
res.status(200).send(file); res.status(200).send(file);
} catch (e: any) { } catch (e: any) {
let errorGuid = Guid.create().toString(); let errorGuid = Guid.create().toString();
@@ -442,59 +436,6 @@ eventsRouter.get('/:calendar/ical', async (req: Request, res: Response) => {
} }
}); });
/**
* @swagger
* /calendar/events/subscriptions:
* get:
* summary: The iCal subscription URL of every calendar
* description: >
* Ready-to-use iCal URLs for each calendar, with the calendar's shared password
* already in the query string where it needs one - for the calendar app's
* "Abonnieren" dialog. A calendar whose password is not configured is left out
* rather than listed with a URL that cannot work. Requires a signed-in account
* with the calendar permission: those editors can read every event anyway, so the
* passwords tell them nothing new. Never cached.
* tags:
* - calendar
* security:
* - AdminSessionCookie: []
* responses:
* 200:
* description: Success
* content:
* application/json:
* schema:
* type: array
* items:
* type: object
* properties:
* calendar:
* type: string
* example: members
* icalUrl:
* type: string
* example: https://api.nachklang.art/calendar/events/members/ical?password=…
* 401:
* description: Unauthorized - not signed in
* 403:
* description: Forbidden - the account lacks the calendar permission
*/
eventsRouter.get('/subscriptions', requireCalendarAccess, (req: Request, res: Response) => {
const base = API_BASE_URL.replace(/\/+$/, '');
const subscriptions = Array.from(calendarNames.keys()).flatMap((calendar) => {
const url = `${base}/calendar/events/${calendar}/ical`;
if (calendar === 'public') {
return [{calendar, icalUrl: url}];
}
const password = CredentialService.credentialFor(calendar);
return password ? [{calendar, icalUrl: `${url}?password=${encodeURIComponent(password)}`}] : [];
});
// These URLs carry the shared passwords; keep them out of every cache on the way.
res.set('Cache-Control', 'no-store');
res.status(200).send(subscriptions);
});
/** /**
* @swagger * @swagger
* /calendar/events: * /calendar/events:
@@ -13,7 +13,7 @@ export const convertToIcal = async (events: Event[]): Promise<string> => {
addEventToFile(ical, event); addEventToFile(ical, event);
} }
return toContentLines(serializeIcalFile(ical)); return serializeIcalFile(ical);
} catch (err) { } catch (err) {
throw err; throw err;
} }
@@ -116,11 +116,9 @@ const addEventToFile = (ical: iCalFile, event: Event) => {
* @param event * @param event
*/ */
const createIcalEvent = (event: Event): iCalEvent => { const createIcalEvent = (event: Event): iCalEvent => {
// Free text is escaped (see escapeText); the URL is a URI value, which has let description = event.description ? event.description + '\n' : '';
// no escapes - it only must not break the line. let location = event.location ? event.location + '\n' : '';
let description = event.description ? escapeText(event.description) + '\n' : ''; let url = event.url ? event.url + '\n' : '';
let location = event.location ? escapeText(event.location) + '\n' : '';
let url = event.url ? event.url.replace(/[\r\n]+/g, '') + '\n' : '';
return { return {
header: 'BEGIN:VEVENT\n', header: 'BEGIN:VEVENT\n',
@@ -130,7 +128,7 @@ const createIcalEvent = (event: Event): iCalEvent => {
start: formatDate(event.startDateTime, event.wholeDay) + '\n', start: formatDate(event.startDateTime, event.wholeDay) + '\n',
end: formatDate(event.endDateTime, event.wholeDay, true) + '\n', end: formatDate(event.endDateTime, event.wholeDay, true) + '\n',
repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '', repeatFrequency: event.repeatFrequency ? event.repeatFrequency + '\n' : '',
summary: escapeText(event.name) + '\n', summary: event.name + '\n',
description: description, description: description,
location: location, location: location,
url: url, url: url,
@@ -145,11 +143,8 @@ const createIcalEvent = (event: Event): iCalEvent => {
* @param wholeDayFormat * @param wholeDayFormat
* @param isEndDate * @param isEndDate
*/ */
const formatDate = (input: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => { const formatDate = (date: Date, wholeDayFormat: boolean = false, isEndDate: boolean = false): string => {
let returnString = ''; let returnString = '';
// A copy: this used to shift the caller's Date in place, so the event object
// came out of the export with its end a day later than it went in.
const date = new Date(input);
// We need to do this for whole day events as otherwise the event ends one day too early // We need to do this for whole day events as otherwise the event ends one day too early
if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1) if(wholeDayFormat && isEndDate) date.setDate(date.getDate() + 1)
@@ -189,58 +184,6 @@ export interface iCalEvent {
footer: string; footer: string;
} }
/**
* RFC 5545 §3.3.11 TEXT escaping. Without it a description with a line break
* ended the DESCRIPTION property early and the rest of the text was read as a
* new (garbage) property, and an unescaped `,` or `;` is invalid in the value.
* The backslash goes first, or the escapes added after it would be doubled.
*/
export const escapeText = (value: string): string =>
value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n');
const MAX_LINE_OCTETS = 75;
/**
* RFC 5545 §3.1 line folding: no content line longer than 75 octets, a longer
* one continues on the next line after a single leading space. Counts UTF-8
* octets, not characters, and never splits inside a character - "ü" is two
* octets and a fold between them corrupts it.
*/
export const foldLine = (line: string): string => {
const parts: string[] = [];
let current = '';
let octets = 0;
for (const char of line) {
const size = Buffer.byteLength(char, 'utf8');
// A continuation line's leading space counts towards its 75.
const limit = parts.length === 0 ? MAX_LINE_OCTETS : MAX_LINE_OCTETS - 1;
if (octets + size > limit) {
parts.push(current);
current = '';
octets = 0;
}
current += char;
octets += size;
}
parts.push(current);
return parts.join('\r\n ');
};
/**
* The generator builds the file with bare `\n` line ends; the format wants
* every content line folded and terminated by CRLF (RFC 5545 §3.1).
*/
const toContentLines = (ical: string): string =>
ical
.split('\n')
.filter((line) => line !== '')
.map(foldLine)
.join('\r\n') + '\r\n';
/** /**
* Checks if a given string is null, undefined or blank * Checks if a given string is null, undefined or blank
* @param str The string to check * @param str The string to check
+10 -1
View File
@@ -3,6 +3,7 @@
*/ */
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../feedback.auth.js'; import {requireAdminAuth} from '../feedback.auth.js';
import {isReadOnly} from '../../admin/admin.schema.js';
import {sendServerError} from '../feedback.errors.js'; import {sendServerError} from '../feedback.errors.js';
import {eventsAdminRouter} from './events.admin.router.js'; import {eventsAdminRouter} from './events.admin.router.js';
import {songsAdminRouter} from './songs.admin.router.js'; import {songsAdminRouter} from './songs.admin.router.js';
@@ -40,13 +41,21 @@ adminRouter.use(requireAdminAuth);
* type: string * type: string
* fullName: * fullName:
* type: string * type: string
* readOnly:
* type: boolean
* description: Holds only the read role for feedback - every write answers 403.
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403: * 403:
* description: Signed in without the permission for this app, or account disabled * description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.get('/me', (req: Request, res: Response) => { adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); res.status(200).send({
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// Lets the frontend hide what the API would refuse anyway.
readOnly: isReadOnly(res.locals.admin.permissions, 'feedback')
});
}); });
/** /**
+19 -1
View File
@@ -1,5 +1,6 @@
import express, {Request, Response} from 'express'; import express, {Request, Response} from 'express';
import {requireAdminAuth} from '../tickets.auth.js'; import {requireAdminAuth} from '../tickets.auth.js';
import {isReadOnly} from '../../admin/admin.schema.js';
import {vouchersAdminRouter} from './vouchers.admin.router.js'; import {vouchersAdminRouter} from './vouchers.admin.router.js';
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js'; import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
import {eventsAdminRouter} from './events.admin.router.js'; import {eventsAdminRouter} from './events.admin.router.js';
@@ -21,13 +22,30 @@ adminRouter.use(requireAdminAuth);
* responses: * responses:
* 200: * 200:
* description: Success * description: Success
* content:
* application/json:
* schema:
* type: object
* properties:
* email:
* type: string
* fullName:
* type: string
* readOnly:
* type: boolean
* description: Holds only the read role for tickets - every write answers 403.
* 401: * 401:
* description: Unauthorized * description: Unauthorized
* 403: * 403:
* description: Signed in without the permission for this app, or account disabled * description: Signed in without the permission for this app, or account disabled
*/ */
adminRouter.get('/me', (req: Request, res: Response) => { adminRouter.get('/me', (req: Request, res: Response) => {
res.status(200).send({email: res.locals.admin.email, fullName: res.locals.admin.displayName}); res.status(200).send({
email: res.locals.admin.email,
fullName: res.locals.admin.displayName,
// Lets the frontend hide what the API would refuse anyway.
readOnly: isReadOnly(res.locals.admin.permissions, 'tickets')
});
}); });
adminRouter.use('/vouchers', vouchersAdminRouter); adminRouter.use('/vouchers', vouchersAdminRouter);
@@ -129,12 +129,13 @@ vouchersAdminRouter.post('/wildcard', async (req: Request, res: Response) => {
* type: array * type: array
* items: * items:
* type: object * type: object
* required: [name, email, eventIds] * required: [name, eventIds]
* properties: * properties:
* name: * name:
* type: string * type: string
* email: * email:
* type: string * type: string
* description: Optional - a guest can supply their own email when redeeming.
* eventIds: * eventIds:
* type: array * type: array
* items: * items:
@@ -123,10 +123,10 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
throw new Error('at least one row is required'); throw new Error('at least one row is required');
} }
for (const row of rows) { for (const row of rows) {
if (!row.name || !row.email || row.eventIds.length === 0) { if (!row.name || row.eventIds.length === 0) {
throw new Error('each row requires a name, email, and at least one eligible event'); throw new Error('each row requires a name and at least one eligible event');
} }
if (!isValidEmail(row.email)) { if (row.email && !isValidEmail(row.email)) {
throw new Error(`"${row.email}" does not look like a valid email address`); throw new Error(`"${row.email}" does not look like a valid email address`);
} }
} }
@@ -147,7 +147,7 @@ export const generatePersonalizedBatch = async (rows: PersonalizedRowInput[], cr
codes.push(code); codes.push(code);
await conn.query( await conn.query(
'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)', 'INSERT INTO voucher_codes (code, status, max_guests, prefill_name, prefill_email, batch_id, created_by_email) VALUES (?,?,?,?,?,?,?)',
[code, 'UNUSED', row.maxGuests, row.name, row.email, batchId, createdByEmail] [code, 'UNUSED', row.maxGuests, row.name, row.email || null, batchId, createdByEmail]
); );
for (const eventId of row.eventIds) { for (const eventId of row.eventIds) {
await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]); await conn.query('INSERT INTO voucher_code_events (code, event_id) VALUES (?,?)', [code, eventId]);
+59 -1
View File
@@ -15,7 +15,7 @@ import {requireAppAccess, requireSignedIn, resolveAccess} from '../../src/models
const mockGetSession = auth.api.getSession as unknown as Mock; const mockGetSession = auth.api.getSession as unknown as Mock;
const mockLoadAccess = UsersService.loadAccess as Mock; const mockLoadAccess = UsersService.loadAccess as Mock;
const makeReq = (): Request => ({headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request); const makeReq = (method = 'GET'): Request => ({method, headers: {cookie: 'nachklang.session_token=abc'}} as unknown as Request);
const makeRes = (): Response => { const makeRes = (): Response => {
const res: any = {}; const res: any = {};
@@ -219,4 +219,62 @@ describe('requireAppAccess', () => {
expect(next).toHaveBeenCalled(); expect(next).toHaveBeenCalled();
}); });
describe('read-only grant', () => {
const readOnlyUser = {
...activeUser,
permissions: [{app: 'tickets', role: 'read'}],
apps: ['tickets']
};
it.each(['GET', 'HEAD', 'OPTIONS'])('lets %s through', async method => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(readOnlyUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(method), res, next);
expect(next).toHaveBeenCalled();
});
it.each(['POST', 'PUT', 'PATCH', 'DELETE'])('403s %s', async method => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(readOnlyUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq(method), res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith(expect.objectContaining({message: 'Du hast für diesen Bereich nur Lesezugriff.'}));
expect(next).not.toHaveBeenCalled();
});
it('does not let full access to another app write here', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue({
...activeUser,
permissions: [{app: 'tickets', role: 'read'}, {app: 'feedback', role: 'access'}],
apps: ['feedback', 'tickets']
});
const res = makeRes();
const next = vi.fn();
await requireAppAccess('tickets')(makeReq('POST'), res, next);
expect(res.status).toHaveBeenCalledWith(403);
});
it('lets full access write', async () => {
mockGetSession.mockResolvedValue({user: {id: 'u1'}});
mockLoadAccess.mockResolvedValue(activeUser);
const res = makeRes();
const next = vi.fn();
await requireAppAccess('feedback')(makeReq('DELETE'), res, next);
expect(next).toHaveBeenCalled();
});
});
}); });
+41
View File
@@ -1,9 +1,12 @@
import {describe, expect, it} from 'vitest'; import {describe, expect, it} from 'vitest';
import { import {
ACCESS_ROLE, ACCESS_ROLE,
READ_ROLE,
appsOf, appsOf,
canWrite,
isAppPermission, isAppPermission,
isAppRole, isAppRole,
isReadOnly,
toPermissions toPermissions
} from '../../src/models/admin/admin.schema.js'; } from '../../src/models/admin/admin.schema.js';
@@ -59,6 +62,44 @@ describe('toPermissions', () => {
it('reads an empty list as "no permissions", not as invalid', () => { it('reads an empty list as "no permissions", not as invalid', () => {
expect(toPermissions([])).toEqual([]); expect(toPermissions([])).toEqual([]);
}); });
it('reads the read-only role', () => {
expect(toPermissions([{app: 'tickets', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: READ_ROLE}
]);
});
it('rejects full and read-only access to the same app together', () => {
expect(toPermissions(['tickets', {app: 'tickets', role: READ_ROLE}])).toBeNull();
});
it('allows full access to one app next to read-only access to another', () => {
expect(toPermissions(['tickets', {app: 'feedback', role: READ_ROLE}])).toEqual([
{app: 'tickets', role: ACCESS_ROLE},
{app: 'feedback', role: READ_ROLE}
]);
});
it('rejects read-only access to user management', () => {
expect(toPermissions([{app: 'admin', role: READ_ROLE}])).toBeNull();
});
});
describe('canWrite / isReadOnly', () => {
it('lets only the access role write', () => {
const permissions = [
{app: 'tickets' as const, role: ACCESS_ROLE},
{app: 'feedback' as const, role: READ_ROLE}
];
expect(canWrite(permissions, 'tickets')).toBe(true);
expect(canWrite(permissions, 'feedback')).toBe(false);
expect(isReadOnly(permissions, 'tickets')).toBe(false);
expect(isReadOnly(permissions, 'feedback')).toBe(true);
});
it('is neither for an app without any grant', () => {
expect(canWrite([], 'calendar')).toBe(false);
expect(isReadOnly([], 'calendar')).toBe(false);
});
}); });
describe('isAppRole', () => { describe('isAppRole', () => {
-49
View File
@@ -68,8 +68,6 @@ const validEvent = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
process.env.MEMBER_CREDENTIAL = 'member-secret'; process.env.MEMBER_CREDENTIAL = 'member-secret';
process.env.CHOIR_CREDENTIAL = 'choir & more';
process.env.MANAGEMENT_CREDENTIAL = '';
(EventService.getAllEvents as any).mockResolvedValue([]); (EventService.getAllEvents as any).mockResolvedValue([]);
(EventService.getAllEventsAdmin as any).mockResolvedValue([]); (EventService.getAllEventsAdmin as any).mockResolvedValue([]);
(EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'}); (EventService.getNextUpcomingEvent as any).mockResolvedValue({eventId: 1, name: 'Konzert'});
@@ -166,13 +164,6 @@ describe('reading', () => {
expect(auth.api.getSession).not.toHaveBeenCalled(); expect(auth.api.getSession).not.toHaveBeenCalled();
}); });
it('serves the iCal export as text/calendar', async () => {
const res = await request(app).get('/calendar/events/public/ical').expect(200);
expect(res.headers['content-type']).toBe('text/calendar; charset=utf-8');
expect(res.headers['content-disposition']).toBe('attachment; filename=Nachklang_calendar.ics');
});
it('keeps the shared password working on the iCal export', async () => { it('keeps the shared password working on the iCal export', async () => {
(EventService.getAllEvents as any).mockResolvedValue([]); (EventService.getAllEvents as any).mockResolvedValue([]);
@@ -231,43 +222,3 @@ describe('writing', () => {
.expect(401); .expect(401);
}); });
}); });
describe('subscriptions', () => {
it('is not for anyone signed out, or signed in without the calendar permission', async () => {
await request(app).get('/calendar/events/subscriptions').expect(401);
signedInAs(['tickets']);
await request(app).get('/calendar/events/subscriptions').expect(403);
});
it('refuses the shared password as a way in', async () => {
// Otherwise one calendar's password would unlock all the others.
await request(app).get('/calendar/events/subscriptions').query({password: 'member-secret'}).expect(401);
});
it('lists a ready-made iCal URL per configured calendar, never cached', async () => {
signedInAs(['calendar']);
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.body).toEqual([
{calendar: 'public', icalUrl: 'http://localhost:3000/calendar/events/public/ical'},
{calendar: 'members', icalUrl: 'http://localhost:3000/calendar/events/members/ical?password=member-secret'},
// URL-encoded, so a password with & or spaces survives.
{calendar: 'choir', icalUrl: 'http://localhost:3000/calendar/events/choir/ical?password=choir%20%26%20more'},
// management is missing: its credential is unset, and a URL without one could never work.
{calendar: 'birthdays', icalUrl: 'http://localhost:3000/calendar/events/birthdays/ical?password=choir%20%26%20more'}
]);
});
it('hands out URLs that actually open the calendar', async () => {
signedInAs(['calendar']);
const res = await request(app).get('/calendar/events/subscriptions').expect(200);
const members = res.body.find((s: {calendar: string}) => s.calendar === 'members');
signedOut();
const path = new URL(members.icalUrl);
await request(app).get(path.pathname + path.search).expect(200);
});
});
-111
View File
@@ -1,111 +0,0 @@
import {describe, expect, it} from 'vitest';
import {convertToIcal, escapeText, foldLine} from '../../src/models/calendar/events/icalgenerator.service.js';
import {Event} from '../../src/models/calendar/events/event.interface.js';
/**
* The iCal export is read by every subscribed calendar app, so a malformed
* file is an outage nobody on our side sees. These pin RFC 5545's text
* escaping and line folding, which the generator did not do before: a line
* break in a description ended the DESCRIPTION property early, and the new
* calendar frontend's multi-line description field would have produced
* exactly that.
*/
const event = (overrides: Partial<Event> = {}): Event => ({
eventId: 1,
calendarId: 1,
uuid: 'uuid-1',
name: 'Konzert',
description: '',
startDateTime: new Date('2026-10-03T17:00:00Z'),
endDateTime: new Date('2026-10-03T19:00:00Z'),
createdDate: new Date('2026-01-01T12:00:00Z'),
location: '',
createdBy: 'Anna',
url: '',
wholeDay: false,
repeatFrequency: '',
...overrides
});
/** RFC 5545 unfolding: a CRLF followed by one space or tab joins the lines. */
const unfold = (ical: string): string[] => ical.replace(/\r\n[ \t]/g, '').split('\r\n').filter(Boolean);
describe('escapeText', () => {
it('escapes backslash, semicolon, comma and line breaks', () => {
expect(escapeText('a\\b;c,d')).toBe('a\\\\b\\;c\\,d');
expect(escapeText('Zeile 1\nZeile 2\r\nZeile 3\rZeile 4')).toBe('Zeile 1\\nZeile 2\\nZeile 3\\nZeile 4');
});
it('escapes the backslash first, so the others are not doubled', () => {
expect(escapeText(';')).toBe('\\;');
});
});
describe('foldLine', () => {
it('leaves a short line alone', () => {
expect(foldLine('SUMMARY:Konzert')).toBe('SUMMARY:Konzert');
});
it('keeps every physical line within 75 octets and unfolds back to the original', () => {
const line = 'DESCRIPTION:' + 'Probe im Gemeindehaus Süd – bitte Noten mitbringen. '.repeat(6);
const folded = foldLine(line);
for (const physical of folded.split('\r\n')) {
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
}
expect(folded.replace(/\r\n /g, '')).toBe(line);
});
it('never splits a multi-byte character', () => {
const line = 'SUMMARY:' + 'ü'.repeat(80);
for (const physical of foldLine(line).split('\r\n')) {
expect(physical.replace(/^ /, '')).toMatch(/^(SUMMARY:)?ü*$/);
expect(Buffer.byteLength(physical, 'utf8')).toBeLessThanOrEqual(75);
}
});
});
describe('convertToIcal', () => {
it('terminates every line with CRLF', async () => {
const ical = await convertToIcal([event()]);
expect(ical.endsWith('END:VCALENDAR\r\n')).toBe(true);
expect(ical.replace(/\r\n/g, '')).not.toContain('\n');
});
it('keeps a multi-line description inside one DESCRIPTION property', async () => {
const ical = await convertToIcal([event({description: 'Einlass 18:30\nBeginn 19:00; Eintritt frei, Spenden willkommen'})]);
const lines = unfold(ical);
expect(lines).toContain('DESCRIPTION:Einlass 18:30\\nBeginn 19:00\\; Eintritt frei\\, Spenden willkommen');
// Nothing leaked out as a line of its own.
expect(lines.some((l) => l.startsWith('Beginn'))).toBe(false);
});
it('escapes the title and the location too', async () => {
const lines = unfold(await convertToIcal([event({name: 'Konzert, Teil 2', location: 'Kirche; Karlsruhe'})]));
expect(lines).toContain('SUMMARY:Konzert\\, Teil 2');
expect(lines).toContain('LOCATION:Kirche\\; Karlsruhe');
});
it('does not escape the URL, but keeps it on one line', async () => {
const lines = unfold(await convertToIcal([event({url: 'https://nachklang.art/konzert?a=1,2'})]));
expect(lines).toContain('URL:https://nachklang.art/konzert?a=1,2');
});
it('still writes the yearly rule for birthdays', async () => {
const lines = unfold(await convertToIcal([event({repeatFrequency: 'YEARLY', wholeDay: true})]));
expect(lines).toContain('RRULE:FREQ=YEARLY');
});
it('leaves the event\'s own dates untouched', async () => {
// formatDate used to add the whole-day end's extra day to the caller's Date in place.
const whole = event({
wholeDay: true,
startDateTime: new Date('2026-10-03T00:00:00'),
endDateTime: new Date('2026-10-03T23:59:00')
});
const endBefore = whole.endDateTime.getTime();
await convertToIcal([whole]);
expect(whole.endDateTime.getTime()).toBe(endBefore);
});
});
+121
View File
@@ -0,0 +1,121 @@
import {describe, it, expect, beforeAll, beforeEach, afterAll} from 'vitest';
import type {Application} from 'express';
import {createApp} from '../../src/app.factory.js';
import {READ_ROLE} from '../../src/models/admin/admin.schema.js';
import {closeDatabase, createAndAcceptInvitation, resetDatabase} from './helpers.js';
/**
* The read-only role end to end: a real session cookie through the real
* middleware into each app's routers. The writes are rejected before any
* handler runs, which is why this suite needs no tickets/feedback/calendar
* database - a 403 here proves the gate held, not that a query failed.
*/
let app: Application;
beforeAll(() => {
app = createApp();
});
beforeEach(async () => {
await resetDatabase();
});
afterAll(async () => {
await closeDatabase();
});
const readOnlyEverywhere = async () => {
return createAndAcceptInvitation(app, 'reader@nachklang.art', 'Reader', [
{app: 'tickets', role: READ_ROLE},
{app: 'feedback', role: READ_ROLE},
{app: 'calendar', role: READ_ROLE}
]);
};
describe('read-only access', () => {
it('reports readOnly on each app\'s /me', async () => {
const {agent} = await readOnlyEverywhere();
const tickets = await agent.get('/tickets/admin/me');
const feedback = await agent.get('/feedback/admin/me');
expect(tickets.status).toBe(200);
expect(tickets.body.readOnly).toBe(true);
expect(feedback.status).toBe(200);
expect(feedback.body.readOnly).toBe(true);
});
it('reports readOnly false for full access', async () => {
const {agent} = await createAndAcceptInvitation(app, 'full@nachklang.art', 'Full', ['tickets']);
const res = await agent.get('/tickets/admin/me');
expect(res.status).toBe(200);
expect(res.body.readOnly).toBe(false);
});
it.each([
['post', '/tickets/admin/vouchers/wildcard'],
['post', '/tickets/admin/redemptions/1/resend-confirmation'],
['patch', '/tickets/admin/redemptions/1'],
['put', '/tickets/admin/events/1/settings'],
['delete', '/feedback/admin/submissions/1'],
['post', '/feedback/admin/events'],
['post', '/calendar/events'],
['delete', '/calendar/events/1']
] as const)('refuses %s %s', async (method, path) => {
const {agent} = await readOnlyEverywhere();
const res = await agent[method](path).send({});
expect(res.status).toBe(403);
expect(res.body.message).toBe('Du hast für diesen Bereich nur Lesezugriff.');
});
it('does not open user management', async () => {
const {agent} = await readOnlyEverywhere();
expect((await agent.get('/admin/users')).status).toBe(403);
});
});
describe('granting read-only access', () => {
const signedInAdmin = () => createAndAcceptInvitation(app, 'admin@nachklang.art', 'Admin', ['admin']);
it('stores the read role per app', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'feedback', role: 'access'}]});
expect(res.status).toBe(200);
expect(res.body.permissions).toEqual(expect.arrayContaining([
{app: 'tickets', role: READ_ROLE},
{app: 'feedback', role: 'access'}
]));
});
it('rejects full and read-only access to the same app', async () => {
const {agent} = await signedInAdmin();
const other = await createAndAcceptInvitation(app, 'user@nachklang.art', 'User', ['feedback']);
const res = await agent
.put(`/admin/users/${other.userId}/permissions`)
.send({permissions: [{app: 'tickets', role: READ_ROLE}, {app: 'tickets', role: 'access'}]});
expect(res.status).toBe(400);
});
it('rejects inviting someone with read-only user management', async () => {
const {agent} = await signedInAdmin();
const res = await agent
.post('/admin/invitations')
.send({email: 'new@nachklang.art', name: 'New', permissions: [{app: 'admin', role: READ_ROLE}]});
expect(res.status).toBe(400);
});
});