pgkiln 0.62.0 → 0.64.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 (61) hide show
  1. package/SECURITY.md +4 -0
  2. package/db/migrations/131_builder_sso.sql +69 -0
  3. package/db/migrations/132_app_user_mfa.sql +58 -0
  4. package/docs/apex-feature-parity.md +5 -2
  5. package/docs/guide/03-builder.md +46 -0
  6. package/docs/guide/08-security.md +48 -6
  7. package/docs/guide/09-reference.md +5 -1
  8. package/docs/guide/11-from-apex.md +2 -1
  9. package/docs/guide/12-development.md +9 -0
  10. package/docs/guide/18-cli.md +1 -0
  11. package/package.json +1 -1
  12. package/public/app.css +10 -0
  13. package/public/builder.css +4 -0
  14. package/src/account-mfa.ts +93 -0
  15. package/src/builder/advisor.ts +8 -0
  16. package/src/builder/builder-sso.ts +135 -0
  17. package/src/builder/i18n/de.ts +56 -0
  18. package/src/builder/i18n/en.ts +56 -0
  19. package/src/builder/i18n/es.ts +56 -0
  20. package/src/builder/i18n/fr.ts +56 -0
  21. package/src/builder/i18n/nl.ts +56 -0
  22. package/src/builder/i18n/pt.ts +56 -0
  23. package/src/builder/ldap.ts +2 -0
  24. package/src/builder/login-page.ts +26 -2
  25. package/src/builder/routes.ts +137 -21
  26. package/src/builder/security.ts +3 -1
  27. package/src/builder/ui.ts +2 -1
  28. package/src/builder/users.ts +36 -3
  29. package/src/builder/workspaces.ts +37 -3
  30. package/src/cli/main.ts +19 -5
  31. package/src/devsso.ts +149 -0
  32. package/src/i18n/ar.ts +34 -0
  33. package/src/i18n/cs.ts +34 -0
  34. package/src/i18n/da.ts +34 -0
  35. package/src/i18n/de.ts +34 -0
  36. package/src/i18n/el.ts +34 -0
  37. package/src/i18n/es.ts +34 -0
  38. package/src/i18n/fi.ts +34 -0
  39. package/src/i18n/fr.ts +34 -0
  40. package/src/i18n/he.ts +34 -0
  41. package/src/i18n/it.ts +34 -0
  42. package/src/i18n/ja.ts +34 -0
  43. package/src/i18n/ko.ts +34 -0
  44. package/src/i18n/nb.ts +34 -0
  45. package/src/i18n/pl.ts +34 -0
  46. package/src/i18n/pt.ts +34 -0
  47. package/src/i18n/ru.ts +34 -0
  48. package/src/i18n/sv.ts +34 -0
  49. package/src/i18n/tr.ts +34 -0
  50. package/src/i18n/uk.ts +34 -0
  51. package/src/i18n/zh.ts +34 -0
  52. package/src/i18n.ts +68 -0
  53. package/src/metadata.ts +4 -1
  54. package/src/mfa.ts +76 -52
  55. package/src/remember.ts +1 -1
  56. package/src/runtime/account.ts +4 -1
  57. package/src/runtime/routes.ts +24 -9
  58. package/src/runtime/two-step.ts +395 -0
  59. package/src/saml.ts +13 -7
  60. package/src/sharedlogin.ts +1 -1
  61. package/src/sso.ts +37 -7
@@ -32,6 +32,11 @@ import { isIP } from 'node:net';
32
32
  import { giveChallenge, takeChallenge } from './security.ts';
33
33
  import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
34
34
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
35
+ import { builderSsoRoutes, leavesNoBreakGlass } from './builder-sso.ts';
36
+ import { builderDirectory, builderProvider, builderSources, linkDeveloper, resolveDeveloper, ssoRequired } from '../devsso.ts';
37
+ import { ldapAuthenticate, LdapError } from '../ldap.ts';
38
+ import { onBuilderSso, SsoError, startSignIn } from '../sso.ts';
39
+ import { startSamlSignIn } from '../saml.ts';
35
40
 
36
41
  // Builder pages: sign-in, workspace and app home, settings, activity and
37
42
  // developers. Shared Components, the page designer and the SQL Workshop
