@warlock.js/access 5.12.0 → 5.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,21 @@ All notable changes to `@warlock.js/access` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.13.0 - 2026-09-17
8
+
9
+ ### Upgrading
10
+
11
+ - Access checks are now deny-by-default: an instance-level check for a permission with no registered ABAC policy DENIES instead of falling back to the RBAC grant alone. If you rely on the old fail-open fallback, set `strictPolicies: false` in `src/config/access.ts`; otherwise register the `definePolicy(...)` calls the new log warnings name.
12
+
13
+ ### Breaking
14
+
15
+ - **BREAKING:** `strictPolicies` now defaults to `true`. An instance-level check (`{ resource }`) for a permission with no registered ABAC policy now DENIES by default instead of silently falling back to the RBAC grant alone. Set `strictPolicies: false` in `src/config/access.ts` to restore the old fallback, or register the missing `definePolicy(...)` calls the log warnings point to.
16
+
17
+ ### Fixed
18
+
19
+ - Restored the portable `typecheck` script and compile access as ESM so workspace source imports using `import.meta` are valid.
20
+ - An unpoliced instance-level check was fail-open (allowed via the RBAC grant alone) unless `strictPolicies` was explicitly enabled, which could silently reach a resource-scoped IDOR for a forgotten `definePolicy` call. It now fails CLOSED by default; `strictPolicies: false` still restores the old fallback and warns once per permission naming the missing policy and the config key.
21
+
7
22
  ## 5.12.0 - 2026-09-16
8
23
 
9
24
  ### Changed
