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
@@ -0,0 +1,126 @@
1
+ import dns from 'node:dns/promises';
2
+ import net from 'node:net';
3
+ import { owner } from './db.ts';
4
+ import { isPrivateAddress, matches, restHosts, scopeProblem, urlProblem, type OutboundScope } from './webclient.ts';
5
+
6
+ // (sprint 118, WS-9; APEX 26.2 workspace network ACLs) A workspace's network
7
+ // access list: when meta.workspace.network_restricted is on, the outgoing web
8
+ // calls made for its applications must match one of meta.workspace.network_hosts
9
+ // as well as the installation's lists (PGKILN_REST_ALLOWED_HOSTS /
10
+ // PGKILN_REST_PRIVATE_HOSTS). It only narrows: the installation's checks
11
+ // (allow-list, private addresses at connect time, redirects) stay as they are.
12
+ //
13
+ // Every caller of src/webclient.ts webRequest() passes a scope built here:
14
+ // websources.ts call() (meta.web_request, REST data sources, write-back,
15
+ // invoke API in pages, workflows and automations, the builder's Test), its
16
+ // token requests, objectstore.ts and the REST source catalogs' "read from a
17
+ // URL". A workspace's own mail service checks its SMTP host with
18
+ // mailHostAddress(). Refusals go to meta.network_refusal (7 days).
19
+ //
20
+ // No cache: one indexed query per call, so a narrowed list applies at once on
21
+ // every server.
22
+
23
+ export const KEEP_DAYS = 7;
24
+ /** At most this many refusals are kept per workspace. */
25
+ const KEEP_PER_WORKSPACE = 2000;
26
+
27
+ /** Entries of a network access list: host, *.domain, host:port, [IPv6]:port. */
28
+ export const HOST_ENTRY = /^(?:(?:\*\.)?[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*|\[[0-9a-f:.]{2,45}\])(?::([1-9][0-9]{0,4}))?$/;
29
+
30
+ /** The entries of a typed list (comma, space or line separated), checked; throws with a message. */
31
+ export function parseHostList(text: string): string[] {
32
+ const entries = [...new Set(text.split(/[\s,;]+/).map((x) => x.trim().toLowerCase()).filter(Boolean))];
33
+ for (const e of entries) {
34
+ const m = HOST_ENTRY.exec(e);
35
+ if (!m || (m[1] && Number(m[1]) > 65535))
36
+ throw new Error(`"${e.slice(0, 80)}" is not a host: use api.example.com, *.example.com or api.example.com:8443 (no scheme, no path).`);
37
+ }
38
+ if (entries.length > 200) throw new Error('At most 200 entries.');
39
+ return entries;
40
+ }
41
+
42
+ interface WorkspaceRow {
43
+ id: number;
44
+ name: string;
45
+ network_restricted: boolean;
46
+ network_hosts: string[];
47
+ }
48
+
49
+ function scopeOf(w: WorkspaceRow, appId: number | null, what: string): OutboundScope {
50
+ return {
51
+ workspaceId: w.id,
52
+ workspaceName: w.name,
53
+ appId,
54
+ what,
55
+ hosts: w.network_restricted ? w.network_hosts : null,
56
+ // a method: a copy of the scope with another "what" (a token endpoint) reports under its own name
57
+ refused(url, list, message) {
58
+ void recordRefusal(this.workspaceId, this.appId, this.what, url, list, message);
59
+ },
60
+ };
61
+ }
62
+
63
+ /** The scope of an application's outgoing calls (its workspace's list); null for no application. */
64
+ export async function scopeForApp(appId: number | null | undefined, what: string): Promise<OutboundScope | null> {
65
+ if (appId === null || appId === undefined || !Number.isInteger(Number(appId))) return null;
66
+ const w = await owner.one<WorkspaceRow>(
67
+ 'select w.id, w.name, w.network_restricted, w.network_hosts from meta.workspace w where w.id = meta.app_workspace($1)', [appId]);
68
+ return w ? scopeOf(w, Number(appId), what) : null;
69
+ }
70
+
71
+ /** The scope of a workspace's own outgoing calls (no application: REST source catalogs, its mail service). */
72
+ export async function scopeForWorkspace(workspaceId: number, what: string): Promise<OutboundScope | null> {
73
+ const w = await owner.one<WorkspaceRow>('select id, name, network_restricted, network_hosts from meta.workspace where id = $1', [workspaceId]);
74
+ return w ? scopeOf(w, null, what) : null;
75
+ }
76
+
77
+ /** Keep a refusal (never throws: a refusal is already an error for the caller). */
78
+ export async function recordRefusal(workspaceId: number | null, appId: number | null, what: string, url: URL, list: 'installation' | 'workspace', message: string) {
79
+ try {
80
+ await owner.query(
81
+ `insert into meta.network_refusal (workspace_id, app_id, what, host, url, list, message) values ($1, $2, $3, $4, $5, $6, $7)`,
82
+ [workspaceId, appId, what.slice(0, 100), url.host.slice(0, 300), `${url.protocol}//${url.host}${url.pathname}`.slice(0, 1000), list, message.slice(0, 1000)]);
83
+ } catch {
84
+ // an application deleted meanwhile, or the database is away: nothing to keep
85
+ }
86
+ }
87
+
88
+ /** Forget old refusals (the scheduler's pass, with the web requests' purge). */
89
+ export async function purgeRefusals() {
90
+ await owner.query(`delete from meta.network_refusal where at < now() - make_interval(days => ${KEEP_DAYS})`);
91
+ await owner.query(
92
+ `delete from meta.network_refusal r using (
93
+ select id, row_number() over (partition by workspace_id order by id desc) as n from meta.network_refusal
94
+ ) x where r.id = x.id and x.n > ${KEEP_PER_WORKSPACE}`);
95
+ }
96
+
97
+ /**
98
+ * Where a workspace's own SMTP service may connect: the host must pass the
99
+ * installation's lists and the workspace's, and it is resolved here once; the
100
+ * connection then goes to the checked address (no DNS rebinding), with the
101
+ * host name kept for TLS. Throws an Error with the reason.
102
+ */
103
+ export async function mailHostAddress(scope: OutboundScope, host: string, port: number): Promise<{ address: string; servername: string | undefined }> {
104
+ let url: URL;
105
+ try {
106
+ url = new URL(`https://${net.isIPv6(host) ? `[${host}]` : host}:${port}/`);
107
+ } catch {
108
+ throw new Error(`The SMTP host ${host} is not valid.`);
109
+ }
110
+ const hosts = restHosts();
111
+ const problem = urlProblem(url, hosts) ?? scopeProblem(scope, url);
112
+ if (problem) {
113
+ scope.refused?.(url, urlProblem(url, hosts) ? 'installation' : 'workspace', problem);
114
+ throw new Error(problem);
115
+ }
116
+ const bare = url.hostname.replace(/^\[|\]$/g, '');
117
+ const addresses = net.isIP(bare) ? [{ address: bare }] : await dns.lookup(bare, { all: true }).catch(() => []);
118
+ if (!addresses.length) throw new Error(`The SMTP host ${host} has no address.`);
119
+ const priv = matches(hosts.private, url);
120
+ if (!priv && addresses.some((a) => isPrivateAddress(a.address))) {
121
+ const msg = `The SMTP host ${host} resolves to a private, loopback or link-local address (allow it with PGKILN_REST_PRIVATE_HOSTS).`;
122
+ scope.refused?.(url, 'installation', msg);
123
+ throw new Error(msg);
124
+ }
125
+ return { address: addresses[0].address, servername: net.isIP(bare) ? undefined : bare };
126
+ }
@@ -10,6 +10,7 @@
10
10
  import { createHash, createHmac, randomUUID } from 'node:crypto';
11
11
  import { decryptSecret } from './secrets.ts';
12
12
  import { WebError, webRequest } from './webclient.ts';
13
+ import { scopeForApp } from './network-acl.ts';
13
14
  import { credentialValidFor, loadCredential } from './websources.ts';
14
15
 
15
16
  export interface ObjectStoreConfig {
@@ -113,6 +114,8 @@ async function send(appId: number, o: ObjectStoreConfig, method: 'PUT' | 'GET' |
113
114
  };
114
115
  const authorization = signV4({ method, url, headers, payloadHash, accessKey: cred.username!, secretKey: decryptSecret(cred.secret_enc!), region: cred.scope || 'us-east-1', amzDate });
115
116
  const res = await webRequest(url.toString(), {
117
+ // (sprint 118) the workspace's network access list
118
+ scope: await scopeForApp(appId, `object storage (web credential ${cred.name})`),
116
119
  method,
117
120
  headers: { ...Object.fromEntries(Object.entries(headers).filter(([k]) => k !== 'host')), authorization, 'accept-encoding': 'identity' },
118
121
  secretHeaders: ['authorization'],
@@ -560,8 +560,10 @@ async function runOneStep(ctx: PageContext, p: Process, names: Set<string>, dept
560
560
  default:
561
561
  await runSql(ctx, p.code ?? '', names);
562
562
  // web requests the process queued (meta.web_request): made now, before the next process
563
+ // (sprint 118) a request an allow-list refused is a warning (level 2), the others trace (level 6)
563
564
  await runPending(ctx.client!, ctx.app.id, (r, res, ms) =>
564
- dbg(ctx, 6, 'web', () => `web request ${r.id} (${r.source ? `source ${r.source}` : r.method}): ${res.status === 'ok' ? `HTTP ${res.statusCode}` : res.message} in ${ms} ms`));
565
+ dbg(ctx, res.status === 'error' && /allow-list|network access list/.test(res.message ?? '') ? 2 : 6, 'web',
566
+ () => `web request ${r.id} (${r.source ? `source ${r.source}` : r.method}): ${res.status === 'ok' ? `HTTP ${res.statusCode}` : res.message} in ${ms} ms`));
565
567
  // AI requests it queued (meta.ai_generate): likewise
566
568
  await runPendingAi(ctx.client!, { appId: ctx.app.id, pageNo: ctx.page.page_no, user: ctx.user, debug: (level, text) => dbg(ctx, level, 'ai', text) });
567
569
  return p.success_message;
@@ -5,7 +5,7 @@ import { pushBody } from './push.ts';
5
5
  import { mapHead } from './maps.ts';
6
6
  import { staticHead } from './static-files.ts';
7
7
  import { pluginAttributes, pluginOf } from './plugins.ts';
8
- import { appStyles, chosenStyle, chosenStyleName, conditionalStyles, styleChoice, themeCss } from './styles.ts';
8
+ import { appStyles, chosenStyle, chosenStyleName, conditionalStyles, navAppearance, styleChoice, themeCss } from './styles.ts';
9
9
  import { html, raw, type Raw } from '../html.ts';
10
10
  import { icon } from '../icons.ts';
11
11
  import { documentShell } from '../layout.ts';
@@ -260,6 +260,8 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
260
260
 
261
261
  const signedIn = ctx.user !== 'nobody';
262
262
  const topNav = ctx.app.theme?.nav === 'top';
263
+ // (sprint 117, APEX 26.2) Pillar Light / Pillar Dark: classes on <body> from a fixed list (styles.ts)
264
+ const appearance = navAppearance(ctx.app.theme, chosenStyle(ctx.app, ctx.session, ctx.styleByCondition));
263
265
  const nav = await navTree(ctx, topNav);
264
266
  const listNodes = ctx.app.navbar_list ? await listTree(ctx, ctx.app.navbar_list) : null;
265
267
  // (084) the Feedback entry, after the application's own navigation bar list
@@ -311,7 +313,7 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
311
313
  <footer class="t-footer">${ctx.app.version ? html`<span class="t-version">${ctx.locale.tr(ctx.app.version)}</span>` : ''}<a class="t-top" href="#top">${icon('arrow-up')} ${t('common.back_to_top')}</a></footer></main>
312
314
  </div>
313
315
  ${devbarHtml(ctx)}`,
314
- `t-app${topNav ? ' nav-top' : ''}`,
316
+ `t-app${topNav ? ' nav-top' : ''}${appearance ? ` nav-pillar nav-${appearance === 'pillar_dark' ? 'pillar-dark' : 'pillar-light'}` : ''}`,
315
317
  { 'data-base': ctx.base, 'data-page': String(ctx.page.page_no), ...pwaBody(ctx.app, ctx.user), ...(await pushBody(ctx.app, ctx.session.username)) },
316
318
  html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`,
317
319
  root,
@@ -69,6 +69,22 @@ export const RADII: Record<string, { label: string; css: string }> = {
69
69
  large: { label: 'Large (14 px)', css: '14px' },
70
70
  };
71
71
 
72
+ /**
73
+ * (sprint 117, APEX 26.2) Navigation appearances: how the header and the
74
+ * navigation menu look, on top of the base style. Pillar Light: the header
75
+ * and menu on the page's own surfaces; Pillar Dark: header and menu as one
76
+ * dark column. Both carry the header pillar strip (theme.strip / a style's
77
+ * strip, else the accent) and a filled "pill" for the current page. The
78
+ * page's <body> gets the classes nav-pillar and nav-pillar-light / -dark.
79
+ */
80
+ export const NAV_APPEARANCES: Record<string, { label: string }> = {
81
+ pillar_light: { label: 'Pillar Light' },
82
+ pillar_dark: { label: 'Pillar Dark' },
83
+ };
84
+ export type NavAppearance = 'pillar_light' | 'pillar_dark';
85
+ /** A style's appearance "default": the base style's own look, whatever the application's appearance. */
86
+ export const APPEARANCE_DEFAULT = 'default';
87
+
72
88
  export interface StyleVariant {
73
89
  name: string;
74
90
  accent?: string;
@@ -76,6 +92,11 @@ export interface StyleVariant {
76
92
  /** (0.29) the colours in dark mode (else dark mode keeps the base style's dark palette) */
77
93
  accent_dark?: string;
78
94
  header_dark?: string;
95
+ /** (sprint 117) the navigation appearance (a key of NAV_APPEARANCES, or 'default'; absent: as the application) */
96
+ appearance?: string;
97
+ /** (sprint 117) the header pillar strip's colour, light and dark mode (absent: the accent) */
98
+ strip?: string;
99
+ strip_dark?: string;
79
100
  font?: string;
80
101
  font_size?: string;
81
102
  radius?: string;
@@ -93,8 +114,11 @@ export function parseStyle(v: Record<string, unknown>): StyleVariant | string {
93
114
  const name = typeof v.name === 'string' ? v.name.trim() : '';
94
115
  if (!STYLE_NAME.test(name)) return 'Name: 1 to 30 letters, digits, spaces, - or _, starting with a letter or digit.';
95
116
  const out: StyleVariant = { name };
96
- const colourLabels = { accent: 'Accent colour', header: 'Header colour', accent_dark: 'Accent colour (dark mode)', header_dark: 'Header colour (dark mode)' };
97
- for (const k of ['accent', 'header', 'accent_dark', 'header_dark'] as const) {
117
+ const colourLabels = {
118
+ accent: 'Accent colour', header: 'Header colour', accent_dark: 'Accent colour (dark mode)', header_dark: 'Header colour (dark mode)',
119
+ strip: 'Header strip colour', strip_dark: 'Header strip colour (dark mode)',
120
+ };
121
+ for (const k of ['accent', 'header', 'accent_dark', 'header_dark', 'strip', 'strip_dark'] as const) {
98
122
  const x = v[k];
99
123
  if (x === undefined || x === null || x === '') continue;
100
124
  if (typeof x === 'string' && ITEM_REF.test(x.trim().toUpperCase())) {
@@ -110,6 +134,11 @@ export function parseStyle(v: Record<string, unknown>): StyleVariant | string {
110
134
  if (!own(list, x)) return `${label}: choose from ${Object.keys(list).join(', ')}.`;
111
135
  out[k] = x as string;
112
136
  }
137
+ const ap = v.appearance;
138
+ if (ap !== undefined && ap !== null && ap !== '') {
139
+ if (ap !== APPEARANCE_DEFAULT && !own(NAV_APPEARANCES, ap)) return `Navigation appearance: choose from ${APPEARANCE_DEFAULT}, ${Object.keys(NAV_APPEARANCES).join(', ')}.`;
140
+ out.appearance = ap as string;
141
+ }
113
142
  if (typeof v.condition === 'string' && v.condition.trim()) {
114
143
  if (v.condition.length > MAX_CONDITION) return `Condition: at most ${MAX_CONDITION} characters.`;
115
144
  out.condition = v.condition.trim();
@@ -162,35 +191,48 @@ export const choosable = (app: Pick<App, 'theme'>, name: unknown): name is strin
162
191
  /** An item's value for &ITEM. colours (session state), when the CSS is made. */
163
192
  export type ItemValue = (name: string) => string | undefined;
164
193
 
165
- const colourVars = (accent?: string, header?: string, item?: ItemValue) => {
194
+ const colourVars = (accent?: unknown, header?: unknown, item?: ItemValue, strip?: unknown) => {
166
195
  // an item reference becomes the item's value, used only when it is a #rrggbb colour
167
- const value = (c?: string) => {
168
- const ref = c ? ITEM_REF.exec(c) : null;
196
+ const value = (c?: unknown) => {
197
+ if (typeof c !== 'string') return undefined;
198
+ const ref = ITEM_REF.exec(c);
169
199
  return ref ? item?.(ref[1])?.trim() : c;
170
200
  };
171
- accent = value(accent);
172
- header = value(header);
201
+ const a = value(accent);
202
+ const h = value(header);
203
+ const st = value(strip);
173
204
  const vars: string[] = [];
174
- if (accent && HEX.test(accent)) vars.push(`--accent:${accent};--accent-soft:color-mix(in srgb, ${accent} 14%, var(--surface))`);
175
- if (header && HEX.test(header)) vars.push(`--header:${header};`);
205
+ if (a && HEX.test(a)) vars.push(`--accent:${a};--accent-soft:color-mix(in srgb, ${a} 14%, var(--surface));`);
206
+ if (h && HEX.test(h)) vars.push(`--header:${h};`);
207
+ // (sprint 117) the header pillar strip
208
+ if (st && HEX.test(st)) vars.push(`--strip:${st};`);
176
209
  return vars;
177
210
  };
178
211
 
212
+ /**
213
+ * (sprint 117) The navigation appearance for this request: the style's own
214
+ * (or 'default' for none), else the application's (theme.appearance).
215
+ */
216
+ export function navAppearance(theme: App['theme'] | undefined, style: StyleVariant | null): NavAppearance | null {
217
+ const v = style?.appearance ?? theme?.appearance;
218
+ return typeof v === 'string' && v !== APPEARANCE_DEFAULT && own(NAV_APPEARANCES, v) ? (v as NavAppearance) : null;
219
+ }
220
+
179
221
  /**
180
222
  * The theme's CSS: the base colours (Settings → Theme), then the style
181
223
  * variant in use. Only checked hex values and constants from the lists above.
182
224
  */
183
225
  export function themeCss(theme: App['theme'] | undefined, style: StyleVariant | null, item?: ItemValue): string {
184
226
  const out: string[] = [];
185
- const base = colourVars(theme?.accent, theme?.header);
227
+ const base = colourVars(theme?.accent, theme?.header, undefined, theme?.strip);
186
228
  // html:root ties with app.css's html[data-style] rules and comes later, so own colours win over a base style's
187
229
  if (base.length) out.push(`html:root{${base.join('')}}`);
188
230
  // dark mode: the same specificity as app.css's dark palettes (html[data-style]…), later in the page
189
- const dark = [...colourVars(theme?.accent_dark, theme?.header_dark), ...(style ? colourVars(style.accent_dark, style.header_dark, item) : [])];
231
+ const dark = [...colourVars(theme?.accent_dark, theme?.header_dark, undefined, theme?.strip_dark), ...(style ? colourVars(style.accent_dark, style.header_dark, item, style.strip_dark) : [])];
190
232
  if (dark.length)
191
233
  out.push(`@media (prefers-color-scheme: dark){html:root:not([data-theme="light"]){${dark.join('')}}}html:root[data-theme="dark"]{${dark.join('')}}`);
192
234
  if (style) {
193
- const colours = colourVars(style.accent, style.header, item);
235
+ const colours = colourVars(style.accent, style.header, item, style.strip);
194
236
  if (colours.length) out.push(`html:root{${colours.join('')}}`);
195
237
  const all: string[] = [];
196
238
  if (style.font && own(FONTS, style.font)) all.push(`--font:${FONTS[style.font].css};`);
package/src/webclient.ts CHANGED
@@ -21,7 +21,12 @@ import zlib from 'node:zlib';
21
21
  // checks, and request headers marked secret are dropped when a redirect
22
22
  // leaves the origin;
23
23
  // - a time limit for the whole exchange and a size limit for the response
24
- // (PGKILN_REST_MAX_BYTES, default 5 MB, also after decompression).
24
+ // (PGKILN_REST_MAX_BYTES, default 5 MB, also after decompression);
25
+ // - (sprint 118) a workspace's network access list (src/network-acl.ts):
26
+ // when the caller passes the scope of the application's workspace, every
27
+ // hop must match that list as well; it only narrows the lists above (the
28
+ // address checks keep using PGKILN_REST_PRIVATE_HOSTS alone). Refusals are
29
+ // reported to the scope (Administration → Network access).
25
30
  //
26
31
  // node:http/https rather than fetch(): fetch can't pin the checked address.
27
32
 
@@ -45,6 +50,21 @@ export interface WebRequest {
45
50
  allowLarge?: boolean;
46
51
  /** an allow-list of its own instead of PGKILN_REST_ALLOWED_HOSTS / PGKILN_REST_PRIVATE_HOSTS (push services) */
47
52
  hosts?: HostLists;
53
+ /** (sprint 118) the workspace the call is made for: its network access list narrows the lists above */
54
+ scope?: OutboundScope | null;
55
+ }
56
+
57
+ /** (sprint 118) The workspace an outgoing call is made for (src/network-acl.ts builds it). */
58
+ export interface OutboundScope {
59
+ workspaceId: number;
60
+ workspaceName: string;
61
+ appId: number | null;
62
+ /** what makes the call: "web request", "REST data source", … */
63
+ what: string;
64
+ /** the workspace's network access list; null = not restricted (the installation's lists only) */
65
+ hosts: string[] | null;
66
+ /** told about every refused URL (never throws; called as a method of the scope) */
67
+ refused?(url: URL, list: 'installation' | 'workspace', message: string): void;
48
68
  }
49
69
 
50
70
  export interface HostLists {
@@ -68,7 +88,7 @@ export const maxResponseBytes = () => {
68
88
  };
69
89
 
70
90
  /** Whether `url`'s host matches an allow-list entry. */
71
- function matches(entries: string[], url: URL) {
91
+ export function matches(entries: string[], url: URL) {
72
92
  const host = url.hostname.toLowerCase();
73
93
  const port = url.port || (url.protocol === 'https:' ? '443' : '80');
74
94
  return entries.some((e) => {
@@ -83,7 +103,7 @@ function matches(entries: string[], url: URL) {
83
103
  });
84
104
  }
85
105
 
86
- const restHosts = (): HostLists => ({ allowed: list(process.env.PGKILN_REST_ALLOWED_HOSTS), private: list(process.env.PGKILN_REST_PRIVATE_HOSTS) });
106
+ export const restHosts = (): HostLists => ({ allowed: list(process.env.PGKILN_REST_ALLOWED_HOSTS), private: list(process.env.PGKILN_REST_PRIVATE_HOSTS) });
87
107
 
88
108
  /** Why the URL may not be called (null: it may, so far as the host name goes). */
89
109
  export function urlProblem(raw: string | URL, hosts: HostLists = restHosts()): string | null {
@@ -103,6 +123,27 @@ export function urlProblem(raw: string | URL, hosts: HostLists = restHosts()): s
103
123
  return null;
104
124
  }
105
125
 
126
+ /** (sprint 118) Why the workspace's network access list refuses the URL (null: it doesn't, or there is none). */
127
+ export function scopeProblem(scope: OutboundScope | null | undefined, url: URL): string | null {
128
+ if (!scope?.hosts) return null;
129
+ if (matches(scope.hosts.filter((h) => h !== '*'), url)) return null;
130
+ return `The host ${url.host} is not on the network access list of workspace ${scope.workspaceName}.`;
131
+ }
132
+
133
+ /** The installation's and the scope's checks of one URL; a refusal is reported to the scope. */
134
+ export function checkUrl(url: URL, req: Pick<WebRequest, 'hosts' | 'scope'>): string | null {
135
+ const own = urlProblem(url, req.hosts);
136
+ const problem = own ?? scopeProblem(req.scope, url);
137
+ if (problem && req.scope?.refused) {
138
+ try {
139
+ req.scope.refused(url, own ? 'installation' : 'workspace', problem);
140
+ } catch {
141
+ // reporting never fails the caller
142
+ }
143
+ }
144
+ return problem;
145
+ }
146
+
106
147
  // ---------------------------------------------------------------- addresses
107
148
 
108
149
  const blocked = new net.BlockList();
@@ -225,7 +266,7 @@ export async function webRequest(raw: string, req: WebRequest = {}): Promise<Web
225
266
  let body = req.body;
226
267
  const origin = url.origin;
227
268
  for (let hop = 0; ; hop++) {
228
- const problem = urlProblem(url, req.hosts);
269
+ const problem = checkUrl(url, req);
229
270
  if (problem) throw new WebError(problem);
230
271
  const res = await once(url, { ...req, method, headers, body }, deadline, maxBytes);
231
272
  const location = res.headers.location;
@@ -2,6 +2,7 @@ import { owner, type Client } from './db.ts';
2
2
  import { purgeUnpacked, unpackForSql } from './unpack.ts';
3
3
  import { call, invoke, loadCredential } from './websources.ts';
4
4
  import { WebError, type WebResponse } from './webclient.ts';
5
+ import { purgeRefusals } from './network-acl.ts';
5
6
 
6
7
  // Web requests from SQL (APEX_WEB_SERVICE; migration 052): application SQL
7
8
  // queues a request with meta.web_request(url, …) or
@@ -77,6 +78,8 @@ export async function execute(appId: number, r: PendingRequest): Promise<Request
77
78
  );
78
79
  const credential = r.credential ? await loadCredential(appId, r.credential) : null;
79
80
  res = await call({
81
+ appId,
82
+ what: 'meta.web_request',
80
83
  url: r.url,
81
84
  method: r.method,
82
85
  headers: { accept: '*/*', ...headers },
@@ -138,6 +141,8 @@ export async function purgeWebRequests(force = false) {
138
141
  where status = 'queued' and requested_at < now() - interval '${KEEP_HOURS} hours'`,
139
142
  );
140
143
  await owner.query(`delete from meta.web_request_log where finished_at < now() - interval '${KEEP_HOURS} hours'`);
144
+ // (sprint 118) refusals of the network access lists: 7 days
145
+ await purgeRefusals();
141
146
  await owner.query(
142
147
  `delete from meta.web_request_log l using (
143
148
  select id, row_number() over (partition by app_id order by id desc) as n from meta.web_request_log where status in ('ok', 'error')
package/src/websources.ts CHANGED
@@ -3,7 +3,8 @@ import pg from 'pg';
3
3
  import { owner, runtime } from './db.ts';
4
4
  import { runtimeForApp } from './workspace-db.ts';
5
5
  import { decryptSecret, encryptSecret } from './secrets.ts';
6
- import { WebError, webRequest, type WebResponse } from './webclient.ts';
6
+ import { checkUrl, WebError, webRequest, type OutboundScope, type WebResponse } from './webclient.ts';
7
+ import { scopeForApp } from './network-acl.ts';
7
8
 
8
9
  // Web credentials and REST data sources (meta.web_credential,
9
10
  // meta.rest_source): building the request (parameters, credential), OAuth2
@@ -466,9 +467,11 @@ export function tokenRequest(c: WebCredential, refresh: string | null) {
466
467
  return { headers, body: form.toString() };
467
468
  }
468
469
 
469
- async function requestToken(c: WebCredential, refresh: string | null, timeoutMs: number): Promise<Token> {
470
+ async function requestToken(c: WebCredential, refresh: string | null, timeoutMs: number, scope: OutboundScope | null): Promise<Token> {
470
471
  const { headers, body } = tokenRequest(c, refresh);
471
472
  const res = await webRequest(c.token_url!, {
473
+ // (sprint 118) the token endpoint is on the workspace's network access list too
474
+ scope: scope ? { ...scope, what: `token endpoint of web credential ${c.name}` } : null,
472
475
  method: 'POST',
473
476
  headers,
474
477
  secretHeaders: ['authorization'],
@@ -515,7 +518,7 @@ function storedRefresh(c: WebCredential) {
515
518
  }
516
519
  }
517
520
 
518
- async function oauthToken(c: WebCredential, timeoutMs: number, fresh = false): Promise<string> {
521
+ async function oauthToken(c: WebCredential, timeoutMs: number, scope: OutboundScope | null, fresh = false): Promise<string> {
519
522
  const key = `${c.id}:${credKey(c)}`;
520
523
  const cached = tokens.get(key);
521
524
  if (cached && !fresh && cached.expires > Date.now()) return cached.access;
@@ -525,14 +528,14 @@ async function oauthToken(c: WebCredential, timeoutMs: number, fresh = false): P
525
528
  const refresh = cached?.refresh ?? storedRefresh(c);
526
529
  if (refresh) {
527
530
  try {
528
- return await requestToken(c, refresh, timeoutMs);
531
+ return await requestToken(c, refresh, timeoutMs, scope);
529
532
  } catch (e) {
530
533
  // the refresh token may have expired: ask again with the grant (a refresh token grant has nothing else)
531
534
  if (grantOf(c) === 'refresh_token')
532
535
  throw new WebError(`${(e as Error).message} The refresh token of web credential ${c.name} may have expired: enter a new one in the builder.`, (e as WebError).status);
533
536
  }
534
537
  }
535
- return requestToken(c, null, timeoutMs);
538
+ return requestToken(c, null, timeoutMs, scope);
536
539
  })();
537
540
  pendingTokens.set(key, p);
538
541
  }
@@ -546,7 +549,7 @@ async function oauthToken(c: WebCredential, timeoutMs: number, fresh = false): P
546
549
  }
547
550
 
548
551
  /** Headers that sign a request with the credential; the names in secret are secrets. */
549
- async function credentialHeaders(c: WebCredential, url: string, timeoutMs: number, fresh = false) {
552
+ async function credentialHeaders(c: WebCredential, url: string, timeoutMs: number, scope: OutboundScope | null, fresh = false) {
550
553
  if (!credentialValidFor(c, url)) throw new WebError(`Web credential ${c.name} is not valid for this URL (see its "Valid for" URLs).`);
551
554
  switch (c.type) {
552
555
  case 'basic':
@@ -559,7 +562,7 @@ async function credentialHeaders(c: WebCredential, url: string, timeoutMs: numbe
559
562
  return { [c.header_name!.toLowerCase()]: value };
560
563
  }
561
564
  case 'oauth2':
562
- return { authorization: `Bearer ${await oauthToken(c, timeoutMs, fresh)}` };
565
+ return { authorization: `Bearer ${await oauthToken(c, timeoutMs, scope, fresh)}` };
563
566
  case 'aws_sigv4':
564
567
  throw new WebError(`Web credential ${c.name} (aws_sigv4) signs object storage requests of file items only.`);
565
568
  default:
@@ -570,6 +573,12 @@ async function credentialHeaders(c: WebCredential, url: string, timeoutMs: numbe
570
573
  // ---------------------------------------------------------------- calling
571
574
 
572
575
  export interface CallOptions {
576
+ /** (sprint 118) the application the call is made for: its workspace's network access list applies (null: none) */
577
+ appId: number | null;
578
+ /** the scope when the caller has it already (else built from appId) */
579
+ scope?: OutboundScope | null;
580
+ /** what makes the call, for refusals (default "web request") */
581
+ what?: string;
573
582
  url: string;
574
583
  method?: string;
575
584
  headers?: Record<string, string>;
@@ -583,9 +592,14 @@ export interface CallOptions {
583
592
  /** One request, signed with the credential; an OAuth2 token refused with 401 is renewed once. */
584
593
  export async function call(o: CallOptions): Promise<WebResponse> {
585
594
  const timeoutMs = o.timeoutMs ?? 10_000;
595
+ const scope = o.scope !== undefined ? o.scope : await scopeForApp(o.appId, o.what ?? 'web request');
596
+ // refused before a credential's token is asked for
597
+ const problem = checkUrl(new URL(o.url), { scope });
598
+ if (problem) throw new WebError(problem);
586
599
  const send = async (fresh: boolean) => {
587
- const auth: Record<string, string> = o.credential ? await credentialHeaders(o.credential, o.url, timeoutMs, fresh) : {};
600
+ const auth: Record<string, string> = o.credential ? await credentialHeaders(o.credential, o.url, timeoutMs, scope, fresh) : {};
588
601
  return webRequest(o.url, {
602
+ scope,
589
603
  method: o.method ?? 'GET',
590
604
  headers: { accept: 'application/json', ...o.headers, ...auth },
591
605
  secretHeaders: [...Object.keys(auth), ...(o.secretHeaders ?? [])],
@@ -748,7 +762,7 @@ export function buildOperation(s: RestSource, name: OperationName, row: Record<s
748
762
  export async function callOperation(s: RestSource, name: OperationName, row: Record<string, unknown>, params: Record<string, string> = {}) {
749
763
  const req = buildOperation(s, name, row, params);
750
764
  const credential = s.credential ? await loadCredential(s.app_id, s.credential) : null;
751
- const res = await call({ ...req, credential, timeoutMs: s.timeout_s * 1000 });
765
+ const res = await call({ ...req, appId: s.app_id, what: `REST data source ${s.name}`, credential, timeoutMs: s.timeout_s * 1000 });
752
766
  if (name === 'fetch' && res.status === 404) return { json: null, row: null, status: 404 };
753
767
  if (res.status < 200 || res.status > 299) throw new WebError(`REST data source ${s.name}: the ${name} operation was answered with ${res.status}.`, res.status);
754
768
  const json = parseJson(res, `REST data source ${s.name}`);
@@ -778,6 +792,10 @@ export function clearResponseCache(sourceId?: number) {
778
792
  /** The parsed JSON response of a source (from the cache when it is fresh enough). */
779
793
  export async function fetchSource(s: RestSource, values: Record<string, string | null | undefined>) {
780
794
  const req = buildRequest(s, values);
795
+ // (sprint 118) the workspace's network access list, before the cache: a narrowed list applies at once
796
+ const scope = await scopeForApp(s.app_id, `REST data source ${s.name}`);
797
+ const problem = checkUrl(new URL(req.url), { scope });
798
+ if (problem) throw new WebError(problem);
781
799
  const credential = s.credential ? await loadCredential(s.app_id, s.credential) : null;
782
800
  const key = s.cache_seconds > 0
783
801
  ? `${s.id}:${createHash('sha256').update(JSON.stringify([s, req, credential ? credKey(credential) : null])).digest('base64url')}`
@@ -786,7 +804,7 @@ export async function fetchSource(s: RestSource, values: Record<string, string |
786
804
  const hit = responses.get(key);
787
805
  if (hit && Date.now() - hit.at < s.cache_seconds * 1000) return { json: hit.json, status: 200, cached: true };
788
806
  }
789
- const res = await call({ ...req, credential, timeoutMs: s.timeout_s * 1000 });
807
+ const res = await call({ ...req, appId: s.app_id, scope, credential, timeoutMs: s.timeout_s * 1000 });
790
808
  if (res.status < 200 || res.status > 299) throw new WebError(`REST data source ${s.name}: the web service answered ${res.status}.`, res.status);
791
809
  const json = parseJson(res, `REST data source ${s.name}`);
792
810
  if (key) {
@@ -878,7 +896,7 @@ export async function invoke(appId: number, conf: InvokeConfig, lookup: Lookup,
878
896
  const source = await loadSource(appId, conf.source);
879
897
  const req = buildRequest(source, sourceParamValues(source, conf.params, lookup));
880
898
  const credential = source.credential ? await loadCredential(appId, source.credential) : null;
881
- return { res: await call({ ...req, credential, timeoutMs: (timeoutS ?? source.timeout_s) * 1000 }), source };
899
+ return { res: await call({ ...req, appId, what: `${what} (REST data source ${source.name})`, credential, timeoutMs: (timeoutS ?? source.timeout_s) * 1000 }), source };
882
900
  }
883
901
  // values are URL-encoded and only follow the host (invokeCallProblems), so the host is always the developer's
884
902
  const url = conf.url!.replace(SUBST, (m, name: string) => {
@@ -893,7 +911,7 @@ export async function invoke(appId: number, conf: InvokeConfig, lookup: Lookup,
893
911
  return JSON.stringify(v ?? m);
894
912
  }) : undefined;
895
913
  const credential = conf.credential ? await loadCredential(appId, conf.credential) : null;
896
- const res = await call({ url, method, headers: body ? { 'content-type': 'application/json' } : {}, body, credential, timeoutMs: (timeoutS ?? 10) * 1000 });
914
+ const res = await call({ appId, what, url, method, headers: body ? { 'content-type': 'application/json' } : {}, body, credential, timeoutMs: (timeoutS ?? 10) * 1000 });
897
915
  return { res, source: null };
898
916
  }
899
917