pgkiln 0.62.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/SECURITY.md +4 -0
  2. package/db/migrations/131_builder_sso.sql +69 -0
  3. package/db/migrations/132_app_user_mfa.sql +58 -0
  4. package/docs/apex-feature-parity.md +5 -2
  5. package/docs/guide/03-builder.md +46 -0
  6. package/docs/guide/08-security.md +48 -6
  7. package/docs/guide/09-reference.md +5 -1
  8. package/docs/guide/11-from-apex.md +2 -1
  9. package/docs/guide/12-development.md +9 -0
  10. package/docs/guide/18-cli.md +1 -0
  11. package/package.json +1 -1
  12. package/public/app.css +10 -0
  13. package/public/builder.css +4 -0
  14. package/src/account-mfa.ts +93 -0
  15. package/src/builder/advisor.ts +8 -0
  16. package/src/builder/builder-sso.ts +135 -0
  17. package/src/builder/i18n/de.ts +56 -0
  18. package/src/builder/i18n/en.ts +56 -0
  19. package/src/builder/i18n/es.ts +56 -0
  20. package/src/builder/i18n/fr.ts +56 -0
  21. package/src/builder/i18n/nl.ts +56 -0
  22. package/src/builder/i18n/pt.ts +56 -0
  23. package/src/builder/ldap.ts +2 -0
  24. package/src/builder/login-page.ts +26 -2
  25. package/src/builder/routes.ts +137 -21
  26. package/src/builder/security.ts +3 -1
  27. package/src/builder/ui.ts +2 -1
  28. package/src/builder/users.ts +36 -3
  29. package/src/builder/workspaces.ts +37 -3
  30. package/src/cli/main.ts +19 -5
  31. package/src/devsso.ts +149 -0
  32. package/src/i18n/ar.ts +34 -0
  33. package/src/i18n/cs.ts +34 -0
  34. package/src/i18n/da.ts +34 -0
  35. package/src/i18n/de.ts +34 -0
  36. package/src/i18n/el.ts +34 -0
  37. package/src/i18n/es.ts +34 -0
  38. package/src/i18n/fi.ts +34 -0
  39. package/src/i18n/fr.ts +34 -0
  40. package/src/i18n/he.ts +34 -0
  41. package/src/i18n/it.ts +34 -0
  42. package/src/i18n/ja.ts +34 -0
  43. package/src/i18n/ko.ts +34 -0
  44. package/src/i18n/nb.ts +34 -0
  45. package/src/i18n/pl.ts +34 -0
  46. package/src/i18n/pt.ts +34 -0
  47. package/src/i18n/ru.ts +34 -0
  48. package/src/i18n/sv.ts +34 -0
  49. package/src/i18n/tr.ts +34 -0
  50. package/src/i18n/uk.ts +34 -0
  51. package/src/i18n/zh.ts +34 -0
  52. package/src/i18n.ts +68 -0
  53. package/src/metadata.ts +4 -1
  54. package/src/mfa.ts +76 -52
  55. package/src/remember.ts +1 -1
  56. package/src/runtime/account.ts +4 -1
  57. package/src/runtime/routes.ts +24 -9
  58. package/src/runtime/two-step.ts +395 -0
  59. package/src/saml.ts +13 -7
  60. package/src/sharedlogin.ts +1 -1
  61. package/src/sso.ts +37 -7
package/SECURITY.md CHANGED
@@ -115,6 +115,8 @@ first version (MVP). Every finding below has a regression test in
115
115
  | Two-factor sign-in for developers (0.60.0) | TOTP secrets (RFC 6238, 160 bits) are encrypted with `PGKILN_SECRET_KEY` and stored only after a code from the app confirms them; recovery codes (10 × 50 bits) are stored as SHA-256 and used up atomically; a code is accepted once (the last step is updated atomically) and only one 30-second step either side. The password step leaves only a pending sign-in in the session (5 minutes, no session for the developer yet); wrong codes count with wrong passwords for the per-user and per-IP lockout. Removing an app needs the password; another developer's factors can only be reset by an administrator, with a reason, which ends that developer's builder sessions. The require policy can only be turned on, and stays enforceable, with at least two administrators (or every one) holding an app; there is no setting, URL or variable that skips the second step: recovery is `pgkiln admin reset-2fa` / `set-2fa-policy off` on the server with the owner connection. Every enrolment, removal, reset, policy change and used recovery code is in the activity log; resets and policy changes are mailed to the administrators. The factor tables are closed to the runtime role. |
