Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/safegres/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ are performance, `P5` is security.
| L5 | info | fail-open | An untrusted role reaches an **RLS-off table** via PUBLIC/inheritance † |
| L6 | info | neutral | **Unaddressable grant** — an API role holds privileges on a relation its API cannot name ‡ |
| L8 | info | fail-open | **DEFINER view bypass** — an untrusted role reads a base relation as the view's owner † |
| L9 | info | fail-open | **DEFINER view write** — an auto-updatable definer view writes a base relation as its owner † |
| L10 | info | fail-open | **Rewrite-rule bypass** — a rule on a view writes a relation as the view's owner, `security_invoker` notwithstanding † |
| W1 | medium | — | **No exposure surface configured** — whole database assumed reachable, score capped |

† R1/R2/L5 are no-ops until you name the untrusted roles:
Expand Down
3 changes: 3 additions & 0 deletions packages/safegres/__tests__/definer-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
ownerBypassesRls: false,
grants: [grant('anon', 'SELECT')],
definition: 'SELECT id, total FROM app.orders',
writable: [],
insteadOfTriggers: false,
rules: [],
...partial
};
}
Expand Down
35 changes: 35 additions & 0 deletions packages/safegres/__tests__/view-introspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,19 @@ beforeAll(async () => {
CREATE VIEW fx_viewopt.v_bare WITH (security_invoker) AS SELECT id FROM fx_viewopt.t;
CREATE VIEW fx_viewopt.v_off WITH (security_invoker = off) AS SELECT id FROM fx_viewopt.t;
CREATE VIEW fx_viewopt.v_none AS SELECT id FROM fx_viewopt.t;

CREATE SCHEMA fx_viewwrite;
CREATE TABLE fx_viewwrite.t (id int);
CREATE TABLE fx_viewwrite.audit (note text);
-- Auto-updatable: a simple view over one relation.
CREATE VIEW fx_viewwrite.v_auto AS SELECT id FROM fx_viewwrite.t;
-- Not updatable: an aggregate has no row to write back.
CREATE VIEW fx_viewwrite.v_agg AS SELECT count(*) AS n FROM fx_viewwrite.t;
-- Updatable only through a rule, which pg_get_viewdef does not show.
CREATE VIEW fx_viewwrite.v_ruled AS SELECT id FROM fx_viewwrite.t;
CREATE RULE v_ruled_ins AS ON INSERT TO fx_viewwrite.v_ruled
DO INSTEAD INSERT INTO fx_viewwrite.audit (note) VALUES ('x');
CREATE RULE v_ruled_del AS ON DELETE TO fx_viewwrite.v_ruled DO INSTEAD NOTHING;
`);
});

Expand All @@ -45,3 +58,25 @@ describe('introspectViews — security_invoker spellings', () => {
});
});
});

describe('introspectViews — write paths', () => {
it('reads updatability and the rules pg_get_viewdef does not show', async () => {
const views = await introspectViews(pg.client as never, { schemas: ['fx_viewwrite'] });
const byName = Object.fromEntries(views.map((v) => [v.name, v]));

expect(byName.v_auto.writable.sort()).toEqual(['DELETE', 'INSERT', 'UPDATE']);
expect(byName.v_auto.rules).toEqual([]);
expect(byName.v_auto.insteadOfTriggers).toBe(false);

expect(byName.v_agg.writable).toEqual([]);

// The bitmask counts rule-conferred updatability too, which is why the
// rules have to be read alongside it rather than inferred from it.
expect(byName.v_ruled.writable).toContain('INSERT');
expect(byName.v_ruled.rules.map((r) => [r.name, r.event, r.instead]).sort()).toEqual([
['v_ruled_del', 'DELETE', true],
['v_ruled_ins', 'INSERT', true]
]);
expect(byName.v_ruled.rules[0].definition).toContain('CREATE RULE');
});
});
Loading
Loading