@astram/guard 0.13.0 → 0.15.0
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/auth/auth.module.d.ts.map +1 -1
- package/dist/auth/auth.module.js +57 -30
- package/dist/auth/connections.d.ts +2 -2
- package/dist/auth/connections.d.ts.map +1 -1
- package/dist/auth/connections.js +4 -13
- package/dist/authentication/mariadb-api-key-store.d.ts +10 -1
- package/dist/authentication/mariadb-api-key-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-api-key-store.js +20 -10
- package/dist/authentication/mariadb-credential-resolver.d.ts +8 -1
- package/dist/authentication/mariadb-credential-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-resolver.js +15 -5
- package/dist/authentication/mariadb-credential-writer.d.ts +6 -1
- package/dist/authentication/mariadb-credential-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-credential-writer.js +9 -3
- package/dist/authentication/mariadb-email-address-changer.d.ts +10 -1
- package/dist/authentication/mariadb-email-address-changer.d.ts.map +1 -1
- package/dist/authentication/mariadb-email-address-changer.js +22 -9
- package/dist/authentication/mariadb-email-change-token-store.d.ts +8 -1
- package/dist/authentication/mariadb-email-change-token-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-email-change-token-store.js +18 -9
- package/dist/authentication/mariadb-mfa-device-store.d.ts +6 -1
- package/dist/authentication/mariadb-mfa-device-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-mfa-device-store.js +22 -13
- package/dist/authentication/mariadb-profile-resolver.d.ts +6 -1
- package/dist/authentication/mariadb-profile-resolver.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-resolver.js +12 -6
- package/dist/authentication/mariadb-profile-writer.d.ts +6 -1
- package/dist/authentication/mariadb-profile-writer.d.ts.map +1 -1
- package/dist/authentication/mariadb-profile-writer.js +11 -4
- package/dist/authentication/mariadb-recovery-code-store.d.ts +6 -1
- package/dist/authentication/mariadb-recovery-code-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-recovery-code-store.js +11 -5
- package/dist/authentication/mariadb-reset-token-store.d.ts +6 -1
- package/dist/authentication/mariadb-reset-token-store.d.ts.map +1 -1
- package/dist/authentication/mariadb-reset-token-store.js +11 -5
- package/dist/authentication/mariadb-session-sweeper.d.ts +6 -1
- package/dist/authentication/mariadb-session-sweeper.d.ts.map +1 -1
- package/dist/authentication/mariadb-session-sweeper.js +11 -5
- package/dist/authz/cross-tenant-grant-writer.d.ts.map +1 -1
- package/dist/authz/cross-tenant-grant-writer.js +4 -3
- package/dist/authz/list-scope-sql.d.ts +10 -2
- package/dist/authz/list-scope-sql.d.ts.map +1 -1
- package/dist/authz/list-scope-sql.js +4 -4
- package/dist/authz/mariadb-cross-tenant-grant-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-cross-tenant-grant-adapter.js +4 -3
- package/dist/authz/mariadb-list-scope-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-list-scope-adapter.js +4 -3
- package/dist/authz/mariadb-policy-store.d.ts.map +1 -1
- package/dist/authz/mariadb-policy-store.js +4 -3
- package/dist/authz/mariadb-record-rule-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-rule-adapter.js +4 -3
- package/dist/authz/mariadb-record-share-adapter.d.ts.map +1 -1
- package/dist/authz/mariadb-record-share-adapter.js +4 -3
- package/dist/authz/record-share-cascade.d.ts.map +1 -1
- package/dist/authz/record-share-cascade.js +4 -3
- package/dist/authz/record-share-writer.d.ts.map +1 -1
- package/dist/authz/record-share-writer.js +4 -3
- package/dist/authz/wire-record-authorization.d.ts +8 -0
- package/dist/authz/wire-record-authorization.d.ts.map +1 -1
- package/dist/authz/wire-record-authorization.js +14 -6
- package/dist/boot/boot-sequence.d.ts +4 -0
- package/dist/boot/boot-sequence.d.ts.map +1 -1
- package/dist/boot/boot-sequence.js +13 -1
- package/dist/boot/migration-ledger-check.d.ts +94 -0
- package/dist/boot/migration-ledger-check.d.ts.map +1 -0
- package/dist/boot/migration-ledger-check.js +280 -0
- package/dist/config/app-config.d.ts +72 -3
- package/dist/config/app-config.d.ts.map +1 -1
- package/dist/config/app-config.js +129 -7
- package/dist/config/config.module.d.ts +7 -0
- package/dist/config/config.module.d.ts.map +1 -1
- package/dist/config/config.module.js +13 -2
- package/dist/config/framework-schema-names.d.ts +29 -0
- package/dist/config/framework-schema-names.d.ts.map +1 -0
- package/dist/config/framework-schema-names.js +41 -0
- package/dist/config/loopback-host.d.ts +9 -0
- package/dist/config/loopback-host.d.ts.map +1 -0
- package/dist/config/loopback-host.js +19 -0
- package/dist/encryption/local-kek-crypto.d.ts +6 -0
- package/dist/encryption/local-kek-crypto.d.ts.map +1 -1
- package/dist/encryption/local-kek-crypto.js +8 -0
- package/dist/identity/identity-actions.d.ts +18 -7
- package/dist/identity/identity-actions.d.ts.map +1 -1
- package/dist/identity/identity-actions.js +16 -17
- package/dist/identity/module-registry.bridge.d.ts.map +1 -1
- package/dist/identity/module-registry.bridge.js +5 -3
- package/dist/identity/permission-facts.adapter.d.ts +7 -1
- package/dist/identity/permission-facts.adapter.d.ts.map +1 -1
- package/dist/identity/permission-facts.adapter.js +15 -6
- package/dist/index.d.ts +14 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +68 -22
- package/dist/module/shield-tokens.d.ts +11 -0
- package/dist/module/shield-tokens.d.ts.map +1 -1
- package/dist/module/shield-tokens.js +12 -1
- package/dist/module/shield.module.d.ts +10 -0
- package/dist/module/shield.module.d.ts.map +1 -1
- package/dist/module/shield.module.js +78 -12
- package/dist/secrets/boot-secrets.d.ts +33 -0
- package/dist/secrets/boot-secrets.d.ts.map +1 -0
- package/dist/secrets/boot-secrets.js +180 -0
- package/dist/session/mariadb-session-record-store.d.ts +8 -1
- package/dist/session/mariadb-session-record-store.d.ts.map +1 -1
- package/dist/session/mariadb-session-record-store.js +23 -13
- package/dist/session/session-restriction-store.d.ts +8 -1
- package/dist/session/session-restriction-store.d.ts.map +1 -1
- package/dist/session/session-restriction-store.js +15 -5
- package/dist/storage/file-intake.interceptor.d.ts +54 -0
- package/dist/storage/file-intake.interceptor.d.ts.map +1 -0
- package/dist/storage/file-intake.interceptor.js +223 -0
- package/dist/storage/node-sha256-digest.d.ts +6 -0
- package/dist/storage/node-sha256-digest.d.ts.map +1 -0
- package/dist/storage/node-sha256-digest.js +15 -0
- package/dist/storage/object-store-boot-check.d.ts +16 -0
- package/dist/storage/object-store-boot-check.d.ts.map +1 -0
- package/dist/storage/object-store-boot-check.js +52 -0
- package/dist/storage/optional-peer.d.ts +10 -0
- package/dist/storage/optional-peer.d.ts.map +1 -0
- package/dist/storage/optional-peer.js +26 -0
- package/dist/storage/s3-object-store.d.ts +35 -0
- package/dist/storage/s3-object-store.d.ts.map +1 -0
- package/dist/storage/s3-object-store.js +238 -0
- package/dist/storage/storage.providers.d.ts +10 -0
- package/dist/storage/storage.providers.d.ts.map +1 -0
- package/dist/storage/storage.providers.js +104 -0
- package/migrations/1710000000006-CreateAuthzSchema.cjs +15 -4
- package/migrations/1710000000007-ExtendPermissionAndRolePermission.cjs +11 -3
- package/migrations/1710000000008-CreateAccessBundleTables.cjs +14 -4
- package/migrations/1710000000009-CreateRecordPermissionRule.cjs +15 -4
- package/migrations/1710000000014-CreateRecordShare.cjs +15 -4
- package/migrations/1710000000015-CreateScopeClosure.cjs +14 -4
- package/migrations/1710000000016-AlterCompanyParentFkRestrict.cjs +20 -9
- package/migrations/1710000000017-CreateServiceAccountKey.cjs +14 -4
- package/package.json +11 -3
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveBootSecrets = resolveBootSecrets;
|
|
4
|
+
exports.wasResolvedFromSecretRef = wasResolvedFromSecretRef;
|
|
5
|
+
exports.createSecretsProviderFromEnv = createSecretsProviderFromEnv;
|
|
6
|
+
exports.assertNoUnresolvedSecretRefs = assertNoUnresolvedSecretRefs;
|
|
7
|
+
/**
|
|
8
|
+
* Boot-time `secret-ref:` resolution (ADR-0032; FILE_STORAGE SPEC §5 L-N, §13).
|
|
9
|
+
*
|
|
10
|
+
* - {@link resolveBootSecrets}: called first in the app's `main.ts`, before `AppModule` is loaded,
|
|
11
|
+
* so every `forRoot` and direct env reader sees resolved values. Resolves every `secret-ref:` env
|
|
12
|
+
* value via core `resolveSecretRefs` and writes the results back into `process.env` for those
|
|
13
|
+
* keys only, and only once every reference has resolved (no partial write-back).
|
|
14
|
+
* - {@link createSecretsProviderFromEnv}: builds the configured {@link SecretsProvider} from the
|
|
15
|
+
* `SECRETS_*` variables (`none` | `encrypted-file`; `vault` reserved).
|
|
16
|
+
* - {@link assertNoUnresolvedSecretRefs}: synchronous backstop at every `loadAppConfig()` call site.
|
|
17
|
+
*
|
|
18
|
+
* SECURITY (Shield #1, #10): fail closed on every bad variable, file, key or reference. Errors name
|
|
19
|
+
* env variable names (and, for a failed resolution, the reference path); never a value, a key
|
|
20
|
+
* byte or file contents. Nothing is logged.
|
|
21
|
+
*/
|
|
22
|
+
const node_crypto_1 = require("node:crypto");
|
|
23
|
+
const node_fs_1 = require("node:fs");
|
|
24
|
+
const core_1 = require("@astram/core");
|
|
25
|
+
const local_kek_crypto_js_1 = require("../encryption/local-kek-crypto.js");
|
|
26
|
+
const encrypted_file_secrets_js_1 = require("./encrypted-file-secrets.js");
|
|
27
|
+
/** AEAD additional data the secrets DEK is wrapped under. */
|
|
28
|
+
const SECRETS_DEK_AAD = 'shield:secrets';
|
|
29
|
+
const KEK_BYTES = 32;
|
|
30
|
+
/** Env keys whose value is still a `secret-ref:` reference. */
|
|
31
|
+
function refKeys(env) {
|
|
32
|
+
return Object.keys(env).filter((k) => (0, core_1.isSecretRef)(env[k]));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve every `secret-ref:` value in `process.env` in place. No references and no provider
|
|
36
|
+
* configured: no-op. Idempotent (a second call finds no references).
|
|
37
|
+
*/
|
|
38
|
+
async function resolveBootSecrets(options = {}) {
|
|
39
|
+
const env = process.env;
|
|
40
|
+
const provider = options.provider ?? createSecretsProviderFromEnv(env);
|
|
41
|
+
const keys = refKeys(env);
|
|
42
|
+
if (keys.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
if (!provider) {
|
|
45
|
+
throw new Error(`secret-ref: values are set but no secrets provider is configured ` +
|
|
46
|
+
`(SECRETS_PROVIDER is unset or 'none'); env keys: ${keys.join(', ')}`);
|
|
47
|
+
}
|
|
48
|
+
// Core resolves into a NEW bag and throws on the first failure (key + path, never a value),
|
|
49
|
+
// so process.env is only touched once every reference has resolved.
|
|
50
|
+
const resolved = await (0, core_1.resolveSecretRefs)(env, provider);
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
const value = resolved[key];
|
|
53
|
+
env[key] = value;
|
|
54
|
+
if (value !== undefined)
|
|
55
|
+
resolvedFromRef.set(key, fingerprint(value));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Env key -> keyed fingerprint of the value {@link resolveBootSecrets} wrote for it. The value
|
|
60
|
+
* itself is never kept: an HMAC-SHA256 under a random per-process key (so the fingerprint cannot
|
|
61
|
+
* be checked against guesses outside this process).
|
|
62
|
+
*/
|
|
63
|
+
const resolvedFromRef = new Map();
|
|
64
|
+
const FINGERPRINT_KEY = (0, node_crypto_1.randomBytes)(32);
|
|
65
|
+
function fingerprint(value) {
|
|
66
|
+
return (0, node_crypto_1.createHmac)('sha256', FINGERPRINT_KEY).update(value, 'utf8').digest('hex');
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* `true` when `key`'s CURRENT value in `env` (default `process.env`) is the value
|
|
70
|
+
* {@link resolveBootSecrets} resolved from a `secret-ref:` at boot (FILE_STORAGE SPEC §5 F:
|
|
71
|
+
* credentials outside development/test must be references, never literals). A value changed
|
|
72
|
+
* after resolution (e.g. a literal written over it) is `false`. No value is kept, only a keyed
|
|
73
|
+
* fingerprint.
|
|
74
|
+
*/
|
|
75
|
+
function wasResolvedFromSecretRef(key, env = process.env) {
|
|
76
|
+
const recorded = resolvedFromRef.get(key);
|
|
77
|
+
const current = env[key];
|
|
78
|
+
if (recorded === undefined || typeof current !== 'string')
|
|
79
|
+
return false;
|
|
80
|
+
return (0, node_crypto_1.timingSafeEqual)(Buffer.from(recorded, 'hex'), Buffer.from(fingerprint(current), 'hex'));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build the {@link SecretsProvider} named by `SECRETS_PROVIDER`. Returns `undefined` for `none`
|
|
84
|
+
* or unset. Throws (naming the variable only) on an unknown value, `vault`, an unset, missing or
|
|
85
|
+
* unreadable file, a corrupt store, a KEK that is not 32 bytes, or a DEK that does not unwrap
|
|
86
|
+
* under the KEK.
|
|
87
|
+
*/
|
|
88
|
+
function createSecretsProviderFromEnv(env) {
|
|
89
|
+
const kind = (env.SECRETS_PROVIDER ?? '').trim();
|
|
90
|
+
if (kind === '' || kind === 'none')
|
|
91
|
+
return undefined;
|
|
92
|
+
if (kind === 'vault') {
|
|
93
|
+
throw new Error(`SECRETS_PROVIDER 'vault' is not supported yet (use 'none' or 'encrypted-file')`);
|
|
94
|
+
}
|
|
95
|
+
if (kind !== 'encrypted-file') {
|
|
96
|
+
throw new Error(`SECRETS_PROVIDER has an unknown value (expected 'none' or 'encrypted-file')`);
|
|
97
|
+
}
|
|
98
|
+
const storeFile = requirePath(env, 'SECRETS_FILE');
|
|
99
|
+
const kekFile = requirePath(env, 'SECRETS_KEK_FILE');
|
|
100
|
+
const dekFile = requirePath(env, 'SECRETS_DEK_FILE');
|
|
101
|
+
// SPEC §13: a missing, unreadable or corrupt store aborts boot naming SECRETS_FILE, even when
|
|
102
|
+
// no env value references it yet (a misconfigured provider is never silently empty).
|
|
103
|
+
checkStoreFile(storeFile);
|
|
104
|
+
const kek = readKeyFile(kekFile, 'SECRETS_KEK_FILE');
|
|
105
|
+
if (kek.length !== KEK_BYTES) {
|
|
106
|
+
throw new Error(`SECRETS_KEK_FILE must hold exactly ${KEK_BYTES} raw bytes`);
|
|
107
|
+
}
|
|
108
|
+
const crypto = new local_kek_crypto_js_1.LocalKekCrypto(new Uint8Array(kek));
|
|
109
|
+
kek.fill(0);
|
|
110
|
+
const wrapped = readKeyFile(dekFile, 'SECRETS_DEK_FILE');
|
|
111
|
+
let dek;
|
|
112
|
+
try {
|
|
113
|
+
dek = crypto.unwrapDekSync(new Uint8Array(wrapped), SECRETS_DEK_AAD);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error(`SECRETS_DEK_FILE could not be unwrapped with the key in SECRETS_KEK_FILE ` +
|
|
117
|
+
`(wrong KEK, wrong wrap context or a corrupt file)`);
|
|
118
|
+
}
|
|
119
|
+
return new encrypted_file_secrets_js_1.EncryptedFileSecretsProvider(storeFile, crypto, dek);
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Synchronous backstop: throws if any env value still starts with `secret-ref:` (the app did not
|
|
123
|
+
* call {@link resolveBootSecrets} before building modules). Lists env key names only.
|
|
124
|
+
*/
|
|
125
|
+
function assertNoUnresolvedSecretRefs(env) {
|
|
126
|
+
const keys = refKeys(env);
|
|
127
|
+
if (keys.length === 0)
|
|
128
|
+
return;
|
|
129
|
+
throw new Error(`unresolved secret references in env keys: ${keys.join(', ')}. ` +
|
|
130
|
+
`Call resolveBootSecrets() in main.ts before loading AppModule.`);
|
|
131
|
+
}
|
|
132
|
+
function requirePath(env, name) {
|
|
133
|
+
const value = env[name]?.trim();
|
|
134
|
+
if (!value)
|
|
135
|
+
throw new Error(`${name} must be set when SECRETS_PROVIDER is 'encrypted-file'`);
|
|
136
|
+
return value;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The store must exist, be readable and hold a JSON object of `{ version, ciphertext }` entries
|
|
140
|
+
* (an empty file is an empty store). Errors name `SECRETS_FILE` and an errno code only, never
|
|
141
|
+
* the path's contents.
|
|
142
|
+
*/
|
|
143
|
+
function checkStoreFile(path) {
|
|
144
|
+
let raw;
|
|
145
|
+
try {
|
|
146
|
+
raw = (0, node_fs_1.readFileSync)(path, 'utf8');
|
|
147
|
+
}
|
|
148
|
+
catch (e) {
|
|
149
|
+
const code = e.code;
|
|
150
|
+
throw new Error(`SECRETS_FILE could not be read${code ? ` (${code})` : ''}`);
|
|
151
|
+
}
|
|
152
|
+
if (raw.trim() === '')
|
|
153
|
+
return;
|
|
154
|
+
let parsed;
|
|
155
|
+
try {
|
|
156
|
+
parsed = JSON.parse(raw);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
parsed = undefined;
|
|
160
|
+
}
|
|
161
|
+
const isEntry = (v) => typeof v === 'object' &&
|
|
162
|
+
v !== null &&
|
|
163
|
+
Number.isInteger(v.version) &&
|
|
164
|
+
typeof v.ciphertext === 'string';
|
|
165
|
+
if (typeof parsed !== 'object' ||
|
|
166
|
+
parsed === null ||
|
|
167
|
+
Array.isArray(parsed) ||
|
|
168
|
+
!Object.values(parsed).every(isEntry)) {
|
|
169
|
+
throw new Error('SECRETS_FILE is not a valid secrets store (corrupt or wrong format)');
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function readKeyFile(path, name) {
|
|
173
|
+
try {
|
|
174
|
+
return (0, node_fs_1.readFileSync)(path);
|
|
175
|
+
}
|
|
176
|
+
catch (e) {
|
|
177
|
+
const code = e.code;
|
|
178
|
+
throw new Error(`${name} could not be read${code ? ` (${code})` : ''}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -19,11 +19,18 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import type { Pool } from 'mariadb';
|
|
21
21
|
import type { CreateSessionInput, InsertRefreshInput, RefreshTokenRecord, SessionRecord, SessionRecordStore } from '@astram/core';
|
|
22
|
+
import { type FrameworkSchemaNames } from '../config/framework-schema-names.js';
|
|
22
23
|
/** The DI token the store is bound under. */
|
|
23
24
|
export declare const SHIELD_SESSION_RECORD_STORE: unique symbol;
|
|
24
25
|
export declare class MariaDbSessionRecordStore implements SessionRecordStore {
|
|
25
26
|
private readonly pool;
|
|
26
|
-
|
|
27
|
+
/** `<identity>`.`session`, resolved at construction (lifecycle writes only, ADR-0013). */
|
|
28
|
+
private readonly sessionTable;
|
|
29
|
+
/** `<identity>`.`refresh_token`. */
|
|
30
|
+
private readonly refreshTable;
|
|
31
|
+
constructor(pool: Pool,
|
|
32
|
+
/** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
|
|
33
|
+
schemas?: FrameworkSchemaNames);
|
|
27
34
|
createSession(input: CreateSessionInput): Promise<SessionRecord>;
|
|
28
35
|
insertRefreshRecord(input: InsertRefreshInput): Promise<RefreshTokenRecord>;
|
|
29
36
|
findRefreshByHash(tokenHash: string): Promise<RefreshTokenRecord | null>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mariadb-session-record-store.d.ts","sourceRoot":"","sources":["../../src/session/mariadb-session-record-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAkB,MAAM,SAAS,CAAC;AACpD,OAAO,KAAK,EACV,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,kBAAkB,EACnB,MAAM,cAAc,CAAC;
|
|
1
|
+
{"version":3,"file":"mariadb-session-record-store.d.ts","sourceRoot":"","sources":["../../src/session/mariadb-session-record-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAkB,MAAM,SAAS,CAAC;AACpD,OAAO,KAAK,EACV,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,kBAAkB,EACnB,MAAM,cAAc,CAAC;AAEtB,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,qCAAqC,CAAC;AAE7C,6CAA6C;AAC7C,eAAO,MAAM,2BAA2B,eAAwC,CAAC;AAmDjF,qBAAa,yBAA0B,YAAW,kBAAkB;IAOhE,OAAO,CAAC,QAAQ,CAAC,IAAI;IANvB,0FAA0F;IAC1F,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,oCAAoC;IACpC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;gBAGnB,IAAI,EAAE,IAAI;IAC3B,yFAAyF;IACzF,OAAO,CAAC,EAAE,oBAAoB;IAO1B,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,aAAa,CAAC;IAuBhE,mBAAmB,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,kBAAkB,CAAC;IA4B3E,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,GAAG,IAAI,CAAC;IASxE,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC;IASvE;;;;;;;OAOG;IACG,mBAAmB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IASxD,WAAW,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAQnD;;;OAGG;IACG,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBrD;;;;OAIG;IACG,oBAAoB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAQ1D,aAAa,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAarD,sEAAsE;YACxD,MAAM;CAiBrB"}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
22
|
exports.MariaDbSessionRecordStore = exports.SHIELD_SESSION_RECORD_STORE = void 0;
|
|
23
|
+
const framework_schema_names_js_1 = require("../config/framework-schema-names.js");
|
|
23
24
|
/** The DI token the store is bound under. */
|
|
24
25
|
exports.SHIELD_SESSION_RECORD_STORE = Symbol('SHIELD_SESSION_RECORD_STORE');
|
|
25
26
|
/** BIGINT columns can arrive as string; normalize to number safely. */
|
|
@@ -50,11 +51,20 @@ function mapRefresh(r) {
|
|
|
50
51
|
}
|
|
51
52
|
class MariaDbSessionRecordStore {
|
|
52
53
|
pool;
|
|
53
|
-
|
|
54
|
+
/** `<identity>`.`session`, resolved at construction (lifecycle writes only, ADR-0013). */
|
|
55
|
+
sessionTable;
|
|
56
|
+
/** `<identity>`.`refresh_token`. */
|
|
57
|
+
refreshTable;
|
|
58
|
+
constructor(pool,
|
|
59
|
+
/** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
|
|
60
|
+
schemas) {
|
|
54
61
|
this.pool = pool;
|
|
62
|
+
const identity = (0, framework_schema_names_js_1.schemaNamesAtConstruction)(schemas).identity;
|
|
63
|
+
this.sessionTable = (0, framework_schema_names_js_1.qualifiedTableName)(identity, 'session');
|
|
64
|
+
this.refreshTable = (0, framework_schema_names_js_1.qualifiedTableName)(identity, 'refresh_token');
|
|
55
65
|
}
|
|
56
66
|
async createSession(input) {
|
|
57
|
-
await this.pool.query(`INSERT INTO
|
|
67
|
+
await this.pool.query(`INSERT INTO ${this.sessionTable}
|
|
58
68
|
(s_session_id, s_principal_id, s_tenant_id, s_status, s_issued_at_ms, s_absolute_expires_at_ms)
|
|
59
69
|
VALUES (?, ?, ?, 'active', ?, ?)`, [
|
|
60
70
|
input.sessionId,
|
|
@@ -73,7 +83,7 @@ class MariaDbSessionRecordStore {
|
|
|
73
83
|
};
|
|
74
84
|
}
|
|
75
85
|
async insertRefreshRecord(input) {
|
|
76
|
-
await this.pool.query(`INSERT INTO
|
|
86
|
+
await this.pool.query(`INSERT INTO ${this.refreshTable}
|
|
77
87
|
(rt_refresh_id, rt_session_id, rt_lineage_id, rt_prev_refresh_id,
|
|
78
88
|
rt_token_hash, rt_status, rt_issued_at_ms, rt_idle_expires_at_ms)
|
|
79
89
|
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`, [
|
|
@@ -97,12 +107,12 @@ class MariaDbSessionRecordStore {
|
|
|
97
107
|
};
|
|
98
108
|
}
|
|
99
109
|
async findRefreshByHash(tokenHash) {
|
|
100
|
-
const rows = (await this.pool.query(`SELECT * FROM
|
|
110
|
+
const rows = (await this.pool.query(`SELECT * FROM ${this.refreshTable} WHERE rt_token_hash = ? LIMIT 1`, [tokenHash]));
|
|
101
111
|
const row = rows[0];
|
|
102
112
|
return row ? mapRefresh(row) : null;
|
|
103
113
|
}
|
|
104
114
|
async findSessionById(sessionId) {
|
|
105
|
-
const rows = (await this.pool.query(`SELECT * FROM
|
|
115
|
+
const rows = (await this.pool.query(`SELECT * FROM ${this.sessionTable} WHERE s_session_id = ? LIMIT 1`, [sessionId]));
|
|
106
116
|
const row = rows[0];
|
|
107
117
|
return row ? mapSession(row) : null;
|
|
108
118
|
}
|
|
@@ -115,12 +125,12 @@ class MariaDbSessionRecordStore {
|
|
|
115
125
|
* gets `0` (row already 'rotated' → loser). This makes rotation single-winner.
|
|
116
126
|
*/
|
|
117
127
|
async markRotatedIfActive(refreshId) {
|
|
118
|
-
const res = (await this.pool.query(`UPDATE
|
|
128
|
+
const res = (await this.pool.query(`UPDATE ${this.refreshTable} SET rt_status = 'rotated'
|
|
119
129
|
WHERE rt_refresh_id = ? AND rt_status = 'active'`, [refreshId]));
|
|
120
130
|
return res?.affectedRows === 1;
|
|
121
131
|
}
|
|
122
132
|
async markRevoked(refreshId) {
|
|
123
|
-
await this.pool.query(`UPDATE
|
|
133
|
+
await this.pool.query(`UPDATE ${this.refreshTable} SET rt_status = 'revoked'
|
|
124
134
|
WHERE rt_refresh_id = ?`, [refreshId]);
|
|
125
135
|
}
|
|
126
136
|
/**
|
|
@@ -129,11 +139,11 @@ class MariaDbSessionRecordStore {
|
|
|
129
139
|
*/
|
|
130
140
|
async revokeLineage(lineageId) {
|
|
131
141
|
await this.withTx(async (conn) => {
|
|
132
|
-
await conn.query(`UPDATE
|
|
142
|
+
await conn.query(`UPDATE ${this.refreshTable} SET rt_status = 'revoked'
|
|
133
143
|
WHERE rt_lineage_id = ?`, [lineageId]);
|
|
134
|
-
await conn.query(`UPDATE
|
|
144
|
+
await conn.query(`UPDATE ${this.sessionTable} SET s_status = 'revoked'
|
|
135
145
|
WHERE s_session_id IN (
|
|
136
|
-
SELECT DISTINCT rt_session_id FROM
|
|
146
|
+
SELECT DISTINCT rt_session_id FROM ${this.refreshTable} WHERE rt_lineage_id = ?
|
|
137
147
|
)`, [lineageId]);
|
|
138
148
|
});
|
|
139
149
|
}
|
|
@@ -143,13 +153,13 @@ class MariaDbSessionRecordStore {
|
|
|
143
153
|
* those sessions in Redis — never on the per-request path.
|
|
144
154
|
*/
|
|
145
155
|
async sessionIdsForLineage(lineageId) {
|
|
146
|
-
const rows = (await this.pool.query(`SELECT DISTINCT rt_session_id FROM
|
|
156
|
+
const rows = (await this.pool.query(`SELECT DISTINCT rt_session_id FROM ${this.refreshTable} WHERE rt_lineage_id = ?`, [lineageId]));
|
|
147
157
|
return rows.map((r) => r.rt_session_id);
|
|
148
158
|
}
|
|
149
159
|
async revokeSession(sessionId) {
|
|
150
160
|
await this.withTx(async (conn) => {
|
|
151
|
-
await conn.query(`UPDATE
|
|
152
|
-
await conn.query(`UPDATE
|
|
161
|
+
await conn.query(`UPDATE ${this.sessionTable} SET s_status = 'revoked' WHERE s_session_id = ?`, [sessionId]);
|
|
162
|
+
await conn.query(`UPDATE ${this.refreshTable} SET rt_status = 'revoked' WHERE rt_session_id = ?`, [sessionId]);
|
|
153
163
|
});
|
|
154
164
|
}
|
|
155
165
|
/** Run `fn` in a transaction, releasing the connection either way. */
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* Every read error propagates: the caller fails closed.
|
|
16
16
|
*/
|
|
17
17
|
import type { Pool } from 'mariadb';
|
|
18
|
+
import { type FrameworkSchemaNames } from '../config/framework-schema-names.js';
|
|
18
19
|
/** What the refresh rebuild needs to know about a session and its principal. */
|
|
19
20
|
export interface SessionRebuildFacts {
|
|
20
21
|
/** The session was opened by a recovery-code login and is not yet re-secured. */
|
|
@@ -35,7 +36,13 @@ export interface SessionRestrictionStore {
|
|
|
35
36
|
}
|
|
36
37
|
export declare class MariaDbSessionRestrictionStore implements SessionRestrictionStore {
|
|
37
38
|
private readonly pool;
|
|
38
|
-
|
|
39
|
+
/** `<identity>`.`session`. */
|
|
40
|
+
private readonly sessionTable;
|
|
41
|
+
/** `<app>`.`users`. */
|
|
42
|
+
private readonly usersTable;
|
|
43
|
+
constructor(pool: Pool,
|
|
44
|
+
/** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
|
|
45
|
+
schemas?: FrameworkSchemaNames);
|
|
39
46
|
markMustReenroll(sessionId: string): Promise<void>;
|
|
40
47
|
clearMustReenroll(sessionId: string): Promise<void>;
|
|
41
48
|
rebuildFacts(sessionId: string, principalId: string): Promise<SessionRebuildFacts>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-restriction-store.d.ts","sourceRoot":"","sources":["../../src/session/session-restriction-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"session-restriction-store.d.ts","sourceRoot":"","sources":["../../src/session/session-restriction-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AACH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,SAAS,CAAC;AACpC,OAAO,EAGL,KAAK,oBAAoB,EAC1B,MAAM,qCAAqC,CAAC;AAE7C,gFAAgF;AAChF,MAAM,WAAW,mBAAmB;IAClC,iFAAiF;IACjF,QAAQ,CAAC,YAAY,EAAE,OAAO,CAAC;IAC/B;;;OAGG;IACH,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;CACjC;AAED,MAAM,WAAW,uBAAuB;IACtC,uGAAuG;IACvG,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACnD,+DAA+D;IAC/D,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IACpD,2FAA2F;IAC3F,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAAC;CACpF;AAKD,qBAAa,8BAA+B,YAAW,uBAAuB;IAO1E,OAAO,CAAC,QAAQ,CAAC,IAAI;IANvB,8BAA8B;IAC9B,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,uBAAuB;IACvB,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;gBAGjB,IAAI,EAAE,IAAI;IAC3B,yFAAyF;IACzF,OAAO,CAAC,EAAE,oBAAoB;IAO1B,gBAAgB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOlD,iBAAiB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAOnD,YAAY,CAAC,SAAS,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC;CAqBzF"}
|
|
@@ -1,26 +1,36 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MariaDbSessionRestrictionStore = void 0;
|
|
4
|
+
const framework_schema_names_js_1 = require("../config/framework-schema-names.js");
|
|
4
5
|
/** MariaDB `ER_NO_SUCH_TABLE`. */
|
|
5
6
|
const ER_NO_SUCH_TABLE = 1146;
|
|
6
7
|
class MariaDbSessionRestrictionStore {
|
|
7
8
|
pool;
|
|
8
|
-
|
|
9
|
+
/** `<identity>`.`session`. */
|
|
10
|
+
sessionTable;
|
|
11
|
+
/** `<app>`.`users`. */
|
|
12
|
+
usersTable;
|
|
13
|
+
constructor(pool,
|
|
14
|
+
/** Framework database names; absent ⇒ resolved from `SHIELD_DB_NAMESPACE` here, once. */
|
|
15
|
+
schemas) {
|
|
9
16
|
this.pool = pool;
|
|
17
|
+
const s = (0, framework_schema_names_js_1.schemaNamesAtConstruction)(schemas);
|
|
18
|
+
this.sessionTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.identity, 'session');
|
|
19
|
+
this.usersTable = (0, framework_schema_names_js_1.qualifiedTableName)(s.app, 'users');
|
|
10
20
|
}
|
|
11
21
|
async markMustReenroll(sessionId) {
|
|
12
|
-
await this.pool.query(
|
|
22
|
+
await this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 1 WHERE s_session_id = ?`, [sessionId]);
|
|
13
23
|
}
|
|
14
24
|
async clearMustReenroll(sessionId) {
|
|
15
|
-
await this.pool.query(
|
|
25
|
+
await this.pool.query(`UPDATE ${this.sessionTable} SET s_must_reenroll = 0 WHERE s_session_id = ?`, [sessionId]);
|
|
16
26
|
}
|
|
17
27
|
async rebuildFacts(sessionId, principalId) {
|
|
18
|
-
const rows = (await this.pool.query(
|
|
28
|
+
const rows = (await this.pool.query(`SELECT s_must_reenroll FROM ${this.sessionTable} WHERE s_session_id = ? LIMIT 1`, [sessionId]));
|
|
19
29
|
// No row → treat as restricted (the caller also re-reads the session and refuses a missing one).
|
|
20
30
|
const mustReenroll = rows[0] === undefined || Number(rows[0].s_must_reenroll) !== 0;
|
|
21
31
|
let accountActive = true;
|
|
22
32
|
try {
|
|
23
|
-
const users = (await this.pool.query(
|
|
33
|
+
const users = (await this.pool.query(`SELECT u_status FROM ${this.usersTable} WHERE u_uuid = ? LIMIT 1`, [principalId]));
|
|
24
34
|
if (users[0] !== undefined)
|
|
25
35
|
accountActive = users[0].u_status === 'active';
|
|
26
36
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `FileIntake(options)` (FILE_STORAGE SPEC §5 G, §13): one multipart file, checked, then handed
|
|
3
|
+
* to the route handler.
|
|
4
|
+
*
|
|
5
|
+
* @Classified({ typeKey: 'stored_file', action: 'create' })
|
|
6
|
+
* @UseInterceptors(FileIntake({ field: 'file', profile: 'xlsx' }))
|
|
7
|
+
*
|
|
8
|
+
* - Interceptors run after guards, so the global guard has decided (and a denied caller is
|
|
9
|
+
* refused) before any multipart byte is read (Shield #1, #3). A request with no principal is
|
|
10
|
+
* refused here too, before parsing.
|
|
11
|
+
* - Parsing wraps `@nestjs/platform-express` `FileInterceptor` with multer's memory storage
|
|
12
|
+
* (no module-level `MulterModule` options apply: no disk storage, no consumer file filter) and
|
|
13
|
+
* limits `{ files: 1, fileSize: maxBytes + 1, fields: 0, parts: 2 }` and `defParamCharset:
|
|
14
|
+
* 'utf8'`. `maxBytes` defaults to the bound profile's configured cap (`OBJECT_STORAGE_MAX_BYTES`
|
|
15
|
+
* under `storage: 'env'`); an explicit smaller `maxBytes` wins; never above 50 MiB.
|
|
16
|
+
* - Then `SHIELD_FILE_INGEST` checks the bytes (size, extension, declared type, magic) with the
|
|
17
|
+
* tenant from the session (`req.principal.tenantId`, never the request body).
|
|
18
|
+
* - Errors are mapped here, at the source: 400 / 413 / 415 `HttpException`s carrying a fixed
|
|
19
|
+
* reason code. The file name, field name and bytes are never echoed (Shield #10).
|
|
20
|
+
* - On success the request carries `req[FILE_INTAKE_REQUEST_KEY] = { file, bytes }` for
|
|
21
|
+
* `FilesService.store`; multer's raw `req.file` (unsanitised name) is removed.
|
|
22
|
+
*/
|
|
23
|
+
import { type NestInterceptor, type Type } from '@nestjs/common';
|
|
24
|
+
import { type FileRejectionReason, type IngestedFile } from '@astram/core';
|
|
25
|
+
/** The HTTP reason codes `FileIntake` answers with: the core reasons plus `bad_request`. */
|
|
26
|
+
export type FileIntakeRejectionReason = FileRejectionReason | 'bad_request';
|
|
27
|
+
/** Options for {@link FileIntake}. */
|
|
28
|
+
export interface FileIntakeOptions {
|
|
29
|
+
/** The multipart field that carries the file. */
|
|
30
|
+
readonly field: string;
|
|
31
|
+
/** A profile name known to `SHIELD_FILE_INGEST` (e.g. `'xlsx'`). */
|
|
32
|
+
readonly profile: string;
|
|
33
|
+
/**
|
|
34
|
+
* Lower multipart byte cap for this route (at most 50 MiB). Default: the profile's configured
|
|
35
|
+
* cap on `SHIELD_FILE_INGEST` (`OBJECT_STORAGE_MAX_BYTES` under `storage: 'env'`, else 10 MiB);
|
|
36
|
+
* a larger value than the profile cap is lowered to it. The parser stops at `maxBytes + 1`;
|
|
37
|
+
* the profile's own cap still applies in the ingest check.
|
|
38
|
+
*/
|
|
39
|
+
readonly maxBytes?: number;
|
|
40
|
+
}
|
|
41
|
+
/** What {@link FileIntake} attaches to the request for `FilesService.store`. */
|
|
42
|
+
export interface FileIntakeResult {
|
|
43
|
+
/** Server-built metadata (storage key, sha256, canonical type, size, sanitised name). */
|
|
44
|
+
readonly file: IngestedFile;
|
|
45
|
+
/** The exact bytes that were checked. */
|
|
46
|
+
readonly bytes: Uint8Array;
|
|
47
|
+
}
|
|
48
|
+
/** The request property {@link FileIntake} sets to a {@link FileIntakeResult}. */
|
|
49
|
+
export declare const FILE_INTAKE_REQUEST_KEY = "shieldFileIntake";
|
|
50
|
+
/** Fallback multipart cap (10 MiB, SPEC §10) when the bound ingest cannot report a profile cap. */
|
|
51
|
+
export declare const FILE_INTAKE_DEFAULT_MAX_BYTES: number;
|
|
52
|
+
/** Build the intake interceptor for one route (see the file header). Throws at boot on bad options. */
|
|
53
|
+
export declare function FileIntake(options: FileIntakeOptions): Type<NestInterceptor>;
|
|
54
|
+
//# sourceMappingURL=file-intake.interceptor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"file-intake.interceptor.d.ts","sourceRoot":"","sources":["../../src/storage/file-intake.interceptor.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,OAAO,EAWL,KAAK,eAAe,EACpB,KAAK,IAAI,EACV,MAAM,gBAAgB,CAAC;AAExB,OAAO,EAIL,KAAK,mBAAmB,EACxB,KAAK,YAAY,EAClB,MAAM,cAAc,CAAC;AAMtB,4FAA4F;AAC5F,MAAM,MAAM,yBAAyB,GAAG,mBAAmB,GAAG,aAAa,CAAC;AAE5E,sCAAsC;AACtC,MAAM,WAAW,iBAAiB;IAChC,iDAAiD;IACjD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,oEAAoE;IACpE,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,gFAAgF;AAChF,MAAM,WAAW,gBAAgB;IAC/B,yFAAyF;IACzF,QAAQ,CAAC,IAAI,EAAE,YAAY,CAAC;IAC5B,yCAAyC;IACzC,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;CAC5B;AAED,kFAAkF;AAClF,eAAO,MAAM,uBAAuB,qBAAqB,CAAC;AAE1D,mGAAmG;AACnG,eAAO,MAAM,6BAA6B,QAAmB,CAAC;AAuH9D,uGAAuG;AACvG,wBAAgB,UAAU,CAAC,OAAO,EAAE,iBAAiB,GAAG,IAAI,CAAC,eAAe,CAAC,CAkF5E"}
|