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 @@ -132,6 +132,8 @@ family, **not** the dimension: `P1`/`P1b` are performance, `P5` is security.
| 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 † |
| L11 | info | fail-open | **Materialized-view snapshot** — stored rows serve an untrusted role what the base relation's grants and policies would not † |
| L12 | info | fail-open | **Non-barrier filtering view** — a view is an untrusted role's only path to a relation, but its row filter is not a boundary † |
| 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
1 change: 1 addition & 0 deletions packages/safegres/__tests__/definer-view.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
owner: 'app_owner',
materialized: false,
securityInvoker: false,
securityBarrier: false,
ownerBypassesRls: false,
grants: [grant('anon', 'SELECT')],
definition: 'SELECT id, total FROM app.orders',
Expand Down
258 changes: 258 additions & 0 deletions packages/safegres/__tests__/view-exposure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,258 @@
import { type RoleGraph } from '../src/checks/lattice';
import {
analyzeViewExposure,
checkLeakyFilterView,
checkMatviewSnapshot
} from '../src/checks/view-exposure';
import type { RoleAttributes } from '../src/pg/acl';
import type { ViewSnapshot } from '../src/pg/indexes';
import type { GrantInfo, TableSnapshot } from '../src/pg/introspect';

function table(partial: Partial<TableSnapshot> = {}): TableSnapshot {
return {
schema: 'app',
name: 'secrets',
oid: 1,
rlsEnabled: true,
rlsForced: false,
isPartitioned: false,
owner: 'app_owner',
grants: [],
policies: [],
...partial
};
}

function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
return {
schema: 'app',
name: 'secrets_mv',
owner: 'app_owner',
materialized: true,
securityInvoker: false,
securityBarrier: false,
ownerBypassesRls: false,
grants: [grant('anon', 'SELECT')],
definition: 'SELECT id, body FROM app.secrets',
writable: [],
insteadOfTriggers: false,
rules: [],
...partial
};
}

function grant(role: string, privilege: GrantInfo['privilege']): GrantInfo {
return { role, privilege, grantable: false, bypassRls: false };
}

function role(name: string, partial: Partial<RoleAttributes> = {}): [string, RoleAttributes] {
return [name, { name, bypassRls: false, isSuper: false, inheritsFrom: [], canSetRole: [], ...partial }];
}

function graph(...entries: Array<[string, RoleAttributes]>): RoleGraph {
return new Map(entries);
}

const GRAPH = graph(role('anon'), role('app_owner'), role('member'));

/** A plain view over one table, filtered, definer, no barrier — the L12 shape. */
function filterView(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
return view({
name: 'my_secrets',
materialized: false,
definition: 'SELECT id, body FROM app.secrets WHERE owner_name = CURRENT_USER',
...partial
});
}

describe('analyzeViewExposure — materialized views', () => {
it('resolves the relations a matview refresh copied from', async () => {
const { matviews } = await analyzeViewExposure([view()], [table()]);
expect(matviews).toHaveLength(1);
expect(matviews[0].materialized).toBe(true);
expect(matviews[0].baseRelations).toEqual([
{ schema: 'app', table: 'secrets', hops: [{ view: 'app.secrets_mv', owner: 'app_owner' }] }
]);
});

it('reads a matview body regardless of security_invoker, which matviews cannot carry', async () => {
const { matviews } = await analyzeViewExposure([view({ securityInvoker: true })], [table()]);
expect(matviews).toHaveLength(1);
});

it('suppresses a matview whose body cannot be parsed', async () => {
const { matviews, suppressed } = await analyzeViewExposure(
[view({ definition: 'SELECT FROM WHERE ((' })],
[table()]
);
expect(matviews).toEqual([]);
expect(suppressed).toHaveLength(1);
});

it('does not follow a matview as a nested relation of another view', async () => {
// `app.secrets_mv` stores rows; a view over it reads the snapshot, not the
// table the snapshot came from.
const outer = view({
name: 'wrapper',
materialized: false,
definition: 'SELECT id FROM app.secrets_mv'
});
const { leaky, matviews } = await analyzeViewExposure([view(), outer], [table()]);
expect(matviews).toHaveLength(1);
expect(leaky).toEqual([]);
});
});

describe('analyzeViewExposure — non-barrier filtering views', () => {
it('collects a filtering definer view with no barrier', async () => {
const { leaky } = await analyzeViewExposure([filterView()], [table()]);
expect(leaky).toHaveLength(1);
expect(leaky[0].name).toBe('my_secrets');
});

it('ignores the same view once it is a barrier', async () => {
const { leaky } = await analyzeViewExposure([filterView({ securityBarrier: true })], [table()]);
expect(leaky).toEqual([]);
});

it('ignores an invoker view: the caller needs its own grant, so nothing is hidden by the view', async () => {
const { leaky } = await analyzeViewExposure([filterView({ securityInvoker: true })], [table()]);
expect(leaky).toEqual([]);
});

it('ignores a view with no row filter — there are no excluded rows to reach', async () => {
const { leaky } = await analyzeViewExposure(
[filterView({ definition: 'SELECT id, body FROM app.secrets' })],
[table()]
);
expect(leaky).toEqual([]);
});

it('counts a HAVING clause as a row filter', async () => {
const { leaky } = await analyzeViewExposure(
[filterView({
definition: 'SELECT owner_name, count(*) FROM app.secrets GROUP BY owner_name HAVING count(*) > 1'
})],
[table()]
);
expect(leaky).toHaveLength(1);
});

it('suppresses a filtering view whose body cannot be parsed', async () => {
const { leaky, suppressed } = await analyzeViewExposure(
[filterView({ definition: 'SELECT ) WHERE (' })],
[table()]
);
expect(leaky).toEqual([]);
expect(suppressed).toHaveLength(1);
});
});

