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/src/devsso.ts ADDED
@@ -0,0 +1,149 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { owner } from './db.ts';
3
+ import { SsoError } from './sso.ts';
4
+
5
+ // (sprint 129, SEC-1) Developers sign in to the builder through an identity
6
+ // provider (OpenID Connect, SAML) or an LDAP directory that has "builder"
7
+ // turned on. The rules, as for application accounts (sso.ts, ldap.ts):
8
+ // * a developer is found by the provider's stable subject
9
+ // (meta.developer_identity), never by a name the provider sends alone;
10
+ // * an existing developer with the same username is linked on the first
11
+ // sign-in only when the source allows it (builder_link_existing), with a
12
+ // verified e-mail address when that is the username, and never a
13
+ // break-glass developer; otherwise the developer links the identity
14
+ // themselves on the Security page, signed in;
15
+ // * a new developer is created when the source allows it
16
+ // (builder_auto_create), for members of builder_group when one is set;
17
+ // * builder_admin_group, when set, decides on every sign-in who is an
18
+ // instance administrator (break-glass developers and the last
19
+ // administrator are never demoted).
20
+ // A workspace can require these sign-ins of its developers (sso_only);
21
+ // break-glass developers may still use the pgkiln password.
22
+
23
+ export interface BuilderSource {
24
+ kind: 'provider' | 'directory';
25
+ id: number;
26
+ name: string;
27
+ builder_auto_create: boolean;
28
+ builder_link_existing: boolean;
29
+ builder_group: string | null;
30
+ builder_admin_group: string | null;
31
+ }
32
+
33
+ export interface BuilderIdentity {
34
+ subject: string;
35
+ username: string;
36
+ name?: string | null;
37
+ email?: string | null;
38
+ /** false: the username is an unverified e-mail address and links nothing */
39
+ mayLink: boolean;
40
+ groups: string[];
41
+ }
42
+
43
+ const column = (k: BuilderSource['kind']) => (k === 'provider' ? 'provider_id' : 'directory_id');
44
+ const validEmail = (v: unknown): v is string => typeof v === 'string' && /^[^@\s<>"(),;:\\]+@[^@\s<>"(),;:\\]+\.[^@\s<>"(),;:\\]+$/.test(v) && v.length <= 254;
45
+
46
+ /** The providers and directories developers can sign in with. */
47
+ export async function builderSources() {
48
+ const providers = (await owner.query<{ name: string; display_name: string }>('select name, display_name from meta.auth_provider where enabled and builder order by display_name')).rows;
49
+ const directories = (await owner.query<{ name: string; display_name: string }>('select name, display_name from meta.ldap_directory where enabled and builder order by display_name')).rows;
50
+ return { providers, directories };
51
+ }
52
+
53
+ export async function builderProvider(name: string) {
54
+ return owner.one('select * from meta.auth_provider where enabled and builder and name = $1', [name]);
55
+ }
56
+ export async function builderDirectory(name: string) {
57
+ return owner.one('select * from meta.ldap_directory where enabled and builder and name = $1', [name]);
58
+ }
59
+
60
+ /** The developer for an identity (see the rules above); throws SsoError when there is none. */
61
+ export async function resolveDeveloper(src: BuilderSource, id: BuilderIdentity): Promise<string> {
62
+ const col = column(src.kind);
63
+ return owner.tx(async (c) => {
64
+ let username: string | undefined = (await c.query(`select username from meta.developer_identity where ${col} = $1 and subject = $2`, [src.id, id.subject])).rows[0]?.username;
65
+ if (!username) {
66
+ const existing = (
67
+ await c.query(
68
+ `select d.username, d.break_glass, exists (select 1 from meta.developer_identity i where i.username = d.username and i.${col} = $2) as linked
69
+ from meta.developer d where lower(d.username) = lower($1)`,
70
+ [id.username, src.id],
71
+ )
72
+ ).rows[0];
73
+ if (existing) {
74
+ if (existing.linked) throw new SsoError('This developer is already linked to another identity here.');
75
+ if (!src.builder_link_existing || !id.mayLink || existing.break_glass)
76
+ throw new SsoError(`A developer "${existing.username}" exists but is not linked to this identity. Sign in with the password and link it on the Security page.`);
77
+ username = existing.username as string;
78
+ } else {
79
+ const member = !src.builder_group || id.groups.includes(src.builder_group) || (!!src.builder_admin_group && id.groups.includes(src.builder_admin_group));
80
+ if (!src.builder_auto_create || !member) throw new SsoError(`There is no developer "${id.username}". Ask an administrator for access.`);
81
+ // a password nobody knows: the developer signs in through the provider (an administrator can set one)
82
+ username = (
83
+ await c.query(
84
+ `insert into meta.developer (username, password_hash, is_admin, email) values ($1, crypt($2, gen_salt('bf', 10)), false, $3) returning username`,
85
+ [id.username, randomBytes(32).toString('base64url'), validEmail(id.email) ? id.email : null],
86
+ )
87
+ ).rows[0].username;
88
+ }
89
+ await c.query(`insert into meta.developer_identity (${col}, subject, username) values ($1, $2, $3)`, [src.id, id.subject, username]);
90
+ }
91
+ if (src.builder_admin_group) {
92
+ const admin = id.groups.includes(src.builder_admin_group);
93
+ // never the last administrator either: the instance would have none
94
+ await c.query(
95
+ `update meta.developer set is_admin = $2 where username = $1 and is_admin <> $2
96
+ and ($2 or (not break_glass and (select count(*) from meta.developer where is_admin) > 1))`,
97
+ [username, admin],
98
+ );
99
+ }
100
+ return username!;
101
+ });
102
+ }
103
+
104
+ /** Link an identity to a signed-in developer (Security page). */
105
+ export async function linkDeveloper(src: Pick<BuilderSource, 'kind' | 'id'>, subject: string, username: string) {
106
+ const col = column(src.kind);
107
+ const other = await owner.one(`select username from meta.developer_identity where ${col} = $1 and (subject = $2 or username = $3)`, [src.id, subject, username]);
108
+ if (other) throw new SsoError(other.username === username ? 'You already linked an identity here; unlink it first.' : 'This identity is linked to another developer.');
109
+ await owner.query(`insert into meta.developer_identity (${col}, subject, username) values ($1, $2, $3)`, [src.id, subject, username]);
110
+ }
111
+
112
+ /** A developer's linked identities (Security page, provider pages). */
113
+ export async function identitiesOf(username: string) {
114
+ return (
115
+ await owner.query<{ id: number; source: string; display_name: string; kind: 'provider' | 'directory'; linked_at: string }>(
116
+ `select i.id, coalesce(p.name, d.name) as source, coalesce(p.display_name, d.display_name) as display_name,
117
+ case when i.provider_id is null then 'directory' else 'provider' end as kind, i.linked_at
118
+ from meta.developer_identity i
119
+ left join meta.auth_provider p on p.id = i.provider_id
120
+ left join meta.ldap_directory d on d.id = i.directory_id
121
+ where i.username = $1 order by 3`,
122
+ [username],
123
+ )
124
+ ).rows;
125
+ }
126
+
127
+ /**
128
+ * Must the developer sign in through an identity provider? A member of a workspace that requires it,
129
+ * unless break-glass.
130
+ */
131
+ export async function ssoRequired(username: string) {
132
+ const r = await owner.one(
133
+ `select not d.break_glass and exists (select 1 from meta.workspace_member m join meta.workspace w on w.id = m.workspace_id
134
+ where m.username = d.username and w.sso_only) as required
135
+ from meta.developer d where d.username = $1`,
136
+ [username],
137
+ );
138
+ return !!r?.required;
139
+ }
140
+
141
+ /** Instance administrators who may still sign in with the password. */
142
+ export async function breakGlassAdmins() {
143
+ return (await owner.query<{ username: string }>('select username from meta.developer where is_admin and break_glass order by username')).rows.map((r) => r.username);
144
+ }
145
+
146
+ /** `pgkiln admin builder-sign-in password`: every workspace allows the password again. */
147
+ export async function clearSsoOnly() {
148
+ return (await owner.query('update meta.workspace set sso_only = false where sso_only')).rowCount ?? 0;
149
+ }
@@ -16,7 +16,7 @@ import { accountRoles, loadApp, loadPage, type App, type Page, type Region } fro
16
16
  import { passwordDaysLeft, passwordProblem } from '../accounts.ts';
17
17
  import { english, type Translate } from '../i18n.ts';
18
18
  import { checksumValid, loginWindowMinutes, urlChecksum } from '../security.ts';
19
- import { enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type SsoResult } from '../sso.ts';
19
+ import { builderSsoFinish, enabledProviders, finishSignIn, loadProvider, ssoAccess, SsoError, startSignIn, type Provider, type SsoResult } from '../sso.ts';
20
20
  import { sessionLimits, clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash, type Session } from '../session.ts';
21
21
  import { checkPageAccess, computeVisibility, Forbidden, isAuthorized } from './authz.ts';
22
22
  import { bindValues, dbg, publicError, stripSemicolon, timed, toState, writeOut, type PageContext } from './context.ts';
@@ -1350,8 +1350,19 @@ export async function runtimeRoutes(app: FastifyInstance) {
1350
1350
  });
