@homeflare/alchemy 0.44.0 → 0.46.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.
Files changed (130) hide show
  1. package/dist/postgres/fake-role-sql.d.ts +2 -1
  2. package/dist/postgres/fake-role-sql.d.ts.map +1 -1
  3. package/dist/postgres/fake-sql.d.ts +3 -3
  4. package/dist/postgres/fake-sql.d.ts.map +1 -1
  5. package/dist/postgres/index.d.ts +1 -1
  6. package/dist/postgres/index.d.ts.map +1 -1
  7. package/dist/postgres/index.js +117 -94
  8. package/dist/postgres/index.js.map +11 -11
  9. package/dist/postgres/role-attrs.d.ts +7 -4
  10. package/dist/postgres/role-attrs.d.ts.map +1 -1
  11. package/dist/postgres/role-diff.d.ts +2 -2
  12. package/dist/postgres/role-diff.d.ts.map +1 -1
  13. package/dist/postgres/role-errors.d.ts +13 -7
  14. package/dist/postgres/role-errors.d.ts.map +1 -1
  15. package/dist/postgres/role-membership-sql.d.ts +30 -27
  16. package/dist/postgres/role-membership-sql.d.ts.map +1 -1
  17. package/dist/postgres/role-provider.d.ts.map +1 -1
  18. package/dist/postgres/role-scram.d.ts +2 -0
  19. package/dist/postgres/role-scram.d.ts.map +1 -1
  20. package/dist/postgres/role-secrets.d.ts +8 -0
  21. package/dist/postgres/role-secrets.d.ts.map +1 -1
  22. package/dist/postgres/role-sql.d.ts.map +1 -1
  23. package/dist/postgres/role.d.ts +2 -2
  24. package/dist/postgres/role.d.ts.map +1 -1
  25. package/dist/proxmox/generated/constraints/index.d.ts +1 -1
  26. package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
  27. package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
  28. package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
  29. package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
  30. package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
  31. package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
  32. package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
  33. package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
  34. package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
  35. package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
  36. package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
  37. package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
  38. package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
  39. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
  40. package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts.map +1 -1
  41. package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +1 -1
  42. package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
  43. package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
  44. package/dist/proxmox/index.d.ts +1 -0
  45. package/dist/proxmox/index.d.ts.map +1 -1
  46. package/dist/proxmox/index.js +283 -130
  47. package/dist/proxmox/index.js.map +31 -27
  48. package/dist/proxmox/qemu-dns.d.ts +2 -0
  49. package/dist/proxmox/qemu-dns.d.ts.map +1 -0
  50. package/dist/proxmox/qemu-errors.d.ts +1 -0
  51. package/dist/proxmox/qemu-errors.d.ts.map +1 -1
  52. package/dist/proxmox/qemu-form.d.ts.map +1 -1
  53. package/dist/proxmox/qemu-judge.d.ts.map +1 -1
  54. package/dist/proxmox/qemu-lifecycle.d.ts +15 -10
  55. package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
  56. package/dist/proxmox/qemu-props.d.ts +10 -0
  57. package/dist/proxmox/qemu-props.d.ts.map +1 -1
  58. package/dist/proxmox/qemu-resize.d.ts +10 -0
  59. package/dist/proxmox/qemu-resize.d.ts.map +1 -0
  60. package/dist/proxmox/qemu-size.d.ts +20 -0
  61. package/dist/proxmox/qemu-size.d.ts.map +1 -0
  62. package/dist/proxmox/qemu-spec.d.ts +25 -0
  63. package/dist/proxmox/qemu-spec.d.ts.map +1 -0
  64. package/dist/verify/fake-engine.d.ts +4 -2
  65. package/dist/verify/fake-engine.d.ts.map +1 -1
  66. package/docs/postgres-role.md +18 -6
  67. package/docs/proxmox-qemu-resize.md +62 -0
  68. package/package.json +1 -1
  69. package/src/ceph/ceph-argv.test.ts +1 -1
  70. package/src/launchd/sudo-allowlist.test.ts +2 -2
  71. package/src/linux/sudo-allowlist.test.ts +3 -3
  72. package/src/linux/sudo-runner.test.ts +2 -2
  73. package/src/postgres/fake-role-sql.ts +26 -6
  74. package/src/postgres/fake-sql.ts +10 -5
  75. package/src/postgres/index.ts +1 -0
  76. package/src/postgres/role-attrs.ts +7 -4
  77. package/src/postgres/role-diff.ts +7 -3
  78. package/src/postgres/role-errors.ts +22 -14
  79. package/src/postgres/role-guard.test.ts +3 -3
  80. package/src/postgres/role-identity.test.ts +14 -2
  81. package/src/postgres/role-lifecycle.test.ts +2 -2
  82. package/src/postgres/role-membership-options.test.ts +73 -0
  83. package/src/postgres/role-membership-repair.test.ts +153 -0
  84. package/src/postgres/role-membership-sql.ts +88 -63
  85. package/src/postgres/role-password-ascii.test.ts +53 -0
  86. package/src/postgres/role-password-transports.test.ts +123 -0
  87. package/src/postgres/role-privilege.test.ts +37 -10
  88. package/src/postgres/role-provenance.test.ts +1 -1
  89. package/src/postgres/role-provider.ts +3 -2
  90. package/src/postgres/role-refusals.test.ts +0 -25
  91. package/src/postgres/role-scram.ts +2 -0
  92. package/src/postgres/role-secrets.ts +15 -0
  93. package/src/postgres/role-sql.ts +3 -1
  94. package/src/postgres/role.ts +9 -1
  95. package/src/postgres/schema-socket.test.ts +1 -0
  96. package/src/proxmox/generated/constraints/index.ts +1 -1
  97. package/src/proxmox/generated/constraints/pve-access.ts +1 -1
  98. package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
  99. package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
  100. package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
  101. package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
  102. package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
  103. package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
  104. package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
  105. package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
  106. package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
  107. package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
  108. package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
  109. package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
  110. package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +6 -1
  111. package/src/proxmox/generated/constraints/pve-nodes-storage.ts +1 -1
  112. package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
  113. package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
  114. package/src/proxmox/index.ts +1 -0
  115. package/src/proxmox/qemu-dns.test.ts +73 -0
  116. package/src/proxmox/qemu-dns.ts +32 -0
  117. package/src/proxmox/qemu-errors.ts +1 -0
  118. package/src/proxmox/qemu-form.ts +6 -3
  119. package/src/proxmox/qemu-judge.ts +2 -1
  120. package/src/proxmox/qemu-lifecycle.ts +28 -47
  121. package/src/proxmox/qemu-props.ts +14 -2
  122. package/src/proxmox/qemu-resize-lock.test.ts +121 -0
  123. package/src/proxmox/qemu-resize-recovery.test.ts +81 -0
  124. package/src/proxmox/qemu-resize.test.ts +199 -0
  125. package/src/proxmox/qemu-resize.ts +86 -0
  126. package/src/proxmox/qemu-size.test.ts +64 -0
  127. package/src/proxmox/qemu-size.ts +95 -0
  128. package/src/proxmox/qemu-spec.ts +44 -0
  129. package/src/proxmox/qemu-volume.ts +5 -5
  130. package/src/verify/fake-engine.ts +6 -3
