@aiquants/authz-react-router 0.7.2 → 0.8.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/README.md +59 -10
- package/dist/index.d.mts +573 -92
- package/dist/index.d.ts +573 -92
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
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
|
|
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 (
|
|
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
|
-
- `
|
|
142
|
-
- `
|
|
143
|
-
- `
|
|
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
|
|
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.
|