pgkiln 0.65.0 → 0.67.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.
@@ -1,5 +1,7 @@
1
1
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import { html } from '../html.ts';
3
+ import { attention, confirmKeyBackup, forgetAttention, instanceChecks, type Level } from '../instance-checks.ts';
4
+ import { relyingParty } from '../webauthn.ts';
3
5
  import { configurationValue, CONFIGURATION, INSTANCE_SETTINGS, instanceSetting, origin, refreshInstanceSettings, saveInstanceSettings } from '../instance.ts';
4
6
  import { clientIp, logActivity, type Session } from '../session.ts';
5
7
  import { isAdmin } from './locks.ts';
@@ -20,6 +22,20 @@ async function adminOnly(s: Session, reply: FastifyReply) {
20
22
  return false;
21
23
  }
22
24
 
25
+ const LEVEL_TEXT: Record<Level, string> = { ok: 'OK', info: 'Note', warning: 'Warning', error: 'Error' };
26
+
27
+ /** (sprint 133, SEC-6) Lockout checks: what could keep people out later (src/instance-checks.ts). */
28
+ async function checksRegion(s: Session, rpId: string) {
29
+ const checks = await instanceChecks(rpId);
30
+ const n = attention(checks);
31
+ return region('Lockout checks', html`<p class="muted u-mt0">${n ? `${n} of these need attention.` : 'Nothing needs attention.'}
32
+ The same checks run on the server with <code>pgkiln admin check</code>; <code>--url</code> shows which passkeys a new address would break.</p>
33
+ <ul class="hits findings">${checks.map((c) => html`<li class="finding-${c.level}"><strong>${c.title}</strong><span></span>
34
+ <span class="tag tag-${c.level}">${LEVEL_TEXT[c.level]}</span><span class="hit-snippet">${c.detail}</span>
35
+ ${c.key === 'key_backup' && c.level === 'warning' ? html`<form method="post" action="${BASE}/instance/key-backup" class="u-mt0">${csrf(s)}
36
+ <button class="btn btn-sm">I have a copy of this key</button></form>` : ''}</li>`)}</ul>`, '', 'lockout-checks');
37
+ }
38
+
23
39
  const ORIGIN_TEXT = { builder: 'set here', environment: 'environment variable', default: 'default' } as const;
24
40
 
25
41
  export async function instanceRoutes(app: FastifyInstance) {
@@ -39,6 +55,7 @@ export async function instanceRoutes(app: FastifyInstance) {
39
55
  <small class="help">${x.help} In effect: <b>${instanceSetting(x.key)}</b> (${ORIGIN_TEXT[from]}; <code>${x.env}</code>, default ${x.default}).</small></div>`;
40
56
  })}</div>
41
57
  <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`)}
58
+ ${await checksRegion(s, relyingParty(req).rpId)}
42
59
  ${await releaseCheckRegion(s)}
43
60
  ${await policyRegion(s)}