@@ -70,6 +75,8 @@ export async function builderRoutes(app: FastifyInstance) {
70
75
  const next = safeNext(req.query?.next);
71
76
  // (sprint 128) a passkey alone, when the instance allows it and the address is a host name
72
77
  const alone = (await passkeySignin()) && !isIP(relyingParty(req).rpId);
78
+ // (sprint 129, SEC-1) identity providers and directories that sign developers in
79
+ const sources = await builderSources();
73
80
  const field = (name: string, label: string, ic: string, value: string, opts: { type?: string; required?: boolean; auto?: string; placeholder?: string; reveal?: boolean }) =>
74
81
  html`<div class="field signin-field"><label class="sr-only" for="f_${name}">${label}</label>
75
82
  <div class="signin-input">${icon(ic)}<input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value}" placeholder="${opts.placeholder ?? label}"${opts.required ? raw(' required') : ''}${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
@@ -91,6 +98,10 @@ export async function builderRoutes(app: FastifyInstance) {
91
98
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
92
99
  ${csrf(s)}<input type="hidden" name="next" value="${next}">
93
100
  ${field('workspace', tx('signin.workspace'), 'database', r?.w ?? '', { placeholder: tx('signin.workspace_placeholder'), auto: 'organization' })}
101
+ ${sources.directories.length ? html`<div class="field signin-field"><label class="sr-only" for="f_method">${tx('signin.method')}</label>
102
+ <div class="signin-input">${icon('server')}<select id="f_method" name="method" aria-label="${tx('signin.method')}">
103
+ <option value="">${tx('signin.method_pgkiln')}</option>
104
+ ${sources.directories.map((d) => html`<option value="ldap:${d.name}">${d.display_name}</option>`)}</select></div></div>` : ''}
94
105
  ${field('username', tx('signin.username'), 'user', r?.u ?? '', { required: true, auto: 'username' })}
95
106
  ${field('password', tx('signin.password'), 'key', '', { type: 'password', required: true, auto: 'current-password', reveal: true })}
96
107
  <label class="check signin-remember"><input type="checkbox" name="remember" value="1"${r ? raw(' checked') : ''}> ${tx('signin.remember')}</label>
@@ -98,6 +109,7 @@ export async function builderRoutes(app: FastifyInstance) {
98
109
  ${alone ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature"><input type="hidden" name="userHandle">
99
110
  <button class="btn signin-submit signin-passkey" hidden data-passkey="${BASE}/login/passkey/options" data-passkey-submit="${BASE}/login/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('signin.passkey')}</button>
100
111
  <p class="small" role="status" data-passkey-status></p>` : ''}
112
+ ${sources.providers.map((p) => html`<a class="btn signin-submit signin-idp" href="${BASE}/sso/${p.name}${next === `${BASE}/home` ? '' : `?next=${encodeURIComponent(next)}`}">${icon('log-in')} ${tx('signin.with', { provider: p.display_name })}</a>`)}
101
113
  <p class="muted small signin-hint">${tx('signin.hint')}</p>
102
114
  <nav class="signin-actions" aria-label="${tx('signin.quick')}">
103
115
  <a href="${BASE}/find-workspace">${icon('search')} ${tx('signin.find_ws')}</a>
@@ -135,29 +147,63 @@ export async function builderRoutes(app: FastifyInstance) {
135
147
  flash(s, tx('signin.throttled'), 'error');
136
148
  return back(reply, s, again);
137
149
  }
138
- // bcrypt always runs (dummy salt for unknown users) so timing does not reveal accounts
139
- const dev = await owner.one(
140
- `select d.username, d.language
141
- from (select $1::text as u) x
142
- left join meta.developer d on d.username = x.u
143
- where crypt($2, coalesce(d.password_hash, gen_salt('bf', 10))) = d.password_hash`,
144
- [username, password],
145
- );
150
+ // (sprint 129, SEC-1) an LDAP directory checks the password; the developer is the one linked to the entry
151
+ const method = String(req.body?.method ?? '');
152
+ const directory = method.startsWith('ldap:') ? await builderDirectory(method.slice(5)) : undefined;
153
+ if (method && !directory) {
154
+ flash(s, tx('signin.expired'), 'error');
155
+ return back(reply, s, again);
156
+ }
157
+ let dev: { username: string; language: string | null } | undefined;
158
+ if (directory) {
159
+ let why = 'invalid';
160
+ try {
161
+ const u = await ldapAuthenticate(directory, username, password);
162
+ if (u) {
163
+ const name = await resolveDeveloper({ kind: 'directory', ...directory }, { subject: u.subject, username: u.username, name: u.displayName, email: u.email, mayLink: true, groups: u.groups });
164
+ dev = await owner.one('select username, language from meta.developer where username = $1', [name]);
165
+ }
166
+ } catch (e) {
167
+ if (!(e instanceof SsoError)) req.log.warn({ err: e, directory: directory.name }, 'builder ldap sign-in failed');
168
+ why = e instanceof SsoError ? e.message : e instanceof LdapError ? e.message : 'unreachable';
169
+ }
170
+ if (!dev) {
171
+ await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: `builder ldap:${directory.name}: ${why}` });
172
+ flash(s, why === 'invalid' ? tx('signin.invalid') : why === 'unreachable' ? tx('signin.ldap_unavailable', { directory: directory.display_name }) : why, 'error');
173
+ return back(reply, s, again);
174
+ }
175
+ } else
176
+ // bcrypt always runs (dummy salt for unknown users) so timing does not reveal accounts
177
+ dev = await owner.one(
178
+ `select d.username, d.language
179
+ from (select $1::text as u) x
180
+ left join meta.developer d on d.username = x.u
181
+ where crypt($2, coalesce(d.password_hash, gen_salt('bf', 10))) = d.password_hash`,
182
+ [username, password],
183
+ );
146
184
  // a workspace, when given, must be one of the developer's (administrators: any); the same answer otherwise
147
185
  const ws = dev && workspace ? await workspaceOf(workspace, dev.username) : undefined;
148
186
  if (!dev || (workspace && !ws)) {
149
- await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: workspace ? 'builder (workspace)' : 'builder' });
187
+ await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: directory ? `builder ldap:${directory.name} (workspace)` : workspace ? 'builder (workspace)' : 'builder' });
150
188
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
151
189
  return back(reply, s, again);
152
190
  }