116
116
  | Passkeys and trusted browsers (0.61.0) | Passkeys (WebAuthn) are checked in `src/webauthn.ts` without a library: a 32-byte one-time challenge per purpose (registration or sign-in) kept in the session for five minutes and used up on the first answer, the `clientDataJSON` type, challenge and origin (`PUBLIC_URL`'s, else the browser's own), the SHA-256 of the relying party id in the authenticator data, user presence, an ES256, EdDSA or RS256 signature over the authenticator data and the client data hash, and a signature counter that must go up (updated atomically, so a replayed or cloned key is refused). The CBOR reader takes only definite lengths, at most 16 levels and 1,000 entries. Credentials are public keys (no secret to leak, independent of the instance key); attestation is *none*. `PGKILN_RP_ID` is only honoured when it is the host or a parent domain of it. The sign-in options (the developer's credential ids) are only given after the password step, with the CSRF token. Trusted browsers (off by default) are a random 256-bit cookie token (`HttpOnly`, `SameSite=Lax`, `Secure` with `COOKIE_SECURE`, path `/builder`) of which only the SHA-256 is stored; the cookie is only looked at after a correct password, for that developer, and ends with its days, a new password, removing a factor, a reset, *Forget trusted browsers* or turning trust off (which removes every trusted browser at once). |
117
117
  | Passkey sign-in without a password, workspace rule (0.62.0) | *Sign in with a passkey* asks the browser for any passkey of this site (empty allow list) with `userVerification: required`, and the server refuses an answer whose authenticator data lacks the user-verified flag, so the passkey alone stands for possession plus a fingerprint, face or PIN. The credential id is looked up for this relying party only; a user handle, when the browser sends one, must be the developer's (a SHA-256-derived handle, not the user name). The same one-time challenge, origin, RP ID hash, signature and counter checks as at the second step apply; failures are logged as `login_failed` with the developer's name when the key is known and count for the per-user and per-IP lockout. Administrators turn it off in Instance settings. A workspace's rule (administrators and its own administrators only, CSRF-checked) can only add a requirement; `pgkiln admin set-2fa-policy off` clears every workspace's rule. While a second factor is required of a developer, a sign-in with the default password leads only to the password change. |
118
+ | Single sign-on for developers (0.63.0) | A provider or directory signs developers in only with *builder* turned on. The flow is the applications' (state, PKCE, nonce, browser-binding cookie, ID token or SAML assertion checks) with a pending row without an application; developers are found by the provider's stable subject in `meta.developer_identity`, never by a name alone. An existing developer is linked by name only when the administrator allows it, once, never a break-glass administrator, and an e-mail user name only when verified; otherwise the developer links the identity on Security, which needs their session and CSRF token to start and the same browser to finish, and one identity can't be linked to two developers. Automatic accounts need the developers group when one is set and start as developers; the administrators group decides the role at every sign-in but never demotes a break-glass administrator. A workspace's single sign-on rule (administrators and its own administrators, CSRF-checked) refuses the pgkiln password and a passkey alone, after the password check so it reveals nothing; it can only be turned on while a break-glass administrator exists and by someone who could still sign in; the last break-glass administrator and a developer's last identity can't be removed while it applies. Failures are logged as `login_failed` with the reason. |
119
+ | Two-factor sign-in for application users (0.64.0) | Per application (`meta.app.mfa`: off, optional, required, required for roles). The account's TOTP secrets are encrypted with `PGKILN_SECRET_KEY` and stored only after a code confirms them; recovery codes are SHA-256 and used up atomically; each 30-second step once, one step either side. A password or LDAP sign-in that needs the step creates no session: the pending sign-in (5 minutes, 10 while adding an app) lives in the signed-out session, bound to it and to the page's CSRF token, and a forged token ends it. Wrong codes count as failed sign-ins (the per-user and per-IP lockout). A *Keep me signed in* or session-sharing token stands in for the step only when its method ends in `+2fa`; an identity provider's sign-in is left to the provider. Without the instance key the step can't be required and enrolment refuses rather than skips. Removing an app needs a current or recovery code and is refused for the last app while the application requires it; a developer's reset (Users, with a reason) removes the apps and codes and ends the account's sessions, remembered and shared sign-ins. Every enrolment, removal, reset and used recovery code is in the activity log. The factor tables are closed to the runtime roles. |
118
120
 
119
121
  ## Findings of the MVP review and their fixes
120
122
 
@@ -238,3 +240,5 @@ A second full review, mainly of what end users and API callers can reach. Regres
238
240
  9. With header authentication: list only your proxy's addresses in `PGKILN_AUTH_HEADER_PROXIES`, make sure users can't reach pgkiln directly, and have the proxy remove any incoming copy of the header.
239
241
  10. With database-account authentication: list exact roles or use a dedicated membership role, and limit what those roles may connect to in `pg_hba.conf` (they can reach the database with their password outside pgkiln too).
240
242
  11. Turn on two-factor sign-in for the builder (Instance settings → Two-factor sign-in, at least for administrators) once two administrators have a passkey or an authenticator app; set `PUBLIC_URL` to the address developers use (passkeys belong to its host name), require it per workspace where the instance policy is too broad, keep *Trust a browser* short or at 0 on shared machines, keep `PGKILN_SECRET_KEY` backed up, and keep the recovery commands (`pgkiln admin reset-2fa`) for people with server access only.
243
+ 12. With single sign-on for developers: leave *Link an existing developer with the same user name* off unless you trust the provider's user names, set a developers group for automatic accounts, make at least one administrator break-glass (with a second factor and a password kept safe) before a workspace requires single sign-on, and keep `pgkiln admin builder-sign-in password` for people with server access only.
244
+ 13. Require two-factor sign-in of an application's users at least for administrators (Application settings → Sign-in methods → Two-factor sign-in → *Required for some roles*: `admin`); the Advisor warns when it is missing. Keep `PGKILN_SECRET_KEY` backed up: without it the authenticator apps stop working and users need their recovery codes or a reset.
@@ -0,0 +1,69 @@
1
+ -- ---------------------------------------------------------------------
2
+ -- 131: signing in to the builder through an identity provider, with a
3
+ -- break-glass administrator (sprint 129, SEC-1; APEX: the workspace's
4
+ -- authentication scheme).
5
+ --
6
+ -- meta.auth_provider / meta.ldap_directory.builder: the provider (OpenID
7
+ -- Connect, SAML) or directory also signs developers in to the builder.
8
+ -- builder_auto_create: a person without a developer account gets one on
9
+ -- the first sign-in, when builder_group is empty or one of their groups;
10
+ -- builder_link_existing: a developer with the same username is linked on
11
+ -- the first sign-in (never a break-glass developer); otherwise developers
12
+ -- link the identity themselves on the Security page;
13
+ -- builder_admin_group: members are instance administrators (checked on
14
+ -- every sign-in; break-glass developers are never demoted).
15
+ -- meta.developer_identity: the identities linked to a developer, by the
16
+ -- provider's or directory's stable subject (as meta.account_identity and
17
+ -- meta.ldap_identity for application accounts).
18
+ -- meta.developer.break_glass: may still sign in with the pgkiln password
19
+ -- (and passkey) where a workspace requires single sign-on; for when the
20
+ -- identity provider is down or misconfigured.
21
+ -- meta.workspace.sso_only: the workspace's developers must sign in through
22
+ -- an identity provider or directory (break-glass developers excepted).
23
+ -- `pgkiln admin builder-sign-in password` clears it everywhere.
24
+ -- meta.sso_pending.app_id null: a sign-in to the builder; link_username: the
25
+ -- signed-in developer who links an identity from the Security page.
26
+ -- ---------------------------------------------------------------------
27
+ alter table meta.auth_provider
28
+ add column builder boolean not null default false,
29
+ add column builder_auto_create boolean not null default false,
30
+ add column builder_link_existing boolean not null default false,
31
+ add column builder_group text check (length(builder_group) between 1 and 200),
32
+ add column builder_admin_group text check (length(builder_admin_group) between 1 and 200);
33
+
34
+ alter table meta.ldap_directory
35
+ add column builder boolean not null default false,
36
+ add column builder_auto_create boolean not null default false,
37
+ add column builder_link_existing boolean not null default false,
38
+ add column builder_group text check (length(builder_group) between 1 and 200),
39
+ add column builder_admin_group text check (length(builder_admin_group) between 1 and 200);
40
+
41
+ comment on column meta.auth_provider.builder is 'Developers can sign in to the builder with this provider (131).';
42
+ comment on column meta.ldap_directory.builder is 'Developers can sign in to the builder with this directory (131).';
43
+
44
+ create table meta.developer_identity (
45
+ id serial primary key,
46
+ provider_id integer references meta.auth_provider (id) on delete cascade,
47
+ directory_id integer references meta.ldap_directory (id) on delete cascade,
48
+ subject text not null check (length(subject) between 1 and 500),
49
+ username text not null references meta.developer (username) on update cascade on delete cascade,
50
+ linked_at timestamptz not null default now(),
51
+ check ((provider_id is null) <> (directory_id is null))
52
+ );
53
+ create unique index developer_identity_provider_subject on meta.developer_identity (provider_id, subject) where provider_id is not null;
54
+ create unique index developer_identity_directory_subject on meta.developer_identity (directory_id, subject) where directory_id is not null;
55
+ create unique index developer_identity_provider_user on meta.developer_identity (provider_id, username) where provider_id is not null;
56
+ create unique index developer_identity_directory_user on meta.developer_identity (directory_id, username) where directory_id is not null;
57
+ create index developer_identity_username on meta.developer_identity (username);
58
+
59
+ comment on table meta.developer_identity is 'Identities at providers or directories linked to a developer, by stable subject (131).';
60
+
61
+ alter table meta.developer add column break_glass boolean not null default false;
62
+ comment on column meta.developer.break_glass is 'May sign in with the pgkiln password where a workspace requires single sign-on (131).';
63
+
64
+ alter table meta.workspace add column sso_only boolean not null default false;
65
+ comment on column meta.workspace.sso_only is 'Developers of this workspace must sign in through an identity provider or directory (131).';
66
+
67
+ alter table meta.sso_pending alter column app_id drop not null;
68
+ alter table meta.sso_pending add column link_username text references meta.developer (username) on update cascade on delete cascade;
69
+ comment on column meta.sso_pending.app_id is 'The application signed in to; null: the builder (131).';
@@ -0,0 +1,58 @@
1
+ -- ---------------------------------------------------------------------
2
+ -- 132: two-step sign-in for application users (sprint 130, SEC-3 part 1;
3
+ -- beyond APEX, which has no second factor for application accounts).
4
+ --
5
+ -- meta.account_factor: an account's second factors. This sprint: an
6
+ -- authenticator app (TOTP, RFC 6238), its secret encrypted with the
7
+ -- instance key (PGKILN_SECRET_KEY), like a developer's (128). last_step is
8
+ -- the last 30-second step a code was accepted for, so a code works once.
9
+ -- An account's factors serve every application it may use.
10
+ --
11
+ -- meta.account_recovery_code: ten one-time codes per account, as SHA-256
12
+ -- of the normalised code; confirmed: the user said they saved them.
13
+ --
14
+ -- meta.app.mfa, per application (exported with it):
15
+ -- off : no second step (the default)
16
+ -- optional : users who added an authenticator app give a code
17
+ -- required : every user gives a code; without an app, they add one while
18
+ -- signing in
19
+ -- roles : required for users with one of mfa_roles, optional for others
20
+ -- It covers the user directory's passwords and LDAP directories; an identity
21
+ -- provider (OpenID Connect, SAML) decides on its own second factor.
22
+ --
23
+ -- Owner-only, like meta.persistent_login: the runtime roles never read them.
24
+ -- Installation data: not exported with an application or a workspace.
25
+ -- ---------------------------------------------------------------------
26
+ create table meta.account_factor (
27
+ id int generated always as identity primary key,
28
+ account_id int not null references meta.account on delete cascade,
29
+ kind text not null check (kind in ('totp')),
30
+ name text not null check (length(name) between 1 and 60),
31
+ secret_enc text not null,
32
+ last_step bigint,
33
+ created_at timestamptz not null default now(),
34
+ last_used_at timestamptz
35
+ );
36
+ create index on meta.account_factor (account_id);
37
+ revoke all on meta.account_factor from public;
38
+ comment on table meta.account_factor is
39
+ 'An application account''s second factors (132): authenticator apps (TOTP) with the secret encrypted with the instance key.';
40
+
41
+ create table meta.account_recovery_code (
42
+ account_id int not null references meta.account on delete cascade,
43
+ code_hash text not null,
44
+ confirmed boolean not null default false,
45
+ created_at timestamptz not null default now(),
46
+ used_at timestamptz,
47
+ primary key (account_id, code_hash)
48
+ );
49
+ revoke all on meta.account_recovery_code from public;
50
+ comment on table meta.account_recovery_code is
51
+ 'One-time recovery codes of application accounts (132), as sha256 hex of the normalised code.';
52
+
53
+ alter table meta.app
54
+ add column mfa text not null default 'off' check (mfa in ('off', 'optional', 'required', 'roles')),
55
+ add column mfa_roles text[] not null default '{}';
56
+ comment on column meta.app.mfa is
57
+ 'Two-step sign-in for the application''s users (132): off, optional, required, or roles (required for mfa_roles).';
58
+ comment on column meta.app.mfa_roles is 'Roles (lower case) that must use two-step sign-in when mfa = roles.';
@@ -138,7 +138,7 @@ the 26.2 comparison.)
138
138
  | APEX accounts | ✅ | Instance-wide user directory, bcrypt, session rotation, idle and absolute timeouts |
139
139
  | Application Access Control (roles per app, any-user switch) | ✅ | Access control per application |
140
140
  | Social sign-in / OpenID Connect | ✅ | Any OIDC provider (Entra ID, Google, Okta, Keycloak, …): PKCE, group → role mapping, account linking, automatic accounts |
141
- | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–128): the builder itself has two-factor sign-in with passkeys (WebAuthn, also on their own without a password), authenticator apps (TOTP) and recovery codes, trusted browsers, an instance and per-workspace policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)); two-factor for application users follows (SEC-3) |
141
+ | Multi-factor authentication | ✅ | As in APEX: through the identity provider (OpenID Connect, SAML). Beyond APEX (sprints 126–128): the builder itself has two-factor sign-in with passkeys (WebAuthn, also on their own without a password), authenticator apps (TOTP) and recovery codes, trusted browsers, an instance and per-workspace policy with a grace period and lockout recovery ([chapter 3](guide/03-builder.md#two-factor-sign-in)). Sprint 130: application users too, per application (off, optional, required, required for roles) with authenticator apps and recovery codes, added while signing in or on My account, reset from Users ([chapter 8](guide/08-security.md#two-factor-sign-in)) |
142
142
  | Change own password | ✅ | A built-in *My account* page (APEX offers the API `CHANGE_CURRENT_USER_PW`) |
143
143
  | Password expiry, change on first use, admin reset, complexity | ✅ | Lifetime in days, expire/unexpire, admin reset, length and letters+digits rules, unlock |
144
144
  | Lockout after failed sign-ins | ✅ | Per user and per IP, time-based (APEX: until an administrator unlocks) |
@@ -221,7 +221,7 @@ the 26.2 comparison.)
221
221
 
222
222
  | APEX | pgkiln | Notes |
223
223
  |---|---|---|
224
- | Developer accounts, workspace sign-in | ✅ | (sprint 61) The builder's sign-in page like APEX's: workspace (optional; it becomes the current one, a wrong one answers like a wrong password), username, password with a Show button, *Remember workspace and username*, a link to Administration. (sprint 68) **Find my workspace**: developers' e-mail addresses, and a mail with their workspaces through the builder's mail service (the same answer for every address, limited per address and IP). (sprint 121) The sign-in page's language links: Deutsch, English, Español, Français, Nederlands, Português (WS-7) |
224
+ | Developer accounts, workspace sign-in | ✅ | (sprint 61) The builder's sign-in page like APEX's: workspace (optional; it becomes the current one, a wrong one answers like a wrong password), username, password with a Show button, *Remember workspace and username*, a link to Administration. (sprint 68) **Find my workspace**: developers' e-mail addresses, and a mail with their workspaces through the builder's mail service (the same answer for every address, limited per address and IP). (sprint 121) The sign-in page's language links: Deutsch, English, Español, Français, Nederlands, Português (WS-7). (sprint 129, SEC-1) **Single sign-on for developers** (APEX: the workspace's authentication scheme): the applications' OpenID Connect and SAML providers and LDAP directories can sign developers in to the builder, linked by the provider's stable subject (or by the developer on Security), created from a developers group, with an administrators group; a workspace can require it of its developers, with break-glass administrators who keep the password and `pgkiln admin builder-sign-in password` ([chapter 3](guide/03-builder.md#single-sign-on-for-developers)) |
225
225
  | Workspace home (tool cards, top apps and users, summary, workspace and system message) | ✅ | (sprint 109) `/builder/home` opens after sign-in ([chapter 3](guide/03-builder.md#workspace-home)): App Builder, SQL Workshop, Data Reporter and App Gallery cards; the workspace message edited in place; a Summary of applications, tables, developers and reporting applications; top applications, users and developers over an hour, a day or a week; a system message with the server's version and, with the optional release check (`PGKILN_RELEASE_CHECK`, off by default), *pgkiln X is available*. The App Builder moved to `/builder/apps`; `/builder` opens either, per workspace. |
226
226
  | Workspace message, environment banner, workspace utilization, preferences and extension links (Manage Service) | ✅ | (sprint 61) An **Administration** menu in the icon rail; administrators and workspace administrators set a **workspace message** (on the App Builder home) and an **environment banner** (a coloured label on every builder page of the workspace), and see **workspace utilization** (applications, pages, page views, users, static files, schema storage) ([chapter 3](guide/03-builder.md#workspace-message-and-environment-banner)). (sprint 62) **Workspace preferences**: the SQL Workshop and the applications' RESTful services (REST modules) on or off per workspace; **extension links** in an Extensions menu of the rail ([chapter 3](guide/03-builder.md#workspace-preferences)). **Missing:** the account login controls of APEX's preferences (pgkiln's password policy and sign-in throttling are installation-wide) |
227
227
  | Instance administration, install/upgrade logs (26.1) | ✅ | Versioned migrations (`npm run db:migrate`), every run logged; Workspace utilities → **Installation** (administrators): server version, install and upgrade runs with errors, applied migrations, and warnings for migrations the database misses or the server doesn't know ([chapter 3](guide/03-builder.md)). Workspace utilities → **Instance settings**: session length and sign-in throttling (stored in the database, over the environment variables) and an overview of the server's configuration without secrets; a server whose database lacks migrations says so (503) or applies them (`MIGRATE_ON_START`). Other settings stay environment variables |
@@ -300,6 +300,9 @@ Small but real differences, for teams comparing the two:
300
300
  passkey alone), authenticator apps (TOTP), recovery codes, a policy for administrators, all developers or a
301
301
  workspace's developers with a grace period, and lockout recovery on the
302
302
  command line ([chapter 3](guide/03-builder.md#two-factor-sign-in)). APEX relies on the identity provider.
303
+ - (sprint 130) **Two-factor sign-in for application users**: per application and per role, with authenticator apps
304
+ and recovery codes, added while signing in when required ([chapter 8](guide/08-security.md#two-factor-sign-in)).
305
+ APEX's application authentication schemes have no second factor.
303
306
  - One RLS policy set protects both the web UI and the REST API.
304
307
  - An import can be checked before it commits (`pgkiln import --check`): SQL that names a missing
305
308
  table or column refuses the deployment instead of failing at run time.
@@ -111,6 +111,51 @@ setting the days to 0 stops every trusted browser at once.
111
111
  `2fa_enrolled`, `2fa_recovery_used`), and resets and policy changes are mailed to the administrators when the
112
112
  builder has a mail service (*Builder mail*).
113
113
 
114
+ ### Single sign-on for developers
115
+
116
+ (0.63) Developers can sign in to the builder through the organisation's **identity provider** (OpenID Connect or SAML
117
+ 2.0) or an **LDAP directory**, the ones applications use (Workspace utilities → Users → *Identity providers*,
118
+ *LDAP directories*; [chapter 8](08-security.md)). APEX does this with the workspace's authentication scheme.
119
+
120
+ **Turning it on.** On the provider's or directory's page, administrators fill in **Builder sign-in**:
121
+
122
+ - *Developers can sign in to the builder with it*: the sign-in page shows **Sign in with Company IdP** under the
123
+ password form. A directory adds a **Sign in with** choice above the user name (*pgkiln account* or the directory),
124
+ and the password goes to the directory. The provider's redirect URI (or SAML ACS URL) is the one registered for
125
+ applications already.
126
+ - *Create developers on their first sign-in*, for members of the **Developers group** (anyone, when it is empty).
127
+ They start as developers in the Default workspace, with the provider's e-mail address and a password nobody knows.
128
+ - **Administrators group**: when set, its members are instance administrators and nobody else is, checked at every
129
+ sign-in (break-glass administrators, below, are never demoted).
130
+ - *Link an existing developer with the same user name*: once, at the first sign-in, never a break-glass
131
+ administrator, and an e-mail address as user name only when the provider says it is verified. Leave it off unless
132
+ you trust the provider's user names; developers can link themselves instead.
133
+
134
+ **Linking.** A developer is found by the provider's stable subject (`sub`, the SAML NameID, the entry's `entryUUID`),
135
+ never by a name the provider sends. A developer who signs in with the password links an identity on **Security** →
136
+ *Single sign-on* → **Link Company IdP**: the provider's page opens and the identity is linked to the developer who
137
+ started it (in the same browser). The region lists linked identities, with **Unlink**. A provider's page lists the
138
+ developers linked to it; administrators unlink any of them.
139
+
140
+ Signing in through a provider skips pgkiln's own second step and the enrolment on Security: the provider decides on
141
+ the second factor. A directory checks a password, so pgkiln's second step follows as with the pgkiln password.
142
+
143
+ **Requiring it per workspace.** A workspace's page (*Preferences* → *Single sign-on*) has *This workspace's
144
+ developers must sign in through an identity provider* (or a directory). Its developers' pgkiln password and a passkey
145
+ alone are refused from then on; the page lists members without a linked identity, who can't sign in until they link
146
+ one. It can be turned on only while there is a **break-glass administrator**, and only by someone who has a linked
147
+ identity or is break-glass, so nobody locks themselves out.
148
+
149
+ **Break-glass administrators.** On **Developers**, an administrator makes another administrator *break-glass*: they
150
+ may still sign in with the pgkiln password where a workspace requires single sign-on, for when the identity provider
151
+ is down or misconfigured. Give them a second factor and a long password, and keep the password somewhere safe. The
152
+ last break-glass administrator can't lose it while a workspace requires single sign-on, nor be made a developer or
153
+ removed. If nobody can sign in at all: `pgkiln admin builder-sign-in password` on the server turns the workspaces'
154
+ rule off ([chapter 18](18-cli.md)).
155
+
156
+ Sign-ins are in the activity log as `login` with *builder sso:company* or *builder ldap:corp*; failures as
157
+ `login_failed` with the reason; linking, unlinking and settings as `sso_linked`, `sso_unlinked` and `sso_policy`.
158
+
114
159
  ### Locked out?
115
160
 
116
161
  | Situation | What to do |
@@ -120,6 +165,7 @@ setting the days to 0 stops every trusted browser at once.
120
165
  | A passkey no longer works after a move to another domain | Sign in with an app code or a recovery code and add a new passkey ([above](#two-factor-sign-in)) |
121
166
  | No administrator can sign in | On the server: `pgkiln admin reset-2fa <developer> --reason "…"` ([chapter 18](18-cli.md)), then sign in with the password |
122
167
  | The policy locks everyone out | On the server: `pgkiln admin set-2fa-policy off` (also clears the workspaces' own rules) |
168
+ | The identity provider is down and a workspace requires single sign-on | A break-glass administrator signs in with the password and turns the workspace's rule off; without one, on the server: `pgkiln admin builder-sign-in password` |
123
169
  | Codes are always wrong | Check the time on the phone and on the server (codes allow 30 seconds either way) |
124
170
  | The instance key was lost or changed | The apps' secrets can't be read: reset the developers (`pgkiln admin reset-2fa`); recovery codes still work |
125
171
 
@@ -148,7 +148,8 @@ select id, 'alice', meta.hash_password('a-strong-password'), '{manager}' from me
148
148
  ### Single sign-on (OpenID Connect)
149
149
 
150
150
  Applications can let people sign in with your organisation's identity provider: Microsoft Entra
151
- ID, Google Workspace, Okta, Keycloak, Auth0 or any other OpenID Connect provider.
151
+ ID, Google Workspace, Okta, Keycloak, Auth0 or any other OpenID Connect provider. (0.63) The same providers, SAML
152
+ ones and LDAP directories can sign developers in to the builder too ([chapter 3](03-builder.md#single-sign-on-for-developers)).
152
153
 
153
154
  **1. Register pgkiln at the provider** as a *web application* (confidential client) using the
154
155
  authorization code flow. The redirect URI is `<PUBLIC_URL>/sso/callback/<name>`, for example
@@ -409,6 +410,46 @@ opening another application of the group signs the user in there without the sig
409
410
 
410
411
  Applications of different groups, and applications without a group, sign in on their own.
411
412
 
413
+ ### Two-factor sign-in
414
+
415
+ APEX has no second factor for an application's own accounts: its documented route is an identity provider whose
416
+ sign-on policy asks for one, or a hand-written TOTP check in a custom authentication scheme. pgkiln has it built in.
417
+ **Application settings → Sign-in methods → Two-factor sign-in → Second step**:
418
+
419
+ | Setting | Who gives a code after the password |
420
+ |---|---|
421
+ | **Off** (the default) | Nobody |
422
+ | **Optional** | Users who added an authenticator app on *My account* |
423
+ | **Required** | Everybody. A user without an app adds one while signing in: a QR code, the app's first code, then ten recovery codes shown once |
424
+ | **Required for some roles** | Users with one of the roles in *Required for roles* (for example `admin, manager`); others as with *Optional* |
425
+
426
+ How it works:
427
+
428
+ - The second step follows the user directory's **password** and **LDAP** directories. An identity provider
429
+ ([OpenID Connect](#single-sign-on-openid-connect), [SAML](#single-sign-on-saml-20)) asks for its own second factor;
430
+ database accounts, custom authentication and HTTP header authentication are not covered.
431
+ - The code comes from any authenticator app (TOTP, RFC 6238: Google Authenticator, Microsoft Authenticator,
432
+ 1Password …), each code once, one 30-second step either side for clock drift. A **recovery code** (ten per account,
433
+ each usable once) works instead, and leads to *My account*, which says how many are left.
434
+ - Until the code is right there is no session: the pending sign-in waits five minutes (ten while adding an app) in
435
+ the signed-out session. Wrong codes count as failed sign-ins, so the usual lockout applies.
436
+ - An account's apps serve every application it uses, and each application decides whether it asks for them.
437
+ - **My account → Two-factor sign-in** adds an app (QR code or key, then its first code), shows new recovery codes,
438
+ and removes an app with a current code or a recovery code. The last app can't be removed while the application
439
+ requires a code of the user.
440
+ - A *Keep me signed in* or session-sharing sign-in skips the second step only when it was made with one (its method
441
+ ends in `+2fa`, shown in the Activity monitor); one made with just a password signs in again with the code.
442
+ - **Users → account → Two-factor sign-in** shows who has an app; **Reset two-factor sign-in** (with a reason, logged
443
+ as `2fa_reset`) removes the apps and recovery codes after a lost phone and signs the account out everywhere. Where
444
+ an application requires the step, the user adds a new app at the next sign-in.
445
+ - The apps' secrets are encrypted with the instance key `PGKILN_SECRET_KEY` (in `meta.account_factor`, readable by
446
+ the owner connection only); without that key *Required* can't be chosen, and the sign-in refuses rather than skips
447
+ the step. Recovery codes are stored as SHA-256 hashes.
448
+ - The **Advisor** warns when users with the role `admin` sign in with only a password.
449
+
450
+ Passkeys, e-mail codes, asking again before a sensitive page (step-up) and tokens for REST and MCP that need a
451
+ two-factor sign-in come in a later release.
452
+
412
453
  ### What sign-in protects against
413
454
 
414
455
  - **Brute force**: after 5 failed attempts for a username (or 50 from one IP address) within 15
@@ -417,13 +458,13 @@ Applications of different groups, and applications without a group, sign in on t
417
458
  time.
418
459
  - **Session fixation**: the session token is replaced at sign-in.
419
460
  - **Stale sessions**: 60 minutes idle or 8 hours total (configurable).
461
+ - **Stolen passwords**: [two-factor sign-in](#two-factor-sign-in) asks for a code from an authenticator app, for every
462
+ user or for some roles; with an identity provider ([OpenID Connect](#single-sign-on-openid-connect),
463
+ [SAML](#single-sign-on-saml-20)) the provider asks for its own. Developers have
464
+ [two-factor sign-in](03-builder.md#two-factor-sign-in) for the builder too.
420
465
 
421
466
  All sign-ins, failures and lockouts appear in the **Activity** monitor.
422
467
 
423
- A second factor for an application's users comes from the identity provider ([OpenID Connect](#single-sign-on-openid-connect),
424
- [SAML](#single-sign-on-saml-20)). Developers can use an authenticator app for the builder itself:
425
- [two-factor sign-in](03-builder.md#two-factor-sign-in).
426
-
427
468
  ## Authorization: what may the user do?
428
469
 
429
470
  ### Authorization schemes
@@ -642,4 +683,5 @@ decision.
642
683
  4. Checksum protection on pages (the default).
643
684
  5. Business rules in constraints, triggers or functions, not only in validations.
644
685
  6. Debug mode and debug messages off.
645
- 7. Review the app's **Settings → Security checklist** and **Activity** monitor now and then.
686
+ 7. [Two-factor sign-in](#two-factor-sign-in) required at least for administrators (the Advisor checks it).
687
+ 8. Review the app's **Settings → Security checklist** and **Activity** monitor now and then.
@@ -337,6 +337,7 @@ All in schema `meta`. `id` columns are generated; `seq` orders siblings (default
337
337
  | `sso_providers` | text[] | Names of identity providers (OpenID Connect or SAML) offered on the login page |
338
338
  | `ldap_directories` | text[] | LDAP directories the password form checks, after local accounts |
339
339
  | `remember_me_days` | int | Days a "Keep me signed in" sign-in lasts; NULL = not offered |
340
+ | `mfa`, `mfa_roles` | text, text[] | [Two-factor sign-in](08-security.md#two-factor-sign-in): `off`, `optional`, `required` or `roles` (required for `mfa_roles`) |
340
341
  | `pwa`, `pwa_short_name`, `pwa_icon`, `pwa_offline_pages`, `pwa_offline_submit` | boolean, text, bytea, boolean, boolean | Progressive Web App ([chapter 17](17-mobile.md)) |
341
342
  | `db_role` | text | Database role every request runs as |
342
343
  | `api_role` | text | Database role of REST API tokens for this app (PostgREST switches to it) |
@@ -510,7 +511,8 @@ navigation entries and application processes have the same `build_option` column
510
511
  | `supporting_script` | Supporting objects per app (see above) | no |
511
512
  | `document_template` | Document templates per app: `name`, `description`, `query`, `template`, `layout`, `filename`, `authz` | yes (read) |
512
513
  | `ldap_identity` | Links an account to a directory entry (`directory_id`, `subject` = entryUUID or DN) | no |
513
- | `persistent_login` | "Keep me signed in" tokens: `token_hash`, `account_id`, `app_id`, `groups`, `method`, `expires_at` | no |
514
+ | `persistent_login` | "Keep me signed in" tokens: `token_hash`, `account_id`, `app_id`, `groups`, `method` (`+2fa` after a second step), `expires_at` | no |
515
+ | `account_factor`, `account_recovery_code` | An account's authenticator apps (secret encrypted with the instance key) and recovery codes (SHA-256) | no |
514
516
  | `instance_setting` | Secrets, e.g. the URL checksum key | no |
515
517
  | `setting` | Account settings: `password_min_length`, `password_require_mixed`, `password_lifetime_days` | yes (read) |
516
518
  | `text_message` | Per app: `name`, `language`, `text` | yes |
@@ -609,6 +611,8 @@ pgkiln's icons; its filter box also searches the Lucide icons by name and search
609
611
  | any page `?doc=NAME` | Download a document template filled with the page's values |
610
612
  | `POST /a/:alias/account/devices` | Sign out on all devices ("Keep me signed in") |
611
613
  | `POST /a/:alias/password` | Change an expired password while signing in |
614
+ | `GET/POST /a/:alias/login/2fa`, `POST /a/:alias/login/2fa/done` | The second step of signing in: a code, or adding the first app and seeing the recovery codes |
615
+ | `POST /a/:alias/account/2fa/start`, `…/cancel`, `…/confirm`, `…/codes`, `…/codes/confirm`, `…/:id/delete` | My account → Two-factor sign-in |
612
616
  | `GET/POST /a/:alias/account`, `POST /a/:alias/account/password`, `POST /a/:alias/account/theme`, `POST /a/:alias/account/style` | My account, own password, the light/dark switch, the [style variant](14-globalization.md#style-variants-theme-roller) switch |
613
617
  | `POST /a/:alias/tz` | The browser's time zone for the session (automatic time zone; sent by `app.js`, CSRF token required) |
614
618
  | any page `?lang=xx` | Switch the language for the session |
@@ -98,7 +98,8 @@ APEX's "Social Sign-In" scheme, with identity-provider groups mapped to applicat
98
98
  in to a second app is then silent via the identity provider's session. See
99
99
  [chapter 8](08-security.md#single-sign-on-openid-connect). **SAML 2.0** providers and **LDAP /
100
100
  Active Directory** passwords work the same way (groups → roles), and *Keep me signed in* matches
101
- APEX's persistent authentication. **Database accounts** sign in with PostgreSQL login roles, and
101
+ APEX's persistent authentication. Unlike APEX, the user directory's accounts can have
102
+ [two-factor sign-in](08-security.md#two-factor-sign-in) (an authenticator app), per application and per role. **Database accounts** sign in with PostgreSQL login roles, and
102
103
  **custom authentication** calls your own PL/pgSQL function (APEX: a custom PL/SQL scheme), see
103
104
  [chapter 8](08-security.md#custom-authentication-a-plpgsql-function). The **login page** (APEX: page 9999 with
104
105
  the Login region template) is a page you design in the Page Designer around a **Login** region, and
@@ -56,10 +56,16 @@ src/
56
56
  sso.ts OpenID Connect: discovery, sign-in flow, ID token checks, account linking
57
57
  saml.ts SAML 2.0 sign-in (node-saml): AuthnRequest, response checks, SP metadata
58
58
  ldap.ts LDAP directories: search + bind, groups, account linking (ldapts)
59
+ devsso.ts (sprint 129) developers through identity providers and directories: resolveDeveloper (subject, link, create,
60
+ administrators group), linkDeveloper, ssoRequired (a workspace's rule, break-glass), clearSsoOnly
59
61
  headerauth.ts HTTP-header authentication: trusted proxies (PGKILN_AUTH_HEADER_PROXIES), header checks, accounts
60
62
  dbauth.ts database-account authentication: role lists, a short connection as the role (DATABASE_URL target), membership/superuser checks
61
63
  customauth.ts custom authentication: the app's function or PL/pgSQL body (a pg_temp function) and post-authentication code, as the app's role
62
64
  remember.ts "Keep me signed in": rotating persistent sign-in tokens
65
+ mfa.ts second factors: TOTP, recovery codes, a FactorStore per kind of user (developers, accounts);
66
+ the developers' policy, passkeys, trusted browsers (webauthn.ts: WebAuthn checks)
67
+ account-mfa.ts (sprint 130) two-factor sign-in for application accounts: the application's rule (off, optional,
68
+ required, roles), secondStep, carriesSecondStep (remembered/shared sign-ins), resets
63
69
  workflow.ts workflows: step checks, the runner with parallel branches (NOTIFY + polling), invoke_api steps
64
70
  (the call between two transactions, with a lease), Advisor references, the diagram;
65
71
  (sprint 114) send_mail and draft steps, switch cases on task outcomes, the diagram as a standalone SVG
@@ -169,6 +175,8 @@ src/
169
175
  routes.ts HTTP handlers: show, submit, dynamic actions, cascading lists, login
170
176
  signin.ts the sign-in form (built-in page and the login region of an application's own login page), safeNext, passwordResetAvailable
171
177
  password-reset.ts Forgot password: request (same answer either way), the link (token into the session), the new password
178
+ two-step.ts (sprint 130) the second step of signing in (/login/2fa: a code, or the first app and its recovery
179
+ codes), passwordSignIn, and My account → Two-factor sign-in
172
180
  context.ts PageContext, dbg()/timed() debug helpers, bind values, substitutions, public error messages, writeOut (streamed responses with back pressure)
173
181
  authz.ts authorization schemes, conditions, visibility (menu requests count as buttons)
174
182
  engine.ts form fetch, validations, processes (conditions, execution chains, queueing background chains; web requests queued by an sql process are made right after it), application processes
@@ -343,6 +351,7 @@ src/
343
351
  quicksql.ts SQL Workshop → Quick SQL page (preview, save as script, run)
344
352
  querybuilder.ts SQL Workshop → Query Builder: catalog, joins by foreign key or drawn, functions/group by, buildQuery() from the URL; the canvas is in builder.js
345
353
  users.ts user directory and identity providers
354
+ builder-sso.ts (sprint 129) a provider's or directory's Builder sign-in region, Security's identities, unlink, break-glass
346
355
  datapump.ts (sprint 103, DA-16) SQL Workshop → Schema Export / Import (/builder/sql/data-pump, administrators only):
347
356
  jobs, export form, upload (streamed to PGKILN_DUMP_DIR), archive preview and import options, job log, download
348
357
  db-users.ts (sprint 99, DA-13) Administration → Database Users (/builder/db-users, administrators only): PostgreSQL
@@ -54,6 +54,7 @@ any (see [what is not exported](03-builder.md#export-format)).
54
54
  | `pgkiln users password <username> [--developer]` | Sets a password and ends that user's sessions |
55
55
  | `pgkiln admin reset-2fa <developer> [--reason <text>]` | (0.60) Removes a developer's authenticator apps and recovery codes and ends their builder sessions: [locked out?](03-builder.md#locked-out) |
56
56
  | `pgkiln admin set-2fa-policy off\|admins\|all [--grace <days>]` | (0.60) Sets the builder's [two-factor policy](03-builder.md#two-factor-sign-in); `off` always works (and, 0.62, clears every workspace's own rule), the others need two administrators with an app |
57
+ | `pgkiln admin builder-sign-in password` | (0.63) Turns off every workspace's [single sign-on rule](03-builder.md#single-sign-on-for-developers), so developers may sign in with the pgkiln password again; logged and mailed to the administrators |
57
58
 
58
59
  `users` reads the password from standard input (the first line, e.g. from a secret store:
59
60
  `printf '%s\n' "$PW" | pgkiln users add ops --developer`) or asks for it twice on a terminal. It is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pgkiln",
3
- "version": "0.62.0",
3
+ "version": "0.64.0",
4
4
  "description": "Open source low-code, SQL-driven application builder for PostgreSQL, inspired by Oracle APEX",
5
5
  "keywords": [
6
6
  "postgresql",
package/public/app.css CHANGED
@@ -2164,3 +2164,13 @@ body.nav-pillar-dark .t-nav details:has(> summary.in-trail) > ul { border-inline
2164
2164
  .region[aria-busy="true"]::after { animation: busy-slide 1.2s cubic-bezier(0.45, 0.05, 0.55, 0.95) infinite; }
2165
2165
  }
2166
2166
  @keyframes busy-slide { from { background-position: -60% 0, 0 0; } to { background-position: 160% 0, 0 0; } }
2167
+
2168
+ /* (sprint 130) two-step sign-in: the QR code for an authenticator app, its key and the recovery codes */
2169
+ .mfa-qr { margin: .5rem 0 1rem; }
2170
+ .mfa-qr svg { width: 200px; max-width: 100%; height: auto; background: #fff; padding: 8px; border-radius: 6px; }
2171
+ .mfa-secret { word-break: break-all; letter-spacing: .05em; }
2172
+ .mfa-steps { padding-left: 1.25rem; margin: 0; }
2173
+ .mfa-codes { columns: 2; column-gap: 2rem; padding-left: 1.5rem; }
2174
+ .mfa-factors { list-style: none; padding: 0; margin: 0 0 .75rem; }
2175
+ .mfa-factors li { padding: .4rem 0; border-bottom: 1px solid var(--border); }
2176
+ @media (max-width: 480px) { .mfa-codes { columns: 1; } }
@@ -676,6 +676,10 @@ kbd { display: inline-block; padding: 0 0.35em; border: 1px solid var(--border);
676
676
  .signin-remember { font-size: 0.9rem; }
677
677
  .signin-submit { padding: 0.75rem; font-size: 1rem; border-radius: var(--radius); }
678
678
  [data-passkey-status]:empty { display: none; }
679
+ /* (sprint 129) linked identities, builder sign-in */
680
+ .plain-list { list-style: none; padding: 0; margin: .25rem 0 .75rem; }
681
+ .plain-list li { padding: .2rem 0; }
682
+ .signin-idp { display: flex; align-items: center; justify-content: center; gap: .5rem; text-decoration: none; }
679
683
  .signin-hint { margin: 0; text-align: center; }
680
684
  .signin-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem 1.5rem; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); }
681
685
  .signin-actions a { display: inline-flex; align-items: center; gap: 0.4rem; color: var(--gold); text-decoration: none; }
@@ -0,0 +1,93 @@
1
+ import { owner } from './db.ts';
2
+ import { ACCOUNTS, storeAddTotp, storeCheck, storeCodesLeft, storeConfirmCodes, storeHasFactor, storeNewCodes } from './mfa.ts';
3
+
4
+ // (sprint 130, SEC-3) Two-step sign-in for application users, beyond APEX (whose application authentication has no
5
+ // second factor). An account's authenticator apps and recovery codes (meta.account_factor, account_recovery_code,
6
+ // migration 132) serve every application it may use; each application decides whether it asks for them (meta.app.mfa):
7
+ // off, optional (users who added an app give a code), required, or required for some roles. The second step follows
8
+ // the user directory's passwords and LDAP directories; an identity provider decides on its own second factor (SEC-4).
9
+ // A remembered or shared sign-in carries its method: only one that ends in "+2fa" stands in for the second step.
10
+
11
+ export type AppMfa = 'off' | 'optional' | 'required' | 'roles';
12
+ export const APP_MFA: AppMfa[] = ['off', 'optional', 'required', 'roles'];
13
+
14
+ export interface MfaApp { authentication: string; mfa?: AppMfa | null; mfa_roles?: string[] | null }
15
+
16
+ /** Whether the application has a second step at all (its own accounts, and not "off"). */
17
+ export const mfaOn = (a: MfaApp) => a.authentication === 'app_users' && !!a.mfa && a.mfa !== 'off';
18
+
19
+ /** Whether the application requires a second step of a user with these roles. */
20
+ export function mfaRequired(a: MfaApp, roles: string[]): boolean {
21
+ if (!mfaOn(a)) return false;
22
+ if (a.mfa === 'required') return true;
23
+ const need = new Set((a.mfa_roles ?? []).map((r) => r.toLowerCase()));
24
+ return a.mfa === 'roles' && roles.some((r) => need.has(r.toLowerCase()));
25
+ }
26
+
27
+ /** The sign-in methods a second step follows (the directory's password, an LDAP directory). */
28
+ export const knowsPassword = (method: string | undefined) => !method || method.startsWith('password') || method.startsWith('ldap:');
29
+ export const MFA_SUFFIX = '+2fa';
30
+ export const withSecondStep = (method: string | undefined) => `${method ?? 'password'}${MFA_SUFFIX}`;
31
+
32
+ /** An active account's id by user name (accounts are matched without regard to case). */
33
+ export async function accountId(username: string): Promise<number | null> {
34
+ return (await owner.one<{ id: number }>('select id from meta.account where lower(username) = lower($1) and active', [username]))?.id ?? null;
35
+ }
36
+
37
+ /**
38
+ * What a password (or LDAP) sign-in to this application still needs: 'code' (the user has an authenticator app),
39
+ * 'enrol' (required, and the user has none yet) or null (no second step).
40
+ */
41
+ export async function secondStep(a: MfaApp, username: string, roles: string[]): Promise<'code' | 'enrol' | null> {
42
+ if (!mfaOn(a)) return null;
43
+ const id = await accountId(username);
44
+ if (id === null) return null;
45
+ if (await storeHasFactor(ACCOUNTS, id)) return 'code';
46
+ return mfaRequired(a, roles) ? 'enrol' : null;
47
+ }
48
+
49
+ /**
50
+ * Whether a remembered or shared sign-in may sign the user in here without a second step: one made with an identity
51
+ * provider, one that had a second step, or a user this application asks nothing of.
52
+ */
53
+ export async function carriesSecondStep(a: MfaApp, username: string, roles: string[], method: string | undefined): Promise<boolean> {
54
+ if (!knowsPassword(method) || method?.endsWith(MFA_SUFFIX)) return true;
55
+ return (await secondStep(a, username, roles)) === null;
56
+ }
57
+
58
+ export interface AccountFactor { id: number; name: string; created_at: string; last_used_at: string | null }
59
+
60
+ export async function accountFactors(id: number): Promise<AccountFactor[]> {
61
+ return (await owner.query<AccountFactor>('select id, name, created_at, last_used_at from meta.account_factor where account_id = $1 order by id', [id])).rows;
62
+ }
63
+
64
+ export const accountHasFactor = (id: number) => storeHasFactor(ACCOUNTS, id);
65
+ export const accountCodesLeft = (id: number) => storeCodesLeft(ACCOUNTS, id);
66
+ export const addAccountTotp = (id: number, name: string, secret: string, step: number) => storeAddTotp(ACCOUNTS, id, name, secret, step);
67
+ export const newAccountCodes = (id: number) => storeNewCodes(ACCOUNTS, id);
68
+ export const confirmAccountCodes = (id: number) => storeConfirmCodes(ACCOUNTS, id);
69
+ export const checkAccountCode = (id: number, code: string) => storeCheck(ACCOUNTS, id, code);
70
+
71
+ /** Remove one of the account's authenticator apps (and the recovery codes with the last one). */
72
+ export async function removeAccountFactor(id: number, factorId: number): Promise<boolean> {
73
+ return owner.tx(async (c) => {
74
+ const gone = (await c.query('delete from meta.account_factor where id = $1 and account_id = $2', [factorId, id])).rowCount;
75
+ if (gone && !(await c.query('select 1 from meta.account_factor where account_id = $1', [id])).rowCount)
76
+ await c.query('delete from meta.account_recovery_code where account_id = $1', [id]);
77
+ return !!gone;
78
+ });
79
+ }
80
+
81
+ /** (Manage users) Remove every second factor of an account and end its sessions and remembered sign-ins. */
82
+ export async function resetAccountFactors(id: number): Promise<boolean> {
83
+ return owner.tx(async (c) => {
84
+ const acc = (await c.query<{ username: string }>('select username from meta.account where id = $1', [id])).rows[0];
85
+ if (!acc) return false;
86
+ await c.query('delete from meta.account_factor where account_id = $1', [id]);
87
+ await c.query('delete from meta.account_recovery_code where account_id = $1', [id]);
88
+ await c.query('delete from meta.persistent_login where account_id = $1', [id]);
89
+ await c.query('delete from meta.shared_login where account_id = $1', [id]);
90
+ await c.query('delete from meta.session where app_id is not null and lower(username) = lower($1)', [acc.username]);
91
+ return true;
92
+ });
93
+ }
@@ -187,6 +187,14 @@ export async function advise(appId: number, tx?: pg.ClientBase, opts: { db?: Db
187
187
  else if (!all.some((x) => x.kind === 'region' && x.row.type === 'login' && Number(x.row.page_no) === Number(app.login_page)))
188
188
  findings.push({ severity: 'error', entry: null, field, message: `Page ${app.login_page} has no Login region: nobody can sign in.` });
189
189
  }
190
+ // (sprint 130, SEC-3) administrators of the application who sign in with only a password
191
+ if (app.authentication === 'app_users' && (app.local_login || (app.ldap_directories ?? []).length)) {
192
+ const covered = app.mfa === 'required' || (app.mfa === 'roles' && (app.mfa_roles ?? []).includes('admin'));
193
+ const admins = covered ? 0 : (await one(`select count(*)::int as n from meta.app_access where app_id = $1 and 'admin' = any(roles)`, [appId])).n;
194
+ if (admins)
195
+ findings.push({ severity: 'warning', entry: null, field: 'Application settings → Two-factor sign-in',
196
+ message: `${admins === 1 ? 'A user' : `${admins} users`} with the admin role sign${admins === 1 ? 's' : ''} in with only a password: require two-factor sign-in (Required, or Required for roles: admin).` });
197
+ }
190
198
  // (089) a page's group exists
191
199
  for (const p of (await query(`select p.page_no, p.page_group from meta.page p where p.app_id = $1 and p.page_group is not null
192
200
  and not exists (select 1 from meta.page_group g where g.app_id = p.app_id and g.name = p.page_group) order by 1`, [appId])).rows)