@lenso/authorization 0.0.0-stage → 0.2.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 +407 -2
- package/VALIDATION.md +105 -0
- package/dist/auth.d.ts +22 -0
- package/dist/auth.js +59 -0
- package/dist/conditions.d.ts +8 -0
- package/dist/core.d.ts +2 -0
- package/dist/drizzle/d1.d.ts +4 -0
- package/dist/drizzle/d1.js +12 -0
- package/dist/drizzle/pg.d.ts +5 -0
- package/dist/drizzle/pg.js +32 -0
- package/dist/drizzle/schema-pg.d.ts +61 -0
- package/dist/drizzle/schema-pg.js +6 -0
- package/dist/drizzle/schema-sqlite.d.ts +65 -0
- package/dist/drizzle/schema-sqlite.js +6 -0
- package/dist/drizzle/shared.d.ts +8 -0
- package/dist/drizzle/sqlite.d.ts +11 -0
- package/dist/drizzle/sqlite.js +9 -0
- package/dist/errors.d.ts +6 -0
- package/dist/index-4znahyqj.js +9 -0
- package/dist/index-5k98eatm.js +224 -0
- package/dist/index-6f5nrdh9.js +16 -0
- package/dist/index-pmwmdjss.js +32 -0
- package/dist/index-qjqy8v4k.js +9 -0
- package/dist/index-t6xdqvqs.js +56 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +464 -0
- package/dist/list.d.ts +11 -0
- package/dist/management.d.ts +43 -0
- package/dist/plugin.d.ts +11 -0
- package/dist/plugin.js +12 -0
- package/dist/rbac.d.ts +24 -0
- package/dist/snapshot.d.ts +5 -0
- package/dist/types.d.ts +162 -0
- package/migrations/0001-role-graphs-pg.sql +5 -0
- package/migrations/0001-role-graphs-sqlite.sql +5 -0
- package/package.json +80 -4
- package/src/drizzle/README.md +48 -0
package/README.md
CHANGED
|
@@ -1,3 +1,408 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @lenso/authorization
|
|
2
2
|
|
|
3
|
-
|
|
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,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,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
|
+
};
|