pgkiln 0.62.0 → 0.63.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 +2 -0
- package/db/migrations/131_builder_sso.sql +69 -0
- package/docs/apex-feature-parity.md +1 -1
- package/docs/guide/03-builder.md +46 -0
- package/docs/guide/08-security.md +2 -1
- package/docs/guide/12-development.md +3 -0
- package/docs/guide/18-cli.md +1 -0
- package/package.json +1 -1
- package/public/builder.css +4 -0
- package/src/builder/builder-sso.ts +135 -0
- package/src/builder/i18n/de.ts +50 -0
- package/src/builder/i18n/en.ts +50 -0
- package/src/builder/i18n/es.ts +50 -0
- package/src/builder/i18n/fr.ts +50 -0
- package/src/builder/i18n/nl.ts +50 -0
- package/src/builder/i18n/pt.ts +50 -0
- package/src/builder/ldap.ts +2 -0
- 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 +2 -0
- package/src/builder/workspaces.ts +37 -3
- package/src/cli/main.ts +19 -5
- package/src/devsso.ts +149 -0
- package/src/runtime/routes.ts +14 -3
- package/src/saml.ts +13 -7
- package/src/sso.ts +37 -7
package/src/builder/i18n/nl.ts
CHANGED
|
@@ -1820,4 +1820,54 @@ export const nl: BuilderMessages = {
|
|
|
1820
1820
|
'wsp.mfa_save': 'Tweestapsregel opslaan',
|
|
1821
1821
|
'wsp.mfa_on': 'De ontwikkelaars van deze werkruimte hebben tweestapsaanmelding nodig.',
|
|
1822
1822
|
'wsp.mfa_off': 'De werkruimte verplicht geen tweestapsaanmelding meer (het beleid van de instantie blijft gelden).',
|
|
1823
|
+
'signin.method': 'Aanmelden met',
|
|
1824
|
+
'signin.method_pgkiln': 'pgkiln-account',
|
|
1825
|
+
'signin.with': 'Aanmelden met {provider}',
|
|
1826
|
+
'signin.ldap_unavailable': '{directory} is niet bereikbaar. Probeer het later opnieuw.',
|
|
1827
|
+
'signin.sso_required': 'Uw werkruimte vereist single sign-on: gebruik uw identiteitsprovider.',
|
|
1828
|
+
'signin.sso_unavailable': '{provider} is niet bereikbaar. Probeer het later opnieuw.',
|
|
1829
|
+
'bsso.title': 'Aanmelden bij de builder',
|
|
1830
|
+
'bsso.intro': 'Ontwikkelaars kunnen met deze provider ook bij de builder aanmelden. Ze worden gevonden via de gekoppelde identiteit, nooit alleen via de naam.',
|
|
1831
|
+
'bsso.on': 'Ontwikkelaars kunnen hiermee bij de builder aanmelden',
|
|
1832
|
+
'bsso.on_help': 'De aanmeldpagina toont hem. Zo aanmelden slaat de eigen tweede factor van pgkiln over: de provider beslist.',
|
|
1833
|
+
'bsso.auto_create': 'Ontwikkelaars aanmaken bij hun eerste aanmelding',
|
|
1834
|
+
'bsso.auto_create_help': 'Voor leden van de ontwikkelaarsgroep hieronder (iedereen als die leeg is). Nieuwe ontwikkelaars komen in de werkruimte Default.',
|
|
1835
|
+
'bsso.link_existing': 'Een bestaande ontwikkelaar met dezelfde gebruikersnaam koppelen',
|
|
1836
|
+
'bsso.link_existing_help': 'Eenmalig, bij de eerste aanmelding; nooit een noodbeheerder. Zet het alleen aan als u de gebruikersnamen van de provider vertrouwt. Anders koppelen ontwikkelaars de identiteit op hun beveiligingspagina.',
|
|
1837
|
+
'bsso.group': 'Ontwikkelaarsgroep',
|
|
1838
|
+
'bsso.group_help': 'Alleen de leden krijgen een ontwikkelaarsaccount. Leeg: iedereen die de provider aanmeldt.',
|
|
1839
|
+
'bsso.admin_group': 'Beheerdersgroep',
|
|
1840
|
+
'bsso.admin_group_help': 'Indien ingevuld zijn de leden beheerder van de instantie en anderen niet, gecontroleerd bij elke aanmelding (noodbeheerders houden hun rol).',
|
|
1841
|
+
'bsso.save': 'Aanmelden bij de builder opslaan',
|
|
1842
|
+
'bsso.saved': 'Aanmelden bij de builder opgeslagen.',
|
|
1843
|
+
'bsso.linked': 'Gekoppelde ontwikkelaars',
|
|
1844
|
+
'bsso.none_linked': 'Nog geen ontwikkelaar gekoppeld.',
|
|
1845
|
+
'bsso.unlink': 'Ontkoppelen',
|
|
1846
|
+
'bsso.unlink_confirm': '{user} ontkoppelen? Diegene meldt zich aan met het wachtwoord of koppelt opnieuw.',
|
|
1847
|
+
'bsso.unlink_mine_confirm': '{provider} ontkoppelen? U kunt het opnieuw koppelen.',
|
|
1848
|
+
'bsso.unlinked': 'De identiteit is ontkoppeld.',
|
|
1849
|
+
'bsso.keep_one': 'Een werkruimte vereist single sign-on van deze ontwikkelaar: houd één gekoppelde identiteit.',
|
|
1850
|
+
'bsso.yours': 'Single sign-on',
|
|
1851
|
+
'bsso.yours_intro': 'De identiteitsproviders waarmee u bij de builder kunt aanmelden.',
|
|
1852
|
+
'bsso.none_yours': 'Nog geen identiteit gekoppeld.',
|
|
1853
|
+
'bsso.link': '{provider} koppelen',
|
|
1854
|
+
'bsso.linked_now': '{provider} is gekoppeld: u kunt ermee aanmelden.',
|
|
1855
|
+
'bsso.directories_note': 'Een LDAP-directory wordt gekoppeld als u er voor het eerst mee aanmeldt (een beheerder staat dat per directory toe).',
|
|
1856
|
+
'bsso.break_glass': 'noodtoegang',
|
|
1857
|
+
'bsso.break_glass_help': 'Mag met het pgkiln-wachtwoord aanmelden waar een werkruimte single sign-on vereist, voor als de identiteitsprovider uitvalt.',
|
|
1858
|
+
'bsso.break_glass_make': 'Noodtoegang geven',
|
|
1859
|
+
'bsso.break_glass_remove': 'Noodtoegang intrekken',
|
|
1860
|
+
'bsso.break_glass_on': '{user} is noodbeheerder. Geef diegene ook een tweede factor.',
|
|
1861
|
+
'bsso.break_glass_off': '{user} is geen noodbeheerder meer.',
|
|
1862
|
+
'bsso.break_glass_admins': 'Alleen beheerders kunnen noodtoegang hebben.',
|
|
1863
|
+
'bsso.keep_break_glass': 'Een werkruimte vereist single sign-on: houd minstens één noodbeheerder.',
|
|
1864
|
+
'wsp.sso': 'Single sign-on',
|
|
1865
|
+
'wsp.sso_only': 'De ontwikkelaars van deze werkruimte moeten aanmelden via een identiteitsprovider',
|
|
1866
|
+
'wsp.sso_only_help': 'Of een LDAP-directory. Het pgkiln-wachtwoord en alleen een passkey worden geweigerd, behalve voor noodbeheerders. `pgkiln admin builder-sign-in password` zet het uit.',
|
|
1867
|
+
'wsp.sso_unlinked': 'Nog geen gekoppelde identiteit (kunnen niet aanmelden): {list}.',
|
|
1868
|
+
'wsp.sso_save': 'Single-sign-onregel opslaan',
|
|
1869
|
+
'wsp.sso_on': 'De ontwikkelaars van deze werkruimte moeten single sign-on gebruiken.',
|
|
1870
|
+
'wsp.sso_off': 'De ontwikkelaars van de werkruimte mogen weer het pgkiln-wachtwoord gebruiken.',
|
|
1871
|
+
'wsp.sso_no_break_glass': 'Geef eerst een beheerder noodtoegang (Ontwikkelaars), zodat iemand kan aanmelden als de identiteitsprovider uitvalt.',
|
|
1872
|
+
'wsp.sso_link_first': 'Koppel eerst een identiteit op uw beveiligingspagina, anders kunt u zelf niet aanmelden.',
|
|
1823
1873
|
};
|
package/src/builder/i18n/pt.ts
CHANGED
|
@@ -1820,4 +1820,54 @@ export const pt: BuilderMessages = {
|
|
|
1820
1820
|
'wsp.mfa_save': 'Salvar a regra de duas etapas',
|
|
1821
1821
|
'wsp.mfa_on': 'Os desenvolvedores deste espaço de trabalho precisam do login em duas etapas.',
|
|
1822
1822
|
'wsp.mfa_off': 'O espaço de trabalho já não exige login em duas etapas (a política da instância continua valendo).',
|
|
1823
|
+
'signin.method': 'Entrar com',
|
|
1824
|
+
'signin.method_pgkiln': 'Conta do pgkiln',
|
|
1825
|
+
'signin.with': 'Entrar com {provider}',
|
|
1826
|
+
'signin.ldap_unavailable': 'Não é possível acessar {directory}. Tente mais tarde.',
|
|
1827
|
+
'signin.sso_required': 'Seu espaço de trabalho exige login único: use seu provedor de identidade.',
|
|
1828
|
+
'signin.sso_unavailable': 'Não é possível acessar {provider}. Tente mais tarde.',
|
|
1829
|
+
'bsso.title': 'Login no builder',
|
|
1830
|
+
'bsso.intro': 'Os desenvolvedores também podem entrar no builder com este provedor. Eles são reconhecidos pela identidade vinculada, nunca só pelo nome.',
|
|
1831
|
+
'bsso.on': 'Os desenvolvedores podem entrar no builder com ele',
|
|
1832
|
+
'bsso.on_help': 'A página de login o mostra. Entrar assim dispensa o segundo fator do próprio pgkiln: o provedor decide.',
|
|
1833
|
+
'bsso.auto_create': 'Criar desenvolvedores no primeiro login',
|
|
1834
|
+
'bsso.auto_create_help': 'Para membros do grupo de desenvolvedores abaixo (qualquer um, se estiver vazio). Novos desenvolvedores entram no espaço de trabalho Default.',
|
|
1835
|
+
'bsso.link_existing': 'Vincular um desenvolvedor existente com o mesmo nome de usuário',
|
|
1836
|
+
'bsso.link_existing_help': 'Uma vez, no primeiro login; nunca um administrador de emergência. Ative só se confiar nos nomes de usuário do provedor. Caso contrário, os desenvolvedores vinculam a identidade na página de segurança.',
|
|
1837
|
+
'bsso.group': 'Grupo de desenvolvedores',
|
|
1838
|
+
'bsso.group_help': 'Só seus membros recebem uma conta de desenvolvedor. Vazio: qualquer pessoa que o provedor autentique.',
|
|
1839
|
+
'bsso.admin_group': 'Grupo de administradores',
|
|
1840
|
+
'bsso.admin_group_help': 'Se preenchido, seus membros são administradores da instância e os demais não, verificado a cada login (administradores de emergência mantêm a função).',
|
|
1841
|
+
'bsso.save': 'Salvar o login no builder',
|
|
1842
|
+
'bsso.saved': 'Login no builder salvo.',
|
|
1843
|
+
'bsso.linked': 'Desenvolvedores vinculados',
|
|
1844
|
+
'bsso.none_linked': 'Ainda não há desenvolvedor vinculado.',
|
|
1845
|
+
'bsso.unlink': 'Desvincular',
|
|
1846
|
+
'bsso.unlink_confirm': 'Desvincular {user}? A pessoa entra com a senha ou vincula de novo.',
|
|
1847
|
+
'bsso.unlink_mine_confirm': 'Desvincular {provider}? Você pode vinculá-lo de novo.',
|
|
1848
|
+
'bsso.unlinked': 'A identidade foi desvinculada.',
|
|
1849
|
+
'bsso.keep_one': 'Um espaço de trabalho exige login único deste desenvolvedor: mantenha uma identidade vinculada.',
|
|
1850
|
+
'bsso.yours': 'Login único',
|
|
1851
|
+
'bsso.yours_intro': 'Os provedores de identidade com que você pode entrar no builder.',
|
|
1852
|
+
'bsso.none_yours': 'Ainda não há identidade vinculada.',
|
|
1853
|
+
'bsso.link': 'Vincular {provider}',
|
|
1854
|
+
'bsso.linked_now': '{provider} está vinculado: você pode entrar com ele.',
|
|
1855
|
+
'bsso.directories_note': 'Um diretório LDAP é vinculado no primeiro login com ele (um administrador permite isso por diretório).',
|
|
1856
|
+
'bsso.break_glass': 'de emergência',
|
|
1857
|
+
'bsso.break_glass_help': 'Pode entrar com a senha do pgkiln onde um espaço de trabalho exige login único, para quando o provedor de identidade falhar.',
|
|
1858
|
+
'bsso.break_glass_make': 'Dar acesso de emergência',
|
|
1859
|
+
'bsso.break_glass_remove': 'Remover acesso de emergência',
|
|
1860
|
+
'bsso.break_glass_on': '{user} é administrador de emergência. Configure também um segundo fator.',
|
|
1861
|
+
'bsso.break_glass_off': '{user} não é mais administrador de emergência.',
|
|
1862
|
+
'bsso.break_glass_admins': 'Só administradores podem ter acesso de emergência.',
|
|
1863
|
+
'bsso.keep_break_glass': 'Um espaço de trabalho exige login único: mantenha pelo menos um administrador de emergência.',
|
|
1864
|
+
'wsp.sso': 'Login único',
|
|
1865
|
+
'wsp.sso_only': 'Os desenvolvedores deste espaço de trabalho devem entrar por um provedor de identidade',
|
|
1866
|
+
'wsp.sso_only_help': 'Ou um diretório LDAP. A senha do pgkiln e uma chave de acesso sozinha são recusadas, exceto para administradores de emergência. `pgkiln admin builder-sign-in password` desativa.',
|
|
1867
|
+
'wsp.sso_unlinked': 'Ainda sem identidade vinculada (não podem entrar): {list}.',
|
|
1868
|
+
'wsp.sso_save': 'Salvar a regra de login único',
|
|
1869
|
+
'wsp.sso_on': 'Os desenvolvedores deste espaço de trabalho devem usar login único.',
|
|
1870
|
+
'wsp.sso_off': 'Os desenvolvedores do espaço de trabalho podem voltar a usar a senha do pgkiln.',
|
|
1871
|
+
'wsp.sso_no_break_glass': 'Primeiro dê acesso de emergência a um administrador (Desenvolvedores), para que alguém possa entrar se o provedor de identidade falhar.',
|
|
1872
|
+
'wsp.sso_link_first': 'Vincule primeiro uma identidade na sua página de segurança, ou você mesmo não conseguiria entrar.',
|
|
1823
1873
|
};
|
package/src/builder/ldap.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
|
|
|
3
3
|
import { html, raw } from '../html.ts';
|
|
4
4
|
import { testDirectory, type Directory } from '../ldap.ts';
|
|
5
5
|
import { administrator, back, BASE, csrf, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
6
|
+
import { builderSignInRegion } from './builder-sso.ts';
|
|
6
7
|
|
|
7
8
|
// Users → LDAP directories: the directories an application's password form
|
|
8
9
|
// can check (enabled per app under Settings → Sign-in methods). The bind
|
|
@@ -114,6 +115,7 @@ export async function ldapRoutes(app: FastifyInstance) {
|
|
|
114
115
|
<form method="post" action="${BASE}/users/directories/${d.id}/test" class="search u-mwnone">${csrf(s)}
|
|
115
116
|
<input name="username" placeholder="Username (optional)" aria-label="Username to look up" maxlength="100">
|
|
116
117
|
<button class="btn">Test</button></form>`)}
|
|
118
|
+
${await builderSignInRegion(s, 'directory', d)}
|
|
117
119
|
</div>`;
|
|
118
120
|
return send(reply, s, shell(s, d.display_name, [['Users', `${BASE}/users`], ['LDAP directories', `${BASE}/users/directories`], [d.display_name]], main, 'users'));
|
|
119
121
|
});
|
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
|
@@ -7,6 +7,7 @@ import { accountSettings, clearAccountSettings, passwordProblem } from '../accou
|
|
|
7
7
|
import { loginMaxFailuresPerUser, loginWindowMinutes } from '../security.ts';
|
|
8
8
|
import { acsUrl, metadataUrl, spEntityId } from '../saml.ts';
|
|
9
9
|
import { discover, publicUrl, redirectUri } from '../sso.ts';
|
|
10
|
+
import { builderSignInRegion } from './builder-sso.ts';
|
|
10
11
|
import { administrator, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
|
|
11
12
|
|
|
12
13
|
// The workspace user directory (like APEX's workspace users with
|
|
@@ -476,6 +477,7 @@ export async function usersRoutes(app: FastifyInstance) {
|
|
|
476
477
|
<p class="muted">${tx.rich('idp.public_url', { env: html`<code>PUBLIC_URL</code>` })}</p>
|
|
477
478
|
<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
479
|
<form method="post" action="${BASE}/users/providers/${pr.id}/test">${csrf(s)}<button class="btn">${tx('idp.test')}</button></form>`)}
|
|
480
|
+
${await builderSignInRegion(s, 'provider', pr)}
|
|
479
481
|
</div>`;
|
|
480
482
|
return send(reply, s, shell(s, pr.display_name, [['Users', `${BASE}/users`], [tx('idp.title'), `${BASE}/users/providers`], [pr.display_name]], main, 'users'));
|
|
481
483
|
});
|
|
@@ -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
|
}
|