191
+ // (sprint 129) a workspace of the developer requires single sign-on: not the pgkiln password (break-glass excepted)
192
+ if (!directory && (await ssoRequired(dev.username))) {
193
+ await logActivity({ username: dev.username, event: 'login_failed', ip, detail: 'builder: single sign-on required' });
194
+ flash(s, tx('signin.sso_required'), 'error');
195
+ return back(reply, s, again);
196
+ }
197
+ const weak = !directory && (password === 'admin' || password === dev.username);
198
+ const detail = directory ? `builder ldap:${directory.name}` : 'builder';
153
199
  // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
154
200
  // (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
155
201
  if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
156
- s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
202
+ s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak, at: Date.now() });
157
203
  await saveState(s);
158
204
  return reply.redirect(`${BASE}/login/2fa`, 303);
159
205
  }
160
- return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, picked, detail: 'builder' });
206
+ return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak, picked, detail });
161
207
  });
162
208
 
163
209
  /** A workspace by name, when it is one of the developer's (administrators: any). */
@@ -170,6 +216,57 @@ export async function builderRoutes(app: FastifyInstance) {
170
216
  [workspace, username],
171
217
  );
172
218
 
219
+ // (sprint 129, SEC-1) signing in through an identity provider (OpenID Connect or SAML; devsso.ts). The
220
+ // provider returns to the runtime's callback (/sso/…, registered at the provider for applications too),
221
+ // which hands sign-ins without an application to the function below.
222
+ const SSO_COOKIE = 'pgkiln_sso';
223
+ app.get(`${BASE}/sso/:provider`, async (req: Req, reply) => {
224
+ const p = await builderProvider(String(req.params.provider));
225
+ if (!p) return reply.code(404).send('Not found');
226
+ try {
227
+ const next = safeNext(req.query?.next);
228
+ const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, null, next) : await startSignIn(p, null, next);
229
+ reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
230
+ return reply.redirect(url);
231
+ } catch (e) {
232
+ req.log.warn({ err: e }, 'builder sso start failed');
233
+ const s = await getSession(req, reply, null, BASE);
234
+ flash(s, translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req))('signin.sso_unavailable', { provider: p.display_name }), 'error');
235
+ return back(reply, s, `${BASE}/login`);
236
+ }
237
+ });
238
+ /** Linking an identity from the Security page (security.ts): the signed-in developer starts it. */
239
+ app.post(`${BASE}/security/link/:provider`, async (req: Req, reply) => {
240
+ const s = await developer(req, reply);
241
+ if (!s) return;
242
+ const p = await builderProvider(String(req.params.provider));
243
+ if (!p) return reply.code(404).send('Not found');
244
+ try {
245
+ const { url, browserKey } = p.protocol === 'saml' ? await startSamlSignIn(p, null, `${BASE}/security`, s.username!) : await startSignIn(p, null, `${BASE}/security`, s.username!);
246
+ reply.setCookie(SSO_COOKIE, browserKey, { path: '/sso', httpOnly: true, sameSite: 'lax', secure: process.env.COOKIE_SECURE === 'true', maxAge: 600 });
247
+ return reply.redirect(url, 303);
248
+ } catch (e) {
249
+ req.log.warn({ err: e }, 'builder sso link failed');
250
+ flash(s, tr(s)('signin.sso_unavailable', { provider: p.display_name }), 'error');
251
+ return back(reply, s, `${BASE}/security`);
252
+ }
253
+ });
254
+ // the end of both (the runtime's callback; SsoError → an error page there)
255
+ onBuilderSso(async (req: Req, reply, p, result, ip) => {
256
+ if (!p.builder || !result.identity) throw new SsoError('This identity provider does not sign developers in.');
257
+ const src = { kind: 'provider' as const, ...p };
258
+ if (result.link) {
259
+ // the browser that started this was signed in as result.link (the start needs the session and its CSRF token)
260
+ await linkDeveloper(src, result.identity.subject, result.link);
261
+ logActivity({ username: result.link, event: 'sso_linked', ip, detail: p.name });
262
+ return reply.redirect(`${BASE}/security?linked=${encodeURIComponent(p.name)}`, 303);
263
+ }
264
+ const username = await resolveDeveloper(src, { ...result.identity, username: result.username, groups: result.groups });
265
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [username]);
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 });
268
+ });
269
+
173
270
  // (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
174
271
  // passkeys it holds for this site (discoverable credentials); the authenticator must verify the developer
175
272
  // (fingerprint, face, PIN), so the passkey counts as both factors. Failures count with wrong passwords.
@@ -219,9 +316,11 @@ export async function builderRoutes(app: FastifyInstance) {
219
316
  }
220
317
  const ws = !why && workspace ? await workspaceOf(workspace, who) : undefined;
221
318
  if (!why && workspace && !ws) why = 'not a workspace of the developer';
319
+ // (sprint 129) a passkey alone is not single sign-on
320
+ if (!why && (await ssoRequired(who))) why = 'single sign-on required';
222
321
  if (why) {
223
322
  await logActivity({ username: who.slice(0, 100), event: 'login_failed', ip, detail: `builder passkey: ${why}` });
224
- flash(s, tx(why === 'not a workspace of the developer' ? 'signin.invalid_ws' : 'mfa.passkey_failed'), 'error');
323
+ flash(s, tx(why === 'not a workspace of the developer' ? 'signin.invalid_ws' : why === 'single sign-on required' ? 'signin.sso_required' : 'mfa.passkey_failed'), 'error');
225
324
  return back(reply, s, again);
226
325
  }