describe('checkMatviewSnapshot (L11)', () => {
async function check(views: ViewSnapshot[], tables: TableSnapshot[], roles: string[]) {
const { matviews } = await analyzeViewExposure(views, tables);
return checkMatviewSnapshot(matviews, tables, GRAPH, { roles });
}

it('reports a matview handing an untrusted role a table it holds nothing on', async () => {
const findings = await check([view()], [table()], ['anon']);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
code: 'L11',
severity: 'info',
schema: 'app',
table: 'secrets',
role: 'anon',
privilege: 'SELECT'
});
expect(findings[0].context).toMatchObject({
matview: 'app.secrets_mv',
effectiveRole: 'app_owner',
holdsBaseSelect: false,
proof: 'ast'
});
});

it('still reports when the role can read the table, because RLS filtered rows the snapshot did not', async () => {
const base = table({ grants: [grant('anon', 'SELECT')] });
const findings = await check([view()], [base], ['anon']);
expect(findings).toHaveLength(1);
expect(findings[0].context).toMatchObject({ holdsBaseSelect: true, rlsBypassed: true });
expect(findings[0].message).toContain('without the row filter its policies apply');
});

it('stays silent when the role reads the table directly and no policy filters it', async () => {
const base = table({ rlsEnabled: false, grants: [grant('anon', 'SELECT')] });
expect(await check([view()], [base], ['anon'])).toEqual([]);
});

it('stays silent for a role that bypasses RLS and holds the grant', async () => {
const base = table({ grants: [grant('member', 'SELECT')] });
const findings = checkMatviewSnapshot(
(await analyzeViewExposure([view({ grants: [grant('member', 'SELECT')] })], [base])).matviews,
[base],
graph(role('member', { bypassRls: true })),
{ roles: ['member'] }
);
expect(findings).toEqual([]);
});

it('needs a grant on the matview itself', async () => {
const findings = await check([view({ grants: [] })], [table()], ['anon']);
expect(findings).toEqual([]);
});

it('follows a PUBLIC grant on the matview', async () => {
const findings = await check([view({ grants: [grant('PUBLIC', 'SELECT')] })], [table()], ['anon']);
expect(findings).toHaveLength(1);
});

it('reports nothing without configured roles', async () => {
expect(await check([view()], [table()], [])).toEqual([]);
});

it('never recommends revoking the grant', async () => {
const findings = await check([view()], [table()], ['anon']);
expect(findings[0].hint).toContain('Do not revoke');
expect(findings[0].hint).not.toMatch(/\bREVOKE\b/);
});
});

describe('checkLeakyFilterView (L12)', () => {
async function check(views: ViewSnapshot[], tables: TableSnapshot[], roles: string[]) {
const { leaky } = await analyzeViewExposure(views, tables);
return checkLeakyFilterView(leaky, tables, GRAPH, { roles });
}

it('reports a filtering view that is an untrusted role\'s only path to the relation', async () => {
const findings = await check([filterView()], [table()], ['anon']);
expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
code: 'L12',
severity: 'info',
schema: 'app',
table: 'secrets',
role: 'anon'
});
expect(findings[0].context).toMatchObject({ view: 'app.my_secrets', proof: 'ast' });
});

it('stays silent once the view is a barrier', async () => {
expect(await check([filterView({ securityBarrier: true })], [table()], ['anon'])).toEqual([]);
});

it('stays silent when the role can read the base relation directly', async () => {
const base = table({ grants: [grant('anon', 'SELECT')] });
expect(await check([filterView()], [base], ['anon'])).toEqual([]);
});

it('stays silent when the caller cannot read the view at all', async () => {
expect(await check([filterView({ grants: [] })], [table()], ['anon'])).toEqual([]);
});

