pgkiln 0.35.0 → 0.37.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/SECURITY.md +11 -9
  2. package/db/migrations/087_login_page.sql +175 -0
  3. package/db/migrations/088_region_templates.sql +18 -0
  4. package/db/migrations/089_page_groups.sql +330 -0
  5. package/db/migrations/090_workspace_isolation.sql +512 -0
  6. package/docs/apex-feature-parity.md +23 -21
  7. package/docs/guide/03-builder.md +85 -8
  8. package/docs/guide/04-pages-and-regions.md +42 -3
  9. package/docs/guide/08-security.md +46 -5
  10. package/docs/guide/11-from-apex.md +4 -1
  11. package/docs/guide/12-development.md +13 -3
  12. package/docs/guide/17-mobile.md +3 -1
  13. package/docs/guide/18-cli.md +2 -0
  14. package/docs/guide/20-ai-agents.md +2 -0
  15. package/examples/hr/hr_40_sample_data.sql +1 -1
  16. package/examples/hr/hr_54_region_templates.sql +67 -0
  17. package/examples/hr/hr_55_page_groups.sql +24 -0
  18. package/package.json +1 -1
  19. package/public/app.css +60 -3
  20. package/public/app.js +89 -3
  21. package/public/builder.css +11 -0
  22. package/public/builder.js +50 -0
  23. package/public/code-editor.css +3 -3
  24. package/public/sw.js +5 -3
  25. package/src/app.ts +20 -0
  26. package/src/appfiles.ts +1 -0
  27. package/src/automations.ts +3 -2
  28. package/src/builder/advisor.ts +25 -5
  29. package/src/builder/ai-builder.ts +27 -18
  30. package/src/builder/blueprints.ts +7 -7
  31. package/src/builder/code-editor.ts +3 -2
  32. package/src/builder/components.ts +15 -2
  33. package/src/builder/dataload.ts +24 -14
  34. package/src/builder/ddl-tools.ts +117 -0
  35. package/src/builder/designer.ts +29 -3
  36. package/src/builder/documents.ts +2 -1
  37. package/src/builder/forms.ts +6 -4
  38. package/src/builder/home.ts +6 -2
  39. package/src/builder/login-page.ts +82 -0
  40. package/src/builder/newapp.ts +21 -3
  41. package/src/builder/quicksql.ts +3 -2
  42. package/src/builder/region-help.ts +4 -2
  43. package/src/builder/region-settings.ts +19 -3
  44. package/src/builder/report-settings.ts +4 -2
  45. package/src/builder/routes.ts +18 -6
  46. package/src/builder/sampledata.ts +22 -18
  47. package/src/builder/scripts.ts +27 -16
  48. package/src/builder/shared.ts +1 -1
  49. package/src/builder/sql.ts +18 -10
  50. package/src/builder/ui.ts +2 -2
  51. package/src/builder/unload.ts +3 -2
  52. package/src/builder/workspaces.ts +175 -15
  53. package/src/cli/main.ts +45 -0
  54. package/src/cli/mcp.ts +42 -0
  55. package/src/cli/replace.ts +4 -0
  56. package/src/db.ts +36 -6
  57. package/src/ddl.ts +510 -0
  58. package/src/debug.ts +3 -2
  59. package/src/i18n/ar.ts +17 -0
  60. package/src/i18n/cs.ts +17 -0
  61. package/src/i18n/da.ts +17 -0
  62. package/src/i18n/de.ts +17 -0
  63. package/src/i18n/el.ts +17 -0
  64. package/src/i18n/es.ts +17 -0
  65. package/src/i18n/fi.ts +17 -0
  66. package/src/i18n/fr.ts +17 -0
  67. package/src/i18n/he.ts +17 -0
  68. package/src/i18n/it.ts +17 -0
  69. package/src/i18n/ja.ts +17 -0
  70. package/src/i18n/ko.ts +17 -0
  71. package/src/i18n/nb.ts +17 -0
  72. package/src/i18n/pl.ts +17 -0
  73. package/src/i18n/pt.ts +17 -0
  74. package/src/i18n/ru.ts +17 -0
  75. package/src/i18n/sv.ts +17 -0
  76. package/src/i18n/tr.ts +17 -0
  77. package/src/i18n/uk.ts +17 -0
  78. package/src/i18n/zh.ts +17 -0
  79. package/src/i18n.ts +34 -0
  80. package/src/icons.ts +2 -1
  81. package/src/layout.ts +3 -2
  82. package/src/metadata.ts +10 -4
  83. package/src/migrate.ts +3 -0
  84. package/src/process-jobs.ts +7 -2
  85. package/src/restsync.ts +3 -2
  86. package/src/runtime/devbar.ts +3 -1
  87. package/src/runtime/display-selector.ts +12 -4
  88. package/src/runtime/grid.ts +170 -26
  89. package/src/runtime/lists.ts +11 -1
  90. package/src/runtime/password-reset.ts +171 -0
  91. package/src/runtime/pwa.ts +7 -10
  92. package/src/runtime/regions.ts +33 -0
  93. package/src/runtime/render.ts +15 -0
  94. package/src/runtime/report-views.ts +4 -2
  95. package/src/runtime/report.ts +36 -22
  96. package/src/runtime/routes.ts +39 -33
  97. package/src/runtime/signin.ts +80 -0
  98. package/src/sampledata.ts +3 -3
  99. package/src/session.ts +5 -2
  100. package/src/static-version.ts +34 -0
  101. package/src/websources.ts +3 -1
  102. package/src/workflow.ts +65 -9
  103. package/src/workspace-db.ts +166 -0
