pgkiln 0.36.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 CHANGED
@@ -67,7 +67,8 @@ first version (MVP). Every finding below has a regression test in
67
67
  | AI services (0.28.0) | Administrators only configure services (anonymous → sign-in, other developers 403, CSRF); API keys are encrypted with `PGKILN_SECRET_KEY`, write-only, never exported, logged or shown; all AI tables are revoked from public and the runtime role. Model, base URL and limits are checked; models are never changed silently. Item values go into prompts as escaped `<data>` text marked as data, never instructions; session ids and password items are never sent. Answers only become item values and are escaped when shown. Usage is logged without prompt or answer text (except at debug level 9). Data in prompts leaves the instance for the chosen provider. AI calls don't use `PGKILN_REST_ALLOWED_HOSTS`: only administrators set the base URL. |
68
68
  | AI assistant, NL2IR (0.28.0) | CSRF and region visibility are checked before any AI call; assistants are for signed-in users unless marked public. Conversations belong to one session and region, are deleted with the session and are closed to the runtime and application roles. Context queries and tools run as the app's role (grants, RLS): arguments are bound literals checked against the declared parameters (unknown arguments refused), each tool is one subquery with a row limit and a 15 s timeout and is rolled back unless the developer marks it as writing; tools can be limited by an authorization scheme; REST tool arguments are never `&ITEM.`-substituted. Natural-language report filters only produce filters a user could set by hand, on visible columns. Answers are escaped (paragraphs, lists, bold, code only). A writing tool lets the model change data on the user's behalf within the role's rights: design those with care. |
69
69
  | App Builder AI and blueprints (0.28.0) | Developer session and CSRF; only administrators choose the builder's AI service. Generated SQL is shown, never run automatically; page proposals are checked (page types, tables the app role can read, free page numbers) and created only after the developer confirms. The model sees names, types and keys, never rows. A blueprint is validated (identifiers, references, rows) and turned into quoted SQL; Create only accepts the exact text the review showed, signed with the developer's session, in one transaction. `meta.builder_ai`, `meta.ai_table_note` and `meta.blueprint` are closed to the runtime role. |
70
- | Workspaces (0.29.0) | Every builder request under `/apps/:id` or `/pages/:pid` (GET and POST), a working copy's main application, a boilerplate and the code editor's application are checked against the developer's workspaces in `developer()` and answer 404 outside them; lists are filtered by the current workspace. Switching needs CSRF and membership; managing workspaces needs an administrator and CSRF; names are escaped; subscriptions only reach theme and library applications of the same workspace. Workspace tables are closed to the runtime role. **Not a tenant boundary:** the SQL Workshop runs as the owner and application code on the runtime connection, so developers who write SQL can reach other workspaces; accounts, identity providers, LDAP directories and AI services are installation-wide. |
70
+ | Workspaces (0.29.0) | Every builder request under `/apps/:id` or `/pages/:pid` (GET and POST), a working copy's main application, a boilerplate and the code editor's application are checked against the developer's workspaces in `developer()` and answer 404 outside them; lists are filtered by the current workspace. Switching needs CSRF and membership; managing workspaces needs an administrator and CSRF; names are escaped; subscriptions only reach theme and library applications of the same workspace. Workspace tables are closed to the runtime role. **A shared workspace is not a tenant boundary:** its SQL Workshop runs as the owner and its application code on the shared runtime connection; isolate a workspace for that (below). |
71
+ | Isolated workspaces (sprint 60) | The boundary is the PostgreSQL login (`session_user`, which SQL can't change): an isolated workspace's applications run on its own run login and its developers' SQL on its own developer login (`src/workspace-db.ts`; passwords derived from `PGKILN_SECRET_KEY`, never stored), so `RESET ROLE` or `set_config('role', …)` only reach that login. The run login has `pgkiln_app_code` (the runtime role's rights in `meta`, kept in step after every migration by `meta.sync_app_code_grants()`, checked by a test) and the workspace's roles; the developer login only the workspace's roles. Row-level security on every `meta` table the runtime reads keys on the login (`meta.visible_apps()`), both ways: isolated workspaces don't see others, the shared runtime doesn't see isolated ones; the owner isn't affected. `meta.app_id()` and the eight functions that take an application id (`meta.checked_app`) refuse an application the login may not see. A workspace's database roles are an administrator's list (`meta.workspace_role`): an application there may only run as one of them, a role belongs to one workspace, and superusers, roles with CREATEROLE/BYPASSRLS/REPLICATION and pgkiln's own roles are refused; memberships follow by triggers (`meta.workspace_sync_roles()`). A new application gets a new role and a new or own schema. Every builder tool that runs a developer's SQL uses the workspace's developer login (`workshopDb`, `devDbForApp`); the import check plans the imported SQL on it, outside the import's owner transaction. Saved scripts, runs, blueprints and generators are per workspace. **Not covered:** administrators and developers who also belong to a shared workspace (owner rights there), the `pg_catalog` (definitions are readable by every login), PostgREST (one authenticator), identity providers, LDAP, mail and AI services (installation-wide). |
71
72
  | Drawers, built-in template components, Theme Roller additions (0.29.0) | Dialog positions and sizes, base styles, dark-mode colours, item and report-column template options only reach the page from fixed lists (checked when saved and again when rendered: a hand-edited value is ignored); the browser gets dialog shapes as JSON values from those lists. Built-in template components pass the same template checks as plug-ins; *Copy into this application* takes a static id from the fixed list, needs CSRF and the application in the developer's workspace, and never overwrites the application's own component. |
