@starci/hfs 4.0.10 → 4.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1 -1
- package/emit/operations.mjs +1 -1
- package/emit/type-schema.mjs +2 -1
- package/lint/run.mjs +3 -2
- package/package.json +1 -1
- package/report/order.mjs +2 -0
- package/runtime/config.example.yaml +187 -0
- package/runtime/engine/by-code-unit.mjs +12 -0
- package/runtime/engine/config.mjs +221 -239
- package/runtime/engine/invalid-config.mjs +5 -5
- package/runtime/engine/model-config.mjs +90 -0
- package/runtime/engine/orca-config.mjs +3 -1
- package/runtime/engine/removed-vocabulary.mjs +28 -0
- package/runtime/engine/resources-config.mjs +52 -0
- package/runtime/engine/runtime-root.mjs +17 -0
- package/runtime/engine/secrets.mjs +35 -31
- package/runtime/engine/sonar-config.mjs +21 -0
- package/runtime/engine/temp-root.mjs +32 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +14 -14
- package/runtime/knowledge/hfs/peer-integrations.yaml +2 -3
- package/runtime/knowledge/hfs/rules.yaml +98 -65
- package/runtime/knowledge/hfs/slots.yaml +11 -14
- package/runtime/knowledge/patterns/be/api.yaml +7 -24
- package/runtime/knowledge/patterns/be/cli.yaml +3 -12
- package/runtime/knowledge/patterns/be/realtime.yaml +1 -14
- package/runtime/knowledge/patterns/be/webhooks.yaml +0 -19
- package/runtime/modules/kernel/failure-codes.yaml +1 -41
- package/runtime/modules/kernel/removed-vocabulary.yaml +168 -0
- package/runtime/modules/models/registry.yaml +25 -359
- package/runtime/modules/models/runtimes.yaml +92 -256
- package/runtime/modules/models/tiers.yaml +65 -0
- package/runtime/scripts/api/fs/claim-file.mjs +30 -0
- package/runtime/scripts/api/fs/ensure-temp-root.mjs +27 -0
- package/runtime/scripts/api/fs/forbidden-root.mjs +2 -1
- package/runtime/scripts/api/fs/make-temp-dir.mjs +11 -0
- package/runtime/scripts/api/fs/safe-remove.mjs +52 -35
- package/runtime/scripts/api/git/lib.mjs +34 -1
- package/runtime/scripts/api/process/resolve-real-tool.mjs +14 -8
- package/runtime/scripts/api/process/run-program.mjs +2 -1
- package/runtime/scripts/api/sops/lib.mjs +198 -120
- package/runtime/scripts/hfs/allows.mjs +3 -3
- package/runtime/scripts/hfs/architecture/ast-walks.mjs +63 -50
- package/runtime/scripts/hfs/architecture/automatic-gates.mjs +96 -0
- package/runtime/scripts/hfs/architecture/backend.mjs +261 -175
- package/runtime/scripts/hfs/architecture/background-unowned.mjs +69 -34
- package/runtime/scripts/hfs/architecture/client-reaches-server.mjs +55 -54
- package/runtime/scripts/hfs/architecture/clones.mjs +179 -106
- package/runtime/scripts/hfs/architecture/config-unread.mjs +5 -22
- package/runtime/scripts/hfs/architecture/config.mjs +95 -80
- package/runtime/scripts/hfs/architecture/connection-map.mjs +233 -141
- package/runtime/scripts/hfs/architecture/constructor-deps.mjs +15 -9
- package/runtime/scripts/hfs/architecture/context-coupling.mjs +76 -52
- package/runtime/scripts/hfs/architecture/context-map.mjs +215 -141
- package/runtime/scripts/hfs/architecture/context-owner.mjs +39 -30
- package/runtime/scripts/hfs/architecture/context-platform-tables.mjs +31 -17
- package/runtime/scripts/hfs/architecture/context-transaction.mjs +38 -25
- package/runtime/scripts/hfs/architecture/contract-fixture-guard.mjs +86 -40
- package/runtime/scripts/hfs/architecture/contracts-readonly.mjs +342 -0
- package/runtime/scripts/hfs/architecture/contracts.mjs +263 -455
- package/runtime/scripts/hfs/architecture/cross-app-duplicate.mjs +43 -34
- package/runtime/scripts/hfs/architecture/dead-exports.mjs +201 -154
- package/runtime/scripts/hfs/architecture/default-deny.mjs +118 -93
- package/runtime/scripts/hfs/architecture/doc-language.mjs +2 -1
- package/runtime/scripts/hfs/architecture/entrypoint.mjs +45 -30
- package/runtime/scripts/hfs/architecture/error-codes.mjs +19 -12
- package/runtime/scripts/hfs/architecture/error-masked.mjs +39 -26
- package/runtime/scripts/hfs/architecture/fe-slot-allows.mjs +2 -1
- package/runtime/scripts/hfs/architecture/feature-shape.mjs +26 -20
- package/runtime/scripts/hfs/architecture/framework-pinned.mjs +1 -1
- package/runtime/scripts/hfs/architecture/frontend-grammar.mjs +190 -0
- package/runtime/scripts/hfs/architecture/frontend-routing.mjs +186 -0
- package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +311 -0
- package/runtime/scripts/hfs/architecture/frontend-world.mjs +328 -0
- package/runtime/scripts/hfs/architecture/frontend.mjs +109 -829
- package/runtime/scripts/hfs/architecture/hfs-graph.mjs +14 -2
- package/runtime/scripts/hfs/architecture/hfs.mjs +120 -288
- package/runtime/scripts/hfs/architecture/hooks-are-hooks.mjs +46 -26
- package/runtime/scripts/hfs/architecture/i18n-keys.mjs +114 -76
- package/runtime/scripts/hfs/architecture/index.mjs +158 -102
- package/runtime/scripts/hfs/architecture/injection-token-exported.mjs +33 -28
- package/runtime/scripts/hfs/architecture/machine-ast.mjs +180 -144
- package/runtime/scripts/hfs/architecture/managed-scripts.mjs +8 -2
- package/runtime/scripts/hfs/architecture/module-per-transport.mjs +150 -101
- package/runtime/scripts/hfs/architecture/next-data-contract.mjs +51 -43
- package/runtime/scripts/hfs/architecture/next-data-key.mjs +338 -0
- package/runtime/scripts/hfs/architecture/next-data.mjs +179 -427
- package/runtime/scripts/hfs/architecture/owners.mjs +27 -13
- package/runtime/scripts/hfs/architecture/package-shape.mjs +51 -23
- package/runtime/scripts/hfs/architecture/presentation.mjs +165 -0
- package/runtime/scripts/hfs/architecture/reachability.mjs +127 -61
- package/runtime/scripts/hfs/architecture/register-once.mjs +112 -63
- package/runtime/scripts/hfs/architecture/registration.mjs +187 -110
- package/runtime/scripts/hfs/architecture/required-files.mjs +100 -68
- package/runtime/scripts/hfs/architecture/route-files-thin.mjs +83 -58
- package/runtime/scripts/hfs/architecture/schema-owner.mjs +182 -108
- package/runtime/scripts/hfs/architecture/source-names-shape.mjs +318 -0
- package/runtime/scripts/hfs/architecture/source-names.mjs +83 -248
- package/runtime/scripts/hfs/architecture/sql-owner.mjs +175 -118
- package/runtime/scripts/hfs/architecture/sql-returning.mjs +39 -28
- package/runtime/scripts/hfs/architecture/sql-tokens.mjs +302 -172
- package/runtime/scripts/hfs/architecture/supabase-ast.mjs +1 -1
- package/runtime/scripts/hfs/architecture/supabase-be.mjs +55 -45
- package/runtime/scripts/hfs/architecture/supabase-results.mjs +182 -0
- package/runtime/scripts/hfs/architecture/supabase-tables.mjs +20 -12
- package/runtime/scripts/hfs/architecture/supabase.mjs +191 -263
- package/runtime/scripts/hfs/architecture/symbols.mjs +175 -99
- package/runtime/scripts/hfs/architecture/test-world-files.mjs +82 -41
- package/runtime/scripts/hfs/architecture/tiers.mjs +120 -69
- package/runtime/scripts/hfs/architecture/transport-owner.mjs +84 -57
- package/runtime/scripts/hfs/architecture/type-context.mjs +282 -0
- package/runtime/scripts/hfs/architecture/typescript.mjs +116 -276
- package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +84 -62
- package/runtime/scripts/hfs/architecture.mjs +3 -4
- package/runtime/scripts/hfs/check.mjs +61 -70
- package/runtime/scripts/hfs/coverage-scope.mjs +84 -38
- package/runtime/scripts/hfs/declaration-shape.mjs +77 -36
- package/runtime/scripts/hfs/declaration-slots.mjs +1 -1
- package/runtime/scripts/hfs/edition-slots.mjs +5 -3
- package/runtime/scripts/hfs/linear-text.mjs +30 -0
- package/runtime/scripts/hfs/manifest-shape.mjs +164 -63
- package/runtime/scripts/hfs/path-findings.mjs +58 -38
- package/runtime/scripts/hfs/pin-findings.mjs +31 -0
- package/runtime/scripts/hfs/repo-identity.mjs +5 -2
- package/runtime/scripts/hfs/rule-catalog.mjs +194 -0
- package/runtime/scripts/hfs/rule-params-shape.mjs +33 -27
- package/runtime/scripts/hfs/rules/cli.mjs +5 -1
- package/runtime/scripts/hfs/rules/contract-compat.mjs +40 -22
- package/runtime/scripts/hfs/rules/contract.mjs +35 -27
- package/runtime/scripts/hfs/rules/database-config.mjs +57 -32
- package/runtime/scripts/hfs/rules/database-migrations.mjs +52 -31
- package/runtime/scripts/hfs/rules/database-plpgsql.mjs +106 -91
- package/runtime/scripts/hfs/rules/database-sql.mjs +254 -173
- package/runtime/scripts/hfs/rules/database.mjs +36 -23
- package/runtime/scripts/hfs/rules/deps.mjs +64 -32
- package/runtime/scripts/hfs/rules/docker.mjs +113 -65
- package/runtime/scripts/hfs/rules/edition.mjs +118 -111
- package/runtime/scripts/hfs/rules/event-bus.mjs +91 -45
- package/runtime/scripts/hfs/rules/fe-contract-documents.mjs +17 -16
- package/runtime/scripts/hfs/rules/fe-no-tests.mjs +36 -16
- package/runtime/scripts/hfs/rules/frontend-tree.mjs +4 -3
- package/runtime/scripts/hfs/rules/integration-specs.mjs +75 -41
- package/runtime/scripts/hfs/rules/kinds.mjs +59 -25
- package/runtime/scripts/hfs/rules/lint-suppression.mjs +9 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +82 -45
- package/runtime/scripts/hfs/rules/peer-integrations.mjs +24 -3
- package/runtime/scripts/hfs/rules/pipeline.mjs +58 -9
- package/runtime/scripts/hfs/rules/proof-commands.mjs +14 -8
- package/runtime/scripts/hfs/rules/repo-local-checks.mjs +11 -6
- package/runtime/scripts/hfs/rules/saga.mjs +128 -61
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -3
- package/runtime/scripts/hfs/rules/services.mjs +34 -16
- package/runtime/scripts/hfs/rules/stacks.mjs +21 -14
- package/runtime/scripts/hfs/rules/supabase-secrets.mjs +32 -23
- package/runtime/scripts/hfs/rules/test-topology.mjs +25 -11
- package/runtime/scripts/hfs/secret.mjs +59 -48
- package/runtime/scripts/hfs/slot-app-view.mjs +102 -0
- package/runtime/scripts/hfs/slot-classify.mjs +65 -0
- package/runtime/scripts/hfs/slot-errors.mjs +11 -0
- package/runtime/scripts/hfs/slot-imports.mjs +76 -0
- package/runtime/scripts/hfs/slot-manifest-shape.mjs +73 -0
- package/runtime/scripts/hfs/slot-match.mjs +153 -0
- package/runtime/scripts/hfs/slot-path.mjs +8 -0
- package/runtime/scripts/hfs/slot-required.mjs +76 -0
- package/runtime/scripts/hfs/slot-semantic-problems.mjs +148 -0
- package/runtime/scripts/hfs/slot-side-problems.mjs +28 -0
- package/runtime/scripts/hfs/slots.mjs +72 -693
- package/runtime/scripts/hfs/sql/pg-parse.mjs +5 -2
- package/runtime/scripts/hfs/trailing-slashes.mjs +8 -0
- package/runtime/scripts/hfs/tree.mjs +19 -14
- package/runtime/scripts/hfs/typescript-programs.mjs +6 -4
- package/runtime/scripts/hfs/view.mjs +1 -1
- package/runtime/scripts/lib/dockerfile.mjs +56 -33
- package/runtime/scripts/lib/event-contract.mjs +17 -13
- package/runtime/scripts/lib/git.mjs +2 -2
- package/runtime/scripts/lib/graphql-contract.mjs +158 -322
- package/runtime/scripts/lib/graphql-sdl.mjs +262 -0
- package/runtime/scripts/lib/i18n.mjs +3 -2
- package/runtime/scripts/lib/in-order.mjs +71 -0
- package/runtime/scripts/lib/language.mjs +3 -3
- package/runtime/scripts/lib/list.mjs +8 -1
- package/runtime/scripts/lib/path-key.mjs +38 -5
- package/runtime/scripts/lib/pid-alive.mjs +7 -0
- package/runtime/scripts/lib/regex.mjs +1 -1
- package/runtime/scripts/lib/same-text.mjs +1 -1
- package/runtime/scripts/lib/secret-patterns.mjs +6 -2
- package/runtime/scripts/lib/sleep-sync.mjs +2 -2
- package/runtime/scripts/lib/sops-envelope.mjs +95 -48
- package/runtime/scripts/lib/stack-services.mjs +1 -2
- package/runtime/scripts/lib/ts-ast.mjs +9 -0
- package/runtime/scripts/lib/walk.mjs +6 -1
- package/scaffold/add-cli-lite.mjs +2 -1
- package/scaffold/add-table.mjs +2 -2
- package/scaffold/add.mjs +3 -2
- package/scaffold/app.mjs +4 -2
- package/scaffold/edition-gate.mjs +25 -25
- package/scaffold/lite-exports.mjs +2 -1
- package/scaffold/service.mjs +5 -4
- package/sync/hygiene.mjs +15 -9
- package/sync/index.mjs +7 -3
- package/templates/app/ci-workflows/github/workflows/ci.yml +4 -4
- package/templates/app/ci-workflows/github/workflows/e2e.yml +3 -3
- package/templates/app/ci-workflows/github/workflows/images.yml +3 -3
- package/templates/app/ci-workflows/sonar-steps.yml +2 -2
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +6 -6
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +3 -3
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +3 -3
- package/templates/app/starciwork.gitignore +1 -1
- package/templates/be/image/api/Dockerfile +1 -1
- package/templates/be/image/cli/Dockerfile +1 -1
- package/templates/be/image/worker/Dockerfile +1 -1
- package/templates/be/patterns/cli/group.cli.spec.ts.tpl +1 -1
- package/templates/be/patterns/cli/group.cli.ts.tpl +2 -2
- package/templates/be/patterns/event-bus/platform/event-runner.service.spec.ts.tpl +13 -0
- package/templates/be/patterns/event-bus/platform/event-runner.service.ts.tpl +7 -2
- package/templates/be/patterns/event-bus/platform/event.policy.ts.tpl +4 -1
- package/templates/be/patterns/event-bus/platform/kafka-event-transport.client.ts.tpl +2 -1
- package/templates/be/patterns/outbox/platform/outbox-relay.policy.ts.tpl +24 -9
- package/templates/be/patterns/queues/platform/queue-relay.service.ts.tpl +3 -2
- package/templates/be/patterns/queues/platform/queue-worker.service.ts.tpl +8 -5
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/migrate/migrate.cli.ts +2 -2
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.spec.ts +1 -1
- package/templates/be/skeleton/src/features/cli/seed/seed.cli.ts +2 -2
- package/templates/be/skeleton/src/modules/platform/database/migrate-connections.client.ts +3 -2
- package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +5 -4
- package/templates/be/skeleton/src/modules/platform/primitives/index.ts +1 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.spec.ts +47 -0
- package/templates/be/skeleton/src/modules/platform/primitives/sequence.policy.ts +16 -0
- package/templates/be/skeleton-lite/apps/api/Dockerfile +1 -0
- package/templates/be/skeleton-lite/src/modules/platform/primitives/index.ts +2 -0
- package/templates/fe/image/next/Dockerfile +2 -1
- package/templates/fe/skeleton/apps/app/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/apps/landing/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton/packages/__project__-i18n/src/index.ts +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/layout.tsx +1 -2
- package/templates/fe/skeleton-lite/apps/web/src/app/[locale]/page.tsx +2 -2
- package/templates/fe/skeleton-lite/apps/web/src/components/blocks/SignInForm/index.tsx +7 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/auth/write-sign-out.ts +1 -1
- package/templates/fe/skeleton-lite/apps/web/src/modules/db/validation/validation.mapper.ts +10 -2
- package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/request.ts +10 -3
- package/upgrade/index.mjs +9 -2
- package/runtime/engine/admission.mjs +0 -308
- package/runtime/engine/canonical-json.mjs +0 -10
- package/runtime/engine/db/blob.mjs +0 -315
- package/runtime/engine/db/ledger-paths.mjs +0 -83
- package/runtime/engine/db/ledger.mjs +0 -1118
- package/runtime/engine/db/machine-connection.mjs +0 -135
- package/runtime/engine/db/machine-schema.mjs +0 -84
- package/runtime/engine/db/machine.mjs +0 -1367
- package/runtime/engine/db/migrations/machine/0001-init.sql +0 -924
- package/runtime/engine/db/migrations/runtime/0001-init.sql +0 -1108
- package/runtime/engine/db/provider-reservations.mjs +0 -101
- package/runtime/engine/digest.mjs +0 -16
- package/runtime/engine/refuse.mjs +0 -11
- package/runtime/modules/ops/_labels.yaml +0 -53
- package/runtime/scripts/api/node/lib.mjs +0 -14
- package/runtime/scripts/api/node/spawn-node.mjs +0 -6
- package/runtime/scripts/api/process/lib.mjs +0 -111
- package/runtime/scripts/api/process/owned-process.mjs +0 -78
- package/runtime/scripts/api/process/stop-owned-process.mjs +0 -9
- package/runtime/scripts/connectors/lib.mjs +0 -488
- package/runtime/scripts/lib/clip.mjs +0 -19
- package/runtime/scripts/lib/display-names.mjs +0 -259
- package/runtime/scripts/lib/example-refs.mjs +0 -158
- package/runtime/scripts/lib/json-schema.mjs +0 -52
- package/runtime/scripts/lib/process-identity.mjs +0 -6
- package/runtime/scripts/lib/read-yaml.mjs +0 -18
- package/runtime/scripts/lib/redact.mjs +0 -161
- package/runtime/scripts/lib/sleep.mjs +0 -7
- package/runtime/scripts/lib/source-phrases.mjs +0 -34
- package/runtime/scripts/lib/sqlite.mjs +0 -21
|
@@ -37,7 +37,8 @@ function statementList(stmts, lineAt) {
|
|
|
37
37
|
return (stmts ?? []).map((entry, index) => {
|
|
38
38
|
const type = Object.keys(entry.stmt ?? {})[0] ?? '';
|
|
39
39
|
const node = entry.stmt?.[type] ?? {};
|
|
40
|
-
const
|
|
40
|
+
const nodeOffset = Math.max(node.location ?? 0, 0);
|
|
41
|
+
const offset = entry.stmt_location >= 0 ? entry.stmt_location : nodeOffset;
|
|
41
42
|
return { type, node, index, line: lineAt(offset) };
|
|
42
43
|
});
|
|
43
44
|
}
|
|
@@ -58,6 +59,125 @@ function searchPathSchema(node, current) {
|
|
|
58
59
|
return schemas.find((schema) => schema && schema !== '$user') ?? 'public';
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
function addTableFacts(statement, node, facts, defaultSchema) {
|
|
63
|
+
const relation = statement.type === 'CreateStmt' ? node.relation : node.into?.rel;
|
|
64
|
+
if (!relation || node.partbound || relation.relpersistence === 't') return;
|
|
65
|
+
facts.tables.push({ schema: schemaOf(relation, defaultSchema), name: relation.relname, index: statement.index, line: statement.line });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function addAlterTableFacts(statement, node, facts, defaultSchema) {
|
|
69
|
+
for (const command of node.cmds ?? []) {
|
|
70
|
+
const subtype = command?.AlterTableCmd?.subtype;
|
|
71
|
+
const item = { schema: schemaOf(node.relation, defaultSchema), name: node.relation?.relname, index: statement.index };
|
|
72
|
+
if (subtype === 'AT_EnableRowSecurity') facts.enables.push(item);
|
|
73
|
+
else if (subtype === 'AT_ForceRowSecurity') facts.forces.push(item);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function addCreatePolicyFacts(statement, node, facts, defaultSchema) {
|
|
78
|
+
const policy = {
|
|
79
|
+
name: node.policy_name,
|
|
80
|
+
schema: schemaOf(node.table, defaultSchema),
|
|
81
|
+
table: node.table?.relname,
|
|
82
|
+
cmd: String(node.cmd_name ?? 'all').toLowerCase(),
|
|
83
|
+
roles: node.roles?.length ? node.roles.map(roleOf) : ['public'],
|
|
84
|
+
implicitPublic: (node.roles ?? []).some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
|
|
85
|
+
qual: node.qual,
|
|
86
|
+
withCheck: node.with_check,
|
|
87
|
+
index: statement.index,
|
|
88
|
+
line: statement.line,
|
|
89
|
+
};
|
|
90
|
+
facts.policies.push(policy);
|
|
91
|
+
facts.policyEvents.push({ kind: 'create', policy, index: statement.index });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function addAlterPolicyFacts(statement, node, facts, defaultSchema) {
|
|
95
|
+
facts.policyEvents.push({
|
|
96
|
+
kind: 'alter',
|
|
97
|
+
schema: schemaOf(node.table, defaultSchema),
|
|
98
|
+
table: node.table?.relname,
|
|
99
|
+
name: node.policy_name,
|
|
100
|
+
...(node.roles !== undefined ? {
|
|
101
|
+
roles: node.roles.map(roleOf),
|
|
102
|
+
implicitPublic: node.roles.some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
|
|
103
|
+
} : {}),
|
|
104
|
+
...(node.qual !== undefined ? { qual: node.qual } : {}),
|
|
105
|
+
...(node.with_check !== undefined ? { withCheck: node.with_check } : {}),
|
|
106
|
+
index: statement.index,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function addDropPolicyFacts(statement, node, facts, defaultSchema) {
|
|
111
|
+
if (node.removeType !== 'OBJECT_POLICY') return;
|
|
112
|
+
for (const object of node.objects ?? []) {
|
|
113
|
+
const parts = object?.List?.items?.map(sval).filter(Boolean) ?? [];
|
|
114
|
+
const [schema, table, name] = parts.length === 3 ? parts : [defaultSchema, ...parts];
|
|
115
|
+
facts.policyEvents.push({ kind: 'drop', schema, table, name, index: statement.index });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function addGrantFacts(statement, node, facts, defaultSchema) {
|
|
120
|
+
facts.grants.push({
|
|
121
|
+
grant: node.is_grant === true,
|
|
122
|
+
targtype: node.targtype,
|
|
123
|
+
objtype: node.objtype,
|
|
124
|
+
objects: node.objects ?? [],
|
|
125
|
+
privileges: (node.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
|
|
126
|
+
grantees: (node.grantees ?? []).map(roleOf),
|
|
127
|
+
defaultSchema,
|
|
128
|
+
index: statement.index,
|
|
129
|
+
line: statement.line,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function addDefaultPrivilegeFacts(statement, node, facts) {
|
|
134
|
+
const action = node.action ?? {};
|
|
135
|
+
facts.defaultPrivileges.push({
|
|
136
|
+
grant: action.is_grant === true,
|
|
137
|
+
objtype: action.objtype,
|
|
138
|
+
privileges: (action.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
|
|
139
|
+
grantees: (action.grantees ?? []).map(roleOf),
|
|
140
|
+
index: statement.index,
|
|
141
|
+
line: statement.line,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function addCreateFunctionFacts(statement, node, facts, defaultSchema) {
|
|
146
|
+
const parts = (node.funcname ?? []).map(sval).filter(Boolean);
|
|
147
|
+
const schema = parts.length > 1 ? parts[0] : defaultSchema;
|
|
148
|
+
facts.functions.push({ node, name: parts.length > 1 ? parts.join('.') : `${schema}.${parts[0]}`, schema, index: statement.index, line: statement.line });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function addAlterFunctionFacts(statement, node, facts, defaultSchema) {
|
|
152
|
+
const rawName = nameOf(node.func?.objname);
|
|
153
|
+
const name = rawName.includes('.') ? rawName : `${defaultSchema}.${rawName}`;
|
|
154
|
+
facts.alterFunctions.push({ name, setsPath: (node.actions ?? []).some((action) => isSearchPathSet(action?.DefElem)), index: statement.index, line: statement.line });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function addDoBlockFacts(statement, node, facts) {
|
|
158
|
+
const option = (key) => (node.args ?? []).find((argument) => argument?.DefElem?.defname === key)?.DefElem?.arg;
|
|
159
|
+
facts.doBlocks.push({ body: option('as')?.String?.sval, language: option('language')?.String?.sval ?? 'plpgsql', index: statement.index, line: statement.line });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function addBucketInsertFacts(statement, node, facts) {
|
|
163
|
+
if (node.relation?.schemaname === 'storage' && node.relation?.relname === 'buckets') facts.buckets.push({ node, index: statement.index, line: statement.line });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function addBucketCallFacts(statement, node, facts) {
|
|
167
|
+
for (const target of node.targetList ?? []) {
|
|
168
|
+
const call = target?.ResTarget?.val?.FuncCall;
|
|
169
|
+
if (call && nameOf(call.funcname).toLowerCase() === 'storage.create_bucket') facts.bucketCalls.push({ node: call, index: statement.index, line: statement.line });
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const statementFactHandlers = new Map([
|
|
174
|
+
['VariableSetStmt', (statement, node, facts, defaultSchema) => searchPathSchema(node, defaultSchema)],
|
|
175
|
+
['CreateStmt', addTableFacts], ['CreateTableAsStmt', addTableFacts], ['AlterTableStmt', addAlterTableFacts],
|
|
176
|
+
['CreatePolicyStmt', addCreatePolicyFacts], ['AlterPolicyStmt', addAlterPolicyFacts], ['DropStmt', addDropPolicyFacts],
|
|
177
|
+
['GrantStmt', addGrantFacts], ['AlterDefaultPrivilegesStmt', addDefaultPrivilegeFacts], ['CreateFunctionStmt', addCreateFunctionFacts],
|
|
178
|
+
['AlterFunctionStmt', addAlterFunctionFacts], ['DoStmt', addDoBlockFacts], ['InsertStmt', addBucketInsertFacts], ['SelectStmt', addBucketCallFacts],
|
|
179
|
+
]);
|
|
180
|
+
|
|
61
181
|
/** Fold each statement to the facts consumed by the rule checks. */
|
|
62
182
|
function factsOf(statements) {
|
|
63
183
|
const facts = {
|
|
@@ -67,95 +187,8 @@ function factsOf(statements) {
|
|
|
67
187
|
let defaultSchema = 'public';
|
|
68
188
|
for (const statement of statements) {
|
|
69
189
|
const { node } = statement;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
} else if (statement.type === 'CreateStmt' || statement.type === 'CreateTableAsStmt') {
|
|
73
|
-
const relation = statement.type === 'CreateStmt' ? node.relation : node.into?.rel;
|
|
74
|
-
if (!relation || node.partbound || relation.relpersistence === 't') continue;
|
|
75
|
-
facts.tables.push({ schema: schemaOf(relation, defaultSchema), name: relation.relname, index: statement.index, line: statement.line });
|
|
76
|
-
} else if (statement.type === 'AlterTableStmt') {
|
|
77
|
-
for (const command of node.cmds ?? []) {
|
|
78
|
-
const subtype = command?.AlterTableCmd?.subtype;
|
|
79
|
-
const item = { schema: schemaOf(node.relation, defaultSchema), name: node.relation?.relname, index: statement.index };
|
|
80
|
-
if (subtype === 'AT_EnableRowSecurity') facts.enables.push(item);
|
|
81
|
-
else if (subtype === 'AT_ForceRowSecurity') facts.forces.push(item);
|
|
82
|
-
}
|
|
83
|
-
} else if (statement.type === 'CreatePolicyStmt') {
|
|
84
|
-
const policy = {
|
|
85
|
-
name: node.policy_name,
|
|
86
|
-
schema: schemaOf(node.table, defaultSchema),
|
|
87
|
-
table: node.table?.relname,
|
|
88
|
-
cmd: String(node.cmd_name ?? 'all').toLowerCase(),
|
|
89
|
-
roles: node.roles?.length ? node.roles.map(roleOf) : ['public'],
|
|
90
|
-
implicitPublic: (node.roles ?? []).some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
|
|
91
|
-
qual: node.qual,
|
|
92
|
-
withCheck: node.with_check,
|
|
93
|
-
index: statement.index,
|
|
94
|
-
line: statement.line,
|
|
95
|
-
};
|
|
96
|
-
facts.policies.push(policy);
|
|
97
|
-
facts.policyEvents.push({ kind: 'create', policy, index: statement.index });
|
|
98
|
-
} else if (statement.type === 'AlterPolicyStmt') {
|
|
99
|
-
facts.policyEvents.push({
|
|
100
|
-
kind: 'alter',
|
|
101
|
-
schema: schemaOf(node.table, defaultSchema),
|
|
102
|
-
table: node.table?.relname,
|
|
103
|
-
name: node.policy_name,
|
|
104
|
-
...(node.roles !== undefined ? {
|
|
105
|
-
roles: node.roles.map(roleOf),
|
|
106
|
-
implicitPublic: node.roles.some((role) => role?.RoleSpec?.roletype === 'ROLESPEC_PUBLIC' && role.RoleSpec.location === -1),
|
|
107
|
-
} : {}),
|
|
108
|
-
...(node.qual !== undefined ? { qual: node.qual } : {}),
|
|
109
|
-
...(node.with_check !== undefined ? { withCheck: node.with_check } : {}),
|
|
110
|
-
index: statement.index,
|
|
111
|
-
});
|
|
112
|
-
} else if (statement.type === 'DropStmt' && node.removeType === 'OBJECT_POLICY') {
|
|
113
|
-
for (const object of node.objects ?? []) {
|
|
114
|
-
const parts = object?.List?.items?.map(sval).filter(Boolean) ?? [];
|
|
115
|
-
const [schema, table, name] = parts.length === 3 ? parts : [defaultSchema, ...parts];
|
|
116
|
-
facts.policyEvents.push({ kind: 'drop', schema, table, name, index: statement.index });
|
|
117
|
-
}
|
|
118
|
-
} else if (statement.type === 'GrantStmt') {
|
|
119
|
-
facts.grants.push({
|
|
120
|
-
grant: node.is_grant === true,
|
|
121
|
-
targtype: node.targtype,
|
|
122
|
-
objtype: node.objtype,
|
|
123
|
-
objects: node.objects ?? [],
|
|
124
|
-
privileges: (node.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
|
|
125
|
-
grantees: (node.grantees ?? []).map(roleOf),
|
|
126
|
-
defaultSchema,
|
|
127
|
-
index: statement.index,
|
|
128
|
-
line: statement.line,
|
|
129
|
-
});
|
|
130
|
-
} else if (statement.type === 'AlterDefaultPrivilegesStmt') {
|
|
131
|
-
const action = node.action ?? {};
|
|
132
|
-
facts.defaultPrivileges.push({
|
|
133
|
-
grant: action.is_grant === true,
|
|
134
|
-
objtype: action.objtype,
|
|
135
|
-
privileges: (action.privileges ?? []).map((privilege) => String(privilege?.AccessPriv?.priv_name ?? '').toLowerCase()),
|
|
136
|
-
grantees: (action.grantees ?? []).map(roleOf),
|
|
137
|
-
index: statement.index,
|
|
138
|
-
line: statement.line,
|
|
139
|
-
});
|
|
140
|
-
} else if (statement.type === 'CreateFunctionStmt') {
|
|
141
|
-
const parts = (node.funcname ?? []).map(sval).filter(Boolean);
|
|
142
|
-
const schema = parts.length > 1 ? parts[0] : defaultSchema;
|
|
143
|
-
facts.functions.push({ node, name: parts.length > 1 ? parts.join('.') : `${schema}.${parts[0]}`, schema, index: statement.index, line: statement.line });
|
|
144
|
-
} else if (statement.type === 'AlterFunctionStmt') {
|
|
145
|
-
const rawName = nameOf(node.func?.objname);
|
|
146
|
-
const name = rawName.includes('.') ? rawName : `${defaultSchema}.${rawName}`;
|
|
147
|
-
facts.alterFunctions.push({ name, setsPath: (node.actions ?? []).some((action) => isSearchPathSet(action?.DefElem)), index: statement.index, line: statement.line });
|
|
148
|
-
} else if (statement.type === 'DoStmt') {
|
|
149
|
-
const option = (key) => (node.args ?? []).find((argument) => argument?.DefElem?.defname === key)?.DefElem?.arg;
|
|
150
|
-
facts.doBlocks.push({ body: option('as')?.String?.sval, language: option('language')?.String?.sval ?? 'plpgsql', index: statement.index, line: statement.line });
|
|
151
|
-
} else if (statement.type === 'InsertStmt' && node.relation?.schemaname === 'storage' && node.relation?.relname === 'buckets') {
|
|
152
|
-
facts.buckets.push({ node, index: statement.index, line: statement.line });
|
|
153
|
-
} else if (statement.type === 'SelectStmt') {
|
|
154
|
-
for (const target of node.targetList ?? []) {
|
|
155
|
-
const call = target?.ResTarget?.val?.FuncCall;
|
|
156
|
-
if (call && nameOf(call.funcname).toLowerCase() === 'storage.create_bucket') facts.bucketCalls.push({ node: call, index: statement.index, line: statement.line });
|
|
157
|
-
}
|
|
158
|
-
}
|
|
190
|
+
const handler = statementFactHandlers.get(statement.type);
|
|
191
|
+
if (handler) defaultSchema = handler(statement, node, facts, defaultSchema) ?? defaultSchema;
|
|
159
192
|
}
|
|
160
193
|
return facts;
|
|
161
194
|
}
|
|
@@ -175,105 +208,139 @@ function rlsFindings(file, facts, exposed, forceRls) {
|
|
|
175
208
|
return findings;
|
|
176
209
|
}
|
|
177
210
|
|
|
211
|
+
function policyNameFindings(file, policy, qualified) {
|
|
212
|
+
const findings = [];
|
|
213
|
+
if (policy.name === undefined || !policy.name.startsWith(`${policy.table}_`)) {
|
|
214
|
+
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${JSON.stringify(policy.name)} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
|
|
215
|
+
} else {
|
|
216
|
+
const tail = policy.name.slice(policy.table.length + 1);
|
|
217
|
+
if (tail !== 'public_read' && !new RegExp(`^[a-z][a-z0-9_]*_(${POLICY_ACTIONS.join('|')})$`).test(tail)) {
|
|
218
|
+
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return findings;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function policyAccessFindings(file, policy, qualified) {
|
|
225
|
+
const findings = [];
|
|
226
|
+
const publicRead = typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX);
|
|
227
|
+
if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.qual)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with USING (true)`, { line: policy.line, policy: policy.name }));
|
|
228
|
+
if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.withCheck)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with WITH CHECK (true)`, { line: policy.line, policy: policy.name }));
|
|
229
|
+
if (policy.cmd === 'select' && isTrue(policy.qual) && !publicRead) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR SELECT with USING (true); a world-readable table declares it with *_public_read`, { line: policy.line, policy: policy.name }));
|
|
230
|
+
if ((policy.roles.includes('anon') || policy.roles.includes('public')) && !(policy.cmd === 'select' && publicRead)) {
|
|
231
|
+
const roleName = policy.roles.includes('anon') ? 'anon' : 'public';
|
|
232
|
+
const role = policy.implicitPublic ? 'the implicit TO public default' : `TO ${roleName}`;
|
|
233
|
+
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} reaches ${role}; anonymous access is only FOR SELECT with *_public_read`, { line: policy.line, policy: policy.name }));
|
|
234
|
+
}
|
|
235
|
+
return findings;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function grantPolicyFindings(file, grant) {
|
|
239
|
+
const findings = [];
|
|
240
|
+
if (!grant.grant) return findings;
|
|
241
|
+
if (grant.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants to service_role; the service role bypasses RLS and is never granted`, { line: grant.line }));
|
|
242
|
+
const broadRole = grant.grantees.find((role) => ['anon', 'authenticated', 'public'].includes(role));
|
|
243
|
+
if (grant.targtype === 'ACL_TARGET_ALL_IN_SCHEMA' && grant.objtype === 'OBJECT_TABLE' && broadRole) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants on all tables in a schema to ${broadRole}; table privileges are declared per table beside the matching policies`, { line: grant.line, role: broadRole }));
|
|
244
|
+
return findings;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function defaultPrivilegeFindings(file, defaults) {
|
|
248
|
+
const findings = [];
|
|
249
|
+
if (!defaults.grant) return findings;
|
|
250
|
+
if (defaults.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} grants default privileges to service_role; the service role bypasses RLS and is never granted`, { line: defaults.line }));
|
|
251
|
+
const broadRole = defaults.grantees.find((role) => ['anon', 'public'].includes(role));
|
|
252
|
+
if (defaults.objtype === 'OBJECT_TABLE' && broadRole) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} alters default table privileges for ${broadRole}; anonymous grants are explicit per table beside *_public_read policies`, { line: defaults.line, role: broadRole }));
|
|
253
|
+
return findings;
|
|
254
|
+
}
|
|
255
|
+
|
|
178
256
|
function localPolicyFindings(file, facts) {
|
|
179
257
|
const findings = [];
|
|
180
258
|
for (const policy of facts.policies) {
|
|
181
259
|
const qualified = `${policy.schema}.${policy.table}`;
|
|
182
|
-
|
|
183
|
-
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${JSON.stringify(policy.name)} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
|
|
184
|
-
} else {
|
|
185
|
-
const tail = policy.name.slice(policy.table.length + 1);
|
|
186
|
-
if (tail !== 'public_read' && !new RegExp(`^[a-z][a-z0-9_]*_(${POLICY_ACTIONS.join('|')})$`).test(tail)) {
|
|
187
|
-
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is not named <table>_<role>_<action>`, { line: policy.line, policy: policy.name }));
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
const publicRead = typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX);
|
|
191
|
-
if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.qual)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with USING (true)`, { line: policy.line, policy: policy.name }));
|
|
192
|
-
if (WRITE_COMMANDS.has(policy.cmd) && isTrue(policy.withCheck)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR ${policy.cmd.toUpperCase()} with WITH CHECK (true)`, { line: policy.line, policy: policy.name }));
|
|
193
|
-
if (policy.cmd === 'select' && isTrue(policy.qual) && !publicRead) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} is FOR SELECT with USING (true); a world-readable table declares it with *_public_read`, { line: policy.line, policy: policy.name }));
|
|
194
|
-
if ((policy.roles.includes('anon') || policy.roles.includes('public')) && !(policy.cmd === 'select' && publicRead)) {
|
|
195
|
-
const role = policy.implicitPublic ? 'the implicit TO public default' : `TO ${policy.roles.includes('anon') ? 'anon' : 'public'}`;
|
|
196
|
-
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${policy.line} policy ${policy.name} on ${qualified} reaches ${role}; anonymous access is only FOR SELECT with *_public_read`, { line: policy.line, policy: policy.name }));
|
|
197
|
-
}
|
|
260
|
+
findings.push(...policyNameFindings(file, policy, qualified), ...policyAccessFindings(file, policy, qualified));
|
|
198
261
|
}
|
|
199
262
|
for (const grant of facts.grants) {
|
|
200
|
-
|
|
201
|
-
if (grant.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants to service_role; the service role bypasses RLS and is never granted`, { line: grant.line }));
|
|
202
|
-
const broadRole = grant.grantees.find((role) => ['anon', 'authenticated', 'public'].includes(role));
|
|
203
|
-
if (grant.targtype === 'ACL_TARGET_ALL_IN_SCHEMA' && grant.objtype === 'OBJECT_TABLE' && broadRole) {
|
|
204
|
-
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants on all tables in a schema to ${broadRole}; table privileges are declared per table beside the matching policies`, { line: grant.line, role: broadRole }));
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
for (const defaults of facts.defaultPrivileges) {
|
|
208
|
-
if (!defaults.grant) continue;
|
|
209
|
-
if (defaults.grantees.includes('service_role')) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} grants default privileges to service_role; the service role bypasses RLS and is never granted`, { line: defaults.line }));
|
|
210
|
-
const broadRole = defaults.grantees.find((role) => ['anon', 'public'].includes(role));
|
|
211
|
-
if (defaults.objtype === 'OBJECT_TABLE' && broadRole) {
|
|
212
|
-
findings.push(found(DB_POLICY_SHAPE, file, `${file}:${defaults.line} alters default table privileges for ${broadRole}; anonymous grants are explicit per table beside *_public_read policies`, { line: defaults.line, role: broadRole }));
|
|
213
|
-
}
|
|
263
|
+
findings.push(...grantPolicyFindings(file, grant));
|
|
214
264
|
}
|
|
265
|
+
for (const defaults of facts.defaultPrivileges) findings.push(...defaultPrivilegeFindings(file, defaults));
|
|
215
266
|
return findings;
|
|
216
267
|
}
|
|
217
268
|
|
|
218
269
|
const policyKey = (policy) => `${policy.schema}\0${policy.table}\0${policy.name}`;
|
|
219
270
|
|
|
220
|
-
|
|
221
|
-
|
|
271
|
+
function applyPolicyEvent(policies, event) {
|
|
272
|
+
const key = policyKey(event.kind === 'create' ? event.policy : event);
|
|
273
|
+
if (event.kind === 'create') policies.set(key, event.policy);
|
|
274
|
+
else if (event.kind === 'drop') policies.delete(key);
|
|
275
|
+
else if (policies.has(key)) policies.set(key, { ...policies.get(key), ...event });
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function policiesAfterMigrations(analyses) {
|
|
222
279
|
const policies = new Map();
|
|
223
280
|
for (const { facts } of analyses) {
|
|
224
|
-
for (const event of [...facts.policyEvents].sort((left, right) => left.index - right.index))
|
|
225
|
-
const key = policyKey(event.kind === 'create' ? event.policy : event);
|
|
226
|
-
if (event.kind === 'create') policies.set(key, event.policy);
|
|
227
|
-
else if (event.kind === 'drop') policies.delete(key);
|
|
228
|
-
else if (policies.has(key)) policies.set(key, { ...policies.get(key), ...event });
|
|
229
|
-
}
|
|
281
|
+
for (const event of [...facts.policyEvents].sort((left, right) => left.index - right.index)) applyPolicyEvent(policies, event);
|
|
230
282
|
}
|
|
231
|
-
|
|
283
|
+
return [...policies.values()];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function anonymousTableGrant(grant) {
|
|
287
|
+
if (!grant.grant || grant.targtype !== 'ACL_TARGET_OBJECT' || grant.objtype !== 'OBJECT_TABLE') return false;
|
|
288
|
+
return grant.grantees.includes('anon') || grant.grantees.includes('public');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function publicReadPolicyCovers(finalPolicies, schema, table) {
|
|
292
|
+
return finalPolicies.some((policy) => policy.schema === schema && policy.table === table && policy.cmd === 'select'
|
|
293
|
+
&& typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX)
|
|
294
|
+
&& (policy.roles.includes('anon') || policy.roles.includes('public')));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function anonymousGrantFindings(file, grant, finalPolicies) {
|
|
298
|
+
if (!anonymousTableGrant(grant)) return [];
|
|
299
|
+
const findings = [];
|
|
300
|
+
for (const object of grant.objects) {
|
|
301
|
+
const relation = object?.RangeVar;
|
|
302
|
+
if (!relation) continue;
|
|
303
|
+
const schema = schemaOf(relation, grant.defaultSchema);
|
|
304
|
+
const table = relation.relname;
|
|
305
|
+
if (!publicReadPolicyCovers(finalPolicies, schema, table)) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants an anonymous role on ${schema}.${table} but the migration set ends without a *_public_read FOR SELECT policy`, { line: grant.line, table: `${schema}.${table}` }));
|
|
306
|
+
}
|
|
307
|
+
return findings;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Anonymous table grants are covered by the final policy state across the ordered migration set. */
|
|
311
|
+
export function migrationSetPolicyFindings(analyses) {
|
|
312
|
+
const finalPolicies = policiesAfterMigrations(analyses);
|
|
232
313
|
const findings = [];
|
|
233
314
|
for (const { file, facts } of analyses) {
|
|
234
|
-
for (const grant of facts.grants)
|
|
235
|
-
if (!grant.grant || grant.targtype !== 'ACL_TARGET_OBJECT' || grant.objtype !== 'OBJECT_TABLE') continue;
|
|
236
|
-
if (!grant.grantees.includes('anon') && !grant.grantees.includes('public')) continue;
|
|
237
|
-
for (const object of grant.objects) {
|
|
238
|
-
const relation = object?.RangeVar;
|
|
239
|
-
if (!relation) continue;
|
|
240
|
-
const schema = schemaOf(relation, grant.defaultSchema);
|
|
241
|
-
const table = relation.relname;
|
|
242
|
-
const covered = finalPolicies.some((policy) => policy.schema === schema && policy.table === table && policy.cmd === 'select'
|
|
243
|
-
&& typeof policy.name === 'string' && policy.name.endsWith(PUBLIC_READ_SUFFIX)
|
|
244
|
-
&& (policy.roles.includes('anon') || policy.roles.includes('public')));
|
|
245
|
-
if (!covered) findings.push(found(DB_POLICY_SHAPE, file, `${file}:${grant.line} grants an anonymous role on ${schema}.${table} but the migration set ends without a *_public_read FOR SELECT policy`, { line: grant.line, table: `${schema}.${table}` }));
|
|
246
|
-
}
|
|
247
|
-
}
|
|
315
|
+
for (const grant of facts.grants) findings.push(...anonymousGrantFindings(file, grant, finalPolicies));
|
|
248
316
|
}
|
|
249
317
|
return findings;
|
|
250
318
|
}
|
|
251
319
|
|
|
252
320
|
const valueRows = (node) => (node.selectStmt?.SelectStmt?.valuesLists ?? []).map((list) => list?.List?.items ?? []);
|
|
253
321
|
const unwrap = (expression) => expression?.TypeCast ? unwrap(expression.TypeCast.arg) : expression;
|
|
254
|
-
const columnName = (expression) => unwrap(expression)?.ColumnRef?.fields?.map(sval).
|
|
322
|
+
const columnName = (expression) => unwrap(expression)?.ColumnRef?.fields?.map(sval).findLast(Boolean);
|
|
255
323
|
const literalString = (expression) => asString(unwrap(expression));
|
|
256
324
|
|
|
325
|
+
function operationScopesBucket(operation, bucket) {
|
|
326
|
+
const operator = operation.name?.map(sval).filter(Boolean).join('');
|
|
327
|
+
if (operator === '=' && ((columnName(operation.lexpr) === 'bucket_id' && literalString(operation.rexpr) === bucket)
|
|
328
|
+
|| (columnName(operation.rexpr) === 'bucket_id' && literalString(operation.lexpr) === bucket))) return true;
|
|
329
|
+
return operation.kind === 'AEXPR_IN' && columnName(operation.lexpr) === 'bucket_id'
|
|
330
|
+
&& (operation.rexpr ?? []).some((item) => literalString(item) === bucket);
|
|
331
|
+
}
|
|
332
|
+
|
|
257
333
|
function expressionScopesBucket(expression, bucket) {
|
|
258
334
|
if (!expression || typeof expression !== 'object') return false;
|
|
259
335
|
if (Array.isArray(expression)) return expression.some((item) => expressionScopesBucket(item, bucket));
|
|
260
336
|
const operation = expression.A_Expr;
|
|
261
|
-
if (operation)
|
|
262
|
-
const operator = operation.name?.map(sval).filter(Boolean).join('');
|
|
263
|
-
if (operator === '=') {
|
|
264
|
-
if ((columnName(operation.lexpr) === 'bucket_id' && literalString(operation.rexpr) === bucket)
|
|
265
|
-
|| (columnName(operation.rexpr) === 'bucket_id' && literalString(operation.lexpr) === bucket)) return true;
|
|
266
|
-
}
|
|
267
|
-
if (operation.kind === 'AEXPR_IN' && columnName(operation.lexpr) === 'bucket_id'
|
|
268
|
-
&& (operation.rexpr ?? []).some((item) => literalString(item) === bucket)) return true;
|
|
269
|
-
}
|
|
337
|
+
if (operation && operationScopesBucket(operation, bucket)) return true;
|
|
270
338
|
return Object.values(expression).some((child) => expressionScopesBucket(child, bucket));
|
|
271
339
|
}
|
|
272
340
|
|
|
273
|
-
function
|
|
274
|
-
const findings = [];
|
|
341
|
+
function insertedBucketDeclarations(file, buckets, findings) {
|
|
275
342
|
const declarations = [];
|
|
276
|
-
for (const { node, line } of
|
|
343
|
+
for (const { node, line } of buckets) {
|
|
277
344
|
const columns = (node.cols ?? []).map((column) => column?.ResTarget?.name);
|
|
278
345
|
const rows = valueRows(node);
|
|
279
346
|
if (!columns.length || !rows.length) {
|
|
@@ -285,7 +352,12 @@ function storageFindings(file, facts) {
|
|
|
285
352
|
declarations.push({ id: asString(cell('id')), publicValue: asBool(cell('public')), fileSize: cell('file_size_limit'), mimeTypes: cell('allowed_mime_types'), line });
|
|
286
353
|
}
|
|
287
354
|
}
|
|
288
|
-
|
|
355
|
+
return declarations;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function calledBucketDeclarations(bucketCalls) {
|
|
359
|
+
const declarations = [];
|
|
360
|
+
for (const { node, line } of bucketCalls) {
|
|
289
361
|
const positional = (node.args ?? []).filter((argument) => !argument?.NamedArgExpr);
|
|
290
362
|
const named = new Map((node.args ?? []).filter((argument) => argument?.NamedArgExpr).map((argument) => [argument.NamedArgExpr.name, argument.NamedArgExpr.arg]));
|
|
291
363
|
declarations.push({
|
|
@@ -296,22 +368,30 @@ function storageFindings(file, facts) {
|
|
|
296
368
|
line,
|
|
297
369
|
});
|
|
298
370
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
for (const [column, value] of [['file_size_limit', fileSize], ['allowed_mime_types', mimeTypes]]) {
|
|
309
|
-
if (value === undefined || isNull(value)) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} declares no ${column}`, { line, bucket: id, column }));
|
|
310
|
-
}
|
|
311
|
-
const covered = facts.policies.some((policy) => policy.schema === 'storage' && policy.table === 'objects'
|
|
312
|
-
&& (expressionScopesBucket(policy.qual, id) || expressionScopesBucket(policy.withCheck, id)));
|
|
313
|
-
if (!covered) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} has no storage.objects policy whose bucket_id predicate names it in this migration`, { line, bucket: id }));
|
|
371
|
+
return declarations;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function bucketDeclarationFindings(file, declaration, policies) {
|
|
375
|
+
const { id, publicValue, fileSize, mimeTypes, line } = declaration;
|
|
376
|
+
const findings = [];
|
|
377
|
+
if (id === undefined) return [found(DB_STORAGE_POLICY, file, `${file}:${line} storage.buckets row has no literal id`, { line })];
|
|
378
|
+
if (publicValue === null || (publicValue && !id.endsWith('_public'))) {
|
|
379
|
+
findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} declares public = ${publicValue === null ? 'absent' : publicValue}; public must be explicit and only an *_public id may be public`, { line, bucket: id }));
|
|
314
380
|
}
|
|
381
|
+
for (const [column, value] of [['file_size_limit', fileSize], ['allowed_mime_types', mimeTypes]]) {
|
|
382
|
+
if (value === undefined || isNull(value)) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} declares no ${column}`, { line, bucket: id, column }));
|
|
383
|
+
}
|
|
384
|
+
const covered = policies.some((policy) => policy.schema === 'storage' && policy.table === 'objects'
|
|
385
|
+
&& (expressionScopesBucket(policy.qual, id) || expressionScopesBucket(policy.withCheck, id)));
|
|
386
|
+
if (!covered) findings.push(found(DB_STORAGE_POLICY, file, `${file}:${line} bucket ${JSON.stringify(id)} has no storage.objects policy whose bucket_id predicate names it in this migration`, { line, bucket: id }));
|
|
387
|
+
return findings;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function storageFindings(file, facts) {
|
|
391
|
+
const findings = [];
|
|
392
|
+
const declarations = insertedBucketDeclarations(file, facts.buckets, findings);
|
|
393
|
+
declarations.push(...calledBucketDeclarations(facts.bucketCalls));
|
|
394
|
+
for (const declaration of declarations) findings.push(...bucketDeclarationFindings(file, declaration, facts.policies));
|
|
315
395
|
return findings;
|
|
316
396
|
}
|
|
317
397
|
|
|
@@ -324,7 +404,8 @@ export async function sqlAnalysis({ file, text, exposed, forceRls }) {
|
|
|
324
404
|
} catch (error) {
|
|
325
405
|
const position = error?.sqlDetails?.cursorPosition;
|
|
326
406
|
const line = position ? lineAt(position - 1) : undefined;
|
|
327
|
-
|
|
407
|
+
const lineNote = line ? `:${line}` : '';
|
|
408
|
+
return { findings: [found(DB_MIGRATION_SHAPE, file, `${file}${lineNote} is not valid PostgreSQL (${String(error?.message ?? error).split('\n')[0]})`, { ...(line ? { line } : {}) })], facts: null };
|
|
328
409
|
}
|
|
329
410
|
const facts = factsOf(statementList(parsed.stmts, lineAt));
|
|
330
411
|
return { facts, findings: [
|
|
@@ -5,6 +5,8 @@ import { configFindings, parseToml, typesFindings } from './database-config.mjs'
|
|
|
5
5
|
import { defaultGit, migrationShapeFindings } from './database-migrations.mjs';
|
|
6
6
|
import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
|
|
7
7
|
import { CONFIG_FILE, DB_CONFIG_POLICY, DB_MIGRATION_SHAPE, MIGRATIONS_DIR, TYPES_FILE } from './database-constants.mjs';
|
|
8
|
+
import { byCodeUnit } from '../../lib/list.mjs';
|
|
9
|
+
import { eachInOrder } from '../../lib/in-order.mjs';
|
|
8
10
|
|
|
9
11
|
export { migrationStamp } from './database-migrations.mjs';
|
|
10
12
|
export {
|
|
@@ -20,6 +22,30 @@ export {
|
|
|
20
22
|
TYPES_FILE,
|
|
21
23
|
} from './database-constants.mjs';
|
|
22
24
|
|
|
25
|
+
const declaresSupabaseConnection = (declaration) => ['be', 'fe'].some((side) => (declaration?.sides?.[side]?.connections ?? []).some((connection) => connection?.provider === 'supabase'));
|
|
26
|
+
|
|
27
|
+
async function configResult(repoRoot, declared) {
|
|
28
|
+
const configText = readText(repoRoot, CONFIG_FILE);
|
|
29
|
+
const config = configText === null ? null : await parseToml(configText);
|
|
30
|
+
if (config === null) return { config, findings: [found(DB_CONFIG_POLICY, CONFIG_FILE, `${CONFIG_FILE} is not readable valid TOML; the config is policy, so it must parse`, {})] };
|
|
31
|
+
return { config, findings: configFindings({ file: CONFIG_FILE, text: configText, toml: config, supabase: declared ?? null }) };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings }) {
|
|
35
|
+
const analyses = [];
|
|
36
|
+
await eachInOrder(migrations, async (file) => {
|
|
37
|
+
const text = readText(repoRoot, file);
|
|
38
|
+
if (text === null) {
|
|
39
|
+
findings.push(found(DB_MIGRATION_SHAPE, file, `${file} is not a readable SQL file`, {}));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const analysis = await sqlAnalysis({ file, text, exposed, forceRls });
|
|
43
|
+
findings.push(...analysis.findings);
|
|
44
|
+
if (analysis.facts) analyses.push({ file, facts: analysis.facts });
|
|
45
|
+
});
|
|
46
|
+
return analyses;
|
|
47
|
+
}
|
|
48
|
+
|
|
23
49
|
/**
|
|
24
50
|
* Judge the Supabase paths in one app. Git and type emission are injected for deterministic specs; this function
|
|
25
51
|
* starts neither Docker nor the Supabase CLI. A full app is judged when it declares a Supabase connection, while
|
|
@@ -27,22 +53,18 @@ export {
|
|
|
27
53
|
*/
|
|
28
54
|
export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes, now = () => Date.now() } = {}) {
|
|
29
55
|
const findings = [];
|
|
30
|
-
const migrations = files.filter((file) => file.startsWith(`${MIGRATIONS_DIR}/`)).sort();
|
|
56
|
+
const migrations = files.filter((file) => file.startsWith(`${MIGRATIONS_DIR}/`)).sort(byCodeUnit);
|
|
31
57
|
const declaration = readJson(repoRoot, 'hfs.json');
|
|
32
58
|
const declared = supabase === undefined ? declaration?.supabase : supabase;
|
|
33
59
|
const hasConfig = files.includes(CONFIG_FILE);
|
|
34
|
-
const supabaseConnection =
|
|
60
|
+
const supabaseConnection = declaresSupabaseConnection(declaration);
|
|
35
61
|
if (!migrations.length && !hasConfig && !files.includes(TYPES_FILE) && !declared && !supabaseConnection && (edition ?? declaration?.edition) !== 'lite') return findings;
|
|
36
62
|
|
|
37
63
|
let config = null;
|
|
38
64
|
if (hasConfig) {
|
|
39
|
-
const
|
|
40
|
-
config =
|
|
41
|
-
|
|
42
|
-
findings.push(found(DB_CONFIG_POLICY, CONFIG_FILE, `${CONFIG_FILE} is not readable valid TOML; the config is policy, so it must parse`, {}));
|
|
43
|
-
} else {
|
|
44
|
-
findings.push(...configFindings({ file: CONFIG_FILE, text: configText, toml: config, supabase: declared ?? null }));
|
|
45
|
-
}
|
|
65
|
+
const result = await configResult(repoRoot, declared);
|
|
66
|
+
config = result.config;
|
|
67
|
+
findings.push(...result.findings);
|
|
46
68
|
}
|
|
47
69
|
|
|
48
70
|
const apiSchemas = (config?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
|
|
@@ -50,19 +72,10 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, supab
|
|
|
50
72
|
const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
|
|
51
73
|
findings.push(...await migrationShapeFindings({ repoRoot, migrations, git: git ?? defaultGit, base, now }));
|
|
52
74
|
|
|
53
|
-
const analyses =
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
|
-
const analysis = await sqlAnalysis({ file, text, exposed, forceRls });
|
|
61
|
-
findings.push(...analysis.findings);
|
|
62
|
-
if (analysis.facts) analyses.push({ file, facts: analysis.facts });
|
|
63
|
-
}
|
|
64
|
-
findings.push(...migrationSetPolicyFindings(analyses));
|
|
65
|
-
|
|
66
|
-
findings.push(...await typesFindings({ repoRoot, files, emitTypes, declaration: declared }));
|
|
75
|
+
const analyses = await migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings });
|
|
76
|
+
findings.push(
|
|
77
|
+
...migrationSetPolicyFindings(analyses),
|
|
78
|
+
...await typesFindings({ repoRoot, files, emitTypes, declaration: declared }),
|
|
79
|
+
);
|
|
67
80
|
return findings;
|
|
68
81
|
}
|