@@ -0,0 +1,512 @@
1
+ -- =====================================================================
2
+ -- 090: isolated workspaces (APEX: workspaces as a tenant boundary)
3
+ --
4
+ -- An administrator can make a workspace *isolated*. Its applications then
5
+ -- run on two PostgreSQL login roles of its own instead of the shared ones:
6
+ --
7
+ -- run_role application code (pages, processes, REST handlers, jobs): a
8
+ -- member of pgkiln_app_code (what application code may use in
9
+ -- meta, the same grants as pgkiln_runtime) and of the database
10
+ -- roles of the workspace's applications;
11
+ -- dev_role the SQL Workshop and every other tool that runs a developer's
12
+ -- SQL in the builder: a member of the applications' database
13
+ -- roles only, no rights in meta.
14
+ --
15
+ -- SQL can always RESET ROLE (or set_config('role', ...)) back to the role it
16
+ -- logged in with, so the boundary is the login role: session_user, which SQL
17
+ -- cannot change. Row-level security on the meta tables the runtime reads
18
+ -- shows a workspace's logins the rows of their own applications only, and
19
+ -- hides isolated workspaces from the shared runtime role (pgkiln_runtime);
20
+ -- the owner (the builder) sees everything. meta.app_id() refuses an
21
+ -- application the login may not see, which closes the "app id from SQL" gap
22
+ -- for the functions that trust it (web requests, AI, mail, push, page links).
23
+ --
24
+ -- The server sets the login roles' passwords (derived from PGKILN_SECRET_KEY)
25
+ -- and opens a small pool per isolated workspace (src/workspace-db.ts).
26
+ -- Role memberships follow the workspace's applications automatically
27
+ -- (triggers below). Installation data, not exported.
28
+ -- =====================================================================
29
+
30
+ alter table meta.workspace
31
+ add column isolated boolean not null default false,
32
+ add column run_role text unique,
33
+ add column dev_role text unique;
34
+ comment on column meta.workspace.isolated is 'Isolated (090): the workspace''s applications and SQL run on its own login roles (run_role, dev_role).';
35
+
36
+ -- workspace administrators (APEX: workspace administrators): developers who manage their workspace's
37
+ -- name and developers; isolation, its database roles, moving applications and deleting stay with
38
+ -- the installation's administrators
39
+ alter table meta.workspace_member add column is_admin boolean not null default false;
40
+ comment on column meta.workspace_member.is_admin is 'A workspace administrator (090): manages the workspace''s name and developers.';
41
+
42
+ -- the SQL Workshop's saved work belongs to a workspace (Default for what exists): an isolated
43
+ -- workspace's developers see their own scripts, runs, blueprints and data generators only
44
+ alter table meta.sql_script add column workspace_id int not null default 1 references meta.workspace on delete cascade;
45
+ alter table meta.sql_script drop constraint sql_script_name_key;
46
+ create unique index sql_script_name_key on meta.sql_script (workspace_id, name);
47
+ alter table meta.sql_script_run add column workspace_id int not null default 1 references meta.workspace on delete cascade;
48
+ create index sql_script_run_workspace_idx on meta.sql_script_run (workspace_id, id);
49
+ alter table meta.blueprint add column workspace_id int not null default 1 references meta.workspace on delete cascade;
50
+ alter table meta.data_generator add column workspace_id int not null default 1 references meta.workspace on delete cascade;
51
+ alter table meta.data_generator drop constraint data_generator_name_key;
52
+ create unique index data_generator_name_key on meta.data_generator (workspace_id, name);
53
+
54
+ -- ---------------------------------------------------------------------
55
+ -- pgkiln_app_code: the rights of application code in meta. pgkiln_runtime
56
+ -- keeps its own grants; meta.sync_app_code_grants() copies them here (also
57
+ -- after every later migration, from src/migrate.ts), so a new grant to
58
+ -- pgkiln_runtime reaches isolated workspaces too.
59
+ -- ---------------------------------------------------------------------
60
+ do $$
61
+ begin
62
+ if not exists (select 1 from pg_roles where rolname = 'pgkiln_app_code') then
63
+ create role pgkiln_app_code nologin;
64
+ end if;
65
+ end $$;
66
+
67
+ create function meta.sync_app_code_grants() returns void
68
+ language plpgsql set search_path = meta, pg_catalog as $$
69
+ declare
70
+ rt oid := 'pgkiln_runtime'::regrole;
71
+ ac oid := 'pgkiln_app_code'::regrole;
72
+ r record;
73
+ begin
74
+ -- schemas
75
+ for r in
76
+ select n.nspname, x.privilege_type, x.grantee from pg_namespace n, aclexplode(n.nspacl) x
77
+ where x.grantee in (rt, ac) and n.nspname not in ('pg_catalog', 'information_schema')
78
+ loop
79
+ if r.grantee = rt and not has_schema_privilege(ac, r.nspname, r.privilege_type) then
80
+ execute format('grant %s on schema %I to pgkiln_app_code', r.privilege_type, r.nspname);
81
+ elsif r.grantee = ac and not exists (select 1 from pg_namespace n, aclexplode(n.nspacl) y
82
+ where n.nspname = r.nspname and y.grantee = rt and y.privilege_type = r.privilege_type) then
83
+ execute format('revoke %s on schema %I from pgkiln_app_code', r.privilege_type, r.nspname);
84
+ end if;
85
+ end loop;
86
+ -- tables, views, sequences
87
+ for r in
88
+ select c.oid::regclass as rel, c.relkind, x.privilege_type, x.grantee from pg_class c, aclexplode(c.relacl) x
89
+ where x.grantee in (rt, ac) and c.relnamespace not in ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
90
+ loop
91
+ if r.grantee = rt and not exists (select 1 from pg_class c, aclexplode(c.relacl) y where c.oid = r.rel and y.grantee = ac and y.privilege_type = r.privilege_type) then
92
+ execute format('grant %s on %s %s to pgkiln_app_code', r.privilege_type, case when r.relkind = 'S' then 'sequence' else 'table' end, r.rel);
93
+ elsif r.grantee = ac and not exists (select 1 from pg_class c, aclexplode(c.relacl) y where c.oid = r.rel and y.grantee = rt and y.privilege_type = r.privilege_type) then
94
+ execute format('revoke %s on %s %s from pgkiln_app_code', r.privilege_type, case when r.relkind = 'S' then 'sequence' else 'table' end, r.rel);
95
+ end if;
96
+ end loop;
97
+ -- columns
98
+ for r in
99
+ select a.attrelid::regclass as rel, a.attname, x.privilege_type, x.grantee from pg_attribute a, aclexplode(a.attacl) x
100
+ where x.grantee in (rt, ac) and not a.attisdropped
101
+ loop
102
+ if r.grantee = rt and not exists (select 1 from pg_attribute a, aclexplode(a.attacl) y where a.attrelid = r.rel and a.attname = r.attname and y.grantee = ac and y.privilege_type = r.privilege_type) then
103
+ execute format('grant %s (%I) on table %s to pgkiln_app_code', r.privilege_type, r.attname, r.rel);
104
+ elsif r.grantee = ac and not exists (select 1 from pg_attribute a, aclexplode(a.attacl) y where a.attrelid = r.rel and a.attname = r.attname and y.grantee = rt and y.privilege_type = r.privilege_type) then
105
+ execute format('revoke %s (%I) on table %s from pgkiln_app_code', r.privilege_type, r.attname, r.rel);
106
+ end if;
107
+ end loop;
108
+ -- functions
109
+ for r in
110
+ select p.oid::regprocedure as fn, x.grantee from pg_proc p, aclexplode(p.proacl) x
111
+ where x.grantee in (rt, ac) and x.privilege_type = 'EXECUTE' and p.pronamespace not in ('pg_catalog'::regnamespace, 'information_schema'::regnamespace)
112
+ loop
113
+ if r.grantee = rt and not exists (select 1 from pg_proc p, aclexplode(p.proacl) y where p.oid = r.fn and y.grantee = ac) then
114
+ execute format('grant execute on function %s to pgkiln_app_code', r.fn);
115
+ elsif r.grantee = ac and not exists (select 1 from pg_proc p, aclexplode(p.proacl) y where p.oid = r.fn and y.grantee = rt) then
116
+ execute format('revoke execute on function %s from pgkiln_app_code', r.fn);
117
+ end if;
118
+ end loop;
119
+ end $$;
120
+ revoke all on function meta.sync_app_code_grants() from public;
121
+
122
+ -- ---------------------------------------------------------------------
123
+ -- Who is asking: the workspace of the login role, and the applications it
124
+ -- may see.
125
+ -- ---------------------------------------------------------------------
126
+
127
+ -- the isolated workspace whose login role this session signed in with (null: another login)
128
+ create function meta.login_workspace() returns int
129
+ language sql stable security definer set search_path = meta, pg_catalog as $$
130
+ select id from meta.workspace where isolated and session_user::text in (run_role, dev_role)
131
+ $$;
132
+
133
+ -- the installation's owner (the builder, migrations) or a superuser: sees every application
134
+ create function meta.owner_login() returns boolean
135
+ language sql stable security definer set search_path = meta, pg_catalog as $$
136
+ select coalesce((select r.rolsuper or r.oid = (select nspowner from pg_namespace where nspname = 'meta')
137
+ from pg_roles r where r.rolname = session_user), false)
138
+ $$;
139
+
140
+ -- no isolated workspace: nothing to filter (the common case, checked once per query)
141
+ create function meta.isolation_off() returns boolean
142
+ language sql stable security definer set search_path = meta, pg_catalog as $$
143
+ select not exists (select 1 from meta.workspace where isolated)
144
+ $$;
145
+
146
+ -- the applications this login may see: its own workspace's (an isolated workspace's
147
+ -- login), every application (the owner), or those outside isolated workspaces (the rest)
148
+ create function meta.visible_apps() returns setof int
149
+ language sql stable security definer set search_path = meta, pg_catalog as $$
150
+ with me as (select meta.login_workspace() as ws, meta.owner_login() as owner)
151
+ select a.id from meta.app a, me
152
+ where case
153
+ when me.ws is not null then meta.app_workspace(a.id) = me.ws
154
+ when me.owner then true
155
+ else not exists (select 1 from meta.workspace w where w.id = meta.app_workspace(a.id) and w.isolated)
156
+ end
157
+ $$;
158
+
159
+ create function meta.app_visible(p_app_id int) returns boolean
160
+ language sql stable security definer set search_path = meta, pg_catalog as $$
161
+ select meta.isolation_off() or p_app_id in (select meta.visible_apps())
162
+ $$;
163
+
164
+ -- p_app_id when this login may see it; otherwise an error (for functions that take an application id)
165
+ create function meta.checked_app(p_app_id int) returns int
166
+ language plpgsql stable security definer set search_path = meta, pg_catalog as $$
167
+ begin
168
+ if p_app_id is not null and not meta.app_visible(p_app_id) then
169
+ raise exception 'Application % is not in this workspace.', p_app_id using errcode = '42501';
170
+ end if;
171
+ return p_app_id;
172
+ end $$;
173
+
174
+ -- accounts: those with access to a visible application, or with no access to one it may not see
175
+ create function meta.visible_accounts() returns setof int
176
+ language sql stable security definer set search_path = meta, pg_catalog as $$
177
+ with vis as (select meta.visible_apps() as app_id)
178
+ select a.id from meta.account a
179
+ where exists (select 1 from meta.app_access x where x.account_id = a.id and x.app_id in (select app_id from vis))
180
+ or not exists (select 1 from meta.app_access x where x.account_id = a.id and x.app_id not in (select app_id from vis))
181
+ $$;
182
+
183
+ revoke all on function meta.login_workspace(), meta.owner_login(), meta.isolation_off(), meta.visible_apps(),
184
+ meta.visible_accounts() from public;
185
+ grant execute on function meta.login_workspace(), meta.isolation_off(), meta.visible_apps(), meta.visible_accounts(),
186
+ meta.owner_login() to pgkiln_runtime;
187
+ -- app_visible and checked_app stay executable by everyone (application code may check an id)
188
+
189
+ -- meta.app_id(): the application of the request, refused when the login may not see it
190
+ -- (application SQL can set pgkiln.app_id to another application's id)
191
+ create or replace function meta.app_id() returns int
192
+ language plpgsql stable security definer set search_path = meta, pg_catalog as $$
193
+ declare
194
+ v int := coalesce(nullif(current_setting('pgkiln.app_id', true), '')::int,
195
+ (select a.id from meta.app a where a.alias = meta.jwt_claims()->>'app'));
196
+ begin
197
+ if v is not null and not meta.isolation_off() and not meta.app_visible(v) then
198
+ raise exception 'Application % is not in this workspace.', v using errcode = '42501';
199
+ end if;
200
+ return v;
201
+ end $$;
202
+
203
+ -- ---------------------------------------------------------------------
204
+ -- Functions the server calls with an application id: the same check.
205
+ -- The originals stay, renamed, for the owner only.
206
+ -- ---------------------------------------------------------------------
207
+ alter function meta.account_roles(int, text) rename to account_roles_unchecked;
208
+ alter function meta.authenticate(int, text, text) rename to authenticate_unchecked;
209
+ alter function meta.change_password(int, text, text, text, uuid) rename to change_password_unchecked;
210
+ alter function meta.debug_save(int, int, text, uuid, text, text, int, int, timestamptz, numeric, jsonb) rename to debug_save_unchecked;
211
+ alter function meta.password_reset_available(int) rename to password_reset_available_unchecked;
212
+ alter function meta.password_reset_user(int, text) rename to password_reset_user_unchecked;
213
+ alter function meta.request_password_reset(int, text, text, text, text, text, text) rename to request_password_reset_unchecked;
214
+ alter function meta.reset_password(int, text, text) rename to reset_password_unchecked;
215
+ revoke all on function meta.account_roles_unchecked(int, text), meta.authenticate_unchecked(int, text, text),
216
+ meta.change_password_unchecked(int, text, text, text, uuid),
217
+ meta.debug_save_unchecked(int, int, text, uuid, text, text, int, int, timestamptz, numeric, jsonb),
218
+ meta.password_reset_available_unchecked(int), meta.password_reset_user_unchecked(int, text),
219
+ meta.request_password_reset_unchecked(int, text, text, text, text, text, text), meta.reset_password_unchecked(int, text, text)
220
+ from public, pgkiln_runtime;
221
+
222
+ create function meta.account_roles(p_app_id int, p_username text) returns text[]
223
+ language sql stable security definer set search_path = meta, pg_catalog as $$
224
+ select meta.account_roles_unchecked(meta.checked_app(p_app_id), p_username)
225
+ $$;
226
+ create function meta.authenticate(p_app_id int, p_username text, p_password text) returns text
227
+ language sql security definer set search_path = meta, pg_catalog as $$
228
+ select meta.authenticate_unchecked(meta.checked_app(p_app_id), p_username, p_password)
229
+ $$;
230
+ create function meta.change_password(p_app_id int, p_username text, p_old text, p_new text, p_keep_session uuid default null) returns boolean
231
+ language sql security definer set search_path = meta, pg_catalog as $$
232
+ select meta.change_password_unchecked(meta.checked_app(p_app_id), p_username, p_old, p_new, p_keep_session)
233
+ $$;
234
+ create function meta.debug_save(p_app_id int, p_page_no int, p_username text, p_session_id uuid, p_method text, p_path text,
235
+ p_status int, p_level int, p_started_at timestamptz, p_elapsed_ms numeric, p_entries jsonb) returns bigint
236
+ language sql security definer set search_path = meta, pg_catalog as $$
237
+ select meta.debug_save_unchecked(meta.checked_app(p_app_id), p_page_no, p_username, p_session_id, p_method, p_path,
238
+ p_status, p_level, p_started_at, p_elapsed_ms, p_entries)
239
+ $$;
240
+ create function meta.password_reset_available(p_app_id int) returns boolean
241
+ language sql stable security definer set search_path = meta, pg_catalog as $$
242
+ select meta.password_reset_available_unchecked(meta.checked_app(p_app_id))
243
+ $$;
244
+ create function meta.password_reset_user(p_app_id int, p_token_hash text) returns text
245
+ language sql stable security definer set search_path = meta, pg_catalog as $$
246
+ select meta.password_reset_user_unchecked(meta.checked_app(p_app_id), p_token_hash)
247
+ $$;
248
+ create function meta.request_password_reset(p_app_id int, p_login text, p_token_hash text, p_ip text, p_subject text,
249
+ p_body text, p_body_html text) returns void
250
+ language sql security definer set search_path = meta, pg_catalog as $$
251
+ select meta.request_password_reset_unchecked(meta.checked_app(p_app_id), p_login, p_token_hash, p_ip, p_subject, p_body, p_body_html)
252
+ $$;
253
+ create function meta.reset_password(p_app_id int, p_token_hash text, p_new text) returns text
254
+ language sql security definer set search_path = meta, pg_catalog as $$
255
+ select meta.reset_password_unchecked(meta.checked_app(p_app_id), p_token_hash, p_new)
256
+ $$;
257
+ revoke all on function meta.account_roles(int, text), meta.authenticate(int, text, text),
258
+ meta.change_password(int, text, text, text, uuid),
259
+ meta.debug_save(int, int, text, uuid, text, text, int, int, timestamptz, numeric, jsonb),
260
+ meta.password_reset_available(int), meta.password_reset_user(int, text),
261
+ meta.request_password_reset(int, text, text, text, text, text, text), meta.reset_password(int, text, text) from public;
262
+ grant execute on function meta.account_roles(int, text), meta.authenticate(int, text, text),
263
+ meta.change_password(int, text, text, text, uuid),
264
+ meta.debug_save(int, int, text, uuid, text, text, int, int, timestamptz, numeric, jsonb),
265
+ meta.password_reset_available(int), meta.password_reset_user(int, text),
266
+ meta.request_password_reset(int, text, text, text, text, text, text), meta.reset_password(int, text, text) to pgkiln_runtime;
267
+
268
+ -- ---------------------------------------------------------------------
269
+ -- Row-level security on the meta tables the runtime role reads or writes.
270
+ -- The owner (table owner) is not affected. Without isolated workspaces the
271
+ -- first condition is true once per query and nothing is filtered.
272
+ -- ---------------------------------------------------------------------
273
+ do $$
274
+ declare
275
+ t text;
276
+ begin
277
+ -- tables with the application's id
278
+ foreach t in array array['account_style', 'app_access', 'app_item', 'app_process', 'authz_scheme', 'build_option',
279
+ 'data_load_def', 'document_template', 'feedback', 'list', 'list_entry', 'lov', 'nav_entry', 'page', 'page_group',
280
+ 'plugin', 'report_layout', 'rest_module', 'rest_source', 'search_config', 'static_file', 'task_definition',
281
+ 'template_component', 'text_message', 'translation', 'workflow_definition', 'web_credential']
282
+ loop
283
+ execute format('alter table meta.%I enable row level security', t);
284
+ execute format($p$create policy workspace on meta.%I using ((select meta.isolation_off()) or app_id in (select meta.visible_apps()))
285
+ with check ((select meta.isolation_off()) or app_id in (select meta.visible_apps()))$p$, t);
286
+ end loop;
287
+ -- tables of a page
288
+ foreach t in array array['branch', 'button', 'computation', 'dynamic_action', 'item', 'process', 'region', 'validation']
289
+ loop
290
+ execute format('alter table meta.%I enable row level security', t);
291
+ execute format($p$create policy workspace on meta.%I using ((select meta.isolation_off())
292
+ or exists (select 1 from meta.page p where p.id = page_id and p.app_id in (select meta.visible_apps())))$p$, t);
293
+ end loop;
294
+ end $$;
295
+
296
+ -- rows without an application (builder sessions, instance activity): not for an isolated workspace's logins
297
+ alter table meta.session enable row level security;
298
+ create policy workspace on meta.session
299
+ using ((select meta.isolation_off()) or app_id in (select meta.visible_apps()) or (app_id is null and meta.login_workspace() is null))
300
+ with check ((select meta.isolation_off()) or app_id in (select meta.visible_apps()) or (app_id is null and meta.login_workspace() is null));
301
+ alter table meta.activity_log enable row level security;
302
+ create policy workspace on meta.activity_log
303
+ using ((select meta.isolation_off()) or app_id in (select meta.visible_apps()) or (app_id is null and meta.login_workspace() is null))
304
+ with check ((select meta.isolation_off()) or app_id in (select meta.visible_apps()) or (app_id is null and meta.login_workspace() is null));
305
+ alter table meta.app enable row level security;
306
+ create policy workspace on meta.app
307
+ using ((select meta.isolation_off()) or id in (select meta.visible_apps()));
308
+ alter table meta.account enable row level security;
309
+ create policy workspace on meta.account
310
+ using ((select meta.isolation_off()) or id in (select meta.visible_accounts()));
311
+
312
+ -- ---------------------------------------------------------------------
313
+ -- The database roles of an isolated workspace (APEX: the workspace's
314
+ -- schemas). Administrators assign them (isolating a workspace assigns the
315
+ -- roles its applications use); a new application made in the workspace adds
316
+ -- its own new role. Its applications may run as these roles only, so an
317
+ -- import or a settings change can't claim another role of the database.
318
+ -- Installation data, not exported.
319
+ -- ---------------------------------------------------------------------
320
+ create table meta.workspace_role (
321
+ workspace_id int not null references meta.workspace on delete cascade,
322
+ role_name text not null unique check (role_name ~ '^[A-Za-z_][A-Za-z0-9_$]{0,62}$'),
323
+ primary key (workspace_id, role_name)
324
+ );
325
+ revoke all on meta.workspace_role from public;
326
+ comment on table meta.workspace_role is 'The database roles of an isolated workspace (090): its applications run as these only. Installation data, not exported.';
327
+
328
+ -- Role memberships follow the workspaces: a role of an isolated workspace belongs to that
329
+ -- workspace's two login roles and not to pgkiln_runtime; it goes back to pgkiln_runtime when
330
+ -- it leaves the workspace (or the workspace is no longer isolated) and a shared application uses it.
331
+ create function meta.workspace_sync_roles() returns void
332
+ language plpgsql set search_path = meta, pg_catalog as $$
333
+ declare
334
+ r record;
335
+ begin
336
+ -- nothing isolated and never was: nothing to do
337
+ if not exists (select 1 from meta.workspace where isolated or run_role is not null) then
338
+ return;
339
+ end if;
340
+ -- the login roles of isolated workspaces
341
+ for r in select id, run_role, dev_role from meta.workspace where isolated loop
342
+ if not exists (select 1 from pg_roles where rolname = r.run_role) then
343
+ execute format('create role %I login', r.run_role);
344
+ end if;
345
+ if not exists (select 1 from pg_roles where rolname = r.dev_role) then
346
+ execute format('create role %I login', r.dev_role);
347
+ end if;
348
+ execute format('alter role %I login inherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls', r.run_role);
349
+ execute format('alter role %I login inherit nosuperuser nocreatedb nocreaterole noreplication nobypassrls', r.dev_role);
350
+ if not pg_has_role(r.run_role, 'pgkiln_app_code', 'MEMBER') then
351
+ execute format('grant pgkiln_app_code to %I', r.run_role);
352
+ end if;
353
+ end loop;
354
+ -- an application of an isolated workspace runs as one of the workspace's roles
355
+ for r in
356
+ select a.alias, a.db_role, w.name from meta.app a join meta.workspace w on w.id = meta.app_workspace(a.id)
357
+ where w.isolated and a.db_role is not null
358
+ and not exists (select 1 from meta.workspace_role x where x.workspace_id = w.id and x.role_name = a.db_role)
359
+ loop
360
+ raise exception 'Application % runs as %, which is not a database role of workspace % (an administrator adds it on the workspace''s page).', r.alias, r.db_role, r.name
361
+ using errcode = '42501';
362
+ end loop;
363
+ -- the workspace's roles: only its logins have them
364
+ for r in
365
+ select x.role_name, w.id as ws, w.run_role, w.dev_role,
366
+ rl.oid is null as missing, coalesce(rl.rolsuper or rl.rolcreaterole or rl.rolbypassrls or rl.rolreplication, false) as powerful
367
+ from meta.workspace_role x join meta.workspace w on w.id = x.workspace_id and w.isolated
368
+ left join pg_roles rl on rl.rolname = x.role_name
369
+ loop
370
+ continue when r.missing;
371
+ if r.powerful or r.role_name like 'pgkiln%' or r.role_name = (select nspowner::regrole::text from pg_namespace where nspname = 'meta') then
372
+ raise exception 'The role % can''t belong to an isolated workspace (a superuser, a role that creates roles or bypasses row-level security, or one of pgkiln''s own).', r.role_name
373
+ using errcode = '42501';
374
+ end if;
375
+ if exists (select 1 from meta.app b where b.db_role = r.role_name and meta.app_workspace(b.id) <> r.ws) then
376
+ raise exception 'The database role % is used by an application of another workspace; an isolated workspace''s roles are its own.', r.role_name
377
+ using errcode = '42501';
378
+ end if;
379
+ if not exists (select 1 from pg_auth_members m where m.roleid = r.role_name::regrole and m.member = r.run_role::regrole) then
380
+ execute format('grant %I to %I', r.role_name, r.run_role);
381
+ end if;
382
+ if not exists (select 1 from pg_auth_members m where m.roleid = r.role_name::regrole and m.member = r.dev_role::regrole) then
383
+ execute format('grant %I to %I', r.role_name, r.dev_role);
384
+ end if;
385
+ if exists (select 1 from pg_auth_members m where m.roleid = r.role_name::regrole and m.member = 'pgkiln_runtime'::regrole) then
386
+ execute format('revoke %I from pgkiln_runtime', r.role_name);
387
+ end if;
388
+ end loop;
389
+ -- the developer login starts as the workspace's first role, so what its SQL creates belongs to an
390
+ -- application role (SET ROLE picks another; RESET ROLE returns to this one)
391
+ for r in
392
+ select w.dev_role, (select x.role_name from meta.workspace_role x join pg_roles rl on rl.rolname = x.role_name
393
+ where x.workspace_id = w.id order by x.role_name limit 1) as first
394
+ from meta.workspace w where w.isolated
395
+ loop
396
+ if r.first is not null then
397
+ execute format('alter role %I set role = %L', r.dev_role, r.first);
398
+ else
399
+ execute format('alter role %I reset role', r.dev_role);
400
+ end if;
401
+ end loop;
402
+ -- memberships a workspace login no longer should have; a role a shared application uses goes back to pgkiln_runtime
403
+ for r in
404
+ select g.rolname as granted, l.rolname as login
405
+ from meta.workspace w
406
+ join pg_roles l on l.rolname in (w.run_role, w.dev_role)
407
+ join pg_auth_members m on m.member = l.oid
408
+ join pg_roles g on g.oid = m.roleid
409
+ where not w.isolated
410
+ or (g.rolname <> 'pgkiln_app_code' and not exists (select 1 from meta.workspace_role x where x.workspace_id = w.id and x.role_name = g.rolname))
411
+ or (g.rolname = 'pgkiln_app_code' and l.rolname = w.dev_role)
412
+ loop
413
+ execute format('revoke %I from %I', r.granted, r.login);
414
+ if r.granted <> 'pgkiln_app_code'
415
+ and exists (select 1 from meta.app a join meta.workspace w on w.id = meta.app_workspace(a.id) where a.db_role = r.granted and not w.isolated)
416
+ and not pg_has_role('pgkiln_runtime', r.granted, 'MEMBER') then
417
+ execute format('grant %I to pgkiln_runtime', r.granted);
418
+ end if;
419
+ end loop;
420
+ -- logins of workspaces that are no longer isolated can't sign in
421
+ for r in select run_role, dev_role from meta.workspace where not isolated and run_role is not null loop
422
+ if exists (select 1 from pg_roles where rolname = r.run_role) then
423
+ execute format('alter role %I nologin', r.run_role);
424
+ end if;
425
+ if exists (select 1 from pg_roles where rolname = r.dev_role) then
426
+ execute format('alter role %I nologin', r.dev_role);
427
+ end if;
428
+ end loop;
429
+ end $$;
430
+ revoke all on function meta.workspace_sync_roles() from public;
431
+
432
+ -- names for the login roles, given when a workspace is first isolated (unique per installation:
433
+ -- roles are shared by every database of the PostgreSQL server)
434
+ create function meta.workspace_isolate() returns trigger
435
+ language plpgsql set search_path = meta, pg_catalog as $$
436
+ declare
437
+ suffix text;
438
+ begin
439
+ if new.isolated and new.run_role is null then
440
+ loop
441
+ suffix := substr(md5(random()::text || clock_timestamp()::text), 1, 8);
442
+ exit when not exists (select 1 from pg_roles where rolname in (format('pgkiln_ws%s_run_%s', new.id, suffix), format('pgkiln_ws%s_dev_%s', new.id, suffix)));
443
+ end loop;
444
+ new.run_role := format('pgkiln_ws%s_run_%s', new.id, suffix);
445
+ new.dev_role := format('pgkiln_ws%s_dev_%s', new.id, suffix);
446
+ end if;
447
+ if new.run_role is distinct from old.run_role and old.run_role is not null then
448
+ raise exception 'The login roles of a workspace can''t be renamed.';
449
+ end if;
450
+ return new;
451
+ end $$;
452
+ create trigger workspace_isolate before insert or update of isolated, run_role, dev_role on meta.workspace
453
+ for each row execute function meta.workspace_isolate();
454
+
455
+ -- isolating a workspace (an administrator) assigns it the roles its applications use
456
+ create function meta.workspace_take_roles() returns trigger
457
+ language plpgsql set search_path = meta, pg_catalog as $$
458
+ begin
459
+ if new.isolated and not coalesce(old.isolated, false) then
460
+ insert into meta.workspace_role (workspace_id, role_name)
461
+ select distinct new.id, a.db_role from meta.app a
462
+ where meta.app_workspace(a.id) = new.id and a.db_role is not null
463
+ on conflict (workspace_id, role_name) do nothing;
464
+ end if;
465
+ return null;
466
+ end $$;
467
+ create trigger workspace_take_roles after insert or update of isolated on meta.workspace
468
+ for each row execute function meta.workspace_take_roles();
469
+
470
+ -- checked at the commit, on the transaction's final state (an import or a working copy inserts an
471
+ -- application before it places it in its workspace); once per transaction
472
+ create function meta.workspace_sync_roles_once() returns trigger
473
+ language plpgsql set search_path = meta, pg_catalog as $$
474
+ begin
475
+ if current_setting('pgkiln.workspace_synced', true) is distinct from txid_current()::text then
476
+ perform set_config('pgkiln.workspace_synced', txid_current()::text, true);
477
+ perform meta.workspace_sync_roles();
478
+ end if;
479
+ return null;
480
+ end $$;
481
+ create constraint trigger workspace_sync_roles after insert or update of isolated on meta.workspace
482
+ deferrable initially deferred for each row execute function meta.workspace_sync_roles_once();
483
+ create constraint trigger workspace_sync_roles after insert or update or delete on meta.workspace_app
484
+ deferrable initially deferred for each row execute function meta.workspace_sync_roles_once();
485
+ create constraint trigger workspace_sync_roles after insert or delete or update of db_role on meta.app
486
+ deferrable initially deferred for each row execute function meta.workspace_sync_roles_once();
487
+ create constraint trigger workspace_sync_roles after insert or update or delete on meta.workspace_role
488
+ deferrable initially deferred for each row execute function meta.workspace_sync_roles_once();
489
+
490
+ -- deleting a workspace: its login roles go too (they own nothing; if they do, they stay, unable to sign in)
491
+ create function meta.workspace_drop_roles() returns trigger
492
+ language plpgsql set search_path = meta, pg_catalog as $$
493
+ declare
494
+ r text;
495
+ begin
496
+ foreach r in array array[old.run_role, old.dev_role] loop
497
+ continue when r is null or not exists (select 1 from pg_roles where rolname = r);
498
+ begin
499
+ execute format('drop role %I', r);
500
+ exception when others then
501
+ execute format('alter role %I nologin', r);
502
+ raise notice 'The login role % owns objects or has privileges; it stays, unable to sign in.', r;
503
+ end;
504
+ end loop;
505
+ return null;
506
+ end $$;
507
+ create trigger workspace_drop_roles after delete on meta.workspace
508
+ for each row execute function meta.workspace_drop_roles();
509
+
510
+ revoke all on function meta.workspace_isolate(), meta.workspace_take_roles(), meta.workspace_sync_roles_once(), meta.workspace_drop_roles() from public;
511
+
512
+ select meta.sync_app_code_grants();