44
61
  ${region('Configuration', html`<p class="muted u-mt0">Environment variables of this server (chapter 1 of the guide). Secrets show only whether they are set.</p>
@@ -51,6 +68,16 @@ export async function instanceRoutes(app: FastifyInstance) {
51
68
  return send(reply, s, shell(s, 'Instance settings', crumbs, main));
52
69
  });
53
70
 
71
+ app.post(`${BASE}/instance/key-backup`, async (req: Req, reply) => {
72
+ const s = await developer(req, reply);
73
+ if (!s || !(await adminOnly(s, reply))) return;
74
+ const ok = await confirmKeyBackup(s.username!);
75
+ forgetAttention();
76
+ if (ok) await logActivity({ username: s.username, event: 'secret_key', ip: clientIp(req), detail: 'backup confirmed' });
77
+ flash(s, ok ? 'Backup of the instance key confirmed.' : 'PGKILN_SECRET_KEY is not set.', ok ? 'ok' : 'error');
78
+ return back(reply, s, `${BASE}/instance#lockout-checks`);
79
+ });
80
+
54
81
  app.post(`${BASE}/instance`, async (req: Req, reply) => {
55
82
  const s = await developer(req, reply);
56
83
  if (!s || !(await adminOnly(s, reply))) return;
@@ -23,7 +23,7 @@ interface AppRow {
23
23
  mfa_email: boolean;
24
24
  }
25
25
 
26
- /** The fields in the settings form. */
26
+ /** The fields in the settings form: the sign-in page, and two-factor sign-in (each placed by the settings page). */
27
27
  export async function loginSettingsFields(a: AppRow) {
28
28
  const pages = (await owner.query<{ page_no: number; name: string; requires_auth: boolean }>(
29
29
  'select page_no, name, requires_auth from meta.page where app_id = $1 order by page_no', [a.id],
@@ -33,35 +33,33 @@ export async function loginSettingsFields(a: AppRow) {
33
33
  from meta.app_mail_service ams join meta.mail_service s on s.id = ams.service_id where ams.app_id = $1`, [a.id],
34
34
  );
35
35
  const mailOk = !!(mail?.enabled && mail.from_address?.trim());
36
+ const noMail = html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`;
36
37
  const opt = (v: string, label: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${label}</option>`;
37
38
  const current = a.login_page ? String(a.login_page) : '';
38
- return html`<div class="form-grid">
39
+ const page = html`<div class="form-grid">
39
40
  <div class="field"><label class="label" for="f_login_page">Login page</label>
40
41
  <select id="f_login_page" name="login_page">${opt('', '- the built-in sign-in page -', current)}${pages.map((p) =>
41
42
  opt(String(p.page_no), `${p.page_no} ${p.name}${p.requires_auth ? ' (requires sign-in: not usable)' : ''}`, current))}</select>
42
- <small class="help">A page of the application with a <em>Login</em> region replaces the built-in sign-in page (APEX: the login page). It must not require authentication; with page template <em>Minimal</em> it has no header and navigation.
43
+ <small class="help">A public page with a <em>Login</em> region (APEX: the login page); page template <em>Minimal</em> hides the header and navigation.
43
44
  <button class="link-button" formaction="${BASE}/apps/${a.id}/login-page">Create a login page</button></small></div>
44
- <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
45
- <small class="help">Users with a password and an e-mail address in the user directory get a link to choose a new password (single-use, valid for 30 minutes).
46
- ${mailOk
47
- ? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.`
48
- : html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address (Workspace utilities → Mail services).`}
49
- Only for the user directory's own passwords: not for LDAP, single sign-on, database accounts or custom authentication.</small></div>
50
45
  </div>
51
- <h3 id="two-factor">Two-factor sign-in</h3>
52
- <div class="form-grid">
46
+ <div class="field"><label class="check"><input type="checkbox" name="password_reset" value="true"${a.password_reset ? raw(' checked') : ''}> Offer "Forgot password?"</label>
47
+ <small class="help">Users with a password and an e-mail address in the user directory get a single-use link, valid for 30 minutes, to choose a new password. Only for the user directory's own passwords.
48
+ ${mailOk ? html`Sent through mail service ${mail!.service} as ${mail!.from_address}.` : noMail}</small></div>`;
49
+ const twoFactor = html`<div class="form-grid">
53
50
  <div class="field"><label class="label" for="f_mfa">Second step</label>
54
51
  <select id="f_mfa" name="mfa" aria-describedby="f_mfa_help">${(APP_MFA as string[]).map((m) => opt(m, MFA_LABELS[m as AppMfa], a.mfa))}</select>
55
- <small class="help" id="f_mfa_help">After the password (or an LDAP directory), users give a code from an authenticator app or a recovery code. They add the app on <em>My account</em>, or while signing in when it is required. An identity provider asks for its own second factor. Accounts share their apps across applications.
52
+ <small class="help" id="f_mfa_help">After the password or an LDAP directory: a code from an authenticator app, a passkey or a recovery code, added on <em>My account</em> or while signing in.
56
53
  ${secretKeyConfigured() ? '' : html`<strong>Not available yet:</strong> the server needs <code>PGKILN_SECRET_KEY</code> to keep the apps' secrets.`}</small></div>
57
54
  <div class="field"><label class="label" for="f_mfa_roles">Required for roles</label>
58
55
  <input id="f_mfa_roles" name="mfa_roles" value="${(a.mfa_roles ?? []).join(', ')}" placeholder="admin, manager" aria-describedby="f_mfa_roles_help">
59
56
  <small class="help" id="f_mfa_roles_help">With <em>Required for some roles</em>: users with one of these roles must use it, others may.</small></div>
60
- <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="mfa_email" value="true"${a.mfa_email ? raw(' checked') : ''}> Offer a code by e-mail</label>
61
- <small class="help">At the second step, users who have an authenticator app or a passkey may ask for a one-time code by e-mail instead (valid ${EMAIL_CODE_MINUTES} minutes, at most ${EMAIL_CODES_PER_HOUR} an hour). It is never someone's only factor: a user who must add one still adds an app or a passkey.
62
- ${mailOk ? '' : html`<strong>Not offered yet:</strong> the application needs an enabled mail service with a From address.`}</small></div>
63
57
  </div>
64
- <p class="muted small">Users also add <strong>passkeys</strong> (fingerprint, face, PIN or a security key) on <em>My account</em> or while signing in; they need the application at a host name, not an IP address. To ask for the second step again before a page opens (step-up), give its authorization scheme <em>Second step within (minutes)</em> in Shared Components.</p>`;
58
+ <div class="field"><label class="check"><input type="checkbox" name="mfa_email" value="true"${a.mfa_email ? raw(' checked') : ''}> Offer a code by e-mail</label>
59
+ <small class="help">Users who already have an app or a passkey may get a one-time code by e-mail instead (valid ${EMAIL_CODE_MINUTES} minutes, at most ${EMAIL_CODES_PER_HOUR} an hour); never as their only factor.
60
+ ${mailOk ? '' : noMail}</small></div>
61
+ <p class="muted small">Passkeys need the application at a host name, not an IP address. Identity providers ask for their own second factor (see the provider's <em>Multi-factor sign-in</em>). To ask again before a page opens (step-up), give its authorization scheme <em>Second step within (minutes)</em> in Shared Components.</p>`;
62
+ return { page, twoFactor };
65
63
  }
66
64
 
67
65
  const MFA_LABELS: Record<AppMfa, string> = { off: 'Off', optional: 'Optional (users may add an app)', required: 'Required', roles: 'Required for some roles' };
@@ -264,7 +264,16 @@ export async function builderRoutes(app: FastifyInstance) {
264
264
  const username = await resolveDeveloper(src, { ...result.identity, username: result.username, groups: result.groups });
265
265
  const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [username]);
266
266
  if (!dev) throw new SsoError('The developer was removed.');
267
- return finishSignIn(req, reply, null, dev, { ws: null, workspace: '', next: safeNext(result.next), remember: false, weak: false, picked: null, detail: `builder sso:${p.name}`, sso: p.name });
267
+ // (sprint 132, SEC-4) the provider leaves the second step to pgkiln: as after a password (the trusted-browser
268
+ // cookie belongs to /builder and doesn't reach this callback, so the step is always asked); a developer without a
269
+ // factor signs in without __SSO, so a 2FA rule makes them add one (ui.ts)
270
+ if (p.local_second_step && (await hasFactor(dev.username))) {
271
+ const ns = await createSession(reply, null, BASE);
272
+ ns.state.__MFA = JSON.stringify({ u: dev.username, ws: null, w: '', next: safeNext(result.next), remember: false, weak: false, at: Date.now() });
273
+ await saveState(ns);
274
+ return reply.redirect(`${BASE}/login/2fa`, 303);
275
+ }
276
+ return finishSignIn(req, reply, null, dev, { ws: null, workspace: '', next: safeNext(result.next), remember: false, weak: false, picked: null, detail: `builder sso:${p.name}${result.mfa ? ' (mfa)' : ''}`, sso: p.local_second_step ? undefined : p.name });
268
277
  });
269
278
 
270
279
  // (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
@@ -762,6 +771,7 @@ export async function builderRoutes(app: FastifyInstance) {
762
771
  if (!a) return reply.code(404).send('Not found');
763
772
  const providers = (await owner.query('select name, display_name, enabled from meta.auth_provider order by display_name')).rows;
764
773
  const directories = (await owner.query('select name, display_name, enabled from meta.ldap_directory order by display_name')).rows;
774
+ const signinFields = await loginSettingsFields(a);
765
775
  const lists = (await owner.query('select name from meta.list where app_id = $1 order by name', [a.id])).rows.map((r) => r.name as string);
766
776
  const listChoices = (v: string | null, none: string): [string, string][] => [['', none], ...lists.map((n): [string, string] => [n, n]), ...(v && !lists.includes(v) ? [[v, tx('st.missing', { name: v })] as [string, string]] : [])];
767
777
  // (sprint 74) tabs like APEX's Edit Application Definition and Security Attributes: every tab of the
@@ -822,15 +832,21 @@ export async function builderRoutes(app: FastifyInstance) {
822
832
  <h3>${tx('st.methods')}</h3>
823
833
  <div class="field"><label class="check"><input type="checkbox" name="local_login" value="true"${a.local_login ? raw(' checked') : ''}> ${tx('st.local')}</label></div>
824
834
  ${directories.length
825
- ? directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> ${tx('st.ldap', { name: d.display_name })}${d.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
826
- : html`<p class="muted">${tx.rich('st.no_ldap', { add: html`<a href="${BASE}/users/directories">${tx('st.add_one')}</a>` })}</p>`}
827
- <small class="help">${tx('st.ldap_help')}</small>
828
- <div class="form-grid">${input('session_group', tx('st.session_group'), a.session_group ?? '', { placeholder: tx('st.session_group_ph'), help: tx('st.session_group_help') })}</div>
829
- ${await loginSettingsFields(a)}
830
- <div class="form-grid">${input('remember_me_days', tx('st.remember'), a.remember_me_days ?? '', { type: 'number', placeholder: tx('st.remember_ph'), help: tx('st.remember_help') })}</div>
835
+ ? html`${directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> ${tx('st.ldap', { name: d.display_name })}${d.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)}
836
+ <small class="help">${tx('st.ldap_help')}</small>`
837
+ : html`<p class="muted small">${tx.rich('st.no_ldap', { add: html`<a href="${BASE}/users/directories">${tx('st.add_one')}</a>` })}</p>`}
831
838
  ${providers.length
832
839
  ? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> ${tx('st.sso', { name: pr.display_name })}${pr.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
833
- : html`<p class="muted">${tx.rich('st.no_sso', { add: html`<a href="${BASE}/users/providers">${tx('st.add_one')}</a>` })}</p>`}
840
+ : html`<p class="muted small">${tx.rich('st.no_sso', { add: html`<a href="${BASE}/users/providers">${tx('st.add_one')}</a>` })}</p>`}
841
+ <h3>${tx('st.signin_page')}</h3>
842
+ ${signinFields.page}
843
+ <h3>${tx('st.staying')}</h3>
844
+ <div class="form-grid">
845
+ ${input('remember_me_days', tx('st.remember'), a.remember_me_days ?? '', { type: 'number', placeholder: tx('st.remember_ph'), help: tx('st.remember_help') })}
846
+ ${input('session_group', tx('st.session_group'), a.session_group ?? '', { placeholder: tx('st.session_group_ph'), help: tx('st.session_group_help') })}
847
+ </div>
848
+ <h3 id="two-factor">${tx('st.two_factor')}</h3>
849
+ ${signinFields.twoFactor}
834
850
  `)}${end}
835
851
  ${panel('ui')}${region(tx('st.tab.ui'), html`
836
852
  <h3>${tx('st.theme')}</h3>
@@ -398,6 +398,9 @@ export async function usersRoutes(app: FastifyInstance) {
398
398
  });
399
399
 
400
400
  // ---------------------------------------------------------------- identity providers (OpenID Connect)
401
+ /** (134) the values a new provider counts as a second factor: amr "mfa" (Entra ID, Okta), Entra ID's and SAML's own */
402
+ const DEFAULT_MFA_VALUES = ['mfa', 'http://schemas.microsoft.com/claims/multipleauthn',
403
+ 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract', 'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorUnregistered'];
401
404
  const providerForm = (pr: any, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean, tx: BuilderTranslate) => html`
402
405
  <form method="post" action="${action}">${csrfField}
403
406
  <div class="form-grid">
@@ -419,6 +422,16 @@ export async function usersRoutes(app: FastifyInstance) {
419
422
  <small class="help">${tx('idp.auto_create_help')}</small></div>
420
423
  <div class="field"><label class="check"><input type="checkbox" name="link_existing" value="true"${pr.link_existing ? raw(' checked') : ''}> ${tx('idp.link')}</label>
421
424
  <small class="help">${tx('idp.link_help')}</small></div>
425
+ <fieldset class="prop-group u-mt075"><legend>${tx('idp.mfa_title')}</legend>
426
+ <div class="field"><label class="check"><input type="checkbox" name="mfa_required" value="true"${pr.mfa_required ? raw(' checked') : ''}> ${tx('idp.mfa_required')}</label>
427
+ <small class="help">${tx('idp.mfa_required_help')}</small></div>
428
+ <div class="form-grid">
429
+ ${input('mfa_request', tx('idp.mfa_request'), pr.mfa_request ?? '', { help: tx('idp.mfa_request_help') })}
430
+ ${input('mfa_values', tx('idp.mfa_values'), (pr.mfa_values ?? DEFAULT_MFA_VALUES).join(' '), { help: tx('idp.mfa_values_help') })}
431
+ </div>
432
+ <div class="field"><label class="check"><input type="checkbox" name="local_second_step" value="true"${pr.local_second_step ? raw(' checked') : ''}> ${tx('idp.local_step')}</label>
433
+ <small class="help">${tx('idp.local_step_help')}</small></div>
434
+ </fieldset>
422
435
  <div class="field"><label class="check"><input type="checkbox" name="enabled" value="true"${pr.enabled !== false ? raw(' checked') : ''}> ${tx('idp.enabled')}</label></div>
423
436
  ${!isNew && pr.has_secret ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_secret" value="true"> ${tx('idp.remove_secret')}</label></div>` : ''}
424
437
  <div class="buttons"><button class="btn btn-hot">${isNew ? tx('idp.add') : tx('us.save')}</button></div>
@@ -439,13 +452,17 @@ export async function usersRoutes(app: FastifyInstance) {
439
452
  b.auto_create === 'true', b.enabled === 'true',
440
453
  protocol === 'saml' ? b.idp_sso_url?.trim() || null : null, protocol === 'saml' ? pem(b.idp_cert) : null,
441
454
  b.link_existing === 'true',
455
+ // (sprint 132, SEC-4) multi-factor sign-in at the provider
456
+ b.mfa_required === 'true', b.mfa_request?.trim().replace(/\s+/g, ' ').slice(0, 500) || null,
457
+ words(b.mfa_values).length ? words(b.mfa_values) : DEFAULT_MFA_VALUES, b.local_second_step === 'true',
442
458
  ];
459
+ const words = (v: string | undefined) => [...new Set((v ?? '').split(/\s+/).filter(Boolean))].slice(0, 20);
443
460
 
444
461
  app.get(`${BASE}/users/providers`, async (req: Req, reply) => {
445
462
  const s = await administrator(req, reply, 'Identity providers');
446
463
  if (!s) return;
447
464
  const tx = tr(s);
448
- const rows = (await owner.query(`select id, name, display_name, issuer, enabled, auto_create, link_existing,
465
+ const rows = (await owner.query(`select id, name, display_name, issuer, enabled, auto_create, link_existing, mfa_required,
449
466
  (select count(*) from meta.account_identity i where i.provider_id = p.id)::int as linked,
450
467
  (select string_agg(a.alias, ', ' order by a.alias) from meta.app a where p.name = any(a.sso_providers)) as apps
451
468
  from meta.auth_provider p order by display_name`)).rows;
@@ -460,7 +477,7 @@ export async function usersRoutes(app: FastifyInstance) {
460
477
  <td data-label="${tx('idp.issuer_col')}">${r.issuer}</td>
461
478
  <td data-label="${tx('idp.used_by')}">${r.apps ?? html`<span class="muted">${tx('idp.no_apps')}</span>`}</td>
462
479
  <td data-label="${tx('idp.linked')}">${r.linked}</td>
463
- <td data-label="${tx('us.status')}">${r.enabled ? tx('idp.enabled_s') : html`<b>${tx('idp.disabled_s')}</b>`}${r.auto_create ? ` · ${tx('idp.auto_s')}` : ''}${r.link_existing ? ` · ${tx('idp.links_s')}` : ''}</td>
480
+ <td data-label="${tx('us.status')}">${r.enabled ? tx('idp.enabled_s') : html`<b>${tx('idp.disabled_s')}</b>`}${r.auto_create ? ` · ${tx('idp.auto_s')}` : ''}${r.link_existing ? ` · ${tx('idp.links_s')}` : ''}${r.mfa_required ? ` · ${tx('idp.mfa_s')}` : ''}</td>
464
481
  </tr>`) : html`<tr><td colspan="5" class="empty">${tx('idp.none')}</td></tr>`}</tbody></table></div>`)}
465
482
  ${region(tx('idp.add'), providerForm({}, `${BASE}/users/providers`, csrf(s), true, tx))}
466
483
  </div>`;
@@ -473,8 +490,9 @@ export async function usersRoutes(app: FastifyInstance) {
473
490
  const b = req.body ?? {};
474
491
  try {
475
492
  const r = await owner.one(
476
- `insert into meta.auth_provider (name, display_name, issuer, client_id, scopes, username_claim, groups_claim, auto_create, enabled, idp_sso_url, idp_cert, link_existing, protocol, client_secret)
477
- values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) returning id`,
493
+ `insert into meta.auth_provider (name, display_name, issuer, client_id, scopes, username_claim, groups_claim, auto_create, enabled, idp_sso_url, idp_cert, link_existing,
494
+ mfa_required, mfa_request, mfa_values, local_second_step, protocol, client_secret)
495
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) returning id`,
478
496
  [b.name?.trim().toLowerCase(), ...providerValues(b, protocolOf(b.protocol), b.name?.trim().toLowerCase() ?? ''), protocolOf(b.protocol), b.client_secret || null],
479
497
  );
480
498
  flash(s, tr(s)('idp.added'));
@@ -523,7 +541,8 @@ export async function usersRoutes(app: FastifyInstance) {
523
541
  await owner.query(
524
542
  `update meta.auth_provider set display_name = $2, issuer = $3, client_id = $4, scopes = $5, username_claim = $6,
525
543
  groups_claim = $7, auto_create = $8, enabled = $9, idp_sso_url = $10, idp_cert = $11, link_existing = $12,
526
- client_secret = case when $14 then null when $13::text is null then client_secret else $13 end
544
+ mfa_required = $13, mfa_request = $14, mfa_values = $15, local_second_step = $16,
545
+ client_secret = case when $18 then null when $17::text is null then client_secret else $17 end
527
546
  where id = $1`,
528
547
  [req.params.id, ...providerValues(b, current.protocol, current.name), b.client_secret || null, b.remove_secret === 'true'],
529
548
  );
@@ -9,6 +9,7 @@ import { isAdmin } from './locks.ts';
9
9
  import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
10
10
  import { currentWorkspace, inWorkspaceSql, workspaceAdmin } from './workspaces.ts';
11
11
  import { teamHomeBlock } from './team.ts';
12
+ import { attentionCount } from '../instance-checks.ts';
12
13
  import { tr } from './i18n.ts';
13
14
 
14
15
  // (sprint 109, WS-1) The Workspace home (APEX: the workspace home page), the page the builder opens after
@@ -112,7 +113,12 @@ async function systemMessage(s: Session, admin: boolean) {
112
113
  ? html`<button class="btn btn-sm" name="check" value="now">${icon('refresh')} ${tx('wh.check_now')}</button><button class="btn btn-sm" name="check" value="off">${tx('wh.turn_off')}</button>`
113
114
  : html`<button class="btn btn-sm" name="check" value="on">${tx('wh.check_on')}</button>`}</div></form>`
114
115
  : '';
115
- return region(tx('wh.system'), html`${body}${admins}`, '', 'system-message');
116
+ // (sprint 133, SEC-6) administrators hear about lockout risks here, not only on Instance settings
117
+ const lockout = admin ? await attentionCount() : 0;
118
+ const checks = lockout
119
+ ? html`<p class="alert alert-warning" role="status">${icon('alert')} ${tx('wh.lockout', { count: lockout })} <a href="${BASE}/instance#lockout-checks">${tx('wh.lockout_open')}</a></p>`
120
+ : '';
121
+ return region(tx('wh.system'), html`${checks}${body}${admins}`, '', 'system-message');
116
122
  }
117
123
 
118
124
  /** (Instance settings page) the release check: on, off, or as the environment says. */
package/src/cli/main.ts CHANGED
@@ -471,24 +471,35 @@ const COMMANDS: Record<string, Command> = {
471
471
  },
472
472
  // (sprint 126, SEC-6) lockout recovery on the server, when nobody can sign in to the builder
473
473
  admin: {
474
- usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all | builder-sign-in password [options]',
475
- summary: 'lockout recovery: reset a developer\'s two-factor sign-in, set the two-factor policy, allow the password again',
474
+ usage: 'pgkiln admin reset-2fa <developer> | set-2fa-policy off|admins|all | builder-sign-in password | rotate-key | key-backed-up | check [options]',
475
+ summary: 'lockout recovery and checks: two-factor reset and policy, the builder password, the instance key',
476
476
  details:
477
477
  'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
478
478
  'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n' +
479
- 'pgkiln admin builder-sign-in password\n\n' +
479
+ 'pgkiln admin builder-sign-in password\n' +
480
+ 'pgkiln admin rotate-key [--dry-run]\n' +
481
+ 'pgkiln admin key-backed-up\n' +
482
+ 'pgkiln admin check [--url <public url>]\n\n' +
480
483
  "reset-2fa removes the developer's authenticator apps and recovery codes and ends their builder\n" +
481
484
  'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works\n' +
482
485
  "(and clears the workspaces' own two-factor rules); admins and all need at least two administrators\n" +
483
486
  'with two-factor sign-in (or every one, when there are fewer). builder-sign-in password turns off the\n' +
484
487
  "workspaces' single sign-on rule, for when the identity provider fails and no break-glass administrator\n" +
485
- 'can sign in. All are written to the activity log and mailed to the administrators.',
486
- options: { reason: { type: 'string' }, grace: { type: 'string' } },
488
+ 'can sign in. All are written to the activity log and mailed to the administrators.\n\n' +
489
+ 'rotate-key re-encrypts every stored secret (authenticator apps, web credentials, mail and AI keys)\n' +
490
+ 'from PGKILN_OLD_SECRET_KEY to PGKILN_SECRET_KEY in one transaction; stop the servers first and start\n' +
491
+ 'them with the new key. key-backed-up records that the current key is kept outside the server.\n' +
492
+ 'check prints the lockout checks of Instance settings (instance key, clock, passkey domain,\n' +
493
+ 'break-glass administrators); --url shows which passkeys a move to that address would break.\n' +
494
+ 'Exit code 1 when a check needs attention.',
495
+ options: { reason: { type: 'string' }, grace: { type: 'string' }, 'dry-run': { type: 'boolean' }, url: { type: 'string' } },
487
496
  optionHelp: [
488
497
  ['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
489
498
  ['--grace <days>', 'set-2fa-policy: days developers have to set up an app (0 to 90, default 7)'],
499
+ ['--dry-run', 'rotate-key: count what would be re-encrypted, change nothing'],
500
+ ['--url <url>', 'check: the public address to check passkeys against (default PUBLIC_URL)'],
490
501
  ],
491
- positionals: [2, 2],
502
+ positionals: [1, 2],
492
503
  async run(v, [action, arg]) {
493
504
  process.env.RUNTIME_DATABASE_URL ??= process.env.DATABASE_URL; // the CLI runs as the owner
494
505
  const mfa = await import('../mfa.ts');
@@ -497,6 +508,7 @@ const COMMANDS: Record<string, Command> = {
497
508
  const by = `pgkiln admin (${userInfo().username})`;
498
509
  try {
499
510
  if (action === 'reset-2fa') {
511
+ if (!arg) throw new UsageError('reset-2fa takes a developer');
500
512
  const reason = String(v.reason ?? 'command line').slice(0, 200);
501
513
  if (!(await mfa.resetFactors(arg))) throw new Error(`no developer ${arg}`);
502
514
  await logActivity({ username: arg, event: '2fa_reset', ip: 'cli', detail: `${arg}: ${reason} (${by})` });
@@ -530,7 +542,46 @@ const COMMANDS: Record<string, Command> = {
530
542
  out(`Developers may sign in with the pgkiln password again; ${cleared} workspace(s) no longer require single sign-on.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
531
543
  return EXIT.ok;
532
544
  }
533
- throw new UsageError(`unknown action ${action}: use reset-2fa, set-2fa-policy or builder-sign-in`);
545
+ // (sprint 133, SEC-6) the instance key and the lockout checks
546
+ if (action === 'rotate-key' || action === 'key-backed-up' || action === 'check') {
547
+ if (arg !== undefined) throw new UsageError(`${action} takes no argument`);
548
+ const checks = await import('../instance-checks.ts');
549
+ if (action === 'rotate-key') {
550
+ const oldKey = process.env.PGKILN_OLD_SECRET_KEY ?? '';
551
+ const newKey = process.env.PGKILN_SECRET_KEY ?? '';
552
+ if (!oldKey) throw new UsageError('set PGKILN_OLD_SECRET_KEY to the key the secrets are stored with, and PGKILN_SECRET_KEY to the new one');
553
+ const dry = !!v['dry-run'];
554
+ const counts = await checks.rotateKey(oldKey, newKey, { dryRun: dry });
555
+ const rotated = counts.reduce((n, c) => n + c.rotated, 0);
556
+ for (const c of counts) out(` ${c.schema}.${c.table}.${c.column}: ${c.rotated} ${dry ? 'to re-encrypt' : 're-encrypted'}${c.already ? `, ${c.already} already with the new key` : ''}\n`);
557
+ if (dry) {
558
+ out(`Dry run: ${rotated} secret(s) would be re-encrypted; nothing changed.\n`);
559
+ return EXIT.ok;
560
+ }
561
+ await logActivity({ event: 'secret_key', ip: 'cli', detail: `rotated, ${rotated} secret(s) re-encrypted (${by})` });
562
+ const sent = await mfa.notifyMfa(await mfa.adminEmails(), 'pgkiln: the instance key was rotated',
563
+ `${by} re-encrypted ${rotated} stored secret(s) with a new PGKILN_SECRET_KEY on the server. Keep a copy of the new key and confirm it on Instance settings.`);
564
+ out(`${rotated} secret(s) re-encrypted with the new key. Start the servers with it, keep a copy, then run 'pgkiln admin key-backed-up'.${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
565
+ return EXIT.ok;
566
+ }
567
+ if (action === 'key-backed-up') {
568
+ if (!(await checks.confirmKeyBackup(by))) throw new Error('PGKILN_SECRET_KEY is not set (at least 32 characters)');
569
+ await logActivity({ event: 'secret_key', ip: 'cli', detail: `backup confirmed (${by})` });
570
+ out('Backup of the instance key confirmed.\n');
571
+ return EXIT.ok;
572
+ }
573
+ let rpId: string | null = null;
574
+ const url = (v.url as string | undefined) ?? process.env.PUBLIC_URL;
575
+ if (url) {
576
+ if (!URL.canParse(url) || !/^https?:$/.test(new URL(url).protocol)) throw new UsageError('--url takes an http(s) address');
577
+ rpId = (await import('../webauthn.ts')).rpIdOf(new URL(url).origin);
578
+ }
579
+ const list = await checks.instanceChecks(rpId);
580
+ for (const c of list) out(`${c.level.toUpperCase().padEnd(7)} ${c.title}\n ${c.detail}\n`);
581
+ if (!rpId) out('(passkey domain not checked: set PUBLIC_URL or pass --url)\n');
582
+ return checks.attention(list) ? EXIT.differences : EXIT.ok;
583
+ }
584
+ throw new UsageError(`unknown action ${action}: use reset-2fa, set-2fa-policy, builder-sign-in, rotate-key, key-backed-up or check`);
534
585
  } finally {
535
586
  await closePools();
536
587
  }