1351
1351
 
1352
1352
  /** The end of a single sign-on (OpenID Connect or SAML): the app's access rules, then the session. */
1353
- const finishSso = (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) =>
1354
- withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1353
+ const finishSso = (req: FastifyRequest, reply: FastifyReply, p: Provider, result: SsoResult, ip: string) => {
1354
+ // (sprint 129) a sign-in to the builder
1355
+ if (result.appId === null) {
1356
+ const builder = builderSsoFinish();
1357
+ if (!builder) return simplePage(reply, 404, english('error.not_found'), english('login.method_unavailable'));
1358
+ return builder(req, reply, p, result, ip).catch((e) => {
1359
+ if (!(e instanceof SsoError)) throw e;
1360
+ logActivity({ username: result.link ?? result.username.slice(0, 100), event: 'login_failed', ip, detail: `builder sso:${p.name}: ${e.message}` });
1361
+ return simplePage(reply, 403, english('login.sso_failed'), e.message, result.link ? '/builder/security' : '/builder/login');
1362
+ });
1363
+ }
1364
+ return withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1365
+ };
1355
1366
  const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1356
1367
  const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
1357
1368
  const loaded = alias ? await appWithLocale(req, alias) : undefined;
package/src/saml.ts CHANGED
@@ -76,7 +76,7 @@ function client(p: Provider, cache: CacheProvider) {
76
76
  }
