@helipod/authz 0.1.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 +438 -0
- package/dist/index.d.ts +204 -0
- package/dist/index.js +345 -0
- package/dist/index.js.map +1 -0
- package/package.json +46 -0
package/README.md
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
# @helipod/authz
|
|
2
|
+
|
|
3
|
+
**Reactive, typed authorization for Helipod.** One model that scales from a two-line ownership rule to multi-tenant SaaS with sharing and hierarchies — enforced *inside the engine* so you can't forget a check, and reactive *by construction* so revoking access empties live subscriptions instantly.
|
|
4
|
+
|
|
5
|
+
> **Status: design target (this README is the spec).** This document describes the complete intended API and behavior. The component is built to match it, layer by layer (see [Build order](#build-order)). The design rationale — why this model beats RBAC-only, Postgres RLS, OpenFGA/Zanzibar, and SpiceDB for a *reactive* backend — is in [`docs/research.md`](./docs/research.md).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why authz is different here
|
|
10
|
+
|
|
11
|
+
Most authorization is bolted on: a library you call (and forget to call), a database feature that only your DB enforces, or a separate service you sync to. Helipod authz is **none of those**. It is:
|
|
12
|
+
|
|
13
|
+
- **Reactive by construction.** A permission check *reads* authorization data, and Helipod tracks every query's read-set. So when you revoke a role or unshare a document, every affected live query **re-runs and updates in the same instant** — no cache invalidation, no polling, no refetch. No other authorization model gives you this, because none of them runs *inside* a reactive engine.
|
|
14
|
+
- **O(1) on the hot path.** Roles, relationships, and hierarchies are *flattened into an indexed table at write time*, so a permission check is a single indexed point-read — not a graph traversal. The thing that runs on every request is the cheapest thing in the system.
|
|
15
|
+
- **Engine-enforced, can't-forget.** Row policies run at the `ctx.db` kernel seam, so *every* read — including joins and counts — is filtered, and *every* write is checked. A function that forgets to authorize still cannot leak data.
|
|
16
|
+
- **One TypeScript model, no DSL.** Permissions, roles, conditions, and relationships are all plain typed TypeScript. A typo'd permission is a *compile error*. No `.fga`/`.zed` schema, no CEL, no SQL `CREATE POLICY`. It's fully typed end-to-end through codegen.
|
|
17
|
+
- **A complexity ladder.** The simple case is two lines. Roles, tenancy, sharing, and hierarchy are each opt-in and appear only when an app needs them — the same model the whole way up, with no paradigm migration.
|
|
18
|
+
|
|
19
|
+
See the full, unbiased comparison and the con-by-con engineering analysis in [`docs/research.md`](./docs/research.md).
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Install & enable
|
|
24
|
+
|
|
25
|
+
`@helipod/authz` is a Helipod component. Add it to your project's `helipod.config.ts`:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
// helipod.config.ts
|
|
29
|
+
import { defineConfig } from "@helipod/component";
|
|
30
|
+
import { auth } from "@helipod/auth";
|
|
31
|
+
import { authz } from "./authz.config";
|
|
32
|
+
|
|
33
|
+
export default defineConfig({ components: [auth, authz] });
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
`authz` requires `auth` (it uses `ctx.auth.getUserId()` as the identity anchor). The engine wires `ctx.authz` into every function and auto-enforces your row policies; the dashboard gains an Authorization page for roles, assignments, and grants.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
The smallest useful authz is one rule — "a user sees only their own rows":
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// authz.config.ts
|
|
46
|
+
import { defineAuthz } from "@helipod/authz";
|
|
47
|
+
|
|
48
|
+
export const authz = defineAuthz({
|
|
49
|
+
policies: {
|
|
50
|
+
documents: {
|
|
51
|
+
// READ rules return a query predicate, AND-merged into every read of `documents`.
|
|
52
|
+
read: ({ auth }) => ({ ownerId: auth.userId }),
|
|
53
|
+
// WRITE rules check the row being written and return true/false.
|
|
54
|
+
write: ({ auth }, doc) => doc.ownerId === auth.userId,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
That's it. Now **every** query on `documents` — including ones that forgot to filter, and including `documents` hydrated through a join — returns only rows the caller owns, and any write to a `documents` row the caller doesn't own throws `Forbidden`. And it's reactive: if you later change `ownerId`, every subscriber's view updates live.
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
// convex/documents.ts — no manual authz call needed; the policy is enforced by the engine
|
|
64
|
+
export const list = query(async (ctx) => ctx.db.query("documents").collect()); // already filtered
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Need an explicit check (e.g. to gate a mutation by capability)? Use the typed `ctx.authz` facade:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
export const remove = mutation(async (ctx, { id }) => {
|
|
71
|
+
const doc = await ctx.db.get(id);
|
|
72
|
+
await ctx.authz.require("documents:delete", { org: doc.orgId }); // throws Forbidden if denied
|
|
73
|
+
await ctx.db.delete(id);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Mental model (three ideas)
|
|
80
|
+
|
|
81
|
+
1. **Authorization is application data.** Roles, grants, and relationships are rows in namespaced `authz/*` tables. There is no separate policy engine — authz *is* data in the same MVCC store as everything else.
|
|
82
|
+
2. **A permission check is a data read.** `ctx.authz.can(...)` is a single indexed point-read against a pre-computed `authz/effective_permissions` index; a row policy is a predicate merged into a query. Because checks read data, they enter the read-set, so they are reactive for free.
|
|
83
|
+
3. **Enforcement lives at the engine, not the call site.** Row policies are applied in the kernel's `ctx.db` path (the same seam that enforces component namespacing). You declare rules once at the data layer; you cannot forget them at a call site, and joins/counts can't slip past them.
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## The complexity ladder
|
|
88
|
+
|
|
89
|
+
The same model covers every project type. Adopt only the layer you need.
|
|
90
|
+
|
|
91
|
+
### Level 0 — Ownership (row policies)
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
defineAuthz({
|
|
95
|
+
policies: {
|
|
96
|
+
todos: {
|
|
97
|
+
read: ({ auth }) => ({ userId: auth.userId }),
|
|
98
|
+
write: ({ auth }, row) => row.userId === auth.userId,
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Level 1 — Roles & permissions
|
|
105
|
+
|
|
106
|
+
Declare a typed permission vocabulary and roles as named permission sets (with inheritance):
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
defineAuthz({
|
|
110
|
+
permissions: {
|
|
111
|
+
documents: ["read", "update", "delete", "share"],
|
|
112
|
+
billing: ["view", "manage"],
|
|
113
|
+
},
|
|
114
|
+
roles: {
|
|
115
|
+
viewer: { documents: ["read"] },
|
|
116
|
+
editor: { inherits: "viewer", documents: ["update"] },
|
|
117
|
+
admin: { inherits: "editor", documents: ["delete", "share"], billing: ["view", "manage"] },
|
|
118
|
+
},
|
|
119
|
+
policies: {
|
|
120
|
+
documents: {
|
|
121
|
+
read: ({ auth }) => auth.can("documents:read"), // a role grants the permission
|
|
122
|
+
write: ({ auth }, doc) => auth.can("documents:update"),
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Assign roles at runtime (in a mutation, or from the dashboard):
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
export const promote = mutation(async (ctx, { userId }) => {
|
|
132
|
+
await ctx.authz.require("billing:manage");
|
|
133
|
+
await ctx.authz.assignRole(userId, "editor");
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`permissions` produce string-literal types: `auth.can("documents:reed")` is a **compile error**.
|
|
138
|
+
|
|
139
|
+
### Level 2 — Multi-tenant scopes
|
|
140
|
+
|
|
141
|
+
A role is granted *within a scope*. The scope becomes part of the index key, so tenant isolation is **structural**, not a remembered `WHERE`:
|
|
142
|
+
|
|
143
|
+
```ts
|
|
144
|
+
await ctx.authz.assignRole(userId, "admin", { type: "org", id: orgId });
|
|
145
|
+
|
|
146
|
+
// check is scoped:
|
|
147
|
+
await ctx.authz.require("documents:delete", { org: doc.orgId });
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
In a read policy, "documents in any org where I can read" is expressed with `auth.scopesWith(...)`, which resolves (from the effective-permissions index) the set of scope ids where the caller holds a permission:
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
policies: {
|
|
154
|
+
documents: {
|
|
155
|
+
read: ({ auth }) => ({ orgId: { in: auth.scopesWith("documents:read", "org") } }),
|
|
156
|
+
},
|
|
157
|
+
}
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### Level 3 — Per-resource sharing (relations)
|
|
161
|
+
|
|
162
|
+
Share a single resource with a single user/team via relationship rows, and express visibility with a **relation predicate** (reactive: a write to the related table re-runs the subscription):
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
// share
|
|
166
|
+
await ctx.authz.addRelation(userId, "viewer", { type: "document", id: docId });
|
|
167
|
+
|
|
168
|
+
// policy: I can read a doc I own OR a doc shared with me OR my team
|
|
169
|
+
policies: {
|
|
170
|
+
documents: {
|
|
171
|
+
read: ({ auth }) => ({
|
|
172
|
+
OR: [
|
|
173
|
+
{ ownerId: auth.userId },
|
|
174
|
+
{ sharedWith: { some: { userId: auth.userId } } }, // direct share
|
|
175
|
+
{ sharedWith: { some: { team: { is: { members: { some: { userId: auth.userId } } } } } } }, // team share
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Level 4 — Hierarchy (opt-in arrow traversal)
|
|
183
|
+
|
|
184
|
+
For folder→doc / recursive structures, declare inherited relations. The engine compiles these to **write-time closure expansion**, so checks stay O(1) (no read-time graph walk):
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
defineAuthz({
|
|
188
|
+
relations: {
|
|
189
|
+
folder: { viewer: ["user", "folder#viewer"] }, // a folder's viewer can be inherited from its parent folder
|
|
190
|
+
document: { viewer: ["user", "folder#viewer from parent"] }, // a doc inherits its folder's viewers
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Apps that declare no arrow relations pay zero overhead — the traversal compiler is a no-op.
|
|
196
|
+
|
|
197
|
+
---
|
|
198
|
+
|
|
199
|
+
## Feature reference
|
|
200
|
+
|
|
201
|
+
### Permissions
|
|
202
|
+
|
|
203
|
+
`permissions: { resource: [action, ...] }` defines the authorization vocabulary. Codegen emits `"resource:action"` string-literal types consumed by `auth.can`, `ctx.authz.can/require`, role declarations, and grants — so every permission reference is type-checked and a typo is a compile error. Supports **wildcards**: `"documents:*"` (all actions on documents).
|
|
204
|
+
|
|
205
|
+
### Roles
|
|
206
|
+
|
|
207
|
+
`roles: { name: { inherits?, ...permissionSets } }`. A role is a typed subset of the permission registry. `inherits` composes roles (single or array). Roles are resolved/unioned at assignment time and flattened into the effective-permissions index. Unknown/undeclared roles grant nothing (**fails closed**).
|
|
208
|
+
|
|
209
|
+
### Row policies
|
|
210
|
+
|
|
211
|
+
`policies: { table: { read?, write? } }` — declared per app table.
|
|
212
|
+
|
|
213
|
+
- **`read(ctx)` → `WhereInput | true | false`** — returns a query predicate that is **AND-merged into every read** of the table (`get`, `query`, `collect`, paginate, and hydrated joins). `true` = unrestricted; `false` = deny (zero rows); returning nothing = no restriction added by this policy.
|
|
214
|
+
- **`write(ctx, row)` → `boolean | Promise<boolean>`** — receives the candidate row (on insert) or pre-write row (on update/delete) and returns allow/deny; `false`/throw → `Forbidden`. **Write rules may call `ctx.db`** to resolve relationships (e.g. "is the caller a member of the row's org?"), and those reads join the transaction.
|
|
215
|
+
|
|
216
|
+
**Policy-author notes:**
|
|
217
|
+
|
|
218
|
+
1. **Write policies gate both images on replace.** Insert checks the new row; replace checks **both** the existing row (you may modify it) **and** the resulting row (so you can't reassign it out of your own visibility); delete checks the existing row. This means a write rule like `row.ownerId === auth.userId` blocks an ownership reassignment even if the caller currently owns the document.
|
|
219
|
+
2. **`scopesWith` returns scoped grants only, not global ones.** Pair it with a preceding `auth.can(permission)` check — a global grant makes `can` true and means unrestricted access — e.g. `auth.can("documents:read") ? true : { orgId: { in: await auth.scopesWith("documents:read") } }`.
|
|
220
|
+
3. **`isNull: true` matches an explicit `null`, not a missing field.** A missing field is `undefined` in the document value, which is distinct from `null` — only explicit `null` values stored on a field match `{ field: { isNull: true } }`.
|
|
221
|
+
|
|
222
|
+
**Rule context** (`ctx`): `auth.userId` (the resolved caller, `null` if anonymous), `auth.roles`, `auth.can(permission, scope?)`, `auth.scopesWith(permission, type?)`, `auth.identity` (raw claims), and `db` (a read-only `ctx.db` for relation lookups).
|
|
223
|
+
|
|
224
|
+
**Predicate operators** (in `WhereInput`): field operators `eq` (bare value), `ne`, `in`, `notIn`, `lt`, `lte`, `gt`, `gte`, `isNull`; logical `AND` / `OR` / `NOT`; **relation predicates** `is` / `isNot` (to-one), `some` / `none` / `every` (to-many). All fully typed against `Doc<T>`.
|
|
225
|
+
|
|
226
|
+
**Relation predicates (`some` / `is`).** A read policy can filter by related rows:
|
|
227
|
+
- `{ sharedWith: { some: { userId: auth.userId } } }` — to-many: a row in the related
|
|
228
|
+
child table names the caller. Declare the relation on the table:
|
|
229
|
+
`defineTable({...}).relation("sharedWith", { table: "document_shares", field: "documentId" })`.
|
|
230
|
+
- `{ orgId: { is: { ownerId: auth.userId } } }` — to-one: follow a `v.id` field to its row
|
|
231
|
+
and test it (no declaration needed).
|
|
232
|
+
|
|
233
|
+
Both are **reactive** — a write to the related table live-updates the subscription — and the leaf
|
|
234
|
+
of a relation clause may itself contain relation clauses (multi-level chains, see below). Performance
|
|
235
|
+
note: v1 scans the related table per clause; declaring an index on the child's filtered field is
|
|
236
|
+
recommended and will be used once index push-down lands.
|
|
237
|
+
|
|
238
|
+
**More relation operators.** Beyond `some`/`is`, read policies support:
|
|
239
|
+
- `{ rel: { none: leaf } }` — to-many: *no* related row matches `leaf`.
|
|
240
|
+
- `{ rel: { every: leaf } }` — to-many: *all* related rows match `leaf`. This is **vacuously true** — a
|
|
241
|
+
parent with zero related rows matches. For "at least one AND all match", write
|
|
242
|
+
`{ AND: [{ rel: { some: {} } }, { rel: { every: leaf } }] }`.
|
|
243
|
+
- `{ fkField: { isNot: leaf } }` — to-one: the referenced row does *not* match `leaf`. A row whose
|
|
244
|
+
`fkField` is null/absent is excluded from both `is` and `isNot` (there is no referenced row to test).
|
|
245
|
+
|
|
246
|
+
**Multi-level chains.** A relation clause's leaf may itself contain relation clauses, e.g. a doc shared
|
|
247
|
+
with a *team* you belong to:
|
|
248
|
+
`{ sharedWith: { some: { team: { is: { members: { some: { userId: auth.userId } } } } } } }`.
|
|
249
|
+
Each level is reactive — a write to any table on the chain (including joining/leaving the team) live-updates
|
|
250
|
+
the subscription. Nesting is capped at **4 relational levels**; a deeper policy throws at query time.
|
|
251
|
+
|
|
252
|
+
Two things to know when authoring relation predicates:
|
|
253
|
+
|
|
254
|
+
- **The relation lookup ignores the related table's own read policy — by intent.** The semi-join
|
|
255
|
+
reads *all* rows of the child/target table to decide the parent's visibility, so a `read` policy on
|
|
256
|
+
`document_shares` does **not** hide share rows from the resolver. The authorization decision is made
|
|
257
|
+
from the true data, not the caller's filtered view (otherwise a user could be denied a resource they
|
|
258
|
+
were actually granted). Only the id-set is used — child rows are never returned to the caller.
|
|
259
|
+
- **`NOT` over a relation clause is an anti-join.** `{ NOT: { sharedWith: { some: { userId: auth.userId } } } }`
|
|
260
|
+
reads as "rows *not* shared with me" — a valid but easy-to-misread negation. Prefer expressing
|
|
261
|
+
visibility positively (an `OR` of the ways a row *is* visible) and reach for `NOT` deliberately.
|
|
262
|
+
|
|
263
|
+
### Scoped role assignment
|
|
264
|
+
|
|
265
|
+
`ctx.authz.assignRole(userId, role, scope?)` / `revokeRole(userId, role, scope?)`. `scope` is `{ type: string, id: string }` (e.g. `{ type: "org", id }`); omitted = global. The scope is the leading component of the effective-permissions index key, making cross-tenant isolation structural. `auth.scopesWith(permission, type?)` returns the scope ids where the caller holds a permission (for read predicates).
|
|
266
|
+
|
|
267
|
+
**Managing roles is itself a permission.** `assignRole`/`revokeRole` require the caller to hold `authz:manage` **in the target scope** (grant it through a role, e.g. `admin: { authz: ["manage"] }`) — so role management can't be used to escalate privilege, and a scope admin can only grant within their own scope. An explicit `scope` must have a non-empty `type` and `id`; `""` is reserved for the global scope (omit `scope` for global).
|
|
268
|
+
|
|
269
|
+
**Bootstrapping the first admin.** There is deliberately no ungated path to the first role. Seed it out-of-band through the privileged admin surface (the `helipod` CLI / admin API / dashboard), which writes the first `admin` assignment directly — e.g. `runSystem("_system:insertDocument", { table: "authz/role_assignments", fields: { userId, role: "admin", scopeType: "", scopeId: "" } })`. From then on, that admin manages everyone else through the gated mutations.
|
|
270
|
+
|
|
271
|
+
### Relations & sharing
|
|
272
|
+
|
|
273
|
+
`ctx.authz.addRelation(subject, relation, object)` / `removeRelation(...)` / `hasRelation(subject, relation, object)`. `subject` is a user (`userId`) or a **userset** (`"team:eng#member"` — group membership, propagates with zero per-member writes). Per-resource sharing is one relation row; revoke is one delete. Relation predicates in read policies make sharing reactive via child-table read-dependencies.
|
|
274
|
+
|
|
275
|
+
**Relationship tuples & usersets.** `authz:addRelation({ subject, relation, object })` / `removeRelation(...)`
|
|
276
|
+
write `(object, relation, subject)` tuples into `authz/relations`; both require `can(\`${object.type}:share\`,
|
|
277
|
+
{ type, id })` (a per-object *share* permission — grant it to owners). `subject` is a user `{ type, id }` or a
|
|
278
|
+
**userset** `{ type, id, relation }` (e.g. `{ type: "team", id: "eng", relation: "member" }` = `team:eng#member`).
|
|
279
|
+
A "group" is not a special kind — it is **any object others are related to**: the userset's middle node can be any
|
|
280
|
+
`type` (`team`, `org`, `folder`, …), and its `relation` can be any relation (`member`, `owner`, …), not just `member`.
|
|
281
|
+
Membership is just a relation whose object is that node: `addRelation({ type:"user", id }, "member", { type:"team", id:"eng" })`.
|
|
282
|
+
|
|
283
|
+
- `ctx.authz.hasRelation(subject, relation, object)` — a specific check (single-level userset expansion).
|
|
284
|
+
- `ctx.authz.objectsWith(relation, objectType)` — the object ids the caller relates to (direct + via their groups),
|
|
285
|
+
for read policies: `read: ({ auth }) => ({ _id: { in: await auth.objectsWith("viewer", "document") } })`.
|
|
286
|
+
|
|
287
|
+
Both are **reactive**: sharing/unsharing and membership changes live-update subscriptions — adding someone to a
|
|
288
|
+
team reveals every resource shared with `team:…#member`, with zero per-resource writes. Only **one level** of userset
|
|
289
|
+
indirection is expanded (a caller, then the groups they directly belong to, then the objects those groups relate to);
|
|
290
|
+
nested groups (a group that is a member of another group) are not expanded yet. This coexists with the typed
|
|
291
|
+
`.relation()` predicates.
|
|
292
|
+
|
|
293
|
+
### Overrides & negation
|
|
294
|
+
|
|
295
|
+
`ctx.authz.grantPermission(userId, permission, scope?)` / `denyPermission(...)` for exceptions without proliferating roles. **Deny wins** over any grant. Grants accept an optional `expiresAt` for temporal access; expired grants are ignored (and a sweep prunes them).
|
|
296
|
+
|
|
297
|
+
### Wildcards & anonymous
|
|
298
|
+
|
|
299
|
+
`"documents:*"` (family-wide permission), `"user:*"` / a `public: true` flag on a relation (anonymous/public access). Anonymous callers (`auth.userId === null`) are handled explicitly by policies — there is no implicit allow.
|
|
300
|
+
|
|
301
|
+
### The `ctx.authz` facade
|
|
302
|
+
|
|
303
|
+
Available in every query/mutation (typed via codegen):
|
|
304
|
+
|
|
305
|
+
| Method | Purpose |
|
|
306
|
+
|---|---|
|
|
307
|
+
| `can(permission, scope?)` → `Promise<boolean>` | capability check (O(1) indexed read) |
|
|
308
|
+
| `require(permission, scope?)` → `Promise<void>` | throws `Forbidden` if denied |
|
|
309
|
+
| `canAny(permissions[], scope?)` / `canAll(...)` | combinator checks |
|
|
310
|
+
| `roles(scope?)` → `Promise<string[]>` | the caller's roles in a scope |
|
|
311
|
+
| `assignRole` / `revokeRole` | mutate role assignments |
|
|
312
|
+
| `addRelation` / `removeRelation` / `hasRelation` | mutate/inspect relationships |
|
|
313
|
+
| `grantPermission` / `denyPermission` | overrides |
|
|
314
|
+
| `deprovisionUser(userId)` | remove all of a user's assignments/relations/grants in one call |
|
|
315
|
+
|
|
316
|
+
### The `effectivePermissions` index (how O(1) works)
|
|
317
|
+
|
|
318
|
+
`assignRole` / `addRelation` / `grant` calls **expand at write time** into an `authz/effective_permissions` index keyed `[scopeKey, userId, permission]`. A `can(...)` check is then a single indexed point-read → exactly one read-set entry → surgical invalidation when that exact row changes. Expansion runs inside the *same* mutation transaction (so it can't drift), is bounded by the declared relation graph, and is observable (logged, with a max-expansion guard).
|
|
319
|
+
|
|
320
|
+
**Boot reconcile (config drift).** When the component boots (or redeploys), `defineAuthz` registers a boot hook that hashes the current roles/permissions config. If the stored hash differs from the current one (because the config changed between deploys), it rebuilds the entire `effective_permissions` index from the current `role_assignments`, drops orphan rows for assignments that no longer exist, and stamps the new hash. The boot hook is idempotent — if the config is unchanged, it returns immediately without scanning any rows.
|
|
321
|
+
|
|
322
|
+
**Out-of-band admin bootstrap.** There is no ungated path to the first role assignment; seed it with `_system:insertDocument` into both `authz/role_assignments` AND `authz/effective_permissions` (both must be seeded, or run `authz:rebuild` after seeding `role_assignments`). The `authz:rebuild` mutation is gated by `authz:manage` and can be called by an already-seeded admin to force a full rebuild at any time.
|
|
323
|
+
|
|
324
|
+
### Engine enforcement
|
|
325
|
+
|
|
326
|
+
- **Default-ON.** Any table with a declared policy is auto-gated on every `ctx.db` op. You opt *out* explicitly (`ctx.db.unsafe(...)` for intentional admin bypass), and an **uncovered-table advisor** warns about tables that hold data but declare no policy.
|
|
327
|
+
- **Deny-by-default** for declared-but-unmatched policies.
|
|
328
|
+
- **Joins are gated.** Hydrated/included relations are filtered by the child table's own read policy — an `include` cannot over-expose.
|
|
329
|
+
- **`count` is correct, not leaky.** Counts run *through* the read predicate (count of *visible* rows), so they never reveal the size of hidden data.
|
|
330
|
+
|
|
331
|
+
---
|
|
332
|
+
|
|
333
|
+
## Reactivity
|
|
334
|
+
|
|
335
|
+
Authorization participates in the reactive loop with no special-casing:
|
|
336
|
+
|
|
337
|
+
- A subscribed query whose policy reads `authz/*` data (a role, a grant, a relation) has those rows in its read-set. **Revoke the role / remove the share → the exact effective-permissions row changes → the subscription re-runs and the now-forbidden rows disappear, live.**
|
|
338
|
+
- Identity is taken from the connection's authenticated session (`ctx.auth`), so signing out also re-evaluates every authorization-dependent subscription.
|
|
339
|
+
- Because checks are indexed point-reads (not table scans or graph walks), invalidation is **surgical** — only the queries that depended on the *specific* changed permission re-run.
|
|
340
|
+
|
|
341
|
+
**Non-reactive escape hatch:** "list every resource a user can access" (a reverse query / admin home-screen) is an explicit, paginated, **non-reactive administrative API** (`ctx.authz.listAccessible(...)`) — never placed on the per-request reactive path, to avoid unbounded read-set fan-out.
|
|
342
|
+
|
|
343
|
+
---
|
|
344
|
+
|
|
345
|
+
## Multi-tenancy
|
|
346
|
+
|
|
347
|
+
Scopes are the tenancy primitive. Assign roles within `{ type: "org", id }`; the scope is the leading index key, so a tenant A assignment can *never* satisfy a tenant B check. Codegen can enforce that tenant-scoped tables carry a tenant key. A typical pattern:
|
|
348
|
+
|
|
349
|
+
```ts
|
|
350
|
+
policies: {
|
|
351
|
+
invoices: {
|
|
352
|
+
read: ({ auth }) => ({ orgId: { in: auth.scopesWith("invoices:read", "org") } }),
|
|
353
|
+
write: ({ auth }, inv) => auth.can("invoices:write", { org: inv.orgId }),
|
|
354
|
+
},
|
|
355
|
+
}
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
---
|
|
359
|
+
|
|
360
|
+
## Testing
|
|
361
|
+
|
|
362
|
+
Policies and permission logic are **pure functions**, testable in isolation with vitest — mirroring production evaluation:
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
import { expectPolicy } from "@helipod/authz/testing";
|
|
366
|
+
|
|
367
|
+
test("owners read their docs; others don't", () => {
|
|
368
|
+
expectPolicy(authz).as({ userId: "alice" }).read("documents")
|
|
369
|
+
.matches({ ownerId: "alice", title: "x" })
|
|
370
|
+
.rejects({ ownerId: "bob", title: "y" });
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("revoking a role removes the permission", async () => {
|
|
374
|
+
// integration: assert a live subscription empties when the role is revoked (reactive contract)
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
The contract test suite asserts: (a) `can` is true after `assignRole`; (b) a subscribed query re-runs and empties when the role is revoked; (c) invalidation is exactly the affected `[scope, user, permission]` row; (d) policies behave identically on the SQLite and Postgres adapters.
|
|
379
|
+
|
|
380
|
+
---
|
|
381
|
+
|
|
382
|
+
## Performance & the write-time contract
|
|
383
|
+
|
|
384
|
+
The model is **read-optimized**: it pre-materializes effective permissions so the *check* (which runs constantly and re-runs on every invalidation) is O(1). The cost moves to *writes* (granting/revoking/re-parenting), which are rare. That cost is contained:
|
|
385
|
+
|
|
386
|
+
- **Bounded** by the declared relation graph (no unbounded expansion).
|
|
387
|
+
- **Transactional** with the originating mutation for the common case (so no drift).
|
|
388
|
+
- **Async-with-a-pending-marker** above a configurable threshold for pathological bulk changes (e.g. re-parenting a folder with 10k descendants), so expansion never blocks the single writer.
|
|
389
|
+
|
|
390
|
+
This is the deliberate, correct trade for a reactive backend — and it is stated plainly because there are no hidden costs.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## Security model
|
|
395
|
+
|
|
396
|
+
- **Deny-by-default**: no policy match → no access.
|
|
397
|
+
- **Can't-forget**: engine enforcement at the `ctx.db` seam; missing a manual check cannot leak data.
|
|
398
|
+
- **No size leaks**: count-through-predicate; gated joins; no hidden-row enumeration.
|
|
399
|
+
- **Drift-proof**: effective-permissions expansion is transactional with its source write.
|
|
400
|
+
- **Fails closed**: unknown roles/permissions grant nothing; anonymous is explicit.
|
|
401
|
+
- **Auditable**: an `authz/audit_log` records assignments, grants, revocations, and denials.
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
## Architecture
|
|
406
|
+
|
|
407
|
+
- **Namespaced tables** (owned by the component, isolated by the engine boundary): `authz/roles`, `authz/role_assignments`, `authz/relations`, `authz/grants`, `authz/effective_permissions` (the index), `authz/audit_log`.
|
|
408
|
+
- **Composition**: `requires: ["auth"]`; uses `ctx.auth.getUserId()` for identity. Contributes a typed `ctx.authz` facade via the component `contextType`/codegen path.
|
|
409
|
+
- **Enforcement seam**: row policies hook the executor kernel's `ctx.db` read/write path (the same seam that enforces component namespacing and ownership), so enforcement is adapter-agnostic and reactive by default.
|
|
410
|
+
- **Adapter-neutral**: all logic is TypeScript over the `DatabaseAdapter` interface; identical on SQLite and Postgres. No DB-specific authorization.
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Comparison (at a glance)
|
|
415
|
+
|
|
416
|
+
| | Helipod authz | OpenFGA / SpiceDB (ReBAC) | Supabase RLS | Plain function checks |
|
|
417
|
+
|---|---|---|---|---|
|
|
418
|
+
| Reactive (live revocation) | ✅ by construction | ❌ out-of-process check | ❌ DB can't notify | ⚠️ only if you read authz data |
|
|
419
|
+
| Check cost | O(1) indexed read | graph traversal | row predicate | varies |
|
|
420
|
+
| Can't-forget | ✅ engine-enforced | ⚠️ guard call | ✅ DB-enforced (but Postgres-only) | ❌ |
|
|
421
|
+
| Language | TypeScript (typed) | DSL + CEL | SQL | TypeScript |
|
|
422
|
+
| Works on SQLite + Postgres | ✅ | n/a (separate service) | ❌ Postgres-only | ✅ |
|
|
423
|
+
| Expressiveness | ownership→roles→sharing→hierarchy | maximal (relationships) | predicates only | arbitrary |
|
|
424
|
+
|
|
425
|
+
Full analysis, scorecard, and the con-by-con engineering teardown: [`docs/research.md`](./docs/research.md).
|
|
426
|
+
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
## Build order
|
|
430
|
+
|
|
431
|
+
The component ships as a complexity ladder; each layer is a working slice with its own tests.
|
|
432
|
+
|
|
433
|
+
1. **Layer 1 — row policies + engine enforcement** (the foundation): `read` (WhereInput-merged) / `write` (candidate-row) policies, the kernel `ctx.db` enforcement seam (default-ON, deny-by-default, gated joins, count-through-predicate), and the reactive contract test (revocation empties a live subscription on both adapters).
|
|
434
|
+
2. **Layer 2 — RBAC**: `definePermissions`/`defineRoles`, `authz/roles` + `role_assignments`, scoped `assignRole`, the `effective_permissions` write-time index, and `ctx.authz.can/require` (typed via codegen).
|
|
435
|
+
3. **Layer 3 — relations & sharing**: `authz/relations`, `addRelation`/`hasRelation`, relation predicates, userset subjects, overrides/deny-wins, expiry.
|
|
436
|
+
4. **Layer 4 — hierarchy**: opt-in `viewer from parent` arrow-traversal compiled to write-time closure expansion; `listAccessible` (non-reactive admin API).
|
|
437
|
+
|
|
438
|
+
Each layer has a spec in `docs/superpowers/specs/` before code, per the project's process.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { TablePolicy, GuestDatabaseWriter, GuestDatabaseReader, ComponentContext, RuleAuth } from '@helipod/executor';
|
|
2
|
+
export { FieldOps, PolicyPredicate, TablePolicy, WhereInput } from '@helipod/executor';
|
|
3
|
+
import * as _helipod_values from '@helipod/values';
|
|
4
|
+
import { ComponentDefinition } from '@helipod/component';
|
|
5
|
+
|
|
6
|
+
interface RoleDef {
|
|
7
|
+
inherits?: string | string[];
|
|
8
|
+
[resource: string]: string[] | string | string[] | undefined;
|
|
9
|
+
}
|
|
10
|
+
interface AuthzConfig {
|
|
11
|
+
permissions?: Record<string, string[]>;
|
|
12
|
+
roles?: Record<string, RoleDef>;
|
|
13
|
+
policies?: Record<string, TablePolicy>;
|
|
14
|
+
}
|
|
15
|
+
/** Does `role` (via inheritance) grant `permission` (a "resource:action" string)? Supports `*`. */
|
|
16
|
+
declare function roleGrants(config: AuthzConfig, role: string, permission: string): boolean;
|
|
17
|
+
declare function expandRole(config: AuthzConfig, role: string, seen: Set<string>): Set<string>;
|
|
18
|
+
|
|
19
|
+
declare const authzSchema: _helipod_values.SchemaDefinition<{
|
|
20
|
+
role_assignments: _helipod_values.TableDefinition<{
|
|
21
|
+
userId: {
|
|
22
|
+
readonly kind: "string";
|
|
23
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
24
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
25
|
+
readonly _output: string;
|
|
26
|
+
readonly isOptional: "required";
|
|
27
|
+
};
|
|
28
|
+
role: {
|
|
29
|
+
readonly kind: "string";
|
|
30
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
31
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
32
|
+
readonly _output: string;
|
|
33
|
+
readonly isOptional: "required";
|
|
34
|
+
};
|
|
35
|
+
scopeType: {
|
|
36
|
+
readonly kind: "string";
|
|
37
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
38
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
39
|
+
readonly _output: string;
|
|
40
|
+
readonly isOptional: "required";
|
|
41
|
+
};
|
|
42
|
+
scopeId: {
|
|
43
|
+
readonly kind: "string";
|
|
44
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
45
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
46
|
+
readonly _output: string;
|
|
47
|
+
readonly isOptional: "required";
|
|
48
|
+
};
|
|
49
|
+
}>;
|
|
50
|
+
effective_permissions: _helipod_values.TableDefinition<{
|
|
51
|
+
userId: {
|
|
52
|
+
readonly kind: "string";
|
|
53
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
54
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
55
|
+
readonly _output: string;
|
|
56
|
+
readonly isOptional: "required";
|
|
57
|
+
};
|
|
58
|
+
scopeType: {
|
|
59
|
+
readonly kind: "string";
|
|
60
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
61
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
62
|
+
readonly _output: string;
|
|
63
|
+
readonly isOptional: "required";
|
|
64
|
+
};
|
|
65
|
+
scopeId: {
|
|
66
|
+
readonly kind: "string";
|
|
67
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
68
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
69
|
+
readonly _output: string;
|
|
70
|
+
readonly isOptional: "required";
|
|
71
|
+
};
|
|
72
|
+
permission: {
|
|
73
|
+
readonly kind: "string";
|
|
74
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
75
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
76
|
+
readonly _output: string;
|
|
77
|
+
readonly isOptional: "required";
|
|
78
|
+
};
|
|
79
|
+
}>;
|
|
80
|
+
meta: _helipod_values.TableDefinition<{
|
|
81
|
+
configHash: {
|
|
82
|
+
readonly kind: "string";
|
|
83
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
84
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
85
|
+
readonly _output: string;
|
|
86
|
+
readonly isOptional: "required";
|
|
87
|
+
};
|
|
88
|
+
}>;
|
|
89
|
+
relations: _helipod_values.TableDefinition<{
|
|
90
|
+
objectType: {
|
|
91
|
+
readonly kind: "string";
|
|
92
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
93
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
94
|
+
readonly _output: string;
|
|
95
|
+
readonly isOptional: "required";
|
|
96
|
+
};
|
|
97
|
+
objectId: {
|
|
98
|
+
readonly kind: "string";
|
|
99
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
100
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
101
|
+
readonly _output: string;
|
|
102
|
+
readonly isOptional: "required";
|
|
103
|
+
};
|
|
104
|
+
relation: {
|
|
105
|
+
readonly kind: "string";
|
|
106
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
107
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
108
|
+
readonly _output: string;
|
|
109
|
+
readonly isOptional: "required";
|
|
110
|
+
};
|
|
111
|
+
subjectType: {
|
|
112
|
+
readonly kind: "string";
|
|
113
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
114
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
115
|
+
readonly _output: string;
|
|
116
|
+
readonly isOptional: "required";
|
|
117
|
+
};
|
|
118
|
+
subjectId: {
|
|
119
|
+
readonly kind: "string";
|
|
120
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
121
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
122
|
+
readonly _output: string;
|
|
123
|
+
readonly isOptional: "required";
|
|
124
|
+
};
|
|
125
|
+
subjectRelation: {
|
|
126
|
+
readonly kind: "string";
|
|
127
|
+
check(value: _helipod_values.Value, path: string, out: _helipod_values.ValidationFailure[]): void;
|
|
128
|
+
toJSON(): _helipod_values.ValidatorJSON;
|
|
129
|
+
readonly _output: string;
|
|
130
|
+
readonly isOptional: "required";
|
|
131
|
+
};
|
|
132
|
+
}>;
|
|
133
|
+
}>;
|
|
134
|
+
|
|
135
|
+
declare function defineAuthz(config: AuthzConfig): ComponentDefinition;
|
|
136
|
+
|
|
137
|
+
interface RelSubject {
|
|
138
|
+
type: string;
|
|
139
|
+
id: string;
|
|
140
|
+
relation?: string;
|
|
141
|
+
}
|
|
142
|
+
interface RelObject {
|
|
143
|
+
type: string;
|
|
144
|
+
id: string;
|
|
145
|
+
}
|
|
146
|
+
declare function addRelationTuple(db: GuestDatabaseWriter, subject: RelSubject, relation: string, object: RelObject): Promise<void>;
|
|
147
|
+
declare function removeRelationTuple(db: GuestDatabaseWriter, subject: RelSubject, relation: string, object: RelObject): Promise<void>;
|
|
148
|
+
/** Does `subject` have `relation` to `object`? Direct, or (for a direct subject) via one of its usersets. */
|
|
149
|
+
declare function hasRelation(db: GuestDatabaseReader, subject: RelSubject, relation: string, object: RelObject): Promise<boolean>;
|
|
150
|
+
/** Object ids of type `objectType` that `userId` has `relation` to — direct or via a group they belong to. */
|
|
151
|
+
declare function objectsWith(db: GuestDatabaseReader, userId: string, relation: string, objectType: string): Promise<string[]>;
|
|
152
|
+
|
|
153
|
+
interface AuthzContext {
|
|
154
|
+
can(permission: string, scope?: {
|
|
155
|
+
type: string;
|
|
156
|
+
id: string;
|
|
157
|
+
}): Promise<boolean>;
|
|
158
|
+
require(permission: string, scope?: {
|
|
159
|
+
type: string;
|
|
160
|
+
id: string;
|
|
161
|
+
}): Promise<void>;
|
|
162
|
+
roles(scope?: {
|
|
163
|
+
type: string;
|
|
164
|
+
id: string;
|
|
165
|
+
}): Promise<string[]>;
|
|
166
|
+
scopesWith(permission: string, type?: string): Promise<string[]>;
|
|
167
|
+
hasRelation(subject: RelSubject, relation: string, object: RelObject): Promise<boolean>;
|
|
168
|
+
objectsWith(relation: string, objectType: string): Promise<string[]>;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Build the `auth` field of a row policy's rule-context from the composed auth+authz facades. */
|
|
172
|
+
declare function buildRuleAuth(cctx: ComponentContext): Promise<RuleAuth>;
|
|
173
|
+
|
|
174
|
+
declare const MAX_PATTERNS_PER_ROLE = 1000;
|
|
175
|
+
/** A role's granted permission patterns (with inheritance), stored verbatim (wildcards kept). */
|
|
176
|
+
declare function expandRolePatterns(config: AuthzConfig, role: string): string[];
|
|
177
|
+
/** The <=4 stored patterns that would grant `permission = "res:act"`. */
|
|
178
|
+
declare function candidateKeys(permission: string): string[];
|
|
179
|
+
/** A stable, order-independent hash of the config's roles + permissions (the index version stamp). */
|
|
180
|
+
declare function configHash(config: AuthzConfig): string;
|
|
181
|
+
/** Insert the role's patterns for (user, scope) that aren't already present (idempotent). */
|
|
182
|
+
declare function upsertPatterns(db: GuestDatabaseWriter, config: AuthzConfig, userId: string, role: string, scopeType: string, scopeId: string): Promise<void>;
|
|
183
|
+
/**
|
|
184
|
+
* Recompute (user, scope) effective patterns from the provided remaining roles; insert
|
|
185
|
+
* missing, delete orphans. The caller passes `remainingRoles` directly because the query
|
|
186
|
+
* engine reads the committed snapshot and cannot see staged (uncommitted) deletes made in
|
|
187
|
+
* the same transaction.
|
|
188
|
+
*/
|
|
189
|
+
declare function reconcileScope(db: GuestDatabaseWriter, config: AuthzConfig, userId: string, scopeType: string, scopeId: string, remainingRoles: string[]): Promise<void>;
|
|
190
|
+
/**
|
|
191
|
+
* Boot/rebuild: if the stored config hash differs from the current config, rebuild the whole
|
|
192
|
+
* effective_permissions index from role_assignments (reconcile every present (user,scope),
|
|
193
|
+
* drop orphans for (user,scope) with no assignment) and stamp the new hash.
|
|
194
|
+
*
|
|
195
|
+
* Transaction read semantics: db.query(...).collect() reads the COMMITTED snapshot.
|
|
196
|
+
* Orphan deletes target (user,scope) tuples that have NO assignment rows; reconcileScope
|
|
197
|
+
* calls target (user,scope) tuples that DO have assignments. These sets are DISJOINT, so
|
|
198
|
+
* the orphan deletes never interfere with reconcileScope reads.
|
|
199
|
+
*/
|
|
200
|
+
declare function reconcileEffectivePermissions(ctx: {
|
|
201
|
+
db: GuestDatabaseWriter;
|
|
202
|
+
}, config: AuthzConfig): Promise<void>;
|
|
203
|
+
|
|
204
|
+
export { type AuthzConfig, type AuthzContext, MAX_PATTERNS_PER_ROLE, type RelObject, type RelSubject, type RoleDef, addRelationTuple, authzSchema, buildRuleAuth, candidateKeys, configHash, defineAuthz, expandRole, expandRolePatterns, hasRelation, objectsWith, reconcileEffectivePermissions, reconcileScope, removeRelationTuple, roleGrants, upsertPatterns };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// src/roles.ts
|
|
2
|
+
function roleGrants(config, role, permission) {
|
|
3
|
+
const perms = expandRole(config, role, /* @__PURE__ */ new Set());
|
|
4
|
+
const [res, act] = permission.split(":");
|
|
5
|
+
for (const p of perms) {
|
|
6
|
+
const [pr, pa] = p.split(":");
|
|
7
|
+
if ((pr === res || pr === "*") && (pa === act || pa === "*")) return true;
|
|
8
|
+
}
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
function expandRole(config, role, seen) {
|
|
12
|
+
const out = /* @__PURE__ */ new Set();
|
|
13
|
+
const def = config.roles?.[role];
|
|
14
|
+
if (!def || seen.has(role)) return out;
|
|
15
|
+
seen.add(role);
|
|
16
|
+
const inherits = def.inherits ? Array.isArray(def.inherits) ? def.inherits : [def.inherits] : [];
|
|
17
|
+
for (const parent of inherits) for (const p of expandRole(config, parent, seen)) out.add(p);
|
|
18
|
+
for (const [resource, actions] of Object.entries(def)) {
|
|
19
|
+
if (resource === "inherits" || !Array.isArray(actions)) continue;
|
|
20
|
+
for (const action of actions) out.add(`${resource}:${action}`);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/schema.ts
|
|
26
|
+
import { defineSchema, defineTable, v } from "@helipod/values";
|
|
27
|
+
var authzSchema = defineSchema({
|
|
28
|
+
role_assignments: defineTable({
|
|
29
|
+
userId: v.string(),
|
|
30
|
+
role: v.string(),
|
|
31
|
+
scopeType: v.string(),
|
|
32
|
+
scopeId: v.string()
|
|
33
|
+
}).index("byUserScope", ["userId", "scopeType", "scopeId"]).index("byUser", ["userId"]),
|
|
34
|
+
effective_permissions: defineTable({
|
|
35
|
+
userId: v.string(),
|
|
36
|
+
scopeType: v.string(),
|
|
37
|
+
scopeId: v.string(),
|
|
38
|
+
permission: v.string()
|
|
39
|
+
}).index("byLookup", ["scopeType", "scopeId", "userId", "permission"]).index("byUser", ["userId"]),
|
|
40
|
+
meta: defineTable({ configHash: v.string() }),
|
|
41
|
+
relations: defineTable({
|
|
42
|
+
objectType: v.string(),
|
|
43
|
+
objectId: v.string(),
|
|
44
|
+
relation: v.string(),
|
|
45
|
+
subjectType: v.string(),
|
|
46
|
+
subjectId: v.string(),
|
|
47
|
+
subjectRelation: v.string()
|
|
48
|
+
}).index("byObject", ["objectType", "objectId", "relation", "subjectType", "subjectId", "subjectRelation"]).index("bySubject", ["subjectType", "subjectId", "subjectRelation", "relation"])
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// src/define-authz.ts
|
|
52
|
+
import { defineComponent } from "@helipod/component";
|
|
53
|
+
|
|
54
|
+
// src/effective-permissions.ts
|
|
55
|
+
var MAX_PATTERNS_PER_ROLE = 1e3;
|
|
56
|
+
function expandRolePatterns(config, role) {
|
|
57
|
+
const set = expandRole(config, role, /* @__PURE__ */ new Set());
|
|
58
|
+
if (set.size > MAX_PATTERNS_PER_ROLE)
|
|
59
|
+
throw new Error(`authz: role "${role}" expands to more than ${MAX_PATTERNS_PER_ROLE} permission patterns`);
|
|
60
|
+
return [...set];
|
|
61
|
+
}
|
|
62
|
+
function candidateKeys(permission) {
|
|
63
|
+
const i = permission.indexOf(":");
|
|
64
|
+
const res = i === -1 ? permission : permission.slice(0, i);
|
|
65
|
+
const act = i === -1 ? "" : permission.slice(i + 1);
|
|
66
|
+
return [.../* @__PURE__ */ new Set([`${res}:${act}`, `${res}:*`, `*:${act}`, "*:*"])];
|
|
67
|
+
}
|
|
68
|
+
function configHash(config) {
|
|
69
|
+
const canonical = stableStringify({ roles: config.roles ?? {}, permissions: config.permissions ?? {} });
|
|
70
|
+
let h = 2166136261;
|
|
71
|
+
for (let i = 0; i < canonical.length; i++) {
|
|
72
|
+
h ^= canonical.charCodeAt(i);
|
|
73
|
+
h = Math.imul(h, 16777619);
|
|
74
|
+
}
|
|
75
|
+
return (h >>> 0).toString(16);
|
|
76
|
+
}
|
|
77
|
+
function stableStringify(v2) {
|
|
78
|
+
if (v2 === null || typeof v2 !== "object") return JSON.stringify(v2);
|
|
79
|
+
if (Array.isArray(v2)) return `[${v2.map(stableStringify).join(",")}]`;
|
|
80
|
+
const keys = Object.keys(v2).sort();
|
|
81
|
+
return `{${keys.map((k) => JSON.stringify(k) + ":" + stableStringify(v2[k])).join(",")}}`;
|
|
82
|
+
}
|
|
83
|
+
async function upsertPatterns(db, config, userId, role, scopeType, scopeId) {
|
|
84
|
+
const existing = await db.query("effective_permissions", "byLookup").eq("scopeType", scopeType).eq("scopeId", scopeId).eq("userId", userId).collect();
|
|
85
|
+
const have = new Set(existing.map((r) => r.permission));
|
|
86
|
+
for (const p of expandRolePatterns(config, role)) if (!have.has(p)) await db.insert("effective_permissions", { userId, scopeType, scopeId, permission: p });
|
|
87
|
+
}
|
|
88
|
+
async function reconcileScope(db, config, userId, scopeType, scopeId, remainingRoles) {
|
|
89
|
+
const desired = /* @__PURE__ */ new Set();
|
|
90
|
+
for (const role of remainingRoles) for (const p of expandRolePatterns(config, role)) desired.add(p);
|
|
91
|
+
const rows = await db.query("effective_permissions", "byLookup").eq("scopeType", scopeType).eq("scopeId", scopeId).eq("userId", userId).collect();
|
|
92
|
+
const have = /* @__PURE__ */ new Set();
|
|
93
|
+
for (const r of rows) {
|
|
94
|
+
if (desired.has(r.permission)) have.add(r.permission);
|
|
95
|
+
else await db.delete(r._id);
|
|
96
|
+
}
|
|
97
|
+
for (const p of desired) if (!have.has(p)) await db.insert("effective_permissions", { userId, scopeType, scopeId, permission: p });
|
|
98
|
+
}
|
|
99
|
+
async function reconcileEffectivePermissions(ctx, config) {
|
|
100
|
+
const db = ctx.db;
|
|
101
|
+
const metaRows = await db.query("meta", "by_creation").collect();
|
|
102
|
+
const meta = metaRows[0];
|
|
103
|
+
const current = configHash(config);
|
|
104
|
+
if (meta != null && meta.configHash === current) return;
|
|
105
|
+
const assigns = await db.query("role_assignments", "by_creation").collect();
|
|
106
|
+
const scopeMap = /* @__PURE__ */ new Map();
|
|
107
|
+
for (const a of assigns) {
|
|
108
|
+
const userId = a.userId;
|
|
109
|
+
const scopeType = a.scopeType;
|
|
110
|
+
const scopeId = a.scopeId;
|
|
111
|
+
const role = a.role;
|
|
112
|
+
const key = `${userId}\0${scopeType}\0${scopeId}`;
|
|
113
|
+
const entry = scopeMap.get(key);
|
|
114
|
+
if (entry != null) {
|
|
115
|
+
entry.roles.push(role);
|
|
116
|
+
} else {
|
|
117
|
+
scopeMap.set(key, { userId, scopeType, scopeId, roles: [role] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const allEff = await db.query("effective_permissions", "by_creation").collect();
|
|
121
|
+
for (const e of allEff) {
|
|
122
|
+
const key = `${e.userId}\0${e.scopeType}\0${e.scopeId}`;
|
|
123
|
+
if (!scopeMap.has(key)) await db.delete(e._id);
|
|
124
|
+
}
|
|
125
|
+
for (const s of scopeMap.values()) {
|
|
126
|
+
await reconcileScope(db, config, s.userId, s.scopeType, s.scopeId, s.roles);
|
|
127
|
+
}
|
|
128
|
+
if (meta != null) {
|
|
129
|
+
await db.replace(meta._id, { configHash: current });
|
|
130
|
+
} else {
|
|
131
|
+
await db.insert("meta", { configHash: current });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/relations.ts
|
|
136
|
+
function subj(s) {
|
|
137
|
+
return [s.type, s.id, s.relation ?? ""];
|
|
138
|
+
}
|
|
139
|
+
function tupleRows(db, obj, relation, st, si, sr) {
|
|
140
|
+
return db.query("relations", "byObject").eq("objectType", obj.type).eq("objectId", obj.id).eq("relation", relation).eq("subjectType", st).eq("subjectId", si).eq("subjectRelation", sr).collect();
|
|
141
|
+
}
|
|
142
|
+
async function addRelationTuple(db, subject, relation, object) {
|
|
143
|
+
const [st, si, sr] = subj(subject);
|
|
144
|
+
if ((await tupleRows(db, object, relation, st, si, sr)).length > 0) return;
|
|
145
|
+
await db.insert("relations", { objectType: object.type, objectId: object.id, relation, subjectType: st, subjectId: si, subjectRelation: sr });
|
|
146
|
+
}
|
|
147
|
+
async function removeRelationTuple(db, subject, relation, object) {
|
|
148
|
+
const [st, si, sr] = subj(subject);
|
|
149
|
+
for (const row of await tupleRows(db, object, relation, st, si, sr)) await db.delete(row._id);
|
|
150
|
+
}
|
|
151
|
+
async function memberships(db, st, si) {
|
|
152
|
+
const rows = await db.query("relations", "bySubject").eq("subjectType", st).eq("subjectId", si).eq("subjectRelation", "").collect();
|
|
153
|
+
return rows.map((r) => [r.objectType, r.objectId, r.relation]);
|
|
154
|
+
}
|
|
155
|
+
async function hasRelation(db, subject, relation, object) {
|
|
156
|
+
const [st, si, sr] = subj(subject);
|
|
157
|
+
if ((await tupleRows(db, object, relation, st, si, sr)).length > 0) return true;
|
|
158
|
+
if (sr !== "") return false;
|
|
159
|
+
for (const [gt, gid, mRel] of await memberships(db, st, si))
|
|
160
|
+
if ((await tupleRows(db, object, relation, gt, gid, mRel)).length > 0) return true;
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
async function objectsWith(db, userId, relation, objectType) {
|
|
164
|
+
const out = /* @__PURE__ */ new Set();
|
|
165
|
+
const direct = await db.query("relations", "bySubject").eq("subjectType", "user").eq("subjectId", userId).eq("subjectRelation", "").eq("relation", relation).collect();
|
|
166
|
+
for (const r of direct) if (r.objectType === objectType) out.add(r.objectId);
|
|
167
|
+
for (const [gt, gid, mRel] of await memberships(db, "user", userId)) {
|
|
168
|
+
const grp = await db.query("relations", "bySubject").eq("subjectType", gt).eq("subjectId", gid).eq("subjectRelation", mRel).eq("relation", relation).collect();
|
|
169
|
+
for (const r of grp) if (r.objectType === objectType) out.add(r.objectId);
|
|
170
|
+
}
|
|
171
|
+
return [...out];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// src/context.ts
|
|
175
|
+
function authzContext(cctx, config) {
|
|
176
|
+
const auth = cctx.components.auth;
|
|
177
|
+
async function uid() {
|
|
178
|
+
return auth ? await auth.getUserId() : null;
|
|
179
|
+
}
|
|
180
|
+
async function assignedRoles(scope) {
|
|
181
|
+
const u = await uid();
|
|
182
|
+
if (!u) return [];
|
|
183
|
+
const rows = await cctx.db.query("role_assignments", "byUser").eq("userId", u).collect();
|
|
184
|
+
const st = scope?.type ?? "", si = scope?.id ?? "";
|
|
185
|
+
return rows.filter((r) => r.scopeType === "" && r.scopeId === "" || r.scopeType === st && r.scopeId === si).map((r) => r.role);
|
|
186
|
+
}
|
|
187
|
+
async function held(scopeType, scopeId, userId, keys) {
|
|
188
|
+
for (const key of keys) {
|
|
189
|
+
const hit = await cctx.db.query("effective_permissions", "byLookup").eq("scopeType", scopeType).eq("scopeId", scopeId).eq("userId", userId).eq("permission", key).collect();
|
|
190
|
+
if (hit.length > 0) return true;
|
|
191
|
+
}
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
void config;
|
|
195
|
+
return {
|
|
196
|
+
async can(permission, scope) {
|
|
197
|
+
const u = await uid();
|
|
198
|
+
if (!u) return false;
|
|
199
|
+
const keys = candidateKeys(permission);
|
|
200
|
+
const st = scope?.type ?? "", si = scope?.id ?? "";
|
|
201
|
+
if (await held(st, si, u, keys)) return true;
|
|
202
|
+
if (st !== "" || si !== "") return held("", "", u, keys);
|
|
203
|
+
return false;
|
|
204
|
+
},
|
|
205
|
+
async require(permission, scope) {
|
|
206
|
+
if (!await this.can(permission, scope)) throw new Error(`Forbidden: ${permission}`);
|
|
207
|
+
},
|
|
208
|
+
async roles(scope) {
|
|
209
|
+
return assignedRoles(scope);
|
|
210
|
+
},
|
|
211
|
+
async scopesWith(permission, type) {
|
|
212
|
+
const u = await uid();
|
|
213
|
+
if (!u) return [];
|
|
214
|
+
const keys = new Set(candidateKeys(permission));
|
|
215
|
+
const rows = await cctx.db.query("effective_permissions", "byUser").eq("userId", u).collect();
|
|
216
|
+
const out = /* @__PURE__ */ new Set();
|
|
217
|
+
for (const r of rows) {
|
|
218
|
+
if (type !== void 0 && r.scopeType !== type) continue;
|
|
219
|
+
if (keys.has(r.permission)) out.add(r.scopeId);
|
|
220
|
+
}
|
|
221
|
+
return [...out];
|
|
222
|
+
},
|
|
223
|
+
async hasRelation(subject, relation, object) {
|
|
224
|
+
return hasRelation(cctx.db, subject, relation, object);
|
|
225
|
+
},
|
|
226
|
+
async objectsWith(relation, objectType) {
|
|
227
|
+
const u = await uid();
|
|
228
|
+
if (!u) return [];
|
|
229
|
+
return objectsWith(cctx.db, u, relation, objectType);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// src/functions.ts
|
|
235
|
+
import { mutation } from "@helipod/executor";
|
|
236
|
+
var MANAGE_PERMISSION = "authz:manage";
|
|
237
|
+
function assertScope(scope) {
|
|
238
|
+
if (scope && (scope.type === "" || scope.id === ""))
|
|
239
|
+
throw new Error('authz: scope.type and scope.id must be non-empty ("" is reserved for the global scope; omit scope for global)');
|
|
240
|
+
}
|
|
241
|
+
function authzModules(config) {
|
|
242
|
+
const assignRole = mutation(async (ctx, { userId, role, scope }) => {
|
|
243
|
+
assertScope(scope);
|
|
244
|
+
await ctx.authz.require(MANAGE_PERMISSION, scope);
|
|
245
|
+
const st = scope?.type ?? "", si = scope?.id ?? "";
|
|
246
|
+
const existing = await ctx.db.query("role_assignments", "byUserScope").eq("userId", userId).eq("scopeType", st).eq("scopeId", si).collect();
|
|
247
|
+
if (!existing.some((r) => r.role === role)) await ctx.db.insert("role_assignments", { userId, role, scopeType: st, scopeId: si });
|
|
248
|
+
await upsertPatterns(ctx.db, config, userId, role, st, si);
|
|
249
|
+
return null;
|
|
250
|
+
});
|
|
251
|
+
const revokeRole = mutation(async (ctx, { userId, role, scope }) => {
|
|
252
|
+
assertScope(scope);
|
|
253
|
+
await ctx.authz.require(MANAGE_PERMISSION, scope);
|
|
254
|
+
const st = scope?.type ?? "", si = scope?.id ?? "";
|
|
255
|
+
const rows = await ctx.db.query("role_assignments", "byUserScope").eq("userId", userId).eq("scopeType", st).eq("scopeId", si).collect();
|
|
256
|
+
const remainingRoles = rows.filter((r) => r.role !== role).map((r) => r.role);
|
|
257
|
+
for (const r of rows) if (r.role === role) await ctx.db.delete(r._id);
|
|
258
|
+
await reconcileScope(ctx.db, config, userId, st, si, remainingRoles);
|
|
259
|
+
return null;
|
|
260
|
+
});
|
|
261
|
+
const rebuild = mutation(async (ctx) => {
|
|
262
|
+
await ctx.authz.require(MANAGE_PERMISSION);
|
|
263
|
+
await reconcileEffectivePermissions({ db: ctx.db }, config);
|
|
264
|
+
return null;
|
|
265
|
+
});
|
|
266
|
+
const bootstrapFirstAdmin = mutation(async (ctx, { userId, role }) => {
|
|
267
|
+
if (!roleGrants(config, role, MANAGE_PERMISSION)) {
|
|
268
|
+
throw new Error(`authz: role "${role}" does not grant ${MANAGE_PERMISSION}`);
|
|
269
|
+
}
|
|
270
|
+
const manageKeys = new Set(candidateKeys(MANAGE_PERMISSION));
|
|
271
|
+
const allEff = await ctx.db.query("effective_permissions", "by_creation").collect();
|
|
272
|
+
for (const row of allEff) {
|
|
273
|
+
if (manageKeys.has(row.permission)) {
|
|
274
|
+
throw new Error("authz: an admin already exists; use assignRole");
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const existing = await ctx.db.query("role_assignments", "byUserScope").eq("userId", userId).eq("scopeType", "").eq("scopeId", "").collect();
|
|
278
|
+
if (!existing.some((r) => r.role === role)) {
|
|
279
|
+
await ctx.db.insert("role_assignments", { userId, role, scopeType: "", scopeId: "" });
|
|
280
|
+
}
|
|
281
|
+
await upsertPatterns(ctx.db, config, userId, role, "", "");
|
|
282
|
+
return null;
|
|
283
|
+
});
|
|
284
|
+
const addRelation = mutation(async (ctx, { subject, relation, object }) => {
|
|
285
|
+
await ctx.authz.require(`${object.type}:share`, { type: object.type, id: object.id });
|
|
286
|
+
await addRelationTuple(ctx.db, subject, relation, object);
|
|
287
|
+
return null;
|
|
288
|
+
});
|
|
289
|
+
const removeRelation = mutation(async (ctx, { subject, relation, object }) => {
|
|
290
|
+
await ctx.authz.require(`${object.type}:share`, { type: object.type, id: object.id });
|
|
291
|
+
await removeRelationTuple(ctx.db, subject, relation, object);
|
|
292
|
+
return null;
|
|
293
|
+
});
|
|
294
|
+
return { assignRole, revokeRole, rebuild, bootstrapFirstAdmin, addRelation, removeRelation };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/policies.ts
|
|
298
|
+
async function buildRuleAuth(cctx) {
|
|
299
|
+
const authFacade = cctx.components.auth;
|
|
300
|
+
const authzFacade = cctx.components.authz;
|
|
301
|
+
const userId = authFacade ? await authFacade.getUserId() : null;
|
|
302
|
+
return {
|
|
303
|
+
userId,
|
|
304
|
+
identity: cctx.identity,
|
|
305
|
+
can: (p, s) => authzFacade.can(p, s),
|
|
306
|
+
roles: (s) => authzFacade.roles(s),
|
|
307
|
+
scopesWith: (p, t) => authzFacade.scopesWith(p, t),
|
|
308
|
+
objectsWith: (relation, objectType) => authzFacade.objectsWith(relation, objectType),
|
|
309
|
+
hasRelation: (subject, relation, object) => authzFacade.hasRelation(subject, relation, object)
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// src/define-authz.ts
|
|
314
|
+
function defineAuthz(config) {
|
|
315
|
+
return defineComponent({
|
|
316
|
+
name: "authz",
|
|
317
|
+
requires: ["auth"],
|
|
318
|
+
schema: authzSchema,
|
|
319
|
+
modules: authzModules(config),
|
|
320
|
+
context: (cctx) => authzContext(cctx, config),
|
|
321
|
+
contextType: { import: "@helipod/authz", type: "AuthzContext" },
|
|
322
|
+
policies: config.policies,
|
|
323
|
+
policyContext: async (cctx) => ({ auth: await buildRuleAuth(cctx) }),
|
|
324
|
+
boot: (ctx) => reconcileEffectivePermissions(ctx, config)
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
export {
|
|
328
|
+
MAX_PATTERNS_PER_ROLE,
|
|
329
|
+
addRelationTuple,
|
|
330
|
+
authzSchema,
|
|
331
|
+
buildRuleAuth,
|
|
332
|
+
candidateKeys,
|
|
333
|
+
configHash,
|
|
334
|
+
defineAuthz,
|
|
335
|
+
expandRole,
|
|
336
|
+
expandRolePatterns,
|
|
337
|
+
hasRelation,
|
|
338
|
+
objectsWith,
|
|
339
|
+
reconcileEffectivePermissions,
|
|
340
|
+
reconcileScope,
|
|
341
|
+
removeRelationTuple,
|
|
342
|
+
roleGrants,
|
|
343
|
+
upsertPatterns
|
|
344
|
+
};
|
|
345
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/roles.ts","../src/schema.ts","../src/define-authz.ts","../src/effective-permissions.ts","../src/relations.ts","../src/context.ts","../src/functions.ts","../src/policies.ts"],"sourcesContent":["import type { TablePolicy } from \"@helipod/executor\";\n\nexport interface RoleDef { inherits?: string | string[]; [resource: string]: string[] | string | string[] | undefined }\nexport interface AuthzConfig {\n permissions?: Record<string, string[]>;\n roles?: Record<string, RoleDef>;\n policies?: Record<string, TablePolicy>;\n}\n\n/** Does `role` (via inheritance) grant `permission` (a \"resource:action\" string)? Supports `*`. */\nexport function roleGrants(config: AuthzConfig, role: string, permission: string): boolean {\n const perms = expandRole(config, role, new Set());\n const [res, act] = permission.split(\":\");\n for (const p of perms) {\n const [pr, pa] = p.split(\":\");\n if ((pr === res || pr === \"*\") && (pa === act || pa === \"*\")) return true;\n }\n return false;\n}\n\nexport function expandRole(config: AuthzConfig, role: string, seen: Set<string>): Set<string> {\n const out = new Set<string>();\n const def = config.roles?.[role];\n if (!def || seen.has(role)) return out;\n seen.add(role);\n const inherits = def.inherits ? (Array.isArray(def.inherits) ? def.inherits : [def.inherits]) : [];\n for (const parent of inherits) for (const p of expandRole(config, parent, seen)) out.add(p);\n for (const [resource, actions] of Object.entries(def)) {\n if (resource === \"inherits\" || !Array.isArray(actions)) continue;\n for (const action of actions) out.add(`${resource}:${action}`);\n }\n return out;\n}\n","import { defineSchema, defineTable, v } from \"@helipod/values\";\nexport const authzSchema = defineSchema({\n role_assignments: defineTable({\n userId: v.string(), role: v.string(), scopeType: v.string(), scopeId: v.string(),\n }).index(\"byUserScope\", [\"userId\", \"scopeType\", \"scopeId\"]).index(\"byUser\", [\"userId\"]),\n effective_permissions: defineTable({\n userId: v.string(), scopeType: v.string(), scopeId: v.string(), permission: v.string(),\n }).index(\"byLookup\", [\"scopeType\", \"scopeId\", \"userId\", \"permission\"]).index(\"byUser\", [\"userId\"]),\n meta: defineTable({ configHash: v.string() }),\n relations: defineTable({\n objectType: v.string(), objectId: v.string(), relation: v.string(),\n subjectType: v.string(), subjectId: v.string(), subjectRelation: v.string(),\n }).index(\"byObject\", [\"objectType\", \"objectId\", \"relation\", \"subjectType\", \"subjectId\", \"subjectRelation\"])\n .index(\"bySubject\", [\"subjectType\", \"subjectId\", \"subjectRelation\", \"relation\"]),\n});\n","import { defineComponent, type ComponentDefinition } from \"@helipod/component\";\nimport { authzSchema } from \"./schema\";\nimport type { AuthzConfig } from \"./roles\";\nimport { authzContext } from \"./context\";\nimport { authzModules } from \"./functions\";\nimport { buildRuleAuth } from \"./policies\";\nimport { reconcileEffectivePermissions } from \"./effective-permissions\";\n\nexport function defineAuthz(config: AuthzConfig): ComponentDefinition {\n return defineComponent({\n name: \"authz\",\n requires: [\"auth\"],\n schema: authzSchema,\n modules: authzModules(config),\n context: (cctx) => authzContext(cctx, config),\n contextType: { import: \"@helipod/authz\", type: \"AuthzContext\" },\n policies: config.policies,\n policyContext: async (cctx) => ({ auth: await buildRuleAuth(cctx) }),\n boot: (ctx) => reconcileEffectivePermissions(ctx, config),\n });\n}\n","import type { GuestDatabaseWriter } from \"@helipod/executor\";\nimport { expandRole, type AuthzConfig } from \"./roles\";\n\nexport const MAX_PATTERNS_PER_ROLE = 1000;\n\n/** A role's granted permission patterns (with inheritance), stored verbatim (wildcards kept). */\nexport function expandRolePatterns(config: AuthzConfig, role: string): string[] {\n const set = expandRole(config, role, new Set());\n if (set.size > MAX_PATTERNS_PER_ROLE)\n throw new Error(`authz: role \"${role}\" expands to more than ${MAX_PATTERNS_PER_ROLE} permission patterns`);\n return [...set];\n}\n\n/** The <=4 stored patterns that would grant `permission = \"res:act\"`. */\nexport function candidateKeys(permission: string): string[] {\n const i = permission.indexOf(\":\");\n const res = i === -1 ? permission : permission.slice(0, i);\n const act = i === -1 ? \"\" : permission.slice(i + 1);\n return [...new Set([`${res}:${act}`, `${res}:*`, `*:${act}`, \"*:*\"])];\n}\n\n/** A stable, order-independent hash of the config's roles + permissions (the index version stamp). */\nexport function configHash(config: AuthzConfig): string {\n const canonical = stableStringify({ roles: config.roles ?? {}, permissions: config.permissions ?? {} });\n let h = 0x811c9dc5;\n for (let i = 0; i < canonical.length; i++) { h ^= canonical.charCodeAt(i); h = Math.imul(h, 0x01000193); }\n return (h >>> 0).toString(16);\n}\n\nfunction stableStringify(v: unknown): string {\n if (v === null || typeof v !== \"object\") return JSON.stringify(v);\n if (Array.isArray(v)) return `[${v.map(stableStringify).join(\",\")}]`;\n const keys = Object.keys(v as object).sort();\n return `{${keys.map((k) => JSON.stringify(k) + \":\" + stableStringify((v as Record<string, unknown>)[k])).join(\",\")}}`;\n}\n\n/** Insert the role's patterns for (user, scope) that aren't already present (idempotent). */\nexport async function upsertPatterns(\n db: GuestDatabaseWriter, config: AuthzConfig, userId: string, role: string, scopeType: string, scopeId: string,\n): Promise<void> {\n const existing = await db.query(\"effective_permissions\", \"byLookup\").eq(\"scopeType\", scopeType).eq(\"scopeId\", scopeId).eq(\"userId\", userId).collect();\n const have = new Set(existing.map((r) => r.permission as string));\n for (const p of expandRolePatterns(config, role)) if (!have.has(p)) await db.insert(\"effective_permissions\", { userId, scopeType, scopeId, permission: p });\n}\n\n/**\n * Recompute (user, scope) effective patterns from the provided remaining roles; insert\n * missing, delete orphans. The caller passes `remainingRoles` directly because the query\n * engine reads the committed snapshot and cannot see staged (uncommitted) deletes made in\n * the same transaction.\n */\nexport async function reconcileScope(\n db: GuestDatabaseWriter, config: AuthzConfig, userId: string, scopeType: string, scopeId: string,\n remainingRoles: string[],\n): Promise<void> {\n const desired = new Set<string>();\n for (const role of remainingRoles) for (const p of expandRolePatterns(config, role)) desired.add(p);\n const rows = await db.query(\"effective_permissions\", \"byLookup\").eq(\"scopeType\", scopeType).eq(\"scopeId\", scopeId).eq(\"userId\", userId).collect();\n const have = new Set<string>();\n for (const r of rows) {\n if (desired.has(r.permission as string)) have.add(r.permission as string);\n else await db.delete(r._id as string);\n }\n for (const p of desired) if (!have.has(p)) await db.insert(\"effective_permissions\", { userId, scopeType, scopeId, permission: p });\n}\n\n/**\n * Boot/rebuild: if the stored config hash differs from the current config, rebuild the whole\n * effective_permissions index from role_assignments (reconcile every present (user,scope),\n * drop orphans for (user,scope) with no assignment) and stamp the new hash.\n *\n * Transaction read semantics: db.query(...).collect() reads the COMMITTED snapshot.\n * Orphan deletes target (user,scope) tuples that have NO assignment rows; reconcileScope\n * calls target (user,scope) tuples that DO have assignments. These sets are DISJOINT, so\n * the orphan deletes never interfere with reconcileScope reads.\n */\nexport async function reconcileEffectivePermissions(\n ctx: { db: GuestDatabaseWriter },\n config: AuthzConfig,\n): Promise<void> {\n const db = ctx.db;\n const metaRows = await db.query(\"meta\", \"by_creation\").collect();\n const meta = metaRows[0];\n const current = configHash(config);\n if (meta != null && (meta.configHash as string) === current) return; // steady state — nothing changed\n\n // Group all role_assignments by (userId, scopeType, scopeId) with their roles\n const assigns = await db.query(\"role_assignments\", \"by_creation\").collect();\n const scopeMap = new Map<string, { userId: string; scopeType: string; scopeId: string; roles: string[] }>();\n for (const a of assigns) {\n const userId = a.userId as string;\n const scopeType = a.scopeType as string;\n const scopeId = a.scopeId as string;\n const role = a.role as string;\n const key = `${userId}\\0${scopeType}\\0${scopeId}`;\n const entry = scopeMap.get(key);\n if (entry != null) {\n entry.roles.push(role);\n } else {\n scopeMap.set(key, { userId, scopeType, scopeId, roles: [role] });\n }\n }\n\n // Drop effective_permissions rows for any (user,scope) that no longer has an assignment\n const allEff = await db.query(\"effective_permissions\", \"by_creation\").collect();\n for (const e of allEff) {\n const key = `${e.userId as string}\\0${e.scopeType as string}\\0${e.scopeId as string}`;\n if (!scopeMap.has(key)) await db.delete(e._id as string);\n }\n\n // Rebuild each assigned (user,scope) from its full set of roles\n for (const s of scopeMap.values()) {\n await reconcileScope(db, config, s.userId, s.scopeType, s.scopeId, s.roles);\n }\n\n // Stamp the new config hash\n if (meta != null) {\n await db.replace(meta._id as string, { configHash: current });\n } else {\n await db.insert(\"meta\", { configHash: current });\n }\n}\n","import type { GuestDatabaseReader, GuestDatabaseWriter } from \"@helipod/executor\";\n\nexport interface RelSubject { type: string; id: string; relation?: string }\nexport interface RelObject { type: string; id: string }\n\n/** The subject's (type, id, subjectRelation) triple; a missing `relation` means a direct subject. */\nfunction subj(s: RelSubject): [string, string, string] { return [s.type, s.id, s.relation ?? \"\"]; }\n\n/** Query a specific object#relation@subject tuple (exact point-read via byObject). */\nfunction tupleRows(db: GuestDatabaseReader, obj: RelObject, relation: string, st: string, si: string, sr: string) {\n return db.query(\"relations\", \"byObject\")\n .eq(\"objectType\", obj.type).eq(\"objectId\", obj.id).eq(\"relation\", relation)\n .eq(\"subjectType\", st).eq(\"subjectId\", si).eq(\"subjectRelation\", sr).collect();\n}\n\nexport async function addRelationTuple(db: GuestDatabaseWriter, subject: RelSubject, relation: string, object: RelObject): Promise<void> {\n const [st, si, sr] = subj(subject);\n if ((await tupleRows(db, object, relation, st, si, sr)).length > 0) return; // idempotent\n await db.insert(\"relations\", { objectType: object.type, objectId: object.id, relation, subjectType: st, subjectId: si, subjectRelation: sr });\n}\n\nexport async function removeRelationTuple(db: GuestDatabaseWriter, subject: RelSubject, relation: string, object: RelObject): Promise<void> {\n const [st, si, sr] = subj(subject);\n for (const row of await tupleRows(db, object, relation, st, si, sr)) await db.delete(row._id as string);\n}\n\n/** The usersets a direct subject belongs to: every tuple where it is a direct subject → (objectType, objectId, relation). */\nasync function memberships(db: GuestDatabaseReader, st: string, si: string): Promise<Array<[string, string, string]>> {\n const rows = await db.query(\"relations\", \"bySubject\").eq(\"subjectType\", st).eq(\"subjectId\", si).eq(\"subjectRelation\", \"\").collect();\n return rows.map((r) => [r.objectType as string, r.objectId as string, r.relation as string]);\n}\n\n/** Does `subject` have `relation` to `object`? Direct, or (for a direct subject) via one of its usersets. */\nexport async function hasRelation(db: GuestDatabaseReader, subject: RelSubject, relation: string, object: RelObject): Promise<boolean> {\n const [st, si, sr] = subj(subject);\n if ((await tupleRows(db, object, relation, st, si, sr)).length > 0) return true;\n if (sr !== \"\") return false; // a userset subject is checked directly only\n for (const [gt, gid, mRel] of await memberships(db, st, si))\n if ((await tupleRows(db, object, relation, gt, gid, mRel)).length > 0) return true;\n return false;\n}\n\n/** Object ids of type `objectType` that `userId` has `relation` to — direct or via a group they belong to. */\nexport async function objectsWith(db: GuestDatabaseReader, userId: string, relation: string, objectType: string): Promise<string[]> {\n const out = new Set<string>();\n const direct = await db.query(\"relations\", \"bySubject\").eq(\"subjectType\", \"user\").eq(\"subjectId\", userId).eq(\"subjectRelation\", \"\").eq(\"relation\", relation).collect();\n for (const r of direct) if (r.objectType === objectType) out.add(r.objectId as string);\n for (const [gt, gid, mRel] of await memberships(db, \"user\", userId)) {\n const grp = await db.query(\"relations\", \"bySubject\").eq(\"subjectType\", gt).eq(\"subjectId\", gid).eq(\"subjectRelation\", mRel).eq(\"relation\", relation).collect();\n for (const r of grp) if (r.objectType === objectType) out.add(r.objectId as string);\n }\n return [...out];\n}\n","import type { ComponentContext } from \"@helipod/executor\";\nimport { type AuthzConfig } from \"./roles\";\nimport { candidateKeys } from \"./effective-permissions\";\nimport { hasRelation as relHasRelation, objectsWith as relObjectsWith } from \"./relations\";\nimport type { RelSubject, RelObject } from \"./relations\";\n\nexport interface AuthzContext {\n can(permission: string, scope?: { type: string; id: string }): Promise<boolean>;\n require(permission: string, scope?: { type: string; id: string }): Promise<void>;\n roles(scope?: { type: string; id: string }): Promise<string[]>;\n scopesWith(permission: string, type?: string): Promise<string[]>;\n hasRelation(subject: RelSubject, relation: string, object: RelObject): Promise<boolean>;\n objectsWith(relation: string, objectType: string): Promise<string[]>;\n}\n\ninterface AuthFacade { getUserId(): Promise<string | null> }\n\nexport function authzContext(cctx: ComponentContext, config: AuthzConfig): AuthzContext {\n const auth = cctx.components.auth as AuthFacade | undefined;\n async function uid(): Promise<string | null> { return auth ? await auth.getUserId() : null; }\n async function assignedRoles(scope?: { type: string; id: string }): Promise<string[]> {\n const u = await uid();\n if (!u) return [];\n const rows = await cctx.db.query(\"role_assignments\", \"byUser\").eq(\"userId\", u).collect();\n const st = scope?.type ?? \"\", si = scope?.id ?? \"\";\n return rows\n .filter((r) => (r.scopeType === \"\" && r.scopeId === \"\") || (r.scopeType === st && r.scopeId === si))\n .map((r) => r.role as string);\n }\n async function held(scopeType: string, scopeId: string, userId: string, keys: string[]): Promise<boolean> {\n for (const key of keys) {\n const hit = await cctx.db.query(\"effective_permissions\", \"byLookup\")\n .eq(\"scopeType\", scopeType).eq(\"scopeId\", scopeId).eq(\"userId\", userId).eq(\"permission\", key).collect();\n if (hit.length > 0) return true;\n }\n return false;\n }\n void config; // config is kept as a parameter for API compatibility; index reads don't need it\n return {\n async can(permission, scope) {\n const u = await uid(); if (!u) return false;\n const keys = candidateKeys(permission);\n const st = scope?.type ?? \"\", si = scope?.id ?? \"\";\n if (await held(st, si, u, keys)) return true;\n if (st !== \"\" || si !== \"\") return held(\"\", \"\", u, keys); // fall back to a global grant\n return false;\n },\n async require(permission, scope) {\n if (!(await this.can(permission, scope))) throw new Error(`Forbidden: ${permission}`);\n },\n async roles(scope) { return assignedRoles(scope); },\n async scopesWith(permission, type) {\n const u = await uid(); if (!u) return [];\n const keys = new Set(candidateKeys(permission));\n const rows = await cctx.db.query(\"effective_permissions\", \"byUser\").eq(\"userId\", u).collect();\n const out = new Set<string>();\n for (const r of rows) {\n if (type !== undefined && r.scopeType !== type) continue;\n if (keys.has(r.permission as string)) out.add(r.scopeId as string);\n }\n return [...out];\n },\n async hasRelation(subject, relation, object) {\n return relHasRelation(cctx.db, subject, relation, object);\n },\n async objectsWith(relation, objectType) {\n const u = await uid(); if (!u) return [];\n return relObjectsWith(cctx.db, u, relation, objectType);\n },\n };\n}\n","import { mutation, type RegisteredFunction, type GuestDatabaseWriter } from \"@helipod/executor\";\nimport type { AuthzContext } from \"./context\";\nimport { roleGrants, type AuthzConfig } from \"./roles\";\nimport { upsertPatterns, reconcileScope, reconcileEffectivePermissions, candidateKeys } from \"./effective-permissions\";\nimport { addRelationTuple, removeRelationTuple, type RelSubject, type RelObject } from \"./relations\";\n\ninterface Assign { userId: string; role: string; scope?: { type: string; id: string } }\n\n/**\n * The permission that authorizes managing role assignments. A role that grants\n * `authz:manage` (e.g. an `admin` role declared with `authz: [\"manage\"]`) may call\n * `assignRole`/`revokeRole`. Held in a scope, it authorizes management within that scope only.\n */\nexport const MANAGE_PERMISSION = \"authz:manage\";\n\n// The module `ctx` carries the composed component facades (see the executor's context-provider\n// loop). `authz` is present because this component contributes it; `require` throws \"Forbidden: …\"\n// when the caller lacks the permission in the requested scope.\ntype WithAuthz = { authz: AuthzContext };\n\n// \"\" is the reserved sentinel for the global scope (an assignment with scopeType/scopeId = \"\" grants\n// everywhere). Reject a partial or empty explicit scope so a real resource scope can never collide\n// with — and silently widen to — the global scope. Omit `scope` entirely for a global assignment.\nexport function assertScope(scope?: { type: string; id: string }): void {\n if (scope && (scope.type === \"\" || scope.id === \"\"))\n throw new Error('authz: scope.type and scope.id must be non-empty (\"\" is reserved for the global scope; omit scope for global)');\n}\n\nexport function authzModules(config: AuthzConfig): Record<string, RegisteredFunction> {\n const assignRole = mutation(async (ctx, { userId, role, scope }: Assign) => {\n assertScope(scope);\n await (ctx as unknown as WithAuthz).authz.require(MANAGE_PERMISSION, scope);\n const st = scope?.type ?? \"\", si = scope?.id ?? \"\";\n const existing = await ctx.db.query(\"role_assignments\", \"byUserScope\").eq(\"userId\", userId).eq(\"scopeType\", st).eq(\"scopeId\", si).collect();\n if (!existing.some((r) => r.role === role)) await ctx.db.insert(\"role_assignments\", { userId, role, scopeType: st, scopeId: si });\n await upsertPatterns(ctx.db as unknown as GuestDatabaseWriter, config, userId, role, st, si); // idempotent even if the assignment already existed\n return null;\n });\n\n const revokeRole = mutation(async (ctx, { userId, role, scope }: Assign) => {\n assertScope(scope);\n await (ctx as unknown as WithAuthz).authz.require(MANAGE_PERMISSION, scope);\n const st = scope?.type ?? \"\", si = scope?.id ?? \"\";\n // Query BEFORE staging deletes: the query engine reads the committed snapshot and cannot\n // see staged (uncommitted) writes, so compute remaining roles in memory first.\n const rows = await ctx.db.query(\"role_assignments\", \"byUserScope\").eq(\"userId\", userId).eq(\"scopeType\", st).eq(\"scopeId\", si).collect();\n const remainingRoles = rows.filter((r) => r.role !== role).map((r) => r.role as string);\n for (const r of rows) if (r.role === role) await ctx.db.delete(r._id as string);\n await reconcileScope(ctx.db as unknown as GuestDatabaseWriter, config, userId, st, si, remainingRoles);\n return null;\n });\n\n const rebuild = mutation(async (ctx) => {\n await (ctx as unknown as WithAuthz).authz.require(MANAGE_PERMISSION);\n await reconcileEffectivePermissions({ db: ctx.db as unknown as GuestDatabaseWriter }, config);\n return null;\n });\n\n const bootstrapFirstAdmin = mutation(async (ctx, { userId, role }: { userId: string; role: string }) => {\n // 1. Validate role grants manage\n if (!roleGrants(config, role, MANAGE_PERMISSION)) {\n throw new Error(`authz: role \"${role}\" does not grant ${MANAGE_PERMISSION}`);\n }\n\n // 2. TOFU gate — reject if ANY admin already exists.\n // Scan effective_permissions; if any row's permission is in candidateKeys(MANAGE_PERMISSION), throw.\n // Note: ctx.db here is the component-scoped writer (same namespace as GuestDatabaseWriter below),\n // so this scan and the upsertPatterns call address the same physical table.\n const manageKeys = new Set(candidateKeys(MANAGE_PERMISSION));\n const allEff = await ctx.db.query(\"effective_permissions\", \"by_creation\").collect();\n for (const row of allEff) {\n if (manageKeys.has(row.permission as string)) {\n throw new Error(\"authz: an admin already exists; use assignRole\");\n }\n }\n\n // 3. Seed BOTH tables atomically (same transaction), global scope (scopeType: \"\", scopeId: \"\").\n const existing = await ctx.db.query(\"role_assignments\", \"byUserScope\").eq(\"userId\", userId).eq(\"scopeType\", \"\").eq(\"scopeId\", \"\").collect();\n if (!existing.some((r) => r.role === role)) {\n await ctx.db.insert(\"role_assignments\", { userId, role, scopeType: \"\", scopeId: \"\" });\n }\n await upsertPatterns(ctx.db as unknown as GuestDatabaseWriter, config, userId, role, \"\", \"\");\n\n // 4. Return null\n return null;\n });\n\n const addRelation = mutation(async (ctx, { subject, relation, object }: { subject: RelSubject; relation: string; object: RelObject }) => {\n await (ctx as unknown as WithAuthz).authz.require(`${object.type}:share`, { type: object.type, id: object.id });\n await addRelationTuple(ctx.db as unknown as GuestDatabaseWriter, subject, relation, object);\n return null;\n });\n\n const removeRelation = mutation(async (ctx, { subject, relation, object }: { subject: RelSubject; relation: string; object: RelObject }) => {\n await (ctx as unknown as WithAuthz).authz.require(`${object.type}:share`, { type: object.type, id: object.id });\n await removeRelationTuple(ctx.db as unknown as GuestDatabaseWriter, subject, relation, object);\n return null;\n });\n\n return { assignRole, revokeRole, rebuild, bootstrapFirstAdmin, addRelation, removeRelation };\n}\n","import type { ComponentContext, RuleAuth } from \"@helipod/executor\";\n\n/** Re-exported so app authors can type their policies. */\nexport type { WhereInput, FieldOps, TablePolicy, PolicyPredicate } from \"@helipod/executor\";\n\ninterface AuthFacade { getUserId(): Promise<string | null> }\ninterface AuthzFacade {\n can(p: string, s?: { type: string; id: string }): Promise<boolean>;\n roles(s?: { type: string; id: string }): Promise<string[]>;\n scopesWith(p: string, t?: string): Promise<string[]>;\n objectsWith(relation: string, objectType: string): Promise<string[]>;\n hasRelation(subject: { type: string; id: string; relation?: string }, relation: string, object: { type: string; id: string }): Promise<boolean>;\n}\n\n/** Build the `auth` field of a row policy's rule-context from the composed auth+authz facades. */\nexport async function buildRuleAuth(cctx: ComponentContext): Promise<RuleAuth> {\n const authFacade = cctx.components.auth as AuthFacade | undefined;\n const authzFacade = cctx.components.authz as AuthzFacade;\n const userId = authFacade ? await authFacade.getUserId() : null;\n return {\n userId,\n identity: cctx.identity,\n can: (p, s) => authzFacade.can(p, s),\n roles: (s) => authzFacade.roles(s),\n scopesWith: (p, t) => authzFacade.scopesWith(p, t),\n objectsWith: (relation, objectType) => authzFacade.objectsWith(relation, objectType),\n hasRelation: (subject, relation, object) => authzFacade.hasRelation(subject, relation, object),\n };\n}\n"],"mappings":"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","names":["v"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@helipod/authz",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "FSL-1.1-Apache-2.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"import": "./dist/index.js"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsup",
|
|
16
|
+
"typecheck": "tsc --noEmit",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@helipod/auth": "0.1.0",
|
|
21
|
+
"@helipod/component": "0.1.0",
|
|
22
|
+
"@helipod/errors": "0.1.0",
|
|
23
|
+
"@helipod/executor": "0.1.0",
|
|
24
|
+
"@helipod/values": "0.1.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@helipod/docstore-sqlite": "0.1.0",
|
|
28
|
+
"@helipod/runtime-embedded": "0.1.0",
|
|
29
|
+
"@types/node": "^22.10.5",
|
|
30
|
+
"tsup": "^8.3.5",
|
|
31
|
+
"typescript": "^5.7.2",
|
|
32
|
+
"vitest": "^2.1.8"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/helipod-sh/helipod.git",
|
|
43
|
+
"directory": "components/authz"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/helipod-sh/helipod"
|
|
46
|
+
}
|