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.
- package/SECURITY.md +1 -0
- package/db/migrations/134_idp_mfa.sql +46 -0
- package/docs/apex-feature-parity.md +10 -1
- package/docs/guide/01-installation.md +5 -2
- package/docs/guide/03-builder.md +40 -3
- package/docs/guide/08-security.md +41 -3
- package/docs/guide/12-development.md +8 -2
- package/docs/guide/18-cli.md +3 -0
- package/docs/guide/19-rest-data-sources.md +2 -2
- package/package.json +1 -1
- package/public/builder.css +6 -0
- package/src/builder/i18n/de.ts +15 -0
- package/src/builder/i18n/en.ts +15 -0
- package/src/builder/i18n/es.ts +15 -0
- package/src/builder/i18n/fr.ts +15 -0
- package/src/builder/i18n/nl.ts +15 -0
- package/src/builder/i18n/pt.ts +15 -0
- package/src/builder/instance.ts +27 -0
- package/src/builder/login-page.ts +14 -16
- package/src/builder/routes.ts +24 -8
- package/src/builder/users.ts +24 -5
- package/src/builder/workspace-home.ts +7 -1
- package/src/cli/main.ts +58 -7
- package/src/instance-checks.ts +260 -0
- package/src/instance.ts +1 -1
- package/src/release-check.ts +10 -0
- package/src/runtime/routes.ts +39 -3
- package/src/runtime/two-step.ts +21 -1
- package/src/saml.ts +34 -11
- package/src/secrets.ts +16 -6
- package/src/server.ts +10 -0
- package/src/sso.ts +72 -11
- package/src/webauthn.ts +6 -2
package/src/builder/instance.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
<
|
|
52
|
-
|
|
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
|
|
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
|
-
<
|
|
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' };
|
package/src/builder/routes.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
827
|
-
|
|
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>
|
package/src/builder/users.ts
CHANGED
|
@@ -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,
|
|
477
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
}
|