forgeos 0.1.0-alpha.2 → 0.1.0-alpha.4
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/AGENTS.md +38 -3
- package/CHANGELOG.md +29 -0
- package/README.md +25 -10
- package/package.json +8 -5
- package/src/forge/_generated/actionSubscriptions.json +2 -2
- package/src/forge/_generated/actionSubscriptions.ts +3 -3
- package/src/forge/_generated/agentAdapterManifest.json +2 -2
- package/src/forge/_generated/agentAdapterManifest.ts +3 -3
- package/src/forge/_generated/agentContract.json +2 -2
- package/src/forge/_generated/agentContract.ts +183 -50
- package/src/forge/_generated/agentQuickstart.md +3 -1
- package/src/forge/_generated/agentTools.json +2 -0
- package/src/forge/_generated/agentTools.md +16 -0
- package/src/forge/_generated/agentTools.ts +12 -0
- package/src/forge/_generated/aiContext.ts +67 -1
- package/src/forge/_generated/aiModels.json +2 -2
- package/src/forge/_generated/aiModels.ts +17 -1
- package/src/forge/_generated/aiProviders.json +1 -1
- package/src/forge/_generated/aiProviders.ts +1 -1
- package/src/forge/_generated/aiRegistry.json +2 -2
- package/src/forge/_generated/aiRegistry.ts +7 -5
- package/src/forge/_generated/api.json +2 -2
- package/src/forge/_generated/api.ts +1 -1
- package/src/forge/_generated/appGraph.json +2 -2
- package/src/forge/_generated/appGraph.ts +512 -260
- package/src/forge/_generated/appMap.md +21 -1
- package/src/forge/_generated/artifactManifest.json +2 -2
- package/src/forge/_generated/artifactManifest.ts +2 -2
- package/src/forge/_generated/authClaims.json +1 -1
- package/src/forge/_generated/authClaims.ts +1 -1
- package/src/forge/_generated/authConfig.json +1 -1
- package/src/forge/_generated/authConfig.ts +1 -1
- package/src/forge/_generated/authContext.ts +1 -1
- package/src/forge/_generated/authRegistry.json +1 -1
- package/src/forge/_generated/authRegistry.ts +1 -1
- package/src/forge/_generated/buildInfo.json +2 -2
- package/src/forge/_generated/buildInfo.ts +4 -4
- package/src/forge/_generated/capabilityMap.json +2 -2
- package/src/forge/_generated/capabilityMap.md +1 -1
- package/src/forge/_generated/capabilityMap.ts +2 -2
- package/src/forge/_generated/client.ts +1 -1
- package/src/forge/_generated/clientApi.ts +1 -1
- package/src/forge/_generated/clientManifest.json +2 -2
- package/src/forge/_generated/clientManifest.ts +3 -3
- package/src/forge/_generated/clientTypes.ts +1 -1
- package/src/forge/_generated/configRegistry.json +1 -1
- package/src/forge/_generated/configRegistry.ts +1 -1
- package/src/forge/_generated/dataGraph.json +2 -2
- package/src/forge/_generated/dataGraph.ts +3 -3
- package/src/forge/_generated/db.json +1 -1
- package/src/forge/_generated/db.ts +1 -1
- package/src/forge/_generated/dbSecurityManifest.json +1 -1
- package/src/forge/_generated/dbSecurityManifest.ts +1 -1
- package/src/forge/_generated/dbSessionContext.json +1 -1
- package/src/forge/_generated/dbSessionContext.ts +1 -1
- package/src/forge/_generated/deployManifest.json +2 -2
- package/src/forge/_generated/deployManifest.ts +7 -7
- package/src/forge/_generated/devManifest.json +2 -2
- package/src/forge/_generated/devManifest.ts +18 -3
- package/src/forge/_generated/envSchema.json +1 -1
- package/src/forge/_generated/envSchema.ts +1 -1
- package/src/forge/_generated/frontendGraph.json +1 -1
- package/src/forge/_generated/frontendGraph.ts +1 -1
- package/src/forge/_generated/importGuards.json +1 -1
- package/src/forge/_generated/importGuards.ts +1 -1
- package/src/forge/_generated/index.ts +2 -1
- package/src/forge/_generated/liveProductionManifest.json +1 -1
- package/src/forge/_generated/liveProductionManifest.ts +1 -1
- package/src/forge/_generated/liveProtocol.json +1 -1
- package/src/forge/_generated/liveProtocol.ts +1 -1
- package/src/forge/_generated/liveQueryRegistry.json +2 -2
- package/src/forge/_generated/liveQueryRegistry.ts +3 -3
- package/src/forge/_generated/liveTransportConfig.json +1 -1
- package/src/forge/_generated/liveTransportConfig.ts +1 -1
- package/src/forge/_generated/makeRegistry.json +2 -2
- package/src/forge/_generated/makeRegistry.ts +16 -2
- package/src/forge/_generated/makeTemplates.json +2 -2
- package/src/forge/_generated/makeTemplates.ts +6 -1
- package/src/forge/_generated/mockMap.json +1 -1
- package/src/forge/_generated/mockMap.ts +1 -1
- package/src/forge/_generated/operationPlaybooks.md +34 -14
- package/src/forge/_generated/packageGraph.json +2 -2
- package/src/forge/_generated/packageGraph.ts +8808 -4723
- package/src/forge/_generated/packageUpgradeRegistry.json +2 -2
- package/src/forge/_generated/packageUpgradeRegistry.ts +2 -2
- package/src/forge/_generated/permissionMatrix.json +2 -2
- package/src/forge/_generated/permissionMatrix.ts +3 -3
- package/src/forge/_generated/policyRegistry.json +2 -2
- package/src/forge/_generated/policyRegistry.ts +3 -3
- package/src/forge/_generated/queryRegistry.json +2 -2
- package/src/forge/_generated/queryRegistry.ts +3 -3
- package/src/forge/_generated/react.d.ts +1 -1
- package/src/forge/_generated/react.ts +1 -1
- package/src/forge/_generated/reactManifest.json +2 -2
- package/src/forge/_generated/reactManifest.ts +3 -3
- package/src/forge/_generated/releaseManifest.json +2 -2
- package/src/forge/_generated/releaseManifest.ts +3 -3
- package/src/forge/_generated/rlsPolicies.json +1 -1
- package/src/forge/_generated/rlsPolicies.sql +1 -1
- package/src/forge/_generated/rlsPolicies.ts +1 -1
- package/src/forge/_generated/runtimeGraph.json +2 -2
- package/src/forge/_generated/runtimeGraph.ts +3 -3
- package/src/forge/_generated/runtimeMatrix.json +2 -2
- package/src/forge/_generated/runtimeMatrix.ts +8684 -1939
- package/src/forge/_generated/runtimeRegistry.ts +1 -1
- package/src/forge/_generated/runtimeRules.md +13 -1
- package/src/forge/_generated/secretRegistry.json +1 -1
- package/src/forge/_generated/secretRegistry.ts +1 -1
- package/src/forge/_generated/secretsContext.ts +1 -1
- package/src/forge/_generated/serverApi.ts +1 -1
- package/src/forge/_generated/sourceMapManifest.json +2 -2
- package/src/forge/_generated/sourceMapManifest.ts +2 -2
- package/src/forge/_generated/sqlPlan.json +1 -1
- package/src/forge/_generated/sqlPlan.ts +1 -1
- package/src/forge/_generated/subscriptionManifest.json +2 -2
- package/src/forge/_generated/subscriptionManifest.ts +3 -3
- package/src/forge/_generated/symbolicationManifest.json +2 -2
- package/src/forge/_generated/symbolicationManifest.ts +2 -2
- package/src/forge/_generated/telemetryRegistry.json +2 -2
- package/src/forge/_generated/telemetryRegistry.ts +3 -3
- package/src/forge/_generated/telemetrySinks.json +2 -2
- package/src/forge/_generated/telemetrySinks.ts +2 -2
- package/src/forge/_generated/tenantScope.json +2 -2
- package/src/forge/_generated/tenantScope.ts +3 -3
- package/src/forge/_generated/testGraph.json +2 -2
- package/src/forge/_generated/testGraph.ts +339 -17
- package/src/forge/_generated/testPlanRegistry.json +2 -2
- package/src/forge/_generated/testPlanRegistry.ts +2 -2
- package/src/forge/_generated/uiRoutes.json +1 -1
- package/src/forge/_generated/uiRoutes.ts +1 -1
- package/src/forge/_generated/uiScenarios.json +1 -1
- package/src/forge/_generated/uiScenarios.ts +1 -1
- package/src/forge/_generated/uiTestManifest.json +2 -2
- package/src/forge/_generated/uiTestManifest.ts +2 -2
- package/src/forge/_generated/workflowRegistry.json +2 -2
- package/src/forge/_generated/workflowRegistry.ts +3 -3
- package/src/forge/_generated/workflowSubscriptions.json +2 -2
- package/src/forge/_generated/workflowSubscriptions.ts +3 -3
- package/src/forge/cli/ai.ts +351 -1
- package/src/forge/cli/auth.ts +36 -1
- package/src/forge/cli/commands.ts +19 -0
- package/src/forge/cli/parse.ts +67 -8
- package/src/forge/cli/rls.ts +529 -17
- package/src/forge/cli/secrets.ts +46 -1
- package/src/forge/cli/security.ts +269 -0
- package/src/forge/compiler/agent-contract/build.ts +289 -8
- package/src/forge/compiler/agent-contract/types.ts +43 -0
- package/src/forge/compiler/ai-registry/build.ts +62 -1
- package/src/forge/compiler/ai-registry/constants.ts +1 -1
- package/src/forge/compiler/ai-registry/parse.ts +98 -4
- package/src/forge/compiler/app-graph/forge-apis.ts +1 -0
- package/src/forge/compiler/dev-manifest/build.ts +3 -0
- package/src/forge/compiler/diagnostics/codes.ts +15 -0
- package/src/forge/compiler/diagnostics/create.ts +1 -1
- package/src/forge/compiler/make-registry/build.ts +13 -0
- package/src/forge/compiler/orchestrator/plan.ts +11 -0
- package/src/forge/compiler/orchestrator/serialize.ts +68 -0
- package/src/forge/compiler/package-graph/compiler.ts +13 -3
- package/src/forge/compiler/types/ai-registry.ts +25 -1
- package/src/forge/compiler/types/app-graph.ts +1 -0
- package/src/forge/compiler/types/cli.ts +1 -0
- package/src/forge/compiler/types/dev-manifest.ts +3 -0
- package/src/forge/dev/server.ts +508 -1
- package/src/forge/make/index.ts +126 -3
- package/src/forge/make/templates.ts +188 -0
- package/src/forge/make/types.ts +1 -0
- package/src/forge/runtime/ai/context.ts +210 -5
- package/src/forge/runtime/ai/types.ts +70 -0
- package/src/forge/runtime/auth/claims.ts +32 -0
- package/src/forge/runtime/auth/errors.ts +2 -0
- package/src/forge/runtime/context/create-context.ts +30 -6
- package/src/forge/runtime/db/memory-adapter.ts +2 -2
- package/src/forge/runtime/telemetry/scrubber.ts +56 -5
- package/src/forge/runtime/webhooks/security.ts +184 -0
- package/src/forge/server.ts +93 -0
- package/src/forge/version.ts +1 -1
- package/templates/b2b-support-web/package.json +1 -0
- package/templates/b2b-support-web/tsconfig.json +4 -1
- package/templates/minimal-web/package.json +1 -0
- package/templates/minimal-web/tsconfig.json +3 -1
package/src/forge/cli/rls.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createDiagnostic } from "../compiler/diagnostics/create.ts";
|
|
|
5
5
|
import {
|
|
6
6
|
FORGE_DB_SUPERUSER_RUNTIME,
|
|
7
7
|
FORGE_RLS_APPLY_FAILED,
|
|
8
|
+
FORGE_RLS_MUTATION_FAILED,
|
|
8
9
|
FORGE_RLS_PGLITE_NOT_AUTHORITATIVE,
|
|
9
10
|
FORGE_RLS_POLICY_MISSING,
|
|
10
11
|
FORGE_RLS_TEST_FAILED,
|
|
@@ -12,11 +13,15 @@ import {
|
|
|
12
13
|
import { GENERATED_DIR } from "../compiler/emitter/constants.ts";
|
|
13
14
|
import { stripDeterministicHeader } from "../compiler/primitives/header.ts";
|
|
14
15
|
import type { Diagnostic } from "../compiler/types/diagnostic.ts";
|
|
15
|
-
import type {
|
|
16
|
+
import type { ColumnDef, SqlPlan } from "../compiler/data-graph/sql/types.ts";
|
|
17
|
+
import { applyMigrations } from "../runtime/db/migrate.ts";
|
|
18
|
+
import type { DbAdapter, DbAdapterKind, DbTransaction } from "../runtime/db/adapter.ts";
|
|
16
19
|
import { createDbAdapter } from "../runtime/db/factory.ts";
|
|
17
20
|
import { databaseUrlUsesPostgresSuperuser } from "../runtime/db/session-context.ts";
|
|
21
|
+
import type { RlsTableSecurity } from "../compiler/data-graph/rls/types.ts";
|
|
22
|
+
import { createHash } from "node:crypto";
|
|
18
23
|
|
|
19
|
-
export type RlsSubcommand = "generate" | "check" | "apply" | "test";
|
|
24
|
+
export type RlsSubcommand = "generate" | "check" | "apply" | "test" | "mutate-test";
|
|
20
25
|
|
|
21
26
|
export interface RlsCommandOptions {
|
|
22
27
|
subcommand: RlsSubcommand;
|
|
@@ -88,6 +93,407 @@ function splitSqlStatements(sql: string): string[] {
|
|
|
88
93
|
return statements;
|
|
89
94
|
}
|
|
90
95
|
|
|
96
|
+
function quoteIdent(identifier: string): string {
|
|
97
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function stableUuid(seed: string): string {
|
|
101
|
+
const hex = createHash("sha256").update(seed).digest("hex");
|
|
102
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-8${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function enumValue(column: ColumnDef): string | null {
|
|
106
|
+
const values = column.checkConstraint?.match(/'([^']+)'/g);
|
|
107
|
+
return values?.[0]?.slice(1, -1) ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function valueForColumn(column: ColumnDef, seed: string): unknown {
|
|
111
|
+
const sqlType = column.sqlType.toLowerCase();
|
|
112
|
+
if (sqlType === "uuid") {
|
|
113
|
+
return stableUuid(`${seed}:${column.name}`);
|
|
114
|
+
}
|
|
115
|
+
if (sqlType === "boolean") {
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
if (sqlType === "integer" || sqlType === "bigint" || sqlType === "smallint") {
|
|
119
|
+
return Math.abs(Number.parseInt(createHash("sha1").update(seed).digest("hex").slice(0, 6), 16)) % 100000;
|
|
120
|
+
}
|
|
121
|
+
if (sqlType === "double precision" || sqlType === "numeric" || sqlType === "real") {
|
|
122
|
+
return 42.25;
|
|
123
|
+
}
|
|
124
|
+
if (sqlType === "jsonb" || sqlType === "json") {
|
|
125
|
+
return JSON.stringify({ forgeRlsProbe: seed });
|
|
126
|
+
}
|
|
127
|
+
if (sqlType === "timestamptz" || sqlType === "timestamp" || sqlType === "date") {
|
|
128
|
+
return "2026-01-01T00:00:00.000Z";
|
|
129
|
+
}
|
|
130
|
+
return enumValue(column) ?? `forge-rls-probe-${seed}-${column.name}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function placeholderForColumn(column: ColumnDef, index: number): string {
|
|
134
|
+
const sqlType = column.sqlType.toLowerCase();
|
|
135
|
+
const placeholder = `$${index}`;
|
|
136
|
+
if (sqlType === "uuid") {
|
|
137
|
+
return `${placeholder}::uuid`;
|
|
138
|
+
}
|
|
139
|
+
if (sqlType === "jsonb") {
|
|
140
|
+
return `${placeholder}::jsonb`;
|
|
141
|
+
}
|
|
142
|
+
if (sqlType === "json") {
|
|
143
|
+
return `${placeholder}::json`;
|
|
144
|
+
}
|
|
145
|
+
if (sqlType === "timestamptz") {
|
|
146
|
+
return `${placeholder}::timestamptz`;
|
|
147
|
+
}
|
|
148
|
+
if (sqlType === "timestamp") {
|
|
149
|
+
return `${placeholder}::timestamp`;
|
|
150
|
+
}
|
|
151
|
+
if (sqlType === "date") {
|
|
152
|
+
return `${placeholder}::date`;
|
|
153
|
+
}
|
|
154
|
+
return placeholder;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function tablePlan(plan: SqlPlan, tableName: string) {
|
|
158
|
+
return plan.tables.find((table) => table.table === tableName && table.columns);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function primaryColumn(columns: ColumnDef[]): ColumnDef {
|
|
162
|
+
return columns.find((column) => column.primaryKey) ?? columns[0]!;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildRow(
|
|
166
|
+
table: { table?: string; columns?: ColumnDef[] },
|
|
167
|
+
seed: string,
|
|
168
|
+
overrides: Record<string, unknown> = {},
|
|
169
|
+
): Record<string, unknown> {
|
|
170
|
+
const row: Record<string, unknown> = {};
|
|
171
|
+
for (const column of table.columns ?? []) {
|
|
172
|
+
row[column.name] = Object.hasOwn(overrides, column.name)
|
|
173
|
+
? overrides[column.name]
|
|
174
|
+
: valueForColumn(column, seed);
|
|
175
|
+
}
|
|
176
|
+
return row;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function insertRow(
|
|
180
|
+
tx: DbTransaction,
|
|
181
|
+
tableName: string,
|
|
182
|
+
columns: ColumnDef[],
|
|
183
|
+
row: Record<string, unknown>,
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const insertColumns = columns.filter((column) => Object.hasOwn(row, column.name));
|
|
186
|
+
const names = insertColumns.map((column) => quoteIdent(column.name)).join(", ");
|
|
187
|
+
const placeholders = insertColumns
|
|
188
|
+
.map((column, index) => placeholderForColumn(column, index + 1))
|
|
189
|
+
.join(", ");
|
|
190
|
+
const values = insertColumns.map((column) => row[column.name]);
|
|
191
|
+
await tx.query(
|
|
192
|
+
`INSERT INTO ${quoteIdent(tableName)} (${names}) VALUES (${placeholders})`,
|
|
193
|
+
values,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function setTenant(tx: DbTransaction, tenantId: string): Promise<void> {
|
|
198
|
+
await tx.query("SELECT set_config($1, $2, true)", ["forge.tenant_id", tenantId]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function setProbeRole(adapter: DbAdapter, tableNames: string[]): Promise<string | null> {
|
|
202
|
+
const roleName = "forge_rls_probe";
|
|
203
|
+
try {
|
|
204
|
+
await adapter.query(
|
|
205
|
+
`DO $$ BEGIN CREATE ROLE ${quoteIdent(roleName)} NOLOGIN NOBYPASSRLS; EXCEPTION WHEN duplicate_object THEN NULL; END $$`,
|
|
206
|
+
);
|
|
207
|
+
await adapter.query(`GRANT USAGE ON SCHEMA public TO ${quoteIdent(roleName)}`);
|
|
208
|
+
await adapter.query(`GRANT USAGE ON SCHEMA forge TO ${quoteIdent(roleName)}`);
|
|
209
|
+
for (const tableName of tableNames) {
|
|
210
|
+
await adapter.query(
|
|
211
|
+
`GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE ${quoteIdent(tableName)} TO ${quoteIdent(roleName)}`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
await adapter.query(`GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO ${quoteIdent(roleName)}`);
|
|
215
|
+
return roleName;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function seedReferencedRows(
|
|
222
|
+
tx: DbTransaction,
|
|
223
|
+
plan: SqlPlan,
|
|
224
|
+
columns: ColumnDef[],
|
|
225
|
+
row: Record<string, unknown>,
|
|
226
|
+
seed: string,
|
|
227
|
+
): Promise<void> {
|
|
228
|
+
for (const column of columns) {
|
|
229
|
+
if (!column.references) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const referenced = tablePlan(plan, column.references.table);
|
|
233
|
+
if (!referenced?.columns) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const referencedRow = buildRow(referenced, `${seed}:${column.references.table}`, {
|
|
237
|
+
[column.references.column]: row[column.name],
|
|
238
|
+
});
|
|
239
|
+
await insertRow(tx, column.references.table, referenced.columns, referencedRow);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function applyRlsSql(adapter: DbAdapter, workspaceRoot: string): Promise<number> {
|
|
244
|
+
const sql = readGeneratedText(workspaceRoot, `${GENERATED_DIR}/rlsPolicies.sql`);
|
|
245
|
+
if (!sql) {
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
const statements = splitSqlStatements(sql);
|
|
249
|
+
for (const statement of statements) {
|
|
250
|
+
await adapter.query(statement);
|
|
251
|
+
}
|
|
252
|
+
return statements.length;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface RlsProbeResult {
|
|
256
|
+
table: string;
|
|
257
|
+
role: string | null;
|
|
258
|
+
tenantAVisible: number;
|
|
259
|
+
tenantBVisible: number;
|
|
260
|
+
unscopedVisible: number;
|
|
261
|
+
crossTenantUpdateBlocked: boolean;
|
|
262
|
+
crossTenantDeleteBlocked: boolean;
|
|
263
|
+
mismatchedInsertBlocked: boolean;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function runTableProbe(
|
|
267
|
+
adapter: DbAdapter,
|
|
268
|
+
plan: SqlPlan,
|
|
269
|
+
table: RlsTableSecurity,
|
|
270
|
+
role: string | null,
|
|
271
|
+
): Promise<RlsProbeResult> {
|
|
272
|
+
const change = tablePlan(plan, table.table);
|
|
273
|
+
const columns = change?.columns ?? [];
|
|
274
|
+
const primary = primaryColumn(columns);
|
|
275
|
+
const tenantA = table.tenantType === "uuid" ? "11111111-1111-4111-8111-111111111111" : "tenant-a";
|
|
276
|
+
const tenantB = table.tenantType === "uuid" ? "22222222-2222-4222-8222-222222222222" : "tenant-b";
|
|
277
|
+
const tenantColumn = columns.find((column) => column.name === table.tenantColumn);
|
|
278
|
+
const mutableColumn = columns.find(
|
|
279
|
+
(column) => !column.primaryKey && column.name !== table.tenantColumn,
|
|
280
|
+
);
|
|
281
|
+
if (!tenantColumn) {
|
|
282
|
+
throw new Error(`missing tenant column ${table.table}.${table.tenantColumn}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const rowA = buildRow(change!, `${table.table}:a`, {
|
|
286
|
+
[table.tenantColumn]: tenantA,
|
|
287
|
+
[primary.name]: valueForColumn(primary, `${table.table}:a:${primary.name}`),
|
|
288
|
+
});
|
|
289
|
+
const rowB = buildRow(change!, `${table.table}:b`, {
|
|
290
|
+
[table.tenantColumn]: tenantB,
|
|
291
|
+
[primary.name]: valueForColumn(primary, `${table.table}:b:${primary.name}`),
|
|
292
|
+
});
|
|
293
|
+
const mismatchRow = buildRow(change!, `${table.table}:mismatch`, {
|
|
294
|
+
[table.tenantColumn]: tenantB,
|
|
295
|
+
[primary.name]: valueForColumn(primary, `${table.table}:mismatch:${primary.name}`),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const tx = await adapter.begin();
|
|
299
|
+
try {
|
|
300
|
+
await seedReferencedRows(tx, plan, columns, rowA, `${table.table}:a`);
|
|
301
|
+
await seedReferencedRows(tx, plan, columns, rowB, `${table.table}:b`);
|
|
302
|
+
await seedReferencedRows(tx, plan, columns, mismatchRow, `${table.table}:mismatch`);
|
|
303
|
+
if (role) {
|
|
304
|
+
await tx.query(`SET ROLE ${quoteIdent(role)}`);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
await setTenant(tx, tenantA);
|
|
308
|
+
await insertRow(tx, table.table, columns, rowA);
|
|
309
|
+
await setTenant(tx, tenantB);
|
|
310
|
+
await insertRow(tx, table.table, columns, rowB);
|
|
311
|
+
|
|
312
|
+
await setTenant(tx, tenantA);
|
|
313
|
+
const tenantAVisible = await tx.query(
|
|
314
|
+
`SELECT ${quoteIdent(primary.name)} FROM ${quoteIdent(table.table)} WHERE ${quoteIdent(primary.name)} IN (${placeholderForColumn(primary, 1)}, ${placeholderForColumn(primary, 2)})`,
|
|
315
|
+
[rowA[primary.name], rowB[primary.name]],
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await setTenant(tx, tenantB);
|
|
319
|
+
const tenantBVisible = await tx.query(
|
|
320
|
+
`SELECT ${quoteIdent(primary.name)} FROM ${quoteIdent(table.table)} WHERE ${quoteIdent(primary.name)} IN (${placeholderForColumn(primary, 1)}, ${placeholderForColumn(primary, 2)})`,
|
|
321
|
+
[rowA[primary.name], rowB[primary.name]],
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
await setTenant(tx, "");
|
|
325
|
+
const unscopedVisible = await tx.query(
|
|
326
|
+
`SELECT ${quoteIdent(primary.name)} FROM ${quoteIdent(table.table)} WHERE ${quoteIdent(primary.name)} IN (${placeholderForColumn(primary, 1)}, ${placeholderForColumn(primary, 2)})`,
|
|
327
|
+
[rowA[primary.name], rowB[primary.name]],
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
await setTenant(tx, tenantA);
|
|
331
|
+
let crossTenantUpdateBlocked = true;
|
|
332
|
+
if (mutableColumn) {
|
|
333
|
+
const updated = await tx.query(
|
|
334
|
+
`UPDATE ${quoteIdent(table.table)} SET ${quoteIdent(mutableColumn.name)} = ${placeholderForColumn(mutableColumn, 1)} WHERE ${quoteIdent(primary.name)} = ${placeholderForColumn(primary, 2)} RETURNING ${quoteIdent(primary.name)}`,
|
|
335
|
+
[valueForColumn(mutableColumn, `${table.table}:updated`), rowB[primary.name]],
|
|
336
|
+
);
|
|
337
|
+
crossTenantUpdateBlocked = updated.rows.length === 0;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const deleted = await tx.query(
|
|
341
|
+
`DELETE FROM ${quoteIdent(table.table)} WHERE ${quoteIdent(primary.name)} = ${placeholderForColumn(primary, 1)} RETURNING ${quoteIdent(primary.name)}`,
|
|
342
|
+
[rowB[primary.name]],
|
|
343
|
+
);
|
|
344
|
+
const crossTenantDeleteBlocked = deleted.rows.length === 0;
|
|
345
|
+
|
|
346
|
+
let mismatchedInsertBlocked = false;
|
|
347
|
+
try {
|
|
348
|
+
await insertRow(tx, table.table, columns, mismatchRow);
|
|
349
|
+
} catch {
|
|
350
|
+
mismatchedInsertBlocked = true;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return {
|
|
354
|
+
table: table.table,
|
|
355
|
+
role,
|
|
356
|
+
tenantAVisible: tenantAVisible.rows.length,
|
|
357
|
+
tenantBVisible: tenantBVisible.rows.length,
|
|
358
|
+
unscopedVisible: unscopedVisible.rows.length,
|
|
359
|
+
crossTenantUpdateBlocked,
|
|
360
|
+
crossTenantDeleteBlocked,
|
|
361
|
+
mismatchedInsertBlocked,
|
|
362
|
+
};
|
|
363
|
+
} finally {
|
|
364
|
+
await tx.rollback();
|
|
365
|
+
if (role) {
|
|
366
|
+
await adapter.query("RESET ROLE").catch(() => undefined);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function runRlsIsolationTests(options: RlsCommandOptions): Promise<RlsCommandResult> {
|
|
372
|
+
const checked = checkGeneratedArtifacts(options);
|
|
373
|
+
if (!checked.ok) {
|
|
374
|
+
return {
|
|
375
|
+
...checked,
|
|
376
|
+
diagnostics: [
|
|
377
|
+
...checked.diagnostics,
|
|
378
|
+
createDiagnostic({
|
|
379
|
+
severity: "error",
|
|
380
|
+
code: FORGE_RLS_TEST_FAILED,
|
|
381
|
+
message: "RLS structural check failed before database isolation test",
|
|
382
|
+
}),
|
|
383
|
+
],
|
|
384
|
+
exitCode: 1,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const plan = readGeneratedJson<SqlPlan>(options.workspaceRoot, `${GENERATED_DIR}/sqlPlan.json`);
|
|
389
|
+
const manifest = readGeneratedJson<{
|
|
390
|
+
tables?: RlsTableSecurity[];
|
|
391
|
+
}>(options.workspaceRoot, `${GENERATED_DIR}/dbSecurityManifest.json`);
|
|
392
|
+
const scopedTables = manifest?.tables ?? [];
|
|
393
|
+
|
|
394
|
+
if (!plan) {
|
|
395
|
+
return {
|
|
396
|
+
ok: false,
|
|
397
|
+
diagnostics: [
|
|
398
|
+
...checked.diagnostics,
|
|
399
|
+
createDiagnostic({
|
|
400
|
+
severity: "error",
|
|
401
|
+
code: FORGE_RLS_TEST_FAILED,
|
|
402
|
+
message: `missing ${GENERATED_DIR}/sqlPlan.json; run forge generate first`,
|
|
403
|
+
}),
|
|
404
|
+
],
|
|
405
|
+
exitCode: 1,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const { adapter, diagnostics } = await createDbAdapter({
|
|
410
|
+
kind: options.db,
|
|
411
|
+
workspaceRoot: options.workspaceRoot,
|
|
412
|
+
databaseUrl: options.databaseUrl,
|
|
413
|
+
});
|
|
414
|
+
if (!adapter) {
|
|
415
|
+
return {
|
|
416
|
+
ok: false,
|
|
417
|
+
diagnostics: [...checked.diagnostics, ...diagnostics],
|
|
418
|
+
exitCode: 1,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
try {
|
|
423
|
+
const migrationDiagnostics = await applyMigrations(adapter, plan);
|
|
424
|
+
const migrationErrors = migrationDiagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
425
|
+
if (migrationErrors.length > 0) {
|
|
426
|
+
return {
|
|
427
|
+
ok: false,
|
|
428
|
+
diagnostics: [...checked.diagnostics, ...diagnostics, ...migrationDiagnostics],
|
|
429
|
+
exitCode: 1,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const appliedStatements = await applyRlsSql(adapter, options.workspaceRoot);
|
|
434
|
+
const role = await setProbeRole(
|
|
435
|
+
adapter,
|
|
436
|
+
[...new Set(plan.tables.map((table) => table.table).filter((table): table is string => Boolean(table)))],
|
|
437
|
+
);
|
|
438
|
+
const probes: RlsProbeResult[] = [];
|
|
439
|
+
const failures: Diagnostic[] = [];
|
|
440
|
+
|
|
441
|
+
for (const table of scopedTables) {
|
|
442
|
+
const probe = await runTableProbe(adapter, plan, table, role);
|
|
443
|
+
probes.push(probe);
|
|
444
|
+
if (
|
|
445
|
+
probe.tenantAVisible !== 1 ||
|
|
446
|
+
probe.tenantBVisible !== 1 ||
|
|
447
|
+
probe.unscopedVisible !== 0 ||
|
|
448
|
+
!probe.crossTenantUpdateBlocked ||
|
|
449
|
+
!probe.crossTenantDeleteBlocked ||
|
|
450
|
+
!probe.mismatchedInsertBlocked
|
|
451
|
+
) {
|
|
452
|
+
failures.push(
|
|
453
|
+
createDiagnostic({
|
|
454
|
+
severity: "error",
|
|
455
|
+
code: FORGE_RLS_TEST_FAILED,
|
|
456
|
+
message: `RLS adversarial probe failed for table '${table.table}'`,
|
|
457
|
+
}),
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
ok: failures.length === 0,
|
|
464
|
+
data: {
|
|
465
|
+
structural: checked.ok,
|
|
466
|
+
appliedStatements,
|
|
467
|
+
role,
|
|
468
|
+
probes,
|
|
469
|
+
},
|
|
470
|
+
diagnostics: [
|
|
471
|
+
...checked.diagnostics,
|
|
472
|
+
...diagnostics,
|
|
473
|
+
...migrationDiagnostics,
|
|
474
|
+
...failures,
|
|
475
|
+
],
|
|
476
|
+
exitCode: failures.length === 0 ? 0 : 1,
|
|
477
|
+
};
|
|
478
|
+
} catch (error) {
|
|
479
|
+
return {
|
|
480
|
+
ok: false,
|
|
481
|
+
diagnostics: [
|
|
482
|
+
...checked.diagnostics,
|
|
483
|
+
...diagnostics,
|
|
484
|
+
createDiagnostic({
|
|
485
|
+
severity: "error",
|
|
486
|
+
code: FORGE_RLS_TEST_FAILED,
|
|
487
|
+
message: error instanceof Error ? error.message : "RLS adversarial probe failed",
|
|
488
|
+
}),
|
|
489
|
+
],
|
|
490
|
+
exitCode: 1,
|
|
491
|
+
};
|
|
492
|
+
} finally {
|
|
493
|
+
await adapter.close();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
91
497
|
function dbWarnings(options: RlsCommandOptions): Diagnostic[] {
|
|
92
498
|
const diagnostics: Diagnostic[] = [];
|
|
93
499
|
|
|
@@ -158,6 +564,119 @@ function checkGeneratedArtifacts(options: RlsCommandOptions): RlsCommandResult {
|
|
|
158
564
|
};
|
|
159
565
|
}
|
|
160
566
|
|
|
567
|
+
function manifestCoverageErrors(
|
|
568
|
+
manifest: { tables?: Array<{ table: string; forceRowLevelSecurity?: boolean; policies?: unknown[] }> } | null,
|
|
569
|
+
): string[] {
|
|
570
|
+
const errors: string[] = [];
|
|
571
|
+
for (const table of manifest?.tables ?? []) {
|
|
572
|
+
if (!table.forceRowLevelSecurity || (table.policies?.length ?? 0) < 4) {
|
|
573
|
+
errors.push(`table '${table.table}' is missing complete FORCE RLS policy coverage`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
return errors;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function sqlHasUnsafeRlsPredicate(sql: string): boolean {
|
|
580
|
+
return /\bUSING\s*\(\s*true\s*\)/i.test(sql) || /\bWITH\s+CHECK\s*\(\s*true\s*\)/i.test(sql);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function sqlUsesBypassRls(sql: string): boolean {
|
|
584
|
+
return /\bBYPASSRLS\b/i.test(sql);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function mutation(id: string, description: string, killed: boolean) {
|
|
588
|
+
return {
|
|
589
|
+
id,
|
|
590
|
+
description,
|
|
591
|
+
killed,
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function runRlsMutationTests(options: RlsCommandOptions): RlsCommandResult {
|
|
596
|
+
const checked = checkGeneratedArtifacts(options);
|
|
597
|
+
if (!checked.ok) {
|
|
598
|
+
return checked;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const manifest = readGeneratedJson<{
|
|
602
|
+
tables?: Array<{ table: string; forceRowLevelSecurity?: boolean; policies?: unknown[] }>;
|
|
603
|
+
}>(options.workspaceRoot, `${GENERATED_DIR}/dbSecurityManifest.json`);
|
|
604
|
+
const sql = readGeneratedText(options.workspaceRoot, `${GENERATED_DIR}/rlsPolicies.sql`) ?? "";
|
|
605
|
+
const firstTable = manifest?.tables?.[0];
|
|
606
|
+
const mutations: Array<ReturnType<typeof mutation>> = [];
|
|
607
|
+
|
|
608
|
+
if (firstTable) {
|
|
609
|
+
mutations.push(
|
|
610
|
+
mutation(
|
|
611
|
+
"force-rls-removed",
|
|
612
|
+
"disable FORCE ROW LEVEL SECURITY on a tenant-scoped table",
|
|
613
|
+
manifestCoverageErrors({
|
|
614
|
+
tables: [
|
|
615
|
+
...((manifest?.tables ?? []).slice(0, 0)),
|
|
616
|
+
{ ...firstTable, forceRowLevelSecurity: false },
|
|
617
|
+
...((manifest?.tables ?? []).slice(1)),
|
|
618
|
+
],
|
|
619
|
+
}).length > 0,
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
mutations.push(
|
|
624
|
+
mutation(
|
|
625
|
+
"policy-removed",
|
|
626
|
+
"remove one generated RLS policy from a tenant-scoped table",
|
|
627
|
+
manifestCoverageErrors({
|
|
628
|
+
tables: [
|
|
629
|
+
{ ...firstTable, policies: (firstTable.policies ?? []).slice(1) },
|
|
630
|
+
...((manifest?.tables ?? []).slice(1)),
|
|
631
|
+
],
|
|
632
|
+
}).length > 0,
|
|
633
|
+
),
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
mutations.push(
|
|
638
|
+
mutation(
|
|
639
|
+
"unsafe-using-true",
|
|
640
|
+
"replace an RLS USING predicate with an unconditional predicate",
|
|
641
|
+
sqlHasUnsafeRlsPredicate(`${sql}\nCREATE POLICY forge_mutant ON tickets USING (true);`),
|
|
642
|
+
),
|
|
643
|
+
);
|
|
644
|
+
mutations.push(
|
|
645
|
+
mutation(
|
|
646
|
+
"unsafe-with-check-true",
|
|
647
|
+
"replace an RLS WITH CHECK predicate with an unconditional predicate",
|
|
648
|
+
sqlHasUnsafeRlsPredicate(`${sql}\nCREATE POLICY forge_mutant ON tickets WITH CHECK (true);`),
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
mutations.push(
|
|
652
|
+
mutation(
|
|
653
|
+
"bypassrls-role",
|
|
654
|
+
"grant a runtime role BYPASSRLS",
|
|
655
|
+
sqlUsesBypassRls(`${sql}\nALTER ROLE forge_runtime BYPASSRLS;`),
|
|
656
|
+
),
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
const survivors = mutations.filter((item) => !item.killed);
|
|
660
|
+
const failures = survivors.map((item) =>
|
|
661
|
+
createDiagnostic({
|
|
662
|
+
severity: "error",
|
|
663
|
+
code: FORGE_RLS_MUTATION_FAILED,
|
|
664
|
+
message: `RLS mutation survived: ${item.id}`,
|
|
665
|
+
}),
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
ok: failures.length === 0,
|
|
670
|
+
data: {
|
|
671
|
+
kind: "rls-mutation-proof",
|
|
672
|
+
structural: true,
|
|
673
|
+
mutations,
|
|
674
|
+
},
|
|
675
|
+
diagnostics: [...checked.diagnostics, ...failures],
|
|
676
|
+
exitCode: failures.length === 0 ? 0 : 1,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
|
|
161
680
|
async function applyRls(options: RlsCommandOptions): Promise<RlsCommandResult> {
|
|
162
681
|
const checked = checkGeneratedArtifacts(options);
|
|
163
682
|
if (!checked.ok) {
|
|
@@ -265,6 +784,10 @@ export async function runRlsCommand(options: RlsCommandOptions): Promise<RlsComm
|
|
|
265
784
|
return applyRls(options);
|
|
266
785
|
}
|
|
267
786
|
|
|
787
|
+
if (options.subcommand === "mutate-test") {
|
|
788
|
+
return runRlsMutationTests(options);
|
|
789
|
+
}
|
|
790
|
+
|
|
268
791
|
const checked = checkGeneratedArtifacts(options);
|
|
269
792
|
if (options.db !== "postgres") {
|
|
270
793
|
return {
|
|
@@ -275,21 +798,7 @@ export async function runRlsCommand(options: RlsCommandOptions): Promise<RlsComm
|
|
|
275
798
|
};
|
|
276
799
|
}
|
|
277
800
|
|
|
278
|
-
return
|
|
279
|
-
ok: checked.ok,
|
|
280
|
-
data: { structural: checked.ok },
|
|
281
|
-
diagnostics: checked.ok
|
|
282
|
-
? checked.diagnostics
|
|
283
|
-
: [
|
|
284
|
-
...checked.diagnostics,
|
|
285
|
-
createDiagnostic({
|
|
286
|
-
severity: "error",
|
|
287
|
-
code: FORGE_RLS_TEST_FAILED,
|
|
288
|
-
message: "RLS structural check failed before database isolation test",
|
|
289
|
-
}),
|
|
290
|
-
],
|
|
291
|
-
exitCode: checked.ok ? 0 : 1,
|
|
292
|
-
};
|
|
801
|
+
return runRlsIsolationTests(options);
|
|
293
802
|
}
|
|
294
803
|
|
|
295
804
|
export function formatRlsJson(result: RlsCommandResult): string {
|
|
@@ -318,5 +827,8 @@ export function formatRlsHuman(subcommand: RlsSubcommand, result: RlsCommandResu
|
|
|
318
827
|
if (subcommand === "test") {
|
|
319
828
|
return `rls checks passed${suffix}`;
|
|
320
829
|
}
|
|
830
|
+
if (subcommand === "mutate-test") {
|
|
831
|
+
return `rls mutation checks passed${suffix}`;
|
|
832
|
+
}
|
|
321
833
|
return `rls contract is up to date${suffix}`;
|
|
322
834
|
}
|
package/src/forge/cli/secrets.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
import { getRuntimeEnvStore, initializeRuntimeEnv } from "../runtime/context/create-context.ts";
|
|
10
10
|
import { redactSecretValue } from "../runtime/secrets/env-loader.ts";
|
|
11
11
|
|
|
12
|
-
export type SecretsSubcommand = "list" | "check" | "print" | "set" | "unset";
|
|
12
|
+
export type SecretsSubcommand = "list" | "check" | "print" | "set" | "unset" | "prove";
|
|
13
13
|
|
|
14
14
|
export interface SecretsCommandOptions {
|
|
15
15
|
subcommand: SecretsSubcommand;
|
|
@@ -90,6 +90,51 @@ export async function runSecretsCommand(
|
|
|
90
90
|
const result = checkSecrets(store, registry);
|
|
91
91
|
return { exitCode: result.ok ? 0 : 1, data: result };
|
|
92
92
|
}
|
|
93
|
+
case "prove": {
|
|
94
|
+
if (!registry) {
|
|
95
|
+
return {
|
|
96
|
+
exitCode: 1,
|
|
97
|
+
diagnostics: [
|
|
98
|
+
createDiagnostic({
|
|
99
|
+
severity: "error",
|
|
100
|
+
code: "FORGE_INSPECT_MISSING",
|
|
101
|
+
message: "missing secretRegistry.json; run forge generate first",
|
|
102
|
+
}),
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const store = getRuntimeEnvStore(options.workspaceRoot);
|
|
108
|
+
const result = checkSecrets(store, registry);
|
|
109
|
+
return {
|
|
110
|
+
exitCode: result.ok ? 0 : 1,
|
|
111
|
+
data: {
|
|
112
|
+
schemaVersion: "0.1.0",
|
|
113
|
+
kind: "secrets-proof",
|
|
114
|
+
ok: result.ok,
|
|
115
|
+
invariants: [
|
|
116
|
+
{
|
|
117
|
+
id: "INV-008",
|
|
118
|
+
name: "secret values are not emitted by the proof",
|
|
119
|
+
status: "passed",
|
|
120
|
+
evidence: "only names, missing names, and redacted presence markers are returned",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "INV-008-REQUIRED",
|
|
124
|
+
name: "required secrets are configured",
|
|
125
|
+
status: result.ok ? "passed" : "failed",
|
|
126
|
+
evidence: {
|
|
127
|
+
missing: result.missing,
|
|
128
|
+
present: result.present.map((entry) => ({
|
|
129
|
+
name: entry.name,
|
|
130
|
+
redacted: entry.redacted,
|
|
131
|
+
})),
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
93
138
|
case "print": {
|
|
94
139
|
if (!registry) {
|
|
95
140
|
return { exitCode: 1, data: { secrets: [] } };
|