@@ -16,12 +16,13 @@ import type { PostgresRoleAttributes, PostgresRoleProps } from './role-attrs.ts'
16
16
  import {
17
17
  PostgresRoleIdentityRefused,
18
18
  type PostgresRoleNameRefused,
19
+ type PostgresRolePasswordNonAsciiRefused,
19
20
  PostgresRoleRenameRefused,
20
21
  type PostgresRoleValidUntilRefused,
21
22
  } from './role-errors.ts';
22
23
  import { membershipDrift, unsafeMemberships } from './role-membership-sql.ts';
23
- import { passwordMatchesSeal, resolvePassword } from './role-secrets.ts';
24
- import { scalarDrift } from './role-sql.ts';
24
+ import { assertPasswordAscii, passwordMatchesSeal, resolvePassword } from './role-secrets.ts';
25
+ import { privilegedFlags, scalarDrift } from './role-sql.ts';
25
26
  import { refuseAtPlan } from './role.ts';
26
27
 
27
28
  /** A catalog read the provider performed for this diff. `found: undefined` means the name is
@@ -47,6 +48,7 @@ export const diffPostgresRole = (
47
48
  | PostgresRoleNameRefused
48
49
  | PostgresRoleValidUntilRefused
49
50
  | PostgresRoleIdentityRefused
51
+ | PostgresRolePasswordNonAsciiRefused
50
52
  > =>
51
53
  Effect.gen(function* () {
52
54
  if (output === undefined || !isResolved(news)) return undefined;
@@ -73,14 +75,16 @@ export const diffPostgresRole = (
73
75
  const membership = membershipDrift(
74
76
  news.memberOf,
75
77
  compared.memberOf ?? [],
76
- live === undefined ? new Set() : unsafeMemberships(live),
78
+ live === undefined ? new Set() : unsafeMemberships(live, news.inherit),
77
79
  );
78
80
  const resolved = resolvePassword(news, env);
81
+ yield* assertPasswordAscii(news.name, resolved);
79
82
  const passwordStale =
80
83
  resolved.value !== undefined && !passwordMatchesSeal(resolved.value, output.passwordSeal);
81
84
  const absent = observation !== undefined && live === undefined;
82
85
  const changed =
83
86
  absent ||
87
+ privilegedFlags(compared).length > 0 ||
84
88
  scalars.length > 0 ||
85
89
  membership.grants.length > 0 ||
86
90
  membership.revokes.length > 0 ||
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * `Postgres.Role` failures as typed tags (S21).
3
3
  *
4
- * ⛔ NONE OF THESE IS A STATUS-CODE OR MESSAGE MATCH. Every one is raised from a fact the
4
+ * ⛔ NEVER MATCH DRIVER MESSAGE TEXT. Most errors are raised from a fact the
5
5
  * provider already checked (a byte count, a live catalog row, a missing environment variable,
6
- * a vanished create) — never from sniffing a driver error's text. A `DROP ROLE` that the
6
+ * a vanished create). Membership repair also maps raw SQLSTATE 2BP01/42501 to a typed
7
+ * refusal naming the observed parent and grantor. A `DROP ROLE` that the
7
8
  * server refuses (the role still owns objects, `2BP01 dependent_objects_still_exist`) surfaces
8
9
  * as the `SqlError` the client raised, unclassified by this family.
9
10
  */
@@ -122,12 +123,9 @@ export class PostgresRoleCreateVanished extends Data.TaggedError('PostgresRoleCr
122
123
  }
123
124
  }
124
125
 
