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: 1 addition & 1 deletion packages/app-scope/__tests__/membership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('app_scope scope frames (membership climb + platform fall-through)', ()
database_id, scope, schema_id, private_schema_id,
memberships_table_id, membership_defaults_table_id, members_table_id,
grants_table_id, sprt_table_id, actor_table_id, limits_table_id,
default_limits_table_id, permissions_table_id, default_permissions_table_id,
default_limits_table_id, capabilities_table_id, default_capabilities_table_id,
entity_table_id, entity_table_owner_id
) VALUES (
$1, $2, $3, $3,
Expand Down
140 changes: 140 additions & 0 deletions packages/function-resolution/__tests__/staging-bucket.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { getConnections, PgTestClient } from 'pgsql-test';

let pg: PgTestClient;
let teardown: () => Promise<void>;

// Deterministic fixture ids.
const TENANT_DB = '22222222-2222-2222-2222-222222222222';
// A tenant whose storage bootstrap never labelled a staging bucket.
const BARE_DB = '33333333-3333-3333-3333-333333333333';
// A tenant that labelled two, which is a tagging mistake and not a coin flip.
const AMBIGUOUS_DB = '44444444-4444-4444-4444-444444444444';

const ids: Record<string, string> = {};

// The database's staging bucket: the answer to "where does an upload land before
// anything has vouched for it". It resolves by the same exactly-one tag rule the
// default bucket uses, so staging is not a second lookup mechanism — and a
// module that has no staging bucket raises rather than staging into a permanent
// one.
describe('staging bucket resolution', () => {
beforeAll(async () => {
({ pg, teardown } = await getConnections());

await pg.query(
`INSERT INTO metaschema_public.database (id, name, platform)
VALUES ($1, 'tenant_db', false), ($2, 'bare_db', false),
($3, 'ambiguous_db', false)`,
[TENANT_DB, BARE_DB, AMBIGUOUS_DB]
);

// The published bucket plane, as the catalog-sync triggers maintain it.
await pg.query(`CREATE SCHEMA catalog_private`);
await pg.query(
`CREATE TABLE catalog_private.buckets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_scope text NOT NULL,
owner_key uuid,
is_visible boolean NOT NULL DEFAULT false,
database_id uuid NOT NULL,
key text NOT NULL,
type text NOT NULL,
physical_name text,
tags text[]
)`
);

const bucket = async (
database: string,
key: string,
type: string,
tags: string[]
) => {
const row = await pg.one(
`INSERT INTO catalog_private.buckets
(owner_scope, owner_key, is_visible, database_id, key, type, physical_name, tags)
VALUES ('database', $1, false, $1, $2, $3, $4, $5) RETURNING id`,
[database, key, type, `phys-${key}`, tags]
);
return row.id;
};

ids.default = await bucket(TENANT_DB, 'default', 'private', ['default']);
ids.staging = await bucket(TENANT_DB, 'default-temp', 'temp', [
'default-temp',
]);

// A permanent bucket carrying the staging tag is not a staging bucket: the
// type filter is part of the rule, not a hint.
ids.mislabelled = await bucket(BARE_DB, 'uploads', 'private', [
'default-temp',
]);

ids.ambiguousOne = await bucket(AMBIGUOUS_DB, 'staging_a', 'temp', [
'default-temp',
]);
ids.ambiguousTwo = await bucket(AMBIGUOUS_DB, 'staging_b', 'temp', [
'default-temp',
]);
});

afterAll(async () => {
await teardown();
});

it('the reserved staging tag is one fact, not a literal per call site', async () => {
const [row] = await pg.any(
`SELECT function_resolution.staging_bucket_tag() AS staging_tag`
);
expect(row).toEqual({ staging_tag: 'default-temp' });
});

it('resolves the database staging bucket without anyone naming one', async () => {
const [row] = await pg.any(
`SELECT bucket_id, resolved_key, bucket_type, physical_name, owner_database_id
FROM function_resolution.resolve_staging_bucket($1, 'database', $1)`,
[TENANT_DB]
);
expect(row).toEqual({
bucket_id: ids.staging,
resolved_key: 'default-temp',
bucket_type: 'temp',
physical_name: 'phys-default-temp',
owner_database_id: TENANT_DB,
});
});

it('a database with no staging bucket raises rather than staging into a permanent one', async () => {
// BARE_DB does carry the tag — on a private bucket, which is exactly the
// case that must not resolve.
await expect(
pg.any(
`SELECT * FROM function_resolution.resolve_staging_bucket($1, 'database', $1)`,
[BARE_DB]
)
).rejects.toThrow(/STORAGE_STAGING_BUCKET_NOT_FOUND/);
});

it('two staging buckets raise, naming the candidates', async () => {
let failure: (Error & { detail?: string }) | null = null;
try {
await pg.any(
`SELECT * FROM function_resolution.resolve_staging_bucket($1, 'database', $1)`,
[AMBIGUOUS_DB]
);
} catch (error) {
failure = error as Error & { detail?: string };
}

expect(failure).not.toBeNull();
expect(failure!.message).toMatch(/STORAGE_STAGING_BUCKET_AMBIGUOUS/);

const detail = JSON.parse(failure!.detail!);
expect(detail.code).toBe('STORAGE_STAGING_BUCKET_AMBIGUOUS');
expect(detail.context.tag).toBe('default-temp');
expect(detail.context.candidates).toEqual([
{ bucket_id: ids.ambiguousOne, key: 'staging_a', type: 'temp' },
{ bucket_id: ids.ambiguousTwo, key: 'staging_b', type: 'temp' },
]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ BEGIN;
-- module has several, and a scope when a module is registered at several:
--
-- notifications_module the module's api surface
-- permissions_module.admin this api from this module
-- capabilities_module.admin this api from this module
-- limits_module@org the module's org-scope registration
-- permissions_module.admin@org both
-- capabilities_module.admin@org both
-- admin an api by its owner-local name
--
-- Anything ending in _module (before its suffixes) is a module selector; a
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
-- Deploy schemas/function_resolution/procedures/resolve_staging_bucket to pg

-- requires: schemas/function_resolution/schema
-- requires: schemas/function_resolution/procedures/bucket_matches
-- requires: schemas/function_resolution/procedures/staging_bucket_tag

BEGIN;

-- resolve_staging_bucket: answer "which bucket does this database stage an
-- upload through", server-side, by the same rule resolve_default_bucket uses
-- for the destination.
--
-- Staging is not a second mechanism: the reserved staging tag is matched in the
-- caller's own frame chain, filtered to type = 'temp', and exactly one match is
-- the only acceptable answer. Zero raises (a module whose storage bootstrap
-- never labelled a staging bucket cannot silently stage into its permanent
-- bucket), several raise naming the candidates (picking one would stage a
-- tenant's uploads through whichever bucket sorted first).
--
-- The staging bucket's destination is its own destination_bucket_id, enforced on
-- the module's buckets table, so promotion reads the destination from the row
-- rather than being handed one by a client.
CREATE FUNCTION function_resolution.resolve_staging_bucket(
database_id uuid,
scope text,
entity_id uuid
) 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
v_tag := function_resolution.staging_bucket_tag();

SELECT COALESCE(jsonb_agg(to_jsonb(m) ORDER BY m.bucket_id), '[]'::jsonb)
INTO v_matches
FROM function_resolution.bucket_matches(
resolve_staging_bucket.database_id,
resolve_staging_bucket.scope,
resolve_staging_bucket.entity_id,
ARRAY[v_tag],
'temp'
) m;

IF jsonb_array_length(v_matches) = 0 THEN
PERFORM errors.raise_error(
'STORAGE_STAGING_BUCKET_NOT_FOUND',
jsonb_build_object(
'database_id', resolve_staging_bucket.database_id,
'scope', resolve_staging_bucket.scope,
'entity_id', resolve_staging_bucket.entity_id,
'tag', v_tag
),
'internal'
);
END IF;

IF jsonb_array_length(v_matches) > 1 THEN
PERFORM errors.raise_error(
'STORAGE_STAGING_BUCKET_AMBIGUOUS',
jsonb_build_object(
'database_id', resolve_staging_bucket.database_id,
'scope', resolve_staging_bucket.scope,
'entity_id', resolve_staging_bucket.entity_id,
'tag', v_tag,
'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;

resolve_staging_bucket.bucket_id := (v_match->>'bucket_id')::uuid;
resolve_staging_bucket.resolved_key := v_match->>'bucket_key';
resolve_staging_bucket.bucket_type := v_match->>'bucket_type';
resolve_staging_bucket.physical_name := v_match->>'physical_name';
resolve_staging_bucket.owner_database_id := (v_match->>'owner_database_id')::uuid;
resolve_staging_bucket.owner_scope := v_match->>'owner_scope';
resolve_staging_bucket.owner_key := (v_match->>'owner_key')::uuid;

RETURN NEXT;
END;
$$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Deploy schemas/function_resolution/procedures/staging_bucket_tag to pg

-- requires: schemas/function_resolution/schema

BEGIN;

-- staging_bucket_tag: the reserved tag for "the bucket this module stages
-- uploads through before they are promoted", in one place.
--
-- Third label in the same reserved vocabulary default_bucket_tag owns
-- ('default', 'default-public'), for the same reason: staging is a bucket a
-- tenant labelled, resolved by the tag rule capabilities already use, not a
-- boolean on the row. A staging bucket is additionally a 'temp' bucket, so
-- resolution filters on type as well and a mislabelled permanent bucket cannot
-- become a staging destination by tag alone.
CREATE FUNCTION function_resolution.staging_bucket_tag() RETURNS text AS $$
SELECT 'default-temp';
$$ LANGUAGE sql IMMUTABLE;

COMMIT;
2 changes: 2 additions & 0 deletions packages/function-resolution/pgpm.plan
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ schemas/function_resolution/procedures/bucket_matches [schemas/function_resoluti
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 <constructive@5b0c196eeb62> # bucket selector {tags,type} resolution
schemas/function_resolution/procedures/default_bucket_tag [schemas/function_resolution/schema] 2017-08-11T08:11:51Z constructive <constructive@5b0c196eeb62> # 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 <constructive@5b0c196eeb62> # the database's default bucket, or an explicit key
schemas/function_resolution/procedures/staging_bucket_tag [schemas/function_resolution/schema] 2017-08-11T08:11:51Z constructive <constructive@5b0c196eeb62> # 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 <constructive@5b0c196eeb62> # 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 <constructive@5b0c196eeb62> # reachable bucket by id (same-tenant proof)
schemas/function_resolution/procedures/api_catalog_row [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates] 2017-08-11T08:11:51Z constructive <constructive@5b0c196eeb62> # reachable api by id
schemas/function_resolution/procedures/resolve_api [schemas/function_resolution/schema schemas/function_resolution/procedures/frame_candidates schemas/function_resolution/procedures/api_catalog_row] 2017-08-11T08:11:51Z constructive <constructive@5b0c196eeb62> # api selector module:/name: resolution
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Revert schemas/function_resolution/procedures/resolve_staging_bucket from pg

BEGIN;

DROP FUNCTION function_resolution.resolve_staging_bucket(uuid, text, uuid);

COMMIT;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
-- Revert schemas/function_resolution/procedures/staging_bucket_tag from pg

BEGIN;

DROP FUNCTION function_resolution.staging_bucket_tag();

COMMIT;
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,88 @@ BEGIN
END;
$EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

CREATE FUNCTION function_resolution.staging_bucket_tag() RETURNS text AS $EOFCODE$
SELECT 'default-temp';
$EOFCODE$ LANGUAGE sql IMMUTABLE;

CREATE FUNCTION function_resolution.resolve_staging_bucket(
database_id uuid,
scope text,
entity_id uuid
) 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_tag text;
v_matches jsonb;
v_match jsonb;
BEGIN
v_tag := function_resolution.staging_bucket_tag();

SELECT COALESCE(jsonb_agg(to_jsonb(m) ORDER BY m.bucket_id), '[]'::jsonb)
INTO v_matches
FROM function_resolution.bucket_matches(
resolve_staging_bucket.database_id,
resolve_staging_bucket.scope,
resolve_staging_bucket.entity_id,
ARRAY[v_tag],
'temp'
) m;

IF jsonb_array_length(v_matches) = 0 THEN
PERFORM errors.raise_error(
'STORAGE_STAGING_BUCKET_NOT_FOUND',
jsonb_build_object(
'database_id', resolve_staging_bucket.database_id,
'scope', resolve_staging_bucket.scope,
'entity_id', resolve_staging_bucket.entity_id,
'tag', v_tag
),
'internal'
);
END IF;

IF jsonb_array_length(v_matches) > 1 THEN
PERFORM errors.raise_error(
'STORAGE_STAGING_BUCKET_AMBIGUOUS',
jsonb_build_object(
'database_id', resolve_staging_bucket.database_id,
'scope', resolve_staging_bucket.scope,
'entity_id', resolve_staging_bucket.entity_id,
'tag', v_tag,
'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;

resolve_staging_bucket.bucket_id := (v_match->>'bucket_id')::uuid;
resolve_staging_bucket.resolved_key := v_match->>'bucket_key';
resolve_staging_bucket.bucket_type := v_match->>'bucket_type';
resolve_staging_bucket.physical_name := v_match->>'physical_name';
resolve_staging_bucket.owner_database_id := (v_match->>'owner_database_id')::uuid;
resolve_staging_bucket.owner_scope := v_match->>'owner_scope';
resolve_staging_bucket.owner_key := (v_match->>'owner_key')::uuid;

RETURN NEXT;
END;
$EOFCODE$ LANGUAGE plpgsql STABLE SECURITY DEFINER;

CREATE FUNCTION function_resolution.bucket_catalog_row(
database_id uuid,
scope text,
Expand Down
Loading
Loading