@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.
@@ -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.11"
12
+ export const EMBEDDED_CLI_VERSION: string = "0.1.12"
@@ -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 = ["anon", "authenticated", "service_role"].filter((r) => !held.has(r))
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
+ })