125
- /** A `pg_auth_members` row survived reconcile in a shape the declaration does not allow — not
126
- * wanted, still `ADMIN`, or still `SET` at the upstream default TRUE. A live grantor's row is
127
- * named in `REVOKE … GRANTED BY`, which either binds or fails loudly; the one shape no
128
- * statement this family may issue can touch is a row whose grantor role was dropped
129
- * (`pg_auth_members` keeps it keyed on the dead oid, and no `GRANTED BY` name reaches it), so
130
- * the message asks the operator to revoke it by hand as a bootstrap superuser and re-plan. */
126
+ /** An option repair was refused, or a catalog row survived outside the declaration. A null grantor is a
127
+ * defensive corruption/test case: PostgreSQL tracks grantor dependencies, so ordinary
128
+ * DROP ROLE does not leave a dangling grantor. Refuse instead of claiming convergence. */
131
129
  export class PostgresRoleMembershipUnrepaired extends Data.TaggedError(
132
130
  'PostgresRoleMembershipUnrepaired',
133
131
  )<{
@@ -138,15 +136,15 @@ export class PostgresRoleMembershipUnrepaired extends Data.TaggedError(
138
136
  }> {
139
137
  override get message(): string {
140
138
  const who =
141
- this.grantor === null ? 'a grantor role that no longer exists' : `grantor "${this.grantor}"`;
139
+ this.grantor === null
140
+ ? 'an unresolvable grantor (inconsistent catalog)'
141
+ : `grantor "${this.grantor}"`;
142
142
  return (
143
143
  `Postgres.Role "${this.role}": membership of "${this.parent}" ` +
144
144
  `(${this.declared ? 'declared, options unsafe' : 'not declared'}) could not be repaired ` +
145
- `after its statements — the row still stands under ${who}. pg_auth_members keys a ` +
146
- 'membership on (parent, member, grantor), and a grantor that no longer exists cannot be ' +
147
- 'named in REVOKE … GRANTED BY. Revoke the row by hand as a bootstrap superuser ' +
148
- '(`REVOKE role FROM member GRANTED BY <grantor>`; a grantor oid with no role name needs ' +
149
- 'catalog surgery) and re-plan.'
145
+ `under ${who}. The server refused the repair or the catalog re-read still found the row ` +
146
+ 'outside the declaration. pg_auth_members keys a membership on (parent, member, grantor). ' +
147
+ 'Check dependent grants, executor permissions and grantor identity before re-planning.'
150
148
  );
151
149
  }
152
150
  }
@@ -186,7 +184,17 @@ export class PostgresRoleIdentityRefused extends Data.TaggedError('PostgresRoleI
186
184
  }
187
185
  }
188
186
 
187
+ /** Never carry the rejected password in this error: errors reach logs and traces. */
188
+ export class PostgresRolePasswordNonAsciiRefused extends Data.TaggedError(
189
+ 'PostgresRolePasswordNonAsciiRefused',
190
+ )<{ readonly role: string }> {
191
+ override get message(): string {
192
+ return `Postgres.Role "${this.role}": password must be ASCII; this provider does not implement PostgreSQL SASLprep normalization. Nothing was written.`;
193
+ }
194
+ }
195
+
189
196
  export type PostgresRoleError =
197
+ | PostgresRolePasswordNonAsciiRefused
190
198
  | PostgresRoleNameRefused
191
199
  | PostgresRoleRenameRefused
192
200
  | PostgresRoleValidUntilRefused
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The create must not survive a missing parent, and a membership whose grantor role is gone
2
+ * The create must not survive a missing parent, and a synthetic inconsistent-catalog membership with no grantor
3
3
  * must fail typed rather than look repaired.
4
4
  */
5
5
  import { describe, expect, test } from 'bun:test';
@@ -30,7 +30,7 @@ const live = (): PostgresRoleAttributes => ({
30
30
  passwordSeal: '',
31
31
  });
32
32
 