227
326
  const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [who]);
@@ -229,12 +328,15 @@ export async function builderRoutes(app: FastifyInstance) {
229
328
  return finishSignIn(req, reply, s, dev, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: false, picked, detail: 'builder passkey' });
230
329
  });
231
330
 
232
- /** The session after a successful sign-in (password, and the second factor when the developer has one). */
233
- async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
234
- o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
331
+ /**
332
+ * The session after a successful sign-in (password, and the second factor when the developer has one;
333
+ * or an identity provider, sso: its name; s null: the sign-in page's session is not at hand there).
334
+ */
335
+ async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>> | null, dev: { username: string; language: string | null },
336
+ o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string; sso?: string }) {
235
337
  const ip = clientIp(req);
236
338
  const { picked, next, workspace } = o;
237
- await destroySession(reply, s, BASE);
339
+ if (s) await destroySession(reply, s, BASE);
238
340
  const ns = await createSession(reply, null, BASE, dev.username);
239
341
  // the builder theme chosen earlier on this device
240
342
  const theme = validTheme(req.cookies?.[THEME_COOKIE]);
@@ -247,8 +349,12 @@ export async function builderRoutes(app: FastifyInstance) {
247
349
  if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
248
350
  }
249
351
  if (o.weak) ns.state.__WEAK = '1';
352
+ // (sprint 129) the identity provider decided on the second factor: no enrolment in pgkiln (ui.ts)
353
+ if (o.sso) ns.state.__SSO = o.sso;
250
354
  await saveState(ns);
251
- if (o.remember)
355
+ if (o.sso) {
356
+ // the sign-in page's "remember" stays as it was
357
+ } else if (o.remember)
252
358
  reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
253
359
  path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
254
360
  });
