@aiquants/authz-react-router 0.7.2 → 0.8.1

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/README.md CHANGED
@@ -45,10 +45,20 @@ const authz = createAuthz({
45
45
  appKey: "myapp",
46
46
  // request → your user id (or null → checked against "@anonymous" permissions)
47
47
  resolveUserId: async (request) => (await getSessionUser(request))?.id ?? null,
48
+ // request → the tenant this request acts within (subdomain / header / session). REQUIRED.
49
+ // Fail fast: `?? "default"` would authorize a session-less request inside the default tenant.
50
+ resolveTenantId: async (request) => {
51
+ const user = await getSessionUser(request)
52
+ if (!user?.tenantId) throw new Response("tenant unresolved", { status: 400 })
53
+ return user.tenantId
54
+ },
55
+ // request + tenant → may this caller act AS that tenant? REQUIRED, and never `() => true`:
56
+ // resolveTenantId only says WHICH tenant was named, which on every transport the client can influence.
57
+ assertTenantMembership: async (request, tenantId) => (await getSessionUser(request))?.tenantId === tenantId,
48
58
  // load + merge this user's grants for MANY resources in one round-trip
49
59
  getEffectivePermissions: (args) => getEffectivePermissionsMany(db, authzTables, args),
50
- // optional: lets `getMyPermissions(request)` report on every registered resource
51
- listResourceKeys: () => listResourceKeys(db, authzTables, "myapp"),
60
+ // optional: lets `getMyPermissions(request)` report on every registered resource of that tenant
61
+ listResourceKeys: (_request, tenantId) => listResourceKeys(db, authzTables, "myapp", tenantId),
52
62
  })
53
63
 
54
64
  export const { requirePermission, getMyPermissions } = authz
@@ -56,6 +66,24 @@ export const { requirePermission, getMyPermissions } = authz
56
66
 
57
67
  > If your DB pool can be cold at the first request (serverless / lazy connect), have `resolveUserId` and `getEffectivePermissions` await a connection-ready guard first — otherwise a cold-start DB error can surface as a misleading 403 instead of a 500.
58
68
 
69
+ > **Tenancy is not optional.** Every check happens inside exactly one tenant, so `resolveTenantId` is a required port and
70
+ > its result must be a non-empty string. Returning `""` / `null` raises `AuthzTenantError` **before** the permission port
71
+ > is consulted — it never degrades into "no filter", which would hand the caller every tenant's grants. The guard also
72
+ > verifies that the permission it received was issued for the requested tenant and denies (`reason: "tenant-mismatch"`)
73
+ > when it was not, so a store that forgets its predicate cannot turn into cross-tenant access.
74
+ >
75
+ > **Naming a tenant is not the same as belonging to it.** `resolveTenantId` answers *which* tenant the request named, and
76
+ > on every transport that name is ultimately client-influenced (a header, a sub-domain, a cookie). `assertTenantMembership`
77
+ > is therefore also required, with no permissive default: without it a user of tenant A is authorized inside tenant B by
78
+ > simply naming tenant B — B's `@authenticated` grants apply, and every tenant check still passes because the permission
79
+ > genuinely belongs to B. It is consulted **before** any permission is loaded and denies with `reason:
80
+ > "not-a-tenant-member"`. `userId` is `null` for an anonymous caller; a tenant's `@anonymous` grants are public by
81
+ > construction, so returning `true` there is the usual answer.
82
+ >
83
+ > Do **not** substitute a fallback tenant (`?? "default"`). A request that cannot name its tenant is a request that must
84
+ > not be authorized — substituting one silently moves the missing-tenant-means-all-tenants inversion out of the library
85
+ > and into every consumer.
86
+
59
87
  **Step 2 — guard a loader/action, apply scope, and render the badge.** On allow you get `{ userId, scope, permission }` from a **single** evaluation, so you can authorize, narrow the data, *and* tell the user what they may do — without asking the database a second time.
60
88
 
61
89
  ```ts
@@ -84,7 +112,7 @@ export async function loader({ request }: { request: Request }) {
84
112
  **Step 3 — register the resource + grant.** Insert a `TMResource('myapp','report')` and a `TDRolePermission` for the role(s) that should see it. Either run SQL, or build a tiny admin screen with the CRUD helpers (see the host app's admin guide). Example grant: "role `viewer` may `read` report, rows where dept ∈ {A}, hiding the amount column":
85
113
 
86
114
  ```sql
87
- -- row_scope / column_scope are the §3.2 JSON from @aiquants/authz-core
115
+ -- row_scope / column_scope are the scope-contract JSON from @aiquants/authz-core
88
116
  INSERT INTO dbo_authz.TDRolePermission (role_id, resource_id, action, row_scope, column_scope)
89
117
  SELECT r.id, res.id, 'read',
90
118
  N'{"filters":[{"field":"dept","op":"in","values":["A"]}]}',
