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.
- package/SECURITY.md +11 -9
- package/db/migrations/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/docs/apex-feature-parity.md +23 -21
- package/docs/guide/03-builder.md +85 -8
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +13 -3
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +20 -0
- package/src/appfiles.ts +1 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +25 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/blueprints.ts +7 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/components.ts +15 -2
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +117 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/documents.ts +2 -1
- package/src/builder/forms.ts +6 -4
- package/src/builder/home.ts +6 -2
- package/src/builder/login-page.ts +82 -0
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +18 -6
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +27 -16
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +18 -10
- package/src/builder/ui.ts +2 -2
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +175 -15
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +510 -0
- package/src/debug.ts +3 -2
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +39 -33
- package/src/runtime/signin.ts +80 -0
- package/src/sampledata.ts +3 -3
- package/src/session.ts +5 -2
- package/src/static-version.ts +34 -0
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- 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();
|