pgkiln 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +1 -1
  2. package/SECURITY.md +5 -1
  3. package/db/migrations/125_app_change.sql +23 -0
  4. package/db/migrations/126_mail_queue_network_access.sql +119 -0
  5. package/docs/apex-feature-parity.md +40 -11
  6. package/docs/guide/01-installation.md +1 -1
  7. package/docs/guide/03-builder.md +76 -3
  8. package/docs/guide/06-processing.md +6 -1
  9. package/docs/guide/08-security.md +55 -0
  10. package/docs/guide/09-reference.md +3 -1
  11. package/docs/guide/11-from-apex.md +1 -0
  12. package/docs/guide/12-development.md +43 -8
  13. package/docs/guide/14-globalization.md +49 -1
  14. package/docs/guide/18-cli.md +21 -1
  15. package/docs/guide/19-rest-data-sources.md +12 -0
  16. package/docs/guide/20-ai-agents.md +8 -2
  17. package/examples/hr/hr_70_data_security.sql +79 -0
  18. package/package.json +1 -1
  19. package/public/app.css +97 -23
  20. package/public/builder.css +50 -0
  21. package/public/builder.js +4 -0
  22. package/src/app.ts +9 -0
  23. package/src/builder/app-updates.ts +55 -0
  24. package/src/builder/data-security.ts +182 -0
  25. package/src/builder/home.ts +16 -5
  26. package/src/builder/mail-queue.ts +212 -0
  27. package/src/builder/mail.ts +72 -45
  28. package/src/builder/network-access.ts +140 -0
  29. package/src/builder/object-actions.ts +1 -0
  30. package/src/builder/object-browser.ts +1 -1
  31. package/src/builder/rest-catalogs.ts +19 -4
  32. package/src/builder/sql.ts +2 -0
  33. package/src/builder/themeroller.ts +47 -6
  34. package/src/builder/ui.ts +5 -0
  35. package/src/builder/websources.ts +1 -1
  36. package/src/builder/workspace-mail.ts +121 -0
  37. package/src/builder/workspaces.ts +1 -0
  38. package/src/cli/main.ts +25 -3
  39. package/src/cli/mcp.ts +40 -4
  40. package/src/cli/only.ts +52 -0
  41. package/src/cli/replace.ts +5 -1
  42. package/src/datasec.ts +343 -0
  43. package/src/mail/mail.ts +50 -11
  44. package/src/mail/providers.ts +6 -3
  45. package/src/metadata.ts +2 -0
  46. package/src/network-acl.ts +126 -0
  47. package/src/objectstore.ts +3 -0
  48. package/src/runtime/engine.ts +3 -1
  49. package/src/runtime/render.ts +4 -2
  50. package/src/runtime/styles.ts +54 -12
  51. package/src/webclient.ts +45 -4
  52. package/src/webrequests.ts +5 -0
  53. package/src/websources.ts +30 -12
package/src/cli/mcp.ts CHANGED
@@ -17,6 +17,7 @@ const PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05'];
17
17
  /** Cap on the text one tool call returns, so a large application does not flood the agent's context. */
18
18
  const MAX_TEXT = 200_000;
19
19
  const QUERY_TIMEOUT = '10s';
20
+ const pgIdent = (n: string) => `"${n.replace(/"/g, '""')}"`;
20
21
 
21
22
  const version = () => JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).version as string;
22
23
 
