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.
- package/SECURITY.md +4 -0
- package/db/migrations/131_builder_sso.sql +69 -0
- package/db/migrations/132_app_user_mfa.sql +58 -0
- package/docs/apex-feature-parity.md +5 -2
- package/docs/guide/03-builder.md +46 -0
- package/docs/guide/08-security.md +48 -6
- package/docs/guide/09-reference.md +5 -1
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -0
- package/docs/guide/18-cli.md +1 -0
- package/package.json +1 -1
- package/public/app.css +10 -0
- package/public/builder.css +4 -0
- package/src/account-mfa.ts +93 -0
- package/src/builder/advisor.ts +8 -0
- package/src/builder/builder-sso.ts +135 -0
- package/src/builder/i18n/de.ts +56 -0
- package/src/builder/i18n/en.ts +56 -0
- package/src/builder/i18n/es.ts +56 -0
- package/src/builder/i18n/fr.ts +56 -0
- package/src/builder/i18n/nl.ts +56 -0
- package/src/builder/i18n/pt.ts +56 -0
- package/src/builder/ldap.ts +2 -0
- package/src/builder/login-page.ts +26 -2
- package/src/builder/routes.ts +137 -21
- package/src/builder/security.ts +3 -1
- package/src/builder/ui.ts +2 -1
- package/src/builder/users.ts +36 -3
- package/src/builder/workspaces.ts +37 -3
- package/src/cli/main.ts +19 -5
- package/src/devsso.ts +149 -0
- package/src/i18n/ar.ts +34 -0
- package/src/i18n/cs.ts +34 -0
- package/src/i18n/da.ts +34 -0
- package/src/i18n/de.ts +34 -0
- package/src/i18n/el.ts +34 -0
- package/src/i18n/es.ts +34 -0
- package/src/i18n/fi.ts +34 -0
- package/src/i18n/fr.ts +34 -0
- package/src/i18n/he.ts +34 -0
- package/src/i18n/it.ts +34 -0
- package/src/i18n/ja.ts +34 -0
- package/src/i18n/ko.ts +34 -0
- package/src/i18n/nb.ts +34 -0
- package/src/i18n/pl.ts +34 -0
- package/src/i18n/pt.ts +34 -0
- package/src/i18n/ru.ts +34 -0
- package/src/i18n/sv.ts +34 -0
- package/src/i18n/tr.ts +34 -0
- package/src/i18n/uk.ts +34 -0
- package/src/i18n/zh.ts +34 -0
- package/src/i18n.ts +68 -0
- package/src/metadata.ts +4 -1
- package/src/mfa.ts +76 -52
- package/src/remember.ts +1 -1
- package/src/runtime/account.ts +4 -1
- package/src/runtime/routes.ts +24 -9
- package/src/runtime/two-step.ts +395 -0
- package/src/saml.ts +13 -7
- package/src/sharedlogin.ts +1 -1
- package/src/sso.ts +37 -7
package/src/builder/routes.ts
CHANGED
|
@@ -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
|
-
//
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
}
|
package/src/builder/security.ts
CHANGED
|
@@ -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
|
-
|
|
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`);
|
package/src/builder/users.ts
CHANGED
|
@@ -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="
|
|
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
|
|
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
|
|
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).
|
|
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
|
-
|
|
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
|
}
|