33
- describe('missing parent and dead grantor', () => {
33
+ describe('missing parent and inconsistent grantor', () => {
34
34
  test('a missing parent fails typed and issues no CREATE', async () => {
35
35
  const fake = makeFakeSql();
36
36
  const error = await fails(reconcileWithClient(fake, props));
@@ -48,7 +48,7 @@ describe('missing parent and dead grantor', () => {
48
48
  expect(fake.statements.some((s) => s.text.startsWith('CREATE ROLE'))).toBe(true);
49
49
  });
50
50
 
51
- test('a wanted membership whose grantor role is gone fails typed', async () => {
51
+ test('a synthetic unresolvable grantor fails typed (not an ordinary DROP ROLE outcome)', async () => {
52
52
  const fake = makeFakeSql({ roleRows: [live()], roles: ['hf_agent'] });
53
53
  fake.memberships.add('seat-observability\0hf_agent\0');
54
54
  fake.membershipOptions.set('seat-observability\0hf_agent\0', { admin: true, set: true });
@@ -45,8 +45,9 @@ const runnerFor =
45
45
  stdin,
46
46
  );
47
47
  const body = (wrapped?.[1] ?? stdin).replace(/;\s*$/, '');
48
- const bound = /^([\s\S]*) = (E'[\s\S]*')$/.exec(body);
49
- const sql = bound === null ? body : `${bound[1]} = $1`;
48
+ // Membership reads have ORDER BY after the bound name; preserve that suffix.
49
+ const bound = /^([\s\S]*) = (E'(?:\\.|''|[^'\\])*')([\s\S]*)$/.exec(body);
50
+ const sql = bound === null ? body : `${bound[1]} = $1${bound[3]}`;
50
51
  const params = bound === null ? [] : [parseLiteral(bound[2] as string)];
51
52
  const rows = await Effect.runPromise(fake.unsafe(sql, params));
52
53
  return { code: 0, stdout: JSON.stringify(rows), stderr: '' };
@@ -74,6 +75,17 @@ const ids = {
74
75
  };
75
76
 
76
77
  describe('oid and live membership options', () => {
78
+ test('read returns the reconcile shape, without observe-only memberships', async () => {
79
+ const fake = makeFakeSql({ roleRows: [stored()], roles: ['hf_agent'] });
80
+ fake.memberships.add('seat-observability\0hf_agent\0postgres');
81
+ const output = stored();
82
+ const found = await Effect.runPromise(
83
+ readRoleHandler({ ...ids, olds: baseProps, output }).pipe(Effect.provide(layerFor(fake))),
84
+ );
85
+ expect(found).toEqual(output);
86
+ expect(found).not.toHaveProperty('memberships');
87
+ });
88
+
77
89
  test('a recycled oid is refused on read, diff and delete, and delete issues no DROP', async () => {
78
90
  const fake = makeFakeSql({ roleRows: [{ ...stored(), oid: 7 }] });
79
91
  const output = stored();
@@ -206,8 +206,8 @@ describe('read', () => {
206
206
  validUntil: null,
207
207
  memberOf: ['hf_agent', 'seat-b'],
208
208
  memberships: [
209
- { parent: 'hf_agent', grantor: 'postgres', admin: false, set: false },
210
- { parent: 'seat-b', grantor: 'postgres', admin: false, set: false },
209
+ { parent: 'hf_agent', grantor: 'postgres', inherit: false, admin: false, set: false },
210
+ { parent: 'seat-b', grantor: 'postgres', inherit: false, admin: false, set: false },
211
211
  ],
212
212
  });
213
213
  });
@@ -0,0 +1,73 @@
1
+ import { expect, test } from 'bun:test';
2
+ import * as Effect from 'effect/Effect';
3
+ import { makeFakeSql } from './fake-sql.ts';
4
+ import { buildRepairMembershipSql, syncMemberships } from './role-membership-sql.ts';
5
+ import { makePsqlExecutor } from './psql-executor.ts';
6
+
7
+ const props = {
8
+ name: 'seat',
9
+ login: false,
10
+ inherit: true,
11
+ connectionLimit: -1,
12
+ memberOf: ['parent'],
13
+ };
14
+ const rows = [{ parent: 'parent', grantor: 'postgres', inherit: true, admin: true, set: true }];
15
+
16
+ test('a failed option revoke rolls back earlier membership changes', async () => {
17
+ const fake = makeFakeSql({ roles: ['parent'], failNext: 'REVOKE SET OPTION' });
18
+ const key = 'seat\0parent\0postgres';
19
+ fake.memberships.add(key);
20
+ fake.membershipOptions.set(key, { admin: true, set: true });
21
+ const error = await Effect.runPromise(
22
+ syncMemberships(fake, props, rows).pipe(Effect.catchTag('SqlError', Effect.succeed)),
23
+ );
24
+ expect(error).toHaveProperty('_tag', 'SqlError');
25
+ expect(fake.memberships.has(key)).toBe(true);
26
+ expect(fake.membershipOptions.get(key)).toEqual({ admin: true, set: true });
27
+ });
28
+
29
+ test.each(['2BP01', '42501'])(
30
+ 'repair refusal %s names parent and grantor over psql',
31
+ async (code) => {
32
+ // Measured on PG 17.11, 2026-10-02: RESTRICT refuses while member has granted parent
33
+ // onward; both membership rows remain. This test STUBS that response (and permission
34
+ // denial); the real server is the dependency authority.
35
+ const scripts: string[] = [];
36
+ const pg = makePsqlExecutor(
37
+ ({ stdin }) => {
38
+ scripts.push(stdin);
39
+ return Promise.resolve(
40
+ stdin.startsWith('BEGIN')
41
+ ? {
42
+ code: 3,
43
+ stdout: '',
44
+ stderr: `ERROR: ${code}: repair refused`,
45
+ }
46
+ : { code: 0, stdout: '[{"present":1}]', stderr: '' },
47
+ );
48
+ },
49
+ { username: 'postgres', database: 'postgres' },
50
+ );
51
+ const error = await Effect.runPromise(
52
+ syncMemberships(pg, props, rows).pipe(
53
+ Effect.catchTag('PostgresRoleMembershipUnrepaired', Effect.succeed),
54
+ ),
55
+ );
56
+ expect(error).toMatchObject({
57
+ _tag: 'PostgresRoleMembershipUnrepaired',
58
+ parent: 'parent',
59
+ grantor: 'postgres',
60
+ });
61
+ expect(String(error)).toContain('parent');
62
+ expect(String(error)).toContain('postgres');
63
+ expect(scripts.at(-1)).toBe(
64
+ 'BEGIN;\nREVOKE ADMIN OPTION FOR "parent" FROM "seat" GRANTED BY "postgres" RESTRICT;\nREVOKE SET OPTION FOR "parent" FROM "seat" GRANTED BY "postgres" RESTRICT;\nCOMMIT;',
65
+ );
66
+ },
67
+ );
68
+
69
+ test('option revocation quotes all three identities', () => {
70
+ expect(buildRepairMembershipSql('m"', 'p"', 'g"', 'ADMIN')).toBe(
71
+ 'REVOKE ADMIN OPTION FOR "p""" FROM "m""" GRANTED BY "g""" RESTRICT',
72
+ );
73
+ });
@@ -0,0 +1,153 @@
1
+ import { expect, test } from 'bun:test';
2
+ import * as Effect from 'effect/Effect';
3
+ import type { PgExecutor } from './database-sql.ts';
4
+ import { makeFakeSql } from './fake-sql.ts';
5
+ import { classifyInstalled } from './installed-classifier.ts';
6
+ import { diffPostgresRole } from './role-diff.ts';
7
+ import { selectRoleMemberships, syncMemberships } from './role-membership-sql.ts';
8
+ import { declarationMatches } from './role-sql.ts';
9
+ import { readRole, reconcileWithClient } from './role.ts';
10
+
11
+ const props = {
12
+ name: 'seat',
13
+ login: false,
14
+ inherit: false,
15
+ connectionLimit: -1,
16
+ memberOf: ['parent'],
17
+ };
18
+ const live = { ...props, oid: 42, validUntil: null, passwordSeal: '' };
19
+ const unsafeRow = { parent: 'parent', grantor: 'other', admin: true, set: true, inherit: false };
20
+
21
+ test.each(['2BP01', '42501'])(
22
+ 'unwanted revokes stay committed after repair refusal %s',
23
+ async (code) => {
24
+ const fake = makeFakeSql({ roles: ['parent'], roleRows: [live] });
25
+ const unwanted = 'seat\0unwanted\0postgres';
26
+ const kept = 'seat\0parent\0other';
27
+ fake.memberships.add(unwanted);
28
+ fake.memberships.add(kept);
29
+ fake.membershipOptions.set(kept, unsafeRow);
30
+ const refusal = await classifyInstalled(code);
31
+ const batches: string[][] = [];
32
+ const pg: PgExecutor = {
33
+ unsafe: fake.unsafe,
34
+ transaction: (sqls) => {
35
+ batches.push([...sqls]);
36
+ return sqls.some((sql) => sql.startsWith('REVOKE ADMIN OPTION'))
37
+ ? Effect.fail(refusal)
38
+ : fake.transaction(sqls);
39
+ },
40
+ };
41
+ const error = await Effect.runPromise(
42
+ reconcileWithClient(pg, props, {}).pipe(
43
+ Effect.catchTag('PostgresRoleMembershipUnrepaired', Effect.succeed),
44
+ ),
45
+ );
46
+ expect(error).toMatchObject({
47
+ _tag: 'PostgresRoleMembershipUnrepaired',
48
+ role: 'seat',
49
+ parent: 'parent',
50
+ grantor: 'other',
51
+ declared: true,
52
+ });
53
+ expect(String(error)).toContain('"parent"');
54
+ expect(String(error)).toContain('"other"');
55
+ expect(batches).toEqual([
56
+ ['REVOKE "unwanted" FROM "seat" GRANTED BY "postgres"'],
57
+ [
58
+ 'REVOKE ADMIN OPTION FOR "parent" FROM "seat" GRANTED BY "other" RESTRICT',
59
+ 'REVOKE SET OPTION FOR "parent" FROM "seat" GRANTED BY "other" RESTRICT',
60
+ ],
61
+ ]);
62
+ expect(fake.memberships.has(unwanted)).toBe(false);
63
+ expect(fake.memberships.has(kept)).toBe(true);
64
+ expect(fake.membershipOptions.get(kept)).toEqual(unsafeRow);
65
+ },
66
+ );
67
+
68
+ test('unrelated socket errors propagate unchanged', async () => {
69
+ const fake = makeFakeSql({ roles: ['parent'] });
70
+ const refusal = await classifyInstalled('42601');
71
+ const pg: PgExecutor = { unsafe: fake.unsafe, transaction: () => Effect.fail(refusal) };
72
+ expect(await Effect.runPromise(Effect.flip(syncMemberships(pg, props, [unsafeRow])))).toBe(
73
+ refusal,
74
+ );
75
+ });
76
+
77
+ test('reads INHERIT and repairs another grantor row for declared NOINHERIT', async () => {
78
+ const fake = makeFakeSql({ roles: ['parent'], roleRows: [live] });
79
+ const key = 'seat\0parent\0other';
80
+ fake.memberships.add(key);
81
+ fake.membershipOptions.set(key, { admin: false, set: false, inherit: true });
82
+ const before = await Effect.runPromise(readRole(fake, 'seat'));
83
+ if (before === undefined) throw new Error('seeded role missing');
84
+ expect(before?.memberships).toEqual([
85
+ { parent: 'parent', grantor: 'other', admin: false, set: false, inherit: true },
86
+ ]);
87
+ expect(fake.statements.find((s) => s.text.includes('FROM pg_auth_members'))?.text).toContain(
88
+ 'm.inherit_option AS inherit',
89
+ );
90
+ expect(await Effect.runPromise(diffPostgresRole(props, live, {}, { found: before }))).toEqual({
91
+ action: 'update',
92
+ });
93
+ expect(declarationMatches(props, before)).toBe(false);
94
+ await Effect.runPromise(reconcileWithClient(fake, props, {}));
95
+ expect(fake.statements.filter((s) => s.text.startsWith('REVOKE')).map((s) => s.text)).toEqual([
96
+ 'REVOKE INHERIT OPTION FOR "parent" FROM "seat" GRANTED BY "other" RESTRICT',
97
+ ]);
98
+ expect(fake.memberships.has(key)).toBe(true);
99
+ const after = await Effect.runPromise(readRole(fake, 'seat'));
100
+ if (after === undefined) throw new Error('reconciled role missing');
101
+ expect(after?.memberships?.[0]?.inherit).toBe(false);
102
+ expect(await Effect.runPromise(diffPostgresRole(props, live, {}, { found: after }))).toEqual({
103
+ action: 'noop',
104
+ });
105
+ expect(declarationMatches(props, after)).toBe(true);
106
+ });
107
+
108
+ test('a warning-only INHERIT no-op fails the catalog backstop', async () => {
109
+ const fake = makeFakeSql({ roles: ['parent'] });
110
+ fake.memberships.add('seat\0parent\0other');
111
+ fake.membershipOptions.set('seat\0parent\0other', { admin: false, set: false, inherit: true });
112
+ const rows = await Effect.runPromise(selectRoleMemberships(fake, 'seat'));
113
+ const pg: PgExecutor = { unsafe: fake.unsafe, transaction: () => Effect.void };
114
+ const error = await Effect.runPromise(Effect.flip(syncMemberships(pg, props, rows)));
115
+ expect(error).toMatchObject({
116
+ _tag: 'PostgresRoleMembershipUnrepaired',
117
+ parent: 'parent',
118
+ grantor: 'other',
119
+ });
120
+ // INHERIT TRUE is safe when declared, and omitted memberOf still means not asserted.
121
+ await Effect.runPromise(syncMemberships(pg, { ...props, inherit: true }, rows));
122
+ const { memberOf: _unasserted, ...unasserted } = props;
123
+ await Effect.runPromise(syncMemberships(pg, unasserted, rows));
124
+ });
125
+
126
+ test('ADMIN or SET on the live read answers update; stored membership rows do not', async () => {
127
+ const declared = { ...props, inherit: true };
128
+ const stored = { ...live, inherit: true };
129
+ const row = (admin: boolean, set: boolean) => ({
130
+ found: {
131
+ ...stored,
132
+ memberships: [{ parent: 'parent', grantor: 'postgres', inherit: true, admin, set }],
133
+ },
134
+ });
135
+ expect(await Effect.runPromise(diffPostgresRole(declared, stored, {}, row(true, true)))).toEqual({
136
+ action: 'update',
137
+ });
138
+ expect(await Effect.runPromise(diffPostgresRole(declared, stored, {}, row(false, true)))).toEqual(
139
+ {
140
+ action: 'update',
141
+ },
142
+ );
143
+ expect(
144
+ await Effect.runPromise(diffPostgresRole(declared, stored, {}, row(false, false))),
145
+ ).toEqual({
146
+ action: 'noop',
147
+ });
148
+ const stuffed = {
149
+ ...stored,
150
+ memberships: [{ parent: 'parent', grantor: 'postgres', inherit: true, admin: true, set: true }],
151
+ };
152
+ expect(await Effect.runPromise(diffPostgresRole(declared, stuffed))).toEqual({ action: 'noop' });
153
+ });
@@ -11,8 +11,8 @@
11
11
  * are exercised (`user-manag.sgml`). A safe seat membership is `ADMIN FALSE, SET FALSE`.
12
12
  * ⛔ REVOKE BY GRANTOR. A plain `REVOKE parent FROM member` only touches rows the session's
13
13
  * own grantor made; Postgres 18 warns and leaves another grantor's rows standing
14
- * (`revoke.sgml`). Every revoke here names `GRANTED BY`, so the statement either removes
15
- * the row it targets or fails loudly — never a silent no-op.
14
+ * (`revoke.sgml`). Every revoke here names `GRANTED BY`. A mismatched grantor can still
15
+ * produce only a warning; the catalog re-read below catches surviving unsafe rows.
16
16
  */
17
17
  import type { SqlError } from 'effect/unstable/sql/SqlError';
18
18
  import * as Effect from 'effect/Effect';
@@ -22,21 +22,24 @@ import { PostgresRoleMembershipUnrepaired, PostgresRoleParentMissing } from './r
22
22
  import type { PostgresRoleAttributes, PostgresRoleProps } from './role-attrs.ts';
23
23
 
24
24
  /** One `pg_auth_members` row as this family reads it: the parent name, the grantor who made
25
- * the grant, and the two options a name-only compare would hide. `admin` true is `WITH ADMIN`;
25
+ * the grant, and the three options a name-only compare would hide. `admin` true is `WITH ADMIN`;
26
+ * `inherit` true inherits the parent's privileges even when the member is NOINHERIT;
26
27
  * `set` true (the `GRANT` default) lets the member `SET ROLE` to the parent. `grantor` is null
27
- * when the granting role was later dropped — such a row can never be named in a `REVOKE …
28
- * GRANTED BY`, so it can only be repaired by hand as a bootstrap superuser. */
28
+ * only in an inconsistent catalog or a synthetic test row, not after an ordinary DROP ROLE:
29
+ * PostgreSQL tracks grantor dependencies. Keep the null guard as a defensive refusal. */
29
30
  export interface MembershipRow {
30
31
  readonly parent: string;
31
32
  readonly grantor: string | null;
32
33
  readonly admin: boolean;
33
34
  readonly set: boolean;
35
+ readonly inherit: boolean;
34
36
  }
35
37
 
36
38
  const MEMBERSHIP_SQL = `SELECT parent.rolname AS parent,
37
39
  grantor.rolname AS grantor,
38
40
  m.admin_option AS admin,
39
- m.set_option AS set
41
+ m.set_option AS set,
42
+ m.inherit_option AS inherit
40
43
  FROM pg_auth_members m
41
44
  JOIN pg_roles member ON member.oid = m.member
42
45
  JOIN pg_roles parent ON parent.oid = m.roleid
@@ -55,11 +58,21 @@ export const selectRoleMemberships = (
55
58
  export const buildGrantMembershipSql = (member: string, parent: string): string =>
56
59
  `GRANT ${quoteIdent(parent)} TO ${quoteIdent(member)} WITH SET FALSE`;
57
60
 
58
- /** Repair an existing membership in place: one atomic full-options `GRANT` per parent.
59
- * Upstream keeps any `WITH` option the new `GRANT` omits (`grant.sgml`), so a repair must
60
- * spell both bits — `ADMIN FALSE, SET FALSE` — rather than rely on `SET FALSE` alone. */
61
- export const buildRepairMembershipSql = (member: string, parent: string): string =>
62
- `GRANT ${quoteIdent(parent)} TO ${quoteIdent(member)} WITH ADMIN FALSE, SET FALSE`;
61
+ /** ⛔ Do not repair with GRANT … ADMIN FALSE: measured in PR 336, that left grants made
62
+ * by the member standing after stripping its ADMIN. PG 16+ tracks dependent role grants:
63
+ * https://www.postgresql.org/docs/16/sql-grant.html#SQL-GRANT-DESCRIPTION-ROLES
64
+ * https://www.postgresql.org/docs/16/sql-revoke.html
65
+ * Measured 2026-10-02 on PG 17.11: REVOKE ADMIN OPTION … RESTRICT refuses dependents;
66
+ * without dependents, ADMIN/SET revocation keeps membership and its INHERIT bit intact.
67
+ * INHERIT revocation explicitly clears that bit for a declared NOINHERIT role.
68
+ * GRANTED BY targets the observed row, including a different grantor's row. */
69
+ export const buildRepairMembershipSql = (
70
+ member: string,
71
+ parent: string,
72
+ grantor: string,
73
+ option: 'ADMIN' | 'SET' | 'INHERIT',
74
+ ): string =>
75
+ `REVOKE ${option} OPTION FOR ${quoteIdent(parent)} FROM ${quoteIdent(member)} GRANTED BY ${quoteIdent(grantor)} RESTRICT`;
63
76
 
64
77
  /** Remove one grantor's row for one parent. Naming the grantor is what makes the revoke
65
78
  * bind: a plain `REVOKE` skips rows other grantors made (`revoke.sgml`). */
@@ -70,12 +83,19 @@ export const buildRevokeGrantorMembershipSql = (
70
83
  ): string =>
71
84
  `REVOKE ${quoteIdent(parent)} FROM ${quoteIdent(member)} GRANTED BY ${quoteIdent(grantor)}`;
72
85
 
73
- /** Catalog flags this family never declares. A create lands on the server default (all false);
74
- * an absent field on a seeded test row is that default. */
86
+ /** ADMIN/SET are always unsafe; per-grant INHERIT is unsafe for declared NOINHERIT.
87
+ * PG 16–18 GRANT docs: the role attribute only defaults NEW membership grants. */
75
88
  export const unsafeMemberships = (
76
89
  live: Omit<PostgresRoleAttributes, 'passwordSeal'>,
90
+ /** The DECLARED inherit flag (never the live one): a declared NOINHERIT role must see live
91
+ * per-grant INHERIT rows as unsafe even when the live role flag says INHERIT. */
92
+ inherit: boolean,
77
93
  ): ReadonlySet<string> =>
78
- new Set((live.memberships ?? []).filter((row) => row.admin || row.set).map((row) => row.parent));
94
+ new Set(
95
+ (live.memberships ?? [])
96
+ .filter((row) => row.admin || row.set || (row.inherit && !inherit))
97
+ .map((row) => row.parent),
98
+ );
79
99
 
80
100
  /** Membership drift: sorted set difference between declared and live. Duplicates in the
81
101
  * declaration collapse to one; `undefined` means "not asserted" — nothing to grant or revoke.
@@ -115,21 +135,14 @@ export const assertParentsExist = (
115
135
  }
116
136
  });
117
137
 
118
- /** Make live memberships equal the declaration, never dropping a membership the declaration
119
- * keeps. A wanted parent that is already granted is repaired with one
120
- * `GRANT … WITH ADMIN FALSE, SET FALSE` — `AddRoleMems` updates that grantor's row in place
121
- * (`user.c@REL_18_6` `SearchSysCache3` on role, member, grantor) — and any other grantor's
122
- * unsafe row is then revoked by name.
123
- *
124
- * 1. Rows the declaration does not want are revoked **by their own grantor** (dead-grantor
125
- * rows are skipped and left for the backstop to report).
126
- * 2. Unsafe-but-wanted rows get one full-options repair `GRANT` per parent — atomic, in place,
127
- * so the membership never drops even when another grantor's row also exists.
128
- * 3. A repair leaves other grantors' unsafe rows beside the safe one, so a mid re-read
129
- * revokes every remaining unsafe row with a live grantor.
130
- * 4. Absent-but-wanted parents get a fresh seat grant.
131
- * 5. Backstop re-read: any row that is not wanted, or still unsafe, fails typed — including a
132
- * dead-grantor row, which no statement this family may issue can repair. */
138
+ /** Make live memberships equal the declaration, preserving wanted memberships in place.
139
+ * The previous full-options GRANT updated only its own grantor's row, then revoked other
140
+ * unsafe rows; it could silently strip ADMIN despite dependent grants (PR 336 round 2).
141
+ * Now each unsafe option is revoked by its observed grantor with RESTRICT. Unwanted rows
142
+ * commit first, so a blocked ADMIN repair cannot restore unwanted access (PR 349 review).
143
+ * Each retained row's options share a transaction, attributing a dependency/permission
144
+ * refusal to that parent and grantor. No CASCADE and no replacement grantor row.
145
+ * Unnameable grantors are defensive catalog-corruption cases, left to the typed backstop. */
133
146
  export const syncMemberships = (
134
147
  pg: PgExecutor,
135
148
  props: PostgresRoleProps,
@@ -144,46 +157,58 @@ export const syncMemberships = (
144
157
  }
145
158
  yield* assertParentsExist(pg, props.name, props.memberOf);
146
159
  const wanted = [...new Set(props.memberOf)].sort();
147
- // 1. Not-wanted rows: revoke each by its own grantor (dead-grantor rows skipped; the
148
- // backstop catches them).
160
+ const revokes: string[] = [];
149
161
  for (const row of rows) {
150
- if (wanted.includes(row.parent) || row.grantor === null) continue;
151
- yield* pg
152
- .unsafe(buildRevokeGrantorMembershipSql(props.name, row.parent, row.grantor))
153
- .pipe(Effect.asVoid);
154
- }
155
- // 2. Unsafe-but-wanted rows: one full-options repair `GRANT` per parent.
156
- const repaired = [
157
- ...new Set(
158
- rows
159
- .filter(
160
- (row) => wanted.includes(row.parent) && (row.admin || row.set) && row.grantor !== null,
161
- )
162
- .map((row) => row.parent),
163
- ),
164
- ];
165
- for (const parent of repaired) {
166
- yield* pg.unsafe(buildRepairMembershipSql(props.name, parent)).pipe(Effect.asVoid);
162
+ if (!wanted.includes(row.parent) && row.grantor !== null) {
163
+ revokes.push(buildRevokeGrantorMembershipSql(props.name, row.parent, row.grantor));
164
+ }
167
165
  }
168
- // 3. If any repair ran, other grantors' unsafe rows may still stand: re-read and revoke
169
- // them by grantor (the repair's own safe row remains).
170
- if (repaired.length > 0) {
171
- const midRead = yield* selectRoleMemberships(pg, props.name);
172
- for (const row of midRead) {
173
- if (wanted.includes(row.parent) && (row.admin || row.set) && row.grantor !== null) {
174
- yield* pg
175
- .unsafe(buildRevokeGrantorMembershipSql(props.name, row.parent, row.grantor))
176
- .pipe(Effect.asVoid);
166
+ if (revokes.length > 0) yield* pg.transaction(revokes);
167
+ for (const row of rows) {
168
+ if (row.grantor === null || !wanted.includes(row.parent)) continue;
169
+ const statements: string[] = [];
170
+ for (const option of ['ADMIN', 'SET', 'INHERIT'] as const) {
171
+ const unsafe =
172
+ option === 'ADMIN'
173
+ ? row.admin
174
+ : option === 'SET'
175
+ ? row.set
176
+ : row.inherit && !props.inherit;
177
+ if (unsafe) {
178
+ statements.push(buildRepairMembershipSql(props.name, row.parent, row.grantor, option));
177
179
  }
178
180
  }
181
+ if (statements.length > 0)
182
+ yield* pg.transaction(statements).pipe(
183
+ Effect.catchTag('SqlError', (error) => {
184
+ // Both installed socket and psql transports preserve raw SQLSTATE here.
185
+ // Class 2B is UnknownError, class 42 is SqlSyntaxError; never match messages.
186
+ const cause = error.reason.cause;
187
+ const code =
188
+ typeof cause === 'object' && cause !== null
189
+ ? (cause as { code?: unknown }).code
190
+ : undefined;
191
+ return Effect.fail(
192
+ code === '2BP01' || code === '42501'
193
+ ? new PostgresRoleMembershipUnrepaired({
194
+ role: props.name,
195
+ parent: row.parent,
196
+ grantor: row.grantor,
197
+ declared: true,
198
+ })
199
+ : error,
200
+ );
201
+ }),
202
+ );
179
203
  }
180
- // 4. Absent-but-wanted parents: fresh seat grant.
181
- for (const parent of wanted.filter((p) => !rows.some((row) => row.parent === p))) {
182
- yield* pg.unsafe(buildGrantMembershipSql(props.name, parent)).pipe(Effect.asVoid);
183
- }
184
- // 5. Backstop: anything not wanted or still unsafe fails typed.
204
+ // Absent-but-wanted parents get a fresh seat grant. Existing rows keep their grantor.
205
+ const grants = wanted
206
+ .filter((p) => !rows.some((row) => row.parent === p))
207
+ .map((parent) => buildGrantMembershipSql(props.name, parent));
208
+ if (grants.length > 0) yield* pg.transaction(grants);
209
+ // Backstop: anything not wanted or still unsafe fails typed.
185
210
  for (const row of yield* selectRoleMemberships(pg, props.name)) {
186
- if (!wanted.includes(row.parent) || row.admin || row.set) {
211
+ if (!wanted.includes(row.parent) || row.admin || row.set || (row.inherit && !props.inherit)) {
187
212
  return yield* Effect.fail(
188
213
  new PostgresRoleMembershipUnrepaired({
189
214
  role: props.name,