diff --git a/migrations/tenant/57-operation-ergonomics.sql b/migrations/tenant/57-operation-ergonomics.sql new file mode 100644 index 000000000..27a731166 --- /dev/null +++ b/migrations/tenant/57-operation-ergonomics.sql @@ -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; +$$; diff --git a/src/internal/database/migrations/types.ts b/src/internal/database/migrations/types.ts index 35bd839c0..f4c95cced 100644 --- a/src/internal/database/migrations/types.ts +++ b/src/internal/database/migrations/types.ts @@ -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 diff --git a/src/scripts/migrations-types.ts b/src/scripts/migrations-types.ts index 374142166..24fdcf9f9 100644 --- a/src/scripts/migrations-types.ts +++ b/src/scripts/migrations-types.ts @@ -39,7 +39,7 @@ function main() { const template = `export const DBMigration = { ${migrationsEnum.join('\n')} -} +} as const ` const destinationPath = path.resolve( diff --git a/src/test/operation-helpers.test.ts b/src/test/operation-helpers.test.ts new file mode 100644 index 000000000..580adbbe2 --- /dev/null +++ b/src/test/operation-helpers.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/test/rls_tests.yaml b/src/test/rls_tests.yaml index 2581bc9d0..1473426ca 100644 --- a/src/test/rls_tests.yaml +++ b/src/test/rls_tests.yaml @@ -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: @@ -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"