Skip to content
Open
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
53 changes: 53 additions & 0 deletions migrations/tenant/57-operation-ergonomics.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- Ergonomic helpers for operation-aware RLS policies.
-- These helpers read the existing transaction-local storage.operation GUC (Grand Unified Configuration).

CREATE OR REPLACE FUNCTION storage.allow_only_operation(expected_operation text)
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
WITH normalized AS (
SELECT
CASE
WHEN storage.operation() LIKE 'storage.%' THEN substr(storage.operation(), 9)
ELSE storage.operation()
END AS current_operation,
CASE
WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9)
ELSE expected_operation
END AS requested_operation
)
SELECT CASE
WHEN requested_operation IS NULL OR requested_operation = '' THEN FALSE
ELSE COALESCE(current_operation = requested_operation, FALSE)
END
FROM normalized;
$$;

CREATE OR REPLACE FUNCTION storage.allow_any_operation(expected_operations text[])
RETURNS boolean
LANGUAGE sql
STABLE
AS $$
WITH normalized AS (
SELECT CASE
WHEN storage.operation() LIKE 'storage.%' THEN substr(storage.operation(), 9)
ELSE storage.operation()
END AS current_operation
)
SELECT COALESCE(
current_operation = ANY(
ARRAY(
SELECT CASE
WHEN expected_operation LIKE 'storage.%' THEN substr(expected_operation, 9)
ELSE expected_operation
END
FROM unnest(expected_operations) AS expected_operation
WHERE expected_operation IS NOT NULL
AND expected_operation <> ''
)
),
FALSE
)
FROM normalized;
$$;
3 changes: 2 additions & 1 deletion src/internal/database/migrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,5 @@ export const DBMigration = {
'drop-index-object-level': 54,
'prevent-direct-deletes': 55,
'fix-optimized-search-function': 56,
}
'operation-ergonomics': 57,
} as const
2 changes: 1 addition & 1 deletion src/scripts/migrations-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ function main() {

const template = `export const DBMigration = {
${migrationsEnum.join('\n')}
}
} as const
`

const destinationPath = path.resolve(
Expand Down
113 changes: 113 additions & 0 deletions src/test/operation-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
'use strict'

import { useStorage } from './utils/storage'

describe('Storage operation helpers', () => {
const tHelper = useStorage()

async function selectAllowed(
sql: string,
bindings: unknown[] = [],
currentOperation?: string
): Promise<boolean> {
const db = tHelper.database.connection.pool.acquire()
const tnx = await db.transaction()

try {
if (currentOperation) {
await tnx.raw(`SELECT set_config('storage.operation', ?, true)`, [currentOperation])
}

const result = await tnx.raw(sql, bindings)
return result.rows[0].allowed
} finally {
if (!tnx.isCompleted()) {
await tnx.rollback()
}
}
}

it('matches canonical operations through short and full names', async () => {
await expect(
selectAllowed(
`SELECT storage.allow_only_operation(?) AS allowed`,
['storage.object.list'],
'storage.object.list'
)
).resolves.toBe(true)

await expect(
selectAllowed(
`SELECT storage.allow_only_operation(?) AS allowed`,
['object.list'],
'storage.object.list'
)
).resolves.toBe(true)

await expect(
selectAllowed(
`SELECT storage.allow_only_operation(?) AS allowed`,
['object.get'],
'storage.object.list'
)
).resolves.toBe(false)
})

it('keeps compatibility with current bare object.* route operation values', async () => {
await expect(
selectAllowed(
`SELECT storage.allow_only_operation(?) AS allowed`,
['object.get_authenticated_info'],
'object.get_authenticated_info'
)
).resolves.toBe(true)

await expect(
selectAllowed(
`SELECT storage.allow_only_operation(?) AS allowed`,
['storage.object.get_authenticated_info'],
'object.get_authenticated_info'
)
).resolves.toBe(true)
})

it('returns false when the current operation is unset or the input is empty', async () => {
await expect(
selectAllowed(`SELECT storage.allow_only_operation(?) AS allowed`, ['object.list'])
).resolves.toBe(false)

await expect(
selectAllowed(
`SELECT storage.allow_only_operation(?) AS allowed`,
[''],
'storage.object.list'
)
).resolves.toBe(false)
})

it('matches any provided operation without prefix semantics', async () => {
await expect(
selectAllowed(
`SELECT storage.allow_any_operation(ARRAY[?, ?]::text[]) AS allowed`,
['bucket.get', 'storage.object.list'],
'storage.object.list'
)
).resolves.toBe(true)

await expect(
selectAllowed(
`SELECT storage.allow_any_operation(ARRAY[?]::text[]) AS allowed`,
['object'],
'storage.object.list'
)
).resolves.toBe(false)

await expect(
selectAllowed(
`SELECT storage.allow_any_operation(ARRAY[]::text[]) AS allowed`,
[],
'storage.object.list'
)
).resolves.toBe(false)
})
})
51 changes: 51 additions & 0 deletions src/test/rls_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ policies:
permissions: ["delete"]
content: "USING(owner = '{{uid}}')"

- name: read_only_list_objects
tables: ["storage.objects"]
roles: ["authenticated"]
permissions: ["select"]
content: "USING(storage.allow_only_operation('object.list') AND owner = '{{uid}}')"

- name: read_list_or_get_objects
tables: ["storage.objects"]
roles: ["authenticated"]
permissions: ["select"]
content: "USING(storage.allow_any_operation(ARRAY['object.list', 'storage.object.get_authenticated']) AND owner = '{{uid}}')"

tests:
- description: "Will only able to read objects"
policies:
Expand Down Expand Up @@ -475,3 +487,42 @@ tests:
- operation: bucket.delete
status: 400
error: "Bucket not found"

- description: "Operation helper list-only policies only allow object listing"
policies:
- read_only_list_objects
asserts:
- operation: upload
policies:
- insert_only_all_objects
status: 200

- operation: object.list
status: 200

- operation: object.get
status: 400
error: "Object not found"

- operation: object.delete
status: 400
error: "Object not found"

- description: "Operation helper any-of policies allow both list and get"
policies:
- read_list_or_get_objects
asserts:
- operation: upload
policies:
- insert_only_all_objects
status: 200

- operation: object.list
status: 200

- operation: object.get
status: 200

- operation: object.delete
status: 400
error: "Object not found"