@lenso/authorization 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,408 @@
1
- # Temporary Holding Version
1
+ # @lenso/authorization
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A composable authorization engine for trusted application facts. It has no User
4
+ table, required organization, HTTP transport, database, authentication method or
5
+ Lenso runtime dependency. RBAC is one module, not the engine's only model.
6
+
7
+ | Entry | Purpose |
8
+ | --------------------------------------------------------- | ------------------------------------------------------------------ |
9
+ | `@lenso/authorization` | Decisions, conditions, scoped RBAC, bounded lists, role management |
10
+ | `@lenso/authorization/auth` | Adapter to one exact existing Auth `Access.enforce` chain |
11
+ | `@lenso/authorization/plugin` | Thin Lenso setup wrapper |
12
+ | `@lenso/authorization/drizzle/pg` | Borrowed native Drizzle PostgreSQL role store |
13
+ | `@lenso/authorization/drizzle/d1` | Borrowed native Drizzle D1 role store |
14
+ | `@lenso/authorization/drizzle/sqlite` | Borrowed Bun SQLite role store |
15
+ | `@lenso/authorization/drizzle/schema-pg`, `schema-sqlite` | Role graph schemas |
16
+ | `@lenso/authorization/migrations/*` | Explicit SQL migrations |
17
+
18
+ The root imports none of Auth, Lenso, Drizzle, Manage, Web, Tasks, Log or OTel.
19
+ Optional peers are required only by the selected subpath. No Cedar, OpenFGA or
20
+ Casbin dependency is installed. Relation and custom-policy interfaces allow an
21
+ application-owned external adapter without copying a policy language.
22
+
23
+ ## Trust and evaluation contract
24
+
25
+ `Principal` identifies `(realmId, subjectId, kind)`. A realm is a verified
26
+ identity-source namespace, not a tenant. Equal emails or subject IDs in different
27
+ realms do not link accounts. Explicit application mapping is required.
28
+
29
+ The pure core accepts facts already trusted by the caller; it **does not
30
+ authenticate a JSON principal**. Keep it inside the service, after authentication
31
+ and resource loading. Public request DTOs must not supply the principal,
32
+ membership, resource scope/owner, credential ceilings or approval facts.
33
+
34
+ `Request<Action, Resource, Context>` carries the principal (or explicit `null`),
35
+ action, resource and context. Core facts must be finite plain data; dates should
36
+ be projected to timestamps/strings. Each check copies and freezes facts before
37
+ awaiting callbacks. Callbacks are trusted installed code, not a sandbox.
38
+
39
+ Evaluation order:
40
+
41
+ 1. Validate the action and required identity, realm, operation audience and
42
+ credential presence. Missing required facts deny.
43
+ 2. Optionally resolve authoritative resource attributes. Its type and ID cannot
44
+ change; scope comes from this resolved object, not an unverified client claim.
45
+ 3. Intersect any verified credential permissions/expiry with the target, optionally
46
+ resolve trusted attributes, then run all application `boundaries`. No
47
+ allow-producing extension runs before these.
48
+ 4. Evaluate matching rules and installed policies. Policies return exactly
49
+ `allow`, `deny` or `abstain`. Applicable explicit deny wins; otherwise any
50
+ allow grants; all abstain/no match denies.
51
+
52
+ Actions, resource types/IDs, scopes and attribute keys match **exactly**.
53
+ `scope` is `{type,id}`, with no inferred parent, tenant prohibition or wildcard.
54
+ A permission with no `resourceId` explicitly covers every resource of its exact
55
+ type in its exact scope. `*` has no special meaning. A rule with no scope
56
+ explicitly applies across scopes, so use it deliberately.
57
+
58
+ Independent allow rules/policies are OR grants. Use `all(...)` for an RBAC grant
59
+ AND a resource/request condition; do not install a second allow rule and expect
60
+ it to restrict the first. `any(...)` is explicit OR inside a condition. Both
61
+ require nonempty children; cycles/depth over 32 are rejected. Every evaluated
62
+ branch is checked, so an exception in an OR branch cannot be hidden by true.
63
+
64
+ Built-in ABAC is limited to own-key primitive `equals`/`in` comparisons on
65
+ principal/resource attributes or context. Missing attributes never match, even
66
+ against `null`. Trusted predicates can express application-specific logic.
67
+ An optional structured `attributes: {resolve(request,evaluation)}` provider
68
+ replaces selected principal/resource attribute bags or context with authoritative
69
+ finite facts. It cannot change identity, action, scope or credential ceilings,
70
+ or mint a principal for an anonymous request. Omitted bags retain supplied facts;
71
+ returned bags replace, not merge. Provider exceptions refuse access.
72
+ Relations make one direct resolver call per relation condition: no graph
73
+ traversal, recursive usersets or relationship database is provided.
74
+
75
+ Unknown actions/roles, invalid graphs/outcomes, evaluated resolver exceptions and
76
+ timeouts cannot yield allow. Default deadline is 1 second; `timeoutMs` supports
77
+ 1–60,000 ms. The deadline races asynchronous evaluation and checks elapsed time
78
+ before allow, but cannot preempt synchronous JavaScript or terminate callback
79
+ work. Callbacks must honor `Evaluation.signal`, remain read-only and settle
80
+ after cancellation. Late callback success cannot change a returned deny.
81
+
82
+ `check` always returns a safe `{effect,code}`; `can` returns a boolean; `enforce`
83
+ rejects with `AuthorizationError("Access denied.")`. All are asynchronous.
84
+ No resource IDs, exception text or rule data enter decisions. Reason codes are
85
+ service diagnostics, not a public policy-discovery endpoint: untrusted callers
86
+ receive the same generic denial via `enforce`, or a boolean preview via `can`.
87
+ Do not serialize distinct check/error categories, backend counts or lookup errors
88
+ in a way that distinguishes a hidden resource from an absent one.
89
+
90
+ ## Four minimal recipes
91
+
92
+ These recipes use the existing Notes domain, not a new demo application.
93
+ Executable counterparts are in [test/usage.test.ts](test/usage.test.ts); the
94
+ real Auth adapter uses `StoredNote` from the existing Notes example in
95
+ [test/auth.test.ts](test/auth.test.ts).
96
+
97
+ ### 1. Pure RBAC, without organizations, DB or Lenso
98
+
99
+ ```ts
100
+ import { createAuthorization, rbacPolicy, type RoleGraph } from "@lenso/authorization";
101
+
102
+ const scope = { type: "personal", id: "home" };
103
+ const principal = { realmId: "notes", subjectId: "alice", kind: "user" };
104
+ const graph: RoleGraph = {
105
+ roles: [
106
+ {
107
+ id: "reader",
108
+ scope,
109
+ permissions: [{ action: "read", resourceType: "note", scope }],
110
+ },
111
+ ],
112
+ bindings: [{ id: "alice-reader", principal, roleId: "reader", scope }],
113
+ };
114
+ const authorization = createAuthorization({
115
+ actions: ["read"],
116
+ policies: [rbacPolicy({ actions: ["read"], graph })],
117
+ });
118
+ await authorization.enforce({
119
+ principal,
120
+ action: "read",
121
+ context: {},
122
+ resource: { type: "note", id: "one", scope },
123
+ });
124
+ ```
125
+
126
+ Static graph configuration is detached on construction. For editable roles use
127
+ `memoryRoleStore(graph, actions)` or a persistent store, and pass `store` instead
128
+ of `graph` to RBAC. Roles belong to a scope, not a global string on the user.
129
+ Same-scope `inherits: ["reader"]` is supported. Graphs reject cycles, missing
130
+ parents, cross-scope permissions/parents, duplicate keys and unknown actions.
131
+ Limits are 512 roles, 4,096 bindings and 512 permissions per role.
132
+
133
+ ### 2. RBAC plus resource/request constraints
134
+
135
+ Replace the standalone RBAC policy with one conjunctive grant:
136
+
137
+ ```ts
138
+ import { all, attribute, predicate, rbacPredicate } from "@lenso/authorization";
139
+
140
+ const authorization = createAuthorization({
141
+ actions: ["read"],
142
+ rules: [
143
+ {
144
+ id: "reader-open",
145
+ effect: "allow",
146
+ actions: ["read"],
147
+ resourceType: "note",
148
+ when: all(
149
+ predicate(rbacPredicate({ actions: ["read"], graph })),
150
+ attribute("resource", "state", "equals", "open"),
151
+ ),
152
+ },
153
+ ],
154
+ });
155
+ ```
156
+
157
+ Additional realm/audience, approval or credential limits go in `identity` and
158
+ `boundaries`, not another allow rule. A platform-scoped administrator role is an
159
+ ordinary explicit grant, still subject to all boundaries.
160
+
161
+ ### 3. Organization membership or resource sharing
162
+
163
+ ```ts
164
+ import { any, relation } from "@lenso/authorization";
165
+
166
+ const organization = {
167
+ type: "organization",
168
+ id: "team-a",
169
+ scope: { type: "platform", id: "my-app" },
170
+ };
171
+ const authorization = createAuthorization({
172
+ actions: ["read"],
173
+ relations: applicationRelations, // { check(principal, relation, resource, evaluation) }
174
+ rules: [
175
+ {
176
+ id: "member-or-shared",
177
+ effect: "allow",
178
+ actions: ["read"],
179
+ resourceType: "note",
180
+ when: any(relation("member", organization), relation("shared-with")),
181
+ },
182
+ ],
183
+ });
184
+ ```
185
+
186
+ `applicationRelations` reads verified memberships/shares from the application's
187
+ existing owner. Explicit resource sharing can permit a legitimate cross-org
188
+ operation. If cross-org access needs an approval, require it in a boundary.
189
+ No organization module or table is mandatory.
190
+
191
+ ### 4. Custom policy with a credential ceiling
192
+
193
+ ```ts
194
+ const authorization = createAuthorization({
195
+ actions: ["read", "write"],
196
+ identity: { credentialRequired: true },
197
+ policies: [
198
+ {
199
+ evaluate: (facts) => (facts.principal?.kind === "service" ? "allow" : "abstain"),
200
+ },
201
+ ],
202
+ });
203
+ await authorization.enforce({
204
+ principal: verifiedServicePrincipal,
205
+ action: "read",
206
+ resource: loadedNoteResource,
207
+ context: {},
208
+ credential: verifiedReadOnlyCredentialLimit,
209
+ });
210
+ ```
211
+
212
+ An API key ceiling intersects the current subject's effective permissions; it
213
+ does not create roles, bypass revocation or confer Console admission. Independent
214
+ service principals are evaluated under their own bindings. A custom policy
215
+ cannot bypass a declared credential ceiling. Expiry is checked at the evaluation
216
+ snapshot, not guaranteed through a later business write.
217
+
218
+ ## Existing Auth and Notes service
219
+
220
+ ```ts
221
+ import { createAuthorizedAccess } from "@lenso/authorization/auth";
222
+
223
+ const protectedRead = createAuthorizedAccess(
224
+ authentication.for(notesAudiences.read),
225
+ authorization,
226
+ ({ resource: note, membership }) => ({
227
+ resource: {
228
+ type: "note",
229
+ id: note.id,
230
+ scope: { type: "personal", id: note.ownerId },
231
+ attributes: { owner: note.ownerId },
232
+ },
233
+ context: { membership },
234
+ }),
235
+ );
236
+ // The service loads the actual note, then enforces before returning content.
237
+ await protectedRead.enforce(actor, "read", loadedNote, { signal });
238
+ ```
239
+
240
+ This is the application-owned replacement for that operation's current service
241
+ policy, not an additional route-only check or a parallel authorization truth.
242
+ Every call enters the exact Auth `Access.enforce` chain, revalidates credentials
243
+ and reads configured membership before projecting facts. Actor identity and
244
+ audience only come from its verified callback. Forged/copied actors, another Auth
245
+ instance, wrong audience, revoked credentials and missing membership fail.
246
+ The adapter detaches cloneable business records and membership before async
247
+ projection; `Date` in `StoredNote` is supported, resource handles/functions are
248
+ not. The projected core facts still must be finite plain data.
249
+
250
+ The current public Auth API does not expose verified API-key scopes. Supply
251
+ `credential` from an application-owned verified ceiling reader in the facts
252
+ callback and set `identity.credentialRequired` when it is mandatory. Do not
253
+ infer scopes from JSON or an actor's kind. Extending Auth's verified source
254
+ contract belongs to its integration owner.
255
+
256
+ Auth currently refuses null in `Access.enforce`; this adapter therefore protects
257
+ authenticated operations only. An explicitly public anonymous entry may call
258
+ the pure engine with `principal:null` and a public-resource predicate. A failed
259
+ login is not anonymous fallback. Auth still works unchanged when this package
260
+ is not installed. Source/facts callbacks use existing Auth's cooperative signal
261
+ contract; the engine timeout does not bound authentication before its callback.
262
+
263
+ ## Console identity choices
264
+
265
+ - **Shared realm:** business accounts, org memberships and Console admission
266
+ remain separate facts. Explicit `console` instance or platform bindings grant
267
+ admission; org ownership does not. Customer-content read, financial changes and
268
+ impersonation need separate actions, credential ceilings and approval gates.
269
+ - **Separate Console SSO/realm:** install a different Auth source/instance and
270
+ audience, then reuse the same core contracts. Map identities only by an explicit
271
+ trusted application mapping. A matching email/subject ID is not a mapping.
272
+
273
+ Neither deployment dictates shared or separate `users` tables. Platform grants
274
+ must deliberately express resource scopes or approved cross-scope policy; there
275
+ is no magic global administrator and no `skipAuth`.
276
+
277
+ ## Role administration and optional Manage
278
+
279
+ Management is **off by default**: no routes, operations or tools are registered.
280
+ Construct `createRoleManagement` only with trusted `authorize` and
281
+ `grantAuthority` adapters. It provides concrete `createRole` (grant),
282
+ `editRole` (edit), `bindRole` (bind), `revokeBinding` (revoke), and
283
+ `delegateRole` (delegate). Using a permission grants none of these actions.
284
+
285
+ `authorize` receives the scope, target ID and immutable proposed role/binding
286
+ (including recipient and delegation source). It must use a real Auth actor or
287
+ trusted entry credential, compare it to the supplied principal, and run the
288
+ same service-side authorization chain. This is not satisfied by accepting
289
+ `request.actor` JSON or returning true for a claimed role. Application policy can
290
+ forbid self-binding, restrict recipients and require approval for a particular
291
+ proposal. Explicit authorized self-binding is not globally forbidden.
292
+
293
+ `grantAuthority` supplies the verified `permissions`, exact `scopes` and finite
294
+ `maxExpiresAt`, intersected with the current credential ceiling and any approval.
295
+ All inherited/effective proposed permissions must fit. New bindings require a
296
+ live finite expiry. Role edits also check affected descendants and existing
297
+ binding expiries, including unbound roles that could stage elevation. An editor
298
+ cannot increase their own effective permissions through an already-bound role
299
+ or ancestor. Every accepted mutation validates the complete graph and performs
300
+ one revision CAS; conflicts are not retried.
301
+
302
+ The graph is read **before** management authorization so revocation/change in
303
+ that same store during authorization loses the final CAS. Authorization from
304
+ another store, credential owner, clock or external approval is not atomically
305
+ fenced by this CAS; stronger operations require a business-owned transaction or
306
+ conditional write at that boundary. Pass a deadline `Evaluation.signal` and a
307
+ fresh trusted `now`; do not retain an Evaluation as a permission ticket.
308
+
309
+ Delegation creates an independent binding capped at issuance by grant authority,
310
+ source effective permissions and source expiry. Revoking its source later does
311
+ not cascade. Later separately authorized role edits change all active bindings;
312
+ issuance permissions are not a permanent delegated snapshot ceiling. Applications
313
+ requiring cascading or immutable delegation should reject `delegateRole` and
314
+ use a separately reviewed adapter, not assume those guarantees.
315
+
316
+ Mutation results contain only a revision, never the complete graph. Store reads
317
+ and `effective*` helpers are trusted internal APIs, not management list endpoints;
318
+ validate graphs before using the helper functions directly.
319
+
320
+ To enable Manage, wrap selected concrete methods in an application service that
321
+ obtains a trusted actor via its existing entry binding. Declare the same shared
322
+ schema/service with `defineOperation`, then use existing `defineManage` with the
323
+ exact plugin and only those operations. CLI, MCP and agent allowlists are separate;
324
+ no business input accepts an actor/grant proof. Reuse Tasks for durable management
325
+ work if needed, but its payload must not persist an allow ticket. No public
326
+ Auth/Tasks/Manage interfaces are modified by this package.
327
+
328
+ ## Lists, snapshots, revocation and write boundaries
329
+
330
+ Single-object `check` does not authorize a whole query. `authorizeList` implements
331
+ the explicit bounded fallback: pass the **complete** trusted candidate set
332
+ (maximum 1,000), shared request facts and `{maxCandidates,offset,limit}`.
333
+ It checks every item before pagination, returns only visible items and a visible
334
+ total, and refuses oversized sets or any evaluation failure without partial
335
+ results. Do not pass one unrestricted backend page, its count/aggregates, or
336
+ resource-specific errors to callers. Aggregate only over the authorized result.
337
+
338
+ This release does not compile rules, arbitrary TypeScript predicates or relation
339
+ resolvers into SQL. For unbounded lists, refuse unless the application has an
340
+ independently reviewed equivalent database constraint that includes boundaries,
341
+ denies and current credentials. A candidate-fetch optimization may narrow a
342
+ superset, but does not replace final item checks. UI previews are UX only; Web,
343
+ CLI, MCP and agents call the same protected service.
344
+
345
+ Store-backed RBAC reads on every evaluation; there is no positive cross-request
346
+ cache. A caller can explicitly read one `RoleSnapshot` and construct request-local
347
+ RBAC with its graph, plus one trusted resource/context snapshot, for repeated
348
+ checks. That snapshot remains stale through the rest of that invocation; discard
349
+ it afterward. Shared snapshots do not create atomicity across separate resolvers.
350
+
351
+ Revocation is observed by the next fresh read that sees the committed graph
352
+ revision. Existing decisions/in-flight snapshots are not invalidated. Database
353
+ replicas, transaction snapshots and adapter caches can delay it; short TTL is not
354
+ instant revocation. Policy/graph, identity/membership, credential and resource
355
+ versions plus invalidation must be designed before adding a positive cache.
356
+
357
+ An allow is not a durable capability. Mutable owner/status/balance conditions
358
+ need an atomic recheck or version/owner predicate in the actual business write.
359
+ [test/toctou.test.ts](test/toctou.test.ts) uses real SQLite to demonstrate a
360
+ concurrent owner change making the conditional write affect zero rows.
361
+ DB transactions cannot cover external notifications/payments; their effects need
362
+ their own reviewed idempotency/fencing/compensation. No exactly-once claim is made.
363
+
364
+ ## Explanation, observation and lifecycle
365
+
366
+ Ordinary `check` has no rule paths. Configure an explicit `explain` action/resource
367
+ gate to enable `explain(managerRequest,targetRequest)`; the manager must be
368
+ authenticated at the trusted entry and pass that gate. Its result contains safe
369
+ reason codes and ordinal paths such as `rules/0/deny`, not rule IDs, tenant IDs,
370
+ predicate details or exception messages. Never expose raw core facts/graphs as
371
+ diagnostics. Auth-backed explanation must run within the application's verified
372
+ management `Access.enforce` callback, not accept a JSON manager request.
373
+
374
+ Optional `observe` receives only completed policy decision codes/effects, not
375
+ all early identity/error outcomes. It can call existing Log/OTel interfaces;
376
+ observer failure/timeout refuses access. Logging is not durable auditing, and
377
+ this callback provides no atomic audit/write guarantee.
378
+
379
+ The core owns no connections, workers or timers beyond per-check deadlines.
380
+ Drizzle stores borrow their database and never close it. `createAuthorizationPlugin`
381
+ accepts `setup(context)` and exact `requires` instances; setup obtains providers
382
+ with `context.get(provider)`. Owned resources must register cleanup immediately
383
+ through existing Lenso lifecycle; borrowed Auth/DB instances remain with owners.
384
+ Startup configuration can use existing `definePluginConfig`/`bindConfig`; validated
385
+ engine options are ordinary TypeScript configuration, not a new config center.
386
+
387
+ ## Persistence and checks
388
+
389
+ See [Drizzle adapter notes](src/drizzle/README.md) for initialization, database
390
+ consistency and migrations. Local checks:
391
+
392
+ ```sh
393
+ bun run --cwd packages/lenso build
394
+ bun run --cwd packages/auth build
395
+ bun run --cwd packages/authorization build
396
+ bun run --cwd packages/authorization typecheck
397
+ bun run --cwd packages/authorization test
398
+ ```
399
+
400
+ The PostgreSQL fixture test is skipped unless an explicitly task-owned local
401
+ `authorization_fixture` database is supplied with `AUTHORIZATION_TEST_PG_URL`
402
+ and `AUTHORIZATION_TEST_PG_OWNED=1`. Never point it at an existing app/production
403
+ database. D1 uses local Miniflare, not a deployed Cloudflare database.
404
+ See [VALIDATION.md](VALIDATION.md) for actual results and remaining unverified items.
405
+
406
+ Design references consulted: [OWASP authorization](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html),
407
+ [Cedar terminology](https://docs.cedarpolicy.com/overview/terminology.html),
408
+ [OpenFGA concepts](https://openfga.dev/docs/authorization-concepts).
package/VALIDATION.md ADDED
@@ -0,0 +1,105 @@
1
+ # Authorization validation
2
+
3
+ ## Initial implementation checks
4
+
5
+ Validated locally with Bun **1.4.2**, TypeScript **7.0.2**, Drizzle **0.45.3**
6
+ and PostgreSQL **18.6**. Dependencies were restored from local cache with
7
+ `bun install --offline --no-save --ignore-scripts`; `bun.lock` was not changed.
8
+
9
+ - Built `packages/lenso` and `packages/auth` before using their public exports.
10
+ - `bun run --cwd packages/authorization build`: declaration generation and split
11
+ Bun build passed.
12
+ - `bun run --cwd packages/authorization typecheck`: passed.
13
+ - `bun run --cwd packages/authorization test`: **64 passed, 1 PostgreSQL test
14
+ skipped by default, 0 failures** across 12 files.
15
+ - The PostgreSQL test was also run separately against a newly initialized,
16
+ task-owned loopback PostgreSQL cluster and `authorization_fixture` database:
17
+ **1 passed, 0 failures**. The cluster was stopped afterward. No existing database
18
+ was touched.
19
+ - `oxlint packages/authorization --deny-warnings`, package-only `oxfmt --check`,
20
+ and `git diff --check`: passed.
21
+ - `bun pm pack --ignore-scripts` produced a local artifact containing declarations,
22
+ split JS chunks, migrations and documentation. In a scratch consumer with none
23
+ of Auth/core/Drizzle/Manage installed, the packed root imported and performed
24
+ actual RBAC allow/deny checks.
25
+ - All eight declared public JavaScript entry points loaded through package exports
26
+ with their selected workspace peers installed. Nothing was published.
27
+ - Final independent source review covered core/attribute ordering, Auth provenance
28
+ and snapshots, management grant/edit/delegation, RBAC/list handling, persistent
29
+ graph validation/CAS and optional dependency boundaries. It found no additional
30
+ source-backed blockers under the documented trust model. This review did not
31
+ independently execute tests, generated declarations or packed consumers.
32
+
33
+ The PostgreSQL command used the test's exact opt-in variables
34
+ `AUTHORIZATION_TEST_PG_URL` (task-owned loopback URL) and
35
+ `AUTHORIZATION_TEST_PG_OWNED=1`, then ran
36
+ `bun test packages/authorization/test/pg.test.ts`. No production connection,
37
+ credential, notification, charge, deployment or publication was used.
38
+
39
+ ## Behavior covered
40
+
41
+ - Full allow/deny/abstain conflict matrix in both orders; matching explicit deny
42
+ precedence; default denial; exact scope/action/resource matching.
43
+ - AND narrowing versus explicit OR; missing attributes; direct relationships;
44
+ anonymous public resources; explicit approved cross-org access and refusal.
45
+ - Credential presence, expiry and permission intersection before custom grants.
46
+ - Real existing Auth provenance checks for copied/JSON/foreign actors, wrong
47
+ audience, source revocation and membership loss; source/facts exception safety.
48
+ - Auth resource/membership snapshots across controlled asynchronous races.
49
+ - Independent management actions, bounded grant scopes/resources/expiry,
50
+ inherited and staged role-edit elevation, malformed/cyclic role graphs,
51
+ recipient-aware authorization and JSON-principal refusal through real Auth.
52
+ - Current-store revocation and concurrent revision conflicts, including
53
+ revocation while a management authorizer is paused.
54
+ - Lists checked before pagination, visible-only total, bounded fallback refusal
55
+ and no partial result on evaluated failures.
56
+ - Timeout, abort, malformed policy outputs, resolver errors and late success;
57
+ explanation gate with ordinal paths and safe denial messages.
58
+ - Real SQLite owner/version conditional update after a concurrent owner change.
59
+ - Actual SQLite migration/driver, JSON corruption, namespace isolation, immutable
60
+ snapshots, unchanged/stale revision refusal and one conditional-write winner.
61
+ - Local **Miniflare D1** migration and real Drizzle D1 driver, `UPDATE RETURNING`,
62
+ concurrent CAS and invalid graph rejection. This is an emulator check, not a
63
+ Cloudflare deployment.
64
+ - Actual PostgreSQL migration/Bun SQL JSONB roundtrip, namespaces, immutable read,
65
+ revocation document update, concurrent CAS and malformed graph rejection.
66
+ - Lenso exact instance dependencies, borrowed resource ownership and existing
67
+ Config binding. Four minimal executable recipes reuse the Notes domain.
68
+
69
+ ## Landing integration
70
+
71
+ The landing integration, explicitly authorized by the user, registers this
72
+ workspace and its dependencies in the shared `bun.lock`. The resulting
73
+ `bun install --frozen-lockfile` passed without further lockfile changes.
74
+ `scripts/ci-checks.sh` creates an additional task-owned `authorization_fixture`
75
+ database in its own temporary cluster and explicitly runs this package's
76
+ PostgreSQL test, rather than treating its default skip as backend validation.
77
+ Ambient Authorization test connection/ownership variables are cleared first.
78
+ The integration also records the public package change through Changesets;
79
+ no package versioning or registry publication is performed by this landing.
80
+
81
+ ## Deliberate limits and unverified items
82
+
83
+ - The initial implementation did not run the whole-repository pipeline or a live
84
+ Web/CLI/MCP/Manage deployment. No transport operations were auto-registered.
85
+ - Public Auth does not expose verified API-key scopes; application-owned verified
86
+ ceiling readers are required. Anonymous Auth `enforce` is not available; an
87
+ explicit public pure-core entry is documented instead.
88
+ - No external SSO, policy/relationship provider, deployed D1, replica-lag/failover,
89
+ other PostgreSQL version or other platform certification was tested.
90
+ - No SQL compiler is provided. Lists use a complete bounded candidate set or
91
+ must refuse pending an independently reviewed application constraint.
92
+ - No positive cross-request cache or invalidation protocol is provided. Revocation
93
+ takes effect when a fresh authoritative read sees the committed revision;
94
+ old decisions and in-flight snapshots remain old.
95
+ - Whole-graph CAS fences changes in the same role store. It does not atomically
96
+ fence separate Auth/membership/credential/resource stores, time expiry, approval
97
+ owners or external side effects. Business writes must recheck/fence their own
98
+ mutable state.
99
+ - Delegation creates independent bindings, with no cascading revocation or
100
+ permanent issuance-permission snapshot through later authorized role edits.
101
+ - Timers cannot preempt synchronous JS or forcibly settle providers. Async
102
+ callbacks must cooperate with cancellation. A cancelled committed mutation
103
+ can leave an uncertain caller outcome; it is not automatically retried.
104
+ - Optional observation is not a durable audit store or an atomic audit/write
105
+ transaction. No strict global consistency or exactly-once guarantee is claimed.
package/dist/auth.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { type Access, type Actor, type PolicyContext } from "@lenso/auth";
2
+ import type { Attributes, Awaitable, Authorization, CredentialLimit, Decision, Resource } from "./types";
3
+ export interface AuthorizationFacts<A extends string, R extends Resource, C> {
4
+ readonly resource: R;
5
+ readonly context: C;
6
+ readonly attributes?: Attributes;
7
+ /** Independently verified credential ceiling. Never read from business JSON. */
8
+ readonly credential?: CredentialLimit<A>;
9
+ }
10
+ export interface AuthorizedAccess<P, A extends string, T> {
11
+ check(actor: P | null, action: A, resource: T, options?: {
12
+ signal?: AbortSignal;
13
+ }): Promise<Decision>;
14
+ can(actor: P | null, action: A, resource: T, options?: {
15
+ signal?: AbortSignal;
16
+ }): Promise<boolean>;
17
+ enforce(actor: P | null, action: A, resource: T, options?: {
18
+ signal?: AbortSignal;
19
+ }): Promise<void>;
20
+ }
21
+ /** Every call reuses the exact Access.enforce chain; this is not another authenticator. */
22
+ export declare function createAuthorizedAccess<Realm extends string, E, S extends string, Audience extends string, T, M, A extends string, R extends Resource, C>(access: Access<Realm, E, S, Audience, T, M>, authorization: Authorization<A, R, C>, facts: (verified: PolicyContext<Actor<Realm, S, Audience>, T, M>) => Awaitable<AuthorizationFacts<A, R, C>>): AuthorizedAccess<Actor<Realm, S, Audience>, A, T>;
package/dist/auth.js ADDED
@@ -0,0 +1,59 @@
1
+ import {
2
+ AuthorizationError2
3
+ } from "./index-6f5nrdh9.js";
4
+
5
+ // src/auth.ts
6
+ import { AuthError } from "@lenso/auth";
7
+ function createAuthorizedAccess(access, authorization, facts) {
8
+ const check = async (actor, action, resource, options) => {
9
+ let decision;
10
+ try {
11
+ const invocationResource = structuredClone(resource);
12
+ await access.enforce(actor, invocationResource, async (verified) => {
13
+ const trusted = await facts(Object.freeze({
14
+ ...verified,
15
+ membership: structuredClone(verified.membership)
16
+ }));
17
+ decision = await authorization.check({
18
+ principal: {
19
+ realmId: verified.principal.realmId,
20
+ subjectId: verified.principal.subjectId,
21
+ kind: verified.principal.kind,
22
+ ...trusted.attributes ? { attributes: trusted.attributes } : {}
23
+ },
24
+ action,
25
+ resource: trusted.resource,
26
+ context: trusted.context,
27
+ audience: verified.principal.audience,
28
+ ...trusted.credential ? { credential: trusted.credential } : {}
29
+ }, { signal: verified.signal });
30
+ return decision.effect === "allow";
31
+ }, options);
32
+ return decision ?? Object.freeze({ effect: "deny", code: "EVALUATION_FAILED" });
33
+ } catch (error) {
34
+ if (error instanceof AuthError) {
35
+ if (error.code === "FORBIDDEN" && decision?.effect === "deny")
36
+ return decision;
37
+ if (error.code === "UNAUTHORIZED" || error.code === "REAUTHENTICATION_REQUIRED")
38
+ return Object.freeze({ effect: "deny", code: "BOUNDARY_DENIED" });
39
+ }
40
+ return Object.freeze({
41
+ effect: "deny",
42
+ code: options?.signal?.aborted ? "CANCELLED" : "EVALUATION_FAILED"
43
+ });
44
+ }
45
+ };
46
+ return Object.freeze({
47
+ check,
48
+ async can(...args) {
49
+ return (await check(...args)).effect === "allow";
50
+ },
51
+ async enforce(...args) {
52
+ if ((await check(...args)).effect !== "allow")
53
+ throw new AuthorizationError2;
54
+ }
55
+ });
56
+ }
57
+ export {
58
+ createAuthorizedAccess
59
+ };
@@ -0,0 +1,8 @@
1
+ import type { Attributes, Condition, Predicate, Resource, Scope, Permission, Request } from "./types";
2
+ export declare function all<A extends string = string, R extends Resource = Resource, C = Attributes>(...conditions: readonly Condition<A, R, C>[]): Condition<A, R, C>;
3
+ export declare function any<A extends string = string, R extends Resource = Resource, C = Attributes>(...conditions: readonly Condition<A, R, C>[]): Condition<A, R, C>;
4
+ export declare function predicate<A extends string = string, R extends Resource = Resource, C = Attributes>(test: Predicate<A, R, C>): Condition<A, R, C>;
5
+ export declare function attribute(source: "principal" | "resource" | "context", key: string, operator: "equals" | "in", value: string | number | boolean | null | readonly (string | number | boolean | null)[]): Condition;
6
+ export declare function relation(name: string, target?: Resource): Condition;
7
+ export declare function sameScope(left: Scope, right: Scope): boolean;
8
+ export declare function matchesPermission(permission: Permission, request: Request<string, Resource, unknown>): boolean;
package/dist/core.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ import type { Attributes, Authorization, AuthorizationOptions, Resource } from "./types";
2
+ export declare function createAuthorization<A extends string = string, R extends Resource = Resource, C = Attributes>(options: AuthorizationOptions<A, R, C>): Authorization<A, R, C>;
@@ -0,0 +1,4 @@
1
+ import type { DrizzleD1Database } from "drizzle-orm/d1";
2
+ export declare function d1RoleStore<A extends string = string, TSchema extends Record<string, unknown> = Record<string, unknown>>(db: DrizzleD1Database<TSchema>, namespace: string, actions: readonly A[]): import("..").RoleStore<A> & {
3
+ initialize(snapshot: import("..").RoleSnapshot<A>): Promise<void>;
4
+ };
@@ -0,0 +1,12 @@
1
+ import"../index-qjqy8v4k.js";
2
+ import {
3
+ sqliteStore2
4
+ } from "../index-pmwmdjss.js";
5
+
6
+ // src/drizzle/d1.ts
7
+ function d1RoleStore(db, namespace, actions) {
8
+ return sqliteStore2(db, namespace, actions);
9
+ }
10
+ export {
11
+ d1RoleStore
12
+ };
@@ -0,0 +1,5 @@
1
+ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
2
+ import type { RoleSnapshot, RoleStore } from "../types";
3
+ export declare function postgresRoleStore<A extends string = string, TSchema extends Record<string, unknown> = Record<string, unknown>, TResult extends PgQueryResultHKT = PgQueryResultHKT>(db: PgDatabase<TResult, TSchema>, namespace: string, actions: readonly A[]): RoleStore<A> & {
4
+ initialize(snapshot: RoleSnapshot<A>): Promise<void>;
5
+ };