77
77
 
78
78
  /** Start: the IdP URL with the AuthnRequest, and the browser-binding cookie value. */
79
- export async function startSamlSignIn(p: Provider, appId: number, next: string | undefined) {
79
+ export async function startSamlSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
80
80
  const state = b64url(randomBytes(32));
81
81
  const browserKey = b64url(randomBytes(32));
82
82
  await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
@@ -86,9 +86,9 @@ export async function startSamlSignIn(p: Provider, appId: number, next: string |
86
86
  if (!requestId) throw new SsoError('The AuthnRequest has no ID.');
87
87
  // code_verifier is unused for SAML; nonce holds the AuthnRequest ID the response must answer
88
88
  await owner.query(
89
- `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next)
90
- values ($1, $2, $3, '', $4, $5, $6)`,
91
- [state, appId, p.id, requestId, sha256(browserKey), next ?? null],
89
+ `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
90
+ values ($1, $2, $3, '', $4, $5, $6, $7)`,
91
+ [state, appId, p.id, requestId, sha256(browserKey), next ?? null, link ?? null],
92
92
  );
93
93
  return { url, browserKey };
94
94
  }
@@ -99,7 +99,7 @@ const values = (v: unknown): string[] => (v === undefined || v === null ? [] : (
99
99
  export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: string; RelayState?: string }, browserKey: string | undefined): Promise<SsoResult> {
100
100
  const pending = await owner.one(
101
101
  `delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
102
- returning app_id, nonce, browser_hash, next`,
102
+ returning app_id, nonce, browser_hash, next, link_username`,
103
103
  [body.RelayState ?? '', p.id, PENDING_MINUTES],
104
104
  );
105
105
  if (!pending) throw new SsoError('This sign-in has expired or was already used. Please try again.');
@@ -117,8 +117,14 @@ export async function finishSamlSignIn(p: Provider, body: { SAMLResponse?: strin
117
117
  if (!profile?.nameID) throw new SsoError('The SAML response names no user.');
118
118
  const username = p.username_claim === 'nameID' ? profile.nameID : values(profile[p.username_claim])[0];
119
119
  if (!usableUsername(username)) throw new SsoError(`The SAML response has no usable "${p.username_claim}" attribute.`);
120
- const account = await resolveAccount(p, { sub: profile.nameID, name: values(profile.displayName ?? profile.cn)[0], email: profile.email ?? profile.mail }, username);
121
- return { appId: pending.app_id, next: pending.next, username: account, groups: values(profile[p.groups_claim]) };
120
+ const name = values(profile.displayName ?? profile.cn)[0];
121
+ const email = values(profile.email ?? profile.mail)[0];
122
+ const groups = values(profile[p.groups_claim]);
123
+ if (pending.app_id === null)
124
+ // the builder: devsso.ts finds the developer (sprint 129)
125
+ return { appId: null, next: pending.next, username, groups, link: pending.link_username, identity: { subject: profile.nameID, name: name ?? null, email: email ?? null, mayLink: true } };
126
+ const account = await resolveAccount(p, { sub: profile.nameID, name, email: profile.email ?? profile.mail }, username);
127
+ return { appId: pending.app_id, next: pending.next, username: account, groups };
122
128
  }
123
129
 
124
130
  /** Service provider metadata (entity ID, ACS) to register pgkiln at the IdP. */
package/src/sso.ts CHANGED
@@ -34,6 +34,12 @@ export interface Provider {
34
34
  protocol: 'oidc' | 'saml';
35
35
  idp_sso_url: string | null;
36
36
  idp_cert: string | null;
37
+ /** (131) developers sign in to the builder with this provider too (devsso.ts) */
38
+ builder: boolean;
39
+ builder_auto_create: boolean;
40
+ builder_link_existing: boolean;
41
+ builder_group: string | null;
42
+ builder_admin_group: string | null;
37
43
  }
38
44
 
39
45
  interface Discovery {
@@ -86,8 +92,11 @@ function jwks(doc: Discovery) {
86
92
  return set;
87
93
  }
88
94
 
89
- /** Start: returns the provider URL to redirect to, and the browser-binding cookie value. */
90
- export async function startSignIn(p: Provider, appId: number, next: string | undefined) {
95
+ /**
96
+ * Start: returns the provider URL to redirect to, and the browser-binding cookie value.
97
+ * appId null: a sign-in to the builder; link: the signed-in developer linking this identity.
98
+ */
99
+ export async function startSignIn(p: Provider, appId: number | null, next: string | undefined, link?: string) {
91
100
  const doc = await discover(p.issuer);
92
101
  const state = b64url(randomBytes(32));
93
102
  const nonce = b64url(randomBytes(32));
@@ -95,9 +104,9 @@ export async function startSignIn(p: Provider, appId: number, next: string | und
95
104
  const browserKey = b64url(randomBytes(32));
96
105
  await owner.query(`delete from meta.sso_pending where created_at < now() - make_interval(mins => $1)`, [PENDING_MINUTES]);
97
106
  await owner.query(
98
- `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next)
99
- values ($1, $2, $3, $4, $5, $6, $7)`,
100
- [state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null],
107
+ `insert into meta.sso_pending (state, app_id, provider_id, code_verifier, nonce, browser_hash, next, link_username)
108
+ values ($1, $2, $3, $4, $5, $6, $7, $8)`,
109
+ [state, appId, p.id, verifier, nonce, sha256(browserKey), next ?? null, link ?? null],
101
110
  );
102
111
  const url = new URL(doc.authorization_endpoint);
103
112
  url.search = new URLSearchParams({
@@ -113,16 +122,29 @@ export async function startSignIn(p: Provider, appId: number, next: string | und
113
122
  return { url: url.toString(), browserKey };
114
123
  }
115
124
 
125
+ // (sprint 129) the builder finishes its own sign-ins (app_id null); the callback routes are the runtime's
126
+ type BuilderFinish = (req: any, reply: any, p: Provider, result: SsoResult, ip: string) => Promise<unknown>;
127
+ let builderFinish: BuilderFinish | null = null;
128
+ export const onBuilderSso = (f: BuilderFinish) => {
129
+ builderFinish = f;
130
+ };
131
+ export const builderSsoFinish = () => builderFinish;
132
+
116
133
  /** Read a claim by name or dot path (e.g. realm_access.roles). */
117
134
  function claim(payload: JWTPayload, path: string): unknown {
118
135
  return path.split('.').reduce<any>((v, k) => (v && typeof v === 'object' ? v[k] : undefined), payload);
119
136
  }
120
137
 
121
138
  export interface SsoResult {
122
- appId: number;
139
+ /** null: a sign-in to the builder (131); username is then the provider's, not resolved to an account */
140
+ appId: number | null;
123
141
  next: string | null;
124
142
  username: string;
125
143
  groups: string[];
144
+ /** builder sign-ins: the identity, for devsso.ts */
145
+ identity?: { subject: string; name: string | null; email: string | null; mayLink: boolean };
146
+ /** builder: the signed-in developer who links this identity (Security page) */
147
+ link?: string | null;
126
148
  }
127
149
 
128
150
  /** Callback: validate state/browser, exchange the code, verify the ID token, resolve the account. */
@@ -132,7 +154,7 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
132
154
  const code = params.get('code') ?? '';
133
155
  const pending = await owner.one(
134
156
  `delete from meta.sso_pending where state = $1 and provider_id = $2 and created_at > now() - make_interval(mins => $3)
135
- returning app_id, code_verifier, nonce, browser_hash, next`,
157
+ returning app_id, code_verifier, nonce, browser_hash, next, link_username`,
136
158
  [state, p.id, PENDING_MINUTES],
137
159
  );
138
160
  if (!pending) throw new SsoError('This sign-in link has expired or was already used. Please try again.');
@@ -181,6 +203,14 @@ export async function finishSignIn(p: Provider, params: URLSearchParams, browser
181
203
 
182
204
  // an unverified e-mail address never links an existing account
183
205
  const verified = p.username_claim !== 'email' || payload.email_verified === true;
206
+ if (pending.app_id === null) {
207
+ // the builder: devsso.ts finds the developer (sprint 129)
208
+ const email = payload.email_verified === false ? null : typeof payload.email === 'string' ? payload.email : null;
209
+ return {
210
+ appId: null, next: pending.next, username, groups, link: pending.link_username,
211
+ identity: { subject: payload.sub, name: typeof payload.name === 'string' ? payload.name : null, email, mayLink: verified },
212
+ };
213
+ }
184
214
  const account = await resolveAccount(p, payload, username, { mayLink: verified });
185
215
  return { appId: pending.app_id, next: pending.next, username: account, groups };
186
216
  }