@@ -447,6 +553,7 @@ export async function builderRoutes(app: FastifyInstance) {
447
553
  await workspaceRoutes(app);
448
554
  await instanceRoutes(app);
449
555
  await securityRoutes(app);
556
+ await builderSsoRoutes(app);
450
557
  await wizardRoutes(app);
451
558
  await appFromFileRoutes(app);
452
559
 
@@ -928,7 +1035,10 @@ export async function builderRoutes(app: FastifyInstance) {
928
1035
  app.get(`${BASE}/developers`, async (req: Req, reply) => {
929
1036
  const s = await developer(req, reply);
930
1037
  if (!s) return;
931
- const devs = (await owner.query(`select username, is_admin, email, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa
1038
+ const devs = (await owner.query(`select username, is_admin, email, break_glass, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa,
1039
+ (select string_agg(coalesce(p.display_name, l.display_name), ', ' order by 1) from meta.developer_identity i
1040
+ left join meta.auth_provider p on p.id = i.provider_id left join meta.ldap_directory l on l.id = i.directory_id
1041
+ where i.username = d.username) as identities
932
1042
  from meta.developer d order by 1`)).rows;
933
1043
  const admin = devs.some((d) => d.username === s.username && d.is_admin);
934
1044
  const me = devs.find((d) => d.username === s.username);
@@ -947,7 +1057,7 @@ export async function builderRoutes(app: FastifyInstance) {
947
1057
  ${region(tx('dev.accounts'), html`
948
1058
  <p class="muted u-mt0">${tx('dev.accounts_help')}</p>
949
1059
  <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th>${tx('mfa.column')}</th><th></th></tr></thead><tbody>
950
- ${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}</td>
1060
+ ${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}${d.break_glass ? html` <span class="badge badge-warn" title="${tx('bsso.break_glass_help')}">${tx('bsso.break_glass')}</span>` : ''}${d.identities ? html`<br><span class="muted small">${icon('log-in')} ${d.identities}</span>` : ''}</td>
951
1061
  <td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
952
1062
  ? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="${tx('dev.email_of', { user: d.username })}" placeholder="${tx('dev.none')}"><button class="btn">${tx('dev.save')}</button></form>`
953
1063
  : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
@@ -955,6 +1065,7 @@ export async function builderRoutes(app: FastifyInstance) {
955
1065
  ${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
956
1066
  <button class="btn btn-sm btn-danger">${tx('mfa.reset_btn')}</button></form></div></details>` : ''}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
957
1067
  <form method="post" action="${BASE}/developers/admin" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="is_admin" value="${d.is_admin ? 'false' : 'true'}"><button class="link-button">${d.is_admin ? tx('dev.make_dev') : tx('dev.make_admin')}</button></form>
1068
+ ${d.is_admin ? html`· <form method="post" action="${BASE}/developers/break-glass" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="break_glass" value="${d.break_glass ? 'false' : 'true'}"><button class="link-button" title="${tx('bsso.break_glass_help')}">${d.break_glass ? tx('bsso.break_glass_remove') : tx('bsso.break_glass_make')}</button></form>` : ''}
958
1069
  · <form method="post" action="${BASE}/developers/delete" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><button class="link-button" data-confirm="${tx('dev.remove_confirm', { user: d.username })}">${tx('dev.remove')}</button></form>`}</td></tr>`)}
959
1070
  </tbody></table></div>
960
1071
  ${admin
@@ -1042,7 +1153,12 @@ export async function builderRoutes(app: FastifyInstance) {
1042
1153
  flash(s, tr(s)('mfa.keep_admins'), 'error');
1043
1154
  return back(reply, s, `${BASE}/developers`);
1044
1155
  }
1045
- await owner.query('update meta.developer set is_admin = $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
1156
+ // (sprint 129) break-glass is for administrators: a developer loses it, unless they are the last one needed
1157
+ if (req.body.is_admin !== 'true' && (await leavesNoBreakGlass(req.body.username))) {
1158
+ flash(s, tr(s)('bsso.keep_break_glass'), 'error');
1159
+ return back(reply, s, `${BASE}/developers`);
1160
+ }
1161
+ await owner.query('update meta.developer set is_admin = $2, break_glass = break_glass and $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
1046
1162
  flash(s, tr(s)('dev.role_changed'));
1047
1163
  }
1048
1164
  return back(reply, s, `${BASE}/developers`);
@@ -1053,7 +1169,7 @@ export async function builderRoutes(app: FastifyInstance) {
1053
1169
  if (!s) return;
1054
1170
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
1055
1171
  if (req.body?.username && req.body.username !== s.username) {
1056
- if (await leavesTooFewAdmins(req.body.username)) {
1172
+ if ((await leavesTooFewAdmins(req.body.username)) || (await leavesNoBreakGlass(req.body.username))) {
1057
1173
  flash(s, tr(s)('mfa.keep_admins'), 'error');
1058
1174
  return back(reply, s, `${BASE}/developers`);
1059
1175
  }
@@ -14,6 +14,7 @@ import { clientIp, logActivity, saveState, type Session } from '../session.ts';
14
14
  import { ALGORITHMS, newChallenge, relyingParty, verifyRegistration, WebAuthnError } from '../webauthn.ts';
15
15
  import { tr } from './i18n.ts';
16
16
  import { isAdmin } from './locks.ts';
17
+ import { identitiesRegion } from './builder-sso.ts';
17
18
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
18
19
 
19
20
  // (sprint 126, SEC-2 and SEC-6) Developer › Security: a developer's second factors (authenticator apps, TOTP)
@@ -135,7 +136,8 @@ export async function securityRoutes(app: FastifyInstance) {
135
136
  ${days || trusted ? html`<h3>${tx('mfa.trusted_title')}</h3>
136
137
  <p class="u-mt0">${days ? tx('mfa.trusted_count', { n: trusted, days }) : tx('mfa.trusted_off')}</p>
137
138
  ${trusted ? html`<form method="post" action="${BASE}/security/forget-browsers">${csrf(s)}<button class="btn">${tx('mfa.forget_browsers')}</button></form>` : ''}` : ''}`)}
138
- </div>`;
139
+ </div>
140
+ ${await identitiesRegion(s, typeof req.query?.linked === 'string' ? req.query.linked : undefined)}`;
139
141
  return send(reply, s, shell(s, tx('mfa.title'), [[tx('mfa.title')]], main, 'developers'));
140
142
  });
141
143
 
package/src/builder/ui.ts CHANGED
@@ -333,7 +333,8 @@ export async function developer(req: Req, reply: FastifyReply) {
333
333
  reply.redirect(`${BASE}/developers`);
334
334
  return null;
335
335
  }
336
- const due = await enrolmentDue(s.username, admin);
336
+ // (sprint 129) signed in through an identity provider: it decides on the second factor
337
+ const due = s.state.__SSO ? null : await enrolmentDue(s.username, admin);
337
338
  if (due?.due === 'grace') s.state.__MFA_DUE = due.until.toISOString();
338
339
  else if (due && !weakFirst && !/^\/builder\/(security|logout|language|theme)(?:[/?]|$)/.test(req.url)) {
339
340
  reply.redirect(`${BASE}/security`);
@@ -4,9 +4,13 @@ import { owner } from '../db.ts';
4
4
  import { appAllowed } from './workspaces.ts';
5
5
  import { html, raw } from '../html.ts';
6
6
  import { accountSettings, clearAccountSettings, passwordProblem } from '../accounts.ts';
7
+ import { accountCodesLeft, accountFactors, resetAccountFactors } from '../account-mfa.ts';
8
+ import { icon } from '../icons.ts';
9
+ import { clientIp, logActivity } from '../session.ts';
7
10
  import { loginMaxFailuresPerUser, loginWindowMinutes } from '../security.ts';
8
11
  import { acsUrl, metadataUrl, spEntityId } from '../saml.ts';
9
12
  import { discover, publicUrl, redirectUri } from '../sso.ts';
13
+ import { builderSignInRegion } from './builder-sso.ts';
10
14
  import { administrator, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
11
15
 
12
16
  // The workspace user directory (like APEX's workspace users with
@@ -106,7 +110,8 @@ export async function usersRoutes(app: FastifyInstance) {
106
110
  const users = (
107
111
  await owner.query(
108
112
  `select a.*, coalesce((select jsonb_agg(jsonb_build_object('alias', p.alias, 'roles', aa.roles) order by p.alias)
109
- from meta.app_access aa join meta.app p on p.id = aa.app_id where aa.account_id = a.id), '[]') as apps
113
+ from meta.app_access aa join meta.app p on p.id = aa.app_id where aa.account_id = a.id), '[]') as apps,
114
+ exists (select 1 from meta.account_factor f where f.account_id = a.id) as mfa
110
115
  from meta.account a
111
116
  where $1 = '' or a.username ilike '%' || $1 || '%' or a.display_name ilike '%' || $1 || '%' or a.email ilike '%' || $1 || '%'
112
117
  order by lower(a.username)
@@ -124,16 +129,17 @@ export async function usersRoutes(app: FastifyInstance) {
124
129
  <input type="search" name="q" value="${q}" placeholder="${tx('us.search_ph')}" aria-label="${tx('us.search_label')}"><button class="btn">${tx('us.search')}</button>
125
130
  </form>
126
131
  <div class="table-wrap"><table class="report report-reflow">
127
- <thead><tr><th>${tx('us.username')}</th><th>${tx('us.name')}</th><th>${tx('us.apps_roles')}</th><th>${tx('us.signin')}</th><th>${tx('us.status')}</th></tr></thead>
132
+ <thead><tr><th>${tx('us.username')}</th><th>${tx('us.name')}</th><th>${tx('us.apps_roles')}</th><th>${tx('us.signin')}</th><th>${tx('mfa.column')}</th><th>${tx('us.status')}</th></tr></thead>
128
133
  <tbody>${users.length
129
134
  ? users.map((u) => html`<tr>
130
135
  <td data-label="${tx('us.username')}"><a href="${BASE}/users/${u.id}">${u.username}</a></td>
131
136
  <td data-label="${tx('us.name')}">${u.display_name ?? ''}${u.email ? html`<div class="muted">${u.email}</div>` : ''}</td>
132
137
  <td data-label="${tx('us.applications')}">${u.apps.length ? u.apps.map((x: any) => html`<span class="chip">${x.alias}${x.roles.length ? `: ${x.roles.join(', ')}` : ''}</span> `) : html`<span class="muted">${tx('us.none')}</span>`}</td>
133
138
  <td data-label="${tx('us.signin')}">${u.password_hash ? tx('us.password') : html`<span class="muted">${tx('us.no_password')}</span>`}</td>
139
+ <td data-label="${tx('mfa.column')}">${u.mfa ? html`${icon('lock')} ${tx('mfa.on')}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td>
134
140
  <td data-label="${tx('us.status')}">${u.active ? tx('us.active') : html`<b>${tx('us.inactive')}</b>`}${u.last_login_at ? html`<div class="muted">${tx('us.last', { at: String(u.last_login_at).slice(0, 16) })}</div>` : ''}</td>
135
141
  </tr>`)
136
- : html`<tr><td colspan="5" class="empty">${tx('us.no_accounts')}</td></tr>`}</tbody>
142
+ : html`<tr><td colspan="6" class="empty">${tx('us.no_accounts')}</td></tr>`}</tbody>
137
143
  </table></div>`)}
138
144
  ${region(tx('us.create'), html`
139
145
  <form method="post" action="${BASE}/users">${csrf(s)}
@@ -214,6 +220,7 @@ export async function usersRoutes(app: FastifyInstance) {
214
220
  owner.query(`select id, name, alias from meta.app where id not in (select app_id from meta.app_access where account_id = $1) order by name`, [u.id]),
215
221
  ]);
216
222
  const hints = await roleHints([...access.rows.map((a) => a.app_id), ...apps.rows.map((a) => a.id)]);
223
+ const [factors, codes] = await Promise.all([accountFactors(u.id), accountCodesLeft(u.id)]);
217
224
  const main = html`
218
225
  <div class="title-row"><h1>${u.username}</h1></div>
219
226
  <div class="columns">
@@ -243,6 +250,13 @@ export async function usersRoutes(app: FastifyInstance) {
243
250
  <div class="buttons"><button class="btn">${tx('us.set_password')}</button>
244
251
  ${u.password_hash ? html`<button class="btn" name="remove" value="1" data-confirm="${tx('us.remove_confirm')}">${tx('us.remove_password')}</button>` : ''}</div>
245
252
  </form>
253
+ <h3 id="two-factor">${tx('us.mfa_title')}</h3>
254
+ <p>${factors.length ? tx('us.mfa_status', { n: factors.length, codes }) : tx('us.mfa_none')}</p>
255
+ ${factors.length
256
+ ? html`<form method="post" action="${BASE}/users/${u.id}/reset-2fa" class="danger-zone">${csrf(s)}
257
+ <div class="form-grid">${input('reason', tx('mfa.reason'), '', { required: true, help: tx('us.mfa_reset_help', { user: u.username }) })}</div>
258
+ <div class="buttons"><button class="btn btn-danger">${tx('mfa.reset_btn')}</button></div></form>`
259
+ : ''}
246
260
  <form method="post" action="${BASE}/users/${u.id}/delete" class="danger-zone">${csrf(s)}
247
261
  <button class="btn btn-danger" data-confirm="${tx('us.delete_confirm', { user: u.username })}">${tx('us.delete')}</button>
248
262
  </form>`)}
@@ -325,6 +339,24 @@ export async function usersRoutes(app: FastifyInstance) {
325
339
  return back(reply, s, `${BASE}/users/${req.params.id}`);
326
340
  });
327
341
 
342
+ // (sprint 130, SEC-3) remove an account's authenticator apps and recovery codes (a lost phone), with a reason
343
+ app.post(`${BASE}/users/:id(^\\d+$)/reset-2fa`, async (req: Req, reply) => {
344
+ const s = await developer(req, reply);
345
+ if (!s) return;
346
+ const tx = tr(s);
347
+ const reason = String(req.body?.reason ?? '').trim().slice(0, 200);
348
+ const acc = await owner.one<{ id: number; username: string }>('select id, username from meta.account where id = $1', [req.params.id]);
349
+ if (!acc) return reply.code(404).send('Not found');
350
+ if (!reason) {
351
+ flash(s, tx('us.mfa_reason_needed'), 'error');
352
+ return back(reply, s, `${BASE}/users/${acc.id}#two-factor`);
353
+ }
354
+ await resetAccountFactors(acc.id);
355
+ await logActivity({ username: acc.username, event: '2fa_reset', ip: clientIp(req), detail: `by ${s.username}: ${reason}` });
356
+ flash(s, tx('us.mfa_reset_done', { user: acc.username }));
357
+ return back(reply, s, `${BASE}/users/${acc.id}`);
358
+ });
359
+
328
360
  app.post(`${BASE}/users/:id(^\\d+$)/delete`, async (req: Req, reply) => {
329
361
  const s = await developer(req, reply);
330
362
  if (!s) return;
@@ -476,6 +508,7 @@ export async function usersRoutes(app: FastifyInstance) {
476
508
  <p class="muted">${tx.rich('idp.public_url', { env: html`<code>PUBLIC_URL</code>` })}</p>
477
509
  <p>${tx.rich('idp.grant', { type: html`<b>${tx('idp.auth_code')}</b>`, scopes: html`<code>${pr.scopes}</code>`, claim: html`<code>${pr.groups_claim}</code>` })}</p>
478
510
  <form method="post" action="${BASE}/users/providers/${pr.id}/test">${csrf(s)}<button class="btn">${tx('idp.test')}</button></form>`)}
511
+ ${await builderSignInRegion(s, 'provider', pr)}
479
512
  </div>`;
480
513
  return send(reply, s, shell(s, pr.display_name, [['Users', `${BASE}/users`], [tx('idp.title'), `${BASE}/users/providers`], [pr.display_name]], main, 'users'));
481
514
  });
@@ -9,6 +9,7 @@ import { icon } from '../icons.ts';
9
9
  import { clientIp, logActivity, type BuilderWorkspace, type Session } from '../session.ts';
10
10
  import { isAdmin } from './locks.ts';
11
11
  import { setWorkspaceRequired } from '../mfa.ts';
12
+ import { breakGlassAdmins } from '../devsso.ts';
12
13
  import { back, BANNER_COLORS, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
13
14
 
14
15
  // Workspaces (064; APEX: workspaces). A workspace groups applications and the
@@ -216,7 +217,7 @@ function serviceRegion(s: Session, w: { id: number; message: string | null; bann
216
217
  }
217
218
 
218
219
  /** (093) Workspace preferences: the SQL Workshop and the applications' REST modules on or off. */
219
- function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null; mfa_required_since: Date | null }, without: string[]) {
220
+ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null; mfa_required_since: Date | null; sso_only: boolean }, without: string[], unlinked: string[]) {
220
221
  const check = (name: string, on: boolean, label: string, help: string) =>
221
222
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
222
223
  const tx = tr(s);
@@ -229,7 +230,12 @@ function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; r
229
230
  <form method="post" action="${BASE}/workspaces/${w.id}/mfa">${csrf(s)}
230
231
  ${check('required', !!w.mfa_required_since, tx('wsp.mfa_required'), tx('wsp.mfa_required_help'))}
231
232
  ${w.mfa_required_since ? html`<p class="small">${tx('wsp.mfa_since', { date: new Date(w.mfa_required_since).toISOString().slice(0, 10) })}${without.length ? html` ${tx('wsp.mfa_without', { list: without.join(', ') })}` : ''}</p>` : ''}
232
- <div class="buttons"><button class="btn">${tx('wsp.mfa_save')}</button></div></form>`, '', 'preferences');
233
+ <div class="buttons"><button class="btn">${tx('wsp.mfa_save')}</button></div></form>
234
+ <h3 id="sso">${tx('wsp.sso')}</h3>
235
+ <form method="post" action="${BASE}/workspaces/${w.id}/sso">${csrf(s)}
236
+ ${check('sso_only', w.sso_only, tx('wsp.sso_only'), tx('wsp.sso_only_help'))}
237
+ ${w.sso_only && unlinked.length ? html`<p class="small">${tx('wsp.sso_unlinked', { list: unlinked.join(', ') })}</p>` : ''}
238
+ <div class="buttons"><button class="btn">${tx('wsp.sso_save')}</button></div></form>`, '', 'preferences');
233
239
  }
234
240
 
235
241
  /** (093) Extension links: links to other tools in the builder's Extensions menu. */
@@ -351,6 +357,11 @@ export async function workspaceRoutes(app: FastifyInstance) {
351
357
  const without = (await owner.query<{ username: string }>(
352
358
  `select m.username from meta.workspace_member m where m.workspace_id = $1
353
359
  and not exists (select 1 from meta.developer_factor f where f.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
360
+ // (sprint 129) the members who could not sign in while single sign-on is required: no linked identity, not break-glass
361
+ const unlinked = (await owner.query<{ username: string }>(
362
+ `select m.username from meta.workspace_member m join meta.developer d on d.username = m.username
363
+ where m.workspace_id = $1 and not d.break_glass
364
+ and not exists (select 1 from meta.developer_identity i where i.username = m.username) order by 1`, [w.id])).rows.map((r) => r.username);
354
365
  const main = html`
355
366
  <div class="title-row"><h1>${w.name}</h1></div>
356
367
  <div class="columns wide-left">
@@ -375,7 +386,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
375
386
  </div>
376
387
  ${serviceRegion(s, w)}
377
388
  ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
378
- <div class="columns">${preferencesRegion(s, w, without)}${linksRegion(s, w, links)}</div>
389
+ <div class="columns">${preferencesRegion(s, w, without, unlinked)}${linksRegion(s, w, links)}</div>
379
390
  ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
380
391
  ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
381
392
  <thead><tr><th>${tx('wsp.application')}</th><th>${tx('wsp.move_to')}</th></tr></thead>
@@ -580,6 +591,29 @@ export async function workspaceRoutes(app: FastifyInstance) {
580
591
  return back(reply, s, `${BASE}/workspaces/${req.params.id}#mfa`);
581
592
  });
582
593
 
594
+ // (sprint 129, SEC-1) a workspace requires single sign-on of its developers: not while no break-glass
595
+ // administrator could sign in with the password, and not by someone who would lock themselves out
596
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/sso`, async (req: Req, reply) => {
597
+ const s = await developer(req, reply);
598
+ if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
599
+ const tx = tr(s);
600
+ const on = req.body?.sso_only === 'true';
601
+ if (on && !(await breakGlassAdmins()).length) {
602
+ flash(s, tx('wsp.sso_no_break_glass'), 'error');
603
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
604
+ }
605
+ if (on && !(await owner.one(`select 1 as ok from meta.developer d where d.username = $1
606
+ and (d.break_glass or exists (select 1 from meta.developer_identity i where i.username = d.username))`, [s.username]))) {
607
+ flash(s, tx('wsp.sso_link_first'), 'error');
608
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
609
+ }
610
+ const r = await owner.query('update meta.workspace set sso_only = $2 where id = $1', [req.params.id, on]);
611
+ if (!r.rowCount) return reply.code(404).send('Not found');
612
+ await logActivity({ appId: null, username: s.username, event: 'sso_policy', ip: clientIp(req), detail: `workspace ${req.params.id}: single sign-on ${on ? 'required' : 'not required'}` });
613
+ flash(s, tx(on ? 'wsp.sso_on' : 'wsp.sso_off'));
614
+ return back(reply, s, `${BASE}/workspaces/${req.params.id}#sso`);
615
+ });
616
+
583
617
  // (093) extension links
584
618
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/links`, async (req: Req, reply) => {
585
619
  const s = await developer(req, reply);
package/src/cli/main.ts CHANGED
@@ -471,15 +471,18 @@ 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 [options]',
475
- summary: 'lockout recovery: reset a developer\'s two-factor sign-in, set the two-factor policy',
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',
476
476
  details:
477
477
  'pgkiln admin reset-2fa <developer> [--reason <text>]\n' +
478
- 'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n\n' +
478
+ 'pgkiln admin set-2fa-policy off|admins|all [--grace <days>]\n' +
479
+ 'pgkiln admin builder-sign-in password\n\n' +
479
480
  "reset-2fa removes the developer's authenticator apps and recovery codes and ends their builder\n" +
480
481
  'sessions; they sign in with the password and set up an app again. set-2fa-policy off always works\n' +
481
482
  "(and clears the workspaces' own two-factor rules); admins and all need at least two administrators\n" +
482
- 'with two-factor sign-in (or every one, when there are fewer). Both are written to the activity log and mailed to the administrators.',
483
+ 'with two-factor sign-in (or every one, when there are fewer). builder-sign-in password turns off the\n' +
484
+ "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.',
483
486
  options: { reason: { type: 'string' }, grace: { type: 'string' } },
484
487
  optionHelp: [
485
488
  ['--reason <text>', 'reset-2fa: why (in the activity log and the mail)'],
@@ -516,7 +519,18 @@ const COMMANDS: Record<string, Command> = {
516
519
  out(`Two-factor policy: ${policy}${policy === 'off' ? '' : `, grace ${grace} days`}.${cleared ? ` ${cleared} workspace(s) no longer require it.` : ''}${sent ? ` ${sent} administrator(s) told by mail.` : ''}\n`);
517
520
  return EXIT.ok;
518
521
  }
519
- throw new UsageError(`unknown action ${action}: use reset-2fa or set-2fa-policy`);
522
+ // (sprint 129, SEC-1) every workspace allows the pgkiln password again
523
+ if (action === 'builder-sign-in') {
524
+ if (arg !== 'password') throw new UsageError('builder-sign-in takes password');
525
+ const { clearSsoOnly } = await import('../devsso.ts');
526
+ const cleared = await clearSsoOnly();
527
+ await logActivity({ event: 'sso_policy', ip: 'cli', detail: `password sign-in allowed again, ${cleared} workspace rule(s) cleared (${by})` });
528
+ const sent = await mfa.notifyMfa(await mfa.adminEmails(), 'pgkiln: the builder password works again',
529
+ `${by} turned off the single sign-on rule of ${cleared} workspace(s) on the server: developers may sign in with the pgkiln password.`);
530
+ 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
+ return EXIT.ok;
532
+ }
533
+ throw new UsageError(`unknown action ${action}: use reset-2fa, set-2fa-policy or builder-sign-in`);
520
534
  } finally {
521
535
  await closePools();
522
536
  }