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>
This commit is contained in:
9 files changed
+377
-10
No files matched your search
@@ -2,9 +2,14 @@ import express from 'express';
|
||||
import {fromNodeHeaders} from 'better-auth/node';
|
||||
import {auth} from './admin.auth.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';
|
||||
|
||||
// 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
|
||||
* 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
next();
|
||||
} catch (e: any) {
|
||||
|
||||
@@ -35,13 +35,26 @@ export const isAppName = (value: unknown): value is AppName => {
|
||||
*/
|
||||
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;
|
||||
|
||||
/** Every role that exists, per app, in display order. Extend to add one. */
|
||||
export const APP_ROLES: Record<AppName, readonly AppRole[]> = {
|
||||
calendar: [ACCESS_ROLE],
|
||||
feedback: [ACCESS_ROLE],
|
||||
tickets: [ACCESS_ROLE],
|
||||
calendar: [ACCESS_ROLE, READ_ROLE],
|
||||
feedback: [ACCESS_ROLE, READ_ROLE],
|
||||
tickets: [ACCESS_ROLE, READ_ROLE],
|
||||
admin: [ACCESS_ROLE]
|
||||
};
|
||||
|
||||
@@ -85,7 +98,7 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return permissions.filter(permission => {
|
||||
const unique = permissions.filter(permission => {
|
||||
const key = `${permission.app}:${permission.role}`;
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
@@ -93,6 +106,11 @@ export const toPermissions = (value: unknown): AppPermission[] | null => {
|
||||
seen.add(key);
|
||||
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. */
|
||||
@@ -100,6 +118,20 @@ export const appsOf = (permissions: AppPermission[]): AppName[] => {
|
||||
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 {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import express, {Request, Response} from 'express';
|
||||
import {requireAdminAuth} from '../feedback.auth.js';
|
||||
import {isReadOnly} from '../../admin/admin.schema.js';
|
||||
import {sendServerError} from '../feedback.errors.js';
|
||||
import {eventsAdminRouter} from './events.admin.router.js';
|
||||
import {songsAdminRouter} from './songs.admin.router.js';
|
||||
@@ -40,13 +41,21 @@ adminRouter.use(requireAdminAuth);
|
||||
* type: string
|
||||
* fullName:
|
||||
* type: string
|
||||
* readOnly:
|
||||
* type: boolean
|
||||
* description: Holds only the read role for feedback - every write answers 403.
|
||||
* 401:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
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')
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import {requireAdminAuth} from '../tickets.auth.js';
|
||||
import {isReadOnly} from '../../admin/admin.schema.js';
|
||||
import {vouchersAdminRouter} from './vouchers.admin.router.js';
|
||||
import {redemptionsAdminRouter, voucherHistoryRouter} from './redemptions.admin.router.js';
|
||||
import {eventsAdminRouter} from './events.admin.router.js';
|
||||
@@ -21,13 +22,30 @@ adminRouter.use(requireAdminAuth);
|
||||
* responses:
|
||||
* 200:
|
||||
* 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:
|
||||
* description: Unauthorized
|
||||
* 403:
|
||||
* description: Signed in without the permission for this app, or account disabled
|
||||
*/
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user