@fonderie/permissions 5.1.6 → 5.2.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/brain/signatures.md +1 -0
- package/dist/index.cjs +22 -9
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.js +22 -9
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +21 -8
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.js +21 -8
- package/dist/middlewares/index.js.map +1 -1
- package/package.json +1 -1
package/brain/signatures.md
CHANGED
|
@@ -58,6 +58,7 @@ new PermissionsModule(store: IStoreAdapter, config?: IPermissionsConfig): Permis
|
|
|
58
58
|
|
|
59
59
|
new PermissionsEngine(store: IStoreAdapter, config?: IPermissionsConfig): PermissionsEngine
|
|
60
60
|
.catalog: readonly IPermissionCatalogEntry[] | null
|
|
61
|
+
.systemGrants: Record<string, Record<string, Operation[]>>
|
|
61
62
|
.isKnown(permissionKey: string): boolean
|
|
62
63
|
.operationsOf(permissionKey: string): Operation[]
|
|
63
64
|
.effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null>
|
package/dist/index.cjs
CHANGED
|
@@ -222,10 +222,10 @@ var PermissionsEngine = class {
|
|
|
222
222
|
this.store = store;
|
|
223
223
|
this.superRole = config.superRole ?? "owner";
|
|
224
224
|
this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
|
|
225
|
-
this.
|
|
225
|
+
this.grantsByRole = config.systemGrants ?? {};
|
|
226
226
|
if (this.catalog) {
|
|
227
227
|
const known = new Set(this.catalog.map((e) => e.key));
|
|
228
|
-
for (const [role, grants] of Object.entries(this.
|
|
228
|
+
for (const [role, grants] of Object.entries(this.grantsByRole)) {
|
|
229
229
|
for (const key of Object.keys(grants)) {
|
|
230
230
|
if (!known.has(key)) {
|
|
231
231
|
throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
|
|
@@ -236,9 +236,22 @@ var PermissionsEngine = class {
|
|
|
236
236
|
}
|
|
237
237
|
store;
|
|
238
238
|
superRole;
|
|
239
|
-
|
|
239
|
+
grantsByRole;
|
|
240
240
|
/** The app's declared resources, or null when it declared none. */
|
|
241
241
|
catalog;
|
|
242
|
+
/**
|
|
243
|
+
* What each SYSTEM role is granted by config (`systemGrants`) — rights that
|
|
244
|
+
* are never stored as rows, so a role editor reads them here to show them.
|
|
245
|
+
* A copy: changing it changes nothing.
|
|
246
|
+
*/
|
|
247
|
+
get systemGrants() {
|
|
248
|
+
return Object.fromEntries(
|
|
249
|
+
Object.entries(this.grantsByRole).map(([role, grants]) => [
|
|
250
|
+
role,
|
|
251
|
+
Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
|
|
252
|
+
])
|
|
253
|
+
);
|
|
254
|
+
}
|
|
242
255
|
/** Is this key in the catalog? Always true when the app declared no catalog. */
|
|
243
256
|
isKnown(permissionKey) {
|
|
244
257
|
return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
|
|
@@ -248,9 +261,9 @@ var PermissionsEngine = class {
|
|
|
248
261
|
return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
|
|
249
262
|
}
|
|
250
263
|
async allows(userId, workspaceId, permissionKey, operation) {
|
|
251
|
-
if (Object.keys(this.
|
|
264
|
+
if (Object.keys(this.grantsByRole).length > 0) {
|
|
252
265
|
const roles = await listSystemRoleNames(userId, workspaceId, this.store);
|
|
253
|
-
if (roles.some((r) => this.
|
|
266
|
+
if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
254
267
|
}
|
|
255
268
|
return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
|
|
256
269
|
}
|
|
@@ -272,11 +285,11 @@ var PermissionsEngine = class {
|
|
|
272
285
|
return { isSuper: true, permissions };
|
|
273
286
|
}
|
|
274
287
|
const [systemRoles, stored] = await Promise.all([
|
|
275
|
-
Object.keys(this.
|
|
288
|
+
Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
|
|
276
289
|
listGrantedPermissions(userId, workspaceId, this.store)
|
|
277
290
|
]);
|
|
278
291
|
for (const role of systemRoles) {
|
|
279
|
-
for (const [key, ops] of Object.entries(this.
|
|
292
|
+
for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
|
|
280
293
|
for (const op of ops) entry(key)[op] = true;
|
|
281
294
|
}
|
|
282
295
|
}
|
|
@@ -299,7 +312,7 @@ var PermissionsEngine = class {
|
|
|
299
312
|
const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
|
|
300
313
|
if (!a.member) return false;
|
|
301
314
|
if (a.isSuper) return true;
|
|
302
|
-
if (a.systemRoles.some((r) => this.
|
|
315
|
+
if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
303
316
|
return a.granted;
|
|
304
317
|
}
|
|
305
318
|
async assert(userId, operation, permissionKey, workspaceId) {
|
|
@@ -342,7 +355,7 @@ var PermissionsModule = class {
|
|
|
342
355
|
name = "@fonderie/permissions";
|
|
343
356
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
344
357
|
// say what is actually deployed rather than 'not reported'.
|
|
345
|
-
version = "5.
|
|
358
|
+
version = "5.2.0";
|
|
346
359
|
deps = ["@fonderie/auth"];
|
|
347
360
|
constructor(store, config = {}) {
|
|
348
361
|
this.engine = new PermissionsEngine(store, config);
|
package/dist/index.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["export type {\n\tOperation,\n\tPermissionKey,\n\tIRole,\n\tIPermission,\n\tIMembership,\n\tIRoleWithPermissions,\n\tIPermissionCatalogEntry,\n\tIEffectivePermissions,\n} from './types';\nexport { PermissionsModule } from './module';\nexport { PermissionsEngine, PermissionDeniedError } from './engine';\nexport type { IPermissionsConfig } from './config';\n\nexport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nexport { requireRole } from './middlewares/require-role';\nexport { requirePermission } from './middlewares/require-permission';\n\n// Access-grant export for the SOC 2 access-review register.\nexport { listGrants } from './services/grants';\nexport type { IGrant } from './services/grants';\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":"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","names":["import_core","import_core","makeHandler"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["export type {\n\tOperation,\n\tPermissionKey,\n\tIRole,\n\tIPermission,\n\tIMembership,\n\tIRoleWithPermissions,\n\tIPermissionCatalogEntry,\n\tIEffectivePermissions,\n} from './types';\nexport { PermissionsModule } from './module';\nexport { PermissionsEngine, PermissionDeniedError } from './engine';\nexport type { IPermissionsConfig } from './config';\n\nexport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nexport { requireRole } from './middlewares/require-role';\nexport { requirePermission } from './middlewares/require-permission';\n\n// Access-grant export for the SOC 2 access-review register.\nexport { listGrants } from './services/grants';\nexport type { IGrant } from './services/grants';\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACGA,kBAA2B;AAEpB,IAAM,oBAA+C;AAAA,EAC3D,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AACT;;;ACNA,eAAsB,cACrB,QACA,aACA,OAC8B;AAC9B,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IAMzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAQA,CAAC,QAAQ,WAAW;AAAA,EACrB;AAEA,MAAI,CAAC,IAAK,QAAO;AAEjB,SAAO;AAAA,IACN,QAAQ,IAAI;AAAA,IACZ,aAAa,IAAI;AAAA,IACjB,QAAQ,IAAI;AAAA,IACZ,UAAU,IAAI;AAAA,EACf;AACD;AAWA,eAAsB,QACrB,QACA,aACA,UACA,OACmB;AACnB,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAYA,CAAC,QAAQ,aAAa,QAAQ;AAAA,EAC/B;AAEA,SAAO,KAAK,UAAU;AACvB;AASA,eAAsB,WACrB,QACA,aACA,WACA,OACmB;AACnB,MAAI,UAAU,WAAW,EAAG,QAAO;AACnC,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAWA,CAAC,QAAQ,aAAa,SAAS;AAAA,EAChC;AAEA,SAAO,KAAK,UAAU;AACvB;AAQA,eAAsB,oBACrB,QACA,aACA,OACoB;AACpB,QAAM,OAAO,MAAM,MAAM;AAAA,IACxB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IASA,CAAC,QAAQ,WAAW;AAAA,EACrB;AACA,SAAO,KAAK,IAAI,CAAC,MAAM,EAAE,IAAI;AAC9B;;;ACzHA,eAAsB,gBACrB,QACA,aACA,eACA,WACA,OACmB;AACnB,QAAM,MAAM,kBAAkB,SAAS;AAEvC,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB,qBAAqB,GAAG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAaxB,CAAC,QAAQ,aAAa,aAAa;AAAA,EACpC;AAEA,SAAO,KAAK,kBAAkB;AAC/B;AAcA,eAAsB,WACrB,QACA,aACA,eACA,WACA,WACA,OAC0F;AAC1F,QAAM,MAAM,kBAAkB,SAAS;AACvC,QAAM,CAAC,GAAG,IAAI,MAAM,MAAM;AAAA,IACzB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,2BAkCyB,GAAG;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAa5B,CAAC,QAAQ,aAAa,eAAe,SAAS;AAAA,EAC/C;AACA,SAAO;AAAA,IACN,QAAQ,KAAK,WAAW;AAAA,IACxB,SAAS,KAAK,aAAa;AAAA,IAC3B,aAAa,KAAK,gBAAgB,CAAC;AAAA,IACnC,SAAS,KAAK,YAAY;AAAA,EAC3B;AACD;AAOA,eAAsB,uBACrB,QACA,aACA,OAC8G;AAC9G,SAAO,MAAM;AAAA,IACZ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,IAgBA,CAAC,QAAQ,WAAW;AAAA,EACrB;AACD;;;ACtIA,IAAM,iBAAiB,OAAO,OAAO,sBAAU;AAExC,IAAM,oBAAN,MAAwB;AAAA,EAM9B,YACS,OACR,SAA6B,CAAC,GAC7B;AAFO;AAGR,SAAK,YAAY,OAAO,aAAa;AACrC,SAAK,UAAU,OAAO,UAAU,OAAO,OAAO,OAAO,QAAQ,IAAI,CAAC,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC,IAAI;AACvF,SAAK,eAAe,OAAO,gBAAgB,CAAC;AAG5C,QAAI,KAAK,SAAS;AACjB,YAAM,QAAQ,IAAI,IAAI,KAAK,QAAQ,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC;AACpD,iBAAW,CAAC,MAAM,MAAM,KAAK,OAAO,QAAQ,KAAK,YAAY,GAAG;AAC/D,mBAAW,OAAO,OAAO,KAAK,MAAM,GAAG;AACtC,cAAI,CAAC,MAAM,IAAI,GAAG,GAAG;AACpB,kBAAM,IAAI,MAAM,8BAA8B,IAAI,IAAI,GAAG,MAAM,GAAG,yBAAyB;AAAA,UAC5F;AAAA,QACD;AAAA,MACD;AAAA,IACD;AAAA,EACD;AAAA,EAlBS;AAAA,EAND;AAAA,EACA;AAAA;AAAA,EAEC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EA4BT,IAAI,eAA4D;AAC/D,WAAO,OAAO;AAAA,MACb,OAAO,QAAQ,KAAK,YAAY,EAAE,IAAI,CAAC,CAAC,MAAM,MAAM,MAAM;AAAA,QACzD;AAAA,QACA,OAAO,YAAY,OAAO,QAAQ,MAAM,EAAE,IAAI,CAAC,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;AAAA,MAC/E,CAAC;AAAA,IACF;AAAA,EACD;AAAA;AAAA,EAGA,QAAQ,eAAgC;AACvC,WAAO,CAAC,KAAK,WAAW,KAAK,QAAQ,KAAK,CAAC,MAAM,EAAE,QAAQ,aAAa;AAAA,EACzE;AAAA;AAAA,EAGA,aAAa,eAAoC;AAChD,WAAO,KAAK,SAAS,KAAK,CAAC,MAAM,EAAE,QAAQ,aAAa,GAAG,cAAc;AAAA,EAC1E;AAAA,EAEA,MAAc,OACb,QACA,aACA,eACA,WACmB;AACnB,QAAI,OAAO,KAAK,KAAK,YAAY,EAAE,SAAS,GAAG;AAC9C,YAAM,QAAQ,MAAM,oBAAoB,QAAQ,aAAa,KAAK,KAAK;AACvE,UAAI,MAAM,KAAK,CAAC,MAAM,KAAK,aAAa,CAAC,IAAI,aAAa,GAAG,SAAS,SAAS,KAAK,KAAK,EAAG,QAAO;AAAA,IACpG;AACA,WAAO,gBAAgB,QAAQ,aAAa,eAAe,WAAW,KAAK,KAAK;AAAA,EACjF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,MAAM,UAAU,QAAgB,aAA4D;AAC3F,QAAI,CAAE,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK,EAAI,QAAO;AAEpE,UAAM,cAA0D,CAAC;AACjE,UAAM,QAAQ,CAAC,QACb,YAAY,GAAG,MAAM,EAAE,QAAQ,OAAO,MAAM,OAAO,QAAQ,OAAO,QAAQ,MAAM;AAClF,eAAW,KAAK,KAAK,WAAW,CAAC,EAAG,OAAM,EAAE,GAAG;AAE/C,QAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,GAAG;AACnE,iBAAW,KAAK,KAAK,WAAW,CAAC,GAAG;AACnC,mBAAW,MAAM,EAAE,cAAc,eAAgB,OAAM,EAAE,GAAG,EAAE,EAAE,IAAI;AAAA,MACrE;AACA,aAAO,EAAE,SAAS,MAAM,YAAY;AAAA,IACrC;AAEA,UAAM,CAAC,aAAa,MAAM,IAAI,MAAM,QAAQ,IAAI;AAAA,MAC/C,OAAO,KAAK,KAAK,YAAY,EAAE,SAAS,oBAAoB,QAAQ,aAAa,KAAK,KAAK,IAAI,QAAQ,QAAQ,CAAC,CAAC;AAAA,MACjH,uBAAuB,QAAQ,aAAa,KAAK,KAAK;AAAA,IACvD,CAAC;AACD,eAAW,QAAQ,aAAa;AAC/B,iBAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,KAAK,aAAa,IAAI,KAAK,CAAC,CAAC,GAAG;AACvE,mBAAW,MAAM,IAAK,OAAM,GAAG,EAAE,EAAE,IAAI;AAAA,MACxC;AAAA,IACD;AACA,eAAW,KAAK,QAAQ;AACvB,YAAM,IAAI,MAAM,EAAE,aAAa;AAC/B,iBAAW,MAAM,eAAgB,GAAE,EAAE,MAAM,QAAQ,EAAE,EAAE,CAAC;AAAA,IACzD;AACA,WAAO,EAAE,SAAS,OAAO,YAAY;AAAA,EACtC;AAAA,EAEA,MAAM,cAAc,QAAgB,aAAqB;AACxD,WAAO,cAAc,QAAQ,aAAa,KAAK,KAAK;AAAA,EACrD;AAAA,EAEA,MAAM,IACL,QACA,WACA,eACA,aACmB;AAGnB,QAAI,CAAC,OAAO,OAAO,mBAAmB,SAAS,GAAG;AACjD,YAAM,WAAW,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK;AACpE,UAAI,CAAC,SAAU,QAAO;AACtB,UAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,EAAG,QAAO;AAC3E,aAAO,KAAK,OAAO,QAAQ,aAAa,eAAe,SAAS;AAAA,IACjE;AAIA,UAAM,IAAI,MAAM,WAAW,QAAQ,aAAa,eAAe,WAAW,KAAK,WAAW,KAAK,KAAK;AACpG,QAAI,CAAC,EAAE,OAAQ,QAAO;AACtB,QAAI,EAAE,QAAS,QAAO;AACtB,QAAI,EAAE,YAAY,KAAK,CAAC,MAAM,KAAK,aAAa,CAAC,IAAI,aAAa,GAAG,SAAS,SAAS,KAAK,KAAK,EAAG,QAAO;AAC3G,WAAO,EAAE;AAAA,EACV;AAAA,EAEA,MAAM,OACL,QACA,WACA,eACA,aACgB;AAChB,UAAM,UAAU,MAAM,KAAK,IAAI,QAAQ,WAAW,eAAe,WAAW;AAC5E,QAAI,CAAC,SAAS;AACb,YAAM,IAAI,sBAAsB,WAAW,aAAa;AAAA,IACzD;AAAA,EACD;AAAA,EAEA,MAAM,OACL,QACA,QACA,aACmB;AACnB,UAAM,WAAW,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK;AACpE,QAAI,CAAC,SAAU,QAAO;AAEtB,QAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,EAAG,QAAO;AAE3E,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC7B,OAAO,IAAI,CAAC,MAAM,KAAK,OAAO,QAAQ,aAAa,EAAE,eAAe,EAAE,SAAS,CAAC;AAAA,IACjF;AACA,WAAO,QAAQ,MAAM,OAAO;AAAA,EAC7B;AAAA,EAEA,MAAM,OACL,QACA,QACA,aACmB;AACnB,UAAM,WAAW,MAAM,cAAc,QAAQ,aAAa,KAAK,KAAK;AACpE,QAAI,CAAC,SAAU,QAAO;AAEtB,QAAI,MAAM,QAAQ,QAAQ,aAAa,KAAK,WAAW,KAAK,KAAK,EAAG,QAAO;AAE3E,UAAM,UAAU,MAAM,QAAQ;AAAA,MAC7B,OAAO,IAAI,CAAC,MAAM,KAAK,OAAO,QAAQ,aAAa,EAAE,eAAe,EAAE,SAAS,CAAC;AAAA,IACjF;AACA,WAAO,QAAQ,KAAK,OAAO;AAAA,EAC5B;AACD;AAEO,IAAM,wBAAN,cAAoC,MAAM;AAAA,EACvC,SAAS;AAAA,EAElB,YAAY,WAAmB,eAAuB;AACrD,UAAM,sBAAsB,SAAS,IAAI,aAAa,EAAE;AACxD,SAAK,OAAO;AAAA,EACb;AACD;;;ACzLO,IAAM,yBAAyB;AAE/B,IAAM,oBAAN,MAAmD;AAAA,EAChD;AAAA,EACA,OAAO;AAAA;AAAA;AAAA,EAGP,UAAU;AAAA,EACV,OAAO,CAAC,gBAAgB;AAAA,EAEjC,YAAY,OAAsB,SAA6B,CAAC,GAAG;AAClE,SAAK,SAAS,IAAI,kBAAkB,OAAO,MAAM;AAAA,EAClD;AAAA,EAEA,QAAQ,KAAyB;AAChC,UAAM,SAAS,KAAK;AAIpB,QAAI,IAAI,OAAO,KAAK,SAAS;AAC5B,UAAI,KAAK,sBAAsB,IAAI;AACnC,aAAO,KAAK;AAAA,IACb,CAAC;AAAA,EACF;AACD;;;AC9BA,IAAAA,eAAqC;AASrC,SAAS,YAAY,UAA6B,OAAkC;AACnF,QAAM,UAAU,MAAM,QAAQ,QAAQ,IAAI,WAAW,CAAC,QAAQ;AAE9D,SAAO,OAAO,KAAK,SAAS;AAC3B,QAAI,CAAC,IAAI,MAAM;AACd,iBAAO,6BAAe,kBAAK,cAAc,gBAAgB,cAAc;AAAA,IACxE;AAEA,UAAM,SAAS,IAAI,KAAK,sBAAsB;AAC9C,QAAI,EAAE,kBAAkB,oBAAoB;AAC3C,iBAAO,6BAAe,kBAAK,cAAc,gBAAgB,kCAAkC;AAAA,IAC5F;AAEA,UAAM,cACL,IAAI,WAAW,MACd,IAAI,KAAK,QAAQ,IAA2C,aAAa;AAE3E,QAAI,CAAC,aAAa;AACjB,iBAAO,6BAAe,kBAAK,aAAa,sBAAsB,4BAA4B;AAAA,IAC3F;AAIA,QAAI,CAAE,MAAM,WAAW,IAAI,KAAK,IAAI,aAAa,SAAS,KAAK,GAAI;AAClE,iBAAO,6BAAe,kBAAK,WAAW,aAAa,mBAAmB;AAAA,IACvE;AAEA,WAAO,KAAK;AAAA,EACb;AACD;AASO,SAAS,YACf,UACA,OACA,KACA,MACiC;AACjC,QAAM,UAAU,YAAY,UAAU,KAAK;AAC3C,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;;;ACxDA,IAAAC,eAAqC;AAQrC,SAASC,aAAY,WAAsB,eAAmC;AAC7E,SAAO,OAAO,KAAK,SAAS;AAC3B,QAAI,CAAC,IAAI,MAAM;AACd,iBAAO,6BAAe,kBAAK,cAAc,gBAAgB,cAAc;AAAA,IACxE;AAEA,UAAM,SAAS,IAAI,KAAK,sBAAsB;AAC9C,QAAI,EAAE,kBAAkB,oBAAoB;AAC3C,iBAAO,6BAAe,kBAAK,cAAc,gBAAgB,kCAAkC;AAAA,IAC5F;AAEA,UAAM,cAAc,mBAAmB,GAAG;AAC1C,QAAI,CAAC,aAAa;AACjB,iBAAO,6BAAe,kBAAK,aAAa,sBAAsB,4BAA4B;AAAA,IAC3F;AAEA,UAAM,UAAU,MAAM,OAAO,IAAI,IAAI,KAAK,IAAI,WAAW,eAAe,WAAW;AACnF,QAAI,CAAC,SAAS;AACb,iBAAO;AAAA,QACN,kBAAK;AAAA,QACL;AAAA,QACA,sBAAsB,SAAS,IAAI,aAAa;AAAA,MACjD;AAAA,IACD;AAEA,WAAO,KAAK;AAAA,EACb;AACD;AASO,SAAS,kBACf,WACA,eACA,KACA,MACiC;AACjC,QAAM,UAAUA,aAAY,WAAW,aAAa;AACpD,MAAI,QAAQ,UAAa,SAAS,OAAW,QAAO,QAAQ,KAAK,IAAI;AACrE,SAAO;AACR;AAEA,SAAS,mBAAmB,KAGV;AACjB,MAAI,IAAI,WAAW,GAAI,QAAO,IAAI,UAAU;AAE5C,QAAM,SAAS,IAAI,KAAK,QAAQ;AAChC,SAAO,SAAS,aAAa,KAAK;AACnC;;;ACjDA,eAAsB,WAAW,OAAyC;AACzE,QAAM,OAAO,MAAM,MAAM;AAAA,IAOxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAKD;AACA,SAAO,KAAK,IAAI,CAAC,OAAO;AAAA,IACvB,QAAQ,EAAE;AAAA,IACV,aAAa,EAAE;AAAA,IACf,QAAQ,EAAE;AAAA,IACV,UAAU,EAAE;AAAA,IACZ,WAAW,EAAE;AAAA,EACd,EAAE;AACH;","names":["import_core","import_core","makeHandler"]}
|
package/dist/index.d.cts
CHANGED
|
@@ -9,10 +9,16 @@ export { requirePermission, requireRole } from './middlewares/index.cjs';
|
|
|
9
9
|
declare class PermissionsEngine {
|
|
10
10
|
private store;
|
|
11
11
|
private superRole;
|
|
12
|
-
private
|
|
12
|
+
private grantsByRole;
|
|
13
13
|
/** The app's declared resources, or null when it declared none. */
|
|
14
14
|
readonly catalog: readonly IPermissionCatalogEntry[] | null;
|
|
15
15
|
constructor(store: IStoreAdapter, config?: IPermissionsConfig);
|
|
16
|
+
/**
|
|
17
|
+
* What each SYSTEM role is granted by config (`systemGrants`) — rights that
|
|
18
|
+
* are never stored as rows, so a role editor reads them here to show them.
|
|
19
|
+
* A copy: changing it changes nothing.
|
|
20
|
+
*/
|
|
21
|
+
get systemGrants(): Record<string, Record<string, Operation[]>>;
|
|
16
22
|
/** Is this key in the catalog? Always true when the app declared no catalog. */
|
|
17
23
|
isKnown(permissionKey: string): boolean;
|
|
18
24
|
/** The operations a resource supports (all four when not declared). */
|
package/dist/index.d.ts
CHANGED
|
@@ -9,10 +9,16 @@ export { requirePermission, requireRole } from './middlewares/index.js';
|
|
|
9
9
|
declare class PermissionsEngine {
|
|
10
10
|
private store;
|
|
11
11
|
private superRole;
|
|
12
|
-
private
|
|
12
|
+
private grantsByRole;
|
|
13
13
|
/** The app's declared resources, or null when it declared none. */
|
|
14
14
|
readonly catalog: readonly IPermissionCatalogEntry[] | null;
|
|
15
15
|
constructor(store: IStoreAdapter, config?: IPermissionsConfig);
|
|
16
|
+
/**
|
|
17
|
+
* What each SYSTEM role is granted by config (`systemGrants`) — rights that
|
|
18
|
+
* are never stored as rows, so a role editor reads them here to show them.
|
|
19
|
+
* A copy: changing it changes nothing.
|
|
20
|
+
*/
|
|
21
|
+
get systemGrants(): Record<string, Record<string, Operation[]>>;
|
|
16
22
|
/** Is this key in the catalog? Always true when the app declared no catalog. */
|
|
17
23
|
isKnown(permissionKey: string): boolean;
|
|
18
24
|
/** The operations a resource supports (all four when not declared). */
|
package/dist/index.js
CHANGED
|
@@ -189,10 +189,10 @@ var PermissionsEngine = class {
|
|
|
189
189
|
this.store = store;
|
|
190
190
|
this.superRole = config.superRole ?? "owner";
|
|
191
191
|
this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
|
|
192
|
-
this.
|
|
192
|
+
this.grantsByRole = config.systemGrants ?? {};
|
|
193
193
|
if (this.catalog) {
|
|
194
194
|
const known = new Set(this.catalog.map((e) => e.key));
|
|
195
|
-
for (const [role, grants] of Object.entries(this.
|
|
195
|
+
for (const [role, grants] of Object.entries(this.grantsByRole)) {
|
|
196
196
|
for (const key of Object.keys(grants)) {
|
|
197
197
|
if (!known.has(key)) {
|
|
198
198
|
throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
|
|
@@ -203,9 +203,22 @@ var PermissionsEngine = class {
|
|
|
203
203
|
}
|
|
204
204
|
store;
|
|
205
205
|
superRole;
|
|
206
|
-
|
|
206
|
+
grantsByRole;
|
|
207
207
|
/** The app's declared resources, or null when it declared none. */
|
|
208
208
|
catalog;
|
|
209
|
+
/**
|
|
210
|
+
* What each SYSTEM role is granted by config (`systemGrants`) — rights that
|
|
211
|
+
* are never stored as rows, so a role editor reads them here to show them.
|
|
212
|
+
* A copy: changing it changes nothing.
|
|
213
|
+
*/
|
|
214
|
+
get systemGrants() {
|
|
215
|
+
return Object.fromEntries(
|
|
216
|
+
Object.entries(this.grantsByRole).map(([role, grants]) => [
|
|
217
|
+
role,
|
|
218
|
+
Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
|
|
219
|
+
])
|
|
220
|
+
);
|
|
221
|
+
}
|
|
209
222
|
/** Is this key in the catalog? Always true when the app declared no catalog. */
|
|
210
223
|
isKnown(permissionKey) {
|
|
211
224
|
return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
|
|
@@ -215,9 +228,9 @@ var PermissionsEngine = class {
|
|
|
215
228
|
return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
|
|
216
229
|
}
|
|
217
230
|
async allows(userId, workspaceId, permissionKey, operation) {
|
|
218
|
-
if (Object.keys(this.
|
|
231
|
+
if (Object.keys(this.grantsByRole).length > 0) {
|
|
219
232
|
const roles = await listSystemRoleNames(userId, workspaceId, this.store);
|
|
220
|
-
if (roles.some((r) => this.
|
|
233
|
+
if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
221
234
|
}
|
|
222
235
|
return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
|
|
223
236
|
}
|
|
@@ -239,11 +252,11 @@ var PermissionsEngine = class {
|
|
|
239
252
|
return { isSuper: true, permissions };
|
|
240
253
|
}
|
|
241
254
|
const [systemRoles, stored] = await Promise.all([
|
|
242
|
-
Object.keys(this.
|
|
255
|
+
Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
|
|
243
256
|
listGrantedPermissions(userId, workspaceId, this.store)
|
|
244
257
|
]);
|
|
245
258
|
for (const role of systemRoles) {
|
|
246
|
-
for (const [key, ops] of Object.entries(this.
|
|
259
|
+
for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
|
|
247
260
|
for (const op of ops) entry(key)[op] = true;
|
|
248
261
|
}
|
|
249
262
|
}
|
|
@@ -266,7 +279,7 @@ var PermissionsEngine = class {
|
|
|
266
279
|
const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
|
|
267
280
|
if (!a.member) return false;
|
|
268
281
|
if (a.isSuper) return true;
|
|
269
|
-
if (a.systemRoles.some((r) => this.
|
|
282
|
+
if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
270
283
|
return a.granted;
|
|
271
284
|
}
|
|
272
285
|
async assert(userId, operation, permissionKey, workspaceId) {
|
|
@@ -309,7 +322,7 @@ var PermissionsModule = class {
|
|
|
309
322
|
name = "@fonderie/permissions";
|
|
310
323
|
// Baked in at build time by tsup.base, so the operator's Modules page can
|
|
311
324
|
// say what is actually deployed rather than 'not reported'.
|
|
312
|
-
version = "5.
|
|
325
|
+
version = "5.2.0";
|
|
313
326
|
deps = ["@fonderie/auth"];
|
|
314
327
|
constructor(store, config = {}) {
|
|
315
328
|
this.engine = new PermissionsEngine(store, config);
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
|
|
1
|
+
{"version":3,"sources":["../src/constants.ts","../src/services/membership.ts","../src/services/permissions.ts","../src/engine.ts","../src/module.ts","../src/middlewares/require-role.ts","../src/middlewares/require-permission.ts","../src/services/grants.ts"],"sourcesContent":["import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\n// Access-grant export (SOC 2 CC6.2/6.3) — feeds the access-review register with\n// the current who-has-what: every active user→workspace→role grant. Run this on\n// a schedule (or during a review) and diff against the previous snapshot.\n\nexport interface IGrant {\n\tuserId: string;\n\tworkspaceId: string;\n\troleId: string;\n\troleName: string;\n\tsuspended: boolean;\n}\n\nexport async function listGrants(store: IStoreAdapter): Promise<IGrant[]> {\n\tconst rows = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t\tsuspended: boolean;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name, ruw.suspended\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.removed = false\n\t\t ORDER BY ruw.workspace_id, ruw.user_id`,\n\t);\n\treturn rows.map((r) => ({\n\t\tuserId: r.user_id,\n\t\tworkspaceId: r.workspace_id,\n\t\troleId: r.role_id,\n\t\troleName: r.role_name,\n\t\tsuspended: r.suspended,\n\t}));\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
|
|
@@ -219,10 +219,10 @@ var PermissionsEngine = class {
|
|
|
219
219
|
this.store = store;
|
|
220
220
|
this.superRole = config.superRole ?? "owner";
|
|
221
221
|
this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
|
|
222
|
-
this.
|
|
222
|
+
this.grantsByRole = config.systemGrants ?? {};
|
|
223
223
|
if (this.catalog) {
|
|
224
224
|
const known = new Set(this.catalog.map((e) => e.key));
|
|
225
|
-
for (const [role, grants] of Object.entries(this.
|
|
225
|
+
for (const [role, grants] of Object.entries(this.grantsByRole)) {
|
|
226
226
|
for (const key of Object.keys(grants)) {
|
|
227
227
|
if (!known.has(key)) {
|
|
228
228
|
throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
|
|
@@ -233,9 +233,22 @@ var PermissionsEngine = class {
|
|
|
233
233
|
}
|
|
234
234
|
store;
|
|
235
235
|
superRole;
|
|
236
|
-
|
|
236
|
+
grantsByRole;
|
|
237
237
|
/** The app's declared resources, or null when it declared none. */
|
|
238
238
|
catalog;
|
|
239
|
+
/**
|
|
240
|
+
* What each SYSTEM role is granted by config (`systemGrants`) — rights that
|
|
241
|
+
* are never stored as rows, so a role editor reads them here to show them.
|
|
242
|
+
* A copy: changing it changes nothing.
|
|
243
|
+
*/
|
|
244
|
+
get systemGrants() {
|
|
245
|
+
return Object.fromEntries(
|
|
246
|
+
Object.entries(this.grantsByRole).map(([role, grants]) => [
|
|
247
|
+
role,
|
|
248
|
+
Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
|
|
249
|
+
])
|
|
250
|
+
);
|
|
251
|
+
}
|
|
239
252
|
/** Is this key in the catalog? Always true when the app declared no catalog. */
|
|
240
253
|
isKnown(permissionKey) {
|
|
241
254
|
return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
|
|
@@ -245,9 +258,9 @@ var PermissionsEngine = class {
|
|
|
245
258
|
return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
|
|
246
259
|
}
|
|
247
260
|
async allows(userId, workspaceId, permissionKey, operation) {
|
|
248
|
-
if (Object.keys(this.
|
|
261
|
+
if (Object.keys(this.grantsByRole).length > 0) {
|
|
249
262
|
const roles = await listSystemRoleNames(userId, workspaceId, this.store);
|
|
250
|
-
if (roles.some((r) => this.
|
|
263
|
+
if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
251
264
|
}
|
|
252
265
|
return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
|
|
253
266
|
}
|
|
@@ -269,11 +282,11 @@ var PermissionsEngine = class {
|
|
|
269
282
|
return { isSuper: true, permissions };
|
|
270
283
|
}
|
|
271
284
|
const [systemRoles, stored] = await Promise.all([
|
|
272
|
-
Object.keys(this.
|
|
285
|
+
Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
|
|
273
286
|
listGrantedPermissions(userId, workspaceId, this.store)
|
|
274
287
|
]);
|
|
275
288
|
for (const role of systemRoles) {
|
|
276
|
-
for (const [key, ops] of Object.entries(this.
|
|
289
|
+
for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
|
|
277
290
|
for (const op of ops) entry(key)[op] = true;
|
|
278
291
|
}
|
|
279
292
|
}
|
|
@@ -296,7 +309,7 @@ var PermissionsEngine = class {
|
|
|
296
309
|
const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
|
|
297
310
|
if (!a.member) return false;
|
|
298
311
|
if (a.isSuper) return true;
|
|
299
|
-
if (a.systemRoles.some((r) => this.
|
|
312
|
+
if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
300
313
|
return a.granted;
|
|
301
314
|
}
|
|
302
315
|
async assert(userId, operation, permissionKey, workspaceId) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["export { requireRole } from './require-role';\nexport { requirePermission } from './require-permission';\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":"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","names":["import_core","import_core","makeHandler"]}
|
|
1
|
+
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["export { requireRole } from './require-role';\nexport { requirePermission } from './require-permission';\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":"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","names":["import_core","import_core","makeHandler"]}
|
|
@@ -192,10 +192,10 @@ var PermissionsEngine = class {
|
|
|
192
192
|
this.store = store;
|
|
193
193
|
this.superRole = config.superRole ?? "owner";
|
|
194
194
|
this.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;
|
|
195
|
-
this.
|
|
195
|
+
this.grantsByRole = config.systemGrants ?? {};
|
|
196
196
|
if (this.catalog) {
|
|
197
197
|
const known = new Set(this.catalog.map((e) => e.key));
|
|
198
|
-
for (const [role, grants] of Object.entries(this.
|
|
198
|
+
for (const [role, grants] of Object.entries(this.grantsByRole)) {
|
|
199
199
|
for (const key of Object.keys(grants)) {
|
|
200
200
|
if (!known.has(key)) {
|
|
201
201
|
throw new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);
|
|
@@ -206,9 +206,22 @@ var PermissionsEngine = class {
|
|
|
206
206
|
}
|
|
207
207
|
store;
|
|
208
208
|
superRole;
|
|
209
|
-
|
|
209
|
+
grantsByRole;
|
|
210
210
|
/** The app's declared resources, or null when it declared none. */
|
|
211
211
|
catalog;
|
|
212
|
+
/**
|
|
213
|
+
* What each SYSTEM role is granted by config (`systemGrants`) — rights that
|
|
214
|
+
* are never stored as rows, so a role editor reads them here to show them.
|
|
215
|
+
* A copy: changing it changes nothing.
|
|
216
|
+
*/
|
|
217
|
+
get systemGrants() {
|
|
218
|
+
return Object.fromEntries(
|
|
219
|
+
Object.entries(this.grantsByRole).map(([role, grants]) => [
|
|
220
|
+
role,
|
|
221
|
+
Object.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]]))
|
|
222
|
+
])
|
|
223
|
+
);
|
|
224
|
+
}
|
|
212
225
|
/** Is this key in the catalog? Always true when the app declared no catalog. */
|
|
213
226
|
isKnown(permissionKey) {
|
|
214
227
|
return !this.catalog || this.catalog.some((e) => e.key === permissionKey);
|
|
@@ -218,9 +231,9 @@ var PermissionsEngine = class {
|
|
|
218
231
|
return this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;
|
|
219
232
|
}
|
|
220
233
|
async allows(userId, workspaceId, permissionKey, operation) {
|
|
221
|
-
if (Object.keys(this.
|
|
234
|
+
if (Object.keys(this.grantsByRole).length > 0) {
|
|
222
235
|
const roles = await listSystemRoleNames(userId, workspaceId, this.store);
|
|
223
|
-
if (roles.some((r) => this.
|
|
236
|
+
if (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
224
237
|
}
|
|
225
238
|
return checkPermission(userId, workspaceId, permissionKey, operation, this.store);
|
|
226
239
|
}
|
|
@@ -242,11 +255,11 @@ var PermissionsEngine = class {
|
|
|
242
255
|
return { isSuper: true, permissions };
|
|
243
256
|
}
|
|
244
257
|
const [systemRoles, stored] = await Promise.all([
|
|
245
|
-
Object.keys(this.
|
|
258
|
+
Object.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),
|
|
246
259
|
listGrantedPermissions(userId, workspaceId, this.store)
|
|
247
260
|
]);
|
|
248
261
|
for (const role of systemRoles) {
|
|
249
|
-
for (const [key, ops] of Object.entries(this.
|
|
262
|
+
for (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {
|
|
250
263
|
for (const op of ops) entry(key)[op] = true;
|
|
251
264
|
}
|
|
252
265
|
}
|
|
@@ -269,7 +282,7 @@ var PermissionsEngine = class {
|
|
|
269
282
|
const a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);
|
|
270
283
|
if (!a.member) return false;
|
|
271
284
|
if (a.isSuper) return true;
|
|
272
|
-
if (a.systemRoles.some((r) => this.
|
|
285
|
+
if (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;
|
|
273
286
|
return a.granted;
|
|
274
287
|
}
|
|
275
288
|
async assert(userId, operation, permissionKey, workspaceId) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate systemGrants: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.systemGrants = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.systemGrants)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.systemGrants).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.systemGrants).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.systemGrants[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.systemGrants[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
|
|
1
|
+
{"version":3,"sources":["../../src/middlewares/require-role.ts","../../src/constants.ts","../../src/services/membership.ts","../../src/services/permissions.ts","../../src/engine.ts","../../src/module.ts","../../src/middlewares/require-permission.ts"],"sourcesContent":["import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\nimport { hasAnyRole } from '../services/membership';\n\nfunction makeHandler(roleName: string | string[], store: IStoreAdapter): Middleware {\n\tconst allowed = Array.isArray(roleName) ? roleName : [roleName];\n\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId =\n\t\t\tctx.workspace?.id ??\n\t\t\t(ctx.meta['params'] as Record<string, string> | undefined)?.['workspaceId'];\n\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\t// EXISTS across ALL of the user's roles — a member holding several roles\n\t\t// must not be denied because a single arbitrary row was the wrong one.\n\t\tif (!(await hasAnyRole(ctx.user.id, workspaceId, allowed, store))) {\n\t\t\treturn setApiResponse(HTTP.FORBIDDEN, 'FORBIDDEN', 'Insufficient role');\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requireRole(roleName: string | string[], store: IStoreAdapter): Middleware;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requireRole(\n\troleName: string | string[],\n\tstore: IStoreAdapter,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(roleName, store);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n","import type { Operation } from './types';\n\n// Canonical definition moved to @fonderie/core; re-exported here for back-compat.\nexport { OPERATIONS } from '@fonderie/core';\n\nexport const PERMISSION_COLUMN: Record<Operation, string> = {\n\tcreate: 'can_create',\n\tread: 'can_read',\n\tupdate: 'can_update',\n\tdelete: 'can_delete',\n};\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IMembership } from '../types';\n\nexport async function getMembership(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<IMembership | null> {\n\tconst [row] = await store.query<{\n\t\tuser_id: string;\n\t\tworkspace_id: string;\n\t\trole_id: string;\n\t\trole_name: string;\n\t}>(\n\t\t`SELECT ruw.user_id, ruw.workspace_id, ruw.role_id, r.name AS role_name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t LIMIT 1`,\n\t\t[userId, workspaceId],\n\t);\n\n\tif (!row) return null;\n\n\treturn {\n\t\tuserId: row.user_id,\n\t\tworkspaceId: row.workspace_id,\n\t\troleId: row.role_id,\n\t\troleName: row.role_name,\n\t};\n}\n\n/**\n * Does the user hold the SUPER-ROLE in this workspace? The engine uses this for\n * its total-access bypass, so the match is deliberately restricted to a\n * **system** role: `r.is_system = true`. Without that guard, a member could\n * create a workspace-local role whose name equals the configured super-role\n * (e.g. 'ADMIN') and self-assign it to gain full access — a privilege\n * escalation. System roles are seeded by the app, not member-creatable. Also\n * requires `active = true` so a deactivated role grants nothing.\n */\nexport async function hasRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleName: string,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $3\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleName],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * Does the user hold ANY of the named roles in this workspace (active, not\n * removed/suspended)? Used by requireRole — an EXISTS over ALL of the user's\n * role rows, so a member holding several roles isn't denied because an\n * arbitrary single row (LIMIT 1) happened to be the wrong one. Not restricted\n * to system roles: requireRole checks arbitrary named roles.\n */\nexport async function hasAnyRole(\n\tuserId: string,\n\tworkspaceId: string,\n\troleNames: string[],\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tif (roleNames.length === 0) return false;\n\tconst [row] = await store.query<{ exists: boolean }>(\n\t\t`SELECT EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = ANY($3)\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS exists`,\n\t\t[userId, workspaceId, roleNames],\n\t);\n\n\treturn row?.exists ?? false;\n}\n\n/**\n * The SYSTEM roles (shared across workspaces) the user holds here — the ones\n * whose rights come from config (systemGrants). System only, for the same\n * reason as hasRole: a member-created local role named 'GUEST' must not pick\n * up the system GUEST's rights.\n */\nexport async function listSystemRoleNames(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<string[]> {\n\tconst rows = await store.query<{ name: string }>(\n\t\t`SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false`,\n\t\t[userId, workspaceId],\n\t);\n\treturn rows.map((r) => r.name);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from '../types';\nimport { PERMISSION_COLUMN } from '../constants';\n\nexport async function checkPermission(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tstore: IStoreAdapter,\n): Promise<boolean> {\n\tconst col = PERMISSION_COLUMN[operation];\n\n\tconst [row] = await store.query<{ has_permission: boolean }>(\n\t\t`SELECT BOOL_OR(rp.${col}) AS has_permission\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY ruw.user_id, ruw.workspace_id`,\n\t\t[userId, workspaceId, permissionKey],\n\t);\n\n\treturn row?.has_permission ?? false;\n}\n\n/**\n * Everything one permission check needs, in ONE round-trip — the same four\n * predicates as getMembership, hasRole (super-role), listSystemRoleNames and\n * checkPermission, each written exactly as there, read side by side:\n *\n * member — an active row joined to an existing role (getMembership)\n * isSuper — holds the super-role as an ACTIVE SYSTEM role (hasRole)\n * systemRoles — the active SYSTEM roles held (listSystemRoleNames)\n * granted — a stored grant for `operation` on `permissionKey` (checkPermission)\n *\n * The caller applies the same order of decisions the engine always has.\n */\nexport async function readAccess(\n\tuserId: string,\n\tworkspaceId: string,\n\tpermissionKey: string,\n\toperation: Operation,\n\tsuperRole: string,\n\tstore: IStoreAdapter,\n): Promise<{ member: boolean; isSuper: boolean; systemRoles: string[]; granted: boolean }> {\n\tconst col = PERMISSION_COLUMN[operation];\n\tconst [row] = await store.query<{ member: boolean; is_super: boolean; system_roles: string[] | null; granted: boolean | null }>(\n\t\t`SELECT\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS member,\n\t\t EXISTS (\n\t\t SELECT 1\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.name = $4\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS is_super,\n\t\t ARRAY (\n\t\t SELECT DISTINCT r.name\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON r.id = ruw.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND r.is_system = true\n\t\t AND r.active = true\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t ) AS system_roles,\n\t\t (\n\t\t SELECT BOOL_OR(rp.${col})\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND rp.permission_key = $3\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t ) AS granted`,\n\t\t[userId, workspaceId, permissionKey, superRole],\n\t);\n\treturn {\n\t\tmember: row?.member === true,\n\t\tisSuper: row?.is_super === true,\n\t\tsystemRoles: row?.system_roles ?? [],\n\t\tgranted: row?.granted === true,\n\t};\n}\n\n/**\n * Every grant the member's STORED roles give in this workspace, folded across\n * roles (a member with two roles gets the union) — one query for the\n * effective-permissions read instead of one check per key.\n */\nexport async function listGrantedPermissions(\n\tuserId: string,\n\tworkspaceId: string,\n\tstore: IStoreAdapter,\n): Promise<Array<{ permissionKey: string; create: boolean; read: boolean; update: boolean; delete: boolean }>> {\n\treturn store.query(\n\t\t`SELECT rp.permission_key AS \"permissionKey\",\n\t\t BOOL_OR(rp.can_create) AS \"create\",\n\t\t BOOL_OR(rp.can_read) AS \"read\",\n\t\t BOOL_OR(rp.can_update) AS \"update\",\n\t\t BOOL_OR(rp.can_delete) AS \"delete\"\n\t\t FROM fonderie_role_user_workspaces ruw\n\t\t JOIN fonderie_roles r ON ruw.role_id = r.id\n\t\t JOIN fonderie_role_permissions rp ON r.id = rp.role_id\n\t\t WHERE ruw.user_id = $1\n\t\t AND ruw.workspace_id = $2\n\t\t AND rp.workspace_id = $2\n\t\t AND ruw.removed = false\n\t\t AND ruw.suspended = false\n\t\t AND r.active = true\n\t\t AND (r.workspace_id = $2 OR r.is_system = true)\n\t\t GROUP BY rp.permission_key`,\n\t\t[userId, workspaceId],\n\t);\n}\n","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation, IPermissionCatalogEntry, IEffectivePermissions } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { OPERATIONS, PERMISSION_COLUMN } from './constants';\nimport { getMembership, hasRole, listSystemRoleNames } from './services/membership';\nimport { checkPermission, listGrantedPermissions, readAccess } from './services/permissions';\n\nconst ALL_OPERATIONS = Object.values(OPERATIONS) as Operation[];\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\tprivate grantsByRole: Record<string, Record<string, Operation[]>>;\n\t/** The app's declared resources, or null when it declared none. */\n\treadonly catalog: readonly IPermissionCatalogEntry[] | null;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\n\t\tthis.catalog = config.catalog ? Object.freeze(config.catalog.map((e) => ({ ...e }))) : null;\n\t\tthis.grantsByRole = config.systemGrants ?? {};\n\t\t// A misspelled resource in systemGrants would grant nothing, silently.\n\t\t// Refuse to start instead — a config mistake, caught at boot.\n\t\tif (this.catalog) {\n\t\t\tconst known = new Set(this.catalog.map((e) => e.key));\n\t\t\tfor (const [role, grants] of Object.entries(this.grantsByRole)) {\n\t\t\t\tfor (const key of Object.keys(grants)) {\n\t\t\t\t\tif (!known.has(key)) {\n\t\t\t\t\t\tthrow new Error(`[permissions] systemGrants.${role}.${key}: '${key}' is not in the catalog`);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * What each SYSTEM role is granted by config (`systemGrants`) — rights that\n\t * are never stored as rows, so a role editor reads them here to show them.\n\t * A copy: changing it changes nothing.\n\t */\n\tget systemGrants(): Record<string, Record<string, Operation[]>> {\n\t\treturn Object.fromEntries(\n\t\t\tObject.entries(this.grantsByRole).map(([role, grants]) => [\n\t\t\t\trole,\n\t\t\t\tObject.fromEntries(Object.entries(grants).map(([key, ops]) => [key, [...ops]])),\n\t\t\t]),\n\t\t);\n\t}\n\n\t/** Is this key in the catalog? Always true when the app declared no catalog. */\n\tisKnown(permissionKey: string): boolean {\n\t\treturn !this.catalog || this.catalog.some((e) => e.key === permissionKey);\n\t}\n\n\t/** The operations a resource supports (all four when not declared). */\n\toperationsOf(permissionKey: string): Operation[] {\n\t\treturn this.catalog?.find((e) => e.key === permissionKey)?.operations ?? ALL_OPERATIONS;\n\t}\n\n\tprivate async allows(\n\t\tuserId: string,\n\t\tworkspaceId: string,\n\t\tpermissionKey: string,\n\t\toperation: Operation,\n\t): Promise<boolean> {\n\t\tif (Object.keys(this.grantsByRole).length > 0) {\n\t\t\tconst roles = await listSystemRoleNames(userId, workspaceId, this.store);\n\t\t\tif (roles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\t}\n\t\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\n\t}\n\n\t/**\n\t * Everything this member may do in this workspace, in one read — what a\n\t * client needs to show only the actions that will succeed. Null when the\n\t * user is not a member. Lists the catalog's resources (every operation\n\t * present, true or false) plus any granted key outside it.\n\t */\n\tasync effective(userId: string, workspaceId: string): Promise<IEffectivePermissions | null> {\n\t\tif (!(await getMembership(userId, workspaceId, this.store))) return null;\n\n\t\tconst permissions: Record<string, Record<Operation, boolean>> = {};\n\t\tconst entry = (key: string) =>\n\t\t\t(permissions[key] ??= { create: false, read: false, update: false, delete: false });\n\t\tfor (const e of this.catalog ?? []) entry(e.key);\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) {\n\t\t\tfor (const e of this.catalog ?? []) {\n\t\t\t\tfor (const op of e.operations ?? ALL_OPERATIONS) entry(e.key)[op] = true;\n\t\t\t}\n\t\t\treturn { isSuper: true, permissions };\n\t\t}\n\n\t\tconst [systemRoles, stored] = await Promise.all([\n\t\t\tObject.keys(this.grantsByRole).length ? listSystemRoleNames(userId, workspaceId, this.store) : Promise.resolve([]),\n\t\t\tlistGrantedPermissions(userId, workspaceId, this.store),\n\t\t]);\n\t\tfor (const role of systemRoles) {\n\t\t\tfor (const [key, ops] of Object.entries(this.grantsByRole[role] ?? {})) {\n\t\t\t\tfor (const op of ops) entry(key)[op] = true;\n\t\t\t}\n\t\t}\n\t\tfor (const g of stored) {\n\t\t\tconst e = entry(g.permissionKey);\n\t\t\tfor (const op of ALL_OPERATIONS) e[op] ||= Boolean(g[op]);\n\t\t}\n\t\treturn { isSuper: false, permissions };\n\t}\n\n\tasync getMembership(userId: string, workspaceId: string) {\n\t\treturn getMembership(userId, workspaceId, this.store);\n\t}\n\n\tasync can(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\t// An operation outside the four has no grant column; keep the old,\n\t\t// step-by-step path for it rather than change what it answers.\n\t\tif (!Object.hasOwn(PERMISSION_COLUMN, operation)) {\n\t\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\t\tif (!isMember) return false;\n\t\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\t\t\treturn this.allows(userId, workspaceId, permissionKey, operation);\n\t\t}\n\n\t\t// Member → super-role → config system grants → stored grants: the same\n\t\t// decisions in the same order, read in one round-trip.\n\t\tconst a = await readAccess(userId, workspaceId, permissionKey, operation, this.superRole, this.store);\n\t\tif (!a.member) return false;\n\t\tif (a.isSuper) return true;\n\t\tif (a.systemRoles.some((r) => this.grantsByRole[r]?.[permissionKey]?.includes(operation) ?? false)) return true;\n\t\treturn a.granted;\n\t}\n\n\tasync assert(\n\t\tuserId: string,\n\t\toperation: Operation,\n\t\tpermissionKey: string,\n\t\tworkspaceId: string,\n\t): Promise<void> {\n\t\tconst allowed = await this.can(userId, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\tthrow new PermissionDeniedError(operation, permissionKey);\n\t\t}\n\t}\n\n\tasync canAll(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.every(Boolean);\n\t}\n\n\tasync canAny(\n\t\tuserId: string,\n\t\tchecks: Array<{ operation: Operation; permissionKey: string }>,\n\t\tworkspaceId: string,\n\t): Promise<boolean> {\n\t\tconst isMember = await getMembership(userId, workspaceId, this.store);\n\t\tif (!isMember) return false;\n\n\t\tif (await hasRole(userId, workspaceId, this.superRole, this.store)) return true;\n\n\t\tconst results = await Promise.all(\n\t\t\tchecks.map((c) => this.allows(userId, workspaceId, c.permissionKey, c.operation)),\n\t\t);\n\t\treturn results.some(Boolean);\n\t}\n}\n\nexport class PermissionDeniedError extends Error {\n\treadonly status = 403;\n\n\tconstructor(operation: string, permissionKey: string) {\n\t\tsuper(`Permission denied: ${operation}:${permissionKey}`);\n\t\tthis.name = 'PermissionDeniedError';\n\t}\n}\n","import type { IFonderieModule, IFonderieApp } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IPermissionsConfig } from './config';\nimport { PermissionsEngine } from './engine';\n\nexport const PERMISSIONS_ENGINE_KEY = 'fonderie.permissions.engine';\n\nexport class PermissionsModule implements IFonderieModule {\n\treadonly engine: PermissionsEngine;\n\treadonly name = '@fonderie/permissions';\n\t// Baked in at build time by tsup.base, so the operator's Modules page can\n\t// say what is actually deployed rather than 'not reported'.\n\treadonly version = process.env['FONDERIE_PKG_VERSION'] ?? '0.0.0-dev';\n\treadonly deps = ['@fonderie/auth'];\n\n\tconstructor(store: IStoreAdapter, config: IPermissionsConfig = {}) {\n\t\tthis.engine = new PermissionsEngine(store, config);\n\t}\n\n\tinstall(app: IFonderieApp): void {\n\t\tconst engine = this.engine;\n\n\t\t// Inject engine into every request context\n\t\t// requirePermission() reads it from here — no manual passing needed\n\t\tapp.use(async (ctx, next) => {\n\t\t\tctx.meta[PERMISSIONS_ENGINE_KEY] = engine;\n\t\t\treturn next();\n\t\t});\n\t}\n}\n","import { setApiResponse, HTTP } from '@fonderie/core';\nimport type { Middleware } from '@fonderie/core';\nimport type { IFonderieContext } from '@fonderie/core';\n\nimport type { Operation } from '../types';\nimport { PermissionsEngine } from '../engine';\nimport { PERMISSIONS_ENGINE_KEY } from '../module';\n\nfunction makeHandler(operation: Operation, permissionKey: string): Middleware {\n\treturn async (ctx, next) => {\n\t\tif (!ctx.user) {\n\t\t\treturn setApiResponse(HTTP.UNAUTHORIZED, 'UNAUTHORIZED', 'Unauthorized');\n\t\t}\n\n\t\tconst engine = ctx.meta[PERMISSIONS_ENGINE_KEY];\n\t\tif (!(engine instanceof PermissionsEngine)) {\n\t\t\treturn setApiResponse(HTTP.SERVER_ERROR, 'SERVER_ERROR', 'Permissions module not installed');\n\t\t}\n\n\t\tconst workspaceId = resolveWorkspaceId(ctx);\n\t\tif (!workspaceId) {\n\t\t\treturn setApiResponse(HTTP.BAD_REQUEST, 'WORKSPACE_REQUIRED', 'Workspace context required');\n\t\t}\n\n\t\tconst allowed = await engine.can(ctx.user.id, operation, permissionKey, workspaceId);\n\t\tif (!allowed) {\n\t\t\treturn setApiResponse(\n\t\t\t\tHTTP.FORBIDDEN,\n\t\t\t\t'FORBIDDEN',\n\t\t\t\t`Permission denied: ${operation}:${permissionKey}`,\n\t\t\t);\n\t\t}\n\n\t\treturn next();\n\t};\n}\n\nexport function requirePermission(operation: Operation, permissionKey: string): Middleware;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx: IFonderieContext,\n\tnext: () => Promise<Response>,\n): Promise<Response>;\nexport function requirePermission(\n\toperation: Operation,\n\tpermissionKey: string,\n\tctx?: IFonderieContext,\n\tnext?: () => Promise<Response>,\n): Middleware | Promise<Response> {\n\tconst handler = makeHandler(operation, permissionKey);\n\tif (ctx !== undefined && next !== undefined) return handler(ctx, next);\n\treturn handler;\n}\n\nfunction resolveWorkspaceId(ctx: {\n\tworkspace?: { id: string } | null;\n\tmeta: Record<string, unknown>;\n}): string | null {\n\tif (ctx.workspace?.id) return ctx.workspace.id;\n\n\tconst params = ctx.meta['params'] as Record<string, string> | undefined;\n\treturn params?.['workspaceId'] ?? null;\n}\n"],"mappings":"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","names":["setApiResponse","HTTP","makeHandler","setApiResponse","HTTP"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/permissions",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"fonderie": { "stability": "stable" },
|
|
5
5
|
"description": "Role-based access control for SaaS — define roles, assign CRUD permissions per resource, and gate any route with requirePermission. Supports multiple roles per user via BOOL_OR aggregation.",
|
|
6
6
|
"keywords": [
|