@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.
- package/dist/postgres/fake-role-sql.d.ts +2 -1
- package/dist/postgres/fake-role-sql.d.ts.map +1 -1
- package/dist/postgres/fake-sql.d.ts +3 -3
- package/dist/postgres/fake-sql.d.ts.map +1 -1
- package/dist/postgres/index.d.ts +1 -1
- package/dist/postgres/index.d.ts.map +1 -1
- package/dist/postgres/index.js +117 -94
- package/dist/postgres/index.js.map +11 -11
- package/dist/postgres/role-attrs.d.ts +7 -4
- package/dist/postgres/role-attrs.d.ts.map +1 -1
- package/dist/postgres/role-diff.d.ts +2 -2
- package/dist/postgres/role-diff.d.ts.map +1 -1
- package/dist/postgres/role-errors.d.ts +13 -7
- package/dist/postgres/role-errors.d.ts.map +1 -1
- package/dist/postgres/role-membership-sql.d.ts +30 -27
- package/dist/postgres/role-membership-sql.d.ts.map +1 -1
- package/dist/postgres/role-provider.d.ts.map +1 -1
- package/dist/postgres/role-scram.d.ts +2 -0
- package/dist/postgres/role-scram.d.ts.map +1 -1
- package/dist/postgres/role-secrets.d.ts +8 -0
- package/dist/postgres/role-secrets.d.ts.map +1 -1
- package/dist/postgres/role-sql.d.ts.map +1 -1
- package/dist/postgres/role.d.ts +2 -2
- package/dist/postgres/role.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/index.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-access.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-backup.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-firewall.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-ha.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-metrics.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-notifications.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-replication.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-cluster-sdn.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-ceph.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-disks.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-lxc.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-network.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-qemu.d.ts.map +1 -1
- package/dist/proxmox/generated/constraints/pve-nodes-storage.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-pools.d.ts +1 -1
- package/dist/proxmox/generated/constraints/pve-storage.d.ts +1 -1
- package/dist/proxmox/index.d.ts +1 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +283 -130
- package/dist/proxmox/index.js.map +31 -27
- package/dist/proxmox/qemu-dns.d.ts +2 -0
- package/dist/proxmox/qemu-dns.d.ts.map +1 -0
- package/dist/proxmox/qemu-errors.d.ts +1 -0
- package/dist/proxmox/qemu-errors.d.ts.map +1 -1
- package/dist/proxmox/qemu-form.d.ts.map +1 -1
- package/dist/proxmox/qemu-judge.d.ts.map +1 -1
- package/dist/proxmox/qemu-lifecycle.d.ts +15 -10
- package/dist/proxmox/qemu-lifecycle.d.ts.map +1 -1
- package/dist/proxmox/qemu-props.d.ts +10 -0
- package/dist/proxmox/qemu-props.d.ts.map +1 -1
- package/dist/proxmox/qemu-resize.d.ts +10 -0
- package/dist/proxmox/qemu-resize.d.ts.map +1 -0
- package/dist/proxmox/qemu-size.d.ts +20 -0
- package/dist/proxmox/qemu-size.d.ts.map +1 -0
- package/dist/proxmox/qemu-spec.d.ts +25 -0
- package/dist/proxmox/qemu-spec.d.ts.map +1 -0
- package/dist/verify/fake-engine.d.ts +4 -2
- package/dist/verify/fake-engine.d.ts.map +1 -1
- package/docs/postgres-role.md +18 -6
- package/docs/proxmox-qemu-resize.md +62 -0
- package/package.json +1 -1
- package/src/ceph/ceph-argv.test.ts +1 -1
- package/src/launchd/sudo-allowlist.test.ts +2 -2
- package/src/linux/sudo-allowlist.test.ts +3 -3
- package/src/linux/sudo-runner.test.ts +2 -2
- package/src/postgres/fake-role-sql.ts +26 -6
- package/src/postgres/fake-sql.ts +10 -5
- package/src/postgres/index.ts +1 -0
- package/src/postgres/role-attrs.ts +7 -4
- package/src/postgres/role-diff.ts +7 -3
- package/src/postgres/role-errors.ts +22 -14
- package/src/postgres/role-guard.test.ts +3 -3
- package/src/postgres/role-identity.test.ts +14 -2
- package/src/postgres/role-lifecycle.test.ts +2 -2
- package/src/postgres/role-membership-options.test.ts +73 -0
- package/src/postgres/role-membership-repair.test.ts +153 -0
- package/src/postgres/role-membership-sql.ts +88 -63
- package/src/postgres/role-password-ascii.test.ts +53 -0
- package/src/postgres/role-password-transports.test.ts +123 -0
- package/src/postgres/role-privilege.test.ts +37 -10
- package/src/postgres/role-provenance.test.ts +1 -1
- package/src/postgres/role-provider.ts +3 -2
- package/src/postgres/role-refusals.test.ts +0 -25
- package/src/postgres/role-scram.ts +2 -0
- package/src/postgres/role-secrets.ts +15 -0
- package/src/postgres/role-sql.ts +3 -1
- package/src/postgres/role.ts +9 -1
- package/src/postgres/schema-socket.test.ts +1 -0
- package/src/proxmox/generated/constraints/index.ts +1 -1
- package/src/proxmox/generated/constraints/pve-access.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-backup.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-firewall.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-ha.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-metrics.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-notifications.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-replication.ts +1 -1
- package/src/proxmox/generated/constraints/pve-cluster-sdn.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-ceph.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-disks.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-lxc.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-network.ts +1 -1
- package/src/proxmox/generated/constraints/pve-nodes-qemu.ts +6 -1
- package/src/proxmox/generated/constraints/pve-nodes-storage.ts +1 -1
- package/src/proxmox/generated/constraints/pve-pools.ts +1 -1
- package/src/proxmox/generated/constraints/pve-storage.ts +1 -1
- package/src/proxmox/index.ts +1 -0
- package/src/proxmox/qemu-dns.test.ts +73 -0
- package/src/proxmox/qemu-dns.ts +32 -0
- package/src/proxmox/qemu-errors.ts +1 -0
- package/src/proxmox/qemu-form.ts +6 -3
- package/src/proxmox/qemu-judge.ts +2 -1
- package/src/proxmox/qemu-lifecycle.ts +28 -47
- package/src/proxmox/qemu-props.ts +14 -2
- package/src/proxmox/qemu-resize-lock.test.ts +121 -0
- package/src/proxmox/qemu-resize-recovery.test.ts +81 -0
- package/src/proxmox/qemu-resize.test.ts +199 -0
- package/src/proxmox/qemu-resize.ts +86 -0
- package/src/proxmox/qemu-size.test.ts +64 -0
- package/src/proxmox/qemu-size.ts +95 -0
- package/src/proxmox/qemu-spec.ts +44 -0
- package/src/proxmox/qemu-volume.ts +5 -5
- 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
|
-
* ⛔
|
|
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)
|
|
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
|
-
/**
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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
|
|
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
|
-
`
|
|
146
|
-
'membership on (parent, member, grantor)
|
|
147
|
-
'
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
const
|
|
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
|
|
15
|
-
*
|
|
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
|
|
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
|
-
*
|
|
28
|
-
*
|
|
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
|
-
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
74
|
-
*
|
|
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(
|
|
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,
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
148
|
-
// backstop catches them).
|
|
160
|
+
const revokes: string[] = [];
|
|
149
161
|
for (const row of rows) {
|
|
150
|
-
if (wanted.includes(row.parent)
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
for (const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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,
|