@@ -122,7 +150,7 @@ import { MyPermissionIndicator, MyPermissionStatus } from "@aiquants/authz-react
122
150
  > ⚠️ Unlike the admin shell's `labels`, these two components ship **Japanese** defaults (`"あなたの権限"`, `"閲覧"` /
123
151
  > `"編集"` / `"削除"`, `"部分制限"`, and the `対象リソース: …` tooltip). Pass `prefixLabel` and `labels` to localize them.
124
152
 
125
- ## Scope cheat-sheet (§3.2)
153
+ ## Scope cheat-sheet (row / column scope contract)
126
154
 
127
155
  The JSON you put in `row_scope` / `column_scope` (full contract in `@aiquants/authz-core`):
128
156
 
@@ -137,12 +165,14 @@ Multiple roles **union** (wider wins): rows OR'd (any `NULL` ⇒ all rows); colu
137
165
 
138
166
  ## API
139
167
 
140
- - `createAuthz({ appKey, resolveUserId, resolveGroupIds?, getEffectivePermissions, listResourceKeys?, onDeny? })` → `{ requirePermission, getMyPermissions }`.
141
- - `getEffectivePermissions({ userId, groupIds?, appKey, resourceKeys })` → `Record<resourceKey, EffectivePermission | null>`. **One call, many resources** pair it with `getEffectivePermissionsMany` from `@aiquants/authz-drizzle` and N resources cost one round-trip. Keys the port omits are treated as "no permission" (fail-close).
142
- - `listResourceKeys(request)` → every resource key registered for the app. Optional; configuring it is what makes the no-argument `getMyPermissions(request)` legal. Without it that call throws `AuthzUsageError` naming both remedies instead of guessing.
143
- - `resolveGroupIds(request)` → the acting user's group ids. Forwarded automatically to `getEffectivePermissions` by both `requirePermission` and `getMyPermissions`, so group-granted roles count toward effective permissions. Returning `null`/`undefined` (or omitting the port) sends no `groupIds` at all.
168
+ - `createAuthz({ appKey, resolveUserId, resolveTenantId, assertTenantMembership, resolveGroupIds?, getEffectivePermissions, listResourceKeys?, onDeny? })` → `{ requirePermission, getMyPermissions }`.
169
+ - `resolveTenantId(request)` → the tenant id this request acts within. **Required, non-empty**; forwarded to every `getEffectivePermissions` call and returned by `requirePermission`. A blank result throws `AuthzTenantError`.
170
+ - `assertTenantMembership(request, tenantId, userId)` → may this caller act **as** that tenant? **Required**, no permissive default; consulted before any permission is loaded, and `false` denies with `reason: "not-a-tenant-member"` (and reports no permissions at all from `getMyPermissions`).
171
+ - `getEffectivePermissions({ userId, tenantId, groupIds?, appKey, resourceKeys })` → `Record<resourceKey, EffectivePermission | null>`. **One call, many resources** pair it with `getEffectivePermissionsMany` from `@aiquants/authz-drizzle` and N resources cost one round-trip. Keys the port omits are treated as "no permission" (fail-close).
172
+ - `listResourceKeys(request, tenantId)` → every resource key registered for the app **within that tenant**. Optional; configuring it is what makes the no-argument `getMyPermissions(request)` legal. Without it that call throws `AuthzUsageError` naming both remedies instead of guessing. The tenant arrives already resolved and validated, so a host never derives it a second time.
173
+ - `resolveGroupIds(request, tenantId)` → the acting user's group ids within that tenant. Forwarded automatically to `getEffectivePermissions` by both `requirePermission` and `getMyPermissions`, so group-granted roles count toward effective permissions. Returning `null`/`undefined` (or omitting the port) sends no `groupIds` at all.
144
174
  ⚠️ Without this port, roles assigned to groups in the admin UI have **no runtime effect** — the store writes succeed and the UI looks correct, so the failure is silent. Cover the wiring with a test.
145
- - `requirePermission(request, {resourceKey, action}, options?)` → `{ userId, scope, permission }` on allow: the scope for row WHERE / column mask, **and** the `PermissionView` derived from the very same evaluation (no second lookup, so the guard and the badge can never disagree). Denies throw `AuthzDeniedError` (403), or `throw redirect(options.failureRedirect)` when set. A malformed query (blank `resourceKey`, unknown `action`, or the derived `"write"`) throws `AuthzUsageError` before anything is loaded — a mistake surfaces as a 500 you can read, never as a mysterious 403.
175
+ - `requirePermission(request, {resourceKey, action}, options?)` → `{ userId, tenantId, scope, permission }` on allow: the scope for row WHERE / column mask, **and** the `PermissionView` derived from the very same evaluation (no second lookup, so the guard and the badge can never disagree). Denies throw `AuthzDeniedError` (403), or `throw redirect(options.failureRedirect)` when set. A malformed query (blank `resourceKey`, unknown `action`, or the derived `"write"`) throws `AuthzUsageError` before anything is loaded — a mistake surfaces as a 500 you can read, never as a mysterious 403.
146
176
  - `getMyPermissions(request, {resourceKeys})` → `Record<K, PermissionView>` where `K` is the literal union of the keys you passed, so an unrequested key is a **compile error** rather than a runtime `undefined`. `getMyPermissions(request)` reports on every registered resource (requires `listResourceKeys`). Duplicate keys collapse; an empty list asks the database nothing.