it('recommends the barrier, and never a revoke', async () => {
const findings = await check([filterView()], [table()], ['anon']);
expect(findings[0].hint).toContain('security_barrier = true');
expect(findings[0].hint).toContain('Do not revoke');
});
});
30 changes: 30 additions & 0 deletions packages/safegres/__tests__/view-introspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ beforeAll(async () => {
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;

CREATE SCHEMA fx_viewbarrier;
CREATE TABLE fx_viewbarrier.t (id int, owner_name text);
CREATE VIEW fx_viewbarrier.v_plain AS
SELECT id FROM fx_viewbarrier.t WHERE owner_name = CURRENT_USER;
CREATE VIEW fx_viewbarrier.v_barrier WITH (security_barrier = true) AS
SELECT id FROM fx_viewbarrier.t WHERE owner_name = CURRENT_USER;
-- Both reloptions at once: reading one must not disturb the other.
CREATE VIEW fx_viewbarrier.v_both WITH (security_barrier = on, security_invoker = 1) AS
SELECT id FROM fx_viewbarrier.t;
CREATE MATERIALIZED VIEW fx_viewbarrier.mv AS SELECT id FROM fx_viewbarrier.t;
`);
});

Expand Down Expand Up @@ -80,3 +91,22 @@ describe('introspectViews — write paths', () => {
expect(byName.v_ruled.rules[0].definition).toContain('CREATE RULE');
});
});

describe('introspectViews — security_barrier and materialization', () => {
it('reads the barrier flag independently of security_invoker', async () => {
const views = await introspectViews(pg.client as never, { schemas: ['fx_viewbarrier'] });
const byName = Object.fromEntries(views.map((v) => [v.name, v]));

expect(byName.v_plain.securityBarrier).toBe(false);
expect(byName.v_barrier.securityBarrier).toBe(true);
expect(byName.v_both).toMatchObject({ securityBarrier: true, securityInvoker: true });

// A matview carries neither option — both are view-only reloptions — and
// that is precisely why it cannot be made to execute as its reader.
expect(byName.mv).toMatchObject({
materialized: true,
securityBarrier: false,
securityInvoker: false
});
});
});
1 change: 1 addition & 0 deletions packages/safegres/__tests__/view-writes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ function view(partial: Partial<ViewSnapshot> = {}): ViewSnapshot {
owner: 'app_owner',
materialized: false,
securityInvoker: false,
securityBarrier: false,
ownerBypassesRls: false,
grants: [grant('anon', 'INSERT')],
definition: 'SELECT id, body FROM app.submissions',
Expand Down
32 changes: 32 additions & 0 deletions packages/safegres/corpus/cases/31-matview-snapshot/case.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"title": "Anonymous role reads an RLS-protected table's rows out of a materialized view",
"dimension": "security",
"exposure": {
"schemas": [
"c_matview_snapshot"
],
"roles": [
"corpus_anon",
"corpus_user"
],
"anonRoles": [
"corpus_anon"
]
},
"expect": [
{
"code": "L11",
"relation": "c_matview_snapshot.readings",
"note": "the matview stores rows computed as c_matview_owner, so corpus_anon reads every tenant's readings without holding a grant on the table and without its policies ever running"
}
],
"forbid": [
"L8",
"L9",
"L10",
"L12"
],
"worstSeverity": "info",
"fix": "Serve the rollup from a `security_invoker` view, or materialize only the columns and rows that are safe to hand out unconditionally and keep the full snapshot in a schema the API does not expose. A materialized view can carry neither RLS policies nor `security_invoker`, so there is no option on the matview itself that reinstates the filter — and revoking corpus_anon's SELECT on it is not the fix, that grant is what the API serves.",
"id": "31-matview-snapshot"
}
36 changes: 36 additions & 0 deletions packages/safegres/corpus/cases/31-matview-snapshot/schema.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
DROP SCHEMA IF EXISTS c_matview_snapshot CASCADE;
CREATE SCHEMA c_matview_snapshot;

-- The role that owns the table and runs REFRESH.
DO $$ BEGIN
CREATE ROLE c_matview_owner NOLOGIN;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;

GRANT USAGE ON SCHEMA c_matview_snapshot TO corpus_anon, corpus_user, c_matview_owner;

CREATE TABLE c_matview_snapshot.readings (
id bigserial PRIMARY KEY,
tenant text NOT NULL,
value numeric NOT NULL
);
ALTER TABLE c_matview_snapshot.readings OWNER TO c_matview_owner;
ALTER TABLE c_matview_snapshot.readings ENABLE ROW LEVEL SECURITY;
ALTER TABLE c_matview_snapshot.readings FORCE ROW LEVEL SECURITY;

-- Signed-in users see only their own tenant's rows.
CREATE POLICY readings_own_tenant ON c_matview_snapshot.readings
FOR SELECT TO corpus_user
USING (tenant = (SELECT current_setting('app.tenant', true)));
CREATE INDEX readings_tenant_idx ON c_matview_snapshot.readings (tenant);
GRANT SELECT ON c_matview_snapshot.readings TO corpus_user;

-- The flaw: a materialized view is a stored copy. Its rows were computed once,
-- as c_matview_owner, and reading it never consults `readings` — so neither
-- the table's ACL nor its policies apply, and the matview can carry neither
-- policies nor `security_invoker` of its own. corpus_anon holds nothing on
-- `readings` and reads every tenant's rows out of the snapshot.
CREATE MATERIALIZED VIEW c_matview_snapshot.readings_rollup AS
SELECT id, tenant, value FROM c_matview_snapshot.readings;
ALTER MATERIALIZED VIEW c_matview_snapshot.readings_rollup OWNER TO c_matview_owner;
GRANT SELECT ON c_matview_snapshot.readings_rollup TO corpus_anon, corpus_user;
Loading
Loading