@getstrata/core 1.0.9 → 1.1.1
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/CHANGELOG.md +51 -0
- package/dist/core/auth/authContext.d.ts +5 -2
- package/dist/core/auth/emailVerification.d.ts +1 -1
- package/dist/core/auth/guard.d.ts +5 -1
- package/dist/core/auth/jwtGuard.d.ts +6 -2
- package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
- package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
- package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
- package/dist/core/auth/oneTimeToken.d.ts +19 -0
- package/dist/core/auth/passwordLogin.d.ts +20 -0
- package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
- package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
- package/dist/core/contracts/authUserDirectory.d.ts +2 -3
- package/dist/core/database/errors.d.ts +2 -1
- package/dist/core/http/response.d.ts +3 -1
- package/dist/core/mail/mailer.d.ts +6 -1
- package/dist/core/security/oauthState.d.ts +6 -2
- package/dist/core/security/safeFetch.d.ts +1 -0
- package/dist/core/security/safePath.d.ts +4 -0
- package/dist/core/security/safeUrl.d.ts +14 -1
- package/dist/core/security/totp.d.ts +1 -1
- package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
- package/dist/core/tenant/enableTenantRls.d.ts +6 -0
- package/dist/core/tenant/postgresAppRole.d.ts +75 -0
- package/dist/entries/audit/exportAuditLogs.js +223 -40
- package/dist/entries/auth/accessControl.js +1 -0
- package/dist/entries/auth/basicAuthGuard.js +638 -17
- package/dist/entries/auth/emailVerification.js +1 -1
- package/dist/entries/auth/intendedUrlCookie.js +32 -2
- package/dist/entries/auth/jwt.js +1 -1
- package/dist/entries/auth/jwtGuard.js +192 -3
- package/dist/entries/auth/membershipMiddleware.js +1 -0
- package/dist/entries/auth/membershipScope.js +1 -0
- package/dist/entries/auth/membershipService.js +1 -0
- package/dist/entries/auth/oauth/oidcProvider.js +542 -20
- package/dist/entries/auth/oauth/providers.js +313 -5
- package/dist/entries/auth/oauth/samlProvider.js +552 -74
- package/dist/entries/auth/oneTimeToken.js +362 -0
- package/dist/entries/auth/password.js +1 -1
- package/dist/entries/auth/passwordLogin.js +548 -0
- package/dist/entries/auth/policy.js +1 -0
- package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
- package/dist/entries/auth/scimAuthMiddleware.js +10 -3
- package/dist/entries/auth/sessionCookie.js +1 -1
- package/dist/entries/auth/sessionGuard.js +1 -1
- package/dist/entries/auth/tokenHash.js +2 -6
- package/dist/entries/crypto/fieldEncryption.js +1 -1
- package/dist/entries/crypto/mfaSecret.js +31 -5
- package/dist/entries/database/errors.js +29 -12
- package/dist/entries/database/model.js +9 -9
- package/dist/entries/facades.js +72 -9
- package/dist/entries/http/authMiddleware.js +7 -14
- package/dist/entries/http/corsMiddleware.js +69 -2
- package/dist/entries/http/csrfMiddleware.js +33 -43
- package/dist/entries/http/csrfToken.js +2 -2
- package/dist/entries/http/flashMiddleware.js +4 -2
- package/dist/entries/http/flashSession.js +4 -2
- package/dist/entries/http/formRequest.js +2 -2
- package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
- package/dist/entries/http/pagination.js +2 -2
- package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
- package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
- package/dist/entries/http/response.js +101 -45
- package/dist/entries/http/routeModelBinding.js +2 -2
- package/dist/entries/http/safeInternalPath.js +32 -2
- package/dist/entries/http/scimThrottleMiddleware.js +7 -1
- package/dist/entries/http/securedRouteModelBinding.js +2 -2
- package/dist/entries/http/securityHeadersMiddleware.js +3 -64
- package/dist/entries/http/signedUrl.js +6 -5
- package/dist/entries/http/throttleMiddleware.js +63 -0
- package/dist/entries/http/validation.js +2 -2
- package/dist/entries/http/webFormRequest.js +2 -2
- package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
- package/dist/entries/mail/mailer.js +54 -8
- package/dist/entries/media/imageTransform.js +12 -1
- package/dist/entries/openapi/generator.js +0 -7
- package/dist/entries/security/oauthState.js +30 -39
- package/dist/entries/security/publicReads.js +1 -1
- package/dist/entries/security/recoveryCodes.js +2 -2
- package/dist/entries/security/safeFetch.js +195 -94
- package/dist/entries/security/safePath.js +25 -0
- package/dist/entries/security/scimTenantTokens.js +10 -3
- package/dist/entries/security/totp.js +16 -4
- package/dist/entries/storage/storage.js +24 -2
- package/dist/entries/tenant/databaseTenantContext.js +34 -12
- package/dist/entries/tenant/enableTenantRls.js +385 -0
- package/dist/entries/tracing/tracingMiddleware.js +308 -5
- package/dist/framework/public-api.d.ts +10 -3
- package/dist/index.js +2767 -1515
- package/package.json +32 -3
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// ../../src/core/runtime/appEnv.ts
|
|
3
|
+
var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
|
|
4
|
+
function normalizeEnvValue(value) {
|
|
5
|
+
return (value ?? "").trim().toLowerCase();
|
|
6
|
+
}
|
|
7
|
+
function isProductionEnv(env = process.env) {
|
|
8
|
+
const appEnv = normalizeEnvValue(env.APP_ENV);
|
|
9
|
+
const nodeEnv = normalizeEnvValue(env.NODE_ENV);
|
|
10
|
+
if (appEnv === "production" || nodeEnv === "production") {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
if (appEnv === "") {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return !NON_PRODUCTION_APP_ENVS.has(appEnv);
|
|
17
|
+
}
|
|
18
|
+
function envFlagEnabled(value) {
|
|
19
|
+
return value === "true";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ../../src/core/tenant/postgresAppRole.ts
|
|
23
|
+
var POSTGRES_APP_ROLE = "strata_app";
|
|
24
|
+
var POSTGRES_APP_ROLE_PASSWORD = "dev-strata-app-change-me";
|
|
25
|
+
var POSTGRES_SUPERUSER_PASSWORD = "dev-postgres-change-me";
|
|
26
|
+
var LEGACY_POSTGRES_SUPERUSER_PASSWORD = "postgres";
|
|
27
|
+
function assertSafeSqlIdentifier(identifier, label) {
|
|
28
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
29
|
+
throw new Error(`Refusing unsafe Postgres ${label}: ${identifier}`);
|
|
30
|
+
}
|
|
31
|
+
return identifier;
|
|
32
|
+
}
|
|
33
|
+
function quoteSqlLiteral(value) {
|
|
34
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
35
|
+
}
|
|
36
|
+
function isPostgresUrl(raw) {
|
|
37
|
+
const trimmed = raw?.trim() ?? "";
|
|
38
|
+
if (!trimmed) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const url = new URL(trimmed);
|
|
43
|
+
return url.protocol === "postgres:" || url.protocol === "postgresql:";
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function postgresUrlUsername(raw) {
|
|
49
|
+
if (!isPostgresUrl(raw)) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
return decodeURIComponent(new URL(raw).username);
|
|
53
|
+
}
|
|
54
|
+
function postgresDatabaseNameFromUrl(raw) {
|
|
55
|
+
let name = "";
|
|
56
|
+
try {
|
|
57
|
+
name = decodeURIComponent(new URL(raw).pathname.replace(/^\//, ""));
|
|
58
|
+
} catch {
|
|
59
|
+
throw new Error(`DATABASE_URL is not a valid URL: ${raw}`);
|
|
60
|
+
}
|
|
61
|
+
if (!name) {
|
|
62
|
+
throw new Error("DATABASE_URL is missing a database name.");
|
|
63
|
+
}
|
|
64
|
+
return assertSafeSqlIdentifier(name, "database name");
|
|
65
|
+
}
|
|
66
|
+
function withPostgresUrlCredentials(raw, username, password) {
|
|
67
|
+
const url = new URL(raw);
|
|
68
|
+
url.username = username;
|
|
69
|
+
url.password = password;
|
|
70
|
+
return url.toString();
|
|
71
|
+
}
|
|
72
|
+
function withPostgresDatabaseName(raw, database) {
|
|
73
|
+
try {
|
|
74
|
+
const url = new URL(raw);
|
|
75
|
+
if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
url.pathname = `/${database}`;
|
|
79
|
+
return url.toString();
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function uniqueUrls(urls) {
|
|
85
|
+
const seen = new Set;
|
|
86
|
+
const out = [];
|
|
87
|
+
for (const url of urls) {
|
|
88
|
+
const trimmed = url?.trim() ?? "";
|
|
89
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
seen.add(trimmed);
|
|
93
|
+
out.push(trimmed);
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
function postgresAdminUrls(options) {
|
|
98
|
+
const urls = [];
|
|
99
|
+
const seen = new Set;
|
|
100
|
+
const push = (url) => {
|
|
101
|
+
const trimmed = url?.trim() ?? "";
|
|
102
|
+
if (!trimmed || seen.has(trimmed)) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
seen.add(trimmed);
|
|
106
|
+
urls.push(trimmed);
|
|
107
|
+
};
|
|
108
|
+
push(options.migrationUrl);
|
|
109
|
+
if (!isPostgresUrl(options.runtimeUrl)) {
|
|
110
|
+
return urls;
|
|
111
|
+
}
|
|
112
|
+
const superuserPassword = options.superuserPassword ?? POSTGRES_SUPERUSER_PASSWORD;
|
|
113
|
+
push(withPostgresUrlCredentials(options.runtimeUrl, "postgres", superuserPassword));
|
|
114
|
+
if (!isProductionEnv()) {
|
|
115
|
+
push(withPostgresUrlCredentials(options.runtimeUrl, "postgres", LEGACY_POSTGRES_SUPERUSER_PASSWORD));
|
|
116
|
+
}
|
|
117
|
+
return urls;
|
|
118
|
+
}
|
|
119
|
+
function resolveTargetDatabaseName(options) {
|
|
120
|
+
if (isPostgresUrl(options.runtimeUrl)) {
|
|
121
|
+
return postgresDatabaseNameFromUrl(options.runtimeUrl);
|
|
122
|
+
}
|
|
123
|
+
const migrationUrl = options.migrationUrl?.trim() ?? "";
|
|
124
|
+
if (isPostgresUrl(migrationUrl)) {
|
|
125
|
+
return postgresDatabaseNameFromUrl(migrationUrl);
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
function defaultConnect(url) {
|
|
130
|
+
return new Bun.SQL(url);
|
|
131
|
+
}
|
|
132
|
+
async function closePostgres(sql) {
|
|
133
|
+
if (!sql.close) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
await sql.close();
|
|
138
|
+
} catch {}
|
|
139
|
+
}
|
|
140
|
+
async function pingPostgres(sql) {
|
|
141
|
+
await sql.unsafe("SELECT 1");
|
|
142
|
+
}
|
|
143
|
+
async function connectFirstSuccessful(urls, connect) {
|
|
144
|
+
let lastError;
|
|
145
|
+
for (const candidate of urls) {
|
|
146
|
+
const sql = connect(candidate);
|
|
147
|
+
try {
|
|
148
|
+
await pingPostgres(sql);
|
|
149
|
+
return sql;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
lastError = error;
|
|
152
|
+
await closePostgres(sql);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
throw lastError instanceof Error ? lastError : new Error("Could not open a Postgres superuser connection for CREATE ROLE / CREATE DATABASE / GRANT. Set MIGRATION_DATABASE_URL.");
|
|
156
|
+
}
|
|
157
|
+
async function openPostgresAdminConnection(options) {
|
|
158
|
+
const connect = options.connect ?? defaultConnect;
|
|
159
|
+
const target = resolveTargetDatabaseName(options);
|
|
160
|
+
const candidates = uniqueUrls(postgresAdminUrls(options).map((url) => target ? withPostgresDatabaseName(url, target) ?? url : url));
|
|
161
|
+
return await connectFirstSuccessful(candidates, connect);
|
|
162
|
+
}
|
|
163
|
+
async function openPostgresMaintenanceConnection(options) {
|
|
164
|
+
const connect = options.connect ?? defaultConnect;
|
|
165
|
+
const candidates = [];
|
|
166
|
+
for (const url of postgresAdminUrls(options)) {
|
|
167
|
+
candidates.push(withPostgresDatabaseName(url, "postgres"));
|
|
168
|
+
candidates.push(withPostgresDatabaseName(url, "template1"));
|
|
169
|
+
candidates.push(url);
|
|
170
|
+
}
|
|
171
|
+
return await connectFirstSuccessful(uniqueUrls(candidates), connect);
|
|
172
|
+
}
|
|
173
|
+
function postgresAppRoleCreateSql(options = {}) {
|
|
174
|
+
const role = assertSafeSqlIdentifier(options.role ?? POSTGRES_APP_ROLE, "role name");
|
|
175
|
+
const password = quoteSqlLiteral(options.password ?? POSTGRES_APP_ROLE_PASSWORD);
|
|
176
|
+
return `DO $$
|
|
177
|
+
BEGIN
|
|
178
|
+
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '${role}') THEN
|
|
179
|
+
CREATE ROLE ${role} LOGIN PASSWORD ${password}
|
|
180
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
181
|
+
ELSE
|
|
182
|
+
ALTER ROLE ${role} WITH LOGIN PASSWORD ${password}
|
|
183
|
+
NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;
|
|
184
|
+
END IF;
|
|
185
|
+
END
|
|
186
|
+
$$`;
|
|
187
|
+
}
|
|
188
|
+
function postgresAppRoleSql(options) {
|
|
189
|
+
const database = assertSafeSqlIdentifier(options.database, "database name");
|
|
190
|
+
const role = assertSafeSqlIdentifier(options.role ?? POSTGRES_APP_ROLE, "role name");
|
|
191
|
+
return `-- Application login role. FORCE RLS applies because this role is NOSUPERUSER and NOBYPASSRLS.
|
|
192
|
+
-- Superuser remains for CREATE ROLE / CREATE DATABASE / GRANT / migrate / migrate:fresh DROP.
|
|
193
|
+
-- This script is repeatable on an already-existing volume (not only docker-entrypoint-initdb.d).
|
|
194
|
+
${postgresAppRoleCreateSql(options)};
|
|
195
|
+
|
|
196
|
+
GRANT CONNECT ON DATABASE ${database} TO ${role};
|
|
197
|
+
GRANT USAGE, CREATE ON SCHEMA public TO ${role};
|
|
198
|
+
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${role};
|
|
199
|
+
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${role};
|
|
200
|
+
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${role};
|
|
201
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${role};
|
|
202
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${role};
|
|
203
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${role};
|
|
204
|
+
`;
|
|
205
|
+
}
|
|
206
|
+
async function grantPostgresAppRolePrivileges(sql, options) {
|
|
207
|
+
const database = assertSafeSqlIdentifier(options.database, "database name");
|
|
208
|
+
const role = assertSafeSqlIdentifier(options.role ?? POSTGRES_APP_ROLE, "role name");
|
|
209
|
+
await sql.unsafe(`GRANT CONNECT ON DATABASE ${database} TO ${role}`);
|
|
210
|
+
await sql.unsafe(`GRANT USAGE, CREATE ON SCHEMA public TO ${role}`);
|
|
211
|
+
await sql.unsafe(`GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO ${role}`);
|
|
212
|
+
await sql.unsafe(`GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO ${role}`);
|
|
213
|
+
await sql.unsafe(`GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA public TO ${role}`);
|
|
214
|
+
await sql.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO ${role}`);
|
|
215
|
+
await sql.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO ${role}`);
|
|
216
|
+
await sql.unsafe(`ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT EXECUTE ON FUNCTIONS TO ${role}`);
|
|
217
|
+
}
|
|
218
|
+
async function ensurePostgresAppRole(sql, options) {
|
|
219
|
+
await sql.unsafe(postgresAppRoleCreateSql(options));
|
|
220
|
+
await grantPostgresAppRolePrivileges(sql, options);
|
|
221
|
+
}
|
|
222
|
+
async function ensurePostgresDatabaseAndAppRole(options) {
|
|
223
|
+
const name = postgresDatabaseNameFromUrl(options.runtimeUrl);
|
|
224
|
+
const maintenance = await openPostgresMaintenanceConnection(options);
|
|
225
|
+
try {
|
|
226
|
+
const rows = await maintenance.unsafe("SELECT 1 AS ok FROM pg_database WHERE datname = $1", [name]);
|
|
227
|
+
if (rows.length === 0) {
|
|
228
|
+
await maintenance.unsafe(`CREATE DATABASE ${name}`);
|
|
229
|
+
}
|
|
230
|
+
} finally {
|
|
231
|
+
await closePostgres(maintenance);
|
|
232
|
+
}
|
|
233
|
+
const admin = await openPostgresAdminConnection(options);
|
|
234
|
+
try {
|
|
235
|
+
await ensurePostgresAppRole(admin, {
|
|
236
|
+
database: name,
|
|
237
|
+
role: options.role,
|
|
238
|
+
password: options.password ?? process.env.STRATA_APP_PASSWORD ?? POSTGRES_APP_ROLE_PASSWORD
|
|
239
|
+
});
|
|
240
|
+
} finally {
|
|
241
|
+
await closePostgres(admin);
|
|
242
|
+
}
|
|
243
|
+
return options.runtimeUrl;
|
|
244
|
+
}
|
|
245
|
+
async function dropPostgresTablesAsAdmin(tables, options) {
|
|
246
|
+
const safeTables = tables.map((table) => assertSafeSqlIdentifier(table, "table name"));
|
|
247
|
+
const sql = await openPostgresAdminConnection(options);
|
|
248
|
+
try {
|
|
249
|
+
for (const table of safeTables) {
|
|
250
|
+
await sql.unsafe(`DROP TABLE IF EXISTS ${table} CASCADE`);
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
await closePostgres(sql);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function inspectCurrentPostgresRole(sql) {
|
|
257
|
+
const rows = await sql.unsafe(`SELECT current_user AS rolname, r.rolsuper, r.rolbypassrls
|
|
258
|
+
FROM pg_roles r WHERE r.rolname = current_user`);
|
|
259
|
+
return rows[0] ?? null;
|
|
260
|
+
}
|
|
261
|
+
function assertPostgresRoleCannotBypassRls(role, source = "DATABASE_URL") {
|
|
262
|
+
if (!role) {
|
|
263
|
+
throw new Error(`Production startup blocked: could not inspect the live Postgres role for ${source}.`);
|
|
264
|
+
}
|
|
265
|
+
if (role.rolsuper || role.rolbypassrls) {
|
|
266
|
+
throw new Error(`Production startup blocked: ${source} role ${role.rolname} is rolsuper or rolbypassrls. FORCE RLS does not apply. Use a NOBYPASSRLS login role.`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ../../src/core/tenant/enableTenantRls.ts
|
|
271
|
+
function assertSafeIdentifier(identifier) {
|
|
272
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(identifier)) {
|
|
273
|
+
throw new Error(`Invalid SQL identifier: ${identifier}`);
|
|
274
|
+
}
|
|
275
|
+
return identifier;
|
|
276
|
+
}
|
|
277
|
+
var RLS_HELPER_SQL = `
|
|
278
|
+
CREATE OR REPLACE FUNCTION app_bypass_rls()
|
|
279
|
+
RETURNS BOOLEAN AS $$
|
|
280
|
+
BEGIN
|
|
281
|
+
RETURN COALESCE(current_setting('app.bypass_rls', true), 'false') = 'true';
|
|
282
|
+
EXCEPTION
|
|
283
|
+
WHEN others THEN
|
|
284
|
+
RETURN FALSE;
|
|
285
|
+
END;
|
|
286
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
287
|
+
|
|
288
|
+
CREATE OR REPLACE FUNCTION app_bypass_identifier()
|
|
289
|
+
RETURNS TEXT AS $$
|
|
290
|
+
BEGIN
|
|
291
|
+
RETURN NULLIF(current_setting('app.bypass_identifier', true), '');
|
|
292
|
+
EXCEPTION
|
|
293
|
+
WHEN others THEN
|
|
294
|
+
RETURN NULL;
|
|
295
|
+
END;
|
|
296
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
297
|
+
|
|
298
|
+
CREATE OR REPLACE FUNCTION app_current_tenant_id()
|
|
299
|
+
RETURNS INTEGER AS $$
|
|
300
|
+
BEGIN
|
|
301
|
+
RETURN NULLIF(current_setting('app.tenant_id', true), '')::INTEGER;
|
|
302
|
+
EXCEPTION
|
|
303
|
+
WHEN others THEN
|
|
304
|
+
RETURN NULL;
|
|
305
|
+
END;
|
|
306
|
+
$$ LANGUAGE plpgsql STABLE;
|
|
307
|
+
`;
|
|
308
|
+
function tenantIsolationPolicy(table, predicate) {
|
|
309
|
+
return `
|
|
310
|
+
ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;
|
|
311
|
+
ALTER TABLE ${table} FORCE ROW LEVEL SECURITY;
|
|
312
|
+
DROP POLICY IF EXISTS tenant_isolation ON ${table};
|
|
313
|
+
CREATE POLICY tenant_isolation ON ${table}
|
|
314
|
+
USING (
|
|
315
|
+
app_bypass_rls()
|
|
316
|
+
OR ${predicate}
|
|
317
|
+
)
|
|
318
|
+
WITH CHECK (
|
|
319
|
+
app_bypass_rls()
|
|
320
|
+
OR ${predicate}
|
|
321
|
+
);
|
|
322
|
+
`;
|
|
323
|
+
}
|
|
324
|
+
function enableTenantRlsSql(tableName) {
|
|
325
|
+
const table = assertSafeIdentifier(tableName);
|
|
326
|
+
return tenantIsolationPolicy(table, `tenant_id = app_current_tenant_id()`);
|
|
327
|
+
}
|
|
328
|
+
function enableUsersRlsSql() {
|
|
329
|
+
return tenantIsolationPolicy("users", `tenant_id = app_current_tenant_id()
|
|
330
|
+
OR (
|
|
331
|
+
app_bypass_identifier() IS NOT NULL
|
|
332
|
+
AND (
|
|
333
|
+
id::text = app_bypass_identifier()
|
|
334
|
+
OR email = app_bypass_identifier()
|
|
335
|
+
)
|
|
336
|
+
)`);
|
|
337
|
+
}
|
|
338
|
+
function userOwnedIdentifierPredicate(table, userIdColumn) {
|
|
339
|
+
const userIdMatch = `${table}.${userIdColumn}::text = app_bypass_identifier()`;
|
|
340
|
+
if (table === "sessions") {
|
|
341
|
+
return `(app_bypass_identifier() IS NOT NULL AND (${table}.id::text = app_bypass_identifier() OR ${userIdMatch}))`;
|
|
342
|
+
}
|
|
343
|
+
if (table === "api_tokens" || table === "auth_one_time_tokens") {
|
|
344
|
+
return `(app_bypass_identifier() IS NOT NULL AND (${table}.token_hash = app_bypass_identifier() OR ${userIdMatch}))`;
|
|
345
|
+
}
|
|
346
|
+
return `(app_bypass_identifier() IS NOT NULL AND ${userIdMatch})`;
|
|
347
|
+
}
|
|
348
|
+
function enableUserOwnedRlsSql(tableName, userIdColumn = "user_id") {
|
|
349
|
+
const table = assertSafeIdentifier(tableName);
|
|
350
|
+
const column = assertSafeIdentifier(userIdColumn);
|
|
351
|
+
return tenantIsolationPolicy(table, `EXISTS (
|
|
352
|
+
SELECT 1 FROM users u
|
|
353
|
+
WHERE u.id = ${table}.${column}
|
|
354
|
+
AND u.tenant_id = app_current_tenant_id()
|
|
355
|
+
)
|
|
356
|
+
OR ${userOwnedIdentifierPredicate(table, column)}`);
|
|
357
|
+
}
|
|
358
|
+
function generatedRlsBootstrapSql(tables, userOwnedTables = []) {
|
|
359
|
+
return [
|
|
360
|
+
RLS_HELPER_SQL,
|
|
361
|
+
...tables.map((table) => table === "users" ? enableUsersRlsSql() : enableTenantRlsSql(table)),
|
|
362
|
+
...userOwnedTables.map((table) => enableUserOwnedRlsSql(table))
|
|
363
|
+
].join(`
|
|
364
|
+
`);
|
|
365
|
+
}
|
|
366
|
+
export {
|
|
367
|
+
POSTGRES_APP_ROLE,
|
|
368
|
+
POSTGRES_APP_ROLE_PASSWORD,
|
|
369
|
+
POSTGRES_SUPERUSER_PASSWORD,
|
|
370
|
+
RLS_HELPER_SQL,
|
|
371
|
+
assertPostgresRoleCannotBypassRls,
|
|
372
|
+
dropPostgresTablesAsAdmin,
|
|
373
|
+
enableTenantRlsSql,
|
|
374
|
+
enableUserOwnedRlsSql,
|
|
375
|
+
ensurePostgresAppRole,
|
|
376
|
+
ensurePostgresDatabaseAndAppRole,
|
|
377
|
+
generatedRlsBootstrapSql,
|
|
378
|
+
grantPostgresAppRolePrivileges,
|
|
379
|
+
inspectCurrentPostgresRole,
|
|
380
|
+
isPostgresUrl,
|
|
381
|
+
openPostgresAdminConnection,
|
|
382
|
+
postgresAppRoleSql,
|
|
383
|
+
postgresDatabaseNameFromUrl,
|
|
384
|
+
postgresUrlUsername
|
|
385
|
+
};
|