@fonderie/permissions 5.0.2 → 5.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -61,6 +61,8 @@ async function hasRole(userId, workspaceId, roleName, store) {
61
61
  WHERE ruw.user_id = $1
62
62
  AND ruw.workspace_id = $2
63
63
  AND r.name = $3
64
+ AND r.is_system = true
65
+ AND r.active = true
64
66
  AND ruw.removed = false
65
67
  AND ruw.suspended = false
66
68
  ) AS exists`,
@@ -68,6 +70,24 @@ async function hasRole(userId, workspaceId, roleName, store) {
68
70
  );
69
71
  return row?.exists ?? false;
70
72
  }
73
+ async function hasAnyRole(userId, workspaceId, roleNames, store) {
74
+ if (roleNames.length === 0) return false;
75
+ const [row] = await store.query(
76
+ `SELECT EXISTS (
77
+ SELECT 1
78
+ FROM fonderie_role_user_workspaces ruw
79
+ JOIN fonderie_roles r ON r.id = ruw.role_id
80
+ WHERE ruw.user_id = $1
81
+ AND ruw.workspace_id = $2
82
+ AND r.name = ANY($3)
83
+ AND r.active = true
84
+ AND ruw.removed = false
85
+ AND ruw.suspended = false
86
+ ) AS exists`,
87
+ [userId, workspaceId, roleNames]
88
+ );
89
+ return row?.exists ?? false;
90
+ }
71
91
 
72
92
  // src/constants.ts
73
93
  var import_core = require("@fonderie/core");
@@ -92,6 +112,7 @@ async function checkPermission(userId, workspaceId, permissionKey, operation, st
92
112
  AND rp.permission_key = $3
93
113
  AND ruw.removed = false
94
114
  AND ruw.suspended = false
115
+ AND r.active = true
95
116
  AND (r.workspace_id = $2 OR r.is_system = true)
96
117
  GROUP BY ruw.user_id, ruw.workspace_id`,
97
118
  [userId, workspaceId, permissionKey]
