diff --git a/src/bin/gqm/gqm.ts b/src/bin/gqm/gqm.ts index 4f908f09..f7dd893b 100644 --- a/src/bin/gqm/gqm.ts +++ b/src/bin/gqm/gqm.ts @@ -20,6 +20,8 @@ import { generateGraphqlApiTypes, generateGraphqlClientTypes } from './codegen'; import { parseFunctionsFile } from './parse-functions'; import { parseKnexfile } from './parse-knexfile'; import { parseModels } from './parse-models'; +import { parsePermissionsConfig } from './parse-permissions-config'; +import { parseScopes } from './parse-scopes'; import { generatePermissionTypes } from './permissions'; import { readLine } from './readline'; import { getSetting, writeToFile } from './settings'; @@ -53,11 +55,14 @@ const readCurrentBranch = (): string | undefined => { if (statSync(gitDir).isFile()) { const pointer = readFileSync(gitDir, 'utf8').trim(); const match = /^gitdir:\s*(.+)$/.exec(pointer); - if (!match) return undefined; + if (!match) { + return undefined; + } gitDir = match[1]; } const head = readFileSync(join(gitDir, 'HEAD'), 'utf8').trim(); const match = /^ref:\s*refs\/heads\/(.+)$/.exec(head); + return match ? match[1] : undefined; } catch { return undefined; @@ -106,7 +111,9 @@ program const models = await parseModels(); const functionsPath = await getSetting('functionsPath'); const parsedFunctions = parseFunctionsFile(functionsPath); - const migrations = await new MigrationGenerator(db, models, parsedFunctions).generate(); + const scopes = await parseScopes(); + const permissionsConfig = await parsePermissionsConfig(); + const migrations = await new MigrationGenerator(db, models, parsedFunctions, scopes, permissionsConfig).generate(); writeToFile(`migrations/${date || getMigrationDate()}_${name}.ts`, migrations); } finally { @@ -125,7 +132,9 @@ program const models = await parseModels(); const functionsPath = await getSetting('functionsPath'); const parsedFunctions = parseFunctionsFile(functionsPath); - const mg = new MigrationGenerator(db, models, parsedFunctions); + const scopes = await parseScopes(); + const permissionsConfig = await parsePermissionsConfig(); + const mg = new MigrationGenerator(db, models, parsedFunctions, scopes, permissionsConfig); await mg.generate(); if (mg.needsMigration) { diff --git a/src/bin/gqm/parse-permissions-config.ts b/src/bin/gqm/parse-permissions-config.ts new file mode 100644 index 00000000..ab926b25 --- /dev/null +++ b/src/bin/gqm/parse-permissions-config.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'fs'; +import { IndentationText, Project } from 'ts-morph'; +import { PermissionsConfig } from '../../permissions/generate'; +import { getSetting } from './settings'; +import { staticEval } from './static-eval'; +import { findDeclarationInFile } from './utils'; + +/** + * Parse the file referenced by `permissionsConfigPath` (default + * `'src/config/permissions/index.ts'`) and return its top-level + * `permissionsConfig` declaration. Returns `undefined` if the file or + * the declaration is missing — e.g., when scope-derivation isn't needed. + */ +export const parsePermissionsConfig = async (): Promise => { + const permissionsConfigPath = await getSetting('permissionsConfigPath'); + + if (!existsSync(permissionsConfigPath)) { + return undefined; + } + + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + }, + }); + const sourceFile = project.addSourceFileAtPath(permissionsConfigPath); + + let declaration; + try { + declaration = findDeclarationInFile(sourceFile, 'permissionsConfig'); + } catch { + return undefined; + } + + return staticEval(declaration, {}) as PermissionsConfig; +}; diff --git a/src/bin/gqm/parse-scopes.ts b/src/bin/gqm/parse-scopes.ts new file mode 100644 index 00000000..c055870a --- /dev/null +++ b/src/bin/gqm/parse-scopes.ts @@ -0,0 +1,36 @@ +import { existsSync } from 'fs'; +import { IndentationText, Project } from 'ts-morph'; +import { ScopesConfig } from '../../permissions/scopes'; +import { getSetting } from './settings'; +import { staticEval } from './static-eval'; +import { findDeclarationInFile } from './utils'; + +/** + * Parse the optional scopes config file declared by `scopesPath` in + * `.gqmrc.json`. Returns `{}` if the file does not exist or does not + * export a `scopes` declaration. + */ +export const parseScopes = async (): Promise => { + const scopesPath = await getSetting('scopesPath'); + + if (!existsSync(scopesPath)) { + return {}; + } + + const project = new Project({ + manipulationSettings: { + indentationText: IndentationText.TwoSpaces, + }, + }); + const sourceFile = project.addSourceFileAtPath(scopesPath); + + let declaration; + try { + declaration = findDeclarationInFile(sourceFile, 'scopes'); + } catch { + // No `scopes` export — treat as empty (file may exist but be unused). + return {}; + } + + return staticEval(declaration, {}) as ScopesConfig; +}; diff --git a/src/bin/gqm/settings.ts b/src/bin/gqm/settings.ts index 7b70e2b9..fc173388 100644 --- a/src/bin/gqm/settings.ts +++ b/src/bin/gqm/settings.ts @@ -46,6 +46,21 @@ const DEFAULTS = { ensureFileExists(path, `export const functions: string[] = [];\n`); }, }, + scopesPath: { + // Optional file declaring permission scope-anchors (see ScopesConfig). + // The migration generator reads this to emit `CREATE MATERIALIZED VIEW` + // for each anchor. If the file does not exist, no scope views are + // managed. + defaultValue: 'src/config/permissions/scopes.ts', + }, + permissionsConfigPath: { + // Optional file declaring `permissionsConfig: PermissionsConfig`. The + // migration generator reads this to derive scope-view SQL from the + // permission tree (one UNION clause per chain reaching the anchor). + // Required only if `scopesPath` is set and any anchor relies on auto- + // derivation (i.e. didn't provide an explicit `sql` body). + defaultValue: 'src/config/permissions/index.ts', + }, generatedFolderPath: { question: 'What is the path for generated stuff?', defaultValue: 'src/generated', diff --git a/src/bin/gqm/static-eval.ts b/src/bin/gqm/static-eval.ts index e07cd86c..3c334894 100644 --- a/src/bin/gqm/static-eval.ts +++ b/src/bin/gqm/static-eval.ts @@ -52,6 +52,17 @@ const VISITOR: Visitor> = { undefined: () => undefined, [SyntaxKind.BindingElement]: (node: BindingElement, context) => context[node.getName()], [SyntaxKind.VariableDeclaration]: (node, context) => staticEval(node.getInitializer(), context), + [SyntaxKind.EnumDeclaration]: (node, context) => { + const result: Dictionary = {}; + for (const member of node.getMembers()) { + const initializer = member.getInitializer(); + result[member.getName()] = initializer + ? staticEval(initializer, context) + : member.getValue(); + } + + return result; + }, [SyntaxKind.ArrayLiteralExpression]: (node, context) => { const values: unknown[] = []; for (const value of node.getElements()) { diff --git a/src/context.ts b/src/context.ts index 95b746a7..e32686fc 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,6 +4,7 @@ import { Knex } from 'knex'; import { Models } from './models/models'; import { MutationHook, QueryHook } from './models/mutation-hook'; import { Permissions } from './permissions/generate'; +import { ScopesConfig } from './permissions/scopes'; import { AliasGenerator } from './resolvers/utils'; import { AnyDateType } from './utils'; @@ -21,6 +22,7 @@ export type Context = { user?: User; models: Models; permissions: Permissions; + scopes?: ScopesConfig; mutationHook?: MutationHook; queryHook?: QueryHook; }; diff --git a/src/migrations/generate.ts b/src/migrations/generate.ts index 83e65de6..532faa4c 100644 --- a/src/migrations/generate.ts +++ b/src/migrations/generate.ts @@ -20,6 +20,14 @@ import { validateCheckConstraint, validateExcludeConstraint, } from '../models/utils'; +import { generatePermissions, PermissionsConfig } from '../permissions/generate'; +import { + ScopesConfig, + deriveScopeSourceTables, + deriveScopeViewSql, + getScopeAnchorIdColumn, + getScopeViewName, +} from '../permissions/scopes'; import { Value } from '../values'; import { ParsedFunction } from './types'; import { @@ -54,6 +62,8 @@ export class MigrationGenerator { knex: Knex, private models: Models, private parsedFunctions?: ParsedFunction[], + private scopes?: ScopesConfig, + private permissionsConfig?: PermissionsConfig, ) { this.knex = knex; this.schema = SchemaInspector(knex); @@ -540,6 +550,8 @@ export class MigrationGenerator { up, ); + await this.handleScopes(up, down); + writer.writeLine(`import { Knex } from 'knex';`); if (this.uuidUsed) { writer.writeLine(`import { randomUUID } from 'crypto';`); @@ -562,6 +574,133 @@ export class MigrationGenerator { return writer.toString(); } + /** + * Emit `CREATE MATERIALIZED VIEW "Scope"` migrations for declared + * scope-anchors. The SQL body comes from `scope.sql` if explicitly + * provided, or is auto-derived from the permissions tree (one UNION + * clause per role-chain that reaches the anchor model from `me`). + * + * Materialized (not plain) because plain views inline the UNION per + * outer row when used in a correlated EXISTS, which is dramatically + * slower than the original (un-shortcut) permission predicate. The + * materialized form gives Postgres a real table with statistics and + * indexes, so the EXISTS collapses to a hash semi-join lookup. + * + * Refresh: AFTER triggers on every source table fire at end of + * transaction (DEFERRABLE INITIALLY DEFERRED) and call REFRESH + * MATERIALIZED VIEW CONCURRENTLY. Within a transaction, an advisory + * xact_lock dedupes so REFRESH runs once even if many rows changed. + * Refresh runs inside the writer's commit, so commit latency grows by + * the cost of refresh — fine for small/medium scopes (ms-scale on tens + * of thousands of rows). Larger scopes can opt out with + * `refreshTriggers: false` and refresh externally. + * + * Source tables come from `scope.sourceTables` if explicitly provided, + * or are auto-derived from the permissions tree (every model traversed + * by any chain reaching the anchor). + * + * Emits only when the materialized view doesn't already exist. To + * regenerate, drop the materialized view first. + */ + private async handleScopes(up: Callbacks, down: Callbacks) { + if (!this.scopes || Object.keys(this.scopes).length === 0) { + return; + } + + const derivedPermissions = this.permissionsConfig ? generatePermissions(this.models, this.permissionsConfig) : undefined; + + const matResult = await this.knex.raw(`SELECT matviewname FROM pg_matviews WHERE schemaname = 'public'`); + const existingMatViews = new Set( + 'rows' in matResult && Array.isArray((matResult as { rows: unknown }).rows) + ? (matResult as { rows: { matviewname: string }[] }).rows.map((r) => r.matviewname) + : [], + ); + + for (const [anchor, config] of Object.entries(this.scopes)) { + const viewName = getScopeViewName(anchor); + const anchorIdCol = getScopeAnchorIdColumn(anchor); + + const sqlBody = config.sql + ? config.sql.trim() + : derivedPermissions + ? deriveScopeViewSql(this.models, derivedPermissions, anchor) + : null; + + if (!sqlBody) { + // No SQL provided and no permissions config to derive from. + continue; + } + + if (existingMatViews.has(viewName)) { + continue; + } + + const refreshTriggers = config.refreshTriggers !== false; + const sourceTables = + config.sourceTables ?? (derivedPermissions ? deriveScopeSourceTables(derivedPermissions, anchor) : []); + + this.needsMigration = true; + up.push(() => { + this.writer.writeLine(`await knex.raw(\`CREATE MATERIALIZED VIEW "${viewName}" AS`); + this.writer.writeLine(sqlBody); + this.writer.writeLine('`);'); + this.writer.blankLine(); + // Unique index serves both REFRESH MATERIALIZED VIEW CONCURRENTLY + // (which requires one) and per-userId lookups via the EXISTS short- + // circuit. + this.writer.writeLine( + `await knex.raw(\`CREATE UNIQUE INDEX "${viewName}_userId_${anchorIdCol}" ON "${viewName}" ("userId", "${anchorIdCol}")\`);`, + ); + // Secondary index on the anchor-id column supports reverse lookups. + this.writer.writeLine( + `await knex.raw(\`CREATE INDEX "${viewName}_${anchorIdCol}" ON "${viewName}" ("${anchorIdCol}")\`);`, + ); + this.writer.blankLine(); + + if (refreshTriggers && sourceTables.length > 0) { + // Refresh function: pg_try_advisory_xact_lock dedupes so REFRESH + // runs at most once per transaction even when the trigger fires + // for many rows. The lock auto-releases at txn end. + const fnName = `refresh_${viewName}`; + this.writer.writeLine(`await knex.raw(\`CREATE OR REPLACE FUNCTION "${fnName}"() RETURNS TRIGGER AS $$`); + this.writer.writeLine(`BEGIN`); + this.writer.writeLine(` IF pg_try_advisory_xact_lock(hashtext('refresh:${viewName}')) THEN`); + this.writer.writeLine(` REFRESH MATERIALIZED VIEW CONCURRENTLY "${viewName}";`); + this.writer.writeLine(` END IF;`); + this.writer.writeLine(` RETURN NULL;`); + this.writer.writeLine(`END;`); + this.writer.writeLine('$$ LANGUAGE plpgsql`);'); + this.writer.blankLine(); + + for (const table of sourceTables) { + const triggerName = `${fnName}_on_${table}`; + // CONSTRAINT TRIGGER + DEFERRABLE INITIALLY DEFERRED fires + // once per row event at end of txn. Multiple fires per txn + // are dedup'd by the advisory lock above. + this.writer.writeLine( + `await knex.raw(\`CREATE CONSTRAINT TRIGGER "${triggerName}" AFTER INSERT OR UPDATE OR DELETE ON "${table}" DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION "${fnName}"()\`);`, + ); + } + this.writer.blankLine(); + } + }); + + down.push(() => { + if (refreshTriggers && sourceTables.length > 0) { + const fnName = `refresh_${viewName}`; + for (const table of sourceTables) { + const triggerName = `${fnName}_on_${table}`; + this.writer.writeLine(`await knex.raw(\`DROP TRIGGER IF EXISTS "${triggerName}" ON "${table}"\`);`); + } + this.writer.writeLine(`await knex.raw(\`DROP FUNCTION IF EXISTS "${fnName}"()\`);`); + this.writer.blankLine(); + } + this.writer.writeLine(`await knex.raw(\`DROP MATERIALIZED VIEW IF EXISTS "${viewName}"\`);`); + this.writer.blankLine(); + }); + } + } + private renameFields(tableName: string, fields: EntityField[], up: Callbacks, down: Callbacks) { if (!fields.length) { return; diff --git a/src/permissions/check.ts b/src/permissions/check.ts index a7e857b8..6df5ab92 100644 --- a/src/permissions/check.ts +++ b/src/permissions/check.ts @@ -5,6 +5,7 @@ import { EntityModel } from '../models/models'; import { get, isRelation, isStoredInDatabase } from '../models/utils'; import { AliasGenerator, getColumnName, hash, ors } from '../resolvers/utils'; import { PermissionAction, PermissionLink, PermissionStack } from './generate'; +import { ScopesConfig, getScopeAnchorIdColumn, getScopeViewName } from './scopes'; export const getRole = (ctx: Pick) => ctx.user?.role ?? 'UNAUTHENTICATED'; @@ -32,7 +33,7 @@ export const getPermissionStack = ( }; export const applyPermissions = ( - ctx: Pick, + ctx: Pick, type: string, tableAlias: string, query: Knex.QueryBuilder, @@ -67,9 +68,18 @@ export const applyPermissions = ( return permissionStack; } + // Dedupe chains whose scope-shortcut emission would be identical. When + // many chains end at the same anchor with no suffix (e.g. multiple paths + // to a scoped model from `me` in a typical role config), each chain + // emits the same `EXISTS(SELECT 1 FROM Scope WHERE userId = $me + // AND … = $outer.id)` — Postgres evaluates each separately, paying the + // same cost N times. Collapsing to one representative chain trims the + // predicate without changing semantics. + const dedupedStack = ctx.scopes ? dedupeStackForScopes(permissionStack, ctx.scopes) : permissionStack; + ors( query, - permissionStack.map( + dedupedStack.map( (links) => (query) => query .whereNull(`${tableAlias}.id`) @@ -88,6 +98,39 @@ export const applyPermissions = ( return permissionStack; }; +const dedupeStackForScopes = (stack: PermissionStack, scopes: ScopesConfig): PermissionStack => { + const seen = new Set(); + const result: PermissionStack = []; + for (const chain of stack) { + let latestAnchorIdx = -1; + if (chain[0]?.me) { + for (let i = chain.length - 1; i > 0; i--) { + if (chain[i].type in scopes) { + latestAnchorIdx = i; + break; + } + } + } + let signature: string; + if (latestAnchorIdx > 0) { + // Scope-shortcut path: signature is the anchor + the suffix (links + // after the anchor, which is what the EXISTS subquery actually joins + // through). Two chains with the same suffix produce identical SQL + // and can be collapsed. + signature = `scope:${chain[latestAnchorIdx].type}:${JSON.stringify(chain.slice(latestAnchorIdx + 1))}`; + } else { + // Non-scope chain: signature is the whole chain. + signature = `chain:${JSON.stringify(chain)}`; + } + if (!seen.has(signature)) { + seen.add(signature); + result.push(chain); + } + } + + return result; +}; + /** * Check whether entity as currently in db can be mutated (update or delete) */ @@ -234,13 +277,39 @@ export const checkCanWrite = async ( }; const permissionLinkQuery = ( - ctx: Pick, + ctx: Pick & { scopes?: ScopesConfig }, subQuery: Knex.QueryBuilder, links: PermissionLink[], id: Knex.RawBinding | Knex.ValueDict, tableAliasForDeleteRoot?: string, ) => { const aliases = new AliasGenerator(); + + // Scope-anchor short-circuit: if any link in the chain (after `me`) is a + // declared scope-anchor, replace the prefix from `me` up to and including + // the anchor with `FROM Scope WHERE userId = $me`. The view's + // pre-computed `(userId, anchorId)` pairs collapse the deep recursive + // delegation chain into a single hash-semi-join-friendly lookup. + // + // We pick the LATEST anchor in the chain (the one closest to the entity + // being permission-checked). This minimises the suffix that has to be + // joined post-scope-view, which is what Postgres evaluates per outer row. + const scopes = ctx.scopes; + if (scopes && links[0]?.me && ctx.user) { + let anchorIdx = -1; + for (let i = links.length - 1; i > 0; i--) { + if (links[i].type in scopes) { + anchorIdx = i; + break; + } + } + if (anchorIdx > 0) { + buildScopedQuery(ctx, subQuery, links, anchorIdx, id, tableAliasForDeleteRoot, aliases); + + return; + } + } + let alias = aliases.getShort(); const { type, me, where } = links[0]; @@ -294,6 +363,78 @@ const permissionLinkQuery = ( subQuery.whereRaw(`"${alias}".id = ?`, id); }; +/** + * Build the SQL body of a permission EXISTS subquery using a scope view + * to short-circuit the prefix [me, ..., anchor]. Continues iterating the + * tail [anchor+1, ..., entity] from the scope view's anchor-id column. + */ +const buildScopedQuery = ( + ctx: Pick, + subQuery: Knex.QueryBuilder, + links: PermissionLink[], + anchorIdx: number, + id: Knex.RawBinding | Knex.ValueDict, + tableAliasForDeleteRoot: string | undefined, + aliases: AliasGenerator, +) => { + const anchorType = links[anchorIdx].type; + const viewName = getScopeViewName(anchorType); + const anchorIdColumn = getScopeAnchorIdColumn(anchorType); + + const scopeAlias = aliases.getShort(); + subQuery.from(`${viewName} as ${scopeAlias}`); + subQuery.where({ [`${scopeAlias}.userId`]: ctx.user!.id }); + + // If the chain ends AT the anchor (we're permission-checking the anchor + // entity itself), no further joins needed. + if (anchorIdx === links.length - 1) { + subQuery.whereRaw(`"${scopeAlias}"."${anchorIdColumn}" = ?`, id); + + return; + } + + // Otherwise, iterate the tail. The first iteration uses the scope view's + // anchor-id column instead of `id`, since the view doesn't have an `id` + // column corresponding to the anchor. + let alias = scopeAlias; + let usingScopeView = true; + for (let i = anchorIdx + 1; i < links.length; i++) { + const { type, foreignKey, reverse, where } = links[i]; + const model = ctx.models.getModel(type, 'entity'); + const subAlias = aliases.getShort(); + const sourceCol = usingScopeView ? anchorIdColumn : reverse ? foreignKey || 'id' : 'id'; + if (reverse) { + subQuery.leftJoin(`${type} as ${subAlias}`, `${alias}.${sourceCol}`, `${subAlias}.id`); + } else { + subQuery.rightJoin(`${type} as ${subAlias}`, `${alias}.${sourceCol}`, `${subAlias}.${foreignKey || 'id'}`); + } + + if (tableAliasForDeleteRoot) { + subQuery.where((query) => + query + .where({ [`${subAlias}.deleted`]: false }) + .orWhere((query) => + query + .whereNotNull(`${subAlias}.deleteRootType`) + .whereNotNull(`${subAlias}.deleteRootId`) + .whereRaw(`??."deleteRootType" = ??."deleteRootType"`, [subAlias, tableAliasForDeleteRoot]) + .whereRaw(`??."deleteRootId" = ??."deleteRootId"`, [subAlias, tableAliasForDeleteRoot]), + ), + ); + } else { + subQuery.where({ [`${subAlias}.deleted`]: false }); + } + + if (where) { + applyWhere(model, subQuery, subAlias, where, aliases); + } + alias = subAlias; + usingScopeView = false; + } + + subQuery.whereRaw(`"${alias}".id = ?`, id); +}; + const applyWhere = (model: EntityModel, query: Knex.QueryBuilder, alias: string, where: any, aliases: AliasGenerator) => { for (const [key, value] of Object.entries(where)) { const relation = model.relationsByName[key]; diff --git a/src/permissions/index.ts b/src/permissions/index.ts index 5e18ade4..e6d4c5d6 100644 --- a/src/permissions/index.ts +++ b/src/permissions/index.ts @@ -2,3 +2,4 @@ export * from './check'; export * from './generate'; +export * from './scopes'; diff --git a/src/permissions/scopes.ts b/src/permissions/scopes.ts new file mode 100644 index 00000000..f70e0395 --- /dev/null +++ b/src/permissions/scopes.ts @@ -0,0 +1,248 @@ +import lowerFirst from 'lodash/lowerFirst'; +import { Models } from '../models/models'; +import { PermissionLink, Permissions } from './generate'; + +/** + * Scope-anchor configuration. Each entry declares a SQL view that maps + * `(userId, anchorEntityId)` pairs — the precomputed access scope for the + * declared anchor model. Permission predicates can short-circuit through + * the view instead of expanding the full permissions tree per request. + * + * Migration generator emits `CREATE OR REPLACE VIEW "Scope" AS ` + * for each declared anchor. SQL body is auto-derived from the permissions + * tree if not explicitly provided — one UNION clause per role-chain that + * reaches the anchor model from `me`. + * + * Example: + * export const scopes: ScopesConfig = { Relation: {} }; + */ +export type ScopesConfig = Record; + +export type ScopeConfig = { + /** + * Optional explicit SQL body of the view (everything after `CREATE VIEW … AS`). + * Must produce two columns named exactly `userId` and `Id`, where + * `` is the lower-camel-cased anchor model name. If omitted, the + * SQL is auto-derived from the permissions tree. + */ + sql?: string; + + /** + * Tables whose row changes invalidate this scope. The migration generator + * emits AFTER triggers on each one to refresh the materialized view at + * end of transaction. + * + * Auto-derived from the permissions tree when `sql` is omitted (every + * model traversed by any chain that reaches the anchor is a source). + * Required when `sql` is set, since we can't statically introspect a + * user-supplied SQL body. + */ + sourceTables?: string[]; + + /** + * Disable trigger emission for this scope (the migration generator still + * creates the materialized view + indexes, but you become responsible for + * refresh — cron, app-level, external job, whatever fits). Default: true. + */ + refreshTriggers?: boolean; +}; + +/** + * Convention: scope view name is `Scope`. + * E.g. anchor `Relation` → view `RelationScope`. + */ +export const getScopeViewName = (anchor: string): string => `${anchor}Scope`; + +/** + * Convention: the anchor's id column in the view is the lower-camel-cased + * anchor name + "Id". E.g. anchor `Relation` → column `relationId`. + * + * Special case: when the anchor IS `User`, the natural name `userId` + * collides with the viewer column (also `userId`), so we use + * `targetUserId` for the anchor side. + */ +export const getScopeAnchorIdColumn = (anchor: string): string => + anchor === 'User' ? 'targetUserId' : `${lowerFirst(anchor)}Id`; + +/** + * Derive the SQL body of `Scope` by walking the permissions tree + * of every role and emitting one UNION clause per chain that ends at the + * anchor model. + * + * Each chain becomes a SELECT that projects `(rootUser.id, lastAlias.id)` + * filtered by the chain's `WHERE` and `deleted=false` predicates. Chains + * that cannot be expressed as a static SQL traversal (true-permissions, + * non-User roots, missing-foreign-keys) are skipped with a comment. + * + * Limitation: cascade-deletion visibility (the OR-branch that admits + * soft-deleted entities sharing a deleteRoot with an outer row) is NOT + * encoded here, since the scope view has no outer correlation context. + * In practice combined with the L1 cascade-decouple patch this is fine + * because the resolver only short-circuits through the scope view when + * the outer is filtered to `deleted = false`. + */ +export const deriveScopeViewSql = (models: Models, permissions: Permissions, anchor: string): string => { + const anchorIdCol = getScopeAnchorIdColumn(anchor); + const lines: string[] = []; + + // Stable role iteration order for reproducible migrations. + const roleNames = Object.keys(permissions).sort(); + for (const role of roleNames) { + const rolePerms = permissions[role]; + if (typeof rolePerms !== 'object') { + continue; + } + const anchorPerms = rolePerms[anchor]; + if (!anchorPerms) { + continue; + } + const stack = anchorPerms.READ; + if (typeof stack !== 'object') { + continue; + } + + for (const chain of stack) { + const sql = chainToSelect(models, chain, anchorIdCol); + if (sql) { + lines.push(`-- role=${role}`); + lines.push(sql); + lines.push('UNION'); + } + } + } + + if (lines.length === 0) { + // No role grants access to the anchor — emit a placeholder view so + // queries against `Scope` still parse. + return `SELECT NULL::uuid AS "userId", NULL::uuid AS "${anchorIdCol}" WHERE false`; + } + + // Drop the trailing UNION + lines.pop(); + + return lines.join('\n'); +}; + +/** + * Walk the permissions tree and return the set of tables whose row changes + * could affect membership in `Scope`. That's `chain[0].type` (the + * `me` user table — role/deletion changes shift membership) plus every + * intermediate type joined through to reach the anchor, for every chain + * across every role. Used by the migration generator to know which tables + * to attach refresh triggers to. + */ +export const deriveScopeSourceTables = (permissions: Permissions, anchor: string): string[] => { + const tables = new Set(); + for (const role of Object.keys(permissions)) { + const rolePerms = permissions[role]; + if (typeof rolePerms !== 'object') { + continue; + } + const anchorPerms = rolePerms[anchor]; + if (!anchorPerms) { + continue; + } + const stack = anchorPerms.READ; + if (typeof stack !== 'object') { + continue; + } + for (const chain of stack) { + if (chain.length < 1 || !chain[0].me) { + continue; + } + for (const link of chain) { + tables.add(link.type); + } + } + } + + return [...tables].sort(); +}; + +const chainToSelect = (models: Models, chain: PermissionLink[], anchorIdCol: string): string | null => { + if (chain.length < 1 || !chain[0].me) { + // Only chains anchored at `me` (User) are expressible as a (userId, anchorId) + // tuple. Other chain shapes (e.g., role-WHERE-only) imply universal access + // and don't fit the per-user scope model — skip. + return null; + } + const aliasFor = (i: number) => `a${i}`; + + // FROM the user (chain[0]) + const lines: string[] = []; + const rootAlias = aliasFor(0); + const lastAlias = aliasFor(chain.length - 1); + lines.push(`SELECT ${rootAlias}.id AS "userId", ${lastAlias}.id AS "${anchorIdCol}"`); + lines.push(` FROM "${chain[0].type}" AS ${rootAlias}`); + + // JOIN each subsequent link + for (let i = 1; i < chain.length; i++) { + const link = chain[i]; + const prevAlias = aliasFor(i - 1); + const alias = aliasFor(i); + const fk = link.foreignKey || 'id'; + const linkModel = models.getModel(link.type, 'entity'); + const onClause = link.reverse ? `${prevAlias}."${fk}" = ${alias}.id` : `${prevAlias}.id = ${alias}."${fk}"`; + const conditions: string[] = [onClause]; + if (linkModel.deletable) { + conditions.push(`${alias}.deleted = false`); + } + if (link.where) { + const extra = whereToSql(linkModel, alias, link.where); + if (extra) { + conditions.push(extra); + } + } + lines.push(` JOIN "${link.type}" AS ${alias} ON ${conditions.join(' AND ')}`); + } + + // Apply the root user's WHERE (e.g., role IN [...]) as a final filter. + const rootWhere = chain[0].where ? whereToSql(models.getModel(chain[0].type, 'entity'), rootAlias, chain[0].where) : null; + if (rootWhere) { + lines.push(` WHERE ${rootWhere}`); + } + + return lines.join('\n'); +}; + +/** + * Translate a permission `WHERE` object into a SQL fragment. Only handles + * the shapes that scope-view derivation needs: scalar equality, scalar + * lists, and nested simple objects (treated as joined relation-WHERE, + * which is rare for scope chains). + */ +const whereToSql = ( + model: { fields: { name: string; kind?: string }[] }, + alias: string, + where: Record, +): string | null => { + const parts: string[] = []; + for (const [key, value] of Object.entries(where)) { + const field = model.fields.find((f) => f.name === key); + if (!field || (field.kind !== undefined && field.kind !== 'enum' && field.kind !== 'primitive')) { + // Skip relation-level WHEREs in scope derivation (would require + // additional joins; out of scope for the v1 deriver). + continue; + } + if (Array.isArray(value)) { + parts.push(`${alias}."${key}" IN (${(value as unknown[]).map((v) => quoteSqlLiteral(v)).join(', ')})`); + } else if (value === null) { + parts.push(`${alias}."${key}" IS NULL`); + } else { + parts.push(`${alias}."${key}" = ${quoteSqlLiteral(value)}`); + } + } + + return parts.length ? parts.join(' AND ') : null; +}; + +const quoteSqlLiteral = (v: unknown): string => { + if (v === null || v === undefined) { + return 'NULL'; + } + if (typeof v === 'number' || typeof v === 'boolean') { + return String(v); + } + + return `'${String(v).replace(/'/g, "''")}'`; +};