@c9up/station 0.1.15 → 0.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/StationProvider.d.ts +51 -11
- package/dist/StationProvider.d.ts.map +1 -1
- package/dist/StationProvider.js +484 -163
- package/dist/StationProvider.js.map +1 -1
- package/dist/columns.d.ts +38 -0
- package/dist/columns.d.ts.map +1 -0
- package/dist/columns.js +93 -0
- package/dist/columns.js.map +1 -0
- package/dist/defineResource.d.ts.map +1 -1
- package/dist/defineResource.js +24 -0
- package/dist/defineResource.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/loginLimiter.d.ts +41 -0
- package/dist/loginLimiter.d.ts.map +1 -0
- package/dist/loginLimiter.js +80 -0
- package/dist/loginLimiter.js.map +1 -0
- package/dist/types.d.ts +74 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/views/form.d.ts +6 -0
- package/dist/views/form.d.ts.map +1 -1
- package/dist/views/form.js +4 -4
- package/dist/views/form.js.map +1 -1
- package/dist/views/login.d.ts +2 -2
- package/dist/views/login.js +2 -2
- package/package.json +1 -1
- package/src/StationProvider.ts +675 -184
- package/src/columns.ts +136 -0
- package/src/defineResource.ts +32 -0
- package/src/index.ts +7 -0
- package/src/loginLimiter.ts +110 -0
- package/src/types.ts +86 -1
- package/src/views/form.ts +26 -4
- package/src/views/login.ts +2 -2
package/dist/StationProvider.js
CHANGED
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import "./augmentations.js";
|
|
21
21
|
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { editableColumns, isFrameworkManaged, isSecretColumn, viewColumns, writeOnlyKeys, } from "./columns.js";
|
|
23
|
+
import { loginKeys, MemoryLoginLimiter, } from "./loginLimiter.js";
|
|
22
24
|
import { ResourceRegistry } from "./ResourceRegistry.js";
|
|
23
25
|
import { clearStation, getStation, setStation } from "./services/main.js";
|
|
24
|
-
import { inProduction } from "./vendor/nodeEnv.js";
|
|
26
|
+
import { inProduction, normalizeNodeEnv } from "./vendor/nodeEnv.js";
|
|
25
27
|
// note: AuditEvent + ResourceAction are used by the CRUD handlers below;
|
|
26
28
|
// the imports stay in one block for clarity.
|
|
27
29
|
import { buildFormViewModel } from "./views/form.js";
|
|
@@ -45,6 +47,12 @@ function isInkerViewRenderer(value) {
|
|
|
45
47
|
typeof value.renderToString === "function");
|
|
46
48
|
}
|
|
47
49
|
const MAX_PER_PAGE = 100;
|
|
50
|
+
const DEFAULT_PERMISSION_PREFIX = "station";
|
|
51
|
+
/**
|
|
52
|
+
* The auth cookie travels only to the admin: scoped to `/`, the admin bearer
|
|
53
|
+
* token rode along with every request to the rest of the application.
|
|
54
|
+
*/
|
|
55
|
+
const AUTH_COOKIE_PATH = "/admin";
|
|
48
56
|
const DEFAULT_PER_PAGE = 25;
|
|
49
57
|
const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
|
|
50
58
|
/** Process-scoped flags so we warn once per process, not once per request. */
|
|
@@ -53,6 +61,7 @@ let perPageClampWarned = false;
|
|
|
53
61
|
let seedPermsWarned = false;
|
|
54
62
|
let missingAuditWarned = false;
|
|
55
63
|
let csrfBlockedWarned = false;
|
|
64
|
+
let revokeUnavailableWarned = false;
|
|
56
65
|
/** @internal Reset module-level flags between tests. */
|
|
57
66
|
export function resetStationProviderFlags() {
|
|
58
67
|
authWarnEmitted = false;
|
|
@@ -60,6 +69,7 @@ export function resetStationProviderFlags() {
|
|
|
60
69
|
seedPermsWarned = false;
|
|
61
70
|
missingAuditWarned = false;
|
|
62
71
|
csrfBlockedWarned = false;
|
|
72
|
+
revokeUnavailableWarned = false;
|
|
63
73
|
}
|
|
64
74
|
/**
|
|
65
75
|
* Stable fingerprint for a failed `station::errors/404` render, used to log each
|
|
@@ -80,16 +90,6 @@ function notFoundFailFingerprint(cause) {
|
|
|
80
90
|
* unforeseen unbounded error-code space still cannot inflate the log or memory.
|
|
81
91
|
*/
|
|
82
92
|
const NOT_FOUND_RENDER_FAIL_CAP = 32;
|
|
83
|
-
const TIMESTAMP_PROPERTY_KEYS = new Set([
|
|
84
|
-
"createdAt",
|
|
85
|
-
"updatedAt",
|
|
86
|
-
"deletedAt",
|
|
87
|
-
]);
|
|
88
|
-
const TIMESTAMP_COLUMN_NAMES = new Set([
|
|
89
|
-
"created_at",
|
|
90
|
-
"updated_at",
|
|
91
|
-
"deleted_at",
|
|
92
|
-
]);
|
|
93
93
|
/**
|
|
94
94
|
* Column `type` strings mapped to a numeric rune rule (mirrors form.ts input
|
|
95
95
|
* inference). `bigint` is deliberately EXCLUDED: coercing it through `Number()`
|
|
@@ -118,9 +118,9 @@ export function resourcesNeedValidation(resources) {
|
|
|
118
118
|
* Derive a `@c9up/rune` validation schema (57.7) from an entity's writable
|
|
119
119
|
* column metadata — the structural replacement for the hand-rolled
|
|
120
120
|
* `filterWritableBody`. The mass-assignment guarantee is now INHERENT:
|
|
121
|
-
* `schema.validateResult()` returns only
|
|
122
|
-
*
|
|
123
|
-
*
|
|
121
|
+
* `schema.validateResult()` returns only the keys of the editable columns it
|
|
122
|
+
* was given, so any other body key — an unknown one, a column outside the
|
|
123
|
+
* allow-list, the PK, a timestamp — is dropped automatically.
|
|
124
124
|
*
|
|
125
125
|
* Excluded, exactly as the old guard did: the primary key, framework-managed
|
|
126
126
|
* timestamps (both the Lucid-style `autoManaged` flag AND the `created_at /
|
|
@@ -141,22 +141,16 @@ export function resourcesNeedValidation(resources) {
|
|
|
141
141
|
* Returns the built schema plus the set of boolean columns — the handler
|
|
142
142
|
* default-fills an ABSENT boolean key to `false` before validating (an
|
|
143
143
|
* unchecked checkbox submits nothing; this preserves the old edit-clears-box
|
|
144
|
-
* behaviour).
|
|
144
|
+
* behaviour). `columns` is the editable allow-list; framework-owned columns
|
|
145
|
+
* are dropped here too, so no caller can make them writable. `updateSchema`
|
|
146
|
+
* differs only for `writeOnly` columns, which an edit may leave out.
|
|
145
147
|
*/
|
|
146
|
-
export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedProps, rune) {
|
|
148
|
+
export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedProps, rune, writeOnly = new Set()) {
|
|
147
149
|
const fields = {};
|
|
150
|
+
const updateFields = {};
|
|
148
151
|
const booleanKeys = new Set();
|
|
149
152
|
for (const c of columns) {
|
|
150
|
-
if (c
|
|
151
|
-
continue;
|
|
152
|
-
if (autoManaged.has(c.propertyKey))
|
|
153
|
-
continue;
|
|
154
|
-
if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey))
|
|
155
|
-
continue;
|
|
156
|
-
const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
157
|
-
if (TIMESTAMP_COLUMN_NAMES.has(snake))
|
|
158
|
-
continue;
|
|
159
|
-
if (excludedProps.has(c.propertyKey))
|
|
153
|
+
if (isFrameworkManaged(c, pkColumn, autoManaged, excludedProps))
|
|
160
154
|
continue;
|
|
161
155
|
const type = (c.type ?? "").toString().toLowerCase();
|
|
162
156
|
if (type === "boolean") {
|
|
@@ -164,26 +158,38 @@ export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedPro
|
|
|
164
158
|
// boolean is always present here → required, no `.optional()`.
|
|
165
159
|
booleanKeys.add(c.propertyKey);
|
|
166
160
|
fields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
|
|
161
|
+
updateFields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
|
|
167
162
|
continue;
|
|
168
163
|
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
// (documented residual limitation — see story Dev Notes); the PK, the
|
|
172
|
-
// common serial case, is already excluded above.
|
|
173
|
-
const required = c.nullable !== true && c.default === undefined;
|
|
174
|
-
let chain = NUMBER_COLUMN_TYPES.has(type)
|
|
175
|
-
? rune.rules.number().parse(parseNumeric)
|
|
176
|
-
: JSON_COLUMN_TYPES.has(type)
|
|
177
|
-
? rune.rules.any()
|
|
178
|
-
: rune.rules.string();
|
|
179
|
-
if (!required) {
|
|
180
|
-
chain = chain.optional();
|
|
181
|
-
if (c.nullable === true)
|
|
182
|
-
chain = chain.nullable();
|
|
183
|
-
}
|
|
184
|
-
fields[c.propertyKey] = chain;
|
|
164
|
+
fields[c.propertyKey] = columnChain(c, type, rune, false);
|
|
165
|
+
updateFields[c.propertyKey] = columnChain(c, type, rune, writeOnly.has(c.propertyKey));
|
|
185
166
|
}
|
|
186
|
-
return {
|
|
167
|
+
return {
|
|
168
|
+
schema: rune.schema(fields),
|
|
169
|
+
updateSchema: rune.schema(updateFields),
|
|
170
|
+
booleanKeys,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The rule for one non-boolean column. A column is required iff the model
|
|
175
|
+
* declares neither nullability nor a default — unless `keepable`: an edit may
|
|
176
|
+
* omit a write-only column to keep its stored value. DB-side defaults
|
|
177
|
+
* (serial, `DEFAULT`) are invisible to metadata (documented residual
|
|
178
|
+
* limitation); the PK, the common serial case, is never editable.
|
|
179
|
+
*/
|
|
180
|
+
function columnChain(c, type, rune, keepable) {
|
|
181
|
+
const required = !keepable && c.nullable !== true && c.default === undefined;
|
|
182
|
+
let chain = NUMBER_COLUMN_TYPES.has(type)
|
|
183
|
+
? rune.rules.number().parse(parseNumeric)
|
|
184
|
+
: JSON_COLUMN_TYPES.has(type)
|
|
185
|
+
? rune.rules.any()
|
|
186
|
+
: rune.rules.string();
|
|
187
|
+
if (!required) {
|
|
188
|
+
chain = chain.optional();
|
|
189
|
+
if (c.nullable === true)
|
|
190
|
+
chain = chain.nullable();
|
|
191
|
+
}
|
|
192
|
+
return chain;
|
|
187
193
|
}
|
|
188
194
|
/**
|
|
189
195
|
* Validate a request body against a derived schema (57.7). Default-fills any
|
|
@@ -191,10 +197,15 @@ export function deriveWritableSchema(columns, pkColumn, autoManaged, excludedPro
|
|
|
191
197
|
* preserving the old `filterWritableBody` checkbox semantics, then runs rune's
|
|
192
198
|
* synchronous, never-throwing `validateResult()`. On success `result.data` holds only
|
|
193
199
|
* declared-column keys (mass-assignment inherent). Exported for the unit tests
|
|
194
|
-
* that pin the coercion CRUX.
|
|
200
|
+
* that pin the coercion CRUX. A blank `writeOnly` value counts as not
|
|
201
|
+
* submitted: the form never pre-fills those, so blank means "unchanged".
|
|
195
202
|
*/
|
|
196
|
-
export function validateWritableBody(schema, booleanKeys, body) {
|
|
203
|
+
export function validateWritableBody(schema, booleanKeys, body, writeOnly = new Set()) {
|
|
197
204
|
const input = { ...body };
|
|
205
|
+
for (const key of writeOnly) {
|
|
206
|
+
if (input[key] === "")
|
|
207
|
+
delete input[key];
|
|
208
|
+
}
|
|
198
209
|
for (const key of booleanKeys) {
|
|
199
210
|
if (!(key in input))
|
|
200
211
|
input[key] = false;
|
|
@@ -243,8 +254,6 @@ function fieldErrors(errors) {
|
|
|
243
254
|
}
|
|
244
255
|
/** Session flash key under which the per-field validation errors are stashed (AdonisJS `flashMessages.errors` parity). */
|
|
245
256
|
const FLASH_ERRORS_KEY = "errors";
|
|
246
|
-
/** Column propertyKeys whose values are secrets — never flashed to the session nor echoed back into HTML (AdonisJS parity). */
|
|
247
|
-
const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
|
|
248
257
|
/**
|
|
249
258
|
* The subset of a submitted body safe to re-surface on a validation error:
|
|
250
259
|
* declared columns only — so `_csrf`, `_method`, and any mass-assignment /
|
|
@@ -255,7 +264,7 @@ const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
|
|
|
255
264
|
function flashableInput(submitted, columns) {
|
|
256
265
|
const out = {};
|
|
257
266
|
for (const c of columns) {
|
|
258
|
-
if (
|
|
267
|
+
if (isSecretColumn(c))
|
|
259
268
|
continue;
|
|
260
269
|
if (c.propertyKey in submitted)
|
|
261
270
|
out[c.propertyKey] = submitted[c.propertyKey];
|
|
@@ -295,42 +304,46 @@ function readFlash(ctx, columns) {
|
|
|
295
304
|
};
|
|
296
305
|
}
|
|
297
306
|
/**
|
|
298
|
-
*
|
|
299
|
-
* `
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
307
|
+
* The path a `Referer` points back to, when it may be trusted — AdonisJS's
|
|
308
|
+
* `isValidRedirectUrl` + `redirect().back()`: a relative path is trusted, an
|
|
309
|
+
* absolute URL only when its host is the request's own authority (`Host` /
|
|
310
|
+
* `:authority`, never a forwarded host), and the redirect goes to the path
|
|
311
|
+
* alone. Comparing with `request.url()` could never work: in ream that is
|
|
312
|
+
* the path, while a browser's Referer is a full URL. Deliberately stricter
|
|
313
|
+
* than upstream in one place: with no authority to compare against, an
|
|
314
|
+
* absolute Referer is refused rather than accepted.
|
|
303
315
|
*/
|
|
304
|
-
function
|
|
316
|
+
function trustedRefererPath(referer, authority) {
|
|
305
317
|
// A relative path is trusted only when it starts with a single "/" and
|
|
306
318
|
// contains no backslash: browsers normalise "\\" to "/", so "/\\evil.com"
|
|
307
319
|
// becomes the protocol-relative "//evil.com" that a lone `!startsWith("//")`
|
|
308
320
|
// check would let through.
|
|
309
|
-
if (referer.startsWith("
|
|
310
|
-
|
|
311
|
-
!referer.includes("\\")) {
|
|
312
|
-
return true;
|
|
313
|
-
}
|
|
314
|
-
if (requestUrl === undefined)
|
|
315
|
-
return false;
|
|
321
|
+
if (referer.startsWith("//") || referer.includes("\\"))
|
|
322
|
+
return undefined;
|
|
316
323
|
try {
|
|
317
|
-
|
|
324
|
+
if (referer.startsWith("/")) {
|
|
325
|
+
const url = new URL(referer, "http://localhost");
|
|
326
|
+
return url.host === "localhost" ? url.pathname : undefined;
|
|
327
|
+
}
|
|
328
|
+
if (authority === null || authority === undefined || authority === "") {
|
|
329
|
+
return undefined;
|
|
330
|
+
}
|
|
331
|
+
const url = new URL(referer);
|
|
332
|
+
return url.host === authority ? url.pathname : undefined;
|
|
318
333
|
}
|
|
319
334
|
catch {
|
|
320
|
-
return
|
|
335
|
+
return undefined;
|
|
321
336
|
}
|
|
322
337
|
}
|
|
323
338
|
/**
|
|
324
|
-
* `redirect().back()` over Station's `redirect(url)` seam (57.7):
|
|
325
|
-
* trusted
|
|
326
|
-
* the same behaviour as ream's `RedirectBuilder.back(fallback)`, open-redirect
|
|
327
|
-
* safe.
|
|
339
|
+
* `redirect().back()` over Station's `redirect(url)` seam (57.7): the path of a
|
|
340
|
+
* trusted `Referer`, else the caller's `fallback` (the form URL).
|
|
328
341
|
*/
|
|
329
342
|
function redirectBack(ctx, fallback) {
|
|
330
343
|
const referer = ctx.request.header?.("referer");
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
? referer
|
|
344
|
+
const authority = ctx.request.authority?.() ?? ctx.request.header?.("host") ?? undefined;
|
|
345
|
+
const target = typeof referer === "string"
|
|
346
|
+
? (trustedRefererPath(referer, authority) ?? fallback)
|
|
334
347
|
: fallback;
|
|
335
348
|
ctx.response.redirect(target);
|
|
336
349
|
}
|
|
@@ -355,10 +368,13 @@ function isCheckedValue(value) {
|
|
|
355
368
|
* field). The fallback is shallow but warned ONCE per process so
|
|
356
369
|
* operators see it.
|
|
357
370
|
*/
|
|
358
|
-
function snapshotEntity(entity, columns) {
|
|
371
|
+
function snapshotEntity(entity, columns, redacted) {
|
|
359
372
|
const out = {};
|
|
360
|
-
for (const c of columns)
|
|
361
|
-
out[c.propertyKey] =
|
|
373
|
+
for (const c of columns) {
|
|
374
|
+
out[c.propertyKey] = redacted.has(c.propertyKey)
|
|
375
|
+
? REDACTED
|
|
376
|
+
: entity[c.propertyKey];
|
|
377
|
+
}
|
|
362
378
|
try {
|
|
363
379
|
return structuredClone(out);
|
|
364
380
|
}
|
|
@@ -386,6 +402,15 @@ function snapshotEntity(entity, columns) {
|
|
|
386
402
|
}
|
|
387
403
|
}
|
|
388
404
|
let auditCloneWarnEmitted = false;
|
|
405
|
+
/** What an audit snapshot holds in place of a secret column's value. */
|
|
406
|
+
const REDACTED = "[redacted]";
|
|
407
|
+
/**
|
|
408
|
+
* The redacted columns an edit changed — read from the raw values, so the
|
|
409
|
+
* audit trail says "password changed" without carrying either hash.
|
|
410
|
+
*/
|
|
411
|
+
function changedRedacted(before, entity, redacted) {
|
|
412
|
+
return [...redacted].filter((key) => !Object.is(before[key], entity[key]));
|
|
413
|
+
}
|
|
389
414
|
/**
|
|
390
415
|
* Filesystem path to the `.inker` templates Station ships inside the
|
|
391
416
|
* package (57.1, D3). Resolved from `import.meta.url` so the SAME path
|
|
@@ -410,11 +435,16 @@ export default class StationProvider {
|
|
|
410
435
|
// 54.7 auth state — populated when warden is wired AND
|
|
411
436
|
// StationConfig.requireAuth is true (default when warden is present).
|
|
412
437
|
#authManager;
|
|
438
|
+
#loginLimiter;
|
|
439
|
+
#cookieSecure;
|
|
440
|
+
#securityAudit;
|
|
413
441
|
#authConfig = {
|
|
414
442
|
requireAuth: false,
|
|
415
443
|
requireRole: undefined,
|
|
416
444
|
loginPath: "/admin/login",
|
|
417
445
|
cookieName: "station_auth",
|
|
446
|
+
permissionPrefix: DEFAULT_PERMISSION_PREFIX,
|
|
447
|
+
accessPermission: undefined,
|
|
418
448
|
};
|
|
419
449
|
constructor(app) {
|
|
420
450
|
this.app = app;
|
|
@@ -454,7 +484,15 @@ export default class StationProvider {
|
|
|
454
484
|
const peers = await this.#loadPeers();
|
|
455
485
|
if (!peers)
|
|
456
486
|
return;
|
|
457
|
-
const {
|
|
487
|
+
const { atlas, rune } = peers;
|
|
488
|
+
const router = withRouteMiddleware(peers.router, userConfig.middleware);
|
|
489
|
+
this.#loginLimiter = buildLoginLimiter(userConfig.loginRateLimit);
|
|
490
|
+
this.#cookieSecure = userConfig.cookieSecure;
|
|
491
|
+
if (userConfig.audit !== undefined &&
|
|
492
|
+
typeof userConfig.audit !== "function") {
|
|
493
|
+
throw new TypeError("[station] `station.audit` must be a function");
|
|
494
|
+
}
|
|
495
|
+
this.#securityAudit = userConfig.audit;
|
|
458
496
|
// Phase 1a2 — resolve the SHARED inker renderer (AdonisJS/Edge parity)
|
|
459
497
|
// and mount Station's package templates as the `station` disk. Unlike
|
|
460
498
|
// warden (whose absence keeps the open dev-preview path), a view engine
|
|
@@ -697,6 +735,10 @@ export default class StationProvider {
|
|
|
697
735
|
* not installed/bound the gate stays open and a one-time warning is emitted.
|
|
698
736
|
*/
|
|
699
737
|
async #configureAuth(userConfig) {
|
|
738
|
+
const permissionPrefix = userConfig.permissionPrefix ?? DEFAULT_PERMISSION_PREFIX;
|
|
739
|
+
if (typeof permissionPrefix !== "string" || permissionPrefix === "") {
|
|
740
|
+
throw new Error("[station] `station.permissionPrefix` must be a non-empty string: admin permissions need their own namespace.");
|
|
741
|
+
}
|
|
700
742
|
const wardenWanted = userConfig.requireAuth !== false;
|
|
701
743
|
if (wardenWanted) {
|
|
702
744
|
try {
|
|
@@ -707,6 +749,10 @@ export default class StationProvider {
|
|
|
707
749
|
requireRole: userConfig.requireRole,
|
|
708
750
|
loginPath: userConfig.loginPath ?? "/admin/login",
|
|
709
751
|
cookieName: userConfig.cookieName ?? "station_auth",
|
|
752
|
+
permissionPrefix,
|
|
753
|
+
accessPermission: userConfig.accessPermission === false
|
|
754
|
+
? undefined
|
|
755
|
+
: (userConfig.accessPermission ?? `${permissionPrefix}.access`),
|
|
710
756
|
};
|
|
711
757
|
}
|
|
712
758
|
catch (cause) {
|
|
@@ -725,14 +771,22 @@ export default class StationProvider {
|
|
|
725
771
|
return;
|
|
726
772
|
// An admin panel with no auth is full CRUD on every registered resource
|
|
727
773
|
// for anyone who can reach the route. That is a different product, and
|
|
728
|
-
//
|
|
729
|
-
//
|
|
774
|
+
// it has to be asked for rather than fallen into — the same bet bay
|
|
775
|
+
// makes about losing a job on a Redis without LMOVE.
|
|
730
776
|
//
|
|
731
777
|
// `requireAuth: false` written down IS the asking. Defaulted (undefined)
|
|
732
|
-
// is not: it means nobody decided, and nobody deciding
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
778
|
+
// is not: it means nobody decided, and nobody deciding is only tolerated
|
|
779
|
+
// on a machine positively named development or test. An unset NODE_ENV,
|
|
780
|
+
// `staging`, `preview`… fail closed like production: they are exactly
|
|
781
|
+
// the boxes nobody configured, and staging often holds a copy of
|
|
782
|
+
// production data (ream's `Application.inDev` takes the same stance).
|
|
783
|
+
const env = normalizeNodeEnv(process.env.NODE_ENV);
|
|
784
|
+
const devOrTest = env === "development" || env === "test";
|
|
785
|
+
if (!devOrTest && userConfig.requireAuth !== false) {
|
|
786
|
+
const where = inProduction()
|
|
787
|
+
? "production"
|
|
788
|
+
: `a non-development environment (NODE_ENV=${process.env.NODE_ENV === undefined ? "unset" : JSON.stringify(process.env.NODE_ENV)})`;
|
|
789
|
+
throw new Error(`[station] Admin routes would mount with NO authorisation in ${where}: ` +
|
|
736
790
|
"no working `auth` binding is registered, so every `/admin/*` route — " +
|
|
737
791
|
"including create, update and delete — is open to anyone who can reach it.\n" +
|
|
738
792
|
" Wire @c9up/warden's WardenProvider, or set `station.requireAuth: false` " +
|
|
@@ -744,10 +798,11 @@ export default class StationProvider {
|
|
|
744
798
|
// Said even when the deployment opted in: agreeing once in a config file
|
|
745
799
|
// is not the same as being reminded that this process is running that
|
|
746
800
|
// way. The line has to be in the logs of the incident.
|
|
747
|
-
const optedIn =
|
|
748
|
-
|
|
801
|
+
const optedIn = !devOrTest && userConfig.requireAuth === false;
|
|
802
|
+
const envName = env === "unknown" ? "AN UNCONFIGURED ENVIRONMENT" : env.toUpperCase();
|
|
803
|
+
console.warn("[station] Admin routes mounted without auth. Wire @c9up/warden (and set `station.requireAuth: true` if you opted out) and seed the per-action admin permissions in the Warden rights store BEFORE production. See https://ream.dev/modules/station#auth." +
|
|
749
804
|
(optedIn
|
|
750
|
-
?
|
|
805
|
+
? `\n Running this way in ${envName} because \`station.requireAuth: false\` was set.`
|
|
751
806
|
: ""));
|
|
752
807
|
}
|
|
753
808
|
/** Phase 3 — mount the login surface + per-resource CRUD routes. */
|
|
@@ -801,18 +856,22 @@ export default class StationProvider {
|
|
|
801
856
|
if (resource.actions.includes("edit")) {
|
|
802
857
|
router.get(`/admin/${slug}/:id/edit`, gate(this.#buildEditFormHandler(resource)));
|
|
803
858
|
router.put(`/admin/${slug}/:id`, gate(this.#buildUpdateHandler(resource)));
|
|
804
|
-
// Browser forms can't issue PUT — accept POST with `_method=PUT`
|
|
805
|
-
// from the auto-generated form (form.ts stamps the hidden input).
|
|
806
|
-
router.post(`/admin/${slug}/:id`, gate(this.#buildMethodOverrideHandler(resource)));
|
|
807
859
|
}
|
|
808
860
|
if (resource.actions.includes("destroy")) {
|
|
809
861
|
router.delete(`/admin/${slug}/:id`, gate(this.#buildDestroyHandler(resource)));
|
|
810
862
|
}
|
|
863
|
+
// Browser forms can't issue PUT/DELETE — accept POST with `_method`
|
|
864
|
+
// from the auto-generated form (form.ts stamps the hidden input),
|
|
865
|
+
// dispatching only to the actions this resource enables.
|
|
866
|
+
if (resource.actions.includes("edit") ||
|
|
867
|
+
resource.actions.includes("destroy")) {
|
|
868
|
+
router.post(`/admin/${slug}/:id`, gate(this.#buildMethodOverrideHandler(resource)));
|
|
869
|
+
}
|
|
811
870
|
}
|
|
812
871
|
}
|
|
813
872
|
/**
|
|
814
873
|
* 56.5 — every admin action is now gated behind a
|
|
815
|
-
* `<resource>.<action>` permission resolved through @c9up/warden
|
|
874
|
+
* `<prefix>.<resource>.<action>` permission resolved through @c9up/warden
|
|
816
875
|
* (`auth.hasPermission`). Warn ONCE at boot, only when the auth layer
|
|
817
876
|
* is wired, that the host must seed roles/grants in the Warden rights
|
|
818
877
|
* store — otherwise every admin request 403s with no hint. The old
|
|
@@ -831,7 +890,14 @@ export default class StationProvider {
|
|
|
831
890
|
const [example] = resources;
|
|
832
891
|
if (example === undefined)
|
|
833
892
|
return;
|
|
834
|
-
|
|
893
|
+
const prefix = this.#authConfig.permissionPrefix;
|
|
894
|
+
const access = this.#authConfig.accessPermission;
|
|
895
|
+
const perms = [
|
|
896
|
+
...(access === undefined ? [] : [access]),
|
|
897
|
+
`${prefix}.${example.name}.list`,
|
|
898
|
+
`${prefix}.${example.name}.create`,
|
|
899
|
+
];
|
|
900
|
+
console.warn(`[station] Admin actions are gated behind '${prefix}.<resource>.<action>' permissions resolved through @c9up/warden${access === undefined ? "" : `, and entering the admin behind '${access}'`}. Seed roles/grants in the Warden rights store (store.defineRole('admin', [${perms.map((p) => `'${p}'`).join(", ")}, ...]) then assignRole) or every admin request will 403. See https://ream.dev/modules/station#authorization.`);
|
|
835
901
|
}
|
|
836
902
|
#warnAuditGapsOnce(resources) {
|
|
837
903
|
if (missingAuditWarned)
|
|
@@ -870,8 +936,9 @@ export default class StationProvider {
|
|
|
870
936
|
* `@c9up/rune` — so an undefined here is an internal invariant break; throw
|
|
871
937
|
* loud (fail-closed) rather than silently accept an unvalidated body.
|
|
872
938
|
*/
|
|
873
|
-
#requireWritableSchema(resource) {
|
|
874
|
-
const
|
|
939
|
+
#requireWritableSchema(resource, mode) {
|
|
940
|
+
const context = this.#requireContext(resource);
|
|
941
|
+
const writableSchema = mode === "create" ? context.writableSchema : context.updateSchema;
|
|
875
942
|
if (writableSchema === undefined) {
|
|
876
943
|
throw new Error(`[station] No validation schema for ${resource.entity.name} — @c9up/rune must be installed to accept admin writes.`);
|
|
877
944
|
}
|
|
@@ -911,8 +978,8 @@ export default class StationProvider {
|
|
|
911
978
|
// Echo only declared, non-sensitive columns back to the form — never
|
|
912
979
|
// `_csrf`, secrets, or mass-assignment keys (both the flash store and the
|
|
913
980
|
// re-rendered HTML). An empty result falls back to `row`.
|
|
914
|
-
const {
|
|
915
|
-
const safe = flashableInput(submitted,
|
|
981
|
+
const { formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
|
|
982
|
+
const safe = flashableInput(submitted, formColumns);
|
|
916
983
|
if (ctx.session !== undefined) {
|
|
917
984
|
// AdonisJS PRG: flash old input + errors, redirect back to the form.
|
|
918
985
|
ctx.session.flashAll(safe);
|
|
@@ -924,8 +991,9 @@ export default class StationProvider {
|
|
|
924
991
|
// submitted values + errors are still shown rather than silently dropped.
|
|
925
992
|
const vm = buildFormViewModel({
|
|
926
993
|
resource,
|
|
927
|
-
columns,
|
|
994
|
+
columns: formColumns,
|
|
928
995
|
pkColumn,
|
|
996
|
+
writeOnly,
|
|
929
997
|
row,
|
|
930
998
|
values: Object.keys(safe).length > 0 ? safe : undefined,
|
|
931
999
|
errors: fieldErrors(errors),
|
|
@@ -938,15 +1006,13 @@ export default class StationProvider {
|
|
|
938
1006
|
}
|
|
939
1007
|
#buildListHandler(resource) {
|
|
940
1008
|
return async (ctx) => {
|
|
941
|
-
const { repo,
|
|
1009
|
+
const { repo, listColumns, pkColumn } = this.#requireContext(resource);
|
|
942
1010
|
// 56.5: the list action is gated behind `<resource>.list` like
|
|
943
1011
|
// every other action — resolved through the Warden `"auth"`
|
|
944
1012
|
// layer. (Retro 2026-06-01: list previously skipped the gate
|
|
945
1013
|
// entirely, leaking the index regardless of authorization.)
|
|
946
|
-
if (!(await
|
|
947
|
-
deny(ctx);
|
|
1014
|
+
if (!(await this.#authorize(ctx, resource, "list")))
|
|
948
1015
|
return;
|
|
949
|
-
}
|
|
950
1016
|
const qs = ctx.request.qs();
|
|
951
1017
|
const page = clampPositiveInt(qs.page, 1);
|
|
952
1018
|
const perPageRaw = clampPositiveInt(qs.perPage, DEFAULT_PER_PAGE);
|
|
@@ -961,6 +1027,18 @@ export default class StationProvider {
|
|
|
961
1027
|
// Station keeps only the `MAX_PER_PAGE` clamp (above) and the
|
|
962
1028
|
// redirect-to-last-page wrapper — atlas floors perPage to ≥1 but
|
|
963
1029
|
// does not cap it.
|
|
1030
|
+
// Past this page `(page-1)*perPage` is no longer an exact integer:
|
|
1031
|
+
// atlas / the driver reject that offset, which surfaced as a 500.
|
|
1032
|
+
// No table that size exists, so such a page is past the end — count
|
|
1033
|
+
// through page 1 and send the caller to the real last page.
|
|
1034
|
+
if (page > Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1) {
|
|
1035
|
+
const first = await repo
|
|
1036
|
+
.query()
|
|
1037
|
+
.orderBy(pkColumn, "desc")
|
|
1038
|
+
.paginate(1, perPage);
|
|
1039
|
+
ctx.response.redirect(`/admin/${resource.name}?page=${Math.max(1, first.meta.lastPage)}&perPage=${perPage}`);
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
964
1042
|
const result = await repo
|
|
965
1043
|
.query()
|
|
966
1044
|
.orderBy(pkColumn, "desc")
|
|
@@ -974,7 +1052,7 @@ export default class StationProvider {
|
|
|
974
1052
|
const viewModel = buildListViewModel({
|
|
975
1053
|
resource,
|
|
976
1054
|
rows: result.all(),
|
|
977
|
-
columns,
|
|
1055
|
+
columns: listColumns,
|
|
978
1056
|
pkColumn,
|
|
979
1057
|
page: result.meta.currentPage,
|
|
980
1058
|
perPage: result.meta.perPage,
|
|
@@ -988,14 +1066,12 @@ export default class StationProvider {
|
|
|
988
1066
|
}
|
|
989
1067
|
#buildShowHandler(resource) {
|
|
990
1068
|
return async (ctx) => {
|
|
991
|
-
const { repo,
|
|
1069
|
+
const { repo, showColumns, pkColumn } = this.#requireContext(resource);
|
|
992
1070
|
// Authorize BEFORE the existence check so an unauthorized caller
|
|
993
1071
|
// can't distinguish 404 (absent) from 403 (exists) — no row-existence
|
|
994
1072
|
// oracle for a user without `<resource>.show`.
|
|
995
|
-
if (!(await
|
|
996
|
-
deny(ctx);
|
|
1073
|
+
if (!(await this.#authorize(ctx, resource, "show")))
|
|
997
1074
|
return;
|
|
998
|
-
}
|
|
999
1075
|
const id = ctx.params.id ?? "";
|
|
1000
1076
|
const row = await repo.find(id);
|
|
1001
1077
|
if (row === null) {
|
|
@@ -1008,7 +1084,7 @@ export default class StationProvider {
|
|
|
1008
1084
|
const viewModel = buildShowViewModel({
|
|
1009
1085
|
resource,
|
|
1010
1086
|
row,
|
|
1011
|
-
columns,
|
|
1087
|
+
columns: showColumns,
|
|
1012
1088
|
pkColumn,
|
|
1013
1089
|
});
|
|
1014
1090
|
const html = await this.#renderView(ctx, "station::show", viewModel);
|
|
@@ -1018,18 +1094,17 @@ export default class StationProvider {
|
|
|
1018
1094
|
}
|
|
1019
1095
|
#buildNewFormHandler(resource) {
|
|
1020
1096
|
return async (ctx) => {
|
|
1021
|
-
const {
|
|
1022
|
-
if (!(await
|
|
1023
|
-
deny(ctx);
|
|
1097
|
+
const { formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
|
|
1098
|
+
if (!(await this.#authorize(ctx, resource, "create")))
|
|
1024
1099
|
return;
|
|
1025
|
-
}
|
|
1026
1100
|
// 57.7 — after a failed create redirected back (PRG), the flash carries
|
|
1027
1101
|
// the old input + per-field errors; re-populate + surface them.
|
|
1028
|
-
const { values, errors } = readFlash(ctx,
|
|
1102
|
+
const { values, errors } = readFlash(ctx, formColumns);
|
|
1029
1103
|
const vm = buildFormViewModel({
|
|
1030
1104
|
resource,
|
|
1031
|
-
columns,
|
|
1105
|
+
columns: formColumns,
|
|
1032
1106
|
pkColumn,
|
|
1107
|
+
writeOnly,
|
|
1033
1108
|
values,
|
|
1034
1109
|
errors,
|
|
1035
1110
|
csrfEnabled: ctx.store?.get("csrfToken") != null,
|
|
@@ -1046,21 +1121,20 @@ export default class StationProvider {
|
|
|
1046
1121
|
// DB work — no 404-vs-403 oracle to a forged request. A seeded token is
|
|
1047
1122
|
// not proof; only `csrfProtected === true` is (AC4/AC7).
|
|
1048
1123
|
if (ctx.request.csrfProtected !== true) {
|
|
1049
|
-
denyCsrf(ctx);
|
|
1124
|
+
await this.#denyCsrf(ctx);
|
|
1050
1125
|
return;
|
|
1051
1126
|
}
|
|
1052
|
-
const { repo, pkColumn, columns, booleanKeys } = this.#requireContext(resource);
|
|
1053
|
-
if (!(await
|
|
1054
|
-
deny(ctx);
|
|
1127
|
+
const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } = this.#requireContext(resource);
|
|
1128
|
+
if (!(await this.#authorize(ctx, resource, "create")))
|
|
1055
1129
|
return;
|
|
1056
|
-
}
|
|
1057
1130
|
const body = await readBody(ctx);
|
|
1058
1131
|
// 57.7 — validate + mass-assignment guard in one pass. `result.data`
|
|
1059
|
-
// holds ONLY
|
|
1060
|
-
//
|
|
1061
|
-
//
|
|
1132
|
+
// holds ONLY editable-column keys (the allow-list: `editable`, or every
|
|
1133
|
+
// non-secret, non-hidden, non-framework column), so a forged `isAdmin`
|
|
1134
|
+
// on a hidden column, a secret column, the PK or a timestamp is dropped
|
|
1135
|
+
// by the schema, and each field is type-checked.
|
|
1062
1136
|
// Invalid input fail-closes with a 422 BEFORE any repository write (AC4).
|
|
1063
|
-
const result = validateWritableBody(this.#requireWritableSchema(resource), booleanKeys, body);
|
|
1137
|
+
const result = validateWritableBody(this.#requireWritableSchema(resource, "create"), booleanKeys, body, writeOnly);
|
|
1064
1138
|
if (!result.valid) {
|
|
1065
1139
|
// PRG fallback URL = the create form (AdonisJS `redirect().back()`).
|
|
1066
1140
|
const formUrl = `/admin/${encodeURIComponent(resource.name)}/new`;
|
|
@@ -1073,7 +1147,8 @@ export default class StationProvider {
|
|
|
1073
1147
|
resource: resource.name,
|
|
1074
1148
|
recordId: created[pkColumn],
|
|
1075
1149
|
userId: ctx.auth?.user?.id,
|
|
1076
|
-
|
|
1150
|
+
...requestOrigin(ctx),
|
|
1151
|
+
after: snapshotEntity(created, columns, redacted),
|
|
1077
1152
|
at: new Date(),
|
|
1078
1153
|
});
|
|
1079
1154
|
redirectToShow(ctx, resource, created[pkColumn]);
|
|
@@ -1081,12 +1156,10 @@ export default class StationProvider {
|
|
|
1081
1156
|
}
|
|
1082
1157
|
#buildEditFormHandler(resource) {
|
|
1083
1158
|
return async (ctx) => {
|
|
1084
|
-
const { repo,
|
|
1159
|
+
const { repo, formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
|
|
1085
1160
|
// Authorize before the existence check (no 404-vs-403 oracle).
|
|
1086
|
-
if (!(await
|
|
1087
|
-
deny(ctx);
|
|
1161
|
+
if (!(await this.#authorize(ctx, resource, "edit")))
|
|
1088
1162
|
return;
|
|
1089
|
-
}
|
|
1090
1163
|
const id = ctx.params.id ?? "";
|
|
1091
1164
|
const row = await repo.find(id);
|
|
1092
1165
|
if (row === null) {
|
|
@@ -1099,11 +1172,12 @@ export default class StationProvider {
|
|
|
1099
1172
|
// 57.7 — after a failed update redirected back (PRG), the flash carries
|
|
1100
1173
|
// the submitted values + errors; they win over the DB `row` so the user
|
|
1101
1174
|
// sees what they typed. A normal edit GET has no flash → fields from row.
|
|
1102
|
-
const { values, errors } = readFlash(ctx,
|
|
1175
|
+
const { values, errors } = readFlash(ctx, formColumns);
|
|
1103
1176
|
const vm = buildFormViewModel({
|
|
1104
1177
|
resource,
|
|
1105
|
-
columns,
|
|
1178
|
+
columns: formColumns,
|
|
1106
1179
|
pkColumn,
|
|
1180
|
+
writeOnly,
|
|
1107
1181
|
row,
|
|
1108
1182
|
values,
|
|
1109
1183
|
errors,
|
|
@@ -1118,15 +1192,13 @@ export default class StationProvider {
|
|
|
1118
1192
|
return async (ctx) => {
|
|
1119
1193
|
// Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
|
|
1120
1194
|
if (ctx.request.csrfProtected !== true) {
|
|
1121
|
-
denyCsrf(ctx);
|
|
1195
|
+
await this.#denyCsrf(ctx);
|
|
1122
1196
|
return;
|
|
1123
1197
|
}
|
|
1124
|
-
const { repo, pkColumn, columns, booleanKeys } = this.#requireContext(resource);
|
|
1198
|
+
const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } = this.#requireContext(resource);
|
|
1125
1199
|
// Authorize before the existence check (no 404-vs-403 oracle).
|
|
1126
|
-
if (!(await
|
|
1127
|
-
deny(ctx);
|
|
1200
|
+
if (!(await this.#authorize(ctx, resource, "edit")))
|
|
1128
1201
|
return;
|
|
1129
|
-
}
|
|
1130
1202
|
const id = ctx.params.id ?? "";
|
|
1131
1203
|
const entity = await repo.find(id);
|
|
1132
1204
|
if (entity === null) {
|
|
@@ -1142,7 +1214,7 @@ export default class StationProvider {
|
|
|
1142
1214
|
// poisons the audit diff nor emits a success event; the form re-renders
|
|
1143
1215
|
// 422 with the submitted values + per-field errors (the existing entity
|
|
1144
1216
|
// still supplies the id/action).
|
|
1145
|
-
const result = validateWritableBody(this.#requireWritableSchema(resource), booleanKeys, body);
|
|
1217
|
+
const result = validateWritableBody(this.#requireWritableSchema(resource, "update"), booleanKeys, body, writeOnly);
|
|
1146
1218
|
if (!result.valid) {
|
|
1147
1219
|
// PRG fallback URL = this row's edit form (AdonisJS `redirect().back()`).
|
|
1148
1220
|
const formUrl = `/admin/${encodeURIComponent(resource.name)}/${encodeURIComponent(id)}/edit`;
|
|
@@ -1154,19 +1226,24 @@ export default class StationProvider {
|
|
|
1154
1226
|
// would shadow the original values after setProp). `result.data`
|
|
1155
1227
|
// holds only declared, type-checked columns — the mass-assignment
|
|
1156
1228
|
// guarantee (PK / timestamps / unknown keys dropped) is inherent.
|
|
1157
|
-
const beforeSnapshot = snapshotEntity(entity, columns);
|
|
1229
|
+
const beforeSnapshot = snapshotEntity(entity, columns, redacted);
|
|
1230
|
+
const rawSecrets = {};
|
|
1231
|
+
for (const key of redacted)
|
|
1232
|
+
rawSecrets[key] = entity[key];
|
|
1158
1233
|
for (const [key, value] of Object.entries(result.data)) {
|
|
1159
1234
|
entity.setProp(key, value);
|
|
1160
1235
|
}
|
|
1161
1236
|
await repo.save(entity);
|
|
1162
|
-
const afterSnapshot = snapshotEntity(entity, columns);
|
|
1237
|
+
const afterSnapshot = snapshotEntity(entity, columns, redacted);
|
|
1163
1238
|
await emitAudit(resource, {
|
|
1164
1239
|
action: "edit",
|
|
1165
1240
|
resource: resource.name,
|
|
1166
1241
|
recordId: entity[pkColumn],
|
|
1167
1242
|
userId: ctx.auth?.user?.id,
|
|
1243
|
+
...requestOrigin(ctx),
|
|
1168
1244
|
before: beforeSnapshot,
|
|
1169
1245
|
after: afterSnapshot,
|
|
1246
|
+
redactedChanges: changedRedacted(rawSecrets, entity, redacted),
|
|
1170
1247
|
at: new Date(),
|
|
1171
1248
|
});
|
|
1172
1249
|
redirectToShow(ctx, resource, entity[pkColumn]);
|
|
@@ -1176,47 +1253,55 @@ export default class StationProvider {
|
|
|
1176
1253
|
// Browser forms can only emit GET / POST. The auto-generated edit
|
|
1177
1254
|
// form ships `<input type="hidden" name="_method" value="PUT">`
|
|
1178
1255
|
// so the POST /admin/:r/:id endpoint can route to the update
|
|
1179
|
-
// handler.
|
|
1180
|
-
//
|
|
1181
|
-
|
|
1182
|
-
const
|
|
1256
|
+
// handler. Only the enabled actions get a handler: an override naming a
|
|
1257
|
+
// disabled one is refused like any unsupported method, so `actions`
|
|
1258
|
+
// stays the boundary of what the admin can do to a resource.
|
|
1259
|
+
const updateHandler = resource.actions.includes("edit")
|
|
1260
|
+
? this.#buildUpdateHandler(resource)
|
|
1261
|
+
: undefined;
|
|
1262
|
+
const destroyHandler = resource.actions.includes("destroy")
|
|
1263
|
+
? this.#buildDestroyHandler(resource)
|
|
1264
|
+
: undefined;
|
|
1265
|
+
const allowed = [
|
|
1266
|
+
...(updateHandler === undefined ? [] : ["PUT", "PATCH"]),
|
|
1267
|
+
...(destroyHandler === undefined ? [] : ["DELETE"]),
|
|
1268
|
+
];
|
|
1183
1269
|
return async (ctx) => {
|
|
1184
1270
|
// Fail-close on CSRF before reading the body (see #buildCreateHandler).
|
|
1185
1271
|
// The PUT/DELETE branches delegate to already-guarded handlers, but the
|
|
1186
1272
|
// unsupported-override 405 branch would otherwise do work on a forged
|
|
1187
1273
|
// request and leak a 403-vs-405 oracle — guard the entrypoint too.
|
|
1188
1274
|
if (ctx.request.csrfProtected !== true) {
|
|
1189
|
-
denyCsrf(ctx);
|
|
1275
|
+
await this.#denyCsrf(ctx);
|
|
1190
1276
|
return;
|
|
1191
1277
|
}
|
|
1192
1278
|
const body = await readBody(ctx);
|
|
1193
1279
|
const override = String(body._method ?? "").toUpperCase();
|
|
1194
|
-
if (
|
|
1280
|
+
if (updateHandler !== undefined &&
|
|
1281
|
+
(override === "PUT" || override === "PATCH")) {
|
|
1195
1282
|
return updateHandler(ctx);
|
|
1196
1283
|
}
|
|
1197
|
-
if (override === "DELETE") {
|
|
1284
|
+
if (destroyHandler !== undefined && override === "DELETE") {
|
|
1198
1285
|
return destroyHandler(ctx);
|
|
1199
1286
|
}
|
|
1200
|
-
// Unsupported override — refuse rather than silently
|
|
1201
|
-
// to a no-op so misconfigured forms surface immediately.
|
|
1287
|
+
// Unsupported or disabled override — refuse rather than silently
|
|
1288
|
+
// downgrade to a no-op so misconfigured forms surface immediately.
|
|
1202
1289
|
ctx.response.status(405);
|
|
1203
1290
|
ctx.response.type("text/html; charset=utf-8");
|
|
1204
|
-
ctx.response.send(`<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id
|
|
1291
|
+
ctx.response.send(`<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id accepts <code>_method</code> = ${escapeMin(allowed.join(" | "))}.</p>`);
|
|
1205
1292
|
};
|
|
1206
1293
|
}
|
|
1207
1294
|
#buildDestroyHandler(resource) {
|
|
1208
1295
|
return async (ctx) => {
|
|
1209
1296
|
// Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
|
|
1210
1297
|
if (ctx.request.csrfProtected !== true) {
|
|
1211
|
-
denyCsrf(ctx);
|
|
1298
|
+
await this.#denyCsrf(ctx);
|
|
1212
1299
|
return;
|
|
1213
1300
|
}
|
|
1214
|
-
const { repo, pkColumn, columns } = this.#requireContext(resource);
|
|
1301
|
+
const { repo, pkColumn, columns, redacted } = this.#requireContext(resource);
|
|
1215
1302
|
// Authorize before the existence check (no 404-vs-403 oracle).
|
|
1216
|
-
if (!(await
|
|
1217
|
-
deny(ctx);
|
|
1303
|
+
if (!(await this.#authorize(ctx, resource, "destroy")))
|
|
1218
1304
|
return;
|
|
1219
|
-
}
|
|
1220
1305
|
const id = ctx.params.id ?? "";
|
|
1221
1306
|
const row = await repo.find(id);
|
|
1222
1307
|
if (row === null) {
|
|
@@ -1226,13 +1311,14 @@ export default class StationProvider {
|
|
|
1226
1311
|
ctx.response.send(notFoundHtml);
|
|
1227
1312
|
return;
|
|
1228
1313
|
}
|
|
1229
|
-
const before = snapshotEntity(row, columns);
|
|
1314
|
+
const before = snapshotEntity(row, columns, redacted);
|
|
1230
1315
|
await repo.delete(row);
|
|
1231
1316
|
await emitAudit(resource, {
|
|
1232
1317
|
action: "destroy",
|
|
1233
1318
|
resource: resource.name,
|
|
1234
1319
|
recordId: row[pkColumn],
|
|
1235
1320
|
userId: ctx.auth?.user?.id,
|
|
1321
|
+
...requestOrigin(ctx),
|
|
1236
1322
|
before,
|
|
1237
1323
|
at: new Date(),
|
|
1238
1324
|
});
|
|
@@ -1333,7 +1419,7 @@ export default class StationProvider {
|
|
|
1333
1419
|
// Clear the stale cookie regardless of response shape, so neither
|
|
1334
1420
|
// a browser refresh nor an XHR caller retries with the dead token.
|
|
1335
1421
|
ctx.response.clearCookie?.(this.#authConfig.cookieName, {
|
|
1336
|
-
path:
|
|
1422
|
+
path: AUTH_COOKIE_PATH,
|
|
1337
1423
|
});
|
|
1338
1424
|
if (this.#wantsJsonResponse(ctx)) {
|
|
1339
1425
|
ctx.response.status(401);
|
|
@@ -1369,6 +1455,11 @@ export default class StationProvider {
|
|
|
1369
1455
|
roleOk = false;
|
|
1370
1456
|
}
|
|
1371
1457
|
if (!roleOk) {
|
|
1458
|
+
await this.#securityEvent(ctx, {
|
|
1459
|
+
action: "denied",
|
|
1460
|
+
userId: user.id,
|
|
1461
|
+
permission: `role:${required}`,
|
|
1462
|
+
});
|
|
1372
1463
|
if (this.#wantsJsonResponse(ctx)) {
|
|
1373
1464
|
ctx.response.status(403);
|
|
1374
1465
|
ctx.response.json({ error: "insufficient role" });
|
|
@@ -1380,6 +1471,30 @@ export default class StationProvider {
|
|
|
1380
1471
|
return;
|
|
1381
1472
|
}
|
|
1382
1473
|
}
|
|
1474
|
+
// Entering the admin is its own grant (bug 214): without it, any app
|
|
1475
|
+
// user holding a same-named permission for the public app got in.
|
|
1476
|
+
const access = this.#authConfig.accessPermission;
|
|
1477
|
+
if (access !== undefined) {
|
|
1478
|
+
let accessOk;
|
|
1479
|
+
try {
|
|
1480
|
+
accessOk =
|
|
1481
|
+
(await manager.hasPermission(user, access, "global")) === true;
|
|
1482
|
+
}
|
|
1483
|
+
catch (err) {
|
|
1484
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1485
|
+
console.error(`[station] access check threw for '${access}' — denying (fail-closed): ${detail}`);
|
|
1486
|
+
accessOk = false;
|
|
1487
|
+
}
|
|
1488
|
+
if (!accessOk) {
|
|
1489
|
+
await this.#securityEvent(ctx, {
|
|
1490
|
+
action: "denied",
|
|
1491
|
+
userId: user.id,
|
|
1492
|
+
permission: access,
|
|
1493
|
+
});
|
|
1494
|
+
deny(ctx);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1383
1498
|
const existingAuth = ctx.auth ?? {};
|
|
1384
1499
|
ctx.auth = {
|
|
1385
1500
|
...existingAuth,
|
|
@@ -1407,11 +1522,11 @@ export default class StationProvider {
|
|
|
1407
1522
|
}
|
|
1408
1523
|
}
|
|
1409
1524
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1525
|
+
// No `?error=` read: Station never redirects with one (a failed POST
|
|
1526
|
+
// re-renders inline), so its only use was a third party writing
|
|
1527
|
+
// arbitrary text into the sign-in page's alert.
|
|
1412
1528
|
const vm = buildLoginViewModel({
|
|
1413
1529
|
action: this.#authConfig.loginPath,
|
|
1414
|
-
error: typeof errorParam === "string" ? errorParam : undefined,
|
|
1415
1530
|
csrfEnabled: ctx.store?.get("csrfToken") != null,
|
|
1416
1531
|
});
|
|
1417
1532
|
const html = await this.#renderView(ctx, "station::login", vm);
|
|
@@ -1431,7 +1546,7 @@ export default class StationProvider {
|
|
|
1431
1546
|
// AuthManager work (see #buildCreateHandler). Login is open by design
|
|
1432
1547
|
// (no auth gate), so CSRF is the sole pre-check here.
|
|
1433
1548
|
if (ctx.request.csrfProtected !== true) {
|
|
1434
|
-
denyCsrf(ctx);
|
|
1549
|
+
await this.#denyCsrf(ctx);
|
|
1435
1550
|
return;
|
|
1436
1551
|
}
|
|
1437
1552
|
const manager = this.#authManager;
|
|
@@ -1457,12 +1572,21 @@ export default class StationProvider {
|
|
|
1457
1572
|
ctx.response.send(html);
|
|
1458
1573
|
return;
|
|
1459
1574
|
}
|
|
1575
|
+
// Counted BEFORE the credential check (warden's AuthRateLimiter
|
|
1576
|
+
// contract): a refused attempt never reaches authenticate.
|
|
1577
|
+
const keys = loginKeys(ctx.request.ip?.() ?? "unknown", email);
|
|
1578
|
+
if (!(await this.#admitLogin(ctx, keys, email)))
|
|
1579
|
+
return;
|
|
1460
1580
|
const result = await manager.authenticate({ email, password });
|
|
1461
1581
|
// Warden returns the issued token on `user.token`, not at the
|
|
1462
1582
|
// top level — reading `result.token` (which never exists) sent
|
|
1463
1583
|
// every valid login down the 401 branch.
|
|
1464
1584
|
const token = typeof result.user?.token === "string" ? result.user.token : undefined;
|
|
1465
1585
|
if (!result.authenticated || token === undefined) {
|
|
1586
|
+
await this.#securityEvent(ctx, {
|
|
1587
|
+
action: "login_failed",
|
|
1588
|
+
identifier: email,
|
|
1589
|
+
});
|
|
1466
1590
|
const vm = buildLoginViewModel({
|
|
1467
1591
|
action: this.#authConfig.loginPath,
|
|
1468
1592
|
email,
|
|
@@ -1475,15 +1599,132 @@ export default class StationProvider {
|
|
|
1475
1599
|
ctx.response.send(html);
|
|
1476
1600
|
return;
|
|
1477
1601
|
}
|
|
1602
|
+
await this.#loginLimiter?.clear(...keys);
|
|
1603
|
+
await this.#securityEvent(ctx, {
|
|
1604
|
+
action: "login",
|
|
1605
|
+
userId: result.user?.id,
|
|
1606
|
+
identifier: email,
|
|
1607
|
+
});
|
|
1608
|
+
// No maxAge: a session cookie, gone with the browser; the token's own
|
|
1609
|
+
// expiry is enforced by verify() on every request.
|
|
1478
1610
|
ctx.response.cookie?.(this.#authConfig.cookieName, token, {
|
|
1479
1611
|
httpOnly: true,
|
|
1480
1612
|
sameSite: "Lax",
|
|
1481
|
-
secure:
|
|
1482
|
-
path:
|
|
1613
|
+
secure: this.#cookieSecure ?? secureByDefault(ctx),
|
|
1614
|
+
path: AUTH_COOKIE_PATH,
|
|
1483
1615
|
});
|
|
1484
1616
|
ctx.response.redirect("/admin");
|
|
1485
1617
|
};
|
|
1486
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* The per-action gate: allow, or record the refusal and answer 403.
|
|
1621
|
+
*/
|
|
1622
|
+
async #authorize(ctx, resource, action) {
|
|
1623
|
+
const prefix = this.#authConfig.permissionPrefix;
|
|
1624
|
+
if (await authorizeAction(resource, action, ctx, this.#authManager, prefix)) {
|
|
1625
|
+
return true;
|
|
1626
|
+
}
|
|
1627
|
+
await this.#securityEvent(ctx, {
|
|
1628
|
+
action: "denied",
|
|
1629
|
+
userId: ctx.auth?.user?.id,
|
|
1630
|
+
resource: resource.name,
|
|
1631
|
+
resourceAction: action,
|
|
1632
|
+
permission: `${prefix}.${resource.name}.${action}`,
|
|
1633
|
+
});
|
|
1634
|
+
deny(ctx);
|
|
1635
|
+
return false;
|
|
1636
|
+
}
|
|
1637
|
+
/** Record a CSRF block, then refuse it. */
|
|
1638
|
+
async #denyCsrf(ctx) {
|
|
1639
|
+
await this.#securityEvent(ctx, { action: "csrf_denied" });
|
|
1640
|
+
denyCsrf(ctx);
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Send a security event to `station.audit`, stamped with the request
|
|
1644
|
+
* origin. A failing sink is logged, never allowed to change the response.
|
|
1645
|
+
*/
|
|
1646
|
+
async #securityEvent(ctx, event) {
|
|
1647
|
+
const sink = this.#securityAudit;
|
|
1648
|
+
if (sink === undefined)
|
|
1649
|
+
return;
|
|
1650
|
+
try {
|
|
1651
|
+
await sink({
|
|
1652
|
+
...event,
|
|
1653
|
+
...requestOrigin(ctx),
|
|
1654
|
+
path: ctx.request.url?.(),
|
|
1655
|
+
at: new Date(),
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
catch (err) {
|
|
1659
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1660
|
+
console.error(`[station] security audit sink threw on ${event.action}: ${detail}. The event was NOT recorded.`);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
/**
|
|
1664
|
+
* Revoke `token` through the default guard when it supports revocation.
|
|
1665
|
+
* A guard without it (or a JwtStrategy without a blacklist, whose
|
|
1666
|
+
* `revoke` throws) is reported once: logout then only clears the cookie.
|
|
1667
|
+
*/
|
|
1668
|
+
async #revoke(token) {
|
|
1669
|
+
const strategy = this.#authManager?.getStrategy?.();
|
|
1670
|
+
let reason = "the auth guard has no revoke(token)";
|
|
1671
|
+
if (typeof strategy === "object" &&
|
|
1672
|
+
strategy !== null &&
|
|
1673
|
+
"revoke" in strategy &&
|
|
1674
|
+
typeof strategy.revoke === "function") {
|
|
1675
|
+
try {
|
|
1676
|
+
await strategy.revoke(token);
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
catch (err) {
|
|
1680
|
+
reason = err instanceof Error ? err.message : String(err);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
if (revokeUnavailableWarned)
|
|
1684
|
+
return;
|
|
1685
|
+
revokeUnavailableWarned = true;
|
|
1686
|
+
console.warn(`[station] Logout cannot revoke the admin token (${reason}); it stays valid until it expires. Give warden's JwtStrategy a \`blacklist\` so a logged-out token is refused.`);
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Count a login attempt and answer the refusal when the limiter says no:
|
|
1690
|
+
* 429 + Retry-After when exhausted, 503 when the limiter itself failed (a
|
|
1691
|
+
* throttle that cannot count must not wave attempts through).
|
|
1692
|
+
*/
|
|
1693
|
+
async #admitLogin(ctx, keys, email) {
|
|
1694
|
+
const limiter = this.#loginLimiter;
|
|
1695
|
+
if (limiter === undefined)
|
|
1696
|
+
return true;
|
|
1697
|
+
let decision;
|
|
1698
|
+
try {
|
|
1699
|
+
decision = await limiter.attempt(...keys);
|
|
1700
|
+
}
|
|
1701
|
+
catch (err) {
|
|
1702
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1703
|
+
console.error(`[station] login rate limiter failed — refusing the attempt (fail-closed): ${detail}`);
|
|
1704
|
+
ctx.response.status(503);
|
|
1705
|
+
ctx.response.type("text/plain; charset=utf-8");
|
|
1706
|
+
ctx.response.send("Sign-in is temporarily unavailable.");
|
|
1707
|
+
return false;
|
|
1708
|
+
}
|
|
1709
|
+
if (decision.allowed)
|
|
1710
|
+
return true;
|
|
1711
|
+
await this.#securityEvent(ctx, {
|
|
1712
|
+
action: "login_throttled",
|
|
1713
|
+
identifier: email,
|
|
1714
|
+
});
|
|
1715
|
+
ctx.response.status(429);
|
|
1716
|
+
ctx.response.header("Retry-After", String(Math.max(1, Math.ceil(decision.retryAfterSeconds))));
|
|
1717
|
+
const vm = buildLoginViewModel({
|
|
1718
|
+
action: this.#authConfig.loginPath,
|
|
1719
|
+
email,
|
|
1720
|
+
error: "Too many sign-in attempts. Try again later.",
|
|
1721
|
+
csrfEnabled: ctx.store?.get("csrfToken") != null,
|
|
1722
|
+
});
|
|
1723
|
+
const html = await this.#renderView(ctx, "station::login", vm);
|
|
1724
|
+
ctx.response.type("text/html; charset=utf-8");
|
|
1725
|
+
ctx.response.send(html);
|
|
1726
|
+
return false;
|
|
1727
|
+
}
|
|
1487
1728
|
/**
|
|
1488
1729
|
* `POST /admin/logout` — clear the session cookie and redirect to
|
|
1489
1730
|
* the login page. POST (not GET) so a crafted `<img src>` can't log
|
|
@@ -1494,16 +1735,83 @@ export default class StationProvider {
|
|
|
1494
1735
|
// Fail-close on CSRF: a forged cross-site POST must not be able to log
|
|
1495
1736
|
// an admin out (session-fixation nuisance) (see #buildCreateHandler).
|
|
1496
1737
|
if (ctx.request.csrfProtected !== true) {
|
|
1497
|
-
denyCsrf(ctx);
|
|
1738
|
+
await this.#denyCsrf(ctx);
|
|
1498
1739
|
return;
|
|
1499
1740
|
}
|
|
1741
|
+
// Clearing the cookie alone left the token valid until it expired:
|
|
1742
|
+
// a copy taken before logout (shared machine, proxy log) still
|
|
1743
|
+
// opened the admin.
|
|
1744
|
+
const token = this.#readAuthToken(ctx);
|
|
1745
|
+
if (token !== undefined) {
|
|
1746
|
+
// Who is leaving, read before the token is revoked — only when
|
|
1747
|
+
// someone is listening, since it costs a verify().
|
|
1748
|
+
const userId = this.#securityAudit === undefined
|
|
1749
|
+
? undefined
|
|
1750
|
+
: (await this.#authManager?.verify(token))?.user?.id;
|
|
1751
|
+
await this.#revoke(token);
|
|
1752
|
+
await this.#securityEvent(ctx, { action: "logout", userId });
|
|
1753
|
+
}
|
|
1754
|
+
else {
|
|
1755
|
+
await this.#securityEvent(ctx, { action: "logout" });
|
|
1756
|
+
}
|
|
1500
1757
|
ctx.response.clearCookie?.(this.#authConfig.cookieName, {
|
|
1501
|
-
path:
|
|
1758
|
+
path: AUTH_COOKIE_PATH,
|
|
1502
1759
|
});
|
|
1503
1760
|
ctx.response.redirect(this.#authConfig.loginPath);
|
|
1504
1761
|
};
|
|
1505
1762
|
}
|
|
1506
1763
|
}
|
|
1764
|
+
/** Who sent the request, as far as the host can tell. */
|
|
1765
|
+
function requestOrigin(ctx) {
|
|
1766
|
+
return {
|
|
1767
|
+
ip: ctx.request.ip?.(),
|
|
1768
|
+
userAgent: ctx.request.header?.("user-agent"),
|
|
1769
|
+
requestId: ctx.request.id?.() ?? ctx.request.header?.("x-request-id"),
|
|
1770
|
+
};
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* `Secure` unless this is a development/test process on plain HTTP — an
|
|
1774
|
+
* unknown environment gets the production answer, and `NODE_ENV=prod` is
|
|
1775
|
+
* production too.
|
|
1776
|
+
*/
|
|
1777
|
+
function secureByDefault(ctx) {
|
|
1778
|
+
const env = normalizeNodeEnv(process.env.NODE_ENV);
|
|
1779
|
+
if (env !== "development" && env !== "test")
|
|
1780
|
+
return true;
|
|
1781
|
+
return ctx.request.secure?.() === true;
|
|
1782
|
+
}
|
|
1783
|
+
function buildLoginLimiter(config) {
|
|
1784
|
+
if (config === false)
|
|
1785
|
+
return undefined;
|
|
1786
|
+
if (config?.limiter !== undefined)
|
|
1787
|
+
return config.limiter;
|
|
1788
|
+
return new MemoryLoginLimiter(config?.maxAttempts, config?.windowSeconds);
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* A router whose every route gets `names` attached through the route's own
|
|
1792
|
+
* `middleware(...names)` (ream's / AdonisJS's route API). A router that hands
|
|
1793
|
+
* back no such method cannot honour the config, which is refused loudly.
|
|
1794
|
+
*/
|
|
1795
|
+
function withRouteMiddleware(router, names) {
|
|
1796
|
+
if (names === undefined || names.length === 0)
|
|
1797
|
+
return router;
|
|
1798
|
+
const attach = (route) => {
|
|
1799
|
+
if (typeof route !== "object" ||
|
|
1800
|
+
route === null ||
|
|
1801
|
+
!("middleware" in route) ||
|
|
1802
|
+
typeof route.middleware !== "function") {
|
|
1803
|
+
throw new Error("[station] `station.middleware` is set but the router's routes have no middleware() method to attach it with.");
|
|
1804
|
+
}
|
|
1805
|
+
route.middleware(...names);
|
|
1806
|
+
return route;
|
|
1807
|
+
};
|
|
1808
|
+
return {
|
|
1809
|
+
get: (path, handler) => attach(router.get(path, handler)),
|
|
1810
|
+
post: (path, handler) => attach(router.post(path, handler)),
|
|
1811
|
+
put: (path, handler) => attach(router.put(path, handler)),
|
|
1812
|
+
delete: (path, handler) => attach(router.delete(path, handler)),
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1507
1815
|
/**
|
|
1508
1816
|
* Cross-package bridge — Station's `Resource.entity` is intentionally
|
|
1509
1817
|
* typed `new (...args: never[]) => unknown` so the package type-compiles
|
|
@@ -1538,15 +1846,27 @@ function buildResourceContext(resource, db, atlas, rune) {
|
|
|
1538
1846
|
const excludedProps = new Set(atlas.getRelationMetadata(resource.entity).map((r) => r.propertyKey));
|
|
1539
1847
|
if (atlas.hasSoftDeletes(resource.entity))
|
|
1540
1848
|
excludedProps.add("deletedAt");
|
|
1849
|
+
const listColumns = viewColumns(columns, resource.list.columns, resource, "list.columns");
|
|
1850
|
+
const showColumns = viewColumns(columns, resource.show.columns, resource, "show.columns");
|
|
1851
|
+
const formColumns = editableColumns(columns, resource, pkColumn, autoManaged, excludedProps);
|
|
1852
|
+
const writeOnly = writeOnlyKeys(formColumns, resource);
|
|
1541
1853
|
const derived = rune === null
|
|
1542
1854
|
? undefined
|
|
1543
|
-
: deriveWritableSchema(
|
|
1855
|
+
: deriveWritableSchema(formColumns, pkColumn, autoManaged, excludedProps, rune, writeOnly);
|
|
1544
1856
|
return {
|
|
1545
1857
|
repo,
|
|
1546
1858
|
columns,
|
|
1859
|
+
listColumns,
|
|
1860
|
+
showColumns,
|
|
1861
|
+
formColumns,
|
|
1862
|
+
writeOnly,
|
|
1863
|
+
redacted: new Set(resource.auditSecrets
|
|
1864
|
+
? []
|
|
1865
|
+
: columns.filter(isSecretColumn).map((c) => c.propertyKey)),
|
|
1547
1866
|
pkColumn,
|
|
1548
1867
|
autoManaged,
|
|
1549
1868
|
writableSchema: derived?.schema,
|
|
1869
|
+
updateSchema: derived?.updateSchema,
|
|
1550
1870
|
booleanKeys: derived?.booleanKeys ?? new Set(),
|
|
1551
1871
|
};
|
|
1552
1872
|
}
|
|
@@ -1624,7 +1944,7 @@ function isRouterProxyUninit(err) {
|
|
|
1624
1944
|
/**
|
|
1625
1945
|
* 56.5 authorization gate. Returns true when the action is allowed.
|
|
1626
1946
|
* Station authorizes EXCLUSIVELY through Warden's unified layer: the
|
|
1627
|
-
* decision is `auth.hasPermission(user, "<resource>.<action>", scope)`
|
|
1947
|
+
* decision is `auth.hasPermission(user, "<prefix>.<resource>.<action>", scope)`
|
|
1628
1948
|
* resolved via the container `"auth"` AuthManager (D1/D2). No
|
|
1629
1949
|
* Station-local RBAC computation, no token-payload read.
|
|
1630
1950
|
*
|
|
@@ -1644,7 +1964,8 @@ function isRouterProxyUninit(err) {
|
|
|
1644
1964
|
* The result MUST be awaited at every call site — a forgotten `await`
|
|
1645
1965
|
* yields a truthy Promise = silent ALLOW (AC10 probe 3).
|
|
1646
1966
|
*/
|
|
1647
|
-
async function authorizeAction(resource, action, ctx, authManager, scope = "global") {
|
|
1967
|
+
async function authorizeAction(resource, action, ctx, authManager, prefix, scope = "global") {
|
|
1968
|
+
const permission = `${prefix}.${resource.name}.${action}`;
|
|
1648
1969
|
if (authManager === undefined)
|
|
1649
1970
|
return true;
|
|
1650
1971
|
const user = ctx.auth?.user;
|
|
@@ -1657,13 +1978,13 @@ async function authorizeAction(resource, action, ctx, authManager, scope = "glob
|
|
|
1657
1978
|
// Coerce to a strict boolean: the duck-typed manager could resolve a
|
|
1658
1979
|
// truthy non-boolean, and `!truthy` would silently ALLOW. Anything
|
|
1659
1980
|
// not exactly `true` denies (fail-closed).
|
|
1660
|
-
return (
|
|
1981
|
+
return (await authManager.hasPermission(user, permission, scope)) === true;
|
|
1661
1982
|
}
|
|
1662
1983
|
catch (err) {
|
|
1663
1984
|
// A rights-store I/O failure (DB/Redis-backed resolver) must not pass
|
|
1664
1985
|
// the gate. Deny and surface it loud rather than 500-ing or allowing.
|
|
1665
1986
|
const detail = err instanceof Error ? err.message : String(err);
|
|
1666
|
-
console.error(`[station] authorization check threw for '${
|
|
1987
|
+
console.error(`[station] authorization check threw for '${permission}' — denying (fail-closed): ${detail}`);
|
|
1667
1988
|
return false;
|
|
1668
1989
|
}
|
|
1669
1990
|
}
|