package/cjs/index.cjs CHANGED
@@ -69,9 +69,9 @@ const accessConfig = {
69
69
  cacheTtl() {
70
70
  return current?.cache?.ttl ?? "10m";
71
71
  },
72
- /** Whether an unpoliced instance-level check should throw instead of warn. */
72
+ /** Whether an unpoliced instance-level check should deny instead of falling back to the RBAC grant. */
73
73
  strictPolicies() {
74
- return current?.strictPolicies ?? false;
74
+ return current?.strictPolicies ?? true;
75
75
  },
76
76
  /** Ambient tenant for this user, delegated to the resolver's optional hook. */
77
77
  tenant(user) {
@@ -179,31 +179,38 @@ async function flush(user, tenant) {
179
179
  }
180
180
  }
181
181
  /**
182
- * Surface a resource-scoped check that has no registered ABAC policy: it
183
- * silently falls back to the RBAC grant alone, which is a plausible IDOR if
184
- * the permission was meant to be resource-scoped (typo'd permission name, or
185
- * a forgotten `import "./policies"` side-effect — the single most common
186
- * mistake app teams make with this package, per the `define-policies` skill).
182
+ * Surface a resource-scoped check that has no registered ABAC policy, and
183
+ * report whether the check should now DENY.
187
184
  *
188
- * In strict mode ({@link AccessConfigurations.strictPolicies}) this throws
189
- * {@link AccessConfigError} instead, which `check()`'s catch re-throws loud
190
- * rather than folding into the fail-closed deny a misconfiguration, not a
191
- * runtime denial.
185
+ * `strictPolicies` defaults to `true`: a forgotten `definePolicy` call (typo'd
186
+ * permission name, or a missing `import "./policies"` side-effect — the
187
+ * single most common mistake app teams make with this package, per the
188
+ * `define-policies` skill) must never fail OPEN, so the check denies.
192
189
  *
193
- * Warns once per permission (not once per call) so a hot request path
194
- * doesn't spam logs mirrors cascade's `warnUndeclaredSensitiveFields`.
190
+ * `strictPolicies: false` explicitly restores the pre-5.13 behavior: fall
191
+ * back to the RBAC grant alone (ALLOW), which is a plausible IDOR if the
192
+ * permission was meant to be resource-scoped.
193
+ *
194
+ * Either way this warns once per permission (not once per call) so a hot
195
+ * request path doesn't spam logs — mirrors cascade's
196
+ * `warnUndeclaredSensitiveFields`.
195
197
  */
196
- function warnUnpoliced(permission) {
197
- if (accessConfig.strictPolicies()) throw new AccessConfigError(`@warlock.js/access: instance-level check for permission "${permission}" has no registered ABAC policy — refusing to fall back to the RBAC grant alone while strictPolicies is enabled. Register one with definePolicy("${permission}", ...), or make sure the module that calls definePolicy is actually imported.`);
198
- if (warnedUnpolicedPermissions.has(permission)) return;
199
- warnedUnpolicedPermissions.add(permission);
200
- _warlock_js_logger.log.warn("access", "unpoliced-check", `Instance-level check for permission "${permission}" has no registered ABAC policy — falling back to the RBAC grant alone, so any holder of "${permission}" can act on ANY resource. If this permission is meant to be resource-scoped, register a policy with definePolicy("${permission}", ...) (and confirm the module that calls it is imported). Set strictPolicies: true in the access config to turn this into a thrown error instead.`);
198
+ function unpolicedShouldDeny(permission) {
199
+ const strict = accessConfig.strictPolicies();
200
+ if (!warnedUnpolicedPermissions.has(permission)) {
201
+ warnedUnpolicedPermissions.add(permission);
202
+ _warlock_js_logger.log.warn("access", "unpoliced-check", strict ? `Instance-level check for permission "${permission}" has no registered ABAC policy — DENYING (strictPolicies defaults to true as of 5.13). Register one with definePolicy("${permission}", ...), or make sure the module that calls definePolicy is actually imported. Set strictPolicies: false in the access config to restore the pre-5.13 RBAC-only fallback (ALLOWS — not recommended).` : `Instance-level check for permission "${permission}" has no registered ABAC policy — falling back to the RBAC grant alone (strictPolicies: false), so any holder of "${permission}" can act on ANY resource. If this permission is meant to be resource-scoped, register a policy with definePolicy("${permission}", ...) (and confirm the module that calls it is imported), or remove strictPolicies: false to deny by default.`);
203
+ }
204
+ return strict;
201
205
  }
202
206
  /**
203
207
  * The core decision: does the user hold `permission`, and — when a `resource`
204
208
  * is supplied — does its policy pass too?
205
209
  *
206
- * Fails CLOSED: any error while resolving the decision denies (and logs).
210
+ * Fails CLOSED: any error while resolving the decision denies (and logs). An
211
+ * instance-level check for a permission with no registered policy also fails
212
+ * CLOSED by default ({@link AccessConfigurations.strictPolicies}) rather than
213
+ * falling back to the RBAC grant alone.
207
214
  */
208
215
  async function check(user, permission, context = {}) {
209
216
  try {
@@ -211,7 +218,9 @@ async function check(user, permission, context = {}) {
211
218
  const permissions = await permissionsFor(user, tenant);
212
219
  if (!matchesPermission(permissions, permission)) return false;
213
220
  const policy = getPolicy(permission);
214
- if (context.resource !== void 0 && policy === void 0) warnUnpoliced(permission);
221
+ if (context.resource !== void 0 && policy === void 0) {
222
+ if (unpolicedShouldDeny(permission)) return false;
223
+ }
215
224
  if (policy === void 0 || context.resource === void 0) return true;
216
225
  const roles = await rolesFor(user, tenant);
217
226
  return Boolean(await policy(user, context.resource, {
@@ -376,6 +385,8 @@ function toRoleList(value) {
376
385
  * new DefaultAccessResolver(rolesMap, (user) => user.get("memberRoles"));
377
386
  */
378
387
  var DefaultAccessResolver = class {
388
+ roles;
389
+ readRoles;
379
390
  constructor(roles, readRoles = (user) => user.get("roles") ?? user.get("role")) {
380
391
  this.roles = roles;
381
392
  this.readRoles = readRoles;
package/cjs/index.cjs.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.cjs","names":["cache","ForbiddenError","flushUser"],"sources":["../../../../../../access/src/utils/access-error-codes.ts","../../../../../../access/src/utils/access-config-error.ts","../../../../../../access/src/services/access-config.ts","../../../../../../access/src/services/matcher.ts","../../../../../../access/src/services/policies.ts","../../../../../../access/src/services/engine.ts","../../../../../../access/src/services/access.ts","../../../../../../access/src/middleware/gate.middleware.ts","../../../../../../access/src/services/default-resolver.ts"],"sourcesContent":["/**\n * Wire-stable error codes emitted by `@warlock.js/access`. Map these in your\n * error transformer the way you map `AuthErrorCodes`. A value change here is a\n * breaking change for any client that reacts to the code.\n */\nexport enum AccessErrorCodes {\n /** EC100 — authenticated, but missing the required permission. */\n Forbidden = \"EC100\",\n}\n","/**\n * Thrown when `@warlock.js/access` is misconfigured (e.g. no resolver). Unlike a\n * runtime resolution failure — which fails CLOSED (denies) — a config error is a\n * developer mistake, so the engine re-throws it LOUD instead of silently denying.\n */\nexport class AccessConfigError extends Error {\n public constructor(message: string) {\n super(message);\n this.name = \"AccessConfigError\";\n }\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { AccessConfigurations } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\n\n/**\n * The active configuration, fed once at boot by the framework's access connector\n * via {@link setAccessConfig} (the connector reads `src/config/access.ts`).\n * Reading it before it's set throws {@link AccessConfigError} — a loud\n * misconfiguration, never a silent deny.\n */\nlet current: AccessConfigurations | undefined;\n\n/**\n * Register the access configuration. The framework's access connector calls this\n * at boot with the default export of `src/config/access.ts`. It validates that a\n * `resolver` is present, so a misconfigured authorization layer fails at STARTUP\n * rather than on the first protected request.\n *\n * @example\n * setAccessConfig({ resolver: new DefaultAccessResolver({ admin: [\"*\"] }) });\n */\nexport function setAccessConfig(configuration: AccessConfigurations): void {\n if (!configuration?.resolver) {\n throw new AccessConfigError(\n \"No access resolver configured. Set `resolver` in src/config/access.ts to a class \" +\n \"implementing AccessResolver — the ejected DatabaseAccessResolver, or \" +\n \"`new DefaultAccessResolver(rolesMap)` for a fixed-role catalog.\",\n );\n }\n\n current = configuration;\n}\n\n/** Clear the active config. Used by the connector on dev-restart and by tests. */\nexport function resetAccessConfig(): void {\n current = undefined;\n}\n\n/** Accessors over the active access configuration. */\nexport const accessConfig = {\n /**\n * The configured resolver. Throws {@link AccessConfigError} if access was never\n * configured (no `src/config/access.ts`, or the connector hasn't run).\n */\n resolver(): AccessResolver {\n if (!current) {\n throw new AccessConfigError(\n \"@warlock.js/access is not configured. Add src/config/access.ts (exporting a `resolver`) \" +\n \"— it is wired at boot by the access connector.\",\n );\n }\n\n return current.resolver;\n },\n\n /** Cache TTL for resolved permission sets. */\n cacheTtl(): string | number {\n return current?.cache?.ttl ?? \"10m\";\n },\n\n /** Whether an unpoliced instance-level check should throw instead of warn. */\n strictPolicies(): boolean {\n return current?.strictPolicies ?? false;\n },\n\n /** Ambient tenant for this user, delegated to the resolver's optional hook. */\n tenant(user: Auth): string | undefined {\n return this.resolver().resolveTenant?.(user);\n },\n};\n","/**\n * Whether a set of granted permission patterns covers a requested permission.\n *\n * Supports three forms:\n * - exact — `\"orders.update\"` covers `\"orders.update\"`\n * - global — `\"*\"` covers everything (the super-grant)\n * - prefix wildcard — `\"orders.*\"` covers `\"orders.update\"` and `\"orders.update.status\"`\n *\n * `\"orders.*\"` does NOT cover the bare `\"orders\"` (no trailing segment).\n */\nexport function matchesPermission(granted: string[], permission: string): boolean {\n for (const pattern of granted) {\n if (pattern === \"*\" || pattern === permission) {\n return true;\n }\n\n // \"orders.*\" → prefix \"orders.\" — matches any nested permission.\n if (pattern.endsWith(\".*\") && permission.startsWith(pattern.slice(0, -1))) {\n return true;\n }\n }\n\n return false;\n}\n","import type { PolicyFn } from \"../contracts/types\";\n\nconst policies = new Map<string, PolicyFn>();\n\n/**\n * Register an ABAC condition for a permission. It runs ON TOP of the RBAC grant\n * whenever an authorization check carries a `resource` — letting you express\n * \"...but only their own / only in their tenant / only while pending\".\n *\n * @example\n * definePolicy(\"orders.update\", (user, order) => order.get(\"customer_id\") === user.id);\n */\nexport function definePolicy(permission: string, policy: PolicyFn): void {\n policies.set(permission, policy);\n}\n\n/** The policy registered for a permission, if any. */\nexport function getPolicy(permission: string): PolicyFn | undefined {\n return policies.get(permission);\n}\n\n/** Remove every registered policy. Primarily for tests. */\nexport function clearPolicies(): void {\n policies.clear();\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { AccessContext } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\nimport { accessConfig } from \"./access-config\";\nimport { matchesPermission } from \"./matcher\";\nimport { getPolicy } from \"./policies\";\n\nconst PERMISSIONS_PREFIX = \"access.perms.\";\nconst ROLES_PREFIX = \"access.roles.\";\n\n/**\n * Permissions that have already triggered the \"unpoliced instance check\"\n * warning below — kept so a hot path doesn't log once per request.\n */\nconst warnedUnpolicedPermissions = new Set<string>();\n\n/** Reset the once-per-permission warning tracker. Primarily for tests. */\nexport function resetUnpolicedWarnings(): void {\n warnedUnpolicedPermissions.clear();\n}\n\n/** Resolve the effective tenant for a check (explicit → ambient → undefined). */\nfunction tenantFor(user: Auth, context: AccessContext): string | undefined {\n return context.tenant ?? accessConfig.tenant(user);\n}\n\nfunction cacheKey(prefix: string, user: Auth, tenant?: string): string {\n // Encode each segment so a `.` in an id or tenant can't collide two distinct\n // principals onto the same cache key (and serve one's grants to the other).\n const segment = (value: unknown) => encodeURIComponent(String(value));\n\n return `${prefix}${segment(user.userType)}.${segment(user.id)}.${segment(tenant ?? \"_\")}`;\n}\n\n/**\n * Read a list through the cache, falling back to the loader.\n *\n * The cache is **best-effort**: a cache failure degrades to the loader (the\n * source of truth) and is logged, never denied. A loader failure propagates —\n * so the DECISION fails closed in {@link check}.\n */\nasync function cached(\n key: string,\n loader: () => Promise<string[]>,\n): Promise<string[]> {\n try {\n const hit = await cache.get<string[]>(key);\n\n if (hit) return hit;\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n const value = await loader();\n\n try {\n await cache.set(key, value, { ttl: accessConfig.cacheTtl() });\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n return value;\n}\n\n/** The user's effective permission set for a tenant (cached). */\nexport function permissionsFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(PERMISSIONS_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolvePermissions(user, tenant),\n );\n}\n\n/** The user's roles for a tenant (cached). */\nexport function rolesFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(ROLES_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolveRoles(user, tenant),\n );\n}\n\n/** Drop the cached role/permission sets for a user (after any out-of-band role change). */\nexport async function flush(user: Auth, tenant?: string): Promise<void> {\n try {\n await cache.remove(cacheKey(PERMISSIONS_PREFIX, user, tenant));\n await cache.remove(cacheKey(ROLES_PREFIX, user, tenant));\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n}\n\n/**\n * Surface a resource-scoped check that has no registered ABAC policy: it\n * silently falls back to the RBAC grant alone, which is a plausible IDOR if\n * the permission was meant to be resource-scoped (typo'd permission name, or\n * a forgotten `import \"./policies\"` side-effect — the single most common\n * mistake app teams make with this package, per the `define-policies` skill).\n *\n * In strict mode ({@link AccessConfigurations.strictPolicies}) this throws\n * {@link AccessConfigError} instead, which `check()`'s catch re-throws loud\n * rather than folding into the fail-closed deny — a misconfiguration, not a\n * runtime denial.\n *\n * Warns once per permission (not once per call) so a hot request path\n * doesn't spam logs — mirrors cascade's `warnUndeclaredSensitiveFields`.\n */\nfunction warnUnpoliced(permission: string): void {\n if (accessConfig.strictPolicies()) {\n throw new AccessConfigError(\n `@warlock.js/access: instance-level check for permission \"${permission}\" has no registered ` +\n `ABAC policy — refusing to fall back to the RBAC grant alone while strictPolicies is enabled. ` +\n `Register one with definePolicy(\"${permission}\", ...), or make sure the module that calls ` +\n `definePolicy is actually imported.`,\n );\n }\n\n if (warnedUnpolicedPermissions.has(permission)) return;\n warnedUnpolicedPermissions.add(permission);\n\n log.warn(\n \"access\",\n \"unpoliced-check\",\n `Instance-level check for permission \"${permission}\" has no registered ABAC policy — falling ` +\n `back to the RBAC grant alone, so any holder of \"${permission}\" can act on ANY resource. If ` +\n `this permission is meant to be resource-scoped, register a policy with ` +\n `definePolicy(\"${permission}\", ...) (and confirm the module that calls it is imported). ` +\n `Set strictPolicies: true in the access config to turn this into a thrown error instead.`,\n );\n}\n\n/**\n * The core decision: does the user hold `permission`, and — when a `resource`\n * is supplied — does its policy pass too?\n *\n * Fails CLOSED: any error while resolving the decision denies (and logs).\n */\nexport async function check(\n user: Auth,\n permission: string,\n context: AccessContext = {},\n): Promise<boolean> {\n try {\n const tenant = tenantFor(user, context);\n const permissions = await permissionsFor(user, tenant);\n\n if (!matchesPermission(permissions, permission)) return false;\n\n const policy = getPolicy(permission);\n\n // A policy only runs on an instance check (a resource was supplied);\n // class-level checks (`gate`) stop at the grant.\n if (context.resource !== undefined && policy === undefined) {\n warnUnpoliced(permission);\n }\n\n if (policy === undefined || context.resource === undefined) return true;\n\n const roles = await rolesFor(user, tenant);\n\n return Boolean(\n await policy(user, context.resource, {\n ...context,\n tenant,\n hasRole: (role) => roles.includes(role),\n hasPermission: (perm) => matchesPermission(permissions, perm),\n }),\n );\n } catch (error) {\n if (error instanceof AccessConfigError) throw error; // misconfig is loud, not a silent deny\n\n log.error(\"access\", \"check\", error);\n\n return false;\n }\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport { ForbiddenError, t } from \"@warlock.js/core\";\nimport type { AccessContext } from \"../contracts/types\";\nimport { AccessErrorCodes } from \"../utils/access-error-codes\";\nimport { check, flush as flushUser, rolesFor } from \"./engine\";\n\n/** Whether the user holds `permission` (and passes its policy when a resource is given). */\nexport function can(\n user: Auth,\n permission: string,\n context?: AccessContext,\n): Promise<boolean> {\n return check(user, permission, context);\n}\n\n/** Inverse of {@link can}. */\nexport async function cannot(\n user: Auth,\n permission: string,\n context?: AccessContext,\n): Promise<boolean> {\n return !(await can(user, permission, context));\n}\n\n/** Whether the user holds EVERY listed permission. Short-circuits on the first miss. */\nexport async function canAll(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<boolean> {\n for (const permission of permissions) {\n if (!(await can(user, permission, context))) return false;\n }\n\n return true;\n}\n\n/** Whether the user holds ANY listed permission. Short-circuits on the first hit. */\nexport async function canAny(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<boolean> {\n for (const permission of permissions) {\n if (await can(user, permission, context)) return true;\n }\n\n return false;\n}\n\nfunction forbidden(): never {\n throw new ForbiddenError(t(\"access.errors.forbidden\"), {\n errorCode: AccessErrorCodes.Forbidden,\n });\n}\n\n/** Assert the user holds `permission`; throw `ForbiddenError` (403) otherwise. */\nexport async function authorize(\n user: Auth,\n permission: string,\n context?: AccessContext,\n): Promise<void> {\n if (!(await can(user, permission, context))) forbidden();\n}\n\n/** Assert the user holds EVERY listed permission; throw otherwise. */\nexport async function authorizeAll(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<void> {\n if (!(await canAll(user, permissions, context))) forbidden();\n}\n\n/** Assert the user holds ANY listed permission; throw otherwise. */\nexport async function authorizeAny(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<void> {\n if (!(await canAny(user, permissions, context))) forbidden();\n}\n\n/** Whether the user has the given role. */\nexport async function hasRole(\n user: Auth,\n role: string,\n tenant?: string,\n): Promise<boolean> {\n return (await rolesFor(user, tenant)).includes(role);\n}\n\n/** Whether the user has ANY of the given roles. */\nexport async function hasAnyRole(\n user: Auth,\n roles: string[],\n tenant?: string,\n): Promise<boolean> {\n const held = await rolesFor(user, tenant);\n\n return roles.some((role) => held.includes(role));\n}\n\n/** Whether the user has ALL of the given roles. */\nexport async function hasAllRoles(\n user: Auth,\n roles: string[],\n tenant?: string,\n): Promise<boolean> {\n const held = await rolesFor(user, tenant);\n\n return roles.every((role) => held.includes(role));\n}\n\n/**\n * The access facade — every check plus `flush` to drop a user's cached set.\n * Role assignment lives on the ejected `UserRole` model in app-land; callers do\n * `UserRole.assign(...)` then `access.flush(user, tenant)`.\n *\n * @example\n * import { access } from \"@warlock.js/access\";\n * if (await access.can(user, \"orders.update\", { resource: order })) { ... }\n * await access.flush(user, \"tenant-1\");\n */\nexport const access = {\n can,\n cannot,\n canAll,\n canAny,\n authorize,\n authorizeAll,\n authorizeAny,\n hasRole,\n hasAnyRole,\n hasAllRoles,\n /** Drop the user's cached role/permission set (call after you mutate role rows). */\n flush: flushUser,\n};\n","import type { Auth } from \"@warlock.js/auth\";\nimport { t, type HttpContext, type Middleware, type Response } from \"@warlock.js/core\";\nimport type { ModelSchema } from \"@warlock.js/cascade\";\nimport { can, canAll, canAny } from \"../services/access\";\nimport { AccessErrorCodes } from \"../utils/access-error-codes\";\n\n/*\n * @warlock.js/access does NOT augment `RequestUser`, deliberately. An\n * interface can extend at most one class hierarchy, so a library-side\n * `interface RequestUser extends Auth<ModelSchema>` PRE-CLAIMS the one\n * extends slot every app needs for its own model — the app's\n * `interface RequestUser extends User {}` then fails with TS2320, and the\n * app can never see its own auth model on `request.locals.user`. (Core\n * documents the same hazard for member-level augmentation in\n * `http/middleware/utils/idempotency-key.ts`.)\n *\n * The contract instead: an app using gates augments `RequestUser` (declared\n * by `@warlock.js/auth`, which also augments core's `RequestLocals` with the\n * `user` key) to its OWN `Auth`-derived model; `authUserOf` below is the\n * single boundary where access asserts that contract to satisfy `can()`'s\n * parameter type.\n */\n\n/**\n * The one place access bridges `request.locals.user` (app-augmented via\n * `@warlock.js/auth`'s `RequestUser`, empty by default) to the\n * `Auth<ModelSchema>` that `can()`/`canAny()`/`canAll()` require. The\n * assertion is the contract boundary, not a shortcut: the app's `RequestUser`\n * augmentation promises an `Auth`-derived model, and the auth middleware\n * placed exactly that instance at `request.locals.user` at runtime. Kept to\n * this single accessor so the promise is asserted once, visibly, instead of\n * scattered.\n */\nfunction authUserOf(request: HttpContext[\"request\"]): Auth<ModelSchema> | undefined {\n return request.locals.user as unknown as Auth<ModelSchema> | undefined;\n}\n\nfunction deny(response: Response) {\n return response.forbidden({\n error: t(\"access.errors.forbidden\"),\n errorCode: AccessErrorCodes.Forbidden,\n });\n}\n\n/**\n * Gate a route on a single permission (class-level). Stack it AFTER\n * `authMiddleware`, which sets `request.locals.user`.\n *\n * @example\n * router.post(\"/orders\", createOrder, {\n * middleware: [authMiddleware([]), gate(\"orders.create\")],\n * });\n */\nexport function gate(permission: string): Middleware {\n return async ({ request, response }: HttpContext) => {\n const user = authUserOf(request);\n\n if (!user) return deny(response);\n\n if (!(await can(user, permission))) return deny(response);\n };\n}\n\n/** Gate a route on holding ANY of the listed permissions. */\nexport function gateAny(permissions: string[]): Middleware {\n return async ({ request, response }: HttpContext) => {\n const user = authUserOf(request);\n\n if (!user) return deny(response);\n\n if (!(await canAny(user, permissions))) return deny(response);\n };\n}\n\n/** Gate a route on holding ALL of the listed permissions. */\nexport function gateAll(permissions: string[]): Middleware {\n return async ({ request, response }: HttpContext) => {\n const user = authUserOf(request);\n\n if (!user) return deny(response);\n\n if (!(await canAll(user, permissions))) return deny(response);\n };\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { RolesMap } from \"../contracts/types\";\n\n/** Coerce a roles value (`undefined` / single string / array) into a string array. */\nfunction toRoleList(value: unknown): string[] {\n if (Array.isArray(value)) return value as string[];\n\n if (typeof value === \"string\" && value.length > 0) return [value];\n\n return [];\n}\n\n/**\n * The zero-config resolver: reads a user's roles from a model field and maps\n * them to permissions through your `roles` catalog. Works out of the box for\n * both a `roles` array column and a single `role` column.\n *\n * Swap `readRoles` to read from anywhere else (a relation, a token claim); for\n * a different storage shape entirely, implement {@link AccessResolver} directly.\n *\n * @example\n * new DefaultAccessResolver({ editor: [\"orders.*\"], viewer: [\"orders.view\"] });\n * new DefaultAccessResolver(rolesMap, (user) => user.get(\"memberRoles\"));\n */\nexport class DefaultAccessResolver implements AccessResolver {\n public constructor(\n private readonly roles: RolesMap,\n private readonly readRoles: (user: Auth) => unknown = (user) =>\n user.get(\"roles\") ?? user.get(\"role\"),\n ) {}\n\n public async resolveRoles(user: Auth): Promise<string[]> {\n return toRoleList(await this.readRoles(user));\n }\n\n public async resolvePermissions(user: Auth): Promise<string[]> {\n const roles = await this.resolveRoles(user);\n\n // Guard against a role named `__proto__` / `constructor` resolving to an\n // inherited (non-array) value.\n return roles.flatMap((role) => {\n if (!Object.prototype.hasOwnProperty.call(this.roles, role)) {\n return [];\n }\n\n // `hasOwnProperty` proves the KEY is present; it says nothing about the\n // value, which under `noUncheckedIndexedAccess` is `string[] |\n // undefined`. Falling back to NO permissions is the only safe direction\n // here: this function's result is what the caller checks before granting\n // access, so an unreadable role must grant nothing rather than be\n // asserted into something.\n return this.roles[role] ?? [];\n });\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.cjs","names":["cache","ForbiddenError","flushUser"],"sources":["../../../../../../access/src/utils/access-error-codes.ts","../../../../../../access/src/utils/access-config-error.ts","../../../../../../access/src/services/access-config.ts","../../../../../../access/src/services/matcher.ts","../../../../../../access/src/services/policies.ts","../../../../../../access/src/services/engine.ts","../../../../../../access/src/services/access.ts","../../../../../../access/src/middleware/gate.middleware.ts","../../../../../../access/src/services/default-resolver.ts"],"sourcesContent":["/**\n * Wire-stable error codes emitted by `@warlock.js/access`. Map these in your\n * error transformer the way you map `AuthErrorCodes`. A value change here is a\n * breaking change for any client that reacts to the code.\n */\nexport enum AccessErrorCodes {\n /** EC100 — authenticated, but missing the required permission. */\n Forbidden = \"EC100\",\n}\n","/**\n * Thrown when `@warlock.js/access` is misconfigured (e.g. no resolver). Unlike a\n * runtime resolution failure — which fails CLOSED (denies) — a config error is a\n * developer mistake, so the engine re-throws it LOUD instead of silently denying.\n */\nexport class AccessConfigError extends Error {\n public constructor(message: string) {\n super(message);\n this.name = \"AccessConfigError\";\n }\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { AccessConfigurations } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\n\n/**\n * The active configuration, fed once at boot by the framework's access connector\n * via {@link setAccessConfig} (the connector reads `src/config/access.ts`).\n * Reading it before it's set throws {@link AccessConfigError} — a loud\n * misconfiguration, never a silent deny.\n */\nlet current: AccessConfigurations | undefined;\n\n/**\n * Register the access configuration. The framework's access connector calls this\n * at boot with the default export of `src/config/access.ts`. It validates that a\n * `resolver` is present, so a misconfigured authorization layer fails at STARTUP\n * rather than on the first protected request.\n *\n * @example\n * setAccessConfig({ resolver: new DefaultAccessResolver({ admin: [\"*\"] }) });\n */\nexport function setAccessConfig(configuration: AccessConfigurations): void {\n if (!configuration?.resolver) {\n throw new AccessConfigError(\n \"No access resolver configured. Set `resolver` in src/config/access.ts to a class \" +\n \"implementing AccessResolver — the ejected DatabaseAccessResolver, or \" +\n \"`new DefaultAccessResolver(rolesMap)` for a fixed-role catalog.\",\n );\n }\n\n current = configuration;\n}\n\n/** Clear the active config. Used by the connector on dev-restart and by tests. */\nexport function resetAccessConfig(): void {\n current = undefined;\n}\n\n/** Accessors over the active access configuration. */\nexport const accessConfig = {\n /**\n * The configured resolver. Throws {@link AccessConfigError} if access was never\n * configured (no `src/config/access.ts`, or the connector hasn't run).\n */\n resolver(): AccessResolver {\n if (!current) {\n throw new AccessConfigError(\n \"@warlock.js/access is not configured. Add src/config/access.ts (exporting a `resolver`) \" +\n \"— it is wired at boot by the access connector.\",\n );\n }\n\n return current.resolver;\n },\n\n /** Cache TTL for resolved permission sets. */\n cacheTtl(): string | number {\n return current?.cache?.ttl ?? \"10m\";\n },\n\n /** Whether an unpoliced instance-level check should deny instead of falling back to the RBAC grant. */\n strictPolicies(): boolean {\n return current?.strictPolicies ?? true;\n },\n\n /** Ambient tenant for this user, delegated to the resolver's optional hook. */\n tenant(user: Auth): string | undefined {\n return this.resolver().resolveTenant?.(user);\n },\n};\n","/**\n * Whether a set of granted permission patterns covers a requested permission.\n *\n * Supports three forms:\n * - exact — `\"orders.update\"` covers `\"orders.update\"`\n * - global — `\"*\"` covers everything (the super-grant)\n * - prefix wildcard — `\"orders.*\"` covers `\"orders.update\"` and `\"orders.update.status\"`\n *\n * `\"orders.*\"` does NOT cover the bare `\"orders\"` (no trailing segment).\n */\nexport function matchesPermission(granted: string[], permission: string): boolean {\n for (const pattern of granted) {\n if (pattern === \"*\" || pattern === permission) {\n return true;\n }\n\n // \"orders.*\" → prefix \"orders.\" — matches any nested permission.\n if (pattern.endsWith(\".*\") && permission.startsWith(pattern.slice(0, -1))) {\n return true;\n }\n }\n\n return false;\n}\n","import type { PolicyFn } from \"../contracts/types\";\n\nconst policies = new Map<string, PolicyFn>();\n\n/**\n * Register an ABAC condition for a permission. It runs ON TOP of the RBAC grant\n * whenever an authorization check carries a `resource` — letting you express\n * \"...but only their own / only in their tenant / only while pending\".\n *\n * @example\n * definePolicy(\"orders.update\", (user, order) => order.get(\"customer_id\") === user.id);\n */\nexport function definePolicy(permission: string, policy: PolicyFn): void {\n policies.set(permission, policy);\n}\n\n/** The policy registered for a permission, if any. */\nexport function getPolicy(permission: string): PolicyFn | undefined {\n return policies.get(permission);\n}\n\n/** Remove every registered policy. Primarily for tests. */\nexport function clearPolicies(): void {\n policies.clear();\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { AccessContext } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\nimport { accessConfig } from \"./access-config\";\nimport { matchesPermission } from \"./matcher\";\nimport { getPolicy } from \"./policies\";\n\nconst PERMISSIONS_PREFIX = \"access.perms.\";\nconst ROLES_PREFIX = \"access.roles.\";\n\n/**\n * Permissions that have already triggered the \"unpoliced instance check\"\n * warning below — kept so a hot path doesn't log once per request.\n */\nconst warnedUnpolicedPermissions = new Set<string>();\n\n/** Reset the once-per-permission warning tracker. Primarily for tests. */\nexport function resetUnpolicedWarnings(): void {\n warnedUnpolicedPermissions.clear();\n}\n\n/** Resolve the effective tenant for a check (explicit → ambient → undefined). */\nfunction tenantFor(user: Auth, context: AccessContext): string | undefined {\n return context.tenant ?? accessConfig.tenant(user);\n}\n\nfunction cacheKey(prefix: string, user: Auth, tenant?: string): string {\n // Encode each segment so a `.` in an id or tenant can't collide two distinct\n // principals onto the same cache key (and serve one's grants to the other).\n const segment = (value: unknown) => encodeURIComponent(String(value));\n\n return `${prefix}${segment(user.userType)}.${segment(user.id)}.${segment(tenant ?? \"_\")}`;\n}\n\n/**\n * Read a list through the cache, falling back to the loader.\n *\n * The cache is **best-effort**: a cache failure degrades to the loader (the\n * source of truth) and is logged, never denied. A loader failure propagates —\n * so the DECISION fails closed in {@link check}.\n */\nasync function cached(\n key: string,\n loader: () => Promise<string[]>,\n): Promise<string[]> {\n try {\n const hit = await cache.get<string[]>(key);\n\n if (hit) return hit;\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n const value = await loader();\n\n try {\n await cache.set(key, value, { ttl: accessConfig.cacheTtl() });\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n return value;\n}\n\n/** The user's effective permission set for a tenant (cached). */\nexport function permissionsFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(PERMISSIONS_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolvePermissions(user, tenant),\n );\n}\n\n/** The user's roles for a tenant (cached). */\nexport function rolesFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(ROLES_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolveRoles(user, tenant),\n );\n}\n\n/** Drop the cached role/permission sets for a user (after any out-of-band role change). */\nexport async function flush(user: Auth, tenant?: string): Promise<void> {\n try {\n await cache.remove(cacheKey(PERMISSIONS_PREFIX, user, tenant));\n await cache.remove(cacheKey(ROLES_PREFIX, user, tenant));\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n}\n\n/**\n * Surface a resource-scoped check that has no registered ABAC policy, and\n * report whether the check should now DENY.\n *\n * `strictPolicies` defaults to `true`: a forgotten `definePolicy` call (typo'd\n * permission name, or a missing `import \"./policies\"` side-effect — the\n * single most common mistake app teams make with this package, per the\n * `define-policies` skill) must never fail OPEN, so the check denies.\n *\n * `strictPolicies: false` explicitly restores the pre-5.13 behavior: fall\n * back to the RBAC grant alone (ALLOW), which is a plausible IDOR if the\n * permission was meant to be resource-scoped.\n *\n * Either way this warns once per permission (not once per call) so a hot\n * request path doesn't spam logs — mirrors cascade's\n * `warnUndeclaredSensitiveFields`.\n */\nfunction unpolicedShouldDeny(permission: string): boolean {\n const strict = accessConfig.strictPolicies();\n\n if (!warnedUnpolicedPermissions.has(permission)) {\n warnedUnpolicedPermissions.add(permission);\n\n log.warn(\n \"access\",\n \"unpoliced-check\",\n strict\n ? `Instance-level check for permission \"${permission}\" has no registered ABAC policy — ` +\n `DENYING (strictPolicies defaults to true as of 5.13). Register one with ` +\n `definePolicy(\"${permission}\", ...), or make sure the module that calls definePolicy is ` +\n `actually imported. Set strictPolicies: false in the access config to restore the pre-5.13 ` +\n `RBAC-only fallback (ALLOWS — not recommended).`\n : `Instance-level check for permission \"${permission}\" has no registered ABAC policy — falling ` +\n `back to the RBAC grant alone (strictPolicies: false), so any holder of \"${permission}\" can ` +\n `act on ANY resource. If this permission is meant to be resource-scoped, register a policy ` +\n `with definePolicy(\"${permission}\", ...) (and confirm the module that calls it is ` +\n `imported), or remove strictPolicies: false to deny by default.`,\n );\n }\n\n return strict;\n}\n\n/**\n * The core decision: does the user hold `permission`, and — when a `resource`\n * is supplied — does its policy pass too?\n *\n * Fails CLOSED: any error while resolving the decision denies (and logs). An\n * instance-level check for a permission with no registered policy also fails\n * CLOSED by default ({@link AccessConfigurations.strictPolicies}) rather than\n * falling back to the RBAC grant alone.\n */\nexport async function check(\n user: Auth,\n permission: string,\n context: AccessContext = {},\n): Promise<boolean> {\n try {\n const tenant = tenantFor(user, context);\n const permissions = await permissionsFor(user, tenant);\n\n if (!matchesPermission(permissions, permission)) return false;\n\n const policy = getPolicy(permission);\n\n // A policy only runs on an instance check (a resource was supplied);\n // class-level checks (`gate`) stop at the grant.\n if (context.resource !== undefined && policy === undefined) {\n if (unpolicedShouldDeny(permission)) return false;\n }\n\n if (policy === undefined || context.resource === undefined) return true;\n\n const roles = await rolesFor(user, tenant);\n\n return Boolean(\n await policy(user, context.resource, {\n ...context,\n tenant,\n hasRole: (role) => roles.includes(role),\n hasPermission: (perm) => matchesPermission(permissions, perm),\n }),\n );\n } catch (error) {\n if (error instanceof AccessConfigError) throw error; // misconfig is loud, not a silent deny\n\n log.error(\"access\", \"check\", error);\n\n return false;\n }\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport { ForbiddenError, t } from \"@warlock.js/core\";\nimport type { AccessContext } from \"../contracts/types\";\nimport { AccessErrorCodes } from \"../utils/access-error-codes\";\nimport { check, flush as flushUser, rolesFor } from \"./engine\";\n\n/** Whether the user holds `permission` (and passes its policy when a resource is given). */\nexport function can(\n user: Auth,\n permission: string,\n context?: AccessContext,\n): Promise<boolean> {\n return check(user, permission, context);\n}\n\n/** Inverse of {@link can}. */\nexport async function cannot(\n user: Auth,\n permission: string,\n context?: AccessContext,\n): Promise<boolean> {\n return !(await can(user, permission, context));\n}\n\n/** Whether the user holds EVERY listed permission. Short-circuits on the first miss. */\nexport async function canAll(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<boolean> {\n for (const permission of permissions) {\n if (!(await can(user, permission, context))) return false;\n }\n\n return true;\n}\n\n/** Whether the user holds ANY listed permission. Short-circuits on the first hit. */\nexport async function canAny(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<boolean> {\n for (const permission of permissions) {\n if (await can(user, permission, context)) return true;\n }\n\n return false;\n}\n\nfunction forbidden(): never {\n throw new ForbiddenError(t(\"access.errors.forbidden\"), {\n errorCode: AccessErrorCodes.Forbidden,\n });\n}\n\n/** Assert the user holds `permission`; throw `ForbiddenError` (403) otherwise. */\nexport async function authorize(\n user: Auth,\n permission: string,\n context?: AccessContext,\n): Promise<void> {\n if (!(await can(user, permission, context))) forbidden();\n}\n\n/** Assert the user holds EVERY listed permission; throw otherwise. */\nexport async function authorizeAll(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<void> {\n if (!(await canAll(user, permissions, context))) forbidden();\n}\n\n/** Assert the user holds ANY listed permission; throw otherwise. */\nexport async function authorizeAny(\n user: Auth,\n permissions: string[],\n context?: AccessContext,\n): Promise<void> {\n if (!(await canAny(user, permissions, context))) forbidden();\n}\n\n/** Whether the user has the given role. */\nexport async function hasRole(\n user: Auth,\n role: string,\n tenant?: string,\n): Promise<boolean> {\n return (await rolesFor(user, tenant)).includes(role);\n}\n\n/** Whether the user has ANY of the given roles. */\nexport async function hasAnyRole(\n user: Auth,\n roles: string[],\n tenant?: string,\n): Promise<boolean> {\n const held = await rolesFor(user, tenant);\n\n return roles.some((role) => held.includes(role));\n}\n\n/** Whether the user has ALL of the given roles. */\nexport async function hasAllRoles(\n user: Auth,\n roles: string[],\n tenant?: string,\n): Promise<boolean> {\n const held = await rolesFor(user, tenant);\n\n return roles.every((role) => held.includes(role));\n}\n\n/**\n * The access facade — every check plus `flush` to drop a user's cached set.\n * Role assignment lives on the ejected `UserRole` model in app-land; callers do\n * `UserRole.assign(...)` then `access.flush(user, tenant)`.\n *\n * @example\n * import { access } from \"@warlock.js/access\";\n * if (await access.can(user, \"orders.update\", { resource: order })) { ... }\n * await access.flush(user, \"tenant-1\");\n */\nexport const access = {\n can,\n cannot,\n canAll,\n canAny,\n authorize,\n authorizeAll,\n authorizeAny,\n hasRole,\n hasAnyRole,\n hasAllRoles,\n /** Drop the user's cached role/permission set (call after you mutate role rows). */\n flush: flushUser,\n};\n","import type { Auth } from \"@warlock.js/auth\";\nimport { t, type HttpContext, type Middleware, type Response } from \"@warlock.js/core\";\nimport type { ModelSchema } from \"@warlock.js/cascade\";\nimport { can, canAll, canAny } from \"../services/access\";\nimport { AccessErrorCodes } from \"../utils/access-error-codes\";\n\n/*\n * @warlock.js/access does NOT augment `RequestUser`, deliberately. An\n * interface can extend at most one class hierarchy, so a library-side\n * `interface RequestUser extends Auth<ModelSchema>` PRE-CLAIMS the one\n * extends slot every app needs for its own model — the app's\n * `interface RequestUser extends User {}` then fails with TS2320, and the\n * app can never see its own auth model on `request.locals.user`. (Core\n * documents the same hazard for member-level augmentation in\n * `http/middleware/utils/idempotency-key.ts`.)\n *\n * The contract instead: an app using gates augments `RequestUser` (declared\n * by `@warlock.js/auth`, which also augments core's `RequestLocals` with the\n * `user` key) to its OWN `Auth`-derived model; `authUserOf` below is the\n * single boundary where access asserts that contract to satisfy `can()`'s\n * parameter type.\n */\n\n/**\n * The one place access bridges `request.locals.user` (app-augmented via\n * `@warlock.js/auth`'s `RequestUser`, empty by default) to the\n * `Auth<ModelSchema>` that `can()`/`canAny()`/`canAll()` require. The\n * assertion is the contract boundary, not a shortcut: the app's `RequestUser`\n * augmentation promises an `Auth`-derived model, and the auth middleware\n * placed exactly that instance at `request.locals.user` at runtime. Kept to\n * this single accessor so the promise is asserted once, visibly, instead of\n * scattered.\n */\nfunction authUserOf(request: HttpContext[\"request\"]): Auth<ModelSchema> | undefined {\n return request.locals.user as unknown as Auth<ModelSchema> | undefined;\n}\n\nfunction deny(response: Response) {\n return response.forbidden({\n error: t(\"access.errors.forbidden\"),\n errorCode: AccessErrorCodes.Forbidden,\n });\n}\n\n/**\n * Gate a route on a single permission (class-level). Stack it AFTER\n * `authMiddleware`, which sets `request.locals.user`.\n *\n * @example\n * router.post(\"/orders\", createOrder, {\n * middleware: [authMiddleware([]), gate(\"orders.create\")],\n * });\n */\nexport function gate(permission: string): Middleware {\n return async ({ request, response }: HttpContext) => {\n const user = authUserOf(request);\n\n if (!user) return deny(response);\n\n if (!(await can(user, permission))) return deny(response);\n };\n}\n\n/** Gate a route on holding ANY of the listed permissions. */\nexport function gateAny(permissions: string[]): Middleware {\n return async ({ request, response }: HttpContext) => {\n const user = authUserOf(request);\n\n if (!user) return deny(response);\n\n if (!(await canAny(user, permissions))) return deny(response);\n };\n}\n\n/** Gate a route on holding ALL of the listed permissions. */\nexport function gateAll(permissions: string[]): Middleware {\n return async ({ request, response }: HttpContext) => {\n const user = authUserOf(request);\n\n if (!user) return deny(response);\n\n if (!(await canAll(user, permissions))) return deny(response);\n };\n}\n","import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { RolesMap } from \"../contracts/types\";\n\n/** Coerce a roles value (`undefined` / single string / array) into a string array. */\nfunction toRoleList(value: unknown): string[] {\n if (Array.isArray(value)) return value as string[];\n\n if (typeof value === \"string\" && value.length > 0) return [value];\n\n return [];\n}\n\n/**\n * The zero-config resolver: reads a user's roles from a model field and maps\n * them to permissions through your `roles` catalog. Works out of the box for\n * both a `roles` array column and a single `role` column.\n *\n * Swap `readRoles` to read from anywhere else (a relation, a token claim); for\n * a different storage shape entirely, implement {@link AccessResolver} directly.\n *\n * @example\n * new DefaultAccessResolver({ editor: [\"orders.*\"], viewer: [\"orders.view\"] });\n * new DefaultAccessResolver(rolesMap, (user) => user.get(\"memberRoles\"));\n */\nexport class DefaultAccessResolver implements AccessResolver {\n public constructor(\n private readonly roles: RolesMap,\n private readonly readRoles: (user: Auth) => unknown = (user) =>\n user.get(\"roles\") ?? user.get(\"role\"),\n ) {}\n\n public async resolveRoles(user: Auth): Promise<string[]> {\n return toRoleList(await this.readRoles(user));\n }\n\n public async resolvePermissions(user: Auth): Promise<string[]> {\n const roles = await this.resolveRoles(user);\n\n // Guard against a role named `__proto__` / `constructor` resolving to an\n // inherited (non-array) value.\n return roles.flatMap((role) => {\n if (!Object.prototype.hasOwnProperty.call(this.roles, role)) {\n return [];\n }\n\n // `hasOwnProperty` proves the KEY is present; it says nothing about the\n // value, which under `noUncheckedIndexedAccess` is `string[] |\n // undefined`. Falling back to NO permissions is the only safe direction\n // here: this function's result is what the caller checks before granting\n // access, so an unreadable role must grant nothing rather than be\n // asserted into something.\n return this.roles[role] ?? [];\n });\n }\n}\n"],"mappings":"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"}
@@ -33,15 +33,17 @@ type AccessConfigurations = {
33
33
  /** TTL for a cached set. @default "10m" */ttl?: string | number;
34
34
  };
35
35
  /**
36
- * When `true`, an instance-level check (a `resource` was supplied) for a
37
- * permission with no registered ABAC policy throws {@link AccessConfigError}
38
- * instead of warning and falling back to the RBAC grant alone. Off by
39
- * default so existing apps aren't broken by a missing `definePolicy` call;
40
- * turn it on once every ABAC-guarded permission has a policy registered, to
41
- * catch the next typo/forgotten-import at the source instead of at review
42
- * time.
36
+ * When `true` (the default), an instance-level check (a `resource` was
37
+ * supplied) for a permission with no registered ABAC policy DENIES instead
38
+ * of falling back to the RBAC grant alone a forgotten `definePolicy` call
39
+ * must never fail open. The miss is logged once per permission via the
40
+ * package's logger.
43
41
  *
44
- * @default false
42
+ * Set to `false` to explicitly restore the pre-5.13 behavior: fall back to
43
+ * the RBAC grant alone (ALLOWS — a plausible IDOR if the permission was
44
+ * meant to be resource-scoped). Still warns once per permission.
45
+ *
46
+ * @default true
45
47
  */
46
48
  strictPolicies?: boolean;
47
49
  };
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../access/src/contracts/types.ts"],"mappings":";;;;;KAIY,QAAA,GAAW,MAAM;AAA7B;;;;AAA6B;AAA7B,KAOY,aAAA;EAAa,iFAEvB,QAAA,YAFuB;EAIvB,MAAA;EAAA,CACC,GAAA;AAAA;;KAIS,aAAA,GAAgB,aAAa;EACvC,MAAA,WADuB;EAGvB,OAAA,GAAU,IAAA,sBAH6B;EAKvC,aAAA,GAAgB,UAAA;AAAA;;KAIN,QAAA,IACV,IAAA,EAAM,IAAA,EACN,QAAA,WACA,OAAA,EAAS,aAAA,eACI,OAAA;AAAA,KAEH,oBAAA;EAVM;;AAAkB;AAIpC;;EAYE,QAAA,EAAU,cAAc,EAXlB;EAcN,KAAA;IAXa,2CAaX,GAAA;EAAA;EAhBI;;;;;;;AAGc;AAEtB;;;EAyBE,cAAA;AAAA"}
1
+ {"version":3,"file":"types.d.mts","names":[],"sources":["../../../../../../../access/src/contracts/types.ts"],"mappings":";;;;;KAIY,QAAA,GAAW,MAAM;AAA7B;;;;AAA6B;AAA7B,KAOY,aAAA;EAAa,iFAEvB,QAAA,YAFuB;EAIvB,MAAA;EAAA,CACC,GAAA;AAAA;;KAIS,aAAA,GAAgB,aAAa;EACvC,MAAA,WADuB;EAGvB,OAAA,GAAU,IAAA,sBAH6B;EAKvC,aAAA,GAAgB,UAAA;AAAA;;KAIN,QAAA,IACV,IAAA,EAAM,IAAA,EACN,QAAA,WACA,OAAA,EAAS,aAAA,eACI,OAAA;AAAA,KAEH,oBAAA;EAVM;;AAAkB;AAIpC;;EAYE,QAAA,EAAU,cAAc,EAXlB;EAcN,KAAA;IAXa,2CAaX,GAAA;EAAA;EAhBI;;;;;;;AAGc;AAEtB;;;;;EA2BE,cAAA;AAAA"}
@@ -39,9 +39,9 @@ const accessConfig = {
39
39
  cacheTtl() {
40
40
  return current?.cache?.ttl ?? "10m";
41
41
  },
42
- /** Whether an unpoliced instance-level check should throw instead of warn. */
42
+ /** Whether an unpoliced instance-level check should deny instead of falling back to the RBAC grant. */
43
43
  strictPolicies() {
44
- return current?.strictPolicies ?? false;
44
+ return current?.strictPolicies ?? true;
45
45
  },
46
46
  /** Ambient tenant for this user, delegated to the resolver's optional hook. */
47
47
  tenant(user) {
@@ -1 +1 @@
1
- {"version":3,"file":"access-config.mjs","names":[],"sources":["../../../../../../../access/src/services/access-config.ts"],"sourcesContent":["import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { AccessConfigurations } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\n\n/**\n * The active configuration, fed once at boot by the framework's access connector\n * via {@link setAccessConfig} (the connector reads `src/config/access.ts`).\n * Reading it before it's set throws {@link AccessConfigError} — a loud\n * misconfiguration, never a silent deny.\n */\nlet current: AccessConfigurations | undefined;\n\n/**\n * Register the access configuration. The framework's access connector calls this\n * at boot with the default export of `src/config/access.ts`. It validates that a\n * `resolver` is present, so a misconfigured authorization layer fails at STARTUP\n * rather than on the first protected request.\n *\n * @example\n * setAccessConfig({ resolver: new DefaultAccessResolver({ admin: [\"*\"] }) });\n */\nexport function setAccessConfig(configuration: AccessConfigurations): void {\n if (!configuration?.resolver) {\n throw new AccessConfigError(\n \"No access resolver configured. Set `resolver` in src/config/access.ts to a class \" +\n \"implementing AccessResolver — the ejected DatabaseAccessResolver, or \" +\n \"`new DefaultAccessResolver(rolesMap)` for a fixed-role catalog.\",\n );\n }\n\n current = configuration;\n}\n\n/** Clear the active config. Used by the connector on dev-restart and by tests. */\nexport function resetAccessConfig(): void {\n current = undefined;\n}\n\n/** Accessors over the active access configuration. */\nexport const accessConfig = {\n /**\n * The configured resolver. Throws {@link AccessConfigError} if access was never\n * configured (no `src/config/access.ts`, or the connector hasn't run).\n */\n resolver(): AccessResolver {\n if (!current) {\n throw new AccessConfigError(\n \"@warlock.js/access is not configured. Add src/config/access.ts (exporting a `resolver`) \" +\n \"— it is wired at boot by the access connector.\",\n );\n }\n\n return current.resolver;\n },\n\n /** Cache TTL for resolved permission sets. */\n cacheTtl(): string | number {\n return current?.cache?.ttl ?? \"10m\";\n },\n\n /** Whether an unpoliced instance-level check should throw instead of warn. */\n strictPolicies(): boolean {\n return current?.strictPolicies ?? false;\n },\n\n /** Ambient tenant for this user, delegated to the resolver's optional hook. */\n tenant(user: Auth): string | undefined {\n return this.resolver().resolveTenant?.(user);\n },\n};\n"],"mappings":";;;;;;;;;AAWA,IAAI;;;;;;;;;;AAWJ,SAAgB,gBAAgB,eAA2C;CACzE,IAAI,CAAC,eAAe,UAClB,MAAM,IAAI,kBACR,uNAGF;CAGF,UAAU;AACZ;;AAGA,SAAgB,oBAA0B;CACxC,UAAU;AACZ;;AAGA,MAAa,eAAe;;;;;CAK1B,WAA2B;EACzB,IAAI,CAAC,SACH,MAAM,IAAI,kBACR,wIAEF;EAGF,OAAO,QAAQ;CACjB;;CAGA,WAA4B;EAC1B,OAAO,SAAS,OAAO,OAAO;CAChC;;CAGA,iBAA0B;EACxB,OAAO,SAAS,kBAAkB;CACpC;;CAGA,OAAO,MAAgC;EACrC,OAAO,KAAK,SAAS,CAAC,CAAC,gBAAgB,IAAI;CAC7C;AACF"}
1
+ {"version":3,"file":"access-config.mjs","names":[],"sources":["../../../../../../../access/src/services/access-config.ts"],"sourcesContent":["import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { AccessConfigurations } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\n\n/**\n * The active configuration, fed once at boot by the framework's access connector\n * via {@link setAccessConfig} (the connector reads `src/config/access.ts`).\n * Reading it before it's set throws {@link AccessConfigError} — a loud\n * misconfiguration, never a silent deny.\n */\nlet current: AccessConfigurations | undefined;\n\n/**\n * Register the access configuration. The framework's access connector calls this\n * at boot with the default export of `src/config/access.ts`. It validates that a\n * `resolver` is present, so a misconfigured authorization layer fails at STARTUP\n * rather than on the first protected request.\n *\n * @example\n * setAccessConfig({ resolver: new DefaultAccessResolver({ admin: [\"*\"] }) });\n */\nexport function setAccessConfig(configuration: AccessConfigurations): void {\n if (!configuration?.resolver) {\n throw new AccessConfigError(\n \"No access resolver configured. Set `resolver` in src/config/access.ts to a class \" +\n \"implementing AccessResolver — the ejected DatabaseAccessResolver, or \" +\n \"`new DefaultAccessResolver(rolesMap)` for a fixed-role catalog.\",\n );\n }\n\n current = configuration;\n}\n\n/** Clear the active config. Used by the connector on dev-restart and by tests. */\nexport function resetAccessConfig(): void {\n current = undefined;\n}\n\n/** Accessors over the active access configuration. */\nexport const accessConfig = {\n /**\n * The configured resolver. Throws {@link AccessConfigError} if access was never\n * configured (no `src/config/access.ts`, or the connector hasn't run).\n */\n resolver(): AccessResolver {\n if (!current) {\n throw new AccessConfigError(\n \"@warlock.js/access is not configured. Add src/config/access.ts (exporting a `resolver`) \" +\n \"— it is wired at boot by the access connector.\",\n );\n }\n\n return current.resolver;\n },\n\n /** Cache TTL for resolved permission sets. */\n cacheTtl(): string | number {\n return current?.cache?.ttl ?? \"10m\";\n },\n\n /** Whether an unpoliced instance-level check should deny instead of falling back to the RBAC grant. */\n strictPolicies(): boolean {\n return current?.strictPolicies ?? true;\n },\n\n /** Ambient tenant for this user, delegated to the resolver's optional hook. */\n tenant(user: Auth): string | undefined {\n return this.resolver().resolveTenant?.(user);\n },\n};\n"],"mappings":";;;;;;;;;AAWA,IAAI;;;;;;;;;;AAWJ,SAAgB,gBAAgB,eAA2C;CACzE,IAAI,CAAC,eAAe,UAClB,MAAM,IAAI,kBACR,uNAGF;CAGF,UAAU;AACZ;;AAGA,SAAgB,oBAA0B;CACxC,UAAU;AACZ;;AAGA,MAAa,eAAe;;;;;CAK1B,WAA2B;EACzB,IAAI,CAAC,SACH,MAAM,IAAI,kBACR,wIAEF;EAGF,OAAO,QAAQ;CACjB;;CAGA,WAA4B;EAC1B,OAAO,SAAS,OAAO,OAAO;CAChC;;CAGA,iBAA0B;EACxB,OAAO,SAAS,kBAAkB;CACpC;;CAGA,OAAO,MAAgC;EACrC,OAAO,KAAK,SAAS,CAAC,CAAC,gBAAgB,IAAI;CAC7C;AACF"}
@@ -18,6 +18,8 @@ function toRoleList(value) {
18
18
  * new DefaultAccessResolver(rolesMap, (user) => user.get("memberRoles"));
19
19
  */
20
20
  var DefaultAccessResolver = class {
21
+ roles;
22
+ readRoles;
21
23
  constructor(roles, readRoles = (user) => user.get("roles") ?? user.get("role")) {
22
24
  this.roles = roles;
23
25
  this.readRoles = readRoles;
@@ -1 +1 @@
1
- {"version":3,"file":"default-resolver.mjs","names":[],"sources":["../../../../../../../access/src/services/default-resolver.ts"],"sourcesContent":["import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { RolesMap } from \"../contracts/types\";\n\n/** Coerce a roles value (`undefined` / single string / array) into a string array. */\nfunction toRoleList(value: unknown): string[] {\n if (Array.isArray(value)) return value as string[];\n\n if (typeof value === \"string\" && value.length > 0) return [value];\n\n return [];\n}\n\n/**\n * The zero-config resolver: reads a user's roles from a model field and maps\n * them to permissions through your `roles` catalog. Works out of the box for\n * both a `roles` array column and a single `role` column.\n *\n * Swap `readRoles` to read from anywhere else (a relation, a token claim); for\n * a different storage shape entirely, implement {@link AccessResolver} directly.\n *\n * @example\n * new DefaultAccessResolver({ editor: [\"orders.*\"], viewer: [\"orders.view\"] });\n * new DefaultAccessResolver(rolesMap, (user) => user.get(\"memberRoles\"));\n */\nexport class DefaultAccessResolver implements AccessResolver {\n public constructor(\n private readonly roles: RolesMap,\n private readonly readRoles: (user: Auth) => unknown = (user) =>\n user.get(\"roles\") ?? user.get(\"role\"),\n ) {}\n\n public async resolveRoles(user: Auth): Promise<string[]> {\n return toRoleList(await this.readRoles(user));\n }\n\n public async resolvePermissions(user: Auth): Promise<string[]> {\n const roles = await this.resolveRoles(user);\n\n // Guard against a role named `__proto__` / `constructor` resolving to an\n // inherited (non-array) value.\n return roles.flatMap((role) => {\n if (!Object.prototype.hasOwnProperty.call(this.roles, role)) {\n return [];\n }\n\n // `hasOwnProperty` proves the KEY is present; it says nothing about the\n // value, which under `noUncheckedIndexedAccess` is `string[] |\n // undefined`. Falling back to NO permissions is the only safe direction\n // here: this function's result is what the caller checks before granting\n // access, so an unreadable role must grant nothing rather than be\n // asserted into something.\n return this.roles[role] ?? [];\n });\n }\n}\n"],"mappings":";;AAKA,SAAS,WAAW,OAA0B;CAC5C,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO;CAEjC,IAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK;CAEhE,OAAO,CAAC;AACV;;;;;;;;;;;;;AAcA,IAAa,wBAAb,MAA6D;CAC3D,AAAO,YACL,AAAiB,OACjB,AAAiB,aAAsC,SACrD,KAAK,IAAI,OAAO,KAAK,KAAK,IAAI,MAAM,GACtC;EAHiB;EACA;CAEhB;CAEH,MAAa,aAAa,MAA+B;EACvD,OAAO,WAAW,MAAM,KAAK,UAAU,IAAI,CAAC;CAC9C;CAEA,MAAa,mBAAmB,MAA+B;EAK7D,QAAO,MAJa,KAAK,aAAa,IAAI,EAI9B,CAAC,SAAS,SAAS;GAC7B,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,KAAK,OAAO,IAAI,GACxD,OAAO,CAAC;GASV,OAAO,KAAK,MAAM,SAAS,CAAC;EAC9B,CAAC;CACH;AACF"}
1
+ {"version":3,"file":"default-resolver.mjs","names":[],"sources":["../../../../../../../access/src/services/default-resolver.ts"],"sourcesContent":["import type { Auth } from \"@warlock.js/auth\";\nimport type { AccessResolver } from \"../contracts/access-resolver\";\nimport type { RolesMap } from \"../contracts/types\";\n\n/** Coerce a roles value (`undefined` / single string / array) into a string array. */\nfunction toRoleList(value: unknown): string[] {\n if (Array.isArray(value)) return value as string[];\n\n if (typeof value === \"string\" && value.length > 0) return [value];\n\n return [];\n}\n\n/**\n * The zero-config resolver: reads a user's roles from a model field and maps\n * them to permissions through your `roles` catalog. Works out of the box for\n * both a `roles` array column and a single `role` column.\n *\n * Swap `readRoles` to read from anywhere else (a relation, a token claim); for\n * a different storage shape entirely, implement {@link AccessResolver} directly.\n *\n * @example\n * new DefaultAccessResolver({ editor: [\"orders.*\"], viewer: [\"orders.view\"] });\n * new DefaultAccessResolver(rolesMap, (user) => user.get(\"memberRoles\"));\n */\nexport class DefaultAccessResolver implements AccessResolver {\n public constructor(\n private readonly roles: RolesMap,\n private readonly readRoles: (user: Auth) => unknown = (user) =>\n user.get(\"roles\") ?? user.get(\"role\"),\n ) {}\n\n public async resolveRoles(user: Auth): Promise<string[]> {\n return toRoleList(await this.readRoles(user));\n }\n\n public async resolvePermissions(user: Auth): Promise<string[]> {\n const roles = await this.resolveRoles(user);\n\n // Guard against a role named `__proto__` / `constructor` resolving to an\n // inherited (non-array) value.\n return roles.flatMap((role) => {\n if (!Object.prototype.hasOwnProperty.call(this.roles, role)) {\n return [];\n }\n\n // `hasOwnProperty` proves the KEY is present; it says nothing about the\n // value, which under `noUncheckedIndexedAccess` is `string[] |\n // undefined`. Falling back to NO permissions is the only safe direction\n // here: this function's result is what the caller checks before granting\n // access, so an unreadable role must grant nothing rather than be\n // asserted into something.\n return this.roles[role] ?? [];\n });\n }\n}\n"],"mappings":";;AAKA,SAAS,WAAW,OAA0B;CAC5C,IAAI,MAAM,QAAQ,KAAK,GAAG,OAAO;CAEjC,IAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG,OAAO,CAAC,KAAK;CAEhE,OAAO,CAAC;AACV;;;;;;;;;;;;;AAcA,IAAa,wBAAb,MAA6D;CAExC;CACA;CAFnB,AAAO,YACL,AAAiB,OACjB,AAAiB,aAAsC,SACrD,KAAK,IAAI,OAAO,KAAK,KAAK,IAAI,MAAM,GACtC;EAHiB;EACA;CAEhB;CAEH,MAAa,aAAa,MAA+B;EACvD,OAAO,WAAW,MAAM,KAAK,UAAU,IAAI,CAAC;CAC9C;CAEA,MAAa,mBAAmB,MAA+B;EAK7D,QAAO,MAJa,KAAK,aAAa,IAAI,EAI9B,CAAC,SAAS,SAAS;GAC7B,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,KAAK,OAAO,IAAI,GACxD,OAAO,CAAC;GASV,OAAO,KAAK,MAAM,SAAS,CAAC;EAC9B,CAAC;CACH;AACF"}
@@ -61,31 +61,38 @@ async function flush(user, tenant) {
61
61
  }
62
62
  }
63
63
  /**
64
- * Surface a resource-scoped check that has no registered ABAC policy: it
65
- * silently falls back to the RBAC grant alone, which is a plausible IDOR if
66
- * the permission was meant to be resource-scoped (typo'd permission name, or
67
- * a forgotten `import "./policies"` side-effect — the single most common
68
- * mistake app teams make with this package, per the `define-policies` skill).
64
+ * Surface a resource-scoped check that has no registered ABAC policy, and
65
+ * report whether the check should now DENY.
69
66
  *
70
- * In strict mode ({@link AccessConfigurations.strictPolicies}) this throws
71
- * {@link AccessConfigError} instead, which `check()`'s catch re-throws loud
72
- * rather than folding into the fail-closed deny a misconfiguration, not a
73
- * runtime denial.
67
+ * `strictPolicies` defaults to `true`: a forgotten `definePolicy` call (typo'd
68
+ * permission name, or a missing `import "./policies"` side-effect — the
69
+ * single most common mistake app teams make with this package, per the
70
+ * `define-policies` skill) must never fail OPEN, so the check denies.
74
71
  *
75
- * Warns once per permission (not once per call) so a hot request path
76
- * doesn't spam logs mirrors cascade's `warnUndeclaredSensitiveFields`.
72
+ * `strictPolicies: false` explicitly restores the pre-5.13 behavior: fall
73
+ * back to the RBAC grant alone (ALLOW), which is a plausible IDOR if the
74
+ * permission was meant to be resource-scoped.
75
+ *
76
+ * Either way this warns once per permission (not once per call) so a hot
77
+ * request path doesn't spam logs — mirrors cascade's
78
+ * `warnUndeclaredSensitiveFields`.
77
79
  */
78
- function warnUnpoliced(permission) {
79
- if (accessConfig.strictPolicies()) throw new AccessConfigError(`@warlock.js/access: instance-level check for permission "${permission}" has no registered ABAC policy — refusing to fall back to the RBAC grant alone while strictPolicies is enabled. Register one with definePolicy("${permission}", ...), or make sure the module that calls definePolicy is actually imported.`);
80
- if (warnedUnpolicedPermissions.has(permission)) return;
81
- warnedUnpolicedPermissions.add(permission);
82
- log.warn("access", "unpoliced-check", `Instance-level check for permission "${permission}" has no registered ABAC policy — falling back to the RBAC grant alone, so any holder of "${permission}" can act on ANY resource. If this permission is meant to be resource-scoped, register a policy with definePolicy("${permission}", ...) (and confirm the module that calls it is imported). Set strictPolicies: true in the access config to turn this into a thrown error instead.`);
80
+ function unpolicedShouldDeny(permission) {
81
+ const strict = accessConfig.strictPolicies();
82
+ if (!warnedUnpolicedPermissions.has(permission)) {
83
+ warnedUnpolicedPermissions.add(permission);
84
+ log.warn("access", "unpoliced-check", strict ? `Instance-level check for permission "${permission}" has no registered ABAC policy — DENYING (strictPolicies defaults to true as of 5.13). Register one with definePolicy("${permission}", ...), or make sure the module that calls definePolicy is actually imported. Set strictPolicies: false in the access config to restore the pre-5.13 RBAC-only fallback (ALLOWS — not recommended).` : `Instance-level check for permission "${permission}" has no registered ABAC policy — falling back to the RBAC grant alone (strictPolicies: false), so any holder of "${permission}" can act on ANY resource. If this permission is meant to be resource-scoped, register a policy with definePolicy("${permission}", ...) (and confirm the module that calls it is imported), or remove strictPolicies: false to deny by default.`);
85
+ }
86
+ return strict;
83
87
  }
84
88
  /**
85
89
  * The core decision: does the user hold `permission`, and — when a `resource`
86
90
  * is supplied — does its policy pass too?
87
91
  *
88
- * Fails CLOSED: any error while resolving the decision denies (and logs).
92
+ * Fails CLOSED: any error while resolving the decision denies (and logs). An
93
+ * instance-level check for a permission with no registered policy also fails
94
+ * CLOSED by default ({@link AccessConfigurations.strictPolicies}) rather than
95
+ * falling back to the RBAC grant alone.
89
96
  */
90
97
  async function check(user, permission, context = {}) {
91
98
  try {
@@ -93,7 +100,9 @@ async function check(user, permission, context = {}) {
93
100
  const permissions = await permissionsFor(user, tenant);
94
101
  if (!matchesPermission(permissions, permission)) return false;
95
102
  const policy = getPolicy(permission);
96
- if (context.resource !== void 0 && policy === void 0) warnUnpoliced(permission);
103
+ if (context.resource !== void 0 && policy === void 0) {
104
+ if (unpolicedShouldDeny(permission)) return false;
105
+ }
97
106
  if (policy === void 0 || context.resource === void 0) return true;
98
107
  const roles = await rolesFor(user, tenant);
99
108
  return Boolean(await policy(user, context.resource, {
@@ -1 +1 @@
1
- {"version":3,"file":"engine.mjs","names":[],"sources":["../../../../../../../access/src/services/engine.ts"],"sourcesContent":["import type { Auth } from \"@warlock.js/auth\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { AccessContext } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\nimport { accessConfig } from \"./access-config\";\nimport { matchesPermission } from \"./matcher\";\nimport { getPolicy } from \"./policies\";\n\nconst PERMISSIONS_PREFIX = \"access.perms.\";\nconst ROLES_PREFIX = \"access.roles.\";\n\n/**\n * Permissions that have already triggered the \"unpoliced instance check\"\n * warning below — kept so a hot path doesn't log once per request.\n */\nconst warnedUnpolicedPermissions = new Set<string>();\n\n/** Reset the once-per-permission warning tracker. Primarily for tests. */\nexport function resetUnpolicedWarnings(): void {\n warnedUnpolicedPermissions.clear();\n}\n\n/** Resolve the effective tenant for a check (explicit → ambient → undefined). */\nfunction tenantFor(user: Auth, context: AccessContext): string | undefined {\n return context.tenant ?? accessConfig.tenant(user);\n}\n\nfunction cacheKey(prefix: string, user: Auth, tenant?: string): string {\n // Encode each segment so a `.` in an id or tenant can't collide two distinct\n // principals onto the same cache key (and serve one's grants to the other).\n const segment = (value: unknown) => encodeURIComponent(String(value));\n\n return `${prefix}${segment(user.userType)}.${segment(user.id)}.${segment(tenant ?? \"_\")}`;\n}\n\n/**\n * Read a list through the cache, falling back to the loader.\n *\n * The cache is **best-effort**: a cache failure degrades to the loader (the\n * source of truth) and is logged, never denied. A loader failure propagates —\n * so the DECISION fails closed in {@link check}.\n */\nasync function cached(\n key: string,\n loader: () => Promise<string[]>,\n): Promise<string[]> {\n try {\n const hit = await cache.get<string[]>(key);\n\n if (hit) return hit;\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n const value = await loader();\n\n try {\n await cache.set(key, value, { ttl: accessConfig.cacheTtl() });\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n return value;\n}\n\n/** The user's effective permission set for a tenant (cached). */\nexport function permissionsFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(PERMISSIONS_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolvePermissions(user, tenant),\n );\n}\n\n/** The user's roles for a tenant (cached). */\nexport function rolesFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(ROLES_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolveRoles(user, tenant),\n );\n}\n\n/** Drop the cached role/permission sets for a user (after any out-of-band role change). */\nexport async function flush(user: Auth, tenant?: string): Promise<void> {\n try {\n await cache.remove(cacheKey(PERMISSIONS_PREFIX, user, tenant));\n await cache.remove(cacheKey(ROLES_PREFIX, user, tenant));\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n}\n\n/**\n * Surface a resource-scoped check that has no registered ABAC policy: it\n * silently falls back to the RBAC grant alone, which is a plausible IDOR if\n * the permission was meant to be resource-scoped (typo'd permission name, or\n * a forgotten `import \"./policies\"` side-effect — the single most common\n * mistake app teams make with this package, per the `define-policies` skill).\n *\n * In strict mode ({@link AccessConfigurations.strictPolicies}) this throws\n * {@link AccessConfigError} instead, which `check()`'s catch re-throws loud\n * rather than folding into the fail-closed deny a misconfiguration, not a\n * runtime denial.\n *\n * Warns once per permission (not once per call) so a hot request path\n * doesn't spam logs — mirrors cascade's `warnUndeclaredSensitiveFields`.\n */\nfunction warnUnpoliced(permission: string): void {\n if (accessConfig.strictPolicies()) {\n throw new AccessConfigError(\n `@warlock.js/access: instance-level check for permission \"${permission}\" has no registered ` +\n `ABAC policy — refusing to fall back to the RBAC grant alone while strictPolicies is enabled. ` +\n `Register one with definePolicy(\"${permission}\", ...), or make sure the module that calls ` +\n `definePolicy is actually imported.`,\n );\n }\n\n if (warnedUnpolicedPermissions.has(permission)) return;\n warnedUnpolicedPermissions.add(permission);\n\n log.warn(\n \"access\",\n \"unpoliced-check\",\n `Instance-level check for permission \"${permission}\" has no registered ABAC policy — falling ` +\n `back to the RBAC grant alone, so any holder of \"${permission}\" can act on ANY resource. If ` +\n `this permission is meant to be resource-scoped, register a policy with ` +\n `definePolicy(\"${permission}\", ...) (and confirm the module that calls it is imported). ` +\n `Set strictPolicies: true in the access config to turn this into a thrown error instead.`,\n );\n}\n\n/**\n * The core decision: does the user hold `permission`, and — when a `resource`\n * is supplied — does its policy pass too?\n *\n * Fails CLOSED: any error while resolving the decision denies (and logs).\n */\nexport async function check(\n user: Auth,\n permission: string,\n context: AccessContext = {},\n): Promise<boolean> {\n try {\n const tenant = tenantFor(user, context);\n const permissions = await permissionsFor(user, tenant);\n\n if (!matchesPermission(permissions, permission)) return false;\n\n const policy = getPolicy(permission);\n\n // A policy only runs on an instance check (a resource was supplied);\n // class-level checks (`gate`) stop at the grant.\n if (context.resource !== undefined && policy === undefined) {\n warnUnpoliced(permission);\n }\n\n if (policy === undefined || context.resource === undefined) return true;\n\n const roles = await rolesFor(user, tenant);\n\n return Boolean(\n await policy(user, context.resource, {\n ...context,\n tenant,\n hasRole: (role) => roles.includes(role),\n hasPermission: (perm) => matchesPermission(permissions, perm),\n }),\n );\n } catch (error) {\n if (error instanceof AccessConfigError) throw error; // misconfig is loud, not a silent deny\n\n log.error(\"access\", \"check\", error);\n\n return false;\n }\n}\n"],"mappings":";;;;;;;;AASA,MAAM,qBAAqB;AAC3B,MAAM,eAAe;;;;;AAMrB,MAAM,6CAA6B,IAAI,IAAY;;AAQnD,SAAS,UAAU,MAAY,SAA4C;CACzE,OAAO,QAAQ,UAAU,aAAa,OAAO,IAAI;AACnD;AAEA,SAAS,SAAS,QAAgB,MAAY,QAAyB;CAGrE,MAAM,WAAW,UAAmB,mBAAmB,OAAO,KAAK,CAAC;CAEpE,OAAO,GAAG,SAAS,QAAQ,KAAK,QAAQ,EAAE,GAAG,QAAQ,KAAK,EAAE,EAAE,GAAG,QAAQ,UAAU,GAAG;AACxF;;;;;;;;AASA,eAAe,OACb,KACA,QACmB;CACnB,IAAI;EACF,MAAM,MAAM,MAAM,MAAM,IAAc,GAAG;EAEzC,IAAI,KAAK,OAAO;CAClB,SAAS,OAAO;EACd,IAAI,MAAM,UAAU,SAAS,KAAK;CACpC;CAEA,MAAM,QAAQ,MAAM,OAAO;CAE3B,IAAI;EACF,MAAM,MAAM,IAAI,KAAK,OAAO,EAAE,KAAK,aAAa,SAAS,EAAE,CAAC;CAC9D,SAAS,OAAO;EACd,IAAI,MAAM,UAAU,SAAS,KAAK;CACpC;CAEA,OAAO;AACT;;AAGA,SAAgB,eAAe,MAAY,QAAoC;CAC7E,OAAO,OAAO,SAAS,oBAAoB,MAAM,MAAM,SACrD,aAAa,SAAS,CAAC,CAAC,mBAAmB,MAAM,MAAM,CACzD;AACF;;AAGA,SAAgB,SAAS,MAAY,QAAoC;CACvE,OAAO,OAAO,SAAS,cAAc,MAAM,MAAM,SAC/C,aAAa,SAAS,CAAC,CAAC,aAAa,MAAM,MAAM,CACnD;AACF;;AAGA,eAAsB,MAAM,MAAY,QAAgC;CACtE,IAAI;EACF,MAAM,MAAM,OAAO,SAAS,oBAAoB,MAAM,MAAM,CAAC;EAC7D,MAAM,MAAM,OAAO,SAAS,cAAc,MAAM,MAAM,CAAC;CACzD,SAAS,OAAO;EACd,IAAI,MAAM,UAAU,SAAS,KAAK;CACpC;AACF;;;;;;;;;;;;;;;;AAiBA,SAAS,cAAc,YAA0B;CAC/C,IAAI,aAAa,eAAe,GAC9B,MAAM,IAAI,kBACR,4DAA4D,WAAW,mJAElC,WAAW,+EAElD;CAGF,IAAI,2BAA2B,IAAI,UAAU,GAAG;CAChD,2BAA2B,IAAI,UAAU;CAEzC,IAAI,KACF,UACA,mBACA,wCAAwC,WAAW,4FACE,WAAW,qHAE7C,WAAW,oJAEhC;AACF;;;;;;;AAQA,eAAsB,MACpB,MACA,YACA,UAAyB,CAAC,GACR;CAClB,IAAI;EACF,MAAM,SAAS,UAAU,MAAM,OAAO;EACtC,MAAM,cAAc,MAAM,eAAe,MAAM,MAAM;EAErD,IAAI,CAAC,kBAAkB,aAAa,UAAU,GAAG,OAAO;EAExD,MAAM,SAAS,UAAU,UAAU;EAInC,IAAI,QAAQ,aAAa,UAAa,WAAW,QAC/C,cAAc,UAAU;EAG1B,IAAI,WAAW,UAAa,QAAQ,aAAa,QAAW,OAAO;EAEnE,MAAM,QAAQ,MAAM,SAAS,MAAM,MAAM;EAEzC,OAAO,QACL,MAAM,OAAO,MAAM,QAAQ,UAAU;GACnC,GAAG;GACH;GACA,UAAU,SAAS,MAAM,SAAS,IAAI;GACtC,gBAAgB,SAAS,kBAAkB,aAAa,IAAI;EAC9D,CAAC,CACH;CACF,SAAS,OAAO;EACd,IAAI,iBAAiB,mBAAmB,MAAM;EAE9C,IAAI,MAAM,UAAU,SAAS,KAAK;EAElC,OAAO;CACT;AACF"}
1
+ {"version":3,"file":"engine.mjs","names":[],"sources":["../../../../../../../access/src/services/engine.ts"],"sourcesContent":["import type { Auth } from \"@warlock.js/auth\";\nimport { cache } from \"@warlock.js/cache\";\nimport { log } from \"@warlock.js/logger\";\nimport type { AccessContext } from \"../contracts/types\";\nimport { AccessConfigError } from \"../utils/access-config-error\";\nimport { accessConfig } from \"./access-config\";\nimport { matchesPermission } from \"./matcher\";\nimport { getPolicy } from \"./policies\";\n\nconst PERMISSIONS_PREFIX = \"access.perms.\";\nconst ROLES_PREFIX = \"access.roles.\";\n\n/**\n * Permissions that have already triggered the \"unpoliced instance check\"\n * warning below — kept so a hot path doesn't log once per request.\n */\nconst warnedUnpolicedPermissions = new Set<string>();\n\n/** Reset the once-per-permission warning tracker. Primarily for tests. */\nexport function resetUnpolicedWarnings(): void {\n warnedUnpolicedPermissions.clear();\n}\n\n/** Resolve the effective tenant for a check (explicit → ambient → undefined). */\nfunction tenantFor(user: Auth, context: AccessContext): string | undefined {\n return context.tenant ?? accessConfig.tenant(user);\n}\n\nfunction cacheKey(prefix: string, user: Auth, tenant?: string): string {\n // Encode each segment so a `.` in an id or tenant can't collide two distinct\n // principals onto the same cache key (and serve one's grants to the other).\n const segment = (value: unknown) => encodeURIComponent(String(value));\n\n return `${prefix}${segment(user.userType)}.${segment(user.id)}.${segment(tenant ?? \"_\")}`;\n}\n\n/**\n * Read a list through the cache, falling back to the loader.\n *\n * The cache is **best-effort**: a cache failure degrades to the loader (the\n * source of truth) and is logged, never denied. A loader failure propagates —\n * so the DECISION fails closed in {@link check}.\n */\nasync function cached(\n key: string,\n loader: () => Promise<string[]>,\n): Promise<string[]> {\n try {\n const hit = await cache.get<string[]>(key);\n\n if (hit) return hit;\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n const value = await loader();\n\n try {\n await cache.set(key, value, { ttl: accessConfig.cacheTtl() });\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n\n return value;\n}\n\n/** The user's effective permission set for a tenant (cached). */\nexport function permissionsFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(PERMISSIONS_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolvePermissions(user, tenant),\n );\n}\n\n/** The user's roles for a tenant (cached). */\nexport function rolesFor(user: Auth, tenant?: string): Promise<string[]> {\n return cached(cacheKey(ROLES_PREFIX, user, tenant), () =>\n accessConfig.resolver().resolveRoles(user, tenant),\n );\n}\n\n/** Drop the cached role/permission sets for a user (after any out-of-band role change). */\nexport async function flush(user: Auth, tenant?: string): Promise<void> {\n try {\n await cache.remove(cacheKey(PERMISSIONS_PREFIX, user, tenant));\n await cache.remove(cacheKey(ROLES_PREFIX, user, tenant));\n } catch (error) {\n log.error(\"access\", \"cache\", error);\n }\n}\n\n/**\n * Surface a resource-scoped check that has no registered ABAC policy, and\n * report whether the check should now DENY.\n *\n * `strictPolicies` defaults to `true`: a forgotten `definePolicy` call (typo'd\n * permission name, or a missing `import \"./policies\"` side-effect — the\n * single most common mistake app teams make with this package, per the\n * `define-policies` skill) must never fail OPEN, so the check denies.\n *\n * `strictPolicies: false` explicitly restores the pre-5.13 behavior: fall\n * back to the RBAC grant alone (ALLOW), which is a plausible IDOR if the\n * permission was meant to be resource-scoped.\n *\n * Either way this warns once per permission (not once per call) so a hot\n * request path doesn't spam logs — mirrors cascade's\n * `warnUndeclaredSensitiveFields`.\n */\nfunction unpolicedShouldDeny(permission: string): boolean {\n const strict = accessConfig.strictPolicies();\n\n if (!warnedUnpolicedPermissions.has(permission)) {\n warnedUnpolicedPermissions.add(permission);\n\n log.warn(\n \"access\",\n \"unpoliced-check\",\n strict\n ? `Instance-level check for permission \"${permission}\" has no registered ABAC policy — ` +\n `DENYING (strictPolicies defaults to true as of 5.13). Register one with ` +\n `definePolicy(\"${permission}\", ...), or make sure the module that calls definePolicy is ` +\n `actually imported. Set strictPolicies: false in the access config to restore the pre-5.13 ` +\n `RBAC-only fallback (ALLOWS — not recommended).`\n : `Instance-level check for permission \"${permission}\" has no registered ABAC policy — falling ` +\n `back to the RBAC grant alone (strictPolicies: false), so any holder of \"${permission}\" can ` +\n `act on ANY resource. If this permission is meant to be resource-scoped, register a policy ` +\n `with definePolicy(\"${permission}\", ...) (and confirm the module that calls it is ` +\n `imported), or remove strictPolicies: false to deny by default.`,\n );\n }\n\n return strict;\n}\n\n/**\n * The core decision: does the user hold `permission`, and — when a `resource`\n * is supplied — does its policy pass too?\n *\n * Fails CLOSED: any error while resolving the decision denies (and logs). An\n * instance-level check for a permission with no registered policy also fails\n * CLOSED by default ({@link AccessConfigurations.strictPolicies}) rather than\n * falling back to the RBAC grant alone.\n */\nexport async function check(\n user: Auth,\n permission: string,\n context: AccessContext = {},\n): Promise<boolean> {\n try {\n const tenant = tenantFor(user, context);\n const permissions = await permissionsFor(user, tenant);\n\n if (!matchesPermission(permissions, permission)) return false;\n\n const policy = getPolicy(permission);\n\n // A policy only runs on an instance check (a resource was supplied);\n // class-level checks (`gate`) stop at the grant.\n if (context.resource !== undefined && policy === undefined) {\n if (unpolicedShouldDeny(permission)) return false;\n }\n\n if (policy === undefined || context.resource === undefined) return true;\n\n const roles = await rolesFor(user, tenant);\n\n return Boolean(\n await policy(user, context.resource, {\n ...context,\n tenant,\n hasRole: (role) => roles.includes(role),\n hasPermission: (perm) => matchesPermission(permissions, perm),\n }),\n );\n } catch (error) {\n if (error instanceof AccessConfigError) throw error; // misconfig is loud, not a silent deny\n\n log.error(\"access\", \"check\", error);\n\n return false;\n }\n}\n"],"mappings":";;;;;;;;AASA,MAAM,qBAAqB;AAC3B,MAAM,eAAe;;;;;AAMrB,MAAM,6CAA6B,IAAI,IAAY;;AAQnD,SAAS,UAAU,MAAY,SAA4C;CACzE,OAAO,QAAQ,UAAU,aAAa,OAAO,IAAI;AACnD;AAEA,SAAS,SAAS,QAAgB,MAAY,QAAyB;CAGrE,MAAM,WAAW,UAAmB,mBAAmB,OAAO,KAAK,CAAC;CAEpE,OAAO,GAAG,SAAS,QAAQ,KAAK,QAAQ,EAAE,GAAG,QAAQ,KAAK,EAAE,EAAE,GAAG,QAAQ,UAAU,GAAG;AACxF;;;;;;;;AASA,eAAe,OACb,KACA,QACmB;CACnB,IAAI;EACF,MAAM,MAAM,MAAM,MAAM,IAAc,GAAG;EAEzC,IAAI,KAAK,OAAO;CAClB,SAAS,OAAO;EACd,IAAI,MAAM,UAAU,SAAS,KAAK;CACpC;CAEA,MAAM,QAAQ,MAAM,OAAO;CAE3B,IAAI;EACF,MAAM,MAAM,IAAI,KAAK,OAAO,EAAE,KAAK,aAAa,SAAS,EAAE,CAAC;CAC9D,SAAS,OAAO;EACd,IAAI,MAAM,UAAU,SAAS,KAAK;CACpC;CAEA,OAAO;AACT;;AAGA,SAAgB,eAAe,MAAY,QAAoC;CAC7E,OAAO,OAAO,SAAS,oBAAoB,MAAM,MAAM,SACrD,aAAa,SAAS,CAAC,CAAC,mBAAmB,MAAM,MAAM,CACzD;AACF;;AAGA,SAAgB,SAAS,MAAY,QAAoC;CACvE,OAAO,OAAO,SAAS,cAAc,MAAM,MAAM,SAC/C,aAAa,SAAS,CAAC,CAAC,aAAa,MAAM,MAAM,CACnD;AACF;;AAGA,eAAsB,MAAM,MAAY,QAAgC;CACtE,IAAI;EACF,MAAM,MAAM,OAAO,SAAS,oBAAoB,MAAM,MAAM,CAAC;EAC7D,MAAM,MAAM,OAAO,SAAS,cAAc,MAAM,MAAM,CAAC;CACzD,SAAS,OAAO;EACd,IAAI,MAAM,UAAU,SAAS,KAAK;CACpC;AACF;;;;;;;;;;;;;;;;;;AAmBA,SAAS,oBAAoB,YAA6B;CACxD,MAAM,SAAS,aAAa,eAAe;CAE3C,IAAI,CAAC,2BAA2B,IAAI,UAAU,GAAG;EAC/C,2BAA2B,IAAI,UAAU;EAEzC,IAAI,KACF,UACA,mBACA,SACI,wCAAwC,WAAW,0HAEhC,WAAW,wMAG9B,wCAAwC,WAAW,oHAC0B,WAAW,qHAEhE,WAAW,gHAEzC;CACF;CAEA,OAAO;AACT;;;;;;;;;;AAWA,eAAsB,MACpB,MACA,YACA,UAAyB,CAAC,GACR;CAClB,IAAI;EACF,MAAM,SAAS,UAAU,MAAM,OAAO;EACtC,MAAM,cAAc,MAAM,eAAe,MAAM,MAAM;EAErD,IAAI,CAAC,kBAAkB,aAAa,UAAU,GAAG,OAAO;EAExD,MAAM,SAAS,UAAU,UAAU;EAInC,IAAI,QAAQ,aAAa,UAAa,WAAW,QAC/C;OAAI,oBAAoB,UAAU,GAAG,OAAO;EAAK;EAGnD,IAAI,WAAW,UAAa,QAAQ,aAAa,QAAW,OAAO;EAEnE,MAAM,QAAQ,MAAM,SAAS,MAAM,MAAM;EAEzC,OAAO,QACL,MAAM,OAAO,MAAM,QAAQ,UAAU;GACnC,GAAG;GACH;GACA,UAAU,SAAS,MAAM,SAAS,IAAI;GACtC,gBAAgB,SAAS,kBAAkB,aAAa,IAAI;EAC9D,CAAC,CACH;CACF,SAAS,OAAO;EACd,IAAI,iBAAiB,mBAAmB,MAAM;EAE9C,IAAI,MAAM,UAAU,SAAS,KAAK;EAElC,OAAO;CACT;AACF"}
package/llms-full.txt CHANGED
@@ -137,13 +137,17 @@ type AccessConfigurations = {
137
137
  cache?: {
138
138
  ttl?: string | number; // resolved-set TTL, default "10m"
139
139
  };
140
- strictPolicies?: boolean; // default false — see below
140
+ strictPolicies?: boolean; // default true — see below
141
141
  };
142
142
  ```
143
143
 
144
144
  ### `strictPolicies`
145
145
 
146
- An instance check (`{ resource }`) for a permission with no registered ABAC policy normally **warns once per permission** (`log.warn`) and falls back to the RBAC grant alone. Set `strictPolicies: true` to turn that gap into a thrown `AccessConfigError` instead for apps that want a missing policy to fail the request/boot loudly rather than just log. See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
146
+ An instance check (`{ resource }`) for a permission with no registered ABAC policy **denies by default** (`strictPolicies: true`) — a forgotten policy must never fail open. The miss is logged once per permission (`log.warn`) either way.
147
+
148
+ Set `strictPolicies: false` to explicitly restore the pre-5.13 behavior: fall back to the RBAC grant alone (ALLOWS — any holder of the permission can act on ANY resource, a plausible IDOR). See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
149
+
150
+ > **Breaking in 5.13:** `strictPolicies` now defaults to `true` and DENIES (it used to default to `false` and ALLOW). Set `strictPolicies: false` to keep the old fail-open fallback.
147
151
 
148
152
  A missing resolver throws `AccessConfigError` **at boot** — the framework's access connector wires `src/config/access.ts` (the same way the notifications connector wires `config/notifications.ts`), so a misconfig fails at startup, never a silent deny.
149
153
 
@@ -208,7 +212,7 @@ So `gate("orders.update")` (a route gate, no resource) checks the grant; the per
208
212
  ## The decision = grant AND policy
209
213
 
210
214
  - No grant → denied (the policy never runs).
211
- - Grant + no policy registered → allowed (RBAC-only), and logs a one-time `log.warn` naming the permission — a typo'd permission or a forgotten `import "./policies"` degrades an instance check to a class-level one, and this is the runtime signal for it. Set `strictPolicies: true` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to throw `AccessConfigError` instead of warning.
215
+ - Grant + no policy registered → **denied by default** (`strictPolicies: true`, the 5.13+ default) — a typo'd permission or a forgotten `import "./policies"` must never silently degrade an instance check to a class-level one. Logs a one-time `log.warn` naming the permission either way. Set `strictPolicies: false` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to explicitly restore the pre-5.13 RBAC-only fallback (allowed).
212
216
  - Grant + policy → the policy decides.
213
217
 
214
218
  Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.
package/package.json CHANGED
@@ -5,12 +5,12 @@
5
5
  "environment": "server"
6
6
  },
7
7
  "peerDependencies": {
8
- "@warlock.js/auth": "5.12.0",
9
- "@warlock.js/cache": "5.12.0",
10
- "@warlock.js/cascade": "5.12.0",
11
- "@warlock.js/core": "5.12.0",
12
- "@warlock.js/logger": "5.12.0",
13
- "@warlock.js/seal": "5.12.0"
8
+ "@warlock.js/auth": "5.13.0",
9
+ "@warlock.js/cache": "5.13.0",
10
+ "@warlock.js/cascade": "5.13.0",
11
+ "@warlock.js/core": "5.13.0",
12
+ "@warlock.js/logger": "5.13.0",
13
+ "@warlock.js/seal": "5.13.0"
14
14
  },
15
15
  "repository": {
16
16
  "type": "git",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "author": "hassanzohdy",
29
29
  "license": "MIT",
30
- "version": "5.12.0",
30
+ "version": "5.13.0",
31
31
  "main": "./cjs/index.cjs",
32
32
  "module": "./esm/index.mjs",
33
33
  "types": "./esm/index.d.mts",
@@ -54,13 +54,17 @@ type AccessConfigurations = {
54
54
  cache?: {
55
55
  ttl?: string | number; // resolved-set TTL, default "10m"
56
56
  };
57
- strictPolicies?: boolean; // default false — see below
57
+ strictPolicies?: boolean; // default true — see below
58
58
  };
59
59
  ```
60
60
 
61
61
  ### `strictPolicies`
62
62
 
63
- An instance check (`{ resource }`) for a permission with no registered ABAC policy normally **warns once per permission** (`log.warn`) and falls back to the RBAC grant alone. Set `strictPolicies: true` to turn that gap into a thrown `AccessConfigError` instead for apps that want a missing policy to fail the request/boot loudly rather than just log. See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
63
+ An instance check (`{ resource }`) for a permission with no registered ABAC policy **denies by default** (`strictPolicies: true`) — a forgotten policy must never fail open. The miss is logged once per permission (`log.warn`) either way.
64
+
65
+ Set `strictPolicies: false` to explicitly restore the pre-5.13 behavior: fall back to the RBAC grant alone (ALLOWS — any holder of the permission can act on ANY resource, a plausible IDOR). See [`define-policies`](@warlock.js/access/define-policies/SKILL.md) for the full decision table.
66
+
67
+ > **Breaking in 5.13:** `strictPolicies` now defaults to `true` and DENIES (it used to default to `false` and ALLOW). Set `strictPolicies: false` to keep the old fail-open fallback.
64
68
 
65
69
  A missing resolver throws `AccessConfigError` **at boot** — the framework's access connector wires `src/config/access.ts` (the same way the notifications connector wires `config/notifications.ts`), so a misconfig fails at startup, never a silent deny.
66
70
 
@@ -36,7 +36,7 @@ So `gate("orders.update")` (a route gate, no resource) checks the grant; the per
36
36
  ## The decision = grant AND policy
37
37
 
38
38
  - No grant → denied (the policy never runs).
39
- - Grant + no policy registered → allowed (RBAC-only), and logs a one-time `log.warn` naming the permission — a typo'd permission or a forgotten `import "./policies"` degrades an instance check to a class-level one, and this is the runtime signal for it. Set `strictPolicies: true` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to throw `AccessConfigError` instead of warning.
39
+ - Grant + no policy registered → **denied by default** (`strictPolicies: true`, the 5.13+ default) — a typo'd permission or a forgotten `import "./policies"` must never silently degrade an instance check to a class-level one. Logs a one-time `log.warn` naming the permission either way. Set `strictPolicies: false` in the access config (see [`configure-access`](@warlock.js/access/configure-access/SKILL.md)) to explicitly restore the pre-5.13 RBAC-only fallback (allowed).
40
40
  - Grant + policy → the policy decides.
41
41
 
42
42
  Policies **deny**, they don't grant — a policy can't let a user past a permission they don't hold.