pgkiln 0.36.0 → 0.38.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 +12 -9
- package/db/migrations/090_workspace_isolation.sql +512 -0
- package/db/migrations/091_workspace_service.sql +20 -0
- package/db/migrations/092_app_groups.sql +23 -0
- package/docs/apex-feature-parity.md +16 -11
- package/docs/guide/03-builder.md +125 -11
- package/docs/guide/12-development.md +12 -2
- package/examples/gallery/README.md +13 -0
- package/examples/gallery/gallery.json +20 -0
- package/examples/gallery/starters/asset-inventory.json +30 -0
- package/examples/gallery/starters/contacts.json +41 -0
- package/examples/gallery/starters/event-registrations.json +29 -0
- package/examples/gallery/starters/expense-claims.json +32 -0
- package/examples/gallery/starters/team-tasks.json +35 -0
- package/examples/hr/hr_40_sample_data.sql +1 -1
- package/package.json +1 -1
- package/public/builder.css +91 -0
- package/src/app.ts +24 -0
- package/src/automations.ts +3 -2
- package/src/builder/advisor.ts +11 -5
- package/src/builder/ai-builder.ts +27 -18
- package/src/builder/app-groups.ts +132 -0
- package/src/builder/blueprints.ts +21 -7
- package/src/builder/code-editor.ts +3 -2
- package/src/builder/cross-app.ts +106 -0
- package/src/builder/dataload.ts +24 -14
- package/src/builder/ddl-tools.ts +7 -4
- package/src/builder/documents.ts +2 -1
- package/src/builder/gallery.ts +154 -0
- package/src/builder/home.ts +33 -10
- package/src/builder/newapp.ts +21 -3
- package/src/builder/quicksql.ts +3 -2
- package/src/builder/report-settings.ts +4 -2
- package/src/builder/routes.ts +78 -26
- package/src/builder/sampledata.ts +22 -18
- package/src/builder/scripts.ts +31 -16
- package/src/builder/sql.ts +119 -15
- package/src/builder/ui.ts +30 -7
- package/src/builder/unload.ts +3 -2
- package/src/builder/workspaces.ts +291 -22
- package/src/cli/replace.ts +2 -0
- package/src/db.ts +36 -6
- package/src/ddl.ts +2 -1
- package/src/debug.ts +3 -2
- package/src/migrate.ts +3 -0
- package/src/process-jobs.ts +7 -2
- package/src/restsync.ts +3 -2
- package/src/runtime/calendar.ts +2 -1
- package/src/runtime/routes.ts +4 -1
- package/src/sampledata.ts +3 -3
- package/src/session.ts +18 -4
- package/src/websources.ts +3 -1
- package/src/workflow.ts +65 -9
- package/src/workspace-db.ts +166 -0
package/SECURITY.md
CHANGED
|
@@ -67,7 +67,9 @@ 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. **
|
|
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). |
|
|
72
|
+
| Builder screens like APEX's (sprint 61) | The sign-in page's workspace is checked after the password (only the developer's workspaces; a wrong one answers like a wrong password, failures are throttled and logged as before); *Remember workspace and username* is an HttpOnly cookie for `/builder` holding no secret, read defensively and escaped; the redirect after sign-in is a `/builder` path only. Workspace message and banner: administrators and the workspace's administrators, CSRF, plain text escaped, the banner colour from a fixed list. Utilization: the same. SQL Workshop home: your own recent commands only (by id and username), the schema checked against the schemas the session's connection may use (and set as a parameter of `set_config`). Application groups: every query bound to the current workspace; groups and applications of other workspaces are ignored. App Gallery: keys are names from `examples/gallery/gallery.json`, never paths; sample apps (shipped SQL as the owner, demo accounts with known passwords) are for administrators only; starter apps go through the blueprint review and its signed create step. Cross application reports: fixed SQL per topic, the workspace id as a parameter. |
|
|
71
73
|
| 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
74
|
| 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
75
|
| 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 +186,19 @@ A second full review, mainly of what end users and API callers can reach. Regres
|
|
|
184
186
|
can do what the runtime role can: read every application's metadata and sessions, set
|
|
185
187
|
`pgkiln.app_id` / `pgkiln.app_user` and call `meta.page_url()` for any application and user (valid
|
|
186
188
|
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.
|
|
188
|
-
|
|
189
|
+
instance's shared workspaces, as the workspace notes say; end users never write SQL. An isolated workspace
|
|
190
|
+
(sprint 60) closes this between workspaces: its SQL resets to a login of its own.
|
|
189
191
|
- **Administrators and developers**: SQL Commands, SQL Scripts and the other SQL Workshop tools run on
|
|
190
|
-
the owner connection for
|
|
192
|
+
the owner connection for administrators and the developers of shared workspaces, so a developer can change anything, including
|
|
191
193
|
`meta.developer.is_admin`. The administrator-only pages (instance settings, AI services, identity
|
|
192
194
|
providers, LDAP directories, the password policy, developers) prevent mistakes; they are not a
|
|
193
195
|
boundary against a developer who means harm.
|
|
194
|
-
- **App id from SQL (
|
|
195
|
-
application's own SQL can change.
|
|
196
|
-
therefore be made under another application's id, using its web credentials or
|
|
197
|
-
Page processes are not affected (they use the server's app id).
|
|
198
|
-
|
|
196
|
+
- **App id from SQL (shared workspaces)**: `meta.app_id()` reads the `pgkiln.app_id` setting, which an
|
|
197
|
+
application's own SQL can change. In shared workspaces, requests queued from SQL (`meta.web_request`,
|
|
198
|
+
`meta.ai_generate`) could therefore be made under another shared application's id, using its web credentials or
|
|
199
|
+
AI services and quota. Page processes are not affected (they use the server's app id). Since sprint 60,
|
|
200
|
+
`meta.app_id()` refuses an application outside the login's workspace: isolate a workspace whose applications must
|
|
201
|
+
not be trusted by the others.
|
|
199
202
|
- **Lockout trade-off**: per-user throttling lets an attacker lock a known
|
|
200
203
|
account for 15 minutes. The per-IP limit and the activity monitor help you
|
|
201
204
|
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();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- 091: a workspace's message and environment banner (APEX: Administration
|
|
3
|
+
-- → Manage Service → Define Workspace Message, Define Environment Banner)
|
|
4
|
+
--
|
|
5
|
+
-- message shown to the workspace's developers on the App Builder home
|
|
6
|
+
-- (plain text, at most 2000 characters)
|
|
7
|
+
-- banner_label a short label (e.g. DEVELOPMENT, TEST) shown in a coloured band
|
|
8
|
+
-- at the top of every builder page while the workspace is current
|
|
9
|
+
-- banner_color one colour of a fixed list (the builder's CSS draws them)
|
|
10
|
+
--
|
|
11
|
+
-- Administrators and the workspace's administrators set them.
|
|
12
|
+
-- Installation data, not exported.
|
|
13
|
+
-- =====================================================================
|
|
14
|
+
alter table meta.workspace
|
|
15
|
+
add column message text check (length(message) between 1 and 2000),
|
|
16
|
+
add column banner_label text check (banner_label ~ '^\S(.{0,38}\S)?$'),
|
|
17
|
+
add column banner_color text not null default 'blue'
|
|
18
|
+
check (banner_color in ('red', 'orange', 'yellow', 'green', 'teal', 'blue', 'purple', 'gray'));
|
|
19
|
+
comment on column meta.workspace.message is 'Workspace message (091): shown to its developers on the App Builder home.';
|
|
20
|
+
comment on column meta.workspace.banner_label is 'Environment banner (091): a label shown at the top of the builder while the workspace is current.';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
-- =====================================================================
|
|
2
|
+
-- 092: application groups (APEX: Workspace Utilities → Application Groups)
|
|
3
|
+
--
|
|
4
|
+
-- A workspace's named groups of applications, to organise the App Builder:
|
|
5
|
+
-- the home page filters and labels applications by group. They change
|
|
6
|
+
-- nothing at run time. Installation data (like workspaces): not exported,
|
|
7
|
+
-- kept by an in-place import.
|
|
8
|
+
-- =====================================================================
|
|
9
|
+
create table meta.app_group (
|
|
10
|
+
id serial primary key,
|
|
11
|
+
workspace_id int not null references meta.workspace on delete cascade,
|
|
12
|
+
name text not null check (name ~ '^\S(.{0,58}\S)?$'),
|
|
13
|
+
description text check (length(description) <= 2000),
|
|
14
|
+
unique (workspace_id, name)
|
|
15
|
+
);
|
|
16
|
+
create table meta.app_group_member (
|
|
17
|
+
app_id int primary key references meta.app on delete cascade,
|
|
18
|
+
group_id int not null references meta.app_group on delete cascade
|
|
19
|
+
);
|
|
20
|
+
create index app_group_member_group_idx on meta.app_group_member (group_id);
|
|
21
|
+
revoke all on meta.app_group, meta.app_group_member from public;
|
|
22
|
+
comment on table meta.app_group is 'Application groups (092): a workspace''s named groups of applications, for the App Builder. Not exported.';
|
|
23
|
+
comment on table meta.app_group_member is 'The group of an application (092); none: ungrouped.';
|