@everystack/server 0.4.36 → 0.4.38
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 +33 -0
- package/package.json +10 -2
- package/src/cache-control.ts +36 -0
- package/src/index.ts +7 -1
- package/src/kvs-metrics.ts +153 -0
- package/src/kvs.ts +326 -29
- package/src/plugin.ts +18 -1
- package/src/provision.ts +97 -1
- package/src/purge-graph.ts +91 -0
- package/src/purge-log.ts +63 -0
- package/src/purge.ts +337 -0
- package/src/role-chain.ts +26 -0
package/src/plugin.ts
CHANGED
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
} from './index';
|
|
38
38
|
import { splitSqlStatements, stripSqlComments } from './sql-lex';
|
|
39
39
|
import { DEFAULT_STRIP_QUERY_PARAMS, stripQueryParams } from './strip-query-params';
|
|
40
|
+
import { htmlCacheControl } from './cache-control';
|
|
40
41
|
|
|
41
42
|
// --- Plugin Types ---
|
|
42
43
|
|
|
@@ -172,6 +173,17 @@ export function createPluginLambdaHandler(
|
|
|
172
173
|
contributions.push(await plugin(ctx));
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
// An OWN `__proto__` key (parsed JSON, a computed key) cannot be an action: Object.assign would
|
|
177
|
+
// run the `__proto__` setter on the map instead of adding it, and the spread below would define
|
|
178
|
+
// it as an own key. Refuse it at init rather than drop or dispatch it.
|
|
179
|
+
const refuseProtoKey = (source: string, actions: Record<string, ActionHandler> | undefined) => {
|
|
180
|
+
if (actions && Object.hasOwn(actions, '__proto__')) {
|
|
181
|
+
throw new Error(`[everystack/server] ${source} action name "__proto__" is not allowed`);
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
for (const contribution of contributions) refuseProtoKey('plugin', contribution.actions);
|
|
185
|
+
refuseProtoKey('app', options.actions);
|
|
186
|
+
|
|
175
187
|
// Collect actions (app-level takes precedence on collision)
|
|
176
188
|
const pluginActions: Record<string, ActionHandler> = {};
|
|
177
189
|
for (const contribution of contributions) {
|
|
@@ -305,7 +317,7 @@ export function createPluginLambdaHandler(
|
|
|
305
317
|
// GETs are version-keyed and can cache long at the edge.
|
|
306
318
|
const isHtml = String(result.headers['content-type'] || '').includes('text/html');
|
|
307
319
|
result.headers['cache-control'] = isHtml
|
|
308
|
-
? (options.cache
|
|
320
|
+
? htmlCacheControl(path, options.cache)
|
|
309
321
|
: (options.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5');
|
|
310
322
|
}
|
|
311
323
|
} else {
|
|
@@ -1092,6 +1104,11 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
1092
1104
|
(payload || {}) as Parameters<typeof runProvision>[0],
|
|
1093
1105
|
{
|
|
1094
1106
|
execute: async (statement) => opsDb.execute(sql.raw(statement)),
|
|
1107
|
+
// Reads for the lifecycle role's existence and member report.
|
|
1108
|
+
query: async (statement) => {
|
|
1109
|
+
const res: any = await opsDb.execute(sql.raw(statement));
|
|
1110
|
+
return Array.isArray(res) ? res : (res?.rows ?? []);
|
|
1111
|
+
},
|
|
1095
1112
|
connection,
|
|
1096
1113
|
// Additive-only: an already-live login role keeps its password (never reset on re-provision).
|
|
1097
1114
|
roleHasLogin: async (role) => {
|
package/src/provision.ts
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* without a database; plugin.ts wires the real deps.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL, generateReaderRoleSQL } from './role-chain';
|
|
23
|
+
import { generateRoleChainSQL, generateAdminRoleSQL, generateSearchPathSQL, generateRotationPairSQL, generateReaderRoleSQL, generateLifecycleRoleSQL, LIFECYCLE_ROLE } from './role-chain';
|
|
24
24
|
import {
|
|
25
25
|
pgVersionRefusal,
|
|
26
26
|
ownerFactsSql,
|
|
@@ -107,6 +107,12 @@ export interface ProvisionPayload {
|
|
|
107
107
|
searchPath?: string[];
|
|
108
108
|
/** Create A/B rotation pairs for credential rotation without downtime. */
|
|
109
109
|
rotationPairs?: boolean;
|
|
110
|
+
/**
|
|
111
|
+
* The Models declare a `lifecycle: true` model, so provision the reserved role it compiles to
|
|
112
|
+
* (everystack_lifecycle): NOLOGIN NOINHERIT NOBYPASSRLS, no grants, no members. Derived from the
|
|
113
|
+
* Models barrel by the CLI. Absent or false (an older CLI) = no role is created, as before.
|
|
114
|
+
*/
|
|
115
|
+
lifecycleRole?: boolean;
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
/**
|
|
@@ -148,6 +154,20 @@ export interface ProvisionDeps {
|
|
|
148
154
|
* not-existing (the role is being created now, so its password IS set).
|
|
149
155
|
*/
|
|
150
156
|
roleHasLogin?: (role: string) => Promise<boolean>;
|
|
157
|
+
/**
|
|
158
|
+
* The lifecycle role's name. A TEST seam only: the role is cluster-level and a dev cluster may
|
|
159
|
+
* already hold the real one. No caller sets it; absent = everystack_lifecycle.
|
|
160
|
+
*/
|
|
161
|
+
lifecycleRoleName?: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One direct member of the lifecycle role, with the options its membership carries. */
|
|
165
|
+
export interface LifecycleRoleMember {
|
|
166
|
+
role: string;
|
|
167
|
+
admin: boolean;
|
|
168
|
+
inherit: boolean;
|
|
169
|
+
/** Can SET ROLE to it (always true before PostgreSQL 16, which has no per-grant SET option). */
|
|
170
|
+
set: boolean;
|
|
151
171
|
}
|
|
152
172
|
|
|
153
173
|
export interface ProvisionResult {
|
|
@@ -202,6 +222,23 @@ export interface ProvisionResult {
|
|
|
202
222
|
* says nothing about it. Same non-fatal contract as ENACT: never throws, never aborts.
|
|
203
223
|
*/
|
|
204
224
|
publicSchemaPosture?: PublicSchemaPosture;
|
|
225
|
+
/**
|
|
226
|
+
* The reserved lifecycle role. Absent when the payload did not ask for it. `created` = false means
|
|
227
|
+
* it existed and its attributes were re-asserted. `members` are its direct members, reported and
|
|
228
|
+
* never revoked: a member can SET ROLE to it, and that passes the lifecycle guard. Null when this
|
|
229
|
+
* caller cannot read (no `query` dep).
|
|
230
|
+
*/
|
|
231
|
+
lifecycleRole?: {
|
|
232
|
+
role: string;
|
|
233
|
+
created: boolean | null;
|
|
234
|
+
members: LifecycleRoleMember[] | null;
|
|
235
|
+
/**
|
|
236
|
+
* Elevated attributes an existing role carries that provision does not strip (CREATEROLE,
|
|
237
|
+
* CREATEDB, REPLICATION), for the CLI to name. SUPERUSER is never here: it is refused.
|
|
238
|
+
* Null when this caller cannot read.
|
|
239
|
+
*/
|
|
240
|
+
attributes: string[] | null;
|
|
241
|
+
};
|
|
205
242
|
error?: string;
|
|
206
243
|
}
|
|
207
244
|
|
|
@@ -238,8 +275,11 @@ export async function runProvision(
|
|
|
238
275
|
let roleSql: string;
|
|
239
276
|
let adminSql: string | null = null;
|
|
240
277
|
let readerSql: string | null = null;
|
|
278
|
+
let lifecycleSql: string | null = null;
|
|
279
|
+
const lifecycleRole = deps.lifecycleRoleName ?? LIFECYCLE_ROLE;
|
|
241
280
|
try {
|
|
242
281
|
roleSql = generateRoleChainSQL({ loginRole, appRoles });
|
|
282
|
+
if (payload.lifecycleRole === true) lifecycleSql = generateLifecycleRoleSQL(lifecycleRole);
|
|
243
283
|
if (adminPassword) adminSql = generateAdminRoleSQL({ adminRole });
|
|
244
284
|
if (readerPassword) readerSql = generateReaderRoleSQL({ readerRole });
|
|
245
285
|
} catch (err: any) {
|
|
@@ -256,6 +296,30 @@ export async function runProvision(
|
|
|
256
296
|
const adminExisted = !!adminPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(admin));
|
|
257
297
|
const readerExisted = !!readerPassword && !!deps.roleHasLogin && (await deps.roleHasLogin(reader));
|
|
258
298
|
|
|
299
|
+
// The lifecycle role's live state, read BEFORE any DDL so a refusal leaves nothing half-done.
|
|
300
|
+
let lifecycleLive: { existed: boolean; attributes: string[] } | null = null;
|
|
301
|
+
if (lifecycleSql && deps.query) {
|
|
302
|
+
const [row] = await deps.query(
|
|
303
|
+
`SELECT rolsuper, rolcreaterole, rolcreatedb, rolreplication FROM pg_roles WHERE rolname = '${lifecycleRole}'`,
|
|
304
|
+
);
|
|
305
|
+
const on = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
306
|
+
if (row && on(row.rolsuper)) {
|
|
307
|
+
// Not fixed by the ALTER: PostgreSQL 16 refuses NOSUPERUSER from a non-superuser, and a
|
|
308
|
+
// superuser owning SECURITY DEFINER functions makes every one of them run as a superuser.
|
|
309
|
+
throw new Error(
|
|
310
|
+
`the lifecycle role '${lifecycleRole}' exists with SUPERUSER — refused before any change. ` +
|
|
311
|
+
`As a superuser run: ALTER ROLE ${lifecycleRole} NOSUPERUSER; (or drop the role and re-run db:provision).`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
lifecycleLive = {
|
|
315
|
+
existed: !!row,
|
|
316
|
+
attributes: row
|
|
317
|
+
? ([['CREATEROLE', row.rolcreaterole], ['CREATEDB', row.rolcreatedb], ['REPLICATION', row.rolreplication]] as const)
|
|
318
|
+
.filter(([, v]) => on(v)).map(([name]) => name)
|
|
319
|
+
: [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
259
323
|
await deps.execute(roleSql);
|
|
260
324
|
|
|
261
325
|
const result: ProvisionResult = {
|
|
@@ -265,6 +329,18 @@ export async function runProvision(
|
|
|
265
329
|
passwordSet: false,
|
|
266
330
|
};
|
|
267
331
|
|
|
332
|
+
// The reserved lifecycle role — a NOLOGIN role with no password, so it sits with the role chain,
|
|
333
|
+
// ahead of every password and well clear of the password-to-secret window.
|
|
334
|
+
if (lifecycleSql) {
|
|
335
|
+
await deps.execute(lifecycleSql);
|
|
336
|
+
result.lifecycleRole = {
|
|
337
|
+
role: lifecycleRole,
|
|
338
|
+
created: lifecycleLive ? !lifecycleLive.existed : null,
|
|
339
|
+
members: deps.query ? await lifecycleRoleMembers(lifecycleRole, deps.query) : null,
|
|
340
|
+
attributes: lifecycleLive ? lifecycleLive.attributes : null,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
268
344
|
// The role-default search_path (search-path-ownership): set it on the login role now that it
|
|
269
345
|
// exists. The right layer — a clean connection secret, bare refs resolved for every direct
|
|
270
346
|
// connection, and it persists through the API's SET ROLE. No-op for an all-public app.
|
|
@@ -387,6 +463,26 @@ export async function runProvision(
|
|
|
387
463
|
return result;
|
|
388
464
|
}
|
|
389
465
|
|
|
466
|
+
/**
|
|
467
|
+
* The direct members of the lifecycle role, from pg_auth_members. `to_jsonb(m)` reads the
|
|
468
|
+
* PostgreSQL 16 per-grant options without naming columns an older server lacks; before 16 every
|
|
469
|
+
* membership could SET ROLE and INHERIT followed the member, so a missing option reads that way.
|
|
470
|
+
*/
|
|
471
|
+
async function lifecycleRoleMembers(role: string, query: (sql: string) => Promise<any[]>): Promise<LifecycleRoleMember[]> {
|
|
472
|
+
const rows = await query(
|
|
473
|
+
`SELECT r.rolname AS member, m.admin_option AS admin,
|
|
474
|
+
COALESCE((to_jsonb(m) ->> 'inherit_option')::boolean, r.rolinherit) AS inherit,
|
|
475
|
+
COALESCE((to_jsonb(m) ->> 'set_option')::boolean, true) AS set
|
|
476
|
+
FROM pg_auth_members m
|
|
477
|
+
JOIN pg_roles g ON g.oid = m.roleid
|
|
478
|
+
JOIN pg_roles r ON r.oid = m.member
|
|
479
|
+
WHERE g.rolname = '${role}'
|
|
480
|
+
ORDER BY r.rolname`,
|
|
481
|
+
);
|
|
482
|
+
const truthy = (v: unknown): boolean => v === true || v === 't' || v === 'true';
|
|
483
|
+
return rows.map((r) => ({ role: String(r.member), admin: truthy(r.admin), inherit: truthy(r.inherit), set: truthy(r.set) }));
|
|
484
|
+
}
|
|
485
|
+
|
|
390
486
|
/**
|
|
391
487
|
* Converge declared ownership — ENACT (Gap 1) then ADOPT (Gap 2) — as a step SEPARATE from and
|
|
392
488
|
* AFTER credential provisioning + the secret-store write (see runProvision's closing note). By the
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Refuse physical removal when live foreign keys exceed the declared lifecycle graph. */
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
import type { ModelDescriptor, Relation } from '@everystack/model';
|
|
4
|
+
import type { SessionTx } from './db-session';
|
|
5
|
+
|
|
6
|
+
interface ForeignKey {
|
|
7
|
+
parent_schema: string;
|
|
8
|
+
parent_table: string;
|
|
9
|
+
child_schema: string;
|
|
10
|
+
child_table: string;
|
|
11
|
+
parent_columns: string[];
|
|
12
|
+
child_columns: string[];
|
|
13
|
+
action: string;
|
|
14
|
+
child_lifecycle: boolean;
|
|
15
|
+
}
|
|
16
|
+
const label = (m: ModelDescriptor): string => `${m.schema}.${m.table}`;
|
|
17
|
+
const parentOf = (f: ForeignKey): string => `${f.parent_schema}.${f.parent_table}`;
|
|
18
|
+
const childOf = (f: ForeignKey): string => `${f.child_schema}.${f.child_table}`;
|
|
19
|
+
const snake = (name: string): string => name.replace(/[A-Z]/g, c => '_' + c.toLowerCase());
|
|
20
|
+
const expectedAction = (rel: Relation, liveLifecycle: boolean): string => rel.dependent === 'nullify' ? 'n'
|
|
21
|
+
: rel.dependent === 'cascade' && !liveLifecycle && !rel.target().lifecycle ? 'c' : 'r';
|
|
22
|
+
|
|
23
|
+
function matches(fk: ForeignKey, parent: ModelDescriptor, rel: Relation): boolean {
|
|
24
|
+
return rel.kind === 'hasMany' && Boolean(rel.dependent)
|
|
25
|
+
&& parentOf(fk) === label(parent) && childOf(fk) === label(rel.target())
|
|
26
|
+
&& fk.child_columns.length === 1 && fk.child_columns[0] === snake(rel.column)
|
|
27
|
+
&& fk.parent_columns.length === 1 && fk.parent_columns[0] === snake(rel.references ?? parent.primaryKey[0]);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Catalog read only. A refusal has no hook, DELETE or receipt, even for eligible rows. */
|
|
31
|
+
export async function unsupportedPurgeGraphs(exec: SessionTx, models: ModelDescriptor[]): Promise<Map<string, string>> {
|
|
32
|
+
const raw: unknown = await exec.execute(sql`
|
|
33
|
+
SELECT pn.nspname AS parent_schema, p.relname AS parent_table,
|
|
34
|
+
cn.nspname AS child_schema, c.relname AS child_table, f.confdeltype AS action,
|
|
35
|
+
ARRAY(SELECT a.attname FROM unnest(f.confkey) WITH ORDINALITY k(num,pos)
|
|
36
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid=p.oid AND a.attnum=k.num ORDER BY k.pos) AS parent_columns,
|
|
37
|
+
ARRAY(SELECT a.attname FROM unnest(f.conkey) WITH ORDINALITY k(num,pos)
|
|
38
|
+
JOIN pg_catalog.pg_attribute a ON a.attrelid=c.oid AND a.attnum=k.num ORDER BY k.pos) AS child_columns,
|
|
39
|
+
EXISTS (SELECT 1 FROM pg_catalog.pg_trigger t
|
|
40
|
+
JOIN pg_catalog.pg_proc fn ON fn.oid=t.tgfoid
|
|
41
|
+
JOIN pg_catalog.pg_roles r ON r.oid=fn.proowner
|
|
42
|
+
WHERE t.tgrelid=c.oid AND NOT t.tgisinternal
|
|
43
|
+
AND (t.tgtype & 2)=2 AND (t.tgtype & 8)=8
|
|
44
|
+
AND r.rolname='everystack_lifecycle') AS child_lifecycle
|
|
45
|
+
FROM pg_catalog.pg_constraint f
|
|
46
|
+
JOIN pg_catalog.pg_class p ON p.oid=f.confrelid
|
|
47
|
+
JOIN pg_catalog.pg_namespace pn ON pn.oid=p.relnamespace
|
|
48
|
+
JOIN pg_catalog.pg_class c ON c.oid=f.conrelid
|
|
49
|
+
JOIN pg_catalog.pg_namespace cn ON cn.oid=c.relnamespace
|
|
50
|
+
WHERE f.contype='f'`);
|
|
51
|
+
const fks = (Array.isArray(raw) ? raw : (raw as { rows: ForeignKey[] }).rows) as ForeignKey[];
|
|
52
|
+
const byParent = new Map<string, ForeignKey[]>();
|
|
53
|
+
for (const fk of fks) byParent.set(parentOf(fk), [...(byParent.get(parentOf(fk)) ?? []), fk]);
|
|
54
|
+
const declared = new Map(models.map(m => [label(m), m]));
|
|
55
|
+
const badActions = new Map<string, string>();
|
|
56
|
+
for (const model of models) {
|
|
57
|
+
const root = label(model);
|
|
58
|
+
for (const rel of Object.values(model.relations)) {
|
|
59
|
+
if (rel.kind !== 'hasMany' || !rel.dependent) continue;
|
|
60
|
+
const edges = (byParent.get(root) ?? []).filter(fk => matches(fk, model, rel));
|
|
61
|
+
if (!edges.length || edges.some(fk => fk.action !== expectedAction(rel, fk.child_lifecycle))) {
|
|
62
|
+
badActions.set(root, `unsupported graph: ${label(rel.target())} foreign key disagrees with declared ${rel.dependent}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const refused = new Map<string, string>();
|
|
67
|
+
for (const model of models.filter(m => m.lifecycle)) {
|
|
68
|
+
const root = label(model);
|
|
69
|
+
const visited = new Set<string>();
|
|
70
|
+
const walk = (parent: string): string | undefined => {
|
|
71
|
+
const mismatch = badActions.get(parent);
|
|
72
|
+
if (mismatch) return mismatch;
|
|
73
|
+
if (visited.has(parent)) return;
|
|
74
|
+
visited.add(parent);
|
|
75
|
+
for (const fk of byParent.get(parent) ?? []) {
|
|
76
|
+
const child = childOf(fk);
|
|
77
|
+
const descriptor = declared.get(parent);
|
|
78
|
+
const rel = descriptor && Object.values(descriptor.relations).find(r => matches(fk, descriptor, r));
|
|
79
|
+
if (fk.child_lifecycle && (!rel || fk.action !== expectedAction(rel, fk.child_lifecycle))) {
|
|
80
|
+
return `unsupported graph: path ${root} reaches undeclared lifecycle edge ${parent} -> ${child}`;
|
|
81
|
+
}
|
|
82
|
+
if (!['c', 'n', 'd'].includes(fk.action)) continue;
|
|
83
|
+
const deeper = walk(child);
|
|
84
|
+
if (deeper) return deeper;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const reason = walk(root);
|
|
88
|
+
if (reason) refused.set(root, reason);
|
|
89
|
+
}
|
|
90
|
+
return refused;
|
|
91
|
+
}
|
package/src/purge-log.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The purge ledger contains keys and outcomes, never row payloads. Run this ensure
|
|
3
|
+
* as the table owner before purging. Only the lifecycle role may append receipts.
|
|
4
|
+
* Mutation guards also refuse a writer with direct privileges. The trusted owner
|
|
5
|
+
* can still disable these triggers; this is not a boundary against that owner.
|
|
6
|
+
*/
|
|
7
|
+
export const ENSURE_PURGE_LOG_SQL: string[] = [
|
|
8
|
+
'CREATE SCHEMA IF NOT EXISTS everystack',
|
|
9
|
+
`CREATE TABLE IF NOT EXISTS everystack.purge_log (
|
|
10
|
+
run_id uuid NOT NULL,
|
|
11
|
+
"table" text NOT NULL,
|
|
12
|
+
"key" text NOT NULL,
|
|
13
|
+
deleted_batch uuid,
|
|
14
|
+
deleted_at timestamptz NOT NULL,
|
|
15
|
+
purged_at timestamptz NOT NULL,
|
|
16
|
+
actor text,
|
|
17
|
+
outcome text NOT NULL CHECK (outcome IN ('purged', 'skipped', 'failed')),
|
|
18
|
+
hook text NOT NULL CHECK (hook IN ('ran', 'none', 'failed')),
|
|
19
|
+
detail text
|
|
20
|
+
)`,
|
|
21
|
+
'CREATE INDEX IF NOT EXISTS purge_log_key_time ON everystack.purge_log ("table", "key", purged_at)',
|
|
22
|
+
// Typed key comparisons still need an index that bounds the recent receipt window.
|
|
23
|
+
'CREATE INDEX IF NOT EXISTS purge_log_table_time ON everystack.purge_log ("table", purged_at)',
|
|
24
|
+
`CREATE OR REPLACE FUNCTION everystack.purge_log_refuse_mutation() RETURNS trigger
|
|
25
|
+
LANGUAGE plpgsql SET search_path = pg_catalog AS $guard$
|
|
26
|
+
BEGIN
|
|
27
|
+
RAISE EXCEPTION 'everystack.purge_log is append-only' USING ERRCODE = '42501';
|
|
28
|
+
END
|
|
29
|
+
$guard$`,
|
|
30
|
+
`CREATE OR REPLACE TRIGGER purge_log_no_mutation BEFORE UPDATE OR DELETE ON everystack.purge_log
|
|
31
|
+
FOR EACH ROW EXECUTE FUNCTION everystack.purge_log_refuse_mutation()`,
|
|
32
|
+
`CREATE OR REPLACE TRIGGER purge_log_no_truncate BEFORE TRUNCATE ON everystack.purge_log
|
|
33
|
+
FOR EACH STATEMENT EXECUTE FUNCTION everystack.purge_log_refuse_mutation()`,
|
|
34
|
+
// PUBLIC function defaults would become reachable after the new schema USAGE grant.
|
|
35
|
+
'REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA everystack FROM PUBLIC',
|
|
36
|
+
'REVOKE ALL PRIVILEGES ON TABLE everystack.purge_log FROM PUBLIC',
|
|
37
|
+
`DO $acl$
|
|
38
|
+
DECLARE r record;
|
|
39
|
+
BEGIN
|
|
40
|
+
FOR r IN
|
|
41
|
+
SELECT DISTINCT roles.rolname
|
|
42
|
+
FROM (
|
|
43
|
+
SELECT acl.grantee
|
|
44
|
+
FROM pg_catalog.pg_class c CROSS JOIN LATERAL pg_catalog.aclexplode(c.relacl) acl
|
|
45
|
+
WHERE c.oid = 'everystack.purge_log'::regclass
|
|
46
|
+
UNION
|
|
47
|
+
SELECT acl.grantee
|
|
48
|
+
FROM pg_catalog.pg_attribute a CROSS JOIN LATERAL pg_catalog.aclexplode(a.attacl) acl
|
|
49
|
+
WHERE a.attrelid = 'everystack.purge_log'::regclass AND a.attnum > 0 AND NOT a.attisdropped
|
|
50
|
+
) grants JOIN pg_catalog.pg_roles roles ON roles.oid = grants.grantee
|
|
51
|
+
WHERE grants.grantee <> 0 AND roles.oid <> (
|
|
52
|
+
SELECT c.relowner FROM pg_catalog.pg_class c WHERE c.oid = 'everystack.purge_log'::regclass
|
|
53
|
+
)
|
|
54
|
+
LOOP
|
|
55
|
+
EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES ON TABLE everystack.purge_log FROM %I', r.rolname);
|
|
56
|
+
EXECUTE pg_catalog.format('REVOKE ALL PRIVILEGES (run_id, "table", "key", deleted_batch, deleted_at, purged_at, actor, outcome, hook, detail) ON TABLE everystack.purge_log FROM %I', r.rolname);
|
|
57
|
+
END LOOP;
|
|
58
|
+
END
|
|
59
|
+
$acl$`,
|
|
60
|
+
'REVOKE ALL PRIVILEGES (run_id, "table", "key", deleted_batch, deleted_at, purged_at, actor, outcome, hook, detail) ON TABLE everystack.purge_log FROM PUBLIC',
|
|
61
|
+
'GRANT USAGE ON SCHEMA everystack TO everystack_lifecycle',
|
|
62
|
+
'GRANT INSERT ON TABLE everystack.purge_log TO everystack_lifecycle',
|
|
63
|
+
];
|