@everystack/cli 0.2.39 → 0.3.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 +28 -5
- package/package.json +12 -2
- package/src/cli/authz-compile.ts +364 -0
- package/src/cli/authz-contract-io.ts +92 -0
- package/src/cli/authz-contract.ts +589 -0
- package/src/cli/authz-owner-probe.ts +218 -0
- package/src/cli/authz-reconcile.ts +152 -0
- package/src/cli/authz-redteam.ts +251 -0
- package/src/cli/authz-render.ts +248 -0
- package/src/cli/aws.ts +100 -0
- package/src/cli/backup.ts +99 -0
- package/src/cli/commands/db-authz.ts +300 -0
- package/src/cli/commands/db-backup.ts +218 -0
- package/src/cli/commands/db-generate.ts +211 -0
- package/src/cli/commands/db-pull.ts +83 -0
- package/src/cli/commands/db-snapshot.ts +113 -0
- package/src/cli/commands/deploy.ts +46 -0
- package/src/cli/config.ts +8 -0
- package/src/cli/index.ts +68 -0
- package/src/cli/migration-compile.ts +133 -0
- package/src/cli/migration-generate.ts +178 -0
- package/src/cli/model-api.ts +36 -0
- package/src/cli/model-render.ts +262 -0
- package/src/cli/pg-ident.ts +59 -0
- package/src/cli/rds-snapshot.ts +47 -0
- package/src/cli/schema-compile.ts +496 -0
- package/src/cli/schema-diff.ts +608 -0
- package/src/cli/schema-introspect.ts +425 -0
- package/src/cli/schema-source.ts +416 -0
- package/src/cli/security-audit.ts +7 -0
- package/src/cli/security-catalog.ts +16 -1
package/README.md
CHANGED
|
@@ -168,17 +168,40 @@ everystack channels create --name staging
|
|
|
168
168
|
everystack channels create --name production
|
|
169
169
|
```
|
|
170
170
|
|
|
171
|
+
### Deploy
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
everystack deploy --stage dev # provision infrastructure (wraps `sst deploy`)
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
A thin wrapper over SST so you never type `sst` directly. Infrastructure only — migrations and bundle publishing stay explicit (`db:migrate`, `update`).
|
|
178
|
+
|
|
171
179
|
### Database Management
|
|
172
180
|
|
|
173
|
-
These commands invoke your Lambda
|
|
181
|
+
These commands invoke your Lambda directly via IAM (no database credentials ever leave AWS):
|
|
174
182
|
|
|
175
183
|
```bash
|
|
176
|
-
|
|
177
|
-
everystack db:
|
|
178
|
-
everystack db:
|
|
184
|
+
# Migrations
|
|
185
|
+
everystack db:migrate # apply Drizzle migrations
|
|
186
|
+
everystack db:seed # seed (dev only)
|
|
187
|
+
everystack db:psql --stage dev [-c SQL] # interactive psql / one-off query via Lambda
|
|
188
|
+
|
|
189
|
+
# Model-driven schema (@everystack/model)
|
|
190
|
+
everystack db:generate # diff Models vs the live DB → next migration
|
|
191
|
+
everystack db:pull # introspect a live DB → field() Models (brownfield on-ramp)
|
|
192
|
+
|
|
193
|
+
# Security
|
|
194
|
+
everystack db:doctor # is the DB least-privilege + RLS-subject?
|
|
195
|
+
everystack db:provision # create the least-privilege role chain (idempotent)
|
|
196
|
+
everystack db:authz:pull|diff|test|owner|report # the authorization contract + red-team
|
|
197
|
+
|
|
198
|
+
# Backups (see docs/backups.md)
|
|
199
|
+
everystack db:backup | db:backups | db:restore --from <id> --confirm # logical pg_dump → S3
|
|
200
|
+
everystack db:backup:download <id> # presigned URL to a dump (1h)
|
|
201
|
+
everystack db:snapshot | db:snapshots # physical RDS snapshots
|
|
179
202
|
```
|
|
180
203
|
|
|
181
|
-
|
|
204
|
+
The full command reference — including `secrets`, `logs:*`, `status`, `cache:purge`, `branches`, and every flag — is in [docs/cli.md](../../docs/cli.md).
|
|
182
205
|
|
|
183
206
|
### Interactive Console
|
|
184
207
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
"types": "./src/index.ts",
|
|
26
26
|
"default": "./src/index.ts"
|
|
27
27
|
},
|
|
28
|
+
"./model": {
|
|
29
|
+
"types": "./src/cli/model-api.ts",
|
|
30
|
+
"default": "./src/cli/model-api.ts"
|
|
31
|
+
},
|
|
28
32
|
"./client": {
|
|
29
33
|
"types": "./src/client/index.ts",
|
|
30
34
|
"default": "./src/client/index.ts"
|
|
@@ -60,13 +64,15 @@
|
|
|
60
64
|
"glob": "13.0.6",
|
|
61
65
|
"node-forge": "1.4.0",
|
|
62
66
|
"structured-headers": "1.0.1",
|
|
63
|
-
"tsx": "4.21.0"
|
|
67
|
+
"tsx": "4.21.0",
|
|
68
|
+
"@everystack/model": "0.3.3"
|
|
64
69
|
},
|
|
65
70
|
"peerDependencies": {
|
|
66
71
|
"@everystack/server": ">=0.1.0",
|
|
67
72
|
"@aws-sdk/client-cloudwatch": "3.1053.0",
|
|
68
73
|
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
69
74
|
"@aws-sdk/client-lambda": "3.1053.0",
|
|
75
|
+
"@aws-sdk/client-rds": "3.1053.0",
|
|
70
76
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
71
77
|
"@aws-sdk/client-ssm": "3.1053.0",
|
|
72
78
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
@@ -91,6 +97,9 @@
|
|
|
91
97
|
"@aws-sdk/client-lambda": {
|
|
92
98
|
"optional": true
|
|
93
99
|
},
|
|
100
|
+
"@aws-sdk/client-rds": {
|
|
101
|
+
"optional": true
|
|
102
|
+
},
|
|
94
103
|
"@aws-sdk/client-ssm": {
|
|
95
104
|
"optional": true
|
|
96
105
|
},
|
|
@@ -111,6 +120,7 @@
|
|
|
111
120
|
"@aws-sdk/client-cloudwatch": "3.1053.0",
|
|
112
121
|
"@aws-sdk/client-cloudwatch-logs": "3.1053.0",
|
|
113
122
|
"@aws-sdk/client-lambda": "3.1053.0",
|
|
123
|
+
"@aws-sdk/client-rds": "3.1053.0",
|
|
114
124
|
"@aws-sdk/client-s3": "3.1053.0",
|
|
115
125
|
"@aws-sdk/client-ssm": "3.1053.0",
|
|
116
126
|
"@aws-sdk/s3-request-presigner": "3.1053.0",
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Model -> Authorization Contract compiler (the second producer).
|
|
3
|
+
*
|
|
4
|
+
* Introspection (authz-contract.ts) reads the live database INTO a contract. This
|
|
5
|
+
* compiles a `defineModel` declaration the other way: `abilities` OUT to the same
|
|
6
|
+
* contract shape, so the two meet at `diffContracts` — declared (Model) vs live
|
|
7
|
+
* (DB). That is the whole leverage of the v3 Model: it is mostly a new producer of
|
|
8
|
+
* an existing format, and `db:authz:reconcile` is the diff turned back into SQL.
|
|
9
|
+
*
|
|
10
|
+
* The make-or-break constraint (proven against apps/example/authz): `diffContracts`
|
|
11
|
+
* compares predicate text VERBATIM, and the contract stores the Postgres deparser
|
|
12
|
+
* (`pg_get_expr`) normal form — which differs from hand-written source SQL (it adds
|
|
13
|
+
* outer parens, an explicit `::text` cast on the claims key, spaces around `->>`,
|
|
14
|
+
* and a `'sub'::text` cast). So the compiler emits that exact normal form, by
|
|
15
|
+
* template, for the shapes that make up ~96% of a real app: owner (uuid or text),
|
|
16
|
+
* soft-delete guard, admin-bypass, the soft-delete-OR-owner read, and the
|
|
17
|
+
* owner-only read. The cross-table `via:` parent subquery and `rawPolicy` escape
|
|
18
|
+
* hatch are the tail — emitted verbatim, round-trip only after a live deparse.
|
|
19
|
+
*
|
|
20
|
+
* Input is a `@everystack/model` ModelDescriptor (type-only — no runtime dep).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ModelDescriptor, Ability } from '@everystack/model';
|
|
24
|
+
import type { PolicyContract, TableContract, PolicyCommand } from './authz-contract.js';
|
|
25
|
+
|
|
26
|
+
export interface CompileOptions {
|
|
27
|
+
/** Schema the table lives in. Default: `public`. */
|
|
28
|
+
schema?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Predicate templates — the deparser normal form, parameterized.
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** `authorId` -> `author_id`. The contract predicates use SQL column names. */
|
|
36
|
+
function toSnakeCase(name: string): string {
|
|
37
|
+
return name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The Postgres cast a JWT-claim string is coerced to for a non-text owner column.
|
|
42
|
+
* The claim arrives as text (`->> 'sub'`); a uuid/bigint owner needs the cast, a
|
|
43
|
+
* text owner does not (see ownerPredicate). Mapped for completeness; only uuid and
|
|
44
|
+
* text are exercised by the example.
|
|
45
|
+
*/
|
|
46
|
+
function castForType(type: string): string {
|
|
47
|
+
switch (type) {
|
|
48
|
+
case 'uuid': return 'uuid';
|
|
49
|
+
case 'bigint':
|
|
50
|
+
case 'bigserial': return 'bigint';
|
|
51
|
+
case 'integer': return 'integer';
|
|
52
|
+
default: return type;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The signed-in user's id from the JWT claims, in deparser normal form. */
|
|
57
|
+
function claimExpr(claim: string): string {
|
|
58
|
+
return `((current_setting('request.jwt.claims'::text, true))::jsonb ->> '${claim}'::text)`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The owner predicate, in deparser normal form. The shape depends on the owner
|
|
63
|
+
* column's type, because the deparser drops a redundant cast:
|
|
64
|
+
* uuid: (author_id = (((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))::uuid)
|
|
65
|
+
* text: (user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))
|
|
66
|
+
*/
|
|
67
|
+
function ownerPredicate(sqlColumn: string, type: string, claim: string): string {
|
|
68
|
+
const ce = claimExpr(claim);
|
|
69
|
+
if (type === 'text') return `(${sqlColumn} = ${ce})`;
|
|
70
|
+
return `(${sqlColumn} = (${ce})::${castForType(type)})`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The soft-delete guard, in deparser normal form: `(deleted_at IS NULL)`. */
|
|
74
|
+
function softDeleteGuard(sqlColumn: string): string {
|
|
75
|
+
return `(${sqlColumn} IS NULL)`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The transitive-owner (`via:`) predicate, in deparser normal form — a parent
|
|
80
|
+
* subquery that scopes the child to rows whose FK points at a parent the caller
|
|
81
|
+
* owns. The exact whitespace (3 spaces before FROM, 2 before WHERE, the newlines)
|
|
82
|
+
* is what `pg_get_expr` emits and the contract stores, so it is reproduced byte
|
|
83
|
+
* for byte; the inner owner predicate reuses {@link ownerPredicate} (table-qualified):
|
|
84
|
+
*
|
|
85
|
+
* (upload_id IN ( SELECT uploads.id
|
|
86
|
+
* FROM uploads
|
|
87
|
+
* WHERE (uploads.user_id = ((current_setting('request.jwt.claims'::text, true))::jsonb ->> 'sub'::text))))
|
|
88
|
+
*/
|
|
89
|
+
function viaPredicate(v: ViaRef): string {
|
|
90
|
+
const parentOwner = ownerPredicate(`${v.parentTable}.${v.parentOwner.sqlColumn}`, v.parentOwner.type, v.parentOwner.claim);
|
|
91
|
+
return `(${v.fkColumn} IN ( SELECT ${v.parentTable}.${v.parentPk}\n FROM ${v.parentTable}\n WHERE ${parentOwner}))`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Ability classification.
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
interface OwnerRef {
|
|
99
|
+
sqlColumn: string;
|
|
100
|
+
/** The field's Postgres type — drives the cast in ownerPredicate. */
|
|
101
|
+
type: string;
|
|
102
|
+
/** JWT claim holding the owner id. */
|
|
103
|
+
claim: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The owner-scoped probe spec a model declares — its schema-qualified table, owner SQL column,
|
|
108
|
+
* and JWT claim — or null when the model has no owner ability. This is what drives the
|
|
109
|
+
* owner-aware red-team (`db:authz:owner`): the Model already names the owner via `can({ owner })`,
|
|
110
|
+
* so isolation testing needs no extra declaration.
|
|
111
|
+
*/
|
|
112
|
+
export function modelOwner(model: ModelDescriptor, opts: CompileOptions = {}): { table: string; ownerColumn: string; claim: string } | null {
|
|
113
|
+
const owner = resolveOwner(model);
|
|
114
|
+
if (!owner) return null;
|
|
115
|
+
const schema = resolveSchema(model, opts);
|
|
116
|
+
return { table: `${schema}.${model.table}`, ownerColumn: owner.sqlColumn, claim: owner.claim };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The schema a model's table lives in. The model's own `schema` wins (a 'dist'
|
|
121
|
+
* model is dist in any run), then the generate call's default, then 'public'.
|
|
122
|
+
*/
|
|
123
|
+
function resolveSchema(model: ModelDescriptor, opts: CompileOptions): string {
|
|
124
|
+
return model.schema ?? opts.schema ?? 'public';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolve the owner column (SQL name + type + claim) from the first owner ability. */
|
|
128
|
+
function resolveOwner(model: ModelDescriptor): OwnerRef | null {
|
|
129
|
+
const ownerAbility = model.abilities.find((a) => a.condition.owner);
|
|
130
|
+
if (!ownerAbility) return null;
|
|
131
|
+
const fieldKey = ownerAbility.condition.owner!;
|
|
132
|
+
return {
|
|
133
|
+
sqlColumn: toSnakeCase(fieldKey),
|
|
134
|
+
type: model.fields[fieldKey]?.spec.type ?? 'uuid',
|
|
135
|
+
claim: ownerAbility.condition.userField ?? 'sub',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface ViaRef {
|
|
140
|
+
/** The child's FK column, snake_cased (e.g. `upload_id`). */
|
|
141
|
+
fkColumn: string;
|
|
142
|
+
/** The referenced parent table (e.g. `uploads`). */
|
|
143
|
+
parentTable: string;
|
|
144
|
+
/** The parent's primary-key column, snake_cased (e.g. `id`). */
|
|
145
|
+
parentPk: string;
|
|
146
|
+
/** The parent's resolved owner — the predicate scopes the subquery on this. */
|
|
147
|
+
parentOwner: OwnerRef;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Resolve the transitive-owner spec from the first `via:` ability. The named field
|
|
152
|
+
* must carry `.references()`; the compiler follows that thunk to the parent
|
|
153
|
+
* descriptor and resolves the parent's own `owner` ability. Throws a clear error
|
|
154
|
+
* when the field has no FK or the parent declares no owner — a `via:` that cannot
|
|
155
|
+
* reach an owner is a model bug, not a silently-empty policy.
|
|
156
|
+
*/
|
|
157
|
+
function resolveVia(model: ModelDescriptor): ViaRef | null {
|
|
158
|
+
const viaAbility = model.abilities.find((a) => a.condition.via);
|
|
159
|
+
if (!viaAbility) return null;
|
|
160
|
+
const fieldKey = viaAbility.condition.via!;
|
|
161
|
+
const ref = model.fields[fieldKey]?.spec.references;
|
|
162
|
+
if (!ref) {
|
|
163
|
+
throw new Error(`via: '${fieldKey}' on '${model.table}' must name a field declared with .references()`);
|
|
164
|
+
}
|
|
165
|
+
const parent = ref() as ModelDescriptor;
|
|
166
|
+
const parentOwner = resolveOwner(parent);
|
|
167
|
+
if (!parentOwner) {
|
|
168
|
+
throw new Error(`via: parent model '${parent.table}' (from '${model.table}.${fieldKey}') declares no owner ability`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
fkColumn: toSnakeCase(fieldKey),
|
|
172
|
+
parentTable: parent.table,
|
|
173
|
+
parentPk: toSnakeCase(parent.primaryKey[0] ?? 'id'),
|
|
174
|
+
parentOwner,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The conventional soft-delete column SQL name, when the model has one. */
|
|
179
|
+
function softDeleteColumn(model: ModelDescriptor): string | null {
|
|
180
|
+
return 'deletedAt' in model.fields ? 'deleted_at' : null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A column-scoped self read — `can('read', { owner, columns })`. It compiles to a COLUMN
|
|
185
|
+
* grant (`GRANT SELECT (cols)`) plus a `<table>_select_self` policy, NOT a table grant and
|
|
186
|
+
* NOT a `_select_own` policy: the row is scoped by the owner predicate, the fields by the
|
|
187
|
+
* column list (`auth.users` → id/email/role to the owner, never the password hash). It is
|
|
188
|
+
* excluded from the table-grant and owner-read paths so the two never double-emit.
|
|
189
|
+
*/
|
|
190
|
+
function isColumnRead(a: Ability): boolean {
|
|
191
|
+
return a.action === 'read' && Boolean(a.condition.owner) && Boolean(a.condition.columns?.length);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Column grants from the column-scoped read abilities: the read role (`authenticated`, since
|
|
196
|
+
* the ability is owner-scoped, not role-gated) gets `SELECT (cols)`. Field keys are snake_cased
|
|
197
|
+
* to SQL columns and sorted, matching the introspected contract. Returns undefined when no
|
|
198
|
+
* ability declares columns — an introspected contract omits the key rather than carry an empty
|
|
199
|
+
* object, so the compiled one must too (or the round-trip diff would false-fire).
|
|
200
|
+
*/
|
|
201
|
+
function compileColumnGrants(abilities: readonly Ability[]): Record<string, Record<string, string[]>> | undefined {
|
|
202
|
+
const out: Record<string, Record<string, string[]>> = {};
|
|
203
|
+
for (const a of abilities) {
|
|
204
|
+
if (!isColumnRead(a)) continue;
|
|
205
|
+
const role = a.condition.role ?? 'authenticated';
|
|
206
|
+
const cols = [...a.condition.columns!].map(toSnakeCase).sort();
|
|
207
|
+
(out[role] ??= {}).SELECT = cols;
|
|
208
|
+
}
|
|
209
|
+
return Object.keys(out).length ? out : undefined;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
// Compile.
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compile one model's `abilities` into its TableContract (rls + grants + policies).
|
|
218
|
+
* Deterministic: policies sorted by name, grant privilege lists sorted — so the
|
|
219
|
+
* output is byte-comparable with an introspected contract.
|
|
220
|
+
*/
|
|
221
|
+
export function compileTableContract(model: ModelDescriptor, opts: CompileOptions = {}): TableContract {
|
|
222
|
+
const schema = resolveSchema(model, opts);
|
|
223
|
+
const table = `${schema}.${model.table}`;
|
|
224
|
+
const owner = resolveOwner(model);
|
|
225
|
+
const via = resolveVia(model);
|
|
226
|
+
const sdCol = softDeleteColumn(model);
|
|
227
|
+
// The row-scoping predicate: a direct owner column, else a transitive `via:`
|
|
228
|
+
// parent subquery. Both drive the same owner-gated policies (select/insert/
|
|
229
|
+
// update/delete_own); only the predicate text differs.
|
|
230
|
+
const rowPred = owner
|
|
231
|
+
? ownerPredicate(owner.sqlColumn, owner.type, owner.claim)
|
|
232
|
+
: via
|
|
233
|
+
? viaPredicate(via)
|
|
234
|
+
: null;
|
|
235
|
+
const sdGuard = sdCol ? softDeleteGuard(sdCol) : null;
|
|
236
|
+
|
|
237
|
+
// A "row-scoped" condition is either a direct owner or a transitive via.
|
|
238
|
+
const rowScoped = (a: Ability): boolean => Boolean(a.condition.owner || a.condition.via);
|
|
239
|
+
|
|
240
|
+
const abilities = model.abilities;
|
|
241
|
+
const hasAdminManage = abilities.some((a) => a.action === 'manage' && a.condition.role === 'admin');
|
|
242
|
+
const hasPublicRead = abilities.some((a) => a.action === 'read' && !a.condition.role && !rowScoped(a));
|
|
243
|
+
// A column-scoped read is row-scoped but emits a self policy + column grant, not the
|
|
244
|
+
// full-row owner read — so it is excluded here and handled by its own branch.
|
|
245
|
+
const hasOwnerRead = abilities.some((a) => a.action === 'read' && rowScoped(a) && !isColumnRead(a));
|
|
246
|
+
const hasColumnRead = abilities.some(isColumnRead);
|
|
247
|
+
const canCreate = abilities.some((a) => a.action === 'create');
|
|
248
|
+
const hasUpdateOwner = abilities.some((a) => a.action === 'update' && rowScoped(a));
|
|
249
|
+
const hasDeleteOwner = abilities.some((a) => a.action === 'delete' && rowScoped(a));
|
|
250
|
+
|
|
251
|
+
const t = model.table;
|
|
252
|
+
const policies: PolicyContract[] = [];
|
|
253
|
+
const policy = (
|
|
254
|
+
name: string, command: PolicyCommand, roles: string[],
|
|
255
|
+
using: string | null, check: string | null,
|
|
256
|
+
): void => {
|
|
257
|
+
policies.push({ name, command, roles, permissive: true, using, check });
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// admin-bypass — one ALL policy, true/true.
|
|
261
|
+
if (hasAdminManage) policy(`${t}_admin`, 'ALL', ['admin'], 'true', 'true');
|
|
262
|
+
|
|
263
|
+
if (hasPublicRead) {
|
|
264
|
+
// public read on a soft-delete table — split per role; authenticated owners
|
|
265
|
+
// also see their own (soft-deleted) rows, hence the OR'd owner check.
|
|
266
|
+
const anonUsing = sdGuard ?? 'true';
|
|
267
|
+
policy(`${t}_select_anon`, 'SELECT', ['anon'], anonUsing, null);
|
|
268
|
+
|
|
269
|
+
let authedUsing = anonUsing;
|
|
270
|
+
if (sdGuard && rowPred) authedUsing = `(${sdGuard} OR ${rowPred})`;
|
|
271
|
+
else if (rowPred && !sdGuard) authedUsing = rowPred;
|
|
272
|
+
policy(`${t}_select_authenticated`, 'SELECT', ['authenticated'], authedUsing, null);
|
|
273
|
+
} else if (hasOwnerRead && rowPred) {
|
|
274
|
+
// owner-scoped read — no anon visibility; you see only the rows you own
|
|
275
|
+
// (directly, or transitively through a `via:` parent).
|
|
276
|
+
policy(`${t}_select_own`, 'SELECT', ['authenticated'], rowPred, null);
|
|
277
|
+
} else if (hasColumnRead && rowPred) {
|
|
278
|
+
// column-scoped self read — `authenticated` reads only its OWN row, and only the
|
|
279
|
+
// granted columns (the GRANT scopes fields; this policy scopes rows). Named
|
|
280
|
+
// `_select_self`, distinct from the full-row `_select_own`.
|
|
281
|
+
policy(`${t}_select_self`, 'SELECT', ['authenticated'], rowPred, null);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// owner-gated writes. INSERT checks ownership (you can only create rows you own);
|
|
285
|
+
// UPDATE gates + checks; DELETE gates. `rowPred` is the direct-owner or `via:` predicate.
|
|
286
|
+
if (rowPred) {
|
|
287
|
+
if (canCreate) policy(`${t}_insert_own`, 'INSERT', ['authenticated'], null, rowPred);
|
|
288
|
+
if (hasUpdateOwner) policy(`${t}_update_own`, 'UPDATE', ['authenticated'], rowPred, rowPred);
|
|
289
|
+
if (hasDeleteOwner) policy(`${t}_delete_own`, 'DELETE', ['authenticated'], rowPred, null);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
policies.sort((a, b) => a.name.localeCompare(b.name));
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
table,
|
|
296
|
+
// The FORCE axiom: FORCE iff the write principal is subject to its own RLS
|
|
297
|
+
// policies. 'app' writes through its policies -> FORCE; 'worker'/'functions'
|
|
298
|
+
// write on the owner connection and bypass RLS -> ENABLE-not-FORCE (on RDS the
|
|
299
|
+
// owner is not a superuser, so a FORCEd table would block the owner's own writes).
|
|
300
|
+
rls: { enabled: true, forced: model.writtenBy === 'app' },
|
|
301
|
+
grants: compileGrants(abilities),
|
|
302
|
+
...(compileColumnGrants(abilities) ? { columnGrants: compileColumnGrants(abilities) } : {}),
|
|
303
|
+
policies,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** The four DML privileges, sorted — the expansion of a `manage` ability. */
|
|
308
|
+
const CRUD = ['DELETE', 'INSERT', 'SELECT', 'UPDATE'];
|
|
309
|
+
|
|
310
|
+
/** The SQL privilege a single CRUD verb grants. `manage` expands to all of CRUD. */
|
|
311
|
+
const VERB: Record<Exclude<Ability['action'], 'manage'>, string> = {
|
|
312
|
+
read: 'SELECT',
|
|
313
|
+
create: 'INSERT',
|
|
314
|
+
update: 'UPDATE',
|
|
315
|
+
delete: 'DELETE',
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
/** The privileges an ability's action grants (a single verb, or all of CRUD for `manage`). */
|
|
319
|
+
function verbsFor(action: Ability['action']): string[] {
|
|
320
|
+
return action === 'manage' ? [...CRUD] : [VERB[action]];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Precise grants — derive each role's privilege set from the abilities, not a
|
|
325
|
+
* uniform tier. everystack's model is coarse grants + fine-grained RLS: the grant
|
|
326
|
+
* is the gate (which roles may touch the table at all), RLS restricts which rows.
|
|
327
|
+
* A role gets a privilege iff some ability grants that verb to it:
|
|
328
|
+
*
|
|
329
|
+
* - `can(action, { role })` -> that role gets the verb(s)
|
|
330
|
+
* - public/owner/via ability (no role) -> `authenticated` gets the verb(s)
|
|
331
|
+
* - a *public* read (no role, no owner/via) -> `anon` also gets SELECT
|
|
332
|
+
*
|
|
333
|
+
* No ability for a role means no grant for that role: an ability-less (internal)
|
|
334
|
+
* table grants nothing, an owner-only table grants no anon SELECT, an admin-managed
|
|
335
|
+
* table grants admin CRUD only. Deterministic: roles and privilege lists sorted,
|
|
336
|
+
* so the output is byte-comparable with an introspected contract.
|
|
337
|
+
*/
|
|
338
|
+
function compileGrants(abilities: readonly Ability[]): Record<string, string[]> {
|
|
339
|
+
const grants: Record<string, Set<string>> = {};
|
|
340
|
+
const add = (role: string, verbs: string[]): void => {
|
|
341
|
+
const set = (grants[role] ??= new Set<string>());
|
|
342
|
+
for (const v of verbs) set.add(v);
|
|
343
|
+
};
|
|
344
|
+
for (const a of abilities) {
|
|
345
|
+
// A column-scoped read grants `SELECT (cols)` (a column grant), not a table-level
|
|
346
|
+
// privilege — emitted by compileColumnGrants, skipped here so it never leaks a
|
|
347
|
+
// whole-table grant that would defeat the column scoping.
|
|
348
|
+
if (isColumnRead(a)) continue;
|
|
349
|
+
const verbs = verbsFor(a.action);
|
|
350
|
+
if (a.condition.role) {
|
|
351
|
+
add(a.condition.role, verbs);
|
|
352
|
+
} else {
|
|
353
|
+
add('authenticated', verbs);
|
|
354
|
+
// A public read (no role, not owner/via-scoped) is anon-visible.
|
|
355
|
+
if (a.action === 'read' && !a.condition.owner && !a.condition.via) add('anon', ['SELECT']);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const out: Record<string, string[]> = {};
|
|
359
|
+
for (const role of Object.keys(grants).sort()) out[role] = [...grants[role]].sort();
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Kept exported for callers that want just the privilege set for a role tier.
|
|
364
|
+
export type { Ability };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filesystem IO for the Authorization Contract — write/load the per-table files.
|
|
3
|
+
*
|
|
4
|
+
* Kept out of authz-contract.ts so the core (introspect/assemble/diff) stays pure and
|
|
5
|
+
* fs-free. The serialized form is deterministic and byte-stable: a re-pull of an
|
|
6
|
+
* unchanged database produces identical files, so a contract change is always a real,
|
|
7
|
+
* reviewable git diff and never reformatting noise.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs/promises';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import type { AuthzContract, TableContract, FunctionContract } from './authz-contract.js';
|
|
13
|
+
import { renderContractMarkdown } from './authz-render.js';
|
|
14
|
+
|
|
15
|
+
/** The human-readable review doc written alongside the JSON. */
|
|
16
|
+
export const REVIEW_FILE = 'AUTHORIZATION.md';
|
|
17
|
+
|
|
18
|
+
/** Stable JSON: object keys sorted recursively, 2-space indent, trailing newline. */
|
|
19
|
+
export function stableStringify(value: unknown): string {
|
|
20
|
+
const sortKeys = (v: any): any => {
|
|
21
|
+
if (Array.isArray(v)) return v.map(sortKeys);
|
|
22
|
+
if (v && typeof v === 'object') {
|
|
23
|
+
return Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])]));
|
|
24
|
+
}
|
|
25
|
+
return v;
|
|
26
|
+
};
|
|
27
|
+
return JSON.stringify(sortKeys(value), null, 2) + '\n';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `public.posts` -> `public.posts.contract.json`. */
|
|
31
|
+
export function tableFileName(table: string): string {
|
|
32
|
+
return `${table}.contract.json`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const FUNCTIONS_FILE = '_functions.contract.json';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Write the contract as one file per table plus `_functions.contract.json`. Prunes any
|
|
39
|
+
* stale `*.contract.json` for a table no longer in the contract, so a dropped table
|
|
40
|
+
* leaves no orphan file. The directory is created if absent.
|
|
41
|
+
*/
|
|
42
|
+
export async function writeContract(dir: string, contract: AuthzContract): Promise<string[]> {
|
|
43
|
+
await fs.mkdir(dir, { recursive: true });
|
|
44
|
+
|
|
45
|
+
const written = new Set<string>();
|
|
46
|
+
for (const table of contract.tables) {
|
|
47
|
+
const file = tableFileName(table.table);
|
|
48
|
+
await fs.writeFile(path.join(dir, file), stableStringify(table), 'utf8');
|
|
49
|
+
written.add(file);
|
|
50
|
+
}
|
|
51
|
+
await fs.writeFile(path.join(dir, FUNCTIONS_FILE), stableStringify({ functions: contract.functions }), 'utf8');
|
|
52
|
+
written.add(FUNCTIONS_FILE);
|
|
53
|
+
|
|
54
|
+
for (const existing of await listContractFiles(dir)) {
|
|
55
|
+
if (!written.has(existing)) await fs.rm(path.join(dir, existing));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The human-readable review doc, regenerated from the same contract so it can't drift.
|
|
59
|
+
await fs.writeFile(path.join(dir, REVIEW_FILE), renderContractMarkdown(contract), 'utf8');
|
|
60
|
+
written.add(REVIEW_FILE);
|
|
61
|
+
return [...written].sort();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Load the per-table files back into a contract. Missing dir -> empty contract. */
|
|
65
|
+
export async function loadContract(dir: string): Promise<AuthzContract> {
|
|
66
|
+
let files: string[];
|
|
67
|
+
try {
|
|
68
|
+
files = await listContractFiles(dir);
|
|
69
|
+
} catch {
|
|
70
|
+
return { tables: [], functions: [] };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tables: TableContract[] = [];
|
|
74
|
+
let functions: FunctionContract[] = [];
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const raw = await fs.readFile(path.join(dir, file), 'utf8');
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (file === FUNCTIONS_FILE) {
|
|
79
|
+
functions = parsed.functions ?? [];
|
|
80
|
+
} else {
|
|
81
|
+
tables.push(parsed as TableContract);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
tables.sort((a, b) => a.table.localeCompare(b.table));
|
|
85
|
+
functions.sort((a, b) => a.name.localeCompare(b.name));
|
|
86
|
+
return { tables, functions };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function listContractFiles(dir: string): Promise<string[]> {
|
|
90
|
+
const entries = await fs.readdir(dir);
|
|
91
|
+
return entries.filter((f) => f.endsWith('.contract.json'));
|
|
92
|
+
}
|