147
177
  ⚠️ The no-argument form's result **keys are your app's resource inventory**. Every value is still evaluated fail-closed (an unauthenticated caller gets all-false views), but do not hand the whole map to an unauthenticated client if the resource names themselves are sensitive — name the resources you actually render instead.
148
178
  - `AuthzUsageError` — thrown for API misuse (missing/ill-typed arguments, missing port). Deliberately distinct from `AuthzDeniedError`: misuse must not masquerade as a denial.
@@ -174,7 +204,11 @@ Two rules make the whole surface predictable, and both exist because their absen
174
204
  | `requireAndGetPermission(request, query)` | `requirePermission(request, query)` — it already returns `permission` |
175
205
  | `getEffectivePermissions: (args) => …(args.resourceKey)` (one resource) | `getEffectivePermissions: (args) => …(args.resourceKeys)` returning a record (many resources) |
176
206
  | `createAuthzAdminApp({ …, getMyPermissions })` | port removed — the admin shell reads its badge off the guard |
177
- | `createAuthzAdminApp({ requirePermission })` resolving to `{ userId }` | must now resolve to `{ userId, permission }` — pass `createAuthz`'s `requirePermission` directly, or add `permission` to a hand-rolled guard |
207
+ | `createAuthzAdminApp({ requirePermission })` resolving to `{ userId }` | must now resolve to `{ userId, tenantId, permission }` — pass `createAuthz`'s `requirePermission` directly, or add both fields to a hand-rolled guard |
208
+ | `createAuthz({ … })` without `resolveTenantId` | the port is **required**; omitting it throws `AuthzUsageError` at factory time |
209
+ | `createAuthz({ … })` without `assertTenantMembership` | also **required**; naming a tenant never proves membership in it, so omitting it throws `AuthzUsageError` at factory time |
210
+ | `getEffectivePermissions({ userId, appKey, resourceKeys })` | `tenantId` is now always present and non-empty — scope your query by it (never treat its absence as "all tenants") |
211
+ | `AuthzAdminStore` methods called without a tenant (`listRoles()`, `deleteRole(id)`, …) | every method takes a required `tenantId` first (`listRoles(tenantId)`, `deleteRole(tenantId, id)`); `RoleInput` / `RoleUpdate` / `ResourceInput` / `GrantInput` also name their target tenant |
178
212
  | `usePermission(view)` → `{ canRead, canWrite, canDelete, …States }` | adds `canCreate` / `canUpdate`, so it is a superset of the view's booleans |
179
213
  | `expectedResourceKey?: string` on the indicator components | now `NoInfer<K>` — bound to the permission's own key, so a mismatch is a compile error (0.6.x code that deliberately passed a different key stops compiling) |
180
214
  | `createAuthzAdminServer(...).loadMyPermissions` | removed — `layoutGuard` returns `{ userId, myPermissions }` from the guard it already runs |
@@ -200,6 +234,21 @@ The splat-mounted admin app (`createAuthzAdminApp`) serves `roles`, `resources`,
200
234
 
201
235
  Reads require `read` on the admin resource; writes require the verb matching their effect (`create` / `update` / `delete`).
202
236
 
237
+ ### Tenancy in the admin UI
238
+
239
+ The whole screen is scoped to the tenant `resolveTenantId` returned for the request — the shell shows it
240
+ (`data-testid="authz-tenant-badge"`), every table has a tenant column, and the create-role / create-resource /
241
+ create-grant forms carry a `tenantId` field prefilled with it.
242
+
243
+ Two rules make that more than decoration:
244
+
245
+ - **The form's tenant never widens the request's.** A submitted `tenantId` states which tenant the payload targets; it is
246
+ compared with the guard's tenant and a mismatch is refused with 403. Neither side is preferred.
247
+ - **Every mutation re-checks ownership server-side.** Ids arrive from a form, so before writing, the target row is loaded
248
+ within the tenant and its **own** `tenantId` compared. A filtered list is not an authorization check: if the store ever
249
+ dropped its predicate, every id in the database would look owned. A row that is absent *or* foreign is refused
250
+ identically (403), so the screen cannot be used to probe which ids exist in another tenant.
251
+
203
252
  ## Demo App
204
253
 
205
254
  This package contains an interactive React Router 7 SPA demo in the `demo/` subdirectory. The demo app runs completely in the browser, using `localStorage` to mock database storage.