@supatype/cli 0.1.11 → 0.1.12
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/.turbo/turbo-build.log +1 -1
- package/.turbo/turbo-test.log +135 -135
- package/.turbo/turbo-typecheck.log +1 -1
- package/dist/cli-version-embedded.js +1 -1
- package/dist/db-preflight.d.ts +23 -0
- package/dist/db-preflight.d.ts.map +1 -1
- package/dist/db-preflight.js +66 -1
- package/dist/db-preflight.js.map +1 -1
- package/package.json +1 -1
- package/src/cli-version-embedded.ts +1 -1
- package/src/db-preflight.ts +89 -1
- package/tests/db-preflight.test.ts +80 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -9,4 +9,4 @@
|
|
|
9
9
|
// Annotated `string`, not left to inference: once a release stamps a value here, the
|
|
10
10
|
// inferred literal type makes the `!== ""` test in cliPackageVersion() a compile error
|
|
11
11
|
// (TS2367, no overlap), so the build would only fail during a release.
|
|
12
|
-
export const EMBEDDED_CLI_VERSION: string = "0.1.
|
|
12
|
+
export const EMBEDDED_CLI_VERSION: string = "0.1.12"
|
package/src/db-preflight.ts
CHANGED
|
@@ -113,6 +113,16 @@ async function probeLogicalDecoding(q: QueryFn): Promise<CheckResult> {
|
|
|
113
113
|
|
|
114
114
|
const REQUIRED_ROLES = ["anon", "authenticated", "service_role", "authenticator"] as const
|
|
115
115
|
|
|
116
|
+
/** The three roles PostgREST switches to per request. `authenticator` only connects and switches. */
|
|
117
|
+
const API_ROLES = ["anon", "authenticated", "service_role"] as const
|
|
118
|
+
|
|
119
|
+
/** One row per API role that exists, for the schema named in the query. */
|
|
120
|
+
export interface AuthSchemaUsageRow {
|
|
121
|
+
rolname: string | null
|
|
122
|
+
has_usage: boolean | null
|
|
123
|
+
owner: string
|
|
124
|
+
}
|
|
125
|
+
|
|
116
126
|
/**
|
|
117
127
|
* Stands in for the `authenticator` password when the operator has not supplied one.
|
|
118
128
|
*
|
|
@@ -309,7 +319,7 @@ export async function runPreflight(
|
|
|
309
319
|
WHERE g.rolname = 'authenticator'`,
|
|
310
320
|
)
|
|
311
321
|
const held = new Set(memberships.map((m) => m.rolname))
|
|
312
|
-
const needed =
|
|
322
|
+
const needed = API_ROLES.filter((r) => !held.has(r))
|
|
313
323
|
results.push({
|
|
314
324
|
id: "authenticator-memberships",
|
|
315
325
|
title: "authenticator can reach the three API roles",
|
|
@@ -322,6 +332,19 @@ export async function runPreflight(
|
|
|
322
332
|
})
|
|
323
333
|
}
|
|
324
334
|
|
|
335
|
+
// USAGE on the auth schema, asked by OID so a role that does not exist cannot raise
|
|
336
|
+
// `role "x" does not exist` the way the name-taking overload would.
|
|
337
|
+
const authUsage = await q<AuthSchemaUsageRow>(
|
|
338
|
+
`SELECT r.rolname,
|
|
339
|
+
has_schema_privilege(r.oid, n.oid, 'USAGE') AS has_usage,
|
|
340
|
+
pg_get_userbyid(n.nspowner) AS owner
|
|
341
|
+
FROM pg_namespace n
|
|
342
|
+
LEFT JOIN pg_roles r ON r.rolname = ANY($1)
|
|
343
|
+
WHERE n.nspname = 'auth'`,
|
|
344
|
+
[API_ROLES as unknown as string[]],
|
|
345
|
+
)
|
|
346
|
+
results.push(authSchemaUsageCheck(authUsage, privs))
|
|
347
|
+
|
|
325
348
|
// ── Extensions ─────────────────────────────────────────────────────────────
|
|
326
349
|
const installed = new Set(
|
|
327
350
|
(await q<{ extname: string }>("SELECT extname FROM pg_extension")).map((r) => r.extname),
|
|
@@ -456,6 +479,71 @@ export async function runPreflight(
|
|
|
456
479
|
return { results, worst: worstOf(results) }
|
|
457
480
|
}
|
|
458
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Can the API roles reach the auth helpers when a query calls them?
|
|
484
|
+
*
|
|
485
|
+
* Postgres evaluates a policy's `USING` clause with the table owner's privileges, so row-level
|
|
486
|
+
* security works without this grant and looks like proof the schema is fine. Field masking does
|
|
487
|
+
* not: `supatype_mask` rewrites a masked column into `CASE WHEN can_read_t__c(t) …` in the target
|
|
488
|
+
* list, which runs as the caller and reaches `auth.role()` directly. Studio's per-record
|
|
489
|
+
* `can_<op>_<table>` calls have the same shape.
|
|
490
|
+
*
|
|
491
|
+
* `supatype/postgres` grants this at initdb, so a missing grant means a database Supatype did not
|
|
492
|
+
* bootstrap. Kept a `degrade` rather than a `fail`: two named features stop working and the rest
|
|
493
|
+
* of the stack is unaffected.
|
|
494
|
+
*/
|
|
495
|
+
export function authSchemaUsageCheck(
|
|
496
|
+
rows: AuthSchemaUsageRow[],
|
|
497
|
+
privs: { current_user: string; is_super: boolean },
|
|
498
|
+
): CheckResult {
|
|
499
|
+
const base = { id: "auth-schema-usage", title: "USAGE on schema auth (API roles)" }
|
|
500
|
+
|
|
501
|
+
// No auth schema yet: the first push creates it and grants on it, so there is nothing to fix.
|
|
502
|
+
if (rows.length === 0) {
|
|
503
|
+
return {
|
|
504
|
+
...base,
|
|
505
|
+
severity: "pass",
|
|
506
|
+
detail: "schema auth does not exist yet; the first push creates it and grants usage",
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const owner = rows[0]!.owner
|
|
511
|
+
const present = rows.filter((r) => r.rolname !== null)
|
|
512
|
+
|
|
513
|
+
// The roles check above owns this finding; repeating it here as a privilege problem would send
|
|
514
|
+
// the operator after the wrong fix.
|
|
515
|
+
if (present.length === 0) {
|
|
516
|
+
return { ...base, severity: "pass", detail: "no API roles exist on this server yet" }
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const lacking = present.filter((r) => !r.has_usage).map((r) => r.rolname!)
|
|
520
|
+
if (lacking.length === 0) {
|
|
521
|
+
return {
|
|
522
|
+
...base,
|
|
523
|
+
severity: "pass",
|
|
524
|
+
detail: `granted to ${present.map((r) => r.rolname).join(", ")} (schema owned by "${owner}")`,
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// A grant from a role that owns neither the schema nor the server is discarded with a WARNING
|
|
529
|
+
// rather than an error, so `--fix` would report "Applied" having changed nothing. That is the
|
|
530
|
+
// same fault as a privilege generated and never applied, so hand it to the operator instead.
|
|
531
|
+
const canGrant = owner === privs.current_user || privs.is_super
|
|
532
|
+
|
|
533
|
+
return {
|
|
534
|
+
...base,
|
|
535
|
+
severity: "degrade",
|
|
536
|
+
detail: `not granted to ${lacking.join(", ")} (schema owned by "${owner}")`,
|
|
537
|
+
impact:
|
|
538
|
+
"Field-level access is unavailable: a push declaring `access.fields` refuses rather than " +
|
|
539
|
+
"applying, and a masked column read by one of these roles fails with 42501 instead of " +
|
|
540
|
+
"masking. Studio's per-record permission checks fail the same way. Row-level security is " +
|
|
541
|
+
"unaffected, because Postgres evaluates policies with the table owner's privileges.",
|
|
542
|
+
remedy: `GRANT USAGE ON SCHEMA auth TO ${lacking.map(ident).join(", ")};`,
|
|
543
|
+
...(!canGrant && { remedyNeedsOperator: true }),
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
459
547
|
function extensionCheck(
|
|
460
548
|
name: string,
|
|
461
549
|
installed: Set<string>,
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import {
|
|
3
3
|
PASSWORD_PLACEHOLDER,
|
|
4
|
+
authSchemaUsageCheck,
|
|
5
|
+
type AuthSchemaUsageRow,
|
|
4
6
|
needsOperatorPassword,
|
|
5
7
|
operatorRemedies,
|
|
6
8
|
transactionalRemedies,
|
|
@@ -70,3 +72,81 @@ describe("the authenticator password guard", () => {
|
|
|
70
72
|
expect(PASSWORD_PLACEHOLDER).toMatch(/^<.*>$/)
|
|
71
73
|
})
|
|
72
74
|
})
|
|
75
|
+
|
|
76
|
+
describe("USAGE on the auth schema", () => {
|
|
77
|
+
const row = (rolname: string, has_usage: boolean, owner = "supatype_admin"): AuthSchemaUsageRow => ({
|
|
78
|
+
rolname,
|
|
79
|
+
has_usage,
|
|
80
|
+
owner,
|
|
81
|
+
})
|
|
82
|
+
const asOwner = { current_user: "supatype_admin", is_super: false }
|
|
83
|
+
const asStranger = { current_user: "app_migrator", is_super: false }
|
|
84
|
+
|
|
85
|
+
// Before the first push there is no auth schema, and the push creates it and grants on it.
|
|
86
|
+
// Reporting that as a problem would send the operator to fix something that fixes itself.
|
|
87
|
+
it("says nothing when the schema does not exist", () => {
|
|
88
|
+
const check = authSchemaUsageCheck([], asOwner)
|
|
89
|
+
expect(check.severity).toBe("pass")
|
|
90
|
+
expect(check.remedy).toBeUndefined()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("passes when all three roles hold usage, and names the owner", () => {
|
|
94
|
+
const check = authSchemaUsageCheck(
|
|
95
|
+
[row("anon", true), row("authenticated", true), row("service_role", true)],
|
|
96
|
+
asOwner,
|
|
97
|
+
)
|
|
98
|
+
expect(check.severity).toBe("pass")
|
|
99
|
+
expect(check.detail).toContain("supatype_admin")
|
|
100
|
+
expect(check.remedy).toBeUndefined()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// Degrade, not fail: REST and RLS keep working because policies run with the table owner's
|
|
104
|
+
// privileges. Calling it a failure would exit non-zero on databases using neither feature.
|
|
105
|
+
it("degrades when a role lacks usage, and grants exactly that role", () => {
|
|
106
|
+
const check = authSchemaUsageCheck(
|
|
107
|
+
[row("anon", true), row("authenticated", false), row("service_role", true)],
|
|
108
|
+
asOwner,
|
|
109
|
+
)
|
|
110
|
+
expect(check.severity).toBe("degrade")
|
|
111
|
+
expect(check.remedy).toBe('GRANT USAGE ON SCHEMA auth TO "authenticated";')
|
|
112
|
+
expect(check.impact).toContain("access.fields")
|
|
113
|
+
expect(check.impact).toContain("42501")
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("grants every lacking role in one statement", () => {
|
|
117
|
+
const check = authSchemaUsageCheck(
|
|
118
|
+
[row("anon", false), row("authenticated", false), row("service_role", true)],
|
|
119
|
+
asOwner,
|
|
120
|
+
)
|
|
121
|
+
expect(check.remedy).toBe('GRANT USAGE ON SCHEMA auth TO "anon", "authenticated";')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// `--fix` applies remedies in a transaction and reports "Applied". Postgres discards a grant
|
|
125
|
+
// from a non-owner with a WARNING rather than an error, so without this flag `--fix` would
|
|
126
|
+
// report success having changed nothing.
|
|
127
|
+
it("hands the remedy to the operator when the caller cannot grant it", () => {
|
|
128
|
+
const check = authSchemaUsageCheck([row("authenticated", false)], asStranger)
|
|
129
|
+
expect(check.remedyNeedsOperator).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("applies it itself when the caller owns the schema", () => {
|
|
133
|
+
const check = authSchemaUsageCheck([row("authenticated", false)], asOwner)
|
|
134
|
+
expect(check.remedyNeedsOperator).toBeUndefined()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it("applies it itself when the caller is a superuser on someone else's schema", () => {
|
|
138
|
+
const check = authSchemaUsageCheck(
|
|
139
|
+
[row("authenticated", false, "someone_else")],
|
|
140
|
+
{ current_user: "postgres", is_super: true },
|
|
141
|
+
)
|
|
142
|
+
expect(check.remedyNeedsOperator).toBeUndefined()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// A LEFT JOIN against a server with none of the API roles yields one all-null row. The roles
|
|
146
|
+
// check owns that finding; reporting it here as a privilege problem points at the wrong fix.
|
|
147
|
+
it("defers to the roles check when no API role exists", () => {
|
|
148
|
+
const check = authSchemaUsageCheck([{ rolname: null, has_usage: null, owner: "postgres" }], asOwner)
|
|
149
|
+
expect(check.severity).toBe("pass")
|
|
150
|
+
expect(check.detail).toContain("no API roles")
|
|
151
|
+
})
|
|
152
|
+
})
|