@@ -139,10 +140,35 @@ export function searchDocs(query: string, max: number) {
139
140
  /** Column names whose values run_query never shows (password hashes, tokens, secrets). */
140
141
  const SENSITIVE = /password|secret|token|hash|private_key|api_key|client_secret/i;
141
142
 
142
- export async function readOnlyQuery(db: Db, sql: string, maxRows: number) {
143
+ /**
144
+ * (sprint 119, WS-13) Whom a query runs as: an application's database role, with meta.app_id(), meta.app_user() and
145
+ * meta.has_role() (the user's roles in the application) set, so grants, column privileges, row level security and
146
+ * masking views apply as they do to that user's pages. It shows the agent what the user sees; it is no sandbox, as
147
+ * the MCP server itself connects as pgkiln's owner.
148
+ */
149
+ export interface QueryIdentity { appId: number; dbRole: string; user: string }
150
+
151
+ /** The identity of `user` in the application `alias`; an application without a database role is refused. */
152
+ export async function queryIdentity(db: Db, alias: string, user: string | undefined): Promise<QueryIdentity> {
153
+ const a = (await db.query(`select id, db_role from meta.app where alias = $1`, [alias])).rows[0];
154
+ if (!a) throw new ToolError(`no application ${alias}`);
155
+ if (!a.db_role) throw new ToolError(`${alias} runs without a database role, so its users see what pgkiln's owner sees: give it one (Settings → Security) to run as its users`);
156
+ return { appId: a.id, dbRole: a.db_role, user: user?.trim() || 'nobody' };
157
+ }
158
+
159
+ export async function readOnlyQuery(db: Db, sql: string, maxRows: number, as?: QueryIdentity) {
143
160
  // the select takes a snapshot, after which "set transaction read write" is refused
144
161
  await db.query(`begin transaction read only; set local statement_timeout = '${QUERY_TIMEOUT}'; select 1`);
145
162
  try {
163
+ if (as) {
164
+ await db.query(
165
+ `select set_config('pgkiln.app_id', $1, true), set_config('pgkiln.app_user', $2, true), set_config('pgkiln.session_id', '', true),
166
+ -- meta.has_role(): the user's roles in the application (meta.account_roles), as for an API token
167
+ set_config('request.jwt.claims', json_build_object('sub', $2::text)::text, true)`,
168
+ [String(as.appId), as.user],
169
+ );
170
+ await db.query(`set local role ${pgIdent(as.dbRole)}`);
171
+ }
146
172
  // the extended protocol runs exactly one statement, so a "commit; delete …"
147
173
  // cannot end the read-only transaction and go on (pg would use the simple
148
174
  // protocol, which runs them all, for a query without parameters)
@@ -521,17 +547,27 @@ export const TOOLS: Tool[] = [
521
547
  description:
522
548
  `Runs one SQL statement in a read-only transaction (rolled back, ${QUERY_TIMEOUT} timeout) and returns the rows as JSON. ` +
523
549
  'Use it to look at data and at the meta schema; it cannot change anything. Values of columns named like password, secret, token or hash are hidden. ' +
524
- 'Bind variables (:P1_X) are not replaced: use literals.',
550
+ 'Bind variables (:P1_X) are not replaced: use literals. ' +
551
+ 'With alias (and user), the query runs as that application\'s database role for that user, with meta.app_user() and meta.has_role() set: ' +
552
+ 'grants, column privileges, row level security and masking views apply as on the user\'s pages. Without, it runs as pgkiln\'s owner and sees everything.',
525
553
  inputSchema: {
526
554
  type: 'object',
527
- properties: { sql: { type: 'string' }, max_rows: { type: 'integer', description: 'default 50, at most 500' } },
555
+ properties: {
556
+ sql: { type: 'string' },
557
+ max_rows: { type: 'integer', description: 'default 50, at most 500' },
558
+ alias: { type: 'string', description: 'run as this application\'s database role (list_apps shows the aliases)' },
559
+ user: { type: 'string', description: 'with alias: the signed-in user (meta.app_user(); their roles in the application for meta.has_role()); default nobody' },
560
+ },
528
561
  required: ['sql'],
529
562
  },
530
563
  annotations: { readOnlyHint: true },
531
564
  async run(a) {
532
565
  const sql = str(a, 'sql', true)!;
533
566
  const max = int(a, 'max_rows', 50, 500);
534
- return withDb(async (db) => cap(stableJson(await readOnlyQuery(db, sql, max))));
567
+ const alias = str(a, 'alias');
568
+ const user = str(a, 'user');
569
+ if (user && !alias) throw new ToolError('user needs alias: the application whose user it is');
570
+ return withDb(async (db) => cap(stableJson(await readOnlyQuery(db, sql, max, alias ? await queryIdentity(db, alias, user) : undefined))));
535
571
  },
536
572
  },
537
573
  {
@@ -0,0 +1,52 @@
1
+ // (sprint 116, WS-14) pgkiln import <dir> --replace --only <path>: a file-level
2
+ // import (APEX 26.2: APEXlang file-level import). Only the named page or
3
+ // component files of a directory export change; everything else stays as it is
4
+ // in the database. How: the application is exported in the directory's style,
5
+ // the named files are taken from the directory (and removed where the directory
6
+ // no longer has them), and the merged files are imported with --replace as usual.
7
+ import type { FileMap } from '../appfiles.ts';
8
+ import { UsageError } from './apps.ts';
9
+
10
+ /**
11
+ * What an --only argument selects: "pages/12" (or "page 12", "12") is page 12's directory, whatever its
12
+ * name; any other path is a file or a directory of the export ("shared/lovs/jobs.yaml", "shared/lovs").
13
+ */
14
+ export function onlyMatcher(arg: string): { label: string; test: (path: string) => boolean } {
15
+ const a = arg.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
16
+ const page = /^(?:pages?[/ ]?)?(\d{1,9})$/.exec(a);
17
+ if (page) {
18
+ const prefix = `pages/${String(Number(page[1])).padStart(4, '0')}-`;
19
+ return { label: `page ${Number(page[1])}`, test: (p) => p.startsWith(prefix) && p.indexOf('/', prefix.length) > 0 };
20
+ }
21
+ if (!a || a.startsWith('/') || a.split('/').includes('..')) throw new UsageError(`--only ${arg}: a path inside the export directory, e.g. pages/12 or shared/lovs/jobs.yaml`);
22
+ if (a === 'pgkiln.json') throw new UsageError('--only pgkiln.json: the marker file is not a component');
23
+ return { label: a, test: (p) => p === a || p.startsWith(`${a}/`) };
24
+ }
25
+
26
+ /**
27
+ * The files to import: the application's current files with the selected ones replaced by the directory's.
28
+ * Refuses a selection that matches nothing on either side (a typo) and directories of another style.
29
+ */
30
+ export function mergeOnly(current: FileMap, incoming: FileMap, only: string[]) {
31
+ const style = (files: FileMap) => {
32
+ try {
33
+ return JSON.parse(files.get('pgkiln.json')?.toString('utf8') ?? '{}').style === 'text' ? 'text' : 'json';
34
+ } catch {
35
+ return 'json';
36
+ }
37
+ };
38
+ if (style(current) !== style(incoming)) throw new Error('the application was exported in another style than the directory');
39
+ const matchers = only.map(onlyMatcher);
40
+ const selected = (p: string) => matchers.some((m) => m.test(p));
41
+ for (const m of matchers)
42
+ if (![...current.keys(), ...incoming.keys()].some((p) => m.test(p))) throw new UsageError(`--only ${m.label}: not in the directory or the application`);
43
+ const merged: FileMap = new Map([...current].filter(([p]) => !selected(p)));
44
+ const changed: string[] = [];
45
+ for (const [p, content] of incoming)
46
+ if (selected(p)) {
47
+ merged.set(p, content);
48
+ if (!current.get(p)?.equals(content)) changed.push(p);
49
+ }
50
+ for (const p of current.keys()) if (selected(p) && !incoming.has(p)) changed.push(p);
51
+ return { files: merged, changed: changed.sort(), labels: matchers.map((m) => m.label) };
52
+ }
@@ -80,7 +80,11 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
80
80
  // (122) Team Development issues of the workspace, linked to the application (workspace data: the workspace export)
81
81
  'team_issue',
82
82
  // (123) the log of running workflow instances migrated to a newer version (runtime data, like the instances)
83
- 'workflow_migration'];
83
+ 'workflow_migration',
84
+ // (125) who changed the application last in the builder (App Builder list: Updated by)
85
+ 'app_change',
86
+ // (126) outgoing calls the network access lists refused (Administration → Network access; a log, 7 days)
87
+ 'network_refusal'];
84
88
  /** Children of pages (replaced with their page). */
85
89
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
86
90
  /** References into replaced tables from kept data, repointed below: "table.column". */
package/src/datasec.ts ADDED
@@ -0,0 +1,343 @@
1
+ import pg from 'pg';
2
+
3
+ // (sprint 119, WS-13: data security per table, pgkiln's answer to Deep Data Security) A table's rows, columns and
4
+ // values in one place. PostgreSQL has row level security but no column masking, so a table's security here is three
5
+ // layers, all of them ordinary database objects that every client (pages, REST handlers, AutoREST, AI tools, MCP,
6
+ // psql) meets in the same way:
7
+ //
8
+ // rows row level security policies (shown here; written in the worksheet; switched on in Table Properties);
9
+ // columns column privileges per database role: GRANT/REVOKE SELECT (c, …) and UPDATE (c, …). A column a role may
10
+ // not read is refused by PostgreSQL ("permission denied"), whoever sends the query;
11
+ // values a masking view per table: the table's columns, a masked column as
12
+ // case when (select meta.has_role('manager')) or … then sal end
13
+ // for the signed-in user's application roles (meta.has_role). The view runs with its owner's rights, and
14
+ // its owner is a separate role per schema (<schema>_masking) that is no owner, superuser or BYPASSRLS
15
+ // role and is a member of the database roles that read the view: so the table's row level security still
16
+ // applies inside the view (policies use meta.app_user() / meta.has_role(), the caller's settings), and a
17
+ // database role can read the masked values while its own SELECT on the raw column is revoked.
18
+ //
19
+ // The mask only holds when the database roles of the matrix cannot read the raw column on the table (the second
20
+ // layer): checkSecurity() allows it (an application whose pages still read the table needs it), but the preview
21
+ // says so. The configuration lives in the view's comment ("pgkiln masking view of <table>: {json}"), so it travels
22
+ // with the schema (pg_dump, Generate DDL, the change log) and needs no meta table. Pure apart from readSecurity.
23
+
24
+ export const MASKS = ['null', 'stars', 'partial'] as const;
25
+ export type Mask = (typeof MASKS)[number];
26
+ export const MASK_LABELS: Record<Mask, string> = { null: 'empty (null)', stars: '***', partial: '*** + last 4' };
27
+ export const MARK = 'pgkiln masking view of ';
28
+ export const NAME_RE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
29
+ /** An application role (meta.has_role): letters, digits and a few separators; compared in lower case. */
30
+ export const APP_ROLE_RE = /^[\p{L}\p{N}_][\p{L}\p{N}_ .:@-]{0,62}$/u;
31
+ const PRIVS = ['SELECT', 'UPDATE'] as const;
32
+
33
+ export interface SecColumn { name: string; type: string }
34
+ export interface Policy { name: string; command: string; permissive: string; roles: string; using: string | null; check: string | null }
35
+ /** A database role's privileges on the table: table-wide or on some columns. */
36
+ export interface DbGrant {
37
+ role: string;
38
+ tableSelect: boolean;
39
+ tableUpdate: boolean;
40
+ colSelect: string[];
41
+ colUpdate: string[];
42
+ /** applications that run as this role */
43
+ apps: string[];
44
+ }
45
+ export interface ColumnMask { see: string[]; others: boolean; mask: Mask }
46
+ export interface MaskConfig { table: string; roles: string[]; cols: Record<string, ColumnMask> }
47
+ export interface TableSecurity {
48
+ oid: number;
49
+ schema: string;
50
+ table: string;
51
+ owner: string;
52
+ rls: boolean;
53
+ forceRls: boolean;
54
+ columns: SecColumn[];
55
+ policies: Policy[];
56
+ grants: DbGrant[];
57
+ /** roles with privileges that the matrix cannot restrict, and why */
58
+ excluded: { role: string; why: string }[];
59
+ /** application roles the applications reading this table check (users.ts roleHints) */
60
+ appRoles: string[];
61
+ view: { name: string; config: MaskConfig; columns: string[] } | null;
62
+ maskRole: string;
63
+ maskRoleExists: boolean;
64
+ /** the roles the masking role is a member of */
65
+ maskMembers: string[];
66
+ }
67
+ export interface SecurityDef {
68
+ grants: { role: string; select: string[]; update: string[] }[];
69
+ view: { enabled: boolean; name: string; roles: string[]; cols: Record<string, ColumnMask> };
70
+ }
71
+
72
+ export const ident = (n: string) => pg.escapeIdentifier(n);
73
+ const lit = (s: string) => pg.escapeLiteral(s);
74
+ const roleIdent = (r: string) => (r === 'PUBLIC' ? 'public' : ident(r));
75
+
76
+ /** The masking role of a schema: <schema>_masking (cut, with a hash, when the name would be too long). */
77
+ export function maskRoleName(schema: string) {
78
+ const name = `${schema}_masking`;
79
+ if (Buffer.byteLength(name) <= 63) return name;
80
+ let h = 0;
81
+ for (const ch of schema) h = (h * 31 + ch.codePointAt(0)!) >>> 0;
82
+ return `${schema.slice(0, 40)}_${h.toString(36)}_masking`;
83
+ }
84
+
85
+ /** The comment's configuration, checked; null when it is not one of ours or is broken. */
86
+ export function parseConfig(comment: string | null, table: string): MaskConfig | null {
87
+ if (!comment?.startsWith(MARK)) return null;
88
+ const colon = comment.indexOf(': {');
89
+ if (colon < 0 || comment.slice(MARK.length, colon) !== table) return null;
90
+ try {
91
+ const x = JSON.parse(comment.slice(colon + 2));
92
+ if (x?.table !== table || !Array.isArray(x.roles) || typeof x.cols !== 'object' || !x.cols) return null;
93
+ const roles = (x.roles as unknown[]).filter((r): r is string => typeof r === 'string' && APP_ROLE_RE.test(r)).map((r) => r.toLowerCase());
94
+ const cols: Record<string, ColumnMask> = {};
95
+ for (const [c, m] of Object.entries(x.cols as Record<string, any>)) {
96
+ if (!m || !Array.isArray(m.see)) continue;
97
+ cols[c] = {
98
+ see: m.see.filter((r: unknown): r is string => typeof r === 'string' && roles.includes(r)),
99
+ others: m.others === true,
100
+ mask: MASKS.includes(m.mask) ? m.mask : 'null',
101
+ };
102
+ }
103
+ return { table, roles, cols };
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ /** The comment that marks a masking view (and holds its configuration). */
110
+ export const configComment = (c: MaskConfig) => {
111
+ // one spelling per configuration (keys in a fixed order), so an unchanged configuration compares equal
112
+ const cols = Object.fromEntries(Object.keys(c.cols).sort().map((k) => [k, { mask: c.cols[k].mask, see: [...c.cols[k].see].sort(), others: c.cols[k].others }]));
113
+ return `${MARK}${c.table}: ${JSON.stringify({ table: c.table, roles: [...c.roles].sort(), cols })}`;
114
+ };
115
+
116
+ type Q = { query: <T extends pg.QueryResultRow = any>(sql: string, params?: unknown[]) => Promise<pg.QueryResult<T>> };
117
+
118
+ /**
119
+ * A table's security as the catalog has it. `appDbRoles`: the database roles of the workspace's applications
120
+ * (shown as matrix rows even without privileges); `appRoles`: the application roles they check.
121
+ */
122
+ export async function readSecurity(db: Q, ref: string, appDbRoles: { role: string; alias: string }[], appRoles: string[]): Promise<TableSecurity | null> {
123
+ const t = (await db.query(
124
+ `select c.oid::int as oid, n.nspname as schema, c.relname as table, pg_get_userbyid(c.relowner) as owner, c.relrowsecurity as rls, c.relforcerowsecurity as force
125
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace where c.oid = to_regclass($1) and c.relkind in ('r', 'p')`, [ref])).rows[0];
126
+ if (!t) return null;
127
+ const columns = (await db.query<SecColumn>(
128
+ `select attname as name, format_type(atttypid, atttypmod) as type from pg_attribute where attrelid = $1 and attnum > 0 and not attisdropped order by attnum`, [t.oid])).rows;
129
+ const policies = (await db.query<Policy>(
130
+ `select policyname as name, cmd as command, permissive, array_to_string(roles, ', ') as roles, qual as using, with_check as check
131
+ from pg_policies where schemaname = $1 and tablename = $2 order by policyname`, [t.schema, t.table])).rows;
132
+ const tableAcl = (await db.query<{ role: string; priv: string }>(
133
+ `select coalesce(r.rolname, 'PUBLIC') as role, a.privilege_type as priv
134
+ from pg_class c cross join lateral aclexplode(c.relacl) a left join pg_roles r on r.oid = a.grantee
135
+ where c.oid = $1 and a.privilege_type in ('SELECT', 'UPDATE')`, [t.oid])).rows;
136
+ const colAcl = (await db.query<{ role: string; col: string; priv: string }>(
137
+ `select coalesce(r.rolname, 'PUBLIC') as role, att.attname as col, a.privilege_type as priv
138
+ from pg_attribute att cross join lateral aclexplode(att.attacl) a left join pg_roles r on r.oid = a.grantee
139
+ where att.attrelid = $1 and att.attnum > 0 and not att.attisdropped and a.privilege_type in ('SELECT', 'UPDATE')
140
+ order by att.attnum`, [t.oid])).rows;
141
+ const maskRole = maskRoleName(t.schema);
142
+ // roles the matrix cannot restrict: the owner and whoever has its privileges, superusers, BYPASSRLS roles
143
+ const names = [...new Set([...tableAcl.map((r) => r.role), ...colAcl.map((r) => r.role), ...appDbRoles.map((a) => a.role)])].filter((r) => r !== 'PUBLIC');
144
+ const special = new Map((await db.query<{ role: string; why: string | null }>(
145
+ `select r.rolname as role,
146
+ case when r.rolsuper then 'superuser' when r.rolbypassrls then 'bypasses row level security'
147
+ when pg_has_role(r.oid, $2::regrole, 'USAGE') then 'owns the table' end as why
148
+ from pg_roles r where r.rolname = any ($1)`, [names, t.owner])).rows.map((r) => [r.role, r.why]));
149
+ const excluded: { role: string; why: string }[] = [];
150
+ const grants = new Map<string, DbGrant>();
151
+ const row = (role: string) => {
152
+ let g = grants.get(role);
153
+ if (!g) grants.set(role, (g = { role, tableSelect: false, tableUpdate: false, colSelect: [], colUpdate: [], apps: [] }));
154
+ return g;
155
+ };
156
+ const usable = (role: string) => {
157
+ if (role === maskRole) return false;
158
+ if (role === 'PUBLIC') return true;
159
+ if (!special.has(role)) return false; // a dropped role
160
+ const why = special.get(role);
161
+ if (why) {
162
+ if (!excluded.some((x) => x.role === role)) excluded.push({ role, why });
163
+ return false;
164
+ }
165
+ return true;
166
+ };
167
+ for (const a of tableAcl) if (usable(a.role)) a.priv === 'SELECT' ? (row(a.role).tableSelect = true) : (row(a.role).tableUpdate = true);
168
+ for (const a of colAcl) if (usable(a.role)) (a.priv === 'SELECT' ? row(a.role).colSelect : row(a.role).colUpdate).push(a.col);
169
+ for (const a of appDbRoles) if (usable(a.role)) row(a.role).apps.push(a.alias);
170
+ // the masking view: a view in the table's schema whose comment names this table
171
+ let view: TableSecurity['view'] = null;
172
+ for (const v of (await db.query<{ oid: number; name: string; comment: string | null }>(
173
+ `select c.oid::int as oid, c.relname as name, obj_description(c.oid, 'pg_class') as comment from pg_class c
174
+ where c.relnamespace = (select relnamespace from pg_class where oid = $1) and c.relkind = 'v'
175
+ and obj_description(c.oid, 'pg_class') like $2 order by c.relname`, [t.oid, `${MARK.replace(/_/g, '\\_')}%`])).rows) {
176
+ const config = parseConfig(v.comment, t.table);
177
+ if (!config) continue;
178
+ const cols = (await db.query<{ name: string }>(`select attname as name from pg_attribute where attrelid = $1 and attnum > 0 and not attisdropped order by attnum`, [v.oid])).rows.map((r) => r.name);
179
+ view = { name: v.name, config, columns: cols };
180
+ break;
181
+ }
182
+ const mr = (await db.query<{ exists: boolean; members: string[] | null }>(
183
+ `select exists (select from pg_roles where rolname = $1) as exists,
184
+ (select array_agg(g.rolname order by g.rolname) from pg_auth_members m join pg_roles g on g.oid = m.roleid join pg_roles u on u.oid = m.member where u.rolname = $1) as members`,
185
+ [maskRole])).rows[0];
186
+ const order = (g: DbGrant) => (g.role === 'PUBLIC' ? 'zz' : g.apps.length ? `a${g.role}` : `b${g.role}`);
187
+ return {
188
+ oid: t.oid, schema: t.schema, table: t.table, owner: t.owner, rls: t.rls, forceRls: t.force, columns, policies,
189
+ grants: [...grants.values()].sort((x, y) => order(x).localeCompare(order(y))), excluded,
190
+ appRoles: [...new Set([...appRoles.map((r) => r.toLowerCase()), ...(view?.config.roles ?? [])])].sort(),
191
+ view, maskRole, maskRoleExists: mr.exists, maskMembers: mr.members ?? [],
192
+ };
193
+ }
194
+
195
+ /** The current state as a definition (what the matrix shows before a change). */
196
+ export function currentDef(cur: TableSecurity): SecurityDef {
197
+ const all = cur.columns.map((c) => c.name);
198
+ return {
199
+ grants: cur.grants.map((g) => ({ role: g.role, select: g.tableSelect ? all : all.filter((c) => g.colSelect.includes(c)), update: g.tableUpdate ? all : all.filter((c) => g.colUpdate.includes(c)) })),
200
+ view: {
201
+ enabled: !!cur.view,
202
+ name: cur.view?.name ?? `${cur.table}_masked`.slice(0, 63),
203
+ roles: cur.appRoles,
204
+ cols: Object.fromEntries(Object.entries(cur.view?.config.cols ?? {}).filter(([c]) => all.includes(c))),
205
+ },
206
+ };
207
+ }
208
+
209
+ /** Whether an application role sees a column's values (roles not in the configuration count as "others"). */
210
+ export function sees(def: SecurityDef['view'], col: string, role: string | null): boolean {
211
+ const m = def.cols[col];
212
+ if (!m) return true;
213
+ if (role === null) return m.others;
214
+ return m.see.includes(role) || (!def.roles.includes(role) && m.others);
215
+ }
216
+
217
+ /** Columns with a mask: some listed role or the others do not see them. */
218
+ export function maskedColumns(v: SecurityDef['view']) {
219
+ return Object.entries(v.cols).filter(([, m]) => !m.others || v.roles.some((r) => !m.see.includes(r))).map(([c]) => c);
220
+ }
221
+
222
+ const hasRole = (r: string) => `(select meta.has_role(${lit(r)}))`;
223
+
224
+ /** The condition under which the signed-in user sees a column: one of the roles that see it, or none of the listed roles. */
225
+ export function seesSql(roles: string[], m: ColumnMask): string {
226
+ const parts = roles.filter((r) => m.see.includes(r)).map(hasRole);
227
+ if (m.others) parts.push(roles.length ? `not (${roles.map(hasRole).join(' or ')})` : 'true');
228
+ return parts.length ? parts.join(' or ') : 'false';
229
+ }
230
+
231
+ /** The view's SELECT list entry for a column. */
232
+ export function columnSql(col: string, roles: string[], m: ColumnMask | undefined): string {
233
+ const c = `t.${ident(col)}`;
234
+ if (!m || (m.others && roles.every((r) => m.see.includes(r)))) return c;
235
+ const cond = seesSql(roles, m);
236
+ if (m.mask === 'stars') return `case when ${cond} then ${c}::text else '***' end as ${ident(col)}`;
237
+ if (m.mask === 'partial') return `case when ${cond} then ${c}::text else '***' || right(${c}::text, 4) end as ${ident(col)}`;
238
+ return `case when ${cond} then ${c} end as ${ident(col)}`;
239
+ }
240
+
241
+ /** What is wrong with a definition (against the catalog's state); `taken`: relation names in the schema. */
242
+ export function checkSecurity(def: SecurityDef, cur: TableSecurity, taken: string[]): string[] {
243
+ const errors: string[] = [];
244
+ const cols = new Set(cur.columns.map((c) => c.name));
245
+ const roles = new Set(cur.grants.map((g) => g.role));
246
+ for (const g of def.grants) {
247
+ if (!roles.has(g.role)) errors.push(`${g.role} is not a database role this page can change.`);
248
+ if ([...g.select, ...g.update].some((c) => !cols.has(c))) errors.push(`A column of ${g.role} is not in the table.`);
249
+ }
250
+ const v = def.view;
251
+ for (const r of v.roles) if (!APP_ROLE_RE.test(r) || r !== r.toLowerCase()) errors.push(`"${r}" is not a valid application role (letters, digits, _ . : @ - and spaces; lower case).`);
252
+ for (const [c, m] of Object.entries(v.cols)) {
253
+ if (!cols.has(c)) errors.push(`${c} is not a column of the table.`);
254
+ if (!MASKS.includes(m.mask)) errors.push(`Unknown mask for ${c}.`);
255
+ if (m.see.some((r) => !v.roles.includes(r))) errors.push(`A role that sees ${c} is not in the list.`);
256
+ }
257
+ if (v.enabled) {
258
+ if (!NAME_RE.test(v.name)) errors.push('The masking view needs a name of letters, digits, _ and $ (at most 63).');
259
+ else if (v.name === cur.table) errors.push('The masking view needs a name other than the table\'s.');
260
+ else if (taken.includes(v.name) && v.name !== cur.view?.name) errors.push(`${cur.schema}.${v.name} already exists.`);
261
+ }
262
+ return errors;
263
+ }
264
+
265
+ /** The roles that read through the view: database roles that may read some column of the table (after the change). */
266
+ const viewReaders = (def: SecurityDef) => def.grants.filter((g) => g.select.length).map((g) => g.role);
267
+
268
+ /**
269
+ * The statements that turn the catalog's state into the definition, and warnings about what they leave open.
270
+ * Order: the table's column privileges, then the masking view (dropped first, so a script starts with schema DDL).
271
+ */
272
+ export function securitySql(def: SecurityDef, cur: TableSecurity): { statements: string[]; warnings: string[] } {
273
+ const out: string[] = [];
274
+ const warnings: string[] = [];
275
+ const T = `${ident(cur.schema)}.${ident(cur.table)}`;
276
+ const all = cur.columns.map((c) => c.name);
277
+ const list = (cs: string[]) => cs.map(ident).join(', ');
278
+ for (const want of def.grants) {
279
+ const g = cur.grants.find((x) => x.role === want.role)!;
280
+ for (const p of PRIVS) {
281
+ const w = new Set(p === 'SELECT' ? want.select : want.update);
282
+ const tableWide = p === 'SELECT' ? g.tableSelect : g.tableUpdate;
283
+ const have = new Set(tableWide ? all : p === 'SELECT' ? g.colSelect : g.colUpdate);
284
+ const R = roleIdent(want.role);
285
+ if (all.every((c) => w.has(c))) {
286
+ if (!tableWide && !all.every((c) => have.has(c))) out.push(`grant ${p.toLowerCase()} on ${T} to ${R}`);
287
+ continue;
288
+ }
289
+ if (tableWide) {
290
+ // revoking the table-wide privilege revokes the column privileges too
291
+ out.push(`revoke ${p.toLowerCase()} on ${T} from ${R}`);
292
+ if (w.size) out.push(`grant ${p.toLowerCase()} (${list(all.filter((c) => w.has(c)))}) on ${T} to ${R}`);
293
+ continue;
294
+ }
295
+ const off = all.filter((c) => have.has(c) && !w.has(c));
296
+ const on = all.filter((c) => w.has(c) && !have.has(c));
297
+ if (off.length) out.push(`revoke ${p.toLowerCase()} (${list(off)}) on ${T} from ${R}`);
298
+ if (on.length) out.push(`grant ${p.toLowerCase()} (${list(on)}) on ${T} to ${R}`);
299
+ }
300
+ }
301
+ const v = def.view;
302
+ const masked = maskedColumns(v);
303
+ const readers = viewReaders(def);
304
+ if (!v.enabled) {
305
+ if (cur.view) out.push(`drop view ${ident(cur.schema)}.${ident(cur.view.name)}`);
306
+ else if (masked.length) warnings.push('Masks only apply through the masking view: switch it on.');
307
+ return { statements: out, warnings };
308
+ }
309
+ const config: MaskConfig = { table: cur.table, roles: v.roles, cols: Object.fromEntries(masked.map((c) => [c, v.cols[c]])) };
310
+ const V = `${ident(cur.schema)}.${ident(v.name)}`;
311
+ const M = ident(cur.maskRole);
312
+ const sameView = cur.view && cur.view.name === v.name && cur.view.columns.join('\0') === all.join('\0') && configComment(cur.view.config) === configComment(config);
313
+ const newMembers = readers.filter((r) => r !== 'PUBLIC' && !cur.maskMembers.includes(r));
314
+ if (!sameView || newMembers.length || !cur.maskRoleExists) {
315
+ if (cur.view) out.push(`drop view ${ident(cur.schema)}.${ident(cur.view.name)}`);
316
+ else out.push(`drop view if exists ${V}`);
317
+ if (!cur.maskRoleExists) {
318
+ out.push(`create role ${M} nologin`);
319
+ out.push(`grant ${M} to current_user`);
320
+ }
321
+ out.push(`grant usage on schema ${ident(cur.schema)} to ${M}`);
322
+ out.push(`grant select on ${T} to ${M}`);
323
+ // a member of the readers: their policies (to <role>) and the functions those policies call apply in the view
324
+ for (const r of newMembers) out.push(`grant ${ident(r)} to ${M}`);
325
+ out.push(`create view ${V} with (security_barrier) as\nselect ${all.map((c) => columnSql(c, v.roles, v.cols[c])).join(',\n ')}\n from ${T} t`);
326
+ out.push(`alter view ${V} owner to ${M}`);
327
+ out.push(`comment on view ${V} is ${lit(configComment(config))}`);
328
+ for (const r of readers) out.push(`grant select on ${V} to ${roleIdent(r)}`);
329
+ // the view runs as the masking role: it must stay under row level security
330
+ out.push(`do $check$ begin
331
+ if exists (select from pg_roles r where r.rolname = ${lit(cur.maskRole)}
332
+ and (r.rolsuper or r.rolbypassrls or pg_has_role(r.oid, ${lit(cur.owner)}::regrole, 'USAGE'))) then
333
+ raise exception 'the masking role ${cur.maskRole.replace(/'/g, "''")} would bypass row level security on ${cur.schema.replace(/'/g, "''")}.${cur.table.replace(/'/g, "''")}';
334
+ end if;
335
+ end $check$`);
336
+ }
337
+ for (const c of masked) {
338
+ const raw = def.grants.filter((g) => g.select.includes(c)).map((g) => g.role);
339
+ if (raw.length) warnings.push(`${raw.join(', ')} can still read ${cur.table}.${c} on the table itself: the mask holds only where ${raw.length > 1 ? 'they read' : 'it reads'} ${v.name}. Untick "read" of ${c} to make it hold everywhere.`);
340
+ }
341
+ if (masked.length && !readers.length) warnings.push('No database role reads the table, so no role gets the masking view.');
342
+ return { statements: out, warnings };
343
+ }
package/src/mail/mail.ts CHANGED
@@ -12,7 +12,8 @@
12
12
  import pg from 'pg';
13
13
  import { owner } from '../db.ts';
14
14
  import { decryptSecret } from '../secrets.ts';
15
- import { deliver, MailError, type OutgoingMail, type Provider, type ServiceSettings } from './providers.ts';
15
+ import { mailHostAddress, scopeForWorkspace } from '../network-acl.ts';
16
+ import { deliver, MailError, verifyConnection, type OutgoingMail, type Provider, type ServiceSettings } from './providers.ts';
16
17
 
17
18
  export interface MailService {
18
19
  id: number;
@@ -23,6 +24,8 @@ export interface MailService {
23
24
  secret_enc: string | null;
24
25
  senders: string[];
25
26
  enabled: boolean;
27
+ /** (sprint 118) a workspace's own service (its administrators manage it); null: the installation's */
28
+ workspace_id?: number | null;
26
29
  }
27
30
 
28
31
  const PER_PASS = 20;
@@ -37,7 +40,7 @@ export function senderAllowed(senders: string[], from: string) {
37
40
  }
38
41
 
39
42
  /** Send one message through a service now (the worker, and the builder's test). Returns the provider's id. */
40
- export async function sendWith(service: MailService, m: OutgoingMail) {
43
+ export async function sendWith(service: MailService, m: OutgoingMail, appId: number | null = null) {
41
44
  if (!service.enabled) throw new MailError(`Mail service ${service.name} is disabled.`, false);
42
45
  if (!senderAllowed(service.senders, m.from)) throw new MailError(`Mail service ${service.name} may not send as ${m.from}.`, false);
43
46
  let secret: string | null = null;
@@ -46,7 +49,35 @@ export async function sendWith(service: MailService, m: OutgoingMail) {
46
49
  } catch (e) {
47
50
  throw new MailError(`The secret of mail service ${service.name} can't be read: ${(e as Error).message}`, false);
48
51
  }
49
- return deliver(service.provider, service.settings ?? {}, secret, m);
52
+ return deliver(service.provider, await serviceSettings(service, appId), secret, m);
53
+ }
54
+
55
+ /**
56
+ * (sprint 118) The settings a service is used with. A workspace's own service has no base URL or
57
+ * token URL of its own (the providers' own APIs only), and its SMTP host must be on the
58
+ * installation's allow-list and the workspace's network access list, and resolve to a public
59
+ * address (unless PGKILN_REST_PRIVATE_HOSTS lists it): the connection goes to the address checked here.
60
+ */
61
+ export async function serviceSettings(service: Pick<MailService, 'name' | 'provider' | 'settings' | 'workspace_id'>, appId: number | null = null): Promise<ServiceSettings> {
62
+ const settings = service.settings ?? {};
63
+ if (!service.workspace_id) return settings;
64
+ if (settings.base_url) throw new MailError(`Mail service ${service.name} belongs to a workspace: it uses the provider's own API (no base URL).`, false);
65
+ if (settings.token_url) throw new MailError(`Mail service ${service.name} belongs to a workspace: OAuth2 uses Microsoft Entra of the tenant (no token URL).`, false);
66
+ if (service.provider !== 'smtp') return settings;
67
+ const scope = await scopeForWorkspace(service.workspace_id, `mail service ${service.name}`);
68
+ if (!scope) throw new MailError(`Mail service ${service.name}: its workspace is gone.`, false);
69
+ const port = settings.port ?? (settings.security === 'tls' ? 465 : 587);
70
+ try {
71
+ const { address, servername } = await mailHostAddress({ ...scope, appId }, String(settings.host ?? ''), port);
72
+ return { ...settings, host: address, port, servername: servername ?? settings.host };
73
+ } catch (e) {
74
+ throw new MailError((e as Error).message, false);
75
+ }
76
+ }
77
+
78
+ /** (sprint 118) The connection check of a service (a workspace's with the checks of serviceSettings). */
79
+ export async function verifyService(service: Pick<MailService, 'name' | 'provider' | 'settings' | 'workspace_id'>, secret: string | null) {
80
+ return verifyConnection(service.provider, await serviceSettings(service), secret);
50
81
  }
51
82
 
52
83
  interface Claimed {
@@ -64,8 +95,11 @@ interface Claimed {
64
95
  }
65
96
 
66
97
  async function sendOne(m: Claimed) {
67
- const service = await owner.one<MailService>(
68
- `select s.* from meta.app_mail_service x join meta.mail_service s on s.id = x.service_id where x.app_id = $1`, [m.app_id]);
98
+ // the application's service, else (sprint 118) its workspace's own
99
+ const service = await owner.one<MailService>(`select s.* from meta.mail_service s where s.id = meta.mail_service_for_app($1)`, [m.app_id]);
100
+ const t0 = performance.now();
101
+ // (sprint 118) when the last attempt ended, how long the provider took (Administration → Mail queue)
102
+ const timing = () => [Math.round(performance.now() - t0), service?.provider ?? null] as const;
69
103
  try {
70
104
  if (!service) throw new MailError('The application has no mail service any more.', false);
71
105
  const attachments = (await owner.query<{ filename: string; mime: string; content: Buffer }>(
@@ -73,16 +107,21 @@ async function sendOne(m: Claimed) {
73
107
  const id = await sendWith(service, {
74
108
  from: m.from_address, to: m.to_addresses, cc: m.cc_addresses, bcc: m.bcc_addresses, replyTo: m.reply_to,
75
109
  subject: m.subject, text: m.body_text, html: m.body_html, attachments,
76
- });
77
- await owner.query(`update meta.mail_queue set status = 'sent', sent_at = now(), provider_id = $2, service = $3, message = null where id = $1`,
78
- [m.id, id ? String(id).slice(0, 500) : null, service.name]);
110
+ }, m.app_id);
111
+ const [ms, provider] = timing();
112
+ await owner.query(`update meta.mail_queue set status = 'sent', sent_at = now(), provider_id = $2, service = $3, message = null,
113
+ last_attempt_at = now(), duration_ms = $4, provider = $5 where id = $1`,
114
+ [m.id, id ? String(id).slice(0, 500) : null, service.name, ms, provider]);
79
115
  } catch (e) {
80
116
  const retry = e instanceof MailError ? e.retry : true;
81
117
  const message = (e as Error).message.slice(0, 2000);
118
+ const [ms, provider] = timing();
82
119
  if (retry && m.attempts < ATTEMPTS)
83
- await owner.query(`update meta.mail_queue set status = 'queued', not_before = now() + make_interval(mins => $2), message = $3, service = $4 where id = $1`,
84
- [m.id, 4 ** (m.attempts - 1), message, service?.name ?? null]);
85
- else await owner.query(`update meta.mail_queue set status = 'error', message = $2, service = $3 where id = $1`, [m.id, message, service?.name ?? null]);
120
+ await owner.query(`update meta.mail_queue set status = 'queued', not_before = now() + make_interval(mins => $2), message = $3, service = $4,
121
+ last_attempt_at = now(), duration_ms = $5, provider = $6 where id = $1`,
122
+ [m.id, 4 ** (m.attempts - 1), message, service?.name ?? null, ms, provider]);
123
+ else await owner.query(`update meta.mail_queue set status = 'error', message = $2, service = $3, last_attempt_at = now(), duration_ms = $4, provider = $5 where id = $1`,
124
+ [m.id, message, service?.name ?? null, ms, provider]);
86
125
  }
87
126
  }
88
127
 
@@ -77,6 +77,8 @@ export interface ServiceSettings {
77
77
  // the API's address instead of the provider's own (administrators only; a proxy, or tests)
78
78
  base_url?: string;
79
79
  default_from?: string;
80
+ /** (sprint 118) not stored: the host name for TLS when `host` is the address checked for a workspace's service */
81
+ servername?: string;
80
82
  }
81
83
 
82
84
  export class MailError extends Error {
@@ -203,6 +205,7 @@ async function smtp(m: OutgoingMail, s: ServiceSettings, secret: string | null)
203
205
  };
204
206
  const transport = nodemailer.createTransport({
205
207
  host: s.host,
208
+ ...(s.servername ? { servername: s.servername } : {}),
206
209
  port: s.port ?? (security === 'tls' ? 465 : 587),
207
210
  secure: security === 'tls',
208
211
  requireTLS: security === 'starttls',
@@ -387,7 +390,7 @@ export async function verifyConnection(provider: Provider, s: ServiceSettings, s
387
390
  const security = s.security ?? 'starttls';
388
391
  const auth = s.auth ?? 'password';
389
392
  const transport = nodemailer.createTransport({
390
- host: s.host, port: s.port ?? (security === 'tls' ? 465 : 587), secure: security === 'tls', requireTLS: security === 'starttls', ignoreTLS: security === 'none',
393
+ host: s.host, ...(s.servername ? { servername: s.servername } : {}), port: s.port ?? (security === 'tls' ? 465 : 587), secure: security === 'tls', requireTLS: security === 'starttls', ignoreTLS: security === 'none',
391
394
  auth: (auth === 'password' ? { user: s.username, pass: secret ?? '' }
392
395
  : auth === 'oauth2' ? { type: 'OAuth2', user: s.username, accessToken: await clientCredentialsToken(entraTokenUrl(s), s.client_id ?? '', secret ?? '', s.scope || 'https://outlook.office365.com/.default') }
393
396
  : undefined) as never,
@@ -397,11 +400,11 @@ export async function verifyConnection(provider: Provider, s: ServiceSettings, s
397
400
  await transport.verify();
398
401
  } catch (e) {
399
402
  const err = e as { response?: string; message: string; code?: string };
400
- throw new MailError(`The SMTP server ${s.host}: ${(err.response ?? err.message).slice(0, 500)}`, false);
403
+ throw new MailError(`The SMTP server ${s.servername ?? s.host}: ${(err.response ?? err.message).slice(0, 500)}`, false);
401
404
  } finally {
402
405
  transport.close();
403
406
  }
404
- return `Connected to ${s.host}:${s.port ?? (security === 'tls' ? 465 : 587)}${security === 'none' ? '' : ` with ${security === 'tls' ? 'TLS' : 'STARTTLS'}`}${
407
+ return `Connected to ${s.servername ?? s.host}:${s.port ?? (security === 'tls' ? 465 : 587)}${security === 'none' ? '' : ` with ${security === 'tls' ? 'TLS' : 'STARTTLS'}`}${
405
408
  auth === 'none' ? '' : ` and signed in as ${s.username}${auth === 'oauth2' ? ' with OAuth2' : ''}`}.`;
406
409
  }
407
410
  case 'graph':
package/src/metadata.ts CHANGED
@@ -123,6 +123,8 @@ export interface App {
123
123
  base?: string;
124
124
  /** (0.29) the accent and header colours in dark mode */
125
125
  accent_dark?: string; header_dark?: string;
126
+ /** (sprint 117) the navigation appearance (Pillar Light / Dark) and the header pillar strip's colours */
127
+ appearance?: string; strip?: string; strip_dark?: string;
126
128
  };
127
129
  /** primary language, translated languages, and how the language is chosen */
128
130
  language: string;