@c9up/station 0.1.14 → 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 +3 -2
- 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/src/StationProvider.ts
CHANGED
|
@@ -26,10 +26,29 @@ import type {
|
|
|
26
26
|
DatabaseConnection,
|
|
27
27
|
DateColumnConfig,
|
|
28
28
|
} from "@c9up/atlas";
|
|
29
|
+
import {
|
|
30
|
+
editableColumns,
|
|
31
|
+
isFrameworkManaged,
|
|
32
|
+
isSecretColumn,
|
|
33
|
+
viewColumns,
|
|
34
|
+
writeOnlyKeys,
|
|
35
|
+
} from "./columns.js";
|
|
36
|
+
import {
|
|
37
|
+
loginKeys,
|
|
38
|
+
MemoryLoginLimiter,
|
|
39
|
+
type StationLoginLimiter,
|
|
40
|
+
type StationLoginRateLimitConfig,
|
|
41
|
+
} from "./loginLimiter.js";
|
|
29
42
|
import { ResourceRegistry } from "./ResourceRegistry.js";
|
|
30
43
|
import { clearStation, getStation, setStation } from "./services/main.js";
|
|
31
|
-
import type {
|
|
32
|
-
|
|
44
|
+
import type {
|
|
45
|
+
AuditEvent,
|
|
46
|
+
Resource,
|
|
47
|
+
ResourceAction,
|
|
48
|
+
SecurityAuditEvent,
|
|
49
|
+
SecurityAuditSink,
|
|
50
|
+
} from "./types.js";
|
|
51
|
+
import { inProduction, normalizeNodeEnv } from "./vendor/nodeEnv.js";
|
|
33
52
|
// note: AuditEvent + ResourceAction are used by the CRUD handlers below;
|
|
34
53
|
// the imports stay in one block for clarity.
|
|
35
54
|
import { buildFormViewModel } from "./views/form.js";
|
|
@@ -69,9 +88,18 @@ interface StationHttpContext {
|
|
|
69
88
|
request: {
|
|
70
89
|
qs(): Record<string, string | undefined>;
|
|
71
90
|
body?(): Promise<unknown> | unknown;
|
|
72
|
-
url?(): string;
|
|
73
91
|
header?(name: string): string | undefined;
|
|
74
92
|
cookie?(name: string): string | undefined;
|
|
93
|
+
/** The request path (ream's `url()` without the query string). */
|
|
94
|
+
url?(): string;
|
|
95
|
+
/** `X-Request-Id`, when the proxy or client set one (AdonisJS `id`). */
|
|
96
|
+
id?(): string | undefined;
|
|
97
|
+
/** `:authority` or `Host` — never a forwarded host (AdonisJS `authority`). */
|
|
98
|
+
authority?(): string | null;
|
|
99
|
+
/** Client IP as the host resolved it (trusted-proxy aware in ream). */
|
|
100
|
+
ip?(): string;
|
|
101
|
+
/** Whether the request came over HTTPS (trusted-proxy aware in ream). */
|
|
102
|
+
secure?(): boolean;
|
|
75
103
|
/**
|
|
76
104
|
* `true` only when the host's `@c9up/blackhole` middleware enforced +
|
|
77
105
|
* validated CSRF for this request (a first-class ream `HttpContext`
|
|
@@ -200,7 +228,18 @@ interface StationPaginator {
|
|
|
200
228
|
/** Per-resource snapshot built once at `start()`, reused on every request. */
|
|
201
229
|
interface ResourceContext {
|
|
202
230
|
repo: StationRepository;
|
|
231
|
+
/** Every column — audit snapshots read these (secrets redacted there). */
|
|
203
232
|
columns: ReadonlyArray<ColumnMetadata>;
|
|
233
|
+
/** What the list page renders (bug 209: secrets excluded by default). */
|
|
234
|
+
listColumns: ReadonlyArray<ColumnMetadata>;
|
|
235
|
+
/** What the show page renders. */
|
|
236
|
+
showColumns: ReadonlyArray<ColumnMetadata>;
|
|
237
|
+
/** The editable allow-list — the form's fields and the schema's keys. */
|
|
238
|
+
formColumns: ReadonlyArray<ColumnMetadata>;
|
|
239
|
+
/** Editable columns never pre-filled; an empty submission keeps them. */
|
|
240
|
+
writeOnly: ReadonlySet<string>;
|
|
241
|
+
/** Columns whose values audit snapshots replace with "[redacted]". */
|
|
242
|
+
redacted: ReadonlySet<string>;
|
|
204
243
|
pkColumn: string;
|
|
205
244
|
/**
|
|
206
245
|
* Property keys of framework-managed timestamp columns — those declared
|
|
@@ -220,6 +259,8 @@ interface ResourceContext {
|
|
|
220
259
|
* unchecked box submits nothing).
|
|
221
260
|
*/
|
|
222
261
|
writableSchema?: RuneSchema;
|
|
262
|
+
/** Same keys, write-only columns optional: an edit may leave them as stored. */
|
|
263
|
+
updateSchema?: RuneSchema;
|
|
223
264
|
booleanKeys: ReadonlySet<string>;
|
|
224
265
|
}
|
|
225
266
|
|
|
@@ -419,6 +460,12 @@ interface WardenAuthManager {
|
|
|
419
460
|
role: string,
|
|
420
461
|
scope?: StationScope,
|
|
421
462
|
): Promise<boolean>;
|
|
463
|
+
/**
|
|
464
|
+
* The guard `verify(token)` uses when no name is given (warden's
|
|
465
|
+
* `getStrategy()`). Logout calls its `revoke(token)` when it has one —
|
|
466
|
+
* JwtStrategy does, backed by its jti blacklist.
|
|
467
|
+
*/
|
|
468
|
+
getStrategy?(name?: string): unknown;
|
|
422
469
|
}
|
|
423
470
|
|
|
424
471
|
/**
|
|
@@ -435,11 +482,23 @@ export interface StationConfig {
|
|
|
435
482
|
*/
|
|
436
483
|
requireAuth?: boolean;
|
|
437
484
|
/**
|
|
438
|
-
* Role required to pass the auth gate
|
|
439
|
-
* authenticated user can access `/admin/*` (the per-action
|
|
440
|
-
* `<resource>.<action>` permission gate still applies).
|
|
485
|
+
* Role required to pass the auth gate, on top of {@link accessPermission}.
|
|
441
486
|
*/
|
|
442
487
|
requireRole?: string;
|
|
488
|
+
/**
|
|
489
|
+
* Namespace of every admin permission: actions are gated behind
|
|
490
|
+
* `<prefix>.<resource>.<action>` (e.g. `station.users.edit`) and entry
|
|
491
|
+
* behind `<prefix>.access`. Defaults to `"station"`, so a grant the
|
|
492
|
+
* application made for its own features (`users.edit`) never opens the
|
|
493
|
+
* admin.
|
|
494
|
+
*/
|
|
495
|
+
permissionPrefix?: string;
|
|
496
|
+
/**
|
|
497
|
+
* Permission every request must hold to enter `/admin/*`, checked before
|
|
498
|
+
* the per-action one. Defaults to `<permissionPrefix>.access`. `false`
|
|
499
|
+
* removes this gate (the per-action permissions still apply).
|
|
500
|
+
*/
|
|
501
|
+
accessPermission?: string | false;
|
|
443
502
|
/**
|
|
444
503
|
* Where to redirect on a failed auth check. Defaults to
|
|
445
504
|
* `/admin/login`.
|
|
@@ -450,9 +509,38 @@ export interface StationConfig {
|
|
|
450
509
|
* to avoid colliding with app-level session cookies.
|
|
451
510
|
*/
|
|
452
511
|
cookieName?: string;
|
|
512
|
+
/**
|
|
513
|
+
* `Secure` flag of the auth cookie. Defaults to on everywhere except a
|
|
514
|
+
* development/test process served over plain HTTP, so an unknown
|
|
515
|
+
* environment behaves like production.
|
|
516
|
+
*/
|
|
517
|
+
cookieSecure?: boolean;
|
|
518
|
+
/**
|
|
519
|
+
* Brute-force throttle on `POST /admin/login`: 5 attempts per IP and per
|
|
520
|
+
* identifier every 15 minutes by default, in this process's memory. Pass
|
|
521
|
+
* `limiter` (warden's `AuthRateLimiter` fits) to share counts across
|
|
522
|
+
* instances. `false` turns it off — only for an app that throttles the
|
|
523
|
+
* route itself (see {@link middleware}).
|
|
524
|
+
*/
|
|
525
|
+
loginRateLimit?: StationLoginRateLimitConfig | false;
|
|
526
|
+
/** Named router middleware attached to every route Station mounts. */
|
|
527
|
+
middleware?: ReadonlyArray<string>;
|
|
528
|
+
/**
|
|
529
|
+
* Sink for the admin's security events: sign-in success, failure and
|
|
530
|
+
* throttling, logout, authorization denials and CSRF blocks, each with the
|
|
531
|
+
* client IP and request id. Write events keep going to each resource's
|
|
532
|
+
* own `audit`.
|
|
533
|
+
*/
|
|
534
|
+
audit?: SecurityAuditSink;
|
|
453
535
|
}
|
|
454
536
|
|
|
455
537
|
const MAX_PER_PAGE = 100;
|
|
538
|
+
const DEFAULT_PERMISSION_PREFIX = "station";
|
|
539
|
+
/**
|
|
540
|
+
* The auth cookie travels only to the admin: scoped to `/`, the admin bearer
|
|
541
|
+
* token rode along with every request to the rest of the application.
|
|
542
|
+
*/
|
|
543
|
+
const AUTH_COOKIE_PATH = "/admin";
|
|
456
544
|
const DEFAULT_PER_PAGE = 25;
|
|
457
545
|
const POSITIVE_INT_RE = /^[1-9][0-9]*$/;
|
|
458
546
|
|
|
@@ -462,6 +550,7 @@ let perPageClampWarned = false;
|
|
|
462
550
|
let seedPermsWarned = false;
|
|
463
551
|
let missingAuditWarned = false;
|
|
464
552
|
let csrfBlockedWarned = false;
|
|
553
|
+
let revokeUnavailableWarned = false;
|
|
465
554
|
|
|
466
555
|
/** @internal Reset module-level flags between tests. */
|
|
467
556
|
export function resetStationProviderFlags(): void {
|
|
@@ -470,6 +559,7 @@ export function resetStationProviderFlags(): void {
|
|
|
470
559
|
seedPermsWarned = false;
|
|
471
560
|
missingAuditWarned = false;
|
|
472
561
|
csrfBlockedWarned = false;
|
|
562
|
+
revokeUnavailableWarned = false;
|
|
473
563
|
}
|
|
474
564
|
|
|
475
565
|
/**
|
|
@@ -491,17 +581,6 @@ function notFoundFailFingerprint(cause: unknown): string {
|
|
|
491
581
|
*/
|
|
492
582
|
const NOT_FOUND_RENDER_FAIL_CAP = 32;
|
|
493
583
|
|
|
494
|
-
const TIMESTAMP_PROPERTY_KEYS: ReadonlySet<string> = new Set([
|
|
495
|
-
"createdAt",
|
|
496
|
-
"updatedAt",
|
|
497
|
-
"deletedAt",
|
|
498
|
-
]);
|
|
499
|
-
const TIMESTAMP_COLUMN_NAMES: ReadonlySet<string> = new Set([
|
|
500
|
-
"created_at",
|
|
501
|
-
"updated_at",
|
|
502
|
-
"deleted_at",
|
|
503
|
-
]);
|
|
504
|
-
|
|
505
584
|
/**
|
|
506
585
|
* Column `type` strings mapped to a numeric rune rule (mirrors form.ts input
|
|
507
586
|
* inference). `bigint` is deliberately EXCLUDED: coercing it through `Number()`
|
|
@@ -537,9 +616,9 @@ export function resourcesNeedValidation(
|
|
|
537
616
|
* Derive a `@c9up/rune` validation schema (57.7) from an entity's writable
|
|
538
617
|
* column metadata — the structural replacement for the hand-rolled
|
|
539
618
|
* `filterWritableBody`. The mass-assignment guarantee is now INHERENT:
|
|
540
|
-
* `schema.validateResult()` returns only
|
|
541
|
-
*
|
|
542
|
-
*
|
|
619
|
+
* `schema.validateResult()` returns only the keys of the editable columns it
|
|
620
|
+
* was given, so any other body key — an unknown one, a column outside the
|
|
621
|
+
* allow-list, the PK, a timestamp — is dropped automatically.
|
|
543
622
|
*
|
|
544
623
|
* Excluded, exactly as the old guard did: the primary key, framework-managed
|
|
545
624
|
* timestamps (both the Lucid-style `autoManaged` flag AND the `created_at /
|
|
@@ -560,7 +639,9 @@ export function resourcesNeedValidation(
|
|
|
560
639
|
* Returns the built schema plus the set of boolean columns — the handler
|
|
561
640
|
* default-fills an ABSENT boolean key to `false` before validating (an
|
|
562
641
|
* unchecked checkbox submits nothing; this preserves the old edit-clears-box
|
|
563
|
-
* behaviour).
|
|
642
|
+
* behaviour). `columns` is the editable allow-list; framework-owned columns
|
|
643
|
+
* are dropped here too, so no caller can make them writable. `updateSchema`
|
|
644
|
+
* differs only for `writeOnly` columns, which an edit may leave out.
|
|
564
645
|
*/
|
|
565
646
|
export function deriveWritableSchema(
|
|
566
647
|
columns: ReadonlyArray<ColumnMetadata>,
|
|
@@ -568,41 +649,65 @@ export function deriveWritableSchema(
|
|
|
568
649
|
autoManaged: ReadonlySet<string>,
|
|
569
650
|
excludedProps: ReadonlySet<string>,
|
|
570
651
|
rune: RuneModule,
|
|
571
|
-
|
|
652
|
+
writeOnly: ReadonlySet<string> = new Set(),
|
|
653
|
+
): {
|
|
654
|
+
schema: RuneSchema;
|
|
655
|
+
updateSchema: RuneSchema;
|
|
656
|
+
booleanKeys: ReadonlySet<string>;
|
|
657
|
+
} {
|
|
572
658
|
const fields: Record<string, RuneRuleChain> = {};
|
|
659
|
+
const updateFields: Record<string, RuneRuleChain> = {};
|
|
573
660
|
const booleanKeys = new Set<string>();
|
|
574
661
|
for (const c of columns) {
|
|
575
|
-
if (c
|
|
576
|
-
if (autoManaged.has(c.propertyKey)) continue;
|
|
577
|
-
if (TIMESTAMP_PROPERTY_KEYS.has(c.propertyKey)) continue;
|
|
578
|
-
const snake = c.propertyKey.replace(/([A-Z])/g, "_$1").toLowerCase();
|
|
579
|
-
if (TIMESTAMP_COLUMN_NAMES.has(snake)) continue;
|
|
580
|
-
if (excludedProps.has(c.propertyKey)) continue;
|
|
662
|
+
if (isFrameworkManaged(c, pkColumn, autoManaged, excludedProps)) continue;
|
|
581
663
|
const type = (c.type ?? "").toString().toLowerCase();
|
|
582
664
|
if (type === "boolean") {
|
|
583
665
|
// Checkbox: the handler pre-fills an absent key to `false`, so every
|
|
584
666
|
// boolean is always present here → required, no `.optional()`.
|
|
585
667
|
booleanKeys.add(c.propertyKey);
|
|
586
668
|
fields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
|
|
669
|
+
updateFields[c.propertyKey] = rune.rules.boolean().parse(isCheckedValue);
|
|
587
670
|
continue;
|
|
588
671
|
}
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
672
|
+
fields[c.propertyKey] = columnChain(c, type, rune, false);
|
|
673
|
+
updateFields[c.propertyKey] = columnChain(
|
|
674
|
+
c,
|
|
675
|
+
type,
|
|
676
|
+
rune,
|
|
677
|
+
writeOnly.has(c.propertyKey),
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
return {
|
|
681
|
+
schema: rune.schema(fields),
|
|
682
|
+
updateSchema: rune.schema(updateFields),
|
|
683
|
+
booleanKeys,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* The rule for one non-boolean column. A column is required iff the model
|
|
689
|
+
* declares neither nullability nor a default — unless `keepable`: an edit may
|
|
690
|
+
* omit a write-only column to keep its stored value. DB-side defaults
|
|
691
|
+
* (serial, `DEFAULT`) are invisible to metadata (documented residual
|
|
692
|
+
* limitation); the PK, the common serial case, is never editable.
|
|
693
|
+
*/
|
|
694
|
+
function columnChain(
|
|
695
|
+
c: ColumnMetadata,
|
|
696
|
+
type: string,
|
|
697
|
+
rune: RuneModule,
|
|
698
|
+
keepable: boolean,
|
|
699
|
+
): RuneRuleChain {
|
|
700
|
+
const required = !keepable && c.nullable !== true && c.default === undefined;
|
|
701
|
+
let chain: RuneRuleChain = NUMBER_COLUMN_TYPES.has(type)
|
|
702
|
+
? rune.rules.number().parse(parseNumeric)
|
|
703
|
+
: JSON_COLUMN_TYPES.has(type)
|
|
704
|
+
? rune.rules.any()
|
|
705
|
+
: rune.rules.string();
|
|
706
|
+
if (!required) {
|
|
707
|
+
chain = chain.optional();
|
|
708
|
+
if (c.nullable === true) chain = chain.nullable();
|
|
604
709
|
}
|
|
605
|
-
return
|
|
710
|
+
return chain;
|
|
606
711
|
}
|
|
607
712
|
|
|
608
713
|
/**
|
|
@@ -611,14 +716,19 @@ export function deriveWritableSchema(
|
|
|
611
716
|
* preserving the old `filterWritableBody` checkbox semantics, then runs rune's
|
|
612
717
|
* synchronous, never-throwing `validateResult()`. On success `result.data` holds only
|
|
613
718
|
* declared-column keys (mass-assignment inherent). Exported for the unit tests
|
|
614
|
-
* that pin the coercion CRUX.
|
|
719
|
+
* that pin the coercion CRUX. A blank `writeOnly` value counts as not
|
|
720
|
+
* submitted: the form never pre-fills those, so blank means "unchanged".
|
|
615
721
|
*/
|
|
616
722
|
export function validateWritableBody(
|
|
617
723
|
schema: RuneSchema,
|
|
618
724
|
booleanKeys: ReadonlySet<string>,
|
|
619
725
|
body: Record<string, unknown>,
|
|
726
|
+
writeOnly: ReadonlySet<string> = new Set(),
|
|
620
727
|
): RuneValidationResult {
|
|
621
728
|
const input: Record<string, unknown> = { ...body };
|
|
729
|
+
for (const key of writeOnly) {
|
|
730
|
+
if (input[key] === "") delete input[key];
|
|
731
|
+
}
|
|
622
732
|
for (const key of booleanKeys) {
|
|
623
733
|
if (!(key in input)) input[key] = false;
|
|
624
734
|
}
|
|
@@ -668,9 +778,6 @@ function fieldErrors(
|
|
|
668
778
|
/** Session flash key under which the per-field validation errors are stashed (AdonisJS `flashMessages.errors` parity). */
|
|
669
779
|
const FLASH_ERRORS_KEY = "errors";
|
|
670
780
|
|
|
671
|
-
/** Column propertyKeys whose values are secrets — never flashed to the session nor echoed back into HTML (AdonisJS parity). */
|
|
672
|
-
const SENSITIVE_KEY_RE = /password|passwd|secret|token|apikey/i;
|
|
673
|
-
|
|
674
781
|
/**
|
|
675
782
|
* The subset of a submitted body safe to re-surface on a validation error:
|
|
676
783
|
* declared columns only — so `_csrf`, `_method`, and any mass-assignment /
|
|
@@ -684,7 +791,7 @@ function flashableInput(
|
|
|
684
791
|
): Record<string, unknown> {
|
|
685
792
|
const out: Record<string, unknown> = {};
|
|
686
793
|
for (const c of columns) {
|
|
687
|
-
if (
|
|
794
|
+
if (isSecretColumn(c)) continue;
|
|
688
795
|
if (c.propertyKey in submitted)
|
|
689
796
|
out[c.propertyKey] = submitted[c.propertyKey];
|
|
690
797
|
}
|
|
@@ -730,44 +837,50 @@ function readFlash(
|
|
|
730
837
|
}
|
|
731
838
|
|
|
732
839
|
/**
|
|
733
|
-
*
|
|
734
|
-
* `
|
|
735
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
840
|
+
* The path a `Referer` points back to, when it may be trusted — AdonisJS's
|
|
841
|
+
* `isValidRedirectUrl` + `redirect().back()`: a relative path is trusted, an
|
|
842
|
+
* absolute URL only when its host is the request's own authority (`Host` /
|
|
843
|
+
* `:authority`, never a forwarded host), and the redirect goes to the path
|
|
844
|
+
* alone. Comparing with `request.url()` could never work: in ream that is
|
|
845
|
+
* the path, while a browser's Referer is a full URL. Deliberately stricter
|
|
846
|
+
* than upstream in one place: with no authority to compare against, an
|
|
847
|
+
* absolute Referer is refused rather than accepted.
|
|
738
848
|
*/
|
|
739
|
-
function
|
|
849
|
+
function trustedRefererPath(
|
|
850
|
+
referer: string,
|
|
851
|
+
authority: string | null | undefined,
|
|
852
|
+
): string | undefined {
|
|
740
853
|
// A relative path is trusted only when it starts with a single "/" and
|
|
741
854
|
// contains no backslash: browsers normalise "\\" to "/", so "/\\evil.com"
|
|
742
855
|
// becomes the protocol-relative "//evil.com" that a lone `!startsWith("//")`
|
|
743
856
|
// check would let through.
|
|
744
|
-
if (
|
|
745
|
-
referer.startsWith("/") &&
|
|
746
|
-
!referer.startsWith("//") &&
|
|
747
|
-
!referer.includes("\\")
|
|
748
|
-
) {
|
|
749
|
-
return true;
|
|
750
|
-
}
|
|
751
|
-
if (requestUrl === undefined) return false;
|
|
857
|
+
if (referer.startsWith("//") || referer.includes("\\")) return undefined;
|
|
752
858
|
try {
|
|
753
|
-
|
|
859
|
+
if (referer.startsWith("/")) {
|
|
860
|
+
const url = new URL(referer, "http://localhost");
|
|
861
|
+
return url.host === "localhost" ? url.pathname : undefined;
|
|
862
|
+
}
|
|
863
|
+
if (authority === null || authority === undefined || authority === "") {
|
|
864
|
+
return undefined;
|
|
865
|
+
}
|
|
866
|
+
const url = new URL(referer);
|
|
867
|
+
return url.host === authority ? url.pathname : undefined;
|
|
754
868
|
} catch {
|
|
755
|
-
return
|
|
869
|
+
return undefined;
|
|
756
870
|
}
|
|
757
871
|
}
|
|
758
872
|
|
|
759
873
|
/**
|
|
760
|
-
* `redirect().back()` over Station's `redirect(url)` seam (57.7):
|
|
761
|
-
* trusted
|
|
762
|
-
* the same behaviour as ream's `RedirectBuilder.back(fallback)`, open-redirect
|
|
763
|
-
* safe.
|
|
874
|
+
* `redirect().back()` over Station's `redirect(url)` seam (57.7): the path of a
|
|
875
|
+
* trusted `Referer`, else the caller's `fallback` (the form URL).
|
|
764
876
|
*/
|
|
765
877
|
function redirectBack(ctx: StationHttpContext, fallback: string): void {
|
|
766
878
|
const referer = ctx.request.header?.("referer");
|
|
879
|
+
const authority =
|
|
880
|
+
ctx.request.authority?.() ?? ctx.request.header?.("host") ?? undefined;
|
|
767
881
|
const target =
|
|
768
|
-
typeof referer === "string"
|
|
769
|
-
|
|
770
|
-
? referer
|
|
882
|
+
typeof referer === "string"
|
|
883
|
+
? (trustedRefererPath(referer, authority) ?? fallback)
|
|
771
884
|
: fallback;
|
|
772
885
|
ctx.response.redirect(target);
|
|
773
886
|
}
|
|
@@ -796,9 +909,14 @@ function isCheckedValue(value: unknown): boolean {
|
|
|
796
909
|
function snapshotEntity(
|
|
797
910
|
entity: Record<string, unknown>,
|
|
798
911
|
columns: ReadonlyArray<ColumnMetadata>,
|
|
912
|
+
redacted: ReadonlySet<string>,
|
|
799
913
|
): Record<string, unknown> {
|
|
800
914
|
const out: Record<string, unknown> = {};
|
|
801
|
-
for (const c of columns)
|
|
915
|
+
for (const c of columns) {
|
|
916
|
+
out[c.propertyKey] = redacted.has(c.propertyKey)
|
|
917
|
+
? REDACTED
|
|
918
|
+
: entity[c.propertyKey];
|
|
919
|
+
}
|
|
802
920
|
try {
|
|
803
921
|
return structuredClone(out);
|
|
804
922
|
} catch (err) {
|
|
@@ -826,6 +944,21 @@ function snapshotEntity(
|
|
|
826
944
|
}
|
|
827
945
|
let auditCloneWarnEmitted = false;
|
|
828
946
|
|
|
947
|
+
/** What an audit snapshot holds in place of a secret column's value. */
|
|
948
|
+
const REDACTED = "[redacted]";
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* The redacted columns an edit changed — read from the raw values, so the
|
|
952
|
+
* audit trail says "password changed" without carrying either hash.
|
|
953
|
+
*/
|
|
954
|
+
function changedRedacted(
|
|
955
|
+
before: Readonly<Record<string, unknown>>,
|
|
956
|
+
entity: Readonly<Record<string, unknown>>,
|
|
957
|
+
redacted: ReadonlySet<string>,
|
|
958
|
+
): string[] {
|
|
959
|
+
return [...redacted].filter((key) => !Object.is(before[key], entity[key]));
|
|
960
|
+
}
|
|
961
|
+
|
|
829
962
|
/**
|
|
830
963
|
* Filesystem path to the `.inker` templates Station ships inside the
|
|
831
964
|
* package (57.1, D3). Resolved from `import.meta.url` so the SAME path
|
|
@@ -851,14 +984,21 @@ export default class StationProvider {
|
|
|
851
984
|
// 54.7 auth state — populated when warden is wired AND
|
|
852
985
|
// StationConfig.requireAuth is true (default when warden is present).
|
|
853
986
|
#authManager: WardenAuthManager | undefined;
|
|
987
|
+
#loginLimiter: StationLoginLimiter | undefined;
|
|
988
|
+
#cookieSecure: boolean | undefined;
|
|
989
|
+
#securityAudit: SecurityAuditSink | undefined;
|
|
854
990
|
#authConfig: Required<Pick<StationConfig, "loginPath" | "cookieName">> & {
|
|
855
991
|
requireAuth: boolean;
|
|
856
992
|
requireRole: string | undefined;
|
|
993
|
+
permissionPrefix: string;
|
|
994
|
+
accessPermission: string | undefined;
|
|
857
995
|
} = {
|
|
858
996
|
requireAuth: false,
|
|
859
997
|
requireRole: undefined,
|
|
860
998
|
loginPath: "/admin/login",
|
|
861
999
|
cookieName: "station_auth",
|
|
1000
|
+
permissionPrefix: DEFAULT_PERMISSION_PREFIX,
|
|
1001
|
+
accessPermission: undefined,
|
|
862
1002
|
};
|
|
863
1003
|
|
|
864
1004
|
constructor(protected app: StationAppContext) {}
|
|
@@ -905,7 +1045,17 @@ export default class StationProvider {
|
|
|
905
1045
|
// peer is a degraded-host signal: #loadPeers returns null → silent stop.
|
|
906
1046
|
const peers = await this.#loadPeers();
|
|
907
1047
|
if (!peers) return;
|
|
908
|
-
const {
|
|
1048
|
+
const { atlas, rune } = peers;
|
|
1049
|
+
const router = withRouteMiddleware(peers.router, userConfig.middleware);
|
|
1050
|
+
this.#loginLimiter = buildLoginLimiter(userConfig.loginRateLimit);
|
|
1051
|
+
this.#cookieSecure = userConfig.cookieSecure;
|
|
1052
|
+
if (
|
|
1053
|
+
userConfig.audit !== undefined &&
|
|
1054
|
+
typeof userConfig.audit !== "function"
|
|
1055
|
+
) {
|
|
1056
|
+
throw new TypeError("[station] `station.audit` must be a function");
|
|
1057
|
+
}
|
|
1058
|
+
this.#securityAudit = userConfig.audit;
|
|
909
1059
|
|
|
910
1060
|
// Phase 1a2 — resolve the SHARED inker renderer (AdonisJS/Edge parity)
|
|
911
1061
|
// and mount Station's package templates as the `station` disk. Unlike
|
|
@@ -1181,6 +1331,13 @@ export default class StationProvider {
|
|
|
1181
1331
|
* not installed/bound the gate stays open and a one-time warning is emitted.
|
|
1182
1332
|
*/
|
|
1183
1333
|
async #configureAuth(userConfig: StationConfig): Promise<void> {
|
|
1334
|
+
const permissionPrefix =
|
|
1335
|
+
userConfig.permissionPrefix ?? DEFAULT_PERMISSION_PREFIX;
|
|
1336
|
+
if (typeof permissionPrefix !== "string" || permissionPrefix === "") {
|
|
1337
|
+
throw new Error(
|
|
1338
|
+
"[station] `station.permissionPrefix` must be a non-empty string: admin permissions need their own namespace.",
|
|
1339
|
+
);
|
|
1340
|
+
}
|
|
1184
1341
|
const wardenWanted = userConfig.requireAuth !== false;
|
|
1185
1342
|
if (wardenWanted) {
|
|
1186
1343
|
try {
|
|
@@ -1191,6 +1348,11 @@ export default class StationProvider {
|
|
|
1191
1348
|
requireRole: userConfig.requireRole,
|
|
1192
1349
|
loginPath: userConfig.loginPath ?? "/admin/login",
|
|
1193
1350
|
cookieName: userConfig.cookieName ?? "station_auth",
|
|
1351
|
+
permissionPrefix,
|
|
1352
|
+
accessPermission:
|
|
1353
|
+
userConfig.accessPermission === false
|
|
1354
|
+
? undefined
|
|
1355
|
+
: (userConfig.accessPermission ?? `${permissionPrefix}.access`),
|
|
1194
1356
|
};
|
|
1195
1357
|
} catch (cause) {
|
|
1196
1358
|
// Container has no working `auth` binding → warden not wired.
|
|
@@ -1211,15 +1373,23 @@ export default class StationProvider {
|
|
|
1211
1373
|
|
|
1212
1374
|
// An admin panel with no auth is full CRUD on every registered resource
|
|
1213
1375
|
// for anyone who can reach the route. That is a different product, and
|
|
1214
|
-
//
|
|
1215
|
-
//
|
|
1376
|
+
// it has to be asked for rather than fallen into — the same bet bay
|
|
1377
|
+
// makes about losing a job on a Redis without LMOVE.
|
|
1216
1378
|
//
|
|
1217
1379
|
// `requireAuth: false` written down IS the asking. Defaulted (undefined)
|
|
1218
|
-
// is not: it means nobody decided, and nobody deciding
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1380
|
+
// is not: it means nobody decided, and nobody deciding is only tolerated
|
|
1381
|
+
// on a machine positively named development or test. An unset NODE_ENV,
|
|
1382
|
+
// `staging`, `preview`… fail closed like production: they are exactly
|
|
1383
|
+
// the boxes nobody configured, and staging often holds a copy of
|
|
1384
|
+
// production data (ream's `Application.inDev` takes the same stance).
|
|
1385
|
+
const env = normalizeNodeEnv(process.env.NODE_ENV);
|
|
1386
|
+
const devOrTest = env === "development" || env === "test";
|
|
1387
|
+
if (!devOrTest && userConfig.requireAuth !== false) {
|
|
1388
|
+
const where = inProduction()
|
|
1389
|
+
? "production"
|
|
1390
|
+
: `a non-development environment (NODE_ENV=${process.env.NODE_ENV === undefined ? "unset" : JSON.stringify(process.env.NODE_ENV)})`;
|
|
1221
1391
|
throw new Error(
|
|
1222
|
-
|
|
1392
|
+
`[station] Admin routes would mount with NO authorisation in ${where}: ` +
|
|
1223
1393
|
"no working `auth` binding is registered, so every `/admin/*` route — " +
|
|
1224
1394
|
"including create, update and delete — is open to anyone who can reach it.\n" +
|
|
1225
1395
|
" Wire @c9up/warden's WardenProvider, or set `station.requireAuth: false` " +
|
|
@@ -1232,11 +1402,13 @@ export default class StationProvider {
|
|
|
1232
1402
|
// Said even when the deployment opted in: agreeing once in a config file
|
|
1233
1403
|
// is not the same as being reminded that this process is running that
|
|
1234
1404
|
// way. The line has to be in the logs of the incident.
|
|
1235
|
-
const optedIn =
|
|
1405
|
+
const optedIn = !devOrTest && userConfig.requireAuth === false;
|
|
1406
|
+
const envName =
|
|
1407
|
+
env === "unknown" ? "AN UNCONFIGURED ENVIRONMENT" : env.toUpperCase();
|
|
1236
1408
|
console.warn(
|
|
1237
|
-
"[station] Admin routes mounted without auth. Wire @c9up/warden (and set `station.requireAuth: true` if you opted out) and seed the per-action
|
|
1409
|
+
"[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." +
|
|
1238
1410
|
(optedIn
|
|
1239
|
-
?
|
|
1411
|
+
? `\n Running this way in ${envName} because \`station.requireAuth: false\` was set.`
|
|
1240
1412
|
: ""),
|
|
1241
1413
|
);
|
|
1242
1414
|
}
|
|
@@ -1315,12 +1487,6 @@ export default class StationProvider {
|
|
|
1315
1487
|
`/admin/${slug}/:id`,
|
|
1316
1488
|
gate(this.#buildUpdateHandler(resource)),
|
|
1317
1489
|
);
|
|
1318
|
-
// Browser forms can't issue PUT — accept POST with `_method=PUT`
|
|
1319
|
-
// from the auto-generated form (form.ts stamps the hidden input).
|
|
1320
|
-
router.post(
|
|
1321
|
-
`/admin/${slug}/:id`,
|
|
1322
|
-
gate(this.#buildMethodOverrideHandler(resource)),
|
|
1323
|
-
);
|
|
1324
1490
|
}
|
|
1325
1491
|
if (resource.actions.includes("destroy")) {
|
|
1326
1492
|
router.delete(
|
|
@@ -1328,12 +1494,24 @@ export default class StationProvider {
|
|
|
1328
1494
|
gate(this.#buildDestroyHandler(resource)),
|
|
1329
1495
|
);
|
|
1330
1496
|
}
|
|
1497
|
+
// Browser forms can't issue PUT/DELETE — accept POST with `_method`
|
|
1498
|
+
// from the auto-generated form (form.ts stamps the hidden input),
|
|
1499
|
+
// dispatching only to the actions this resource enables.
|
|
1500
|
+
if (
|
|
1501
|
+
resource.actions.includes("edit") ||
|
|
1502
|
+
resource.actions.includes("destroy")
|
|
1503
|
+
) {
|
|
1504
|
+
router.post(
|
|
1505
|
+
`/admin/${slug}/:id`,
|
|
1506
|
+
gate(this.#buildMethodOverrideHandler(resource)),
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1331
1509
|
}
|
|
1332
1510
|
}
|
|
1333
1511
|
|
|
1334
1512
|
/**
|
|
1335
1513
|
* 56.5 — every admin action is now gated behind a
|
|
1336
|
-
* `<resource>.<action>` permission resolved through @c9up/warden
|
|
1514
|
+
* `<prefix>.<resource>.<action>` permission resolved through @c9up/warden
|
|
1337
1515
|
* (`auth.hasPermission`). Warn ONCE at boot, only when the auth layer
|
|
1338
1516
|
* is wired, that the host must seed roles/grants in the Warden rights
|
|
1339
1517
|
* store — otherwise every admin request 403s with no hint. The old
|
|
@@ -1348,8 +1526,15 @@ export default class StationProvider {
|
|
|
1348
1526
|
seedPermsWarned = true;
|
|
1349
1527
|
const [example] = resources;
|
|
1350
1528
|
if (example === undefined) return;
|
|
1529
|
+
const prefix = this.#authConfig.permissionPrefix;
|
|
1530
|
+
const access = this.#authConfig.accessPermission;
|
|
1531
|
+
const perms = [
|
|
1532
|
+
...(access === undefined ? [] : [access]),
|
|
1533
|
+
`${prefix}.${example.name}.list`,
|
|
1534
|
+
`${prefix}.${example.name}.create`,
|
|
1535
|
+
];
|
|
1351
1536
|
console.warn(
|
|
1352
|
-
`[station] Admin actions are gated behind '
|
|
1537
|
+
`[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.`,
|
|
1353
1538
|
);
|
|
1354
1539
|
}
|
|
1355
1540
|
|
|
@@ -1398,8 +1583,13 @@ export default class StationProvider {
|
|
|
1398
1583
|
* `@c9up/rune` — so an undefined here is an internal invariant break; throw
|
|
1399
1584
|
* loud (fail-closed) rather than silently accept an unvalidated body.
|
|
1400
1585
|
*/
|
|
1401
|
-
#requireWritableSchema(
|
|
1402
|
-
|
|
1586
|
+
#requireWritableSchema(
|
|
1587
|
+
resource: Resource,
|
|
1588
|
+
mode: "create" | "update",
|
|
1589
|
+
): RuneSchema {
|
|
1590
|
+
const context = this.#requireContext(resource);
|
|
1591
|
+
const writableSchema =
|
|
1592
|
+
mode === "create" ? context.writableSchema : context.updateSchema;
|
|
1403
1593
|
if (writableSchema === undefined) {
|
|
1404
1594
|
throw new Error(
|
|
1405
1595
|
`[station] No validation schema for ${resource.entity.name} — @c9up/rune must be installed to accept admin writes.`,
|
|
@@ -1449,8 +1639,8 @@ export default class StationProvider {
|
|
|
1449
1639
|
// Echo only declared, non-sensitive columns back to the form — never
|
|
1450
1640
|
// `_csrf`, secrets, or mass-assignment keys (both the flash store and the
|
|
1451
1641
|
// re-rendered HTML). An empty result falls back to `row`.
|
|
1452
|
-
const {
|
|
1453
|
-
const safe = flashableInput(submitted,
|
|
1642
|
+
const { formColumns, pkColumn, writeOnly } = this.#requireContext(resource);
|
|
1643
|
+
const safe = flashableInput(submitted, formColumns);
|
|
1454
1644
|
if (ctx.session !== undefined) {
|
|
1455
1645
|
// AdonisJS PRG: flash old input + errors, redirect back to the form.
|
|
1456
1646
|
ctx.session.flashAll(safe);
|
|
@@ -1462,8 +1652,9 @@ export default class StationProvider {
|
|
|
1462
1652
|
// submitted values + errors are still shown rather than silently dropped.
|
|
1463
1653
|
const vm = buildFormViewModel({
|
|
1464
1654
|
resource,
|
|
1465
|
-
columns,
|
|
1655
|
+
columns: formColumns,
|
|
1466
1656
|
pkColumn,
|
|
1657
|
+
writeOnly,
|
|
1467
1658
|
row,
|
|
1468
1659
|
values: Object.keys(safe).length > 0 ? safe : undefined,
|
|
1469
1660
|
errors: fieldErrors(errors),
|
|
@@ -1479,15 +1670,12 @@ export default class StationProvider {
|
|
|
1479
1670
|
resource: Resource,
|
|
1480
1671
|
): (ctx: StationHttpContext) => Promise<void> {
|
|
1481
1672
|
return async (ctx) => {
|
|
1482
|
-
const { repo,
|
|
1673
|
+
const { repo, listColumns, pkColumn } = this.#requireContext(resource);
|
|
1483
1674
|
// 56.5: the list action is gated behind `<resource>.list` like
|
|
1484
1675
|
// every other action — resolved through the Warden `"auth"`
|
|
1485
1676
|
// layer. (Retro 2026-06-01: list previously skipped the gate
|
|
1486
1677
|
// entirely, leaking the index regardless of authorization.)
|
|
1487
|
-
if (!(await
|
|
1488
|
-
deny(ctx);
|
|
1489
|
-
return;
|
|
1490
|
-
}
|
|
1678
|
+
if (!(await this.#authorize(ctx, resource, "list"))) return;
|
|
1491
1679
|
const qs = ctx.request.qs();
|
|
1492
1680
|
const page = clampPositiveInt(qs.page, 1);
|
|
1493
1681
|
const perPageRaw = clampPositiveInt(qs.perPage, DEFAULT_PER_PAGE);
|
|
@@ -1505,6 +1693,20 @@ export default class StationProvider {
|
|
|
1505
1693
|
// Station keeps only the `MAX_PER_PAGE` clamp (above) and the
|
|
1506
1694
|
// redirect-to-last-page wrapper — atlas floors perPage to ≥1 but
|
|
1507
1695
|
// does not cap it.
|
|
1696
|
+
// Past this page `(page-1)*perPage` is no longer an exact integer:
|
|
1697
|
+
// atlas / the driver reject that offset, which surfaced as a 500.
|
|
1698
|
+
// No table that size exists, so such a page is past the end — count
|
|
1699
|
+
// through page 1 and send the caller to the real last page.
|
|
1700
|
+
if (page > Math.floor(Number.MAX_SAFE_INTEGER / perPage) + 1) {
|
|
1701
|
+
const first = await repo
|
|
1702
|
+
.query()
|
|
1703
|
+
.orderBy(pkColumn, "desc")
|
|
1704
|
+
.paginate(1, perPage);
|
|
1705
|
+
ctx.response.redirect(
|
|
1706
|
+
`/admin/${resource.name}?page=${Math.max(1, first.meta.lastPage)}&perPage=${perPage}`,
|
|
1707
|
+
);
|
|
1708
|
+
return;
|
|
1709
|
+
}
|
|
1508
1710
|
const result = await repo
|
|
1509
1711
|
.query()
|
|
1510
1712
|
.orderBy(pkColumn, "desc")
|
|
@@ -1521,7 +1723,7 @@ export default class StationProvider {
|
|
|
1521
1723
|
const viewModel = buildListViewModel({
|
|
1522
1724
|
resource,
|
|
1523
1725
|
rows: result.all(),
|
|
1524
|
-
columns,
|
|
1726
|
+
columns: listColumns,
|
|
1525
1727
|
pkColumn,
|
|
1526
1728
|
page: result.meta.currentPage,
|
|
1527
1729
|
perPage: result.meta.perPage,
|
|
@@ -1538,14 +1740,11 @@ export default class StationProvider {
|
|
|
1538
1740
|
resource: Resource,
|
|
1539
1741
|
): (ctx: StationHttpContext) => Promise<void> {
|
|
1540
1742
|
return async (ctx) => {
|
|
1541
|
-
const { repo,
|
|
1743
|
+
const { repo, showColumns, pkColumn } = this.#requireContext(resource);
|
|
1542
1744
|
// Authorize BEFORE the existence check so an unauthorized caller
|
|
1543
1745
|
// can't distinguish 404 (absent) from 403 (exists) — no row-existence
|
|
1544
1746
|
// oracle for a user without `<resource>.show`.
|
|
1545
|
-
if (!(await
|
|
1546
|
-
deny(ctx);
|
|
1547
|
-
return;
|
|
1548
|
-
}
|
|
1747
|
+
if (!(await this.#authorize(ctx, resource, "show"))) return;
|
|
1549
1748
|
const id = ctx.params.id ?? "";
|
|
1550
1749
|
const row = await repo.find(id);
|
|
1551
1750
|
if (row === null) {
|
|
@@ -1558,7 +1757,7 @@ export default class StationProvider {
|
|
|
1558
1757
|
const viewModel = buildShowViewModel({
|
|
1559
1758
|
resource,
|
|
1560
1759
|
row,
|
|
1561
|
-
columns,
|
|
1760
|
+
columns: showColumns,
|
|
1562
1761
|
pkColumn,
|
|
1563
1762
|
});
|
|
1564
1763
|
const html = await this.#renderView(ctx, "station::show", viewModel);
|
|
@@ -1571,20 +1770,17 @@ export default class StationProvider {
|
|
|
1571
1770
|
resource: Resource,
|
|
1572
1771
|
): (ctx: StationHttpContext) => Promise<void> {
|
|
1573
1772
|
return async (ctx) => {
|
|
1574
|
-
const {
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
) {
|
|
1578
|
-
deny(ctx);
|
|
1579
|
-
return;
|
|
1580
|
-
}
|
|
1773
|
+
const { formColumns, pkColumn, writeOnly } =
|
|
1774
|
+
this.#requireContext(resource);
|
|
1775
|
+
if (!(await this.#authorize(ctx, resource, "create"))) return;
|
|
1581
1776
|
// 57.7 — after a failed create redirected back (PRG), the flash carries
|
|
1582
1777
|
// the old input + per-field errors; re-populate + surface them.
|
|
1583
|
-
const { values, errors } = readFlash(ctx,
|
|
1778
|
+
const { values, errors } = readFlash(ctx, formColumns);
|
|
1584
1779
|
const vm = buildFormViewModel({
|
|
1585
1780
|
resource,
|
|
1586
|
-
columns,
|
|
1781
|
+
columns: formColumns,
|
|
1587
1782
|
pkColumn,
|
|
1783
|
+
writeOnly,
|
|
1588
1784
|
values,
|
|
1589
1785
|
errors,
|
|
1590
1786
|
csrfEnabled: ctx.store?.get("csrfToken") != null,
|
|
@@ -1604,27 +1800,24 @@ export default class StationProvider {
|
|
|
1604
1800
|
// DB work — no 404-vs-403 oracle to a forged request. A seeded token is
|
|
1605
1801
|
// not proof; only `csrfProtected === true` is (AC4/AC7).
|
|
1606
1802
|
if (ctx.request.csrfProtected !== true) {
|
|
1607
|
-
denyCsrf(ctx);
|
|
1803
|
+
await this.#denyCsrf(ctx);
|
|
1608
1804
|
return;
|
|
1609
1805
|
}
|
|
1610
|
-
const { repo, pkColumn, columns, booleanKeys } =
|
|
1806
|
+
const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } =
|
|
1611
1807
|
this.#requireContext(resource);
|
|
1612
|
-
if (
|
|
1613
|
-
!(await authorizeAction(resource, "create", ctx, this.#authManager))
|
|
1614
|
-
) {
|
|
1615
|
-
deny(ctx);
|
|
1616
|
-
return;
|
|
1617
|
-
}
|
|
1808
|
+
if (!(await this.#authorize(ctx, resource, "create"))) return;
|
|
1618
1809
|
const body = await readBody(ctx);
|
|
1619
1810
|
// 57.7 — validate + mass-assignment guard in one pass. `result.data`
|
|
1620
|
-
// holds ONLY
|
|
1621
|
-
//
|
|
1622
|
-
//
|
|
1811
|
+
// holds ONLY editable-column keys (the allow-list: `editable`, or every
|
|
1812
|
+
// non-secret, non-hidden, non-framework column), so a forged `isAdmin`
|
|
1813
|
+
// on a hidden column, a secret column, the PK or a timestamp is dropped
|
|
1814
|
+
// by the schema, and each field is type-checked.
|
|
1623
1815
|
// Invalid input fail-closes with a 422 BEFORE any repository write (AC4).
|
|
1624
1816
|
const result = validateWritableBody(
|
|
1625
|
-
this.#requireWritableSchema(resource),
|
|
1817
|
+
this.#requireWritableSchema(resource, "create"),
|
|
1626
1818
|
booleanKeys,
|
|
1627
1819
|
body,
|
|
1820
|
+
writeOnly,
|
|
1628
1821
|
);
|
|
1629
1822
|
if (!result.valid) {
|
|
1630
1823
|
// PRG fallback URL = the create form (AdonisJS `redirect().back()`).
|
|
@@ -1638,7 +1831,8 @@ export default class StationProvider {
|
|
|
1638
1831
|
resource: resource.name,
|
|
1639
1832
|
recordId: created[pkColumn],
|
|
1640
1833
|
userId: ctx.auth?.user?.id,
|
|
1641
|
-
|
|
1834
|
+
...requestOrigin(ctx),
|
|
1835
|
+
after: snapshotEntity(created, columns, redacted),
|
|
1642
1836
|
at: new Date(),
|
|
1643
1837
|
});
|
|
1644
1838
|
redirectToShow(ctx, resource, created[pkColumn]);
|
|
@@ -1649,12 +1843,10 @@ export default class StationProvider {
|
|
|
1649
1843
|
resource: Resource,
|
|
1650
1844
|
): (ctx: StationHttpContext) => Promise<void> {
|
|
1651
1845
|
return async (ctx) => {
|
|
1652
|
-
const { repo,
|
|
1846
|
+
const { repo, formColumns, pkColumn, writeOnly } =
|
|
1847
|
+
this.#requireContext(resource);
|
|
1653
1848
|
// Authorize before the existence check (no 404-vs-403 oracle).
|
|
1654
|
-
if (!(await
|
|
1655
|
-
deny(ctx);
|
|
1656
|
-
return;
|
|
1657
|
-
}
|
|
1849
|
+
if (!(await this.#authorize(ctx, resource, "edit"))) return;
|
|
1658
1850
|
const id = ctx.params.id ?? "";
|
|
1659
1851
|
const row = await repo.find(id);
|
|
1660
1852
|
if (row === null) {
|
|
@@ -1667,11 +1859,12 @@ export default class StationProvider {
|
|
|
1667
1859
|
// 57.7 — after a failed update redirected back (PRG), the flash carries
|
|
1668
1860
|
// the submitted values + errors; they win over the DB `row` so the user
|
|
1669
1861
|
// sees what they typed. A normal edit GET has no flash → fields from row.
|
|
1670
|
-
const { values, errors } = readFlash(ctx,
|
|
1862
|
+
const { values, errors } = readFlash(ctx, formColumns);
|
|
1671
1863
|
const vm = buildFormViewModel({
|
|
1672
1864
|
resource,
|
|
1673
|
-
columns,
|
|
1865
|
+
columns: formColumns,
|
|
1674
1866
|
pkColumn,
|
|
1867
|
+
writeOnly,
|
|
1675
1868
|
row,
|
|
1676
1869
|
values,
|
|
1677
1870
|
errors,
|
|
@@ -1689,16 +1882,13 @@ export default class StationProvider {
|
|
|
1689
1882
|
return async (ctx) => {
|
|
1690
1883
|
// Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
|
|
1691
1884
|
if (ctx.request.csrfProtected !== true) {
|
|
1692
|
-
denyCsrf(ctx);
|
|
1885
|
+
await this.#denyCsrf(ctx);
|
|
1693
1886
|
return;
|
|
1694
1887
|
}
|
|
1695
|
-
const { repo, pkColumn, columns, booleanKeys } =
|
|
1888
|
+
const { repo, pkColumn, columns, booleanKeys, writeOnly, redacted } =
|
|
1696
1889
|
this.#requireContext(resource);
|
|
1697
1890
|
// Authorize before the existence check (no 404-vs-403 oracle).
|
|
1698
|
-
if (!(await
|
|
1699
|
-
deny(ctx);
|
|
1700
|
-
return;
|
|
1701
|
-
}
|
|
1891
|
+
if (!(await this.#authorize(ctx, resource, "edit"))) return;
|
|
1702
1892
|
const id = ctx.params.id ?? "";
|
|
1703
1893
|
const entity = await repo.find(id);
|
|
1704
1894
|
if (entity === null) {
|
|
@@ -1715,9 +1905,10 @@ export default class StationProvider {
|
|
|
1715
1905
|
// 422 with the submitted values + per-field errors (the existing entity
|
|
1716
1906
|
// still supplies the id/action).
|
|
1717
1907
|
const result = validateWritableBody(
|
|
1718
|
-
this.#requireWritableSchema(resource),
|
|
1908
|
+
this.#requireWritableSchema(resource, "update"),
|
|
1719
1909
|
booleanKeys,
|
|
1720
1910
|
body,
|
|
1911
|
+
writeOnly,
|
|
1721
1912
|
);
|
|
1722
1913
|
if (!result.valid) {
|
|
1723
1914
|
// PRG fallback URL = this row's edit form (AdonisJS `redirect().back()`).
|
|
@@ -1737,19 +1928,23 @@ export default class StationProvider {
|
|
|
1737
1928
|
// would shadow the original values after setProp). `result.data`
|
|
1738
1929
|
// holds only declared, type-checked columns — the mass-assignment
|
|
1739
1930
|
// guarantee (PK / timestamps / unknown keys dropped) is inherent.
|
|
1740
|
-
const beforeSnapshot = snapshotEntity(entity, columns);
|
|
1931
|
+
const beforeSnapshot = snapshotEntity(entity, columns, redacted);
|
|
1932
|
+
const rawSecrets: Record<string, unknown> = {};
|
|
1933
|
+
for (const key of redacted) rawSecrets[key] = entity[key];
|
|
1741
1934
|
for (const [key, value] of Object.entries(result.data)) {
|
|
1742
1935
|
entity.setProp(key, value);
|
|
1743
1936
|
}
|
|
1744
1937
|
await repo.save(entity);
|
|
1745
|
-
const afterSnapshot = snapshotEntity(entity, columns);
|
|
1938
|
+
const afterSnapshot = snapshotEntity(entity, columns, redacted);
|
|
1746
1939
|
await emitAudit(resource, {
|
|
1747
1940
|
action: "edit",
|
|
1748
1941
|
resource: resource.name,
|
|
1749
1942
|
recordId: entity[pkColumn],
|
|
1750
1943
|
userId: ctx.auth?.user?.id,
|
|
1944
|
+
...requestOrigin(ctx),
|
|
1751
1945
|
before: beforeSnapshot,
|
|
1752
1946
|
after: afterSnapshot,
|
|
1947
|
+
redactedChanges: changedRedacted(rawSecrets, entity, redacted),
|
|
1753
1948
|
at: new Date(),
|
|
1754
1949
|
});
|
|
1755
1950
|
redirectToShow(ctx, resource, entity[pkColumn]);
|
|
@@ -1762,33 +1957,45 @@ export default class StationProvider {
|
|
|
1762
1957
|
// Browser forms can only emit GET / POST. The auto-generated edit
|
|
1763
1958
|
// form ships `<input type="hidden" name="_method" value="PUT">`
|
|
1764
1959
|
// so the POST /admin/:r/:id endpoint can route to the update
|
|
1765
|
-
// handler.
|
|
1766
|
-
//
|
|
1767
|
-
|
|
1768
|
-
const
|
|
1960
|
+
// handler. Only the enabled actions get a handler: an override naming a
|
|
1961
|
+
// disabled one is refused like any unsupported method, so `actions`
|
|
1962
|
+
// stays the boundary of what the admin can do to a resource.
|
|
1963
|
+
const updateHandler = resource.actions.includes("edit")
|
|
1964
|
+
? this.#buildUpdateHandler(resource)
|
|
1965
|
+
: undefined;
|
|
1966
|
+
const destroyHandler = resource.actions.includes("destroy")
|
|
1967
|
+
? this.#buildDestroyHandler(resource)
|
|
1968
|
+
: undefined;
|
|
1969
|
+
const allowed = [
|
|
1970
|
+
...(updateHandler === undefined ? [] : ["PUT", "PATCH"]),
|
|
1971
|
+
...(destroyHandler === undefined ? [] : ["DELETE"]),
|
|
1972
|
+
];
|
|
1769
1973
|
return async (ctx) => {
|
|
1770
1974
|
// Fail-close on CSRF before reading the body (see #buildCreateHandler).
|
|
1771
1975
|
// The PUT/DELETE branches delegate to already-guarded handlers, but the
|
|
1772
1976
|
// unsupported-override 405 branch would otherwise do work on a forged
|
|
1773
1977
|
// request and leak a 403-vs-405 oracle — guard the entrypoint too.
|
|
1774
1978
|
if (ctx.request.csrfProtected !== true) {
|
|
1775
|
-
denyCsrf(ctx);
|
|
1979
|
+
await this.#denyCsrf(ctx);
|
|
1776
1980
|
return;
|
|
1777
1981
|
}
|
|
1778
1982
|
const body = await readBody(ctx);
|
|
1779
1983
|
const override = String(body._method ?? "").toUpperCase();
|
|
1780
|
-
if (
|
|
1984
|
+
if (
|
|
1985
|
+
updateHandler !== undefined &&
|
|
1986
|
+
(override === "PUT" || override === "PATCH")
|
|
1987
|
+
) {
|
|
1781
1988
|
return updateHandler(ctx);
|
|
1782
1989
|
}
|
|
1783
|
-
if (override === "DELETE") {
|
|
1990
|
+
if (destroyHandler !== undefined && override === "DELETE") {
|
|
1784
1991
|
return destroyHandler(ctx);
|
|
1785
1992
|
}
|
|
1786
|
-
// Unsupported override — refuse rather than silently
|
|
1787
|
-
// to a no-op so misconfigured forms surface immediately.
|
|
1993
|
+
// Unsupported or disabled override — refuse rather than silently
|
|
1994
|
+
// downgrade to a no-op so misconfigured forms surface immediately.
|
|
1788
1995
|
ctx.response.status(405);
|
|
1789
1996
|
ctx.response.type("text/html; charset=utf-8");
|
|
1790
1997
|
ctx.response.send(
|
|
1791
|
-
`<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id
|
|
1998
|
+
`<h1>405 Method Not Allowed</h1><p>POST /admin/${escapeMin(resource.name)}/:id accepts <code>_method</code> = ${escapeMin(allowed.join(" | "))}.</p>`,
|
|
1792
1999
|
);
|
|
1793
2000
|
};
|
|
1794
2001
|
}
|
|
@@ -1799,17 +2006,13 @@ export default class StationProvider {
|
|
|
1799
2006
|
return async (ctx) => {
|
|
1800
2007
|
// Fail-close on CSRF before any auth/DB work (see #buildCreateHandler).
|
|
1801
2008
|
if (ctx.request.csrfProtected !== true) {
|
|
1802
|
-
denyCsrf(ctx);
|
|
2009
|
+
await this.#denyCsrf(ctx);
|
|
1803
2010
|
return;
|
|
1804
2011
|
}
|
|
1805
|
-
const { repo, pkColumn, columns } =
|
|
2012
|
+
const { repo, pkColumn, columns, redacted } =
|
|
2013
|
+
this.#requireContext(resource);
|
|
1806
2014
|
// Authorize before the existence check (no 404-vs-403 oracle).
|
|
1807
|
-
if (
|
|
1808
|
-
!(await authorizeAction(resource, "destroy", ctx, this.#authManager))
|
|
1809
|
-
) {
|
|
1810
|
-
deny(ctx);
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
2015
|
+
if (!(await this.#authorize(ctx, resource, "destroy"))) return;
|
|
1813
2016
|
const id = ctx.params.id ?? "";
|
|
1814
2017
|
const row = await repo.find(id);
|
|
1815
2018
|
if (row === null) {
|
|
@@ -1819,13 +2022,14 @@ export default class StationProvider {
|
|
|
1819
2022
|
ctx.response.send(notFoundHtml);
|
|
1820
2023
|
return;
|
|
1821
2024
|
}
|
|
1822
|
-
const before = snapshotEntity(row, columns);
|
|
2025
|
+
const before = snapshotEntity(row, columns, redacted);
|
|
1823
2026
|
await repo.delete(row);
|
|
1824
2027
|
await emitAudit(resource, {
|
|
1825
2028
|
action: "destroy",
|
|
1826
2029
|
resource: resource.name,
|
|
1827
2030
|
recordId: row[pkColumn],
|
|
1828
2031
|
userId: ctx.auth?.user?.id,
|
|
2032
|
+
...requestOrigin(ctx),
|
|
1829
2033
|
before,
|
|
1830
2034
|
at: new Date(),
|
|
1831
2035
|
});
|
|
@@ -1938,7 +2142,7 @@ export default class StationProvider {
|
|
|
1938
2142
|
// Clear the stale cookie regardless of response shape, so neither
|
|
1939
2143
|
// a browser refresh nor an XHR caller retries with the dead token.
|
|
1940
2144
|
ctx.response.clearCookie?.(this.#authConfig.cookieName, {
|
|
1941
|
-
path:
|
|
2145
|
+
path: AUTH_COOKIE_PATH,
|
|
1942
2146
|
});
|
|
1943
2147
|
if (this.#wantsJsonResponse(ctx)) {
|
|
1944
2148
|
ctx.response.status(401);
|
|
@@ -1975,6 +2179,11 @@ export default class StationProvider {
|
|
|
1975
2179
|
roleOk = false;
|
|
1976
2180
|
}
|
|
1977
2181
|
if (!roleOk) {
|
|
2182
|
+
await this.#securityEvent(ctx, {
|
|
2183
|
+
action: "denied",
|
|
2184
|
+
userId: user.id,
|
|
2185
|
+
permission: `role:${required}`,
|
|
2186
|
+
});
|
|
1978
2187
|
if (this.#wantsJsonResponse(ctx)) {
|
|
1979
2188
|
ctx.response.status(403);
|
|
1980
2189
|
ctx.response.json({ error: "insufficient role" });
|
|
@@ -1986,6 +2195,31 @@ export default class StationProvider {
|
|
|
1986
2195
|
return;
|
|
1987
2196
|
}
|
|
1988
2197
|
}
|
|
2198
|
+
// Entering the admin is its own grant (bug 214): without it, any app
|
|
2199
|
+
// user holding a same-named permission for the public app got in.
|
|
2200
|
+
const access = this.#authConfig.accessPermission;
|
|
2201
|
+
if (access !== undefined) {
|
|
2202
|
+
let accessOk: boolean;
|
|
2203
|
+
try {
|
|
2204
|
+
accessOk =
|
|
2205
|
+
(await manager.hasPermission(user, access, "global")) === true;
|
|
2206
|
+
} catch (err) {
|
|
2207
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2208
|
+
console.error(
|
|
2209
|
+
`[station] access check threw for '${access}' — denying (fail-closed): ${detail}`,
|
|
2210
|
+
);
|
|
2211
|
+
accessOk = false;
|
|
2212
|
+
}
|
|
2213
|
+
if (!accessOk) {
|
|
2214
|
+
await this.#securityEvent(ctx, {
|
|
2215
|
+
action: "denied",
|
|
2216
|
+
userId: user.id,
|
|
2217
|
+
permission: access,
|
|
2218
|
+
});
|
|
2219
|
+
deny(ctx);
|
|
2220
|
+
return;
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
1989
2223
|
const existingAuth = ctx.auth ?? {};
|
|
1990
2224
|
ctx.auth = {
|
|
1991
2225
|
...existingAuth,
|
|
@@ -2014,11 +2248,11 @@ export default class StationProvider {
|
|
|
2014
2248
|
}
|
|
2015
2249
|
}
|
|
2016
2250
|
}
|
|
2017
|
-
|
|
2018
|
-
|
|
2251
|
+
// No `?error=` read: Station never redirects with one (a failed POST
|
|
2252
|
+
// re-renders inline), so its only use was a third party writing
|
|
2253
|
+
// arbitrary text into the sign-in page's alert.
|
|
2019
2254
|
const vm = buildLoginViewModel({
|
|
2020
2255
|
action: this.#authConfig.loginPath,
|
|
2021
|
-
error: typeof errorParam === "string" ? errorParam : undefined,
|
|
2022
2256
|
csrfEnabled: ctx.store?.get("csrfToken") != null,
|
|
2023
2257
|
});
|
|
2024
2258
|
const html = await this.#renderView(ctx, "station::login", vm);
|
|
@@ -2039,7 +2273,7 @@ export default class StationProvider {
|
|
|
2039
2273
|
// AuthManager work (see #buildCreateHandler). Login is open by design
|
|
2040
2274
|
// (no auth gate), so CSRF is the sole pre-check here.
|
|
2041
2275
|
if (ctx.request.csrfProtected !== true) {
|
|
2042
|
-
denyCsrf(ctx);
|
|
2276
|
+
await this.#denyCsrf(ctx);
|
|
2043
2277
|
return;
|
|
2044
2278
|
}
|
|
2045
2279
|
const manager = this.#authManager;
|
|
@@ -2065,6 +2299,10 @@ export default class StationProvider {
|
|
|
2065
2299
|
ctx.response.send(html);
|
|
2066
2300
|
return;
|
|
2067
2301
|
}
|
|
2302
|
+
// Counted BEFORE the credential check (warden's AuthRateLimiter
|
|
2303
|
+
// contract): a refused attempt never reaches authenticate.
|
|
2304
|
+
const keys = loginKeys(ctx.request.ip?.() ?? "unknown", email);
|
|
2305
|
+
if (!(await this.#admitLogin(ctx, keys, email))) return;
|
|
2068
2306
|
const result = await manager.authenticate({ email, password });
|
|
2069
2307
|
// Warden returns the issued token on `user.token`, not at the
|
|
2070
2308
|
// top level — reading `result.token` (which never exists) sent
|
|
@@ -2072,6 +2310,10 @@ export default class StationProvider {
|
|
|
2072
2310
|
const token =
|
|
2073
2311
|
typeof result.user?.token === "string" ? result.user.token : undefined;
|
|
2074
2312
|
if (!result.authenticated || token === undefined) {
|
|
2313
|
+
await this.#securityEvent(ctx, {
|
|
2314
|
+
action: "login_failed",
|
|
2315
|
+
identifier: email,
|
|
2316
|
+
});
|
|
2075
2317
|
const vm = buildLoginViewModel({
|
|
2076
2318
|
action: this.#authConfig.loginPath,
|
|
2077
2319
|
email,
|
|
@@ -2084,16 +2326,158 @@ export default class StationProvider {
|
|
|
2084
2326
|
ctx.response.send(html);
|
|
2085
2327
|
return;
|
|
2086
2328
|
}
|
|
2329
|
+
await this.#loginLimiter?.clear(...keys);
|
|
2330
|
+
await this.#securityEvent(ctx, {
|
|
2331
|
+
action: "login",
|
|
2332
|
+
userId: result.user?.id,
|
|
2333
|
+
identifier: email,
|
|
2334
|
+
});
|
|
2335
|
+
// No maxAge: a session cookie, gone with the browser; the token's own
|
|
2336
|
+
// expiry is enforced by verify() on every request.
|
|
2087
2337
|
ctx.response.cookie?.(this.#authConfig.cookieName, token, {
|
|
2088
2338
|
httpOnly: true,
|
|
2089
2339
|
sameSite: "Lax",
|
|
2090
|
-
secure:
|
|
2091
|
-
path:
|
|
2340
|
+
secure: this.#cookieSecure ?? secureByDefault(ctx),
|
|
2341
|
+
path: AUTH_COOKIE_PATH,
|
|
2092
2342
|
});
|
|
2093
2343
|
ctx.response.redirect("/admin");
|
|
2094
2344
|
};
|
|
2095
2345
|
}
|
|
2096
2346
|
|
|
2347
|
+
/**
|
|
2348
|
+
* The per-action gate: allow, or record the refusal and answer 403.
|
|
2349
|
+
*/
|
|
2350
|
+
async #authorize(
|
|
2351
|
+
ctx: StationHttpContext,
|
|
2352
|
+
resource: Resource,
|
|
2353
|
+
action: ResourceAction,
|
|
2354
|
+
): Promise<boolean> {
|
|
2355
|
+
const prefix = this.#authConfig.permissionPrefix;
|
|
2356
|
+
if (
|
|
2357
|
+
await authorizeAction(resource, action, ctx, this.#authManager, prefix)
|
|
2358
|
+
) {
|
|
2359
|
+
return true;
|
|
2360
|
+
}
|
|
2361
|
+
await this.#securityEvent(ctx, {
|
|
2362
|
+
action: "denied",
|
|
2363
|
+
userId: ctx.auth?.user?.id,
|
|
2364
|
+
resource: resource.name,
|
|
2365
|
+
resourceAction: action,
|
|
2366
|
+
permission: `${prefix}.${resource.name}.${action}`,
|
|
2367
|
+
});
|
|
2368
|
+
deny(ctx);
|
|
2369
|
+
return false;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/** Record a CSRF block, then refuse it. */
|
|
2373
|
+
async #denyCsrf(ctx: StationHttpContext): Promise<void> {
|
|
2374
|
+
await this.#securityEvent(ctx, { action: "csrf_denied" });
|
|
2375
|
+
denyCsrf(ctx);
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
/**
|
|
2379
|
+
* Send a security event to `station.audit`, stamped with the request
|
|
2380
|
+
* origin. A failing sink is logged, never allowed to change the response.
|
|
2381
|
+
*/
|
|
2382
|
+
async #securityEvent(
|
|
2383
|
+
ctx: StationHttpContext,
|
|
2384
|
+
event: Omit<
|
|
2385
|
+
SecurityAuditEvent,
|
|
2386
|
+
"at" | "ip" | "userAgent" | "requestId" | "path"
|
|
2387
|
+
>,
|
|
2388
|
+
): Promise<void> {
|
|
2389
|
+
const sink = this.#securityAudit;
|
|
2390
|
+
if (sink === undefined) return;
|
|
2391
|
+
try {
|
|
2392
|
+
await sink({
|
|
2393
|
+
...event,
|
|
2394
|
+
...requestOrigin(ctx),
|
|
2395
|
+
path: ctx.request.url?.(),
|
|
2396
|
+
at: new Date(),
|
|
2397
|
+
});
|
|
2398
|
+
} catch (err) {
|
|
2399
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2400
|
+
console.error(
|
|
2401
|
+
`[station] security audit sink threw on ${event.action}: ${detail}. The event was NOT recorded.`,
|
|
2402
|
+
);
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
/**
|
|
2407
|
+
* Revoke `token` through the default guard when it supports revocation.
|
|
2408
|
+
* A guard without it (or a JwtStrategy without a blacklist, whose
|
|
2409
|
+
* `revoke` throws) is reported once: logout then only clears the cookie.
|
|
2410
|
+
*/
|
|
2411
|
+
async #revoke(token: string): Promise<void> {
|
|
2412
|
+
const strategy = this.#authManager?.getStrategy?.();
|
|
2413
|
+
let reason = "the auth guard has no revoke(token)";
|
|
2414
|
+
if (
|
|
2415
|
+
typeof strategy === "object" &&
|
|
2416
|
+
strategy !== null &&
|
|
2417
|
+
"revoke" in strategy &&
|
|
2418
|
+
typeof strategy.revoke === "function"
|
|
2419
|
+
) {
|
|
2420
|
+
try {
|
|
2421
|
+
await strategy.revoke(token);
|
|
2422
|
+
return;
|
|
2423
|
+
} catch (err) {
|
|
2424
|
+
reason = err instanceof Error ? err.message : String(err);
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
if (revokeUnavailableWarned) return;
|
|
2428
|
+
revokeUnavailableWarned = true;
|
|
2429
|
+
console.warn(
|
|
2430
|
+
`[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.`,
|
|
2431
|
+
);
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
/**
|
|
2435
|
+
* Count a login attempt and answer the refusal when the limiter says no:
|
|
2436
|
+
* 429 + Retry-After when exhausted, 503 when the limiter itself failed (a
|
|
2437
|
+
* throttle that cannot count must not wave attempts through).
|
|
2438
|
+
*/
|
|
2439
|
+
async #admitLogin(
|
|
2440
|
+
ctx: StationHttpContext,
|
|
2441
|
+
keys: string[],
|
|
2442
|
+
email: string,
|
|
2443
|
+
): Promise<boolean> {
|
|
2444
|
+
const limiter = this.#loginLimiter;
|
|
2445
|
+
if (limiter === undefined) return true;
|
|
2446
|
+
let decision: { allowed: boolean; retryAfterSeconds: number };
|
|
2447
|
+
try {
|
|
2448
|
+
decision = await limiter.attempt(...keys);
|
|
2449
|
+
} catch (err) {
|
|
2450
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
2451
|
+
console.error(
|
|
2452
|
+
`[station] login rate limiter failed — refusing the attempt (fail-closed): ${detail}`,
|
|
2453
|
+
);
|
|
2454
|
+
ctx.response.status(503);
|
|
2455
|
+
ctx.response.type("text/plain; charset=utf-8");
|
|
2456
|
+
ctx.response.send("Sign-in is temporarily unavailable.");
|
|
2457
|
+
return false;
|
|
2458
|
+
}
|
|
2459
|
+
if (decision.allowed) return true;
|
|
2460
|
+
await this.#securityEvent(ctx, {
|
|
2461
|
+
action: "login_throttled",
|
|
2462
|
+
identifier: email,
|
|
2463
|
+
});
|
|
2464
|
+
ctx.response.status(429);
|
|
2465
|
+
ctx.response.header(
|
|
2466
|
+
"Retry-After",
|
|
2467
|
+
String(Math.max(1, Math.ceil(decision.retryAfterSeconds))),
|
|
2468
|
+
);
|
|
2469
|
+
const vm = buildLoginViewModel({
|
|
2470
|
+
action: this.#authConfig.loginPath,
|
|
2471
|
+
email,
|
|
2472
|
+
error: "Too many sign-in attempts. Try again later.",
|
|
2473
|
+
csrfEnabled: ctx.store?.get("csrfToken") != null,
|
|
2474
|
+
});
|
|
2475
|
+
const html = await this.#renderView(ctx, "station::login", vm);
|
|
2476
|
+
ctx.response.type("text/html; charset=utf-8");
|
|
2477
|
+
ctx.response.send(html);
|
|
2478
|
+
return false;
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2097
2481
|
/**
|
|
2098
2482
|
* `POST /admin/logout` — clear the session cookie and redirect to
|
|
2099
2483
|
* the login page. POST (not GET) so a crafted `<img src>` can't log
|
|
@@ -2104,17 +2488,97 @@ export default class StationProvider {
|
|
|
2104
2488
|
// Fail-close on CSRF: a forged cross-site POST must not be able to log
|
|
2105
2489
|
// an admin out (session-fixation nuisance) (see #buildCreateHandler).
|
|
2106
2490
|
if (ctx.request.csrfProtected !== true) {
|
|
2107
|
-
denyCsrf(ctx);
|
|
2491
|
+
await this.#denyCsrf(ctx);
|
|
2108
2492
|
return;
|
|
2109
2493
|
}
|
|
2494
|
+
// Clearing the cookie alone left the token valid until it expired:
|
|
2495
|
+
// a copy taken before logout (shared machine, proxy log) still
|
|
2496
|
+
// opened the admin.
|
|
2497
|
+
const token = this.#readAuthToken(ctx);
|
|
2498
|
+
if (token !== undefined) {
|
|
2499
|
+
// Who is leaving, read before the token is revoked — only when
|
|
2500
|
+
// someone is listening, since it costs a verify().
|
|
2501
|
+
const userId =
|
|
2502
|
+
this.#securityAudit === undefined
|
|
2503
|
+
? undefined
|
|
2504
|
+
: (await this.#authManager?.verify(token))?.user?.id;
|
|
2505
|
+
await this.#revoke(token);
|
|
2506
|
+
await this.#securityEvent(ctx, { action: "logout", userId });
|
|
2507
|
+
} else {
|
|
2508
|
+
await this.#securityEvent(ctx, { action: "logout" });
|
|
2509
|
+
}
|
|
2110
2510
|
ctx.response.clearCookie?.(this.#authConfig.cookieName, {
|
|
2111
|
-
path:
|
|
2511
|
+
path: AUTH_COOKIE_PATH,
|
|
2112
2512
|
});
|
|
2113
2513
|
ctx.response.redirect(this.#authConfig.loginPath);
|
|
2114
2514
|
};
|
|
2115
2515
|
}
|
|
2116
2516
|
}
|
|
2117
2517
|
|
|
2518
|
+
/** Who sent the request, as far as the host can tell. */
|
|
2519
|
+
function requestOrigin(ctx: StationHttpContext): {
|
|
2520
|
+
ip?: string;
|
|
2521
|
+
userAgent?: string;
|
|
2522
|
+
requestId?: string;
|
|
2523
|
+
} {
|
|
2524
|
+
return {
|
|
2525
|
+
ip: ctx.request.ip?.(),
|
|
2526
|
+
userAgent: ctx.request.header?.("user-agent"),
|
|
2527
|
+
requestId: ctx.request.id?.() ?? ctx.request.header?.("x-request-id"),
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
|
|
2531
|
+
/**
|
|
2532
|
+
* `Secure` unless this is a development/test process on plain HTTP — an
|
|
2533
|
+
* unknown environment gets the production answer, and `NODE_ENV=prod` is
|
|
2534
|
+
* production too.
|
|
2535
|
+
*/
|
|
2536
|
+
function secureByDefault(ctx: StationHttpContext): boolean {
|
|
2537
|
+
const env = normalizeNodeEnv(process.env.NODE_ENV);
|
|
2538
|
+
if (env !== "development" && env !== "test") return true;
|
|
2539
|
+
return ctx.request.secure?.() === true;
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
function buildLoginLimiter(
|
|
2543
|
+
config: StationConfig["loginRateLimit"],
|
|
2544
|
+
): StationLoginLimiter | undefined {
|
|
2545
|
+
if (config === false) return undefined;
|
|
2546
|
+
if (config?.limiter !== undefined) return config.limiter;
|
|
2547
|
+
return new MemoryLoginLimiter(config?.maxAttempts, config?.windowSeconds);
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* A router whose every route gets `names` attached through the route's own
|
|
2552
|
+
* `middleware(...names)` (ream's / AdonisJS's route API). A router that hands
|
|
2553
|
+
* back no such method cannot honour the config, which is refused loudly.
|
|
2554
|
+
*/
|
|
2555
|
+
function withRouteMiddleware(
|
|
2556
|
+
router: StationRouter,
|
|
2557
|
+
names: ReadonlyArray<string> | undefined,
|
|
2558
|
+
): StationRouter {
|
|
2559
|
+
if (names === undefined || names.length === 0) return router;
|
|
2560
|
+
const attach = (route: unknown): unknown => {
|
|
2561
|
+
if (
|
|
2562
|
+
typeof route !== "object" ||
|
|
2563
|
+
route === null ||
|
|
2564
|
+
!("middleware" in route) ||
|
|
2565
|
+
typeof route.middleware !== "function"
|
|
2566
|
+
) {
|
|
2567
|
+
throw new Error(
|
|
2568
|
+
"[station] `station.middleware` is set but the router's routes have no middleware() method to attach it with.",
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
route.middleware(...names);
|
|
2572
|
+
return route;
|
|
2573
|
+
};
|
|
2574
|
+
return {
|
|
2575
|
+
get: (path, handler) => attach(router.get(path, handler)),
|
|
2576
|
+
post: (path, handler) => attach(router.post(path, handler)),
|
|
2577
|
+
put: (path, handler) => attach(router.put(path, handler)),
|
|
2578
|
+
delete: (path, handler) => attach(router.delete(path, handler)),
|
|
2579
|
+
};
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2118
2582
|
/**
|
|
2119
2583
|
* Cross-package bridge — Station's `Resource.entity` is intentionally
|
|
2120
2584
|
* typed `new (...args: never[]) => unknown` so the package type-compiles
|
|
@@ -2163,22 +2627,53 @@ function buildResourceContext(
|
|
|
2163
2627
|
atlas.getRelationMetadata(resource.entity).map((r) => r.propertyKey),
|
|
2164
2628
|
);
|
|
2165
2629
|
if (atlas.hasSoftDeletes(resource.entity)) excludedProps.add("deletedAt");
|
|
2630
|
+
const listColumns = viewColumns(
|
|
2631
|
+
columns,
|
|
2632
|
+
resource.list.columns,
|
|
2633
|
+
resource,
|
|
2634
|
+
"list.columns",
|
|
2635
|
+
);
|
|
2636
|
+
const showColumns = viewColumns(
|
|
2637
|
+
columns,
|
|
2638
|
+
resource.show.columns,
|
|
2639
|
+
resource,
|
|
2640
|
+
"show.columns",
|
|
2641
|
+
);
|
|
2642
|
+
const formColumns = editableColumns(
|
|
2643
|
+
columns,
|
|
2644
|
+
resource,
|
|
2645
|
+
pkColumn,
|
|
2646
|
+
autoManaged,
|
|
2647
|
+
excludedProps,
|
|
2648
|
+
);
|
|
2649
|
+
const writeOnly = writeOnlyKeys(formColumns, resource);
|
|
2166
2650
|
const derived =
|
|
2167
2651
|
rune === null
|
|
2168
2652
|
? undefined
|
|
2169
2653
|
: deriveWritableSchema(
|
|
2170
|
-
|
|
2654
|
+
formColumns,
|
|
2171
2655
|
pkColumn,
|
|
2172
2656
|
autoManaged,
|
|
2173
2657
|
excludedProps,
|
|
2174
2658
|
rune,
|
|
2659
|
+
writeOnly,
|
|
2175
2660
|
);
|
|
2176
2661
|
return {
|
|
2177
2662
|
repo,
|
|
2178
2663
|
columns,
|
|
2664
|
+
listColumns,
|
|
2665
|
+
showColumns,
|
|
2666
|
+
formColumns,
|
|
2667
|
+
writeOnly,
|
|
2668
|
+
redacted: new Set(
|
|
2669
|
+
resource.auditSecrets
|
|
2670
|
+
? []
|
|
2671
|
+
: columns.filter(isSecretColumn).map((c) => c.propertyKey),
|
|
2672
|
+
),
|
|
2179
2673
|
pkColumn,
|
|
2180
2674
|
autoManaged,
|
|
2181
2675
|
writableSchema: derived?.schema,
|
|
2676
|
+
updateSchema: derived?.updateSchema,
|
|
2182
2677
|
booleanKeys: derived?.booleanKeys ?? new Set<string>(),
|
|
2183
2678
|
};
|
|
2184
2679
|
}
|
|
@@ -2263,7 +2758,7 @@ function isRouterProxyUninit(err: unknown): boolean {
|
|
|
2263
2758
|
/**
|
|
2264
2759
|
* 56.5 authorization gate. Returns true when the action is allowed.
|
|
2265
2760
|
* Station authorizes EXCLUSIVELY through Warden's unified layer: the
|
|
2266
|
-
* decision is `auth.hasPermission(user, "<resource>.<action>", scope)`
|
|
2761
|
+
* decision is `auth.hasPermission(user, "<prefix>.<resource>.<action>", scope)`
|
|
2267
2762
|
* resolved via the container `"auth"` AuthManager (D1/D2). No
|
|
2268
2763
|
* Station-local RBAC computation, no token-payload read.
|
|
2269
2764
|
*
|
|
@@ -2288,8 +2783,10 @@ async function authorizeAction(
|
|
|
2288
2783
|
action: ResourceAction,
|
|
2289
2784
|
ctx: StationHttpContext,
|
|
2290
2785
|
authManager: WardenAuthManager | undefined,
|
|
2786
|
+
prefix: string,
|
|
2291
2787
|
scope: StationScope = "global",
|
|
2292
2788
|
): Promise<boolean> {
|
|
2789
|
+
const permission = `${prefix}.${resource.name}.${action}`;
|
|
2293
2790
|
if (authManager === undefined) return true;
|
|
2294
2791
|
const user = ctx.auth?.user;
|
|
2295
2792
|
// `=== undefined` alone is too narrow: a host whose middleware writes
|
|
@@ -2300,19 +2797,13 @@ async function authorizeAction(
|
|
|
2300
2797
|
// Coerce to a strict boolean: the duck-typed manager could resolve a
|
|
2301
2798
|
// truthy non-boolean, and `!truthy` would silently ALLOW. Anything
|
|
2302
2799
|
// not exactly `true` denies (fail-closed).
|
|
2303
|
-
return (
|
|
2304
|
-
(await authManager.hasPermission(
|
|
2305
|
-
user,
|
|
2306
|
-
`${resource.name}.${action}`,
|
|
2307
|
-
scope,
|
|
2308
|
-
)) === true
|
|
2309
|
-
);
|
|
2800
|
+
return (await authManager.hasPermission(user, permission, scope)) === true;
|
|
2310
2801
|
} catch (err) {
|
|
2311
2802
|
// A rights-store I/O failure (DB/Redis-backed resolver) must not pass
|
|
2312
2803
|
// the gate. Deny and surface it loud rather than 500-ing or allowing.
|
|
2313
2804
|
const detail = err instanceof Error ? err.message : String(err);
|
|
2314
2805
|
console.error(
|
|
2315
|
-
`[station] authorization check threw for '${
|
|
2806
|
+
`[station] authorization check threw for '${permission}' — denying (fail-closed): ${detail}`,
|
|
2316
2807
|
);
|
|
2317
2808
|
return false;
|
|
2318
2809
|
}
|