cvx-kit 0.0.2 → 0.0.3
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 +1 -0
- package/dist/auth.d.mts +52 -5
- package/dist/auth.mjs +1 -1
- package/dist/auth.mjs.map +1 -1
- package/dist/components/approvals/audit.mjs +1 -1
- package/dist/components/foundation/client.d.mts +40 -3
- package/dist/components/foundation/client.mjs +1 -1
- package/dist/components/foundation/client.mjs.map +1 -1
- package/dist/index.d.mts +6 -5
- package/dist/index.mjs +1 -1
- package/dist/tenancy.d.mts +34 -0
- package/dist/tenancy.mjs +2 -0
- package/dist/tenancy.mjs.map +1 -0
- package/dist/triggers.d.mts +9 -1
- package/dist/triggers.mjs +1 -1
- package/dist/triggers.mjs.map +1 -1
- package/dist/zod-table.d.mts +21 -1
- package/dist/zod-table.mjs +1 -1
- package/dist/zod-table.mjs.map +1 -1
- package/docs/auth.md +10 -0
- package/docs/commands.md +56 -4
- package/docs/conventions.md +21 -0
- package/docs/explain/modules-and-multitenancy.html +469 -0
- package/docs/llms.md +16 -4
- package/docs/tenancy.md +179 -0
- package/docs/triggers.md +5 -0
- package/docs/zod-table.md +23 -0
- package/package.json +5 -1
- package/src/auth.ts +127 -14
- package/src/components/foundation/client.ts +114 -3
- package/src/index.ts +15 -0
- package/src/tenancy.ts +110 -0
- package/src/triggers.ts +34 -0
- package/src/zod-table.ts +86 -0
- /package/docs/{convex-architecture-explainer.html → explain/convex-architecture-explainer.html} +0 -0
package/docs/tenancy.md
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# `cvx-kit/tenancy` — row-level security, with tenancy as an opt-in layer
|
|
2
|
+
|
|
3
|
+
Row-level security and tenancy are **separate, composable features** of the
|
|
4
|
+
same `security` config on `createAuthFunctions`:
|
|
5
|
+
|
|
6
|
+
- **`security.rules` alone** — role-level / module-level RLS with no tenancy
|
|
7
|
+
at all: gate writes by role, freeze evidence tables, ownership-gate reads.
|
|
8
|
+
`defaultPolicy` stays `'allow'`; unlisted tables behave normally.
|
|
9
|
+
- **`security.tenancy` alone** — pure tenant isolation from a table registry.
|
|
10
|
+
- **Both** — rules are AND-composed with tenant isolation.
|
|
11
|
+
|
|
12
|
+
A single-org app that only wants role-gated writes configures `rules` and
|
|
13
|
+
never touches tenancy. Nothing below is required unless you opt into it.
|
|
14
|
+
|
|
15
|
+
Multi-tenancy itself is a structural machine, not a per-handler
|
|
16
|
+
convention. The model: **tenant = the identity provider's organization id**,
|
|
17
|
+
stamped as a server-owned `tenant` string field on every tenant-scoped row,
|
|
18
|
+
always derived from the verified identity — never accepted as an argument.
|
|
19
|
+
Isolation is defense-in-depth; each layer covers a hole in the previous one:
|
|
20
|
+
|
|
21
|
+
1. **Schema** — `tenantTable` injects the field and strips it from every
|
|
22
|
+
write boundary.
|
|
23
|
+
2. **Row-level security** — deny-default rules generated mechanically from
|
|
24
|
+
one table registry, wrapped into the auth constructors.
|
|
25
|
+
3. **Triggers** — a post-image invariant forbids tenant reassignment (RLS
|
|
26
|
+
authorizes against the pre-image only).
|
|
27
|
+
4. **Reference guards** — client-supplied ids re-verify ownership without
|
|
28
|
+
disclosing existence.
|
|
29
|
+
|
|
30
|
+
Single-org apps skip all of this: every piece is opt-in, and
|
|
31
|
+
`createAuthFunctions` without a `security` config behaves exactly as before.
|
|
32
|
+
|
|
33
|
+
## The registry is the design
|
|
34
|
+
|
|
35
|
+
One list of table names drives everything. Adding a table to the registry is
|
|
36
|
+
the entire onboarding — there is no second per-table step to forget:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
// convex/tenancy.ts (or alongside triggers.ts)
|
|
40
|
+
export const TENANT_TABLES = ['<entities>', '<others>'] as const
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `security.tenancy.tables: TENANT_TABLES` → RLS rules generated per table
|
|
44
|
+
- `tenantOwnership(triggers, ...TENANT_TABLES)` → reassignment guard per table
|
|
45
|
+
- each listed table is declared with `tenantTable(...)` in its module
|
|
46
|
+
|
|
47
|
+
Recommended architecture test: assert every `tenantTable` in the codebase
|
|
48
|
+
appears in `TENANT_TABLES`, and vice versa.
|
|
49
|
+
|
|
50
|
+
## Wiring
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// domain/<module>/schema.ts — tenant injected at the schema boundary
|
|
54
|
+
export const <entities> = tenantTable('<entities>', (id) => ({
|
|
55
|
+
name: z.string(), ownerId: id('users'),
|
|
56
|
+
}), { publicFields: ['name'] })
|
|
57
|
+
// tenant: excluded from insertSchema/updateSchema/commandInput,
|
|
58
|
+
// in publicDto only if explicitly allowlisted
|
|
59
|
+
|
|
60
|
+
// domain/<module>/table.ts — every index tenant-prefixed
|
|
61
|
+
<entities>: <entities>.table.index('by_tenant', ['tenant'])
|
|
62
|
+
.index('by_tenant_owner', ['tenant', 'ownerId'])
|
|
63
|
+
|
|
64
|
+
// convex/triggers.ts
|
|
65
|
+
tenantOwnership(triggers, ...TENANT_TABLES)
|
|
66
|
+
|
|
67
|
+
// convex/functions.ts
|
|
68
|
+
createAuthFunctions<DataModel>({
|
|
69
|
+
...,
|
|
70
|
+
security: {
|
|
71
|
+
tenancy: {
|
|
72
|
+
tables: TENANT_TABLES,
|
|
73
|
+
// optional; defaults to bundle.org.organizationId
|
|
74
|
+
resolve: (bundle) => bundle.org.organizationId,
|
|
75
|
+
},
|
|
76
|
+
// role-level security, AND-composed with tenant isolation
|
|
77
|
+
rules: (bundle) => ({
|
|
78
|
+
'<entities>': { modify: async () => bundle.role === '<privileged>' },
|
|
79
|
+
}),
|
|
80
|
+
// defaults to 'deny' when tenancy is configured
|
|
81
|
+
defaultPolicy: 'deny',
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## What the constructors do with it
|
|
87
|
+
|
|
88
|
+
- Every authenticated ctx gains **`ctx.tenant`** (also on the frozen bundle) —
|
|
89
|
+
resolved via `security.tenancy.resolve`, defaulting to the organization id.
|
|
90
|
+
Actions re-derive it from the live-verified membership.
|
|
91
|
+
- **Queries** get a wrapped reader: rows failing `read` are invisible —
|
|
92
|
+
filtered from every `get`/`query`, even a full table scan.
|
|
93
|
+
- **Mutations** wrap triggers first, then the RLS writer — `insert`/`modify`
|
|
94
|
+
failing a rule throws. Order matters and is handled for you.
|
|
95
|
+
- **`system*` constructors stay unwrapped** — trusted internal paths reach
|
|
96
|
+
every table. This is deliberate: schedulers and callbacks receive the
|
|
97
|
+
server-derived tenant as data, never as authority.
|
|
98
|
+
- With tenancy configured, `defaultPolicy` defaults to **deny**: tables
|
|
99
|
+
outside the registry are unreachable from `auth*`/`role*`/`admin*`
|
|
100
|
+
functions. Global/platform tables are reached from `system*` paths, or by
|
|
101
|
+
explicitly granting rules for them.
|
|
102
|
+
|
|
103
|
+
Handlers stamp inserts from the ctx — the schema forbids clients supplying
|
|
104
|
+
it, RLS rejects a wrong value, and the trigger forbids changing it later:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
await ctx.db.insert('<entities>', { ...args, tenant: ctx.tenant })
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Role-level security — with or without tenancy
|
|
111
|
+
|
|
112
|
+
`security.rules` receives the full auth bundle and returns convex-helpers
|
|
113
|
+
`Rules`. It works standalone (no tenancy configured) or AND-composed with
|
|
114
|
+
tenant isolation (`composeRules`) — a row must pass **every** rule set that
|
|
115
|
+
defines the operation. Use it for role-gated writes, ownership-gated reads,
|
|
116
|
+
or state-machine write locks:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
// rules-only, single-org app: no tenancy anywhere
|
|
120
|
+
security: {
|
|
121
|
+
rules: (bundle) => ({
|
|
122
|
+
'<entities>': { modify: async () => bundle.role !== '<readonly-role>' },
|
|
123
|
+
}),
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
rules: (bundle) => ({
|
|
129
|
+
'<entities>': {
|
|
130
|
+
modify: async (_ctx, doc) =>
|
|
131
|
+
bundle.role === 'admin' || doc.ownerId === bundle.actor.userId,
|
|
132
|
+
},
|
|
133
|
+
'<evidence>': { modify: async () => false }, // frozen via RLS as well as trigger
|
|
134
|
+
})
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Client-supplied ids
|
|
138
|
+
|
|
139
|
+
RLS makes foreign rows invisible to reads, but any id a client sends should
|
|
140
|
+
still be re-verified — and without disclosing existence:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { requireTenantReference } from 'cvx-kit/tenancy'
|
|
144
|
+
|
|
145
|
+
const row = await requireTenantReference(ctx.tenant, () => ctx.db.get(args.id))
|
|
146
|
+
// missing row and foreign row fail identically: REFERENCE_NOT_FOUND
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`assertTenantOwned(tenant, doc)` is the internal-path variant
|
|
150
|
+
(CROSS_TENANT_REFERENCE, for rows you already loaded on trusted paths).
|
|
151
|
+
|
|
152
|
+
## Footguns
|
|
153
|
+
|
|
154
|
+
1. **Actions have no db** — an action passing work to internal functions must
|
|
155
|
+
pass `ctx.tenant` (server-derived) as data; internal functions must never
|
|
156
|
+
accept a caller-chosen tenant on a public path.
|
|
157
|
+
2. **Provider ids are global** (memberships, invitations in WorkOS-style
|
|
158
|
+
providers): org-scoped mutations against the provider must re-check the
|
|
159
|
+
id belongs to the active org, or you ship a cross-tenant IDOR.
|
|
160
|
+
3. **Tables outside the registry get none of this.** Keep the global-table
|
|
161
|
+
list short and named; every one is a per-handler-discipline zone.
|
|
162
|
+
4. **`t.run(...)` in convex-test bypasses RLS and triggers** — same rule as
|
|
163
|
+
always: seeding only.
|
|
164
|
+
5. **Return "not found", never "forbidden"** for cross-tenant references —
|
|
165
|
+
existence is also tenant data.
|
|
166
|
+
|
|
167
|
+
## Module maps
|
|
168
|
+
|
|
169
|
+
`createModule` (from `cvx-kit/zod-table`) merges per-module table maps into
|
|
170
|
+
the schema and rejects a table declared by two modules:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
// convex/domain/table.ts
|
|
174
|
+
export const domainTables = createModule(
|
|
175
|
+
<moduleA>Tables, <moduleB>Tables, <moduleC>Tables,
|
|
176
|
+
)
|
|
177
|
+
// convex/schema.ts
|
|
178
|
+
export default defineSchema(domainTables)
|
|
179
|
+
```
|
package/docs/triggers.md
CHANGED
|
@@ -48,6 +48,11 @@ createAuthFunctions<DataModel>({ ..., triggers })
|
|
|
48
48
|
votes/history/audit-style evidence tables.
|
|
49
49
|
- **`noDelete(triggers, ...tables)`** — forbids hard deletes only; updates
|
|
50
50
|
pass. Pair with `archivedAt` for soft deletion.
|
|
51
|
+
- **`tenantOwnership(triggers, ...tables)`** — the tenancy post-image
|
|
52
|
+
invariant: inserts must carry a non-empty `tenant`, updates may never
|
|
53
|
+
reassign it. Register for every table in the tenancy registry (row-level
|
|
54
|
+
security checks the pre-image only — this closes that hole). See
|
|
55
|
+
`tenancy.md`.
|
|
51
56
|
|
|
52
57
|
`Triggers`, `Change`, and `Trigger` are re-exported for custom registrations.
|
|
53
58
|
|
package/docs/zod-table.md
CHANGED
|
@@ -93,6 +93,29 @@ Think of the options as three independent allowlists over the same shape:
|
|
|
93
93
|
All derived objects are `.strict()` — unknown keys are rejected, in both
|
|
94
94
|
directions.
|
|
95
95
|
|
|
96
|
+
## `tenantTable` — tenant-owned rows
|
|
97
|
+
|
|
98
|
+
`tenantTable` is `zodTable` plus a server-owned `tenant` string field —
|
|
99
|
+
injected exactly like timestamps: present in storage, excluded from
|
|
100
|
+
`insertSchema`/`updateSchema`/`commandInput`, in `publicDto` only by explicit
|
|
101
|
+
allowlist. Handlers stamp it from `ctx.tenant`; row-level security matches on
|
|
102
|
+
it; the `tenantOwnership` trigger forbids reassigning it. See `tenancy.md`.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
export const <entities> = tenantTable('<entities>', (id) => ({ ... }), {
|
|
106
|
+
commandFields: [...], publicFields: [...],
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## `createModule` — module table maps
|
|
111
|
+
|
|
112
|
+
Merges per-module table maps into the application schema, throwing when two
|
|
113
|
+
modules declare the same table — the combinator for `domain/table.ts`:
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
export default defineSchema(createModule(<moduleA>Tables, <moduleB>Tables))
|
|
117
|
+
```
|
|
118
|
+
|
|
96
119
|
## `zodVariantTable` — discriminated unions
|
|
97
120
|
|
|
98
121
|
For tables whose rows are a discriminated union (events, polymorphic
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cvx-kit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Reusable Convex application kit: foundation kernels, approvals component, zod table boundaries, WorkOS-backed auth function constructors, and audited command protocol.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./dist/auth.d.mts",
|
|
26
26
|
"import": "./dist/auth.mjs"
|
|
27
27
|
},
|
|
28
|
+
"./tenancy": {
|
|
29
|
+
"types": "./dist/tenancy.d.mts",
|
|
30
|
+
"import": "./dist/tenancy.mjs"
|
|
31
|
+
},
|
|
28
32
|
"./triggers": {
|
|
29
33
|
"types": "./dist/triggers.d.mts",
|
|
30
34
|
"import": "./dist/triggers.mjs"
|
package/src/auth.ts
CHANGED
|
@@ -22,7 +22,16 @@ import type {
|
|
|
22
22
|
UserIdentity,
|
|
23
23
|
} from 'convex/server'
|
|
24
24
|
import type { Triggers } from 'convex-helpers/server/triggers'
|
|
25
|
+
import type { TableNamesInDataModel } from 'convex/server'
|
|
25
26
|
import { defaultErrors, type ErrorFactory } from './errors'
|
|
27
|
+
import {
|
|
28
|
+
composeRules,
|
|
29
|
+
createTenantRules,
|
|
30
|
+
wrapDatabaseReader,
|
|
31
|
+
wrapDatabaseWriter,
|
|
32
|
+
type RLSConfig,
|
|
33
|
+
type Rules,
|
|
34
|
+
} from './tenancy'
|
|
26
35
|
|
|
27
36
|
/** The default vocabulary; every app can supply its own via the Role generic. */
|
|
28
37
|
export type DefaultRole = 'reader' | 'writer' | 'admin'
|
|
@@ -39,8 +48,43 @@ export type AuthBundle<Role extends string> = Readonly<{
|
|
|
39
48
|
org: Readonly<{ organizationId: string; role: Role }>
|
|
40
49
|
role: Role
|
|
41
50
|
actor: Actor<Role>
|
|
51
|
+
/** The resolved tenant — security.tenancy.resolve, or the organization id. */
|
|
52
|
+
tenant: string
|
|
42
53
|
}>
|
|
43
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Row-level security configuration. Tenant isolation and role-level rules
|
|
57
|
+
* compose (AND) onto the same wrapped database: queries get a wrapped
|
|
58
|
+
* reader, mutations get triggers first, then a wrapped writer.
|
|
59
|
+
*/
|
|
60
|
+
export type SecurityConfig<
|
|
61
|
+
DataModel extends GenericDataModel,
|
|
62
|
+
Role extends string,
|
|
63
|
+
> = {
|
|
64
|
+
/**
|
|
65
|
+
* Tenant isolation. `tables` is THE registry: every listed table gets
|
|
66
|
+
* read/insert/modify gated on row.tenant === ctx.tenant. Pair with
|
|
67
|
+
* tenantTable (schema) and tenantOwnership (triggers) on the same list.
|
|
68
|
+
*/
|
|
69
|
+
tenancy?: {
|
|
70
|
+
tables: readonly TableNamesInDataModel<DataModel>[]
|
|
71
|
+
/** Maps the auth bundle to the tenant; defaults to org.organizationId. */
|
|
72
|
+
resolve?: (bundle: Omit<AuthBundle<Role>, 'tenant'>) => string
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Role-level (or any policy) rules derived from the authenticated bundle,
|
|
76
|
+
* composed AND-wise with tenant isolation. E.g. gate `modify` on a table
|
|
77
|
+
* to admin roles.
|
|
78
|
+
*/
|
|
79
|
+
rules?: (bundle: AuthBundle<Role>) => Rules<unknown, DataModel>
|
|
80
|
+
/**
|
|
81
|
+
* Policy for tables with no rule. Defaults to 'deny' when tenancy is
|
|
82
|
+
* configured (unlisted tables are unreachable — fail closed), 'allow'
|
|
83
|
+
* otherwise.
|
|
84
|
+
*/
|
|
85
|
+
defaultPolicy?: RLSConfig['defaultPolicy']
|
|
86
|
+
}
|
|
87
|
+
|
|
44
88
|
type AnyAuthContext<DataModel extends GenericDataModel> =
|
|
45
89
|
| GenericQueryCtx<DataModel>
|
|
46
90
|
| GenericMutationCtx<DataModel>
|
|
@@ -90,6 +134,12 @@ export type AuthFunctionsConfig<
|
|
|
90
134
|
) => GenericMutationCtx<DataModel>
|
|
91
135
|
/** Upper bound for include() reads. */
|
|
92
136
|
maxIncludedQueryRows?: number
|
|
137
|
+
/**
|
|
138
|
+
* Row-level security: tenant isolation (from a table registry) and
|
|
139
|
+
* role-level rules, applied structurally inside every auth*, role*, and
|
|
140
|
+
* admin* constructor. system* constructors stay unwrapped (trusted).
|
|
141
|
+
*/
|
|
142
|
+
security?: SecurityConfig<DataModel, Role>
|
|
93
143
|
}
|
|
94
144
|
|
|
95
145
|
/** WorkOS-style default: `member` writes; reader/writer/admin pass through. */
|
|
@@ -223,15 +273,68 @@ export function createAuthFunctions<
|
|
|
223
273
|
: undefined
|
|
224
274
|
const role = config.mapRole(organizationRole)
|
|
225
275
|
if (!role) return errors.throw({ code: 'FORBIDDEN' })
|
|
226
|
-
|
|
276
|
+
const base = {
|
|
227
277
|
identity,
|
|
228
278
|
user: Object.freeze({ id: user.id }),
|
|
229
279
|
org: Object.freeze({ organizationId, role }),
|
|
230
280
|
role,
|
|
231
281
|
actor: Object.freeze({ userId: user.id, organizationId, role }),
|
|
282
|
+
}
|
|
283
|
+
return Object.freeze({
|
|
284
|
+
...base,
|
|
285
|
+
tenant: config.security?.tenancy?.resolve?.(base) ?? organizationId,
|
|
232
286
|
})
|
|
233
287
|
}
|
|
234
288
|
|
|
289
|
+
const securityConfig: RLSConfig | undefined = config.security
|
|
290
|
+
? {
|
|
291
|
+
defaultPolicy:
|
|
292
|
+
config.security.defaultPolicy ??
|
|
293
|
+
(config.security.tenancy ? 'deny' : 'allow'),
|
|
294
|
+
}
|
|
295
|
+
: undefined
|
|
296
|
+
|
|
297
|
+
function securityRules(
|
|
298
|
+
bundle: AuthBundle<Role>,
|
|
299
|
+
): Rules<unknown, DataModel> | undefined {
|
|
300
|
+
const security = config.security
|
|
301
|
+
if (!security) return undefined
|
|
302
|
+
const sets: Rules<unknown, DataModel>[] = []
|
|
303
|
+
if (security.tenancy) {
|
|
304
|
+
sets.push(
|
|
305
|
+
createTenantRules<DataModel>(bundle.tenant, security.tenancy.tables),
|
|
306
|
+
)
|
|
307
|
+
}
|
|
308
|
+
if (security.rules) sets.push(security.rules(bundle))
|
|
309
|
+
return sets.length === 1 ? sets[0] : composeRules(...sets)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function secureReader(
|
|
313
|
+
ctx: GenericQueryCtx<DataModel>,
|
|
314
|
+
bundle: AuthBundle<Role>,
|
|
315
|
+
): GenericQueryCtx<DataModel> {
|
|
316
|
+
const rules = securityRules(bundle)
|
|
317
|
+
if (!rules) return ctx
|
|
318
|
+
return {
|
|
319
|
+
...ctx,
|
|
320
|
+
db: wrapDatabaseReader({}, ctx.db, rules as never, securityConfig),
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function secureWriter(
|
|
325
|
+
ctx: GenericMutationCtx<DataModel>,
|
|
326
|
+
bundle: AuthBundle<Role>,
|
|
327
|
+
): GenericMutationCtx<DataModel> {
|
|
328
|
+
const rules = securityRules(bundle)
|
|
329
|
+
if (!rules) return ctx
|
|
330
|
+
// Triggers wrap first (ctx already trigger-wrapped by the caller),
|
|
331
|
+
// then row-level security wraps the triggered db — order matters.
|
|
332
|
+
return {
|
|
333
|
+
...ctx,
|
|
334
|
+
db: wrapDatabaseWriter({}, ctx.db, rules as never, securityConfig),
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
235
338
|
async function verifiedMembership(userId: string, organizationId: string) {
|
|
236
339
|
if (!config.verifyMembership) return null
|
|
237
340
|
try {
|
|
@@ -253,15 +356,17 @@ export function createAuthFunctions<
|
|
|
253
356
|
}
|
|
254
357
|
|
|
255
358
|
async function queryInput(ctx: GenericQueryCtx<DataModel>) {
|
|
359
|
+
const bundle = await authenticatedUser(ctx)
|
|
256
360
|
return {
|
|
257
|
-
ctx: { ...ctx, include, ...
|
|
361
|
+
ctx: { ...secureReader(ctx, bundle), include, ...bundle },
|
|
258
362
|
args: {},
|
|
259
363
|
}
|
|
260
364
|
}
|
|
261
365
|
|
|
262
366
|
async function mutationInput(ctx: GenericMutationCtx<DataModel>) {
|
|
367
|
+
const bundle = await authenticatedUser(ctx)
|
|
263
368
|
return {
|
|
264
|
-
ctx: { ...wrapDB(ctx), include, ...
|
|
369
|
+
ctx: { ...secureWriter(wrapDB(ctx), bundle), include, ...bundle },
|
|
265
370
|
args: {},
|
|
266
371
|
}
|
|
267
372
|
}
|
|
@@ -275,20 +380,28 @@ export function createAuthFunctions<
|
|
|
275
380
|
if (!membership) return { ctx: { ...ctx, ...authenticated }, args: {} }
|
|
276
381
|
const role = config.mapRole(membership.roleSlug)
|
|
277
382
|
if (!role) return errors.throw({ code: 'FORBIDDEN' })
|
|
383
|
+
const verified = {
|
|
384
|
+
identity: authenticated.identity,
|
|
385
|
+
user: authenticated.user,
|
|
386
|
+
org: Object.freeze({
|
|
387
|
+
organizationId: membership.organizationId,
|
|
388
|
+
role,
|
|
389
|
+
}),
|
|
390
|
+
role,
|
|
391
|
+
actor: Object.freeze({
|
|
392
|
+
userId: authenticated.user.id,
|
|
393
|
+
organizationId: membership.organizationId,
|
|
394
|
+
role,
|
|
395
|
+
}),
|
|
396
|
+
}
|
|
278
397
|
return {
|
|
279
398
|
ctx: {
|
|
280
399
|
...ctx,
|
|
281
|
-
...
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
role,
|
|
287
|
-
actor: Object.freeze({
|
|
288
|
-
userId: authenticated.user.id,
|
|
289
|
-
organizationId: membership.organizationId,
|
|
290
|
-
role,
|
|
291
|
-
}),
|
|
400
|
+
...verified,
|
|
401
|
+
// Tenant re-derives from the live-verified organization.
|
|
402
|
+
tenant:
|
|
403
|
+
config.security?.tenancy?.resolve?.(verified) ??
|
|
404
|
+
membership.organizationId,
|
|
292
405
|
},
|
|
293
406
|
args: {},
|
|
294
407
|
}
|
|
@@ -35,12 +35,41 @@ export type AuditedOperation = Readonly<{
|
|
|
35
35
|
command: Readonly<{ parse: (value: unknown) => unknown }>
|
|
36
36
|
result: Readonly<{ parse: (value: unknown) => unknown }>
|
|
37
37
|
classification: string
|
|
38
|
+
/**
|
|
39
|
+
* Permission slug required to execute this operation. Checked through the
|
|
40
|
+
* Foundation's injected checkPermission BEFORE guards and the handler;
|
|
41
|
+
* declaring a permission without injecting a checker fails closed.
|
|
42
|
+
*/
|
|
43
|
+
permission?: string
|
|
44
|
+
/**
|
|
45
|
+
* Per-operation precondition, after the permission check and the registry
|
|
46
|
+
* default guard, before the handler. Throw to deny — nothing has run yet.
|
|
47
|
+
*/
|
|
48
|
+
guard?: (context: never, command: never) => MaybePromise<void>
|
|
49
|
+
/**
|
|
50
|
+
* Allowlist of aggregate types this operation's audit may reference. When
|
|
51
|
+
* declared, an audit entry whose aggregate.type is not listed throws —
|
|
52
|
+
* the audit vocabulary is enforced, not advisory.
|
|
53
|
+
*/
|
|
54
|
+
aggregates?: readonly string[]
|
|
38
55
|
audit: (
|
|
39
56
|
resolution: Readonly<{ command: never; result: never }>,
|
|
40
57
|
context: never,
|
|
41
58
|
) => MaybePromise<Omit<AuditEntryInput, 'classification'> | null>
|
|
42
59
|
}>
|
|
43
60
|
|
|
61
|
+
/** Registry-wide defaults applied to every operation of one Command. */
|
|
62
|
+
export type CommandDefaults = Readonly<{
|
|
63
|
+
/** Runs before every operation's own guard. Throw to deny. */
|
|
64
|
+
guard?: (context: never) => MaybePromise<void>
|
|
65
|
+
}>
|
|
66
|
+
|
|
67
|
+
/** Injected permission policy: throw to deny. Host semantics, kit ordering. */
|
|
68
|
+
export type PermissionChecker = (
|
|
69
|
+
context: never,
|
|
70
|
+
input: Readonly<{ permission: string; operation: string }>,
|
|
71
|
+
) => MaybePromise<void>
|
|
72
|
+
|
|
44
73
|
export type AuditedRegistry = Readonly<Record<string, AuditedOperation>>
|
|
45
74
|
|
|
46
75
|
export type FoundationOptions = Readonly<{
|
|
@@ -52,6 +81,12 @@ export type FoundationOptions = Readonly<{
|
|
|
52
81
|
*/
|
|
53
82
|
writeAudit?: AuditWriter
|
|
54
83
|
}>
|
|
84
|
+
/**
|
|
85
|
+
* Permission policy for operations that declare a `permission`. Runs
|
|
86
|
+
* before guards and the handler; throw to deny. An operation with a
|
|
87
|
+
* permission but no injected checker fails closed.
|
|
88
|
+
*/
|
|
89
|
+
checkPermission?: PermissionChecker
|
|
55
90
|
}>
|
|
56
91
|
|
|
57
92
|
type FoundationComponentApi = Readonly<{
|
|
@@ -68,6 +103,13 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
|
|
|
68
103
|
readonly #kernel: CommandKernelClass<Context, Operations>
|
|
69
104
|
readonly #observability: Observability
|
|
70
105
|
readonly #writeAudit: AuditWriter
|
|
106
|
+
readonly #checkPermission: PermissionChecker | undefined
|
|
107
|
+
readonly #defaults: CommandDefaults
|
|
108
|
+
|
|
109
|
+
/** Declared aggregate types per operation — introspectable for tests. */
|
|
110
|
+
readonly aggregates: Readonly<{
|
|
111
|
+
[Key in OperationKey<Operations>]: Operations[Key]['aggregates']
|
|
112
|
+
}>
|
|
71
113
|
|
|
72
114
|
static operation<const Definition extends AuditedOperation>(
|
|
73
115
|
definition: Definition,
|
|
@@ -77,10 +119,25 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
|
|
|
77
119
|
|
|
78
120
|
constructor(
|
|
79
121
|
operations: Operations,
|
|
80
|
-
deps: {
|
|
122
|
+
deps: {
|
|
123
|
+
observability: Observability
|
|
124
|
+
writeAudit: AuditWriter
|
|
125
|
+
checkPermission?: PermissionChecker
|
|
126
|
+
defaults?: CommandDefaults
|
|
127
|
+
},
|
|
81
128
|
) {
|
|
82
129
|
this.#observability = deps.observability
|
|
83
130
|
this.#writeAudit = deps.writeAudit
|
|
131
|
+
this.#checkPermission = deps.checkPermission
|
|
132
|
+
this.#defaults = deps.defaults ?? {}
|
|
133
|
+
this.aggregates = Object.freeze(
|
|
134
|
+
Object.fromEntries(
|
|
135
|
+
Object.entries(operations).map(([operation, definition]) => [
|
|
136
|
+
operation,
|
|
137
|
+
definition.aggregates,
|
|
138
|
+
]),
|
|
139
|
+
),
|
|
140
|
+
) as this['aggregates']
|
|
84
141
|
this.#kernel = new CommandKernelClass<Context, Operations>({
|
|
85
142
|
operations,
|
|
86
143
|
execute: (execution) => this.#execute(execution),
|
|
@@ -106,12 +163,35 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
|
|
|
106
163
|
classification: execution.definition.classification,
|
|
107
164
|
},
|
|
108
165
|
async () => {
|
|
166
|
+
// Guards run before the handler: permission → default → operation.
|
|
167
|
+
const permission = execution.definition.permission
|
|
168
|
+
if (permission !== undefined) {
|
|
169
|
+
if (!this.#checkPermission) {
|
|
170
|
+
throw new CommandPermissionError(execution.operation)
|
|
171
|
+
}
|
|
172
|
+
await this.#checkPermission(execution.context as never, {
|
|
173
|
+
permission,
|
|
174
|
+
operation: execution.operation,
|
|
175
|
+
})
|
|
176
|
+
}
|
|
177
|
+
await this.#defaults.guard?.(execution.context as never)
|
|
178
|
+
await execution.definition.guard?.(
|
|
179
|
+
execution.context as never,
|
|
180
|
+
execution.command as never,
|
|
181
|
+
)
|
|
109
182
|
const result = await execution.run()
|
|
110
183
|
const audit = await execution.definition.audit(
|
|
111
184
|
{ command: execution.command, result } as never,
|
|
112
185
|
execution.context as never,
|
|
113
186
|
)
|
|
114
187
|
if (audit) {
|
|
188
|
+
const allowed = execution.definition.aggregates
|
|
189
|
+
if (allowed && !allowed.includes(audit.aggregate.type)) {
|
|
190
|
+
throw new CommandAggregateError(
|
|
191
|
+
execution.operation,
|
|
192
|
+
audit.aggregate.type,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
115
195
|
await this.#writeAudit(execution.context as never, {
|
|
116
196
|
...audit,
|
|
117
197
|
classification: execution.definition.classification,
|
|
@@ -123,6 +203,30 @@ class BoundCommand<Context, const Operations extends AuditedRegistry> {
|
|
|
123
203
|
}
|
|
124
204
|
}
|
|
125
205
|
|
|
206
|
+
/** The audit referenced an aggregate type outside the operation's allowlist. */
|
|
207
|
+
class CommandAggregateError extends Error {
|
|
208
|
+
readonly code = 'COMMAND_AGGREGATE_NOT_DECLARED'
|
|
209
|
+
readonly name = 'CommandAggregateError'
|
|
210
|
+
|
|
211
|
+
constructor(operation: string, aggregateType: string) {
|
|
212
|
+
super(
|
|
213
|
+
`Operation "${operation}" audited aggregate type "${aggregateType}" outside its declared aggregates`,
|
|
214
|
+
)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Fails closed: an operation declared a permission but no checker exists. */
|
|
219
|
+
class CommandPermissionError extends Error {
|
|
220
|
+
readonly code = 'COMMAND_PERMISSION_NOT_CONFIGURED'
|
|
221
|
+
readonly name = 'CommandPermissionError'
|
|
222
|
+
|
|
223
|
+
constructor(operation: string) {
|
|
224
|
+
super(
|
|
225
|
+
`Operation "${operation}" declares a permission but the Foundation has no checkPermission`,
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
126
230
|
export type ApplicationCommand<
|
|
127
231
|
Context,
|
|
128
232
|
Operations extends AuditedRegistry,
|
|
@@ -131,6 +235,7 @@ export type ApplicationCommand<
|
|
|
131
235
|
type CommandConstructor = {
|
|
132
236
|
new <Context, const Operations extends AuditedRegistry>(
|
|
133
237
|
operations: Operations,
|
|
238
|
+
defaults?: CommandDefaults,
|
|
134
239
|
): BoundCommand<Context, Operations>
|
|
135
240
|
operation: (typeof BoundCommand)['operation']
|
|
136
241
|
}
|
|
@@ -153,12 +258,18 @@ export class Foundation<
|
|
|
153
258
|
const observability = new Observability(options.observability)
|
|
154
259
|
this.observability = observability
|
|
155
260
|
const writeAudit = options.observability.writeAudit ?? (() => undefined)
|
|
261
|
+
const checkPermission = options.checkPermission
|
|
156
262
|
this.Command = class <
|
|
157
263
|
Context,
|
|
158
264
|
const Operations extends AuditedRegistry,
|
|
159
265
|
> extends BoundCommand<Context, Operations> {
|
|
160
|
-
constructor(operations: Operations) {
|
|
161
|
-
super(operations, {
|
|
266
|
+
constructor(operations: Operations, defaults?: CommandDefaults) {
|
|
267
|
+
super(operations, {
|
|
268
|
+
observability,
|
|
269
|
+
writeAudit,
|
|
270
|
+
checkPermission,
|
|
271
|
+
defaults,
|
|
272
|
+
})
|
|
162
273
|
}
|
|
163
274
|
}
|
|
164
275
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,23 @@ export {
|
|
|
4
4
|
type ErrorFactory,
|
|
5
5
|
} from './errors'
|
|
6
6
|
export {
|
|
7
|
+
createModule,
|
|
7
8
|
jsonSafeZid,
|
|
9
|
+
tenantTable,
|
|
8
10
|
TIMESTAMP_FIELDS,
|
|
9
11
|
zodTable,
|
|
10
12
|
zodVariantTable,
|
|
11
13
|
type TableBoundaryOptions,
|
|
12
14
|
} from './zod-table'
|
|
15
|
+
export {
|
|
16
|
+
assertTenantOwned,
|
|
17
|
+
composeRules,
|
|
18
|
+
createTenantRules,
|
|
19
|
+
requireTenantReference,
|
|
20
|
+
TENANT_FIELD,
|
|
21
|
+
type RLSConfig,
|
|
22
|
+
type Rules,
|
|
23
|
+
} from './tenancy'
|
|
13
24
|
export {
|
|
14
25
|
createAuthFunctions,
|
|
15
26
|
createInclude,
|
|
@@ -20,9 +31,11 @@ export {
|
|
|
20
31
|
type DefaultRole,
|
|
21
32
|
type Include,
|
|
22
33
|
type IncludedQuery,
|
|
34
|
+
type SecurityConfig,
|
|
23
35
|
} from './auth'
|
|
24
36
|
export {
|
|
25
37
|
appendOnly,
|
|
38
|
+
tenantOwnership,
|
|
26
39
|
createTriggers,
|
|
27
40
|
noDelete,
|
|
28
41
|
timestamps,
|
|
@@ -37,5 +50,7 @@ export {
|
|
|
37
50
|
type AuditedRegistry,
|
|
38
51
|
type AuditEntryInput,
|
|
39
52
|
type AuditWriter,
|
|
53
|
+
type CommandDefaults,
|
|
40
54
|
type FoundationOptions,
|
|
55
|
+
type PermissionChecker,
|
|
41
56
|
} from './components/foundation/client'
|