diff --git a/packages/function-resolution/__tests__/default-bucket.test.ts b/packages/function-resolution/__tests__/default-bucket.test.ts index 60977c4f..8d45c19b 100644 --- a/packages/function-resolution/__tests__/default-bucket.test.ts +++ b/packages/function-resolution/__tests__/default-bucket.test.ts @@ -93,7 +93,7 @@ describe('default bucket resolution', () => { it('resolves the database default without anyone naming a bucket', async () => { const [row] = await pg.any( `SELECT bucket_id, resolved_key, bucket_type, physical_name, owner_database_id - FROM function_resolution.resolve_default_bucket($1, 'database', $1, false)`, + FROM function_resolution.frame_default_bucket($1, 'database', $1, false)`, [TENANT_DB] ); expect(row).toEqual({ @@ -108,7 +108,7 @@ describe('default bucket resolution', () => { it('public access resolves the CDN-served default, not the private one', async () => { const [row] = await pg.any( `SELECT bucket_id, bucket_type - FROM function_resolution.resolve_default_bucket($1, 'database', $1, true)`, + FROM function_resolution.frame_default_bucket($1, 'database', $1, true)`, [TENANT_DB] ); expect(row).toEqual({ bucket_id: ids.defaultPublic, bucket_type: 'public' }); @@ -117,7 +117,7 @@ describe('default bucket resolution', () => { it('an explicit logical key overrides the default and resolves by the same rule', async () => { const [row] = await pg.any( `SELECT bucket_id, resolved_key - FROM function_resolution.resolve_default_bucket($1, 'database', $1, false, 'avatars')`, + FROM function_resolution.frame_default_bucket($1, 'database', $1, false, 'avatars')`, [TENANT_DB] ); expect(row).toEqual({ bucket_id: ids.avatars, resolved_key: 'avatars' }); @@ -126,7 +126,7 @@ describe('default bucket resolution', () => { it('an explicit key nothing carries raises rather than falling back to the default', async () => { await expect( pg.any( - `SELECT * FROM function_resolution.resolve_default_bucket($1, 'database', $1, false, 'nope')`, + `SELECT * FROM function_resolution.frame_default_bucket($1, 'database', $1, false, 'nope')`, [TENANT_DB] ) ).rejects.toThrow(/STORAGE_DEFAULT_BUCKET_NOT_FOUND/); @@ -135,7 +135,7 @@ describe('default bucket resolution', () => { it('a blank key is a caller bug, not a request for the default', async () => { await expect( pg.any( - `SELECT * FROM function_resolution.resolve_default_bucket($1, 'database', $1, false, ' ')`, + `SELECT * FROM function_resolution.frame_default_bucket($1, 'database', $1, false, ' ')`, [TENANT_DB] ) ).rejects.toThrow(/STORAGE_BUCKET_KEY_BLANK/); @@ -144,7 +144,7 @@ describe('default bucket resolution', () => { it('a database with no default bucket raises', async () => { await expect( pg.any( - `SELECT * FROM function_resolution.resolve_default_bucket($1, 'database', $1, false)`, + `SELECT * FROM function_resolution.frame_default_bucket($1, 'database', $1, false)`, [BARE_DB] ) ).rejects.toThrow(/STORAGE_DEFAULT_BUCKET_NOT_FOUND/); @@ -153,14 +153,14 @@ describe('default bucket resolution', () => { it('another tenant default is never borrowed', async () => { // OTHER_DB has a bucket tagged 'default'; BARE_DB still has none. const [foreign] = await pg.any( - `SELECT bucket_id FROM function_resolution.resolve_default_bucket($1, 'database', $1, false)`, + `SELECT bucket_id FROM function_resolution.frame_default_bucket($1, 'database', $1, false)`, [OTHER_DB] ); expect(foreign.bucket_id).toBe(ids.otherDefault); await expect( pg.any( - `SELECT * FROM function_resolution.resolve_default_bucket($1, 'database', $1, false)`, + `SELECT * FROM function_resolution.frame_default_bucket($1, 'database', $1, false)`, [BARE_DB] ) ).rejects.toThrow(/STORAGE_DEFAULT_BUCKET_NOT_FOUND/); @@ -170,7 +170,7 @@ describe('default bucket resolution', () => { let failure: (Error & { detail?: string }) | null = null; try { await pg.any( - `SELECT * FROM function_resolution.resolve_default_bucket($1, 'database', $1, false)`, + `SELECT * FROM function_resolution.frame_default_bucket($1, 'database', $1, false)`, [AMBIGUOUS_DB] ); } catch (error) { diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/bucket_matches.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/bucket_matches.sql index 2d3dca9f..803c4add 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/bucket_matches.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/bucket_matches.sql @@ -11,7 +11,7 @@ BEGIN; -- -- Split out so that "which buckets match" has exactly one implementation while -- each caller states its own arity rule: resolve_bucket demands one for a --- capability declaration, resolve_default_bucket demands one for a database's +-- capability declaration, frame_default_bucket demands one for a database's -- default, and both raise on zero or several. A second copy of this query would -- be a second answer to "which bucket is this". -- diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/frame_default_bucket.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/frame_default_bucket.sql new file mode 100644 index 00000000..0555070b --- /dev/null +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/frame_default_bucket.sql @@ -0,0 +1,137 @@ +-- Deploy schemas/function_resolution/procedures/frame_default_bucket to pg + +-- requires: schemas/function_resolution/schema +-- requires: schemas/function_resolution/procedures/bucket_matches +-- requires: schemas/function_resolution/procedures/default_bucket_tag + +BEGIN; + +-- frame_default_bucket: answer "which bucket does this database store into +-- when the caller did not name one", server-side, for an explicit frame. +-- +-- The frame is caller-supplied and catalog_private.buckets is read past RLS, so +-- this is the platform lane's entry point alone (the worker's build resolver +-- and bootstrap verification; fun platform COMPONENT_GRANTS). A request reaches +-- the same answer through resolve_default_bucket, which pins the frame to its +-- own claims and membership first. +-- +-- Bucket choice belongs to the database, never to a client: a client-chosen +-- string means a different bucket per tenant, and an environment-level bucket +-- means storage that does not belong to any tenant at all. So there is no +-- global fallback here and no default that resolves outside the execution's own +-- frame chain — a database either labelled a default bucket or it has none, and +-- the second case raises. +-- +-- Two routes, one rule: +-- * bucket_key given -- the per-field override. A logical key is a label, the +-- same vocabulary a function's required_buckets declares, so the override +-- resolves through the identical match: nothing here is key-specific. +-- * bucket_key omitted -- the reserved default tag for the requested access +-- (see default_bucket_tag): 'default' or 'default-public'. +-- +-- Either way the match must be exactly one bucket. Zero raises, several raise +-- naming the candidates, and nothing is guessed in between: an ambiguous default +-- is a tenant's tagging mistake, and picking one would silently write a tenant's +-- files into whichever bucket sorted first. +CREATE FUNCTION function_resolution.frame_default_bucket( + database_id uuid, + scope text, + entity_id uuid, + public_access boolean, + bucket_key text DEFAULT NULL +) RETURNS TABLE ( + bucket_id uuid, + resolved_key text, + bucket_type text, + physical_name text, + owner_database_id uuid, + owner_scope text, + owner_key uuid +) AS $$ +DECLARE + v_tag text; + v_matches jsonb; + v_match jsonb; +BEGIN + -- A blank override is a caller bug, not a request for the default: falling + -- through to tag resolution would turn a broken field binding into a + -- silently different bucket. + IF frame_default_bucket.bucket_key IS NOT NULL + AND btrim(frame_default_bucket.bucket_key) = '' THEN + PERFORM errors.raise_error( + 'STORAGE_BUCKET_KEY_BLANK', + jsonb_build_object( + 'database_id', frame_default_bucket.database_id, + 'scope', frame_default_bucket.scope + ), + 'internal' + ); + END IF; + + v_tag := COALESCE( + frame_default_bucket.bucket_key, + function_resolution.default_bucket_tag(frame_default_bucket.public_access) + ); + + SELECT COALESCE(jsonb_agg(to_jsonb(m) ORDER BY m.bucket_id), '[]'::jsonb) + INTO v_matches + FROM function_resolution.bucket_matches( + frame_default_bucket.database_id, + frame_default_bucket.scope, + frame_default_bucket.entity_id, + ARRAY[v_tag] + ) m; + + IF jsonb_array_length(v_matches) = 0 THEN + PERFORM errors.raise_error( + 'STORAGE_DEFAULT_BUCKET_NOT_FOUND', + jsonb_build_object( + 'database_id', frame_default_bucket.database_id, + 'scope', frame_default_bucket.scope, + 'entity_id', frame_default_bucket.entity_id, + 'tag', v_tag, + 'explicit_key', frame_default_bucket.bucket_key IS NOT NULL + ), + 'internal' + ); + END IF; + + IF jsonb_array_length(v_matches) > 1 THEN + PERFORM errors.raise_error( + 'STORAGE_DEFAULT_BUCKET_AMBIGUOUS', + jsonb_build_object( + 'database_id', frame_default_bucket.database_id, + 'scope', frame_default_bucket.scope, + 'entity_id', frame_default_bucket.entity_id, + 'tag', v_tag, + 'explicit_key', frame_default_bucket.bucket_key IS NOT NULL, + 'candidates', ( + SELECT jsonb_agg(jsonb_build_object( + 'bucket_id', c->>'bucket_id', + 'key', c->>'bucket_key', + 'type', c->>'bucket_type' + ) ORDER BY c->>'bucket_key') + FROM jsonb_array_elements(v_matches) c + ) + ), + 'internal' + ); + END IF; + + v_match := v_matches->0; + + frame_default_bucket.bucket_id := (v_match->>'bucket_id')::uuid; + frame_default_bucket.resolved_key := v_match->>'bucket_key'; + frame_default_bucket.bucket_type := v_match->>'bucket_type'; + frame_default_bucket.physical_name := v_match->>'physical_name'; + frame_default_bucket.owner_database_id := (v_match->>'owner_database_id')::uuid; + frame_default_bucket.owner_scope := v_match->>'owner_scope'; + frame_default_bucket.owner_key := (v_match->>'owner_key')::uuid; + + RETURN NEXT; +END; +$$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +REVOKE ALL ON FUNCTION function_resolution.frame_default_bucket(uuid, text, uuid, boolean, text) FROM PUBLIC; + +COMMIT; diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_default_bucket.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_default_bucket.sql index e911199a..0c94913a 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_default_bucket.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_default_bucket.sql @@ -1,34 +1,28 @@ -- Deploy schemas/function_resolution/procedures/resolve_default_bucket to pg -- requires: schemas/function_resolution/schema --- requires: schemas/function_resolution/procedures/bucket_matches --- requires: schemas/function_resolution/procedures/default_bucket_tag +-- requires: schemas/function_resolution/procedures/frame_default_bucket +-- requires: schemas/function_resolution/procedures/storage_module_registrations +-- requires: schemas/function_resolution/procedures/memberships_module_registrations +-- requires: pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id +-- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id +-- requires: pgpm-jwt-claims:schemas/jwt_public/procedures/current_principal_id +-- requires: errors:schemas/errors/procedures/raise_error BEGIN; --- resolve_default_bucket: answer "which bucket does this database store into --- when the caller did not name one", server-side. +-- resolve_default_bucket: the request lane's default bucket — what an upload +-- that names no bucketKey stores into — for the request's own database. -- --- Bucket choice belongs to the database, never to a client: a client-chosen --- string means a different bucket per tenant, and an environment-level bucket --- means storage that does not belong to any tenant at all. So there is no --- global fallback here and no default that resolves outside the execution's own --- frame chain — a database either labelled a default bucket or it has none, and --- the second case raises. --- --- Two routes, one rule: --- * bucket_key given -- the per-field override. A logical key is a label, the --- same vocabulary a function's required_buckets declares, so the override --- resolves through the identical match: nothing here is key-specific. --- * bucket_key omitted -- the reserved default tag for the requested access --- (see default_bucket_tag): 'default' or 'default-public'. --- --- Either way the match must be exactly one bucket. Zero raises, several raise --- naming the candidates, and nothing is guessed in between: an ambiguous default --- is a tenant's tagging mistake, and picking one would silently write a tenant's --- files into whichever bucket sorted first. +-- The frame is never the caller's to choose. The database is the request's +-- database claim, exactly as storage_modules() reads it, and the scope must be +-- one of that database's storage planes. The caller must then hold a membership +-- at that scope (its SPRT row, the plane every policy reads): of the entity for +-- an entity-scoped plane, of the app for a global one. Only then is the frame +-- handed to frame_default_bucket, which reads catalog_private.buckets past RLS. +-- Without these checks a caller could name another tenant's database or another +-- entity and read back its private default bucket id and physical name. CREATE FUNCTION function_resolution.resolve_default_bucket( - database_id uuid, scope text, entity_id uuid, public_access boolean, @@ -43,87 +37,91 @@ CREATE FUNCTION function_resolution.resolve_default_bucket( owner_key uuid ) AS $$ DECLARE - v_tag text; - v_matches jsonb; - v_match jsonb; + v_database_id uuid := jwt_private.require_database_id(); + v_actor_id uuid := COALESCE(jwt_public.current_principal_id(), jwt_public.current_user_id()); + v_entity_scoped boolean; + v_membership_scope text; + v_sprt_schema text; + v_sprt_table text; + v_is_member boolean; BEGIN - -- A blank override is a caller bug, not a request for the default: falling - -- through to tag resolution would turn a broken field binding into a - -- silently different bucket. - IF resolve_default_bucket.bucket_key IS NOT NULL - AND btrim(resolve_default_bucket.bucket_key) = '' THEN + SELECT bool_or(r.entity_table_id IS NOT NULL) + INTO v_entity_scoped + FROM function_resolution.storage_module_registrations(v_database_id) AS r + WHERE r.scope = resolve_default_bucket.scope; + + IF v_entity_scoped IS NULL THEN PERFORM errors.raise_error( - 'STORAGE_BUCKET_KEY_BLANK', - jsonb_build_object( - 'database_id', resolve_default_bucket.database_id, - 'scope', resolve_default_bucket.scope - ), + 'STORAGE_MODULE_NOT_FOUND', + jsonb_build_object('scope', resolve_default_bucket.scope), 'internal' ); END IF; - v_tag := COALESCE( - resolve_default_bucket.bucket_key, - function_resolution.default_bucket_tag(resolve_default_bucket.public_access) - ); - - SELECT COALESCE(jsonb_agg(to_jsonb(m) ORDER BY m.bucket_id), '[]'::jsonb) - INTO v_matches - FROM function_resolution.bucket_matches( - resolve_default_bucket.database_id, - resolve_default_bucket.scope, - resolve_default_bucket.entity_id, - ARRAY[v_tag] - ) m; + IF v_entity_scoped AND resolve_default_bucket.entity_id IS NULL THEN + PERFORM errors.raise_error( + 'STORAGE_SCOPE_KEY_REQUIRED', + jsonb_build_object('scope', resolve_default_bucket.scope), + 'internal' + ); + END IF; - IF jsonb_array_length(v_matches) = 0 THEN + IF NOT v_entity_scoped AND resolve_default_bucket.entity_id IS NOT NULL THEN PERFORM errors.raise_error( - 'STORAGE_DEFAULT_BUCKET_NOT_FOUND', - jsonb_build_object( - 'database_id', resolve_default_bucket.database_id, - 'scope', resolve_default_bucket.scope, - 'entity_id', resolve_default_bucket.entity_id, - 'tag', v_tag, - 'explicit_key', resolve_default_bucket.bucket_key IS NOT NULL - ), + 'STORAGE_SCOPE_KEY_UNEXPECTED', + jsonb_build_object('scope', resolve_default_bucket.scope), 'internal' ); END IF; - IF jsonb_array_length(v_matches) > 1 THEN + v_membership_scope := CASE WHEN v_entity_scoped THEN resolve_default_bucket.scope ELSE 'app' END; + + SELECT m.private_schema, m.sprt_table_name + INTO v_sprt_schema, v_sprt_table + FROM function_resolution.memberships_module_registrations(v_database_id) AS m + WHERE m.scope = v_membership_scope; + + IF v_actor_id IS NOT NULL AND v_sprt_table IS NOT NULL THEN + IF v_entity_scoped THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the SPRT lives under the database's generated schema; identifiers are catalog data, values are bound + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I m WHERE m.actor_id = $1 AND m.entity_id = $2)', + v_sprt_schema, v_sprt_table + ) INTO v_is_member USING v_actor_id, resolve_default_bucket.entity_id; + ELSE + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the SPRT lives under the database's generated schema; identifiers are catalog data, values are bound + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I m WHERE m.actor_id = $1)', + v_sprt_schema, v_sprt_table + ) INTO v_is_member USING v_actor_id; + END IF; + END IF; + + IF v_is_member IS NOT TRUE THEN PERFORM errors.raise_error( - 'STORAGE_DEFAULT_BUCKET_AMBIGUOUS', + 'STORAGE_SCOPE_MEMBERSHIP_REQUIRED', jsonb_build_object( - 'database_id', resolve_default_bucket.database_id, 'scope', resolve_default_bucket.scope, - 'entity_id', resolve_default_bucket.entity_id, - 'tag', v_tag, - 'explicit_key', resolve_default_bucket.bucket_key IS NOT NULL, - 'candidates', ( - SELECT jsonb_agg(jsonb_build_object( - 'bucket_id', c->>'bucket_id', - 'key', c->>'bucket_key', - 'type', c->>'bucket_type' - ) ORDER BY c->>'bucket_key') - FROM jsonb_array_elements(v_matches) c - ) + 'entity_id', resolve_default_bucket.entity_id ), 'internal' ); END IF; - v_match := v_matches->0; - - resolve_default_bucket.bucket_id := (v_match->>'bucket_id')::uuid; - resolve_default_bucket.resolved_key := v_match->>'bucket_key'; - resolve_default_bucket.bucket_type := v_match->>'bucket_type'; - resolve_default_bucket.physical_name := v_match->>'physical_name'; - resolve_default_bucket.owner_database_id := (v_match->>'owner_database_id')::uuid; - resolve_default_bucket.owner_scope := v_match->>'owner_scope'; - resolve_default_bucket.owner_key := (v_match->>'owner_key')::uuid; - - RETURN NEXT; + RETURN QUERY + SELECT f.bucket_id, f.resolved_key, f.bucket_type, f.physical_name, + f.owner_database_id, f.owner_scope, f.owner_key + FROM function_resolution.frame_default_bucket( + v_database_id, + resolve_default_bucket.scope, + resolve_default_bucket.entity_id, + resolve_default_bucket.public_access, + resolve_default_bucket.bucket_key + ) AS f; END; $$ LANGUAGE plpgsql STABLE SECURITY DEFINER; +REVOKE ALL ON FUNCTION function_resolution.resolve_default_bucket(text, uuid, boolean, text) FROM PUBLIC; +GRANT EXECUTE ON FUNCTION function_resolution.resolve_default_bucket(text, uuid, boolean, text) TO authenticated; + COMMIT; diff --git a/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_staging_bucket.sql b/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_staging_bucket.sql index 96d87845..e00e058b 100644 --- a/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_staging_bucket.sql +++ b/packages/function-resolution/deploy/schemas/function_resolution/procedures/resolve_staging_bucket.sql @@ -7,7 +7,7 @@ BEGIN; -- resolve_staging_bucket: answer "which bucket does this database stage an --- upload through", server-side, by the same rule resolve_default_bucket uses +-- upload through", server-side, by the same rule frame_default_bucket uses -- for the destination. -- -- Staging is not a second mechanism: the reserved staging tag is matched in the diff --git a/packages/function-resolution/pgpm.plan b/packages/function-resolution/pgpm.plan index 0fc67391..d8118f63 100644 --- a/packages/function-resolution/pgpm.plan +++ b/packages/function-resolution/pgpm.plan @@ -12,7 +12,7 @@ schemas/function_resolution/procedures/enqueue [schemas/function_resolution/sche schemas/function_resolution/procedures/bucket_matches [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates] 2017-08-11T08:11:51Z constructive # buckets a selector matches in the nearest frame schemas/function_resolution/procedures/resolve_bucket [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates schemas/function_resolution/procedures/bucket_matches] 2017-08-11T08:11:51Z constructive # bucket selector {tags,type} resolution schemas/function_resolution/procedures/default_bucket_tag [schemas/function_resolution/schema] 2017-08-11T08:11:51Z constructive # reserved default-bucket tag vocabulary -schemas/function_resolution/procedures/resolve_default_bucket [schemas/function_resolution/schema schemas/function_resolution/procedures/bucket_matches schemas/function_resolution/procedures/default_bucket_tag] 2017-08-11T08:11:51Z constructive # the database's default bucket, or an explicit key +schemas/function_resolution/procedures/frame_default_bucket [schemas/function_resolution/schema schemas/function_resolution/procedures/bucket_matches schemas/function_resolution/procedures/default_bucket_tag] 2017-08-11T08:11:51Z constructive # a frame's default bucket, or an explicit key, for the platform lane schemas/function_resolution/procedures/staging_bucket_tag [schemas/function_resolution/schema] 2017-08-11T08:11:51Z constructive # reserved staging-bucket tag vocabulary schemas/function_resolution/procedures/resolve_staging_bucket [schemas/function_resolution/schema schemas/function_resolution/procedures/bucket_matches schemas/function_resolution/procedures/staging_bucket_tag] 2017-08-11T08:11:51Z constructive # the module's staging bucket for uploads schemas/function_resolution/procedures/bucket_catalog_row [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates] 2017-08-11T08:11:51Z constructive # reachable bucket by id (same-tenant proof) @@ -85,3 +85,4 @@ schemas/function_resolution/procedures/database_features [schemas/function_resol schemas/function_resolution/procedures/request_protection [schemas/function_resolution/schema] 2026-10-15T05:00:00Z devin # one database's raw request bounds and API overrides, for the platform login only schemas/function_resolution/procedures/refusal_log_module_registrations [schemas/function_resolution/schema metaschema-modules:schemas/metaschema_modules_public/tables/refusal_log_module/table metaschema-schema:schemas/metaschema_public/tables/schema/table pgpm-app-scope:schemas/app_scope/procedures/platform_database_id] 2026-10-17T16:30:00Z devin # platform-login reader for refusal_log_module (worker sql-runner, NOBYPASSRLS) schemas/function_resolution/procedures/ownership_challenge [schemas/function_resolution/schema] 2026-10-20T00:00:00Z devin # the pending http_01 token a hostname is verified with, for the platform login only +schemas/function_resolution/procedures/resolve_default_bucket [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_default_bucket schemas/function_resolution/procedures/storage_module_registrations schemas/function_resolution/procedures/memberships_module_registrations pgpm-jwt-claims:schemas/jwt_private/procedures/require_database_id pgpm-jwt-claims:schemas/jwt_public/procedures/current_user_id pgpm-jwt-claims:schemas/jwt_public/procedures/current_principal_id errors:schemas/errors/procedures/raise_error] 2026-10-25T12:00:00Z devin # the request database's default bucket, pinned to its claims and the caller's membership diff --git a/packages/function-resolution/revert/schemas/function_resolution/procedures/frame_default_bucket.sql b/packages/function-resolution/revert/schemas/function_resolution/procedures/frame_default_bucket.sql new file mode 100644 index 00000000..1ac97c8b --- /dev/null +++ b/packages/function-resolution/revert/schemas/function_resolution/procedures/frame_default_bucket.sql @@ -0,0 +1,7 @@ +-- Revert schemas/function_resolution/procedures/frame_default_bucket from pg + +BEGIN; + +DROP FUNCTION function_resolution.frame_default_bucket(uuid, text, uuid, boolean, text); + +COMMIT; diff --git a/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_default_bucket.sql b/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_default_bucket.sql index 088579fd..47363255 100644 --- a/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_default_bucket.sql +++ b/packages/function-resolution/revert/schemas/function_resolution/procedures/resolve_default_bucket.sql @@ -2,6 +2,6 @@ BEGIN; -DROP FUNCTION function_resolution.resolve_default_bucket(uuid, text, uuid, boolean, text); +DROP FUNCTION function_resolution.resolve_default_bucket(text, uuid, boolean, text); COMMIT; diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.bundle.tar.gz b/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.bundle.tar.gz index 872d79ca..2b54a700 100644 Binary files a/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.bundle.tar.gz and b/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.bundle.tar.gz differ diff --git a/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.sql b/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.sql index b0a19549..fd87d9a3 100644 --- a/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.sql +++ b/packages/function-resolution/sql/pgpm-function-resolution--0.47.8.sql @@ -556,7 +556,7 @@ $EOFCODE$ LANGUAGE sql IMMUTABLE; GRANT EXECUTE ON FUNCTION function_resolution.default_bucket_tag(boolean) TO authenticated; -CREATE FUNCTION function_resolution.resolve_default_bucket( +CREATE FUNCTION function_resolution.frame_default_bucket( database_id uuid, scope text, entity_id uuid, @@ -579,29 +579,29 @@ BEGIN -- A blank override is a caller bug, not a request for the default: falling -- through to tag resolution would turn a broken field binding into a -- silently different bucket. - IF resolve_default_bucket.bucket_key IS NOT NULL - AND btrim(resolve_default_bucket.bucket_key) = '' THEN + IF frame_default_bucket.bucket_key IS NOT NULL + AND btrim(frame_default_bucket.bucket_key) = '' THEN PERFORM errors.raise_error( 'STORAGE_BUCKET_KEY_BLANK', jsonb_build_object( - 'database_id', resolve_default_bucket.database_id, - 'scope', resolve_default_bucket.scope + 'database_id', frame_default_bucket.database_id, + 'scope', frame_default_bucket.scope ), 'internal' ); END IF; v_tag := COALESCE( - resolve_default_bucket.bucket_key, - function_resolution.default_bucket_tag(resolve_default_bucket.public_access) + frame_default_bucket.bucket_key, + function_resolution.default_bucket_tag(frame_default_bucket.public_access) ); SELECT COALESCE(jsonb_agg(to_jsonb(m) ORDER BY m.bucket_id), '[]'::jsonb) INTO v_matches FROM function_resolution.bucket_matches( - resolve_default_bucket.database_id, - resolve_default_bucket.scope, - resolve_default_bucket.entity_id, + frame_default_bucket.database_id, + frame_default_bucket.scope, + frame_default_bucket.entity_id, ARRAY[v_tag] ) m; @@ -609,11 +609,11 @@ BEGIN PERFORM errors.raise_error( 'STORAGE_DEFAULT_BUCKET_NOT_FOUND', jsonb_build_object( - 'database_id', resolve_default_bucket.database_id, - 'scope', resolve_default_bucket.scope, - 'entity_id', resolve_default_bucket.entity_id, + 'database_id', frame_default_bucket.database_id, + 'scope', frame_default_bucket.scope, + 'entity_id', frame_default_bucket.entity_id, 'tag', v_tag, - 'explicit_key', resolve_default_bucket.bucket_key IS NOT NULL + 'explicit_key', frame_default_bucket.bucket_key IS NOT NULL ), 'internal' ); @@ -623,11 +623,11 @@ BEGIN PERFORM errors.raise_error( 'STORAGE_DEFAULT_BUCKET_AMBIGUOUS', jsonb_build_object( - 'database_id', resolve_default_bucket.database_id, - 'scope', resolve_default_bucket.scope, - 'entity_id', resolve_default_bucket.entity_id, + 'database_id', frame_default_bucket.database_id, + 'scope', frame_default_bucket.scope, + 'entity_id', frame_default_bucket.entity_id, 'tag', v_tag, - 'explicit_key', resolve_default_bucket.bucket_key IS NOT NULL, + 'explicit_key', frame_default_bucket.bucket_key IS NOT NULL, 'candidates', ( SELECT jsonb_agg(jsonb_build_object( 'bucket_id', c->>'bucket_id', @@ -643,18 +643,20 @@ BEGIN v_match := v_matches->0; - resolve_default_bucket.bucket_id := (v_match->>'bucket_id')::uuid; - resolve_default_bucket.resolved_key := v_match->>'bucket_key'; - resolve_default_bucket.bucket_type := v_match->>'bucket_type'; - resolve_default_bucket.physical_name := v_match->>'physical_name'; - resolve_default_bucket.owner_database_id := (v_match->>'owner_database_id')::uuid; - resolve_default_bucket.owner_scope := v_match->>'owner_scope'; - resolve_default_bucket.owner_key := (v_match->>'owner_key')::uuid; + frame_default_bucket.bucket_id := (v_match->>'bucket_id')::uuid; + frame_default_bucket.resolved_key := v_match->>'bucket_key'; + frame_default_bucket.bucket_type := v_match->>'bucket_type'; + frame_default_bucket.physical_name := v_match->>'physical_name'; + frame_default_bucket.owner_database_id := (v_match->>'owner_database_id')::uuid; + frame_default_bucket.owner_scope := v_match->>'owner_scope'; + frame_default_bucket.owner_key := (v_match->>'owner_key')::uuid; RETURN NEXT; END; $EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; +REVOKE ALL ON FUNCTION function_resolution.frame_default_bucket(uuid, text, uuid, boolean, text) FROM PUBLIC RESTRICT; + CREATE FUNCTION function_resolution.staging_bucket_tag() RETURNS text AS $EOFCODE$ SELECT 'default-temp'; $EOFCODE$ LANGUAGE sql IMMUTABLE; @@ -4654,4 +4656,107 @@ BEGIN END; $EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; -REVOKE ALL ON FUNCTION function_resolution.ownership_challenge(text, text) FROM PUBLIC RESTRICT; \ No newline at end of file +REVOKE ALL ON FUNCTION function_resolution.ownership_challenge(text, text) FROM PUBLIC RESTRICT; + +CREATE FUNCTION function_resolution.resolve_default_bucket( + scope text, + entity_id uuid, + public_access boolean, + bucket_key text DEFAULT NULL +) RETURNS TABLE ( + bucket_id uuid, + resolved_key text, + bucket_type text, + physical_name text, + owner_database_id uuid, + owner_scope text, + owner_key uuid +) AS $EOFCODE$ +DECLARE + v_database_id uuid := jwt_private.require_database_id(); + v_actor_id uuid := COALESCE(jwt_public.current_principal_id(), jwt_public.current_user_id()); + v_entity_scoped boolean; + v_membership_scope text; + v_sprt_schema text; + v_sprt_table text; + v_is_member boolean; +BEGIN + SELECT bool_or(r.entity_table_id IS NOT NULL) + INTO v_entity_scoped + FROM function_resolution.storage_module_registrations(v_database_id) AS r + WHERE r.scope = resolve_default_bucket.scope; + + IF v_entity_scoped IS NULL THEN + PERFORM errors.raise_error( + 'STORAGE_MODULE_NOT_FOUND', + jsonb_build_object('scope', resolve_default_bucket.scope), + 'internal' + ); + END IF; + + IF v_entity_scoped AND resolve_default_bucket.entity_id IS NULL THEN + PERFORM errors.raise_error( + 'STORAGE_SCOPE_KEY_REQUIRED', + jsonb_build_object('scope', resolve_default_bucket.scope), + 'internal' + ); + END IF; + + IF NOT v_entity_scoped AND resolve_default_bucket.entity_id IS NOT NULL THEN + PERFORM errors.raise_error( + 'STORAGE_SCOPE_KEY_UNEXPECTED', + jsonb_build_object('scope', resolve_default_bucket.scope), + 'internal' + ); + END IF; + + v_membership_scope := CASE WHEN v_entity_scoped THEN resolve_default_bucket.scope ELSE 'app' END; + + SELECT m.private_schema, m.sprt_table_name + INTO v_sprt_schema, v_sprt_table + FROM function_resolution.memberships_module_registrations(v_database_id) AS m + WHERE m.scope = v_membership_scope; + + IF v_actor_id IS NOT NULL AND v_sprt_table IS NOT NULL THEN + IF v_entity_scoped THEN + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the SPRT lives under the database's generated schema; identifiers are catalog data, values are bound + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I m WHERE m.actor_id = $1 AND m.entity_id = $2)', + v_sprt_schema, v_sprt_table + ) INTO v_is_member USING v_actor_id, resolve_default_bucket.entity_id; + ELSE + -- pgsql-lint-disable-next-line no-dynamic-sql -- lookup-only: the SPRT lives under the database's generated schema; identifiers are catalog data, values are bound + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I m WHERE m.actor_id = $1)', + v_sprt_schema, v_sprt_table + ) INTO v_is_member USING v_actor_id; + END IF; + END IF; + + IF v_is_member IS NOT TRUE THEN + PERFORM errors.raise_error( + 'STORAGE_SCOPE_MEMBERSHIP_REQUIRED', + jsonb_build_object( + 'scope', resolve_default_bucket.scope, + 'entity_id', resolve_default_bucket.entity_id + ), + 'internal' + ); + END IF; + + RETURN QUERY + SELECT f.bucket_id, f.resolved_key, f.bucket_type, f.physical_name, + f.owner_database_id, f.owner_scope, f.owner_key + FROM function_resolution.frame_default_bucket( + v_database_id, + resolve_default_bucket.scope, + resolve_default_bucket.entity_id, + resolve_default_bucket.public_access, + resolve_default_bucket.bucket_key + ) AS f; +END; +$EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER; + +REVOKE ALL ON FUNCTION function_resolution.resolve_default_bucket(text, uuid, boolean, text) FROM PUBLIC RESTRICT; + +GRANT EXECUTE ON FUNCTION function_resolution.resolve_default_bucket(text, uuid, boolean, text) TO authenticated; \ No newline at end of file diff --git a/packages/function-resolution/verify/schemas/function_resolution/procedures/frame_default_bucket.sql b/packages/function-resolution/verify/schemas/function_resolution/procedures/frame_default_bucket.sql new file mode 100644 index 00000000..9b7b04b6 --- /dev/null +++ b/packages/function-resolution/verify/schemas/function_resolution/procedures/frame_default_bucket.sql @@ -0,0 +1,7 @@ +-- Verify schemas/function_resolution/procedures/frame_default_bucket on pg + +BEGIN; + +SELECT assert_function('function_resolution.frame_default_bucket(uuid, text, uuid, boolean, text)'::regprocedure); + +ROLLBACK; diff --git a/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_default_bucket.sql b/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_default_bucket.sql index 3ed76ac8..6f80a31a 100644 --- a/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_default_bucket.sql +++ b/packages/function-resolution/verify/schemas/function_resolution/procedures/resolve_default_bucket.sql @@ -2,6 +2,6 @@ BEGIN; -SELECT assert_function('function_resolution.resolve_default_bucket(uuid, text, uuid, boolean, text)'::regprocedure); +SELECT assert_function('function_resolution.resolve_default_bucket(text, uuid, boolean, text)'::regprocedure); ROLLBACK;