@@ -187,8 +208,7 @@ function makeHandler(roleName, store) {
187
208
  if (!workspaceId) {
188
209
  return (0, import_core2.setApiResponse)(import_core2.HTTP.BAD_REQUEST, "WORKSPACE_REQUIRED", "Workspace context required");
189
210
  }
190
- const membership = await getMembership(ctx.user.id, workspaceId, store);
191
- if (!membership || !allowed.includes(membership.roleName)) {
211
+ if (!await hasAnyRole(ctx.user.id, workspaceId, allowed, store)) {
192
212
  return (0, import_core2.setApiResponse)(import_core2.HTTP.FORBIDDEN, "FORBIDDEN", "Insufficient role");
193
213
  }
194
214
  return next();
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts","../src/services/membership.ts","../src/constants.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} 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 { 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\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 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","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 { 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.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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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 { getMembership } 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\tconst membership = await getMembership(ctx.user.id, workspaceId, store);\n\t\tif (!membership || !allowed.includes(membership.roleName)) {\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;;;ACIA,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;AAEA,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,IAUA,CAAC,QAAQ,aAAa,QAAQ;AAAA,EAC/B;AAEA,SAAO,KAAK,UAAU;AACvB;;;ACtDA,kBAA2B;AAEpB,IAAM,oBAA+C;AAAA,EAC3D,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,QAAQ;AACT;;;ACLA,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,IAYxB,CAAC,QAAQ,aAAa,aAAa;AAAA,EACpC;AAEA,SAAO,KAAK,kBAAkB;AAC/B;;;ACxBO,IAAM,oBAAN,MAAwB;AAAA,EAG9B,YACS,OACR,SAA6B,CAAC,GAC7B;AAFO;AAGR,SAAK,YAAY,OAAO,aAAa;AAAA,EACtC;AAAA,EAJS;AAAA,EAHD;AAAA,EASR,MAAM,cAAc,QAAgB,aAAqB;AACxD,WAAO,cAAc,QAAQ,aAAa,KAAK,KAAK;AAAA,EACrD;AAAA,EAEA,MAAM,IACL,QACA,WACA,eACA,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,WAAO,gBAAgB,QAAQ,aAAa,eAAe,WAAW,KAAK,KAAK;AAAA,EACjF;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;AAAA,QAAI,CAAC,MACX,gBAAgB,QAAQ,aAAa,EAAE,eAAe,EAAE,WAAW,KAAK,KAAK;AAAA,MAC9E;AAAA,IACD;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;AAAA,QAAI,CAAC,MACX,gBAAgB,QAAQ,aAAa,EAAE,eAAe,EAAE,WAAW,KAAK,KAAK;AAAA,MAC9E;AAAA,IACD;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;;;ACrFO,IAAM,yBAAyB;AAE/B,IAAM,oBAAN,MAAmD;AAAA,EAChD;AAAA,EACA,OAAO;AAAA,EACP,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;;;AC3BA,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;AAEA,UAAM,aAAa,MAAM,cAAc,IAAI,KAAK,IAAI,aAAa,KAAK;AACtE,QAAI,CAAC,cAAc,CAAC,QAAQ,SAAS,WAAW,QAAQ,GAAG;AAC1D,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;;;ACvDA,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"]}
1
+ {"version":3,"sources":["../src/index.ts","../src/services/membership.ts","../src/constants.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} 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 { 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","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 { 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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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"]}
package/dist/index.js CHANGED
@@ -28,6 +28,8 @@ async function hasRole(userId, workspaceId, roleName, store) {
28
28
  WHERE ruw.user_id = $1
29
29
  AND ruw.workspace_id = $2
30
30
  AND r.name = $3
31
+ AND r.is_system = true
32
+ AND r.active = true
31
33
  AND ruw.removed = false
32
34
  AND ruw.suspended = false
33
35
  ) AS exists`,
@@ -35,6 +37,24 @@ async function hasRole(userId, workspaceId, roleName, store) {
35
37
  );
36
38
  return row?.exists ?? false;
37
39
  }
40
+ async function hasAnyRole(userId, workspaceId, roleNames, store) {
41
+ if (roleNames.length === 0) return false;
42
+ const [row] = await store.query(
43
+ `SELECT EXISTS (
44
+ SELECT 1
45
+ FROM fonderie_role_user_workspaces ruw
46
+ JOIN fonderie_roles r ON r.id = ruw.role_id
47
+ WHERE ruw.user_id = $1
48
+ AND ruw.workspace_id = $2
49
+ AND r.name = ANY($3)
50
+ AND r.active = true
51
+ AND ruw.removed = false
52
+ AND ruw.suspended = false
53
+ ) AS exists`,
54
+ [userId, workspaceId, roleNames]
55
+ );
56
+ return row?.exists ?? false;
57
+ }
38
58
 
39
59
  // src/constants.ts
40
60
  import { OPERATIONS } from "@fonderie/core";
@@ -59,6 +79,7 @@ async function checkPermission(userId, workspaceId, permissionKey, operation, st
59
79
  AND rp.permission_key = $3
60
80
  AND ruw.removed = false
61
81
  AND ruw.suspended = false
82
+ AND r.active = true
62
83
  AND (r.workspace_id = $2 OR r.is_system = true)
63
84
  GROUP BY ruw.user_id, ruw.workspace_id`,
64
85
  [userId, workspaceId, permissionKey]
@@ -154,8 +175,7 @@ function makeHandler(roleName, store) {
154
175
  if (!workspaceId) {
155
176
  return setApiResponse(HTTP.BAD_REQUEST, "WORKSPACE_REQUIRED", "Workspace context required");
156
177
  }
157
- const membership = await getMembership(ctx.user.id, workspaceId, store);
158
- if (!membership || !allowed.includes(membership.roleName)) {
178
+ if (!await hasAnyRole(ctx.user.id, workspaceId, allowed, store)) {
159
179
  return setApiResponse(HTTP.FORBIDDEN, "FORBIDDEN", "Insufficient role");
160
180
  }
161
181
  return next();
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/services/membership.ts","../src/constants.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 { 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\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 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","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 { 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.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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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 { getMembership } 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\tconst membership = await getMembership(ctx.user.id, workspaceId, store);\n\t\tif (!membership || !allowed.includes(membership.roleName)) {\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/services/membership.ts","../src/constants.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 { 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","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 { 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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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"]}
@@ -58,6 +58,8 @@ async function hasRole(userId, workspaceId, roleName, store) {
58
58
  WHERE ruw.user_id = $1
59
59
  AND ruw.workspace_id = $2
60
60
  AND r.name = $3
61
+ AND r.is_system = true
62
+ AND r.active = true
61
63
  AND ruw.removed = false
62
64
  AND ruw.suspended = false
63
65
  ) AS exists`,
@@ -65,6 +67,24 @@ async function hasRole(userId, workspaceId, roleName, store) {
65
67
  );
66
68
  return row?.exists ?? false;
67
69
  }
70
+ async function hasAnyRole(userId, workspaceId, roleNames, store) {
71
+ if (roleNames.length === 0) return false;
72
+ const [row] = await store.query(
73
+ `SELECT EXISTS (
74
+ SELECT 1
75
+ FROM fonderie_role_user_workspaces ruw
76
+ JOIN fonderie_roles r ON r.id = ruw.role_id
77
+ WHERE ruw.user_id = $1
78
+ AND ruw.workspace_id = $2
79
+ AND r.name = ANY($3)
80
+ AND r.active = true
81
+ AND ruw.removed = false
82
+ AND ruw.suspended = false
83
+ ) AS exists`,
84
+ [userId, workspaceId, roleNames]
85
+ );
86
+ return row?.exists ?? false;
87
+ }
68
88
 
69
89
  // src/constants.ts
70
90
  var import_core = require("@fonderie/core");
@@ -89,6 +109,7 @@ async function checkPermission(userId, workspaceId, permissionKey, operation, st
89
109
  AND rp.permission_key = $3
90
110
  AND ruw.removed = false
91
111
  AND ruw.suspended = false
112
+ AND r.active = true
92
113
  AND (r.workspace_id = $2 OR r.is_system = true)
93
114
  GROUP BY ruw.user_id, ruw.workspace_id`,
94
115
  [userId, workspaceId, permissionKey]
@@ -168,8 +189,7 @@ function makeHandler(roleName, store) {
168
189
  if (!workspaceId) {
169
190
  return (0, import_core2.setApiResponse)(import_core2.HTTP.BAD_REQUEST, "WORKSPACE_REQUIRED", "Workspace context required");
170
191
  }
171
- const membership = await getMembership(ctx.user.id, workspaceId, store);
172
- if (!membership || !allowed.includes(membership.roleName)) {
192
+ if (!await hasAnyRole(ctx.user.id, workspaceId, allowed, store)) {
173
193
  return (0, import_core2.setApiResponse)(import_core2.HTTP.FORBIDDEN, "FORBIDDEN", "Insufficient role");
174
194
  }
175
195
  return next();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/index.ts","../../src/middlewares/require-role.ts","../../src/services/membership.ts","../../src/constants.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 { getMembership } 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\tconst membership = await getMembership(ctx.user.id, workspaceId, store);\n\t\tif (!membership || !allowed.includes(membership.roleName)) {\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 { 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\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 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","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 { 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.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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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/services/membership.ts","../../src/constants.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 { 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","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 { 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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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"]}
@@ -31,6 +31,8 @@ async function hasRole(userId, workspaceId, roleName, store) {
31
31
  WHERE ruw.user_id = $1
32
32
  AND ruw.workspace_id = $2
33
33
  AND r.name = $3
34
+ AND r.is_system = true
35
+ AND r.active = true
34
36
  AND ruw.removed = false
35
37
  AND ruw.suspended = false
36
38
  ) AS exists`,
@@ -38,6 +40,24 @@ async function hasRole(userId, workspaceId, roleName, store) {
38
40
  );
39
41
  return row?.exists ?? false;
40
42
  }
43
+ async function hasAnyRole(userId, workspaceId, roleNames, store) {
44
+ if (roleNames.length === 0) return false;
45
+ const [row] = await store.query(
46
+ `SELECT EXISTS (
47
+ SELECT 1
48
+ FROM fonderie_role_user_workspaces ruw
49
+ JOIN fonderie_roles r ON r.id = ruw.role_id
50
+ WHERE ruw.user_id = $1
51
+ AND ruw.workspace_id = $2
52
+ AND r.name = ANY($3)
53
+ AND r.active = true
54
+ AND ruw.removed = false
55
+ AND ruw.suspended = false
56
+ ) AS exists`,
57
+ [userId, workspaceId, roleNames]
58
+ );
59
+ return row?.exists ?? false;
60
+ }
41
61
 
42
62
  // src/constants.ts
43
63
  import { OPERATIONS } from "@fonderie/core";
@@ -62,6 +82,7 @@ async function checkPermission(userId, workspaceId, permissionKey, operation, st
62
82
  AND rp.permission_key = $3
63
83
  AND ruw.removed = false
64
84
  AND ruw.suspended = false
85
+ AND r.active = true
65
86
  AND (r.workspace_id = $2 OR r.is_system = true)
66
87
  GROUP BY ruw.user_id, ruw.workspace_id`,
67
88
  [userId, workspaceId, permissionKey]
@@ -141,8 +162,7 @@ function makeHandler(roleName, store) {
141
162
  if (!workspaceId) {
142
163
  return setApiResponse(HTTP.BAD_REQUEST, "WORKSPACE_REQUIRED", "Workspace context required");
143
164
  }
144
- const membership = await getMembership(ctx.user.id, workspaceId, store);
145
- if (!membership || !allowed.includes(membership.roleName)) {
165
+ if (!await hasAnyRole(ctx.user.id, workspaceId, allowed, store)) {
146
166
  return setApiResponse(HTTP.FORBIDDEN, "FORBIDDEN", "Insufficient role");
147
167
  }
148
168
  return next();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/middlewares/require-role.ts","../../src/services/membership.ts","../../src/constants.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 { getMembership } 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\tconst membership = await getMembership(ctx.user.id, workspaceId, store);\n\t\tif (!membership || !allowed.includes(membership.roleName)) {\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 { 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\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 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","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 { 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.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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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/services/membership.ts","../../src/constants.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 { 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","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 { 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","import type { IStoreAdapter } from '@fonderie/store';\n\nimport type { Operation } from './types';\nimport type { IPermissionsConfig } from './config';\nimport { getMembership, hasRole } from './services/membership';\nimport { checkPermission } from './services/permissions';\n\nexport class PermissionsEngine {\n\tprivate superRole: string;\n\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tconfig: IPermissionsConfig = {},\n\t) {\n\t\tthis.superRole = config.superRole ?? 'owner';\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\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\treturn checkPermission(userId, workspaceId, permissionKey, operation, this.store);\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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) =>\n\t\t\t\tcheckPermission(userId, workspaceId, c.permissionKey, c.operation, this.store),\n\t\t\t),\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\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.0.2",
3
+ "version": "5.0.4",
4
4
  "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.",
5
5
  "keywords": [
6
6
  "fonderiejs",
@@ -56,15 +56,15 @@
56
56
  "check": "biome check --write src"
57
57
  },
58
58
  "peerDependencies": {
59
- "@fonderie/core": "^0.7.0",
59
+ "@fonderie/core": "^0.8.0",
60
60
  "@fonderie/store": "^0.2.0"
61
61
  },
62
62
  "devDependencies": {
63
63
  "@fonderie/core": "../core",
64
64
  "@fonderie/store": "../store",
65
- "@types/node": "^26.4.0",
65
+ "@types/node": "^26.4.1",
66
66
  "tsup": "^8.5.1",
67
- "tsx": "^4.23.12",
67
+ "tsx": "^4.23.13",
68
68
  "typescript": "^6.0.3"
69
69
  },
70
70
  "publishConfig": {