72
73
  | Report row selection across pages (0.29.0) | `POST …/report/<id>/select` needs the CSRF token, page access and a visible report region whose `selection` names the item (and the item visible); it only changes that item's session state; values are bounded (5000, 400 characters, no `:`) and treated as user input, like a submit; output is escaped. |
73
74
  | Instance settings (0.29.0) | Administrators only, with CSRF; only whole numbers within each setting's range are stored (a bad stored value falls back to the environment variable or default); the configuration overview never shows secrets (connection strings, keys: only whether they are set); changes are logged.
@@ -184,18 +185,19 @@ A second full review, mainly of what end users and API callers can reach. Regres
184
185
  can do what the runtime role can: read every application's metadata and sessions, set
185
186
  `pgkiln.app_id` / `pgkiln.app_user` and call `meta.page_url()` for any application and user (valid
186
187
  checksums), or change sessions. Treat every developer as trusted with all applications of the
187
- instance, as the workspace notes say; end users never write SQL. A separate login role per
188
- application would close this.
188
+ instance's shared workspaces, as the workspace notes say; end users never write SQL. An isolated workspace
189
+ (sprint 60) closes this between workspaces: its SQL resets to a login of its own.
189
190
  - **Administrators and developers**: SQL Commands, SQL Scripts and the other SQL Workshop tools run on
190
- the owner connection for every developer, so a developer can change anything, including
191
+ the owner connection for administrators and the developers of shared workspaces, so a developer can change anything, including
191
192
  `meta.developer.is_admin`. The administrator-only pages (instance settings, AI services, identity
192
193
  providers, LDAP directories, the password policy, developers) prevent mistakes; they are not a
193
194
  boundary against a developer who means harm.
194
- - **App id from SQL (known gap, to fix)**: `meta.app_id()` reads the `pgkiln.app_id` setting, which an
195
- application's own SQL can change. Requests queued from SQL (`meta.web_request`, `meta.ai_generate`) could
196
- therefore be made under another application's id, using its web credentials or AI services and quota.
197
- Page processes are not affected (they use the server's app id). Until this is fixed, treat applications
198
- on one instance as mutually trusted for these features.
195
+ - **App id from SQL (shared workspaces)**: `meta.app_id()` reads the `pgkiln.app_id` setting, which an
196
+ application's own SQL can change. In shared workspaces, requests queued from SQL (`meta.web_request`,
197
+ `meta.ai_generate`) could therefore be made under another shared application's id, using its web credentials or
198
+ AI services and quota. Page processes are not affected (they use the server's app id). Since sprint 60,
199
+ `meta.app_id()` refuses an application outside the login's workspace: isolate a workspace whose applications must
200
+ not be trusted by the others.
199
201
  - **Lockout trade-off**: per-user throttling lets an attacker lock a known
200
202
  account for 15 minutes. The per-IP limit and the activity monitor help you
201
203
  spot this.
@@ -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();