|
| 1 | +import { type RoleGraph } from '../src/checks/lattice'; |
| 2 | +import { |
| 3 | + checkUntrustedForeignTableGrants, |
| 4 | + checkUntrustedSequenceGrants |
| 5 | +} from '../src/checks/object-acls'; |
| 6 | +import type { RoleAttributes } from '../src/pg/acl'; |
| 7 | +import type { GrantInfo, PgPrivilege } from '../src/pg/introspect'; |
| 8 | +import type { ObjectAclSnapshot } from '../src/pg/objects'; |
| 9 | + |
| 10 | +function grant(role: string, privilege: PgPrivilege): GrantInfo { |
| 11 | + return { role, privilege, grantable: false, bypassRls: false }; |
| 12 | +} |
| 13 | + |
| 14 | +function sequence(partial: Partial<ObjectAclSnapshot> = {}): ObjectAclSnapshot { |
| 15 | + return { |
| 16 | + schema: 'app', |
| 17 | + name: 'orders_id_seq', |
| 18 | + kind: 'sequence', |
| 19 | + owner: 'app_owner', |
| 20 | + grants: [grant('anon', 'USAGE')], |
| 21 | + ...partial |
| 22 | + }; |
| 23 | +} |
| 24 | + |
| 25 | +function foreignTable(partial: Partial<ObjectAclSnapshot> = {}): ObjectAclSnapshot { |
| 26 | + return { |
| 27 | + schema: 'app', |
| 28 | + name: 'remote_orders', |
| 29 | + kind: 'foreign table', |
| 30 | + owner: 'app_owner', |
| 31 | + grants: [grant('anon', 'SELECT')], |
| 32 | + server: 'analytics', |
| 33 | + ...partial |
| 34 | + }; |
| 35 | +} |
| 36 | + |
| 37 | +function role(name: string, partial: Partial<RoleAttributes> = {}): [string, RoleAttributes] { |
| 38 | + return [ |
| 39 | + name, |
| 40 | + { name, bypassRls: false, isSuper: false, inheritsFrom: [], canSetRole: [], ...partial } |
| 41 | + ]; |
| 42 | +} |
| 43 | + |
| 44 | +const GRAPH: RoleGraph = new Map([role('anon'), role('app_owner'), role('reader')]); |
| 45 | +const ANON = { roles: ['anon'] }; |
| 46 | + |
| 47 | +describe('L16 — sequence privileges', () => { |
| 48 | + it('reports what USAGE actually confers, not just that a grant exists', () => { |
| 49 | + const [f] = checkUntrustedSequenceGrants([sequence()], GRAPH, ANON); |
| 50 | + expect(f.code).toBe('L16'); |
| 51 | + expect(f.table).toBe('orders_id_seq'); |
| 52 | + expect(f.message).toMatch(/nextval/); |
| 53 | + expect(f.context).toMatchObject({ objectKind: 'sequence', privileges: ['USAGE'] }); |
| 54 | + }); |
| 55 | + |
| 56 | + it('separates reading the counter from advancing it', () => { |
| 57 | + const [read] = checkUntrustedSequenceGrants( |
| 58 | + [sequence({ grants: [grant('anon', 'SELECT')] })], |
| 59 | + GRAPH, |
| 60 | + ANON |
| 61 | + ); |
| 62 | + expect(read.message).toMatch(/last_value/); |
| 63 | + expect(read.message).not.toMatch(/nextval/); |
| 64 | + }); |
| 65 | + |
| 66 | + it('follows PUBLIC, so a grant nobody named still reports', () => { |
| 67 | + const [f] = checkUntrustedSequenceGrants( |
| 68 | + [sequence({ grants: [grant('PUBLIC', 'USAGE')] })], |
| 69 | + GRAPH, |
| 70 | + ANON |
| 71 | + ); |
| 72 | + expect(f?.code).toBe('L16'); |
| 73 | + expect(f.context).toMatchObject({ via: 'PUBLIC' }); |
| 74 | + }); |
| 75 | + |
| 76 | + it('follows role inheritance the same way the rest of the lattice does', () => { |
| 77 | + const graph: RoleGraph = new Map([role('anon', { inheritsFrom: ['reader'] }), role('reader')]); |
| 78 | + const [f] = checkUntrustedSequenceGrants( |
| 79 | + [sequence({ grants: [grant('reader', 'USAGE')] })], |
| 80 | + graph, |
| 81 | + ANON |
| 82 | + ); |
| 83 | + expect(f.context).toMatchObject({ via: 'member of reader' }); |
| 84 | + }); |
| 85 | + |
| 86 | + it('leads with identity columns, not a revoke, when the sequence feeds a column', () => { |
| 87 | + const [f] = checkUntrustedSequenceGrants( |
| 88 | + [sequence({ ownedBy: 'app.orders.id' })], |
| 89 | + GRAPH, |
| 90 | + ANON |
| 91 | + ); |
| 92 | + expect(f.message).toMatch(/feeds app\.orders\.id/); |
| 93 | + expect(f.hint).toMatch(/AS IDENTITY/); |
| 94 | + expect(f.hint).toMatch(/needs USAGE, and revoking it/); |
| 95 | + expect(f.context).toMatchObject({ ownedBy: 'app.orders.id' }); |
| 96 | + }); |
| 97 | + |
| 98 | + it('stays silent on a sequence the role cannot touch', () => { |
| 99 | + expect( |
| 100 | + checkUntrustedSequenceGrants([sequence({ grants: [] })], GRAPH, ANON) |
| 101 | + ).toEqual([]); |
| 102 | + }); |
| 103 | + |
| 104 | + it('does nothing without a configured role list', () => { |
| 105 | + expect(checkUntrustedSequenceGrants([sequence()], GRAPH)).toEqual([]); |
| 106 | + }); |
| 107 | +}); |
| 108 | + |
| 109 | +describe('L17 — foreign-table grants', () => { |
| 110 | + it('reports the grant and names the server behind it', () => { |
| 111 | + const [f] = checkUntrustedForeignTableGrants([foreignTable()], GRAPH, ANON); |
| 112 | + expect(f.code).toBe('L17'); |
| 113 | + expect(f.message).toMatch(/server analytics/); |
| 114 | + expect(f.context).toMatchObject({ objectKind: 'foreign table', privileges: ['SELECT'] }); |
| 115 | + }); |
| 116 | + |
| 117 | + it('says the A2 remedy is unavailable here, because Postgres refuses RLS on one', () => { |
| 118 | + const [f] = checkUntrustedForeignTableGrants([foreignTable()], GRAPH, ANON); |
| 119 | + expect(f.message).toMatch(/cannot carry RLS/); |
| 120 | + expect(f.hint).toMatch(/rejects `ENABLE ROW LEVEL SECURITY`/); |
| 121 | + }); |
| 122 | + |
| 123 | + it('does not grade a sequence, and the sequence rule does not grade it', () => { |
| 124 | + expect(checkUntrustedForeignTableGrants([sequence()], GRAPH, ANON)).toEqual([]); |
| 125 | + expect(checkUntrustedSequenceGrants([foreignTable()], GRAPH, ANON)).toEqual([]); |
| 126 | + }); |
| 127 | + |
| 128 | + it('stays silent when the untrusted role holds nothing on it', () => { |
| 129 | + expect( |
| 130 | + checkUntrustedForeignTableGrants([foreignTable({ grants: [] })], GRAPH, ANON) |
| 131 | + ).toEqual([]); |
| 132 | + }); |
| 133 | +}); |
0 commit comments