feat(scripts): polymorphic script owner (app XOR group) — Phase 4 foundation
Phase 4-lite C1. Make script ownership polymorphic so a script can be owned
by a GROUP (a template inherited by descendant apps) instead of an app —
mirroring vars/secrets (0048/0049), but ON DELETE RESTRICT (code is not data).
Schema (0050): `scripts.group_id` (nullable FK→groups RESTRICT), `app_id` made
nullable, `scripts_owner_exactly_one` CHECK, and the per-app name index split
into two per-owner partial-unique indexes. Existing app-owned rows keep their
exact `(app_id, lower(name))` uniqueness.
Type: `Script.app_id` becomes `Option<AppId>`; add `Script.group_id` +
`ScriptOwner` / `is_owned_by_app()`. `NewScript` gains the same polymorphic
owner. The execution-context app (what a script runs *under*) is supplied by
the invoking route/trigger/caller, never read off the script — group scripts
have no single app.
Behavior is fully preserved for app-owned scripts (the only kind creatable
today): every isolation backstop and authz site now uses `is_owned_by_app`,
which is byte-identical for app owners and **fails closed** for group owners.
A group script therefore can't yet be run, route/trigger-bound, invoked, or
managed via the app-script API — those land in C2 (group-script creation) and
C3 (chain-membership resolution + binding). The `/execute/{id}` bypass 404s a
group script (no app context to run under).
Re-blesses expected_schema.txt; note the golden was last blessed at migration
0044, so this also captures the already-committed 0045–0049 schema.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -132,6 +132,8 @@ table: apps
|
||||
description: text NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
group_id: uuid NOT NULL
|
||||
environment: text NULL
|
||||
|
||||
table: cron_trigger_details
|
||||
trigger_id: uuid NOT NULL
|
||||
@@ -212,6 +214,23 @@ table: files_trigger_details
|
||||
collection_glob: text NOT NULL
|
||||
ops: ARRAY NOT NULL
|
||||
|
||||
table: group_members
|
||||
group_id: uuid NOT NULL
|
||||
user_id: uuid NOT NULL
|
||||
role: text NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: groups
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
parent_id: uuid NULL
|
||||
slug: text NOT NULL
|
||||
name: text NOT NULL
|
||||
description: text NULL
|
||||
structure_version: bigint NOT NULL default=1
|
||||
owner_project: uuid NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
table: kv_entries
|
||||
app_id: uuid NOT NULL
|
||||
collection: text NOT NULL
|
||||
@@ -277,6 +296,7 @@ table: routes
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
app_id: uuid NOT NULL
|
||||
dispatch_mode: text NOT NULL default='sync'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
|
||||
table: script_imports
|
||||
app_id: uuid NOT NULL
|
||||
@@ -295,17 +315,21 @@ table: scripts
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
sandbox: jsonb NOT NULL default='{}'::jsonb
|
||||
app_id: uuid NOT NULL
|
||||
app_id: uuid NULL
|
||||
kind: text NOT NULL default='endpoint'::text
|
||||
enabled: boolean NOT NULL default=true
|
||||
group_id: uuid NULL
|
||||
|
||||
table: secrets
|
||||
app_id: uuid NOT NULL
|
||||
app_id: uuid NULL
|
||||
name: text NOT NULL
|
||||
encrypted_value: bytea NOT NULL
|
||||
nonce: bytea NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
version: smallint NOT NULL default=0
|
||||
group_id: uuid NULL
|
||||
environment_scope: text NOT NULL default='*'::text
|
||||
|
||||
table: topics
|
||||
app_id: uuid NOT NULL
|
||||
@@ -328,6 +352,18 @@ table: triggers
|
||||
registered_by_principal: uuid NOT NULL
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
name: text NOT NULL default=(gen_random_uuid())::text
|
||||
|
||||
table: vars
|
||||
id: uuid NOT NULL default=gen_random_uuid()
|
||||
group_id: uuid NULL
|
||||
app_id: uuid NULL
|
||||
environment_scope: text NOT NULL default='*'::text
|
||||
key: text NOT NULL
|
||||
value: jsonb NOT NULL
|
||||
is_tombstone: boolean NOT NULL default=false
|
||||
created_at: timestamp with time zone NOT NULL default=now()
|
||||
updated_at: timestamp with time zone NOT NULL default=now()
|
||||
|
||||
## indexes
|
||||
|
||||
@@ -395,6 +431,7 @@ indexes on app_users:
|
||||
idx_app_users_app_email_lower: public.app_users USING btree (app_id, lower(email))
|
||||
|
||||
indexes on apps:
|
||||
apps_group_id_idx: public.apps USING btree (group_id)
|
||||
apps_pkey: public.apps USING btree (id)
|
||||
apps_slug_key: public.apps USING btree (slug)
|
||||
|
||||
@@ -433,6 +470,15 @@ indexes on files:
|
||||
indexes on files_trigger_details:
|
||||
files_trigger_details_pkey: public.files_trigger_details USING btree (trigger_id)
|
||||
|
||||
indexes on group_members:
|
||||
group_members_pkey: public.group_members USING btree (group_id, user_id)
|
||||
group_members_user_id_idx: public.group_members USING btree (user_id)
|
||||
|
||||
indexes on groups:
|
||||
groups_parent_id_idx: public.groups USING btree (parent_id)
|
||||
groups_pkey: public.groups USING btree (id)
|
||||
groups_slug_key: public.groups USING btree (slug)
|
||||
|
||||
indexes on kv_entries:
|
||||
idx_kv_entries_app_collection: public.kv_entries USING btree (app_id, collection)
|
||||
kv_entries_pkey: public.kv_entries USING btree (app_id, collection, key)
|
||||
@@ -473,12 +519,16 @@ indexes on script_imports:
|
||||
indexes on scripts:
|
||||
idx_scripts_app_kind: public.scripts USING btree (app_id, kind)
|
||||
scripts_app_id_idx: public.scripts USING btree (app_id)
|
||||
scripts_name_uidx: public.scripts USING btree (app_id, lower(name))
|
||||
scripts_app_name_uidx: public.scripts USING btree (app_id, lower(name)) WHERE (app_id IS NOT NULL)
|
||||
scripts_group_id_idx: public.scripts USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
scripts_group_name_uidx: public.scripts USING btree (group_id, lower(name)) WHERE (group_id IS NOT NULL)
|
||||
scripts_pkey: public.scripts USING btree (id)
|
||||
|
||||
indexes on secrets:
|
||||
idx_secrets_app: public.secrets USING btree (app_id)
|
||||
secrets_pkey: public.secrets USING btree (app_id, name)
|
||||
idx_secrets_group: public.secrets USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
secrets_app_uidx: public.secrets USING btree (app_id, environment_scope, name) WHERE (app_id IS NOT NULL)
|
||||
secrets_group_uidx: public.secrets USING btree (group_id, environment_scope, name) WHERE (group_id IS NOT NULL)
|
||||
|
||||
indexes on topics:
|
||||
topics_pkey: public.topics USING btree (app_id, name)
|
||||
@@ -487,8 +537,16 @@ indexes on triggers:
|
||||
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
|
||||
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
|
||||
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
|
||||
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
|
||||
triggers_pkey: public.triggers USING btree (id)
|
||||
|
||||
indexes on vars:
|
||||
vars_app_id_idx: public.vars USING btree (app_id) WHERE (app_id IS NOT NULL)
|
||||
vars_app_uidx: public.vars USING btree (app_id, environment_scope, key) WHERE (app_id IS NOT NULL)
|
||||
vars_group_id_idx: public.vars USING btree (group_id) WHERE (group_id IS NOT NULL)
|
||||
vars_group_uidx: public.vars USING btree (group_id, environment_scope, key) WHERE (group_id IS NOT NULL)
|
||||
vars_pkey: public.vars USING btree (id)
|
||||
|
||||
## constraints
|
||||
|
||||
constraints on abandoned_executions:
|
||||
@@ -561,6 +619,7 @@ constraints on app_users:
|
||||
[PRIMARY KEY] app_users_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on apps:
|
||||
[FOREIGN KEY] apps_group_id_fk: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] apps_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] apps_slug_key: UNIQUE (slug)
|
||||
|
||||
@@ -605,6 +664,17 @@ constraints on files_trigger_details:
|
||||
[FOREIGN KEY] files_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] files_trigger_details_pkey: PRIMARY KEY (trigger_id)
|
||||
|
||||
constraints on group_members:
|
||||
[CHECK] group_members_role_check: CHECK ((role = ANY (ARRAY['app_admin'::text, 'editor'::text, 'viewer'::text])))
|
||||
[FOREIGN KEY] group_members_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] group_members_user_id_fkey: FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
|
||||
|
||||
constraints on groups:
|
||||
[FOREIGN KEY] groups_parent_id_fkey: FOREIGN KEY (parent_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] groups_pkey: PRIMARY KEY (id)
|
||||
[UNIQUE] groups_slug_key: UNIQUE (slug)
|
||||
|
||||
constraints on kv_entries:
|
||||
[FOREIGN KEY] kv_entries_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] kv_entries_pkey: PRIMARY KEY (app_id, collection, key)
|
||||
@@ -648,13 +718,16 @@ constraints on scripts:
|
||||
[CHECK] scripts_kind_check: CHECK ((kind = ANY (ARRAY['endpoint'::text, 'module'::text])))
|
||||
[CHECK] scripts_memory_limit_mb_check: CHECK (((memory_limit_mb > 0) AND (memory_limit_mb <= 2048)))
|
||||
[CHECK] scripts_module_name_shape: CHECK (((kind <> 'module'::text) OR (name ~ '^[a-zA-Z_][a-zA-Z0-9_]{0,63}$'::text)))
|
||||
[CHECK] scripts_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[CHECK] scripts_timeout_seconds_check: CHECK (((timeout_seconds > 0) AND (timeout_seconds <= 300)))
|
||||
[FOREIGN KEY] scripts_app_id_fk: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE RESTRICT
|
||||
[FOREIGN KEY] scripts_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE RESTRICT
|
||||
[PRIMARY KEY] scripts_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on secrets:
|
||||
[CHECK] secrets_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] secrets_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] secrets_pkey: PRIMARY KEY (app_id, name)
|
||||
[FOREIGN KEY] secrets_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
|
||||
constraints on topics:
|
||||
[CHECK] topics_auth_mode_check: CHECK ((auth_mode = ANY (ARRAY['public'::text, 'token'::text, 'session'::text])))
|
||||
@@ -670,6 +743,12 @@ constraints on triggers:
|
||||
[FOREIGN KEY] triggers_script_id_fkey: FOREIGN KEY (script_id) REFERENCES scripts(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] triggers_pkey: PRIMARY KEY (id)
|
||||
|
||||
constraints on vars:
|
||||
[CHECK] vars_owner_exactly_one: CHECK (((group_id IS NULL) <> (app_id IS NULL)))
|
||||
[FOREIGN KEY] vars_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
|
||||
[FOREIGN KEY] vars_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
|
||||
[PRIMARY KEY] vars_pkey: PRIMARY KEY (id)
|
||||
|
||||
## applied migrations
|
||||
0001: init
|
||||
0002: sandbox
|
||||
@@ -715,3 +794,9 @@ constraints on triggers:
|
||||
0042: secrets envelope version
|
||||
0043: execution logs source
|
||||
0044: delete reserved path routes
|
||||
0045: scripts routes enabled
|
||||
0046: trigger name
|
||||
0047: groups
|
||||
0048: vars
|
||||
0049: group secrets
|
||||
0050: group scripts
|
||||
|
||||
Reference in New Issue
Block a user