@manablox/auth 0.3.0 → 0.4.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/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
+ import { ALL_PERMISSIONS, AuditActor, AuthConfig, BUILT_IN_ROLES, BuiltInRole, CONTENT_ACTIONS, ContentAction, ContentPermission, ContentPermission as ContentPermission$1, Grant, Manablox, PERMISSION_GROUPS, Permission, Permission as Permission$1, PermissionGroup, SpaceRole, SpaceRole as SpaceRole$1, grantsCover, intersectGrants, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, typesCoveredBy } from "@manablox/core";
1
2
  import { Database, MembershipRow, Repositories, SpaceRow } from "@manablox/db";
2
- import { ALL_PERMISSIONS, AuthConfig, BUILT_IN_ROLES, BuiltInRole, CONTENT_ACTIONS, ContentAction, ContentPermission, ContentPermission as ContentPermission$1, Grant, Manablox, PERMISSION_GROUPS, Permission, Permission as Permission$1, PermissionGroup, SpaceRole, SpaceRole as SpaceRole$1, grantsCover, intersectGrants, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, typesCoveredBy } from "@manablox/core";
3
3
  //#region src/rbac.d.ts
4
4
  export interface Principal {
5
5
  userId: string;
@@ -15,6 +15,9 @@ export interface Principal {
15
15
  permissions?: Record<string, readonly string[]>;
16
16
  /** True when the request authenticated with an API key rather than a session. */
17
17
  viaApiKey?: boolean;
18
+ /** The key that authenticated the request, for the audit log. */
19
+ apiKeyId?: string | null;
20
+ apiKeyName?: string | null;
18
21
  /**
19
22
  * Spaces this principal is confined to, or `null`/absent for no confinement. Set by an
20
23
  * API key that was issued with a space restriction: it narrows the key below its
@@ -28,6 +31,14 @@ export interface Principal {
28
31
  */
29
32
  allowedGrants?: readonly string[] | null;
30
33
  }
34
+ /**
35
+ * The principal as the audit log records it: a user, or a user through an API key, with
36
+ * the request's client details beside it. Anonymous requests act as the system.
37
+ */
38
+ export declare function auditActorFor(principal: Principal | null, request?: {
39
+ headers: Headers;
40
+ requestId?: string | null | undefined;
41
+ }): AuditActor;
31
42
  /** The grants a principal's role gives in a space, whatever kind of role it is. */
32
43
  export declare function grantsIn(principal: Principal, spaceId: string): readonly string[];
33
44
  /**
@@ -174,12 +185,20 @@ export declare class UserService {
174
185
  * before the reset does not keep it afterwards.
175
186
  */
176
187
  setPassword(userId: string, password: string): Promise<void>;
188
+ /**
189
+ * The account holder changing their own password: the current one must be right, and
190
+ * unlike an administrator's reset the other sessions stay signed in, since the person
191
+ * holding the account is the one making the change.
192
+ */
193
+ changePassword(userId: string, currentPassword: string, password: string): Promise<void>;
177
194
  /** A banned user is signed out everywhere and refused on the next request. */
178
195
  ban(actorId: string, userId: string, reason: string | null): Promise<UserSummary>;
179
196
  unban(userId: string): Promise<UserSummary>;
180
197
  delete(actorId: string, userId: string): Promise<void>;
181
198
  /** Signs the user out of every device without touching the account. */
182
199
  revokeSessions(userId: string): Promise<void>;
200
+ /** Accounts are instance-wide, so their entries carry no space. */
201
+ private audit;
183
202
  private require;
184
203
  private assertNotSelf;
185
204
  /**
@@ -202,6 +221,12 @@ export interface AuthCallbacks {
202
221
  * created by an administrator rather than by whoever finds the login page.
203
222
  */
204
223
  allowSignUp?: () => Promise<boolean>;
224
+ /** Runs after a session row is created: a sign-in. */
225
+ onSessionCreated?: (session: {
226
+ userId: string;
227
+ ipAddress?: string | null | undefined;
228
+ userAgent?: string | null | undefined;
229
+ }) => Promise<void>;
205
230
  }
206
231
  export declare function createAuth(config: AuthConfig, db: Database, callbacks?: AuthCallbacks): import("better-auth").Auth<{
207
232
  secret: string;
@@ -279,6 +304,20 @@ export declare function createAuth(config: AuthConfig, db: Database, callbacks?:
279
304
  } & Record<string, unknown>) => Promise<void>;
280
305
  };
281
306
  };
307
+ session: {
308
+ create: {
309
+ after: (session: {
310
+ id: string;
311
+ createdAt: Date;
312
+ updatedAt: Date;
313
+ userId: string;
314
+ expiresAt: Date;
315
+ token: string;
316
+ ipAddress?: string | null | undefined;
317
+ userAgent?: string | null | undefined;
318
+ } & Record<string, unknown>) => Promise<void>;
319
+ };
320
+ };
282
321
  };
283
322
  advanced: {
284
323
  database: {
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
+ import { ALL_PERMISSIONS, ALL_PERMISSIONS as ALL_PERMISSIONS$1, BUILT_IN_ROLES, CONTENT_ACTIONS, ManabloxError, PERMISSION_GROUPS, diffRecords, grantsCover, grantsCover as grantsCover$1, intersectGrants, intersectGrants as intersectGrants$1, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, permissionsFor as permissionsFor$1, systemActor, typesCoveredBy, typesCoveredBy as typesCoveredBy$1 } from "@manablox/core";
1
2
  import { rethrowUniqueViolation, schema } from "@manablox/db";
2
3
  import { betterAuth } from "better-auth";
3
4
  import { drizzleAdapter } from "better-auth/adapters/drizzle";
4
5
  import { APIError } from "better-auth/api";
5
6
  import { bearer } from "better-auth/plugins";
6
7
  import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
7
- import { ALL_PERMISSIONS, ALL_PERMISSIONS as ALL_PERMISSIONS$1, BUILT_IN_ROLES, CONTENT_ACTIONS, ManabloxError, PERMISSION_GROUPS, grantsCover, grantsCover as grantsCover$1, intersectGrants, intersectGrants as intersectGrants$1, isBuiltInRole, normaliseGrants, parseGrant, permissionsFor, permissionsFor as permissionsFor$1, typesCoveredBy, typesCoveredBy as typesCoveredBy$1 } from "@manablox/core";
8
8
  import { and, eq, sql } from "drizzle-orm";
9
9
  //#region src/password.ts
10
10
  /**
@@ -71,6 +71,30 @@ var ApiKeyService = class {
71
71
  permissions: options.permissions ?? null
72
72
  }).returning();
73
73
  if (!row) throw new ManabloxError("apiKey.create.failed");
74
+ await this.repos.audit.record({
75
+ action: "apiKey.issue",
76
+ targetKind: "apiKey",
77
+ targetId: row.id,
78
+ targetLabel: name,
79
+ changes: [
80
+ {
81
+ path: "expiresAt",
82
+ from: null,
83
+ to: row.expiresAt
84
+ },
85
+ {
86
+ path: "spaceIds",
87
+ from: null,
88
+ to: row.spaceIds
89
+ },
90
+ {
91
+ path: "permissions",
92
+ from: null,
93
+ to: row.permissions
94
+ }
95
+ ],
96
+ meta: { userId }
97
+ });
74
98
  return {
75
99
  id: row.id,
76
100
  name,
@@ -83,7 +107,19 @@ var ApiKeyService = class {
83
107
  * or re-enabled, so a disabled row is only a secret digest left lying around.
84
108
  */
85
109
  async revoke(id) {
86
- await this.db.delete(schema.apikeys).where(eq(schema.apikeys.id, id));
110
+ const [row] = await this.db.delete(schema.apikeys).where(eq(schema.apikeys.id, id)).returning({
111
+ id: schema.apikeys.id,
112
+ name: schema.apikeys.name,
113
+ userId: schema.apikeys.userId
114
+ });
115
+ if (!row) return;
116
+ await this.repos.audit.record({
117
+ action: "apiKey.revoke",
118
+ targetKind: "apiKey",
119
+ targetId: row.id,
120
+ targetLabel: row.name,
121
+ meta: { userId: row.userId }
122
+ });
87
123
  }
88
124
  async list(userId) {
89
125
  return this.db.select({
@@ -129,18 +165,68 @@ var ApiKeyService = class {
129
165
  spaces,
130
166
  permissions,
131
167
  viaApiKey: true,
168
+ apiKeyId: row.id,
169
+ apiKeyName: row.name,
132
170
  allowedSpaceIds: allowed ? [...allowed] : null,
133
171
  allowedGrants: row.permissions
134
172
  };
135
173
  }
136
174
  /** Removes expired keys; scheduled by the jobs package. */
137
175
  async pruneExpired() {
138
- return (await this.db.delete(schema.apikeys).where(sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`).returning({ id: schema.apikeys.id })).length;
176
+ const deleted = await this.db.delete(schema.apikeys).where(sql`${schema.apikeys.expiresAt} is not null and ${schema.apikeys.expiresAt} < now()`).returning({
177
+ id: schema.apikeys.id,
178
+ name: schema.apikeys.name
179
+ });
180
+ if (deleted.length) await this.repos.audit.record({
181
+ action: "apiKey.prune",
182
+ targetKind: "apiKey",
183
+ meta: { removed: deleted.map((row) => ({
184
+ id: row.id,
185
+ name: row.name
186
+ })) }
187
+ });
188
+ return deleted.length;
139
189
  }
140
190
  };
141
191
  const digest = (secret) => createHash("sha256").update(secret).digest("hex");
142
192
  //#endregion
143
193
  //#region src/rbac.ts
194
+ /**
195
+ * The principal as the audit log records it: a user, or a user through an API key, with
196
+ * the request's client details beside it. Anonymous requests act as the system.
197
+ */
198
+ function auditActorFor(principal, request) {
199
+ const detail = {};
200
+ if (request) {
201
+ const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request.headers.get("x-real-ip");
202
+ if (ip) detail.ip = ip;
203
+ const userAgent = request.headers.get("user-agent");
204
+ if (userAgent) detail.userAgent = userAgent.slice(0, 300);
205
+ if (request.requestId) detail.requestId = request.requestId;
206
+ }
207
+ if (!principal) return {
208
+ kind: "system",
209
+ id: null,
210
+ label: "anonymous",
211
+ detail
212
+ };
213
+ if (principal.viaApiKey) {
214
+ detail.apiKeyId = principal.apiKeyId ?? null;
215
+ detail.apiKeyName = principal.apiKeyName ?? null;
216
+ return {
217
+ kind: "apikey",
218
+ id: principal.userId,
219
+ label: principal.email,
220
+ detail
221
+ };
222
+ }
223
+ return {
224
+ kind: "user",
225
+ id: principal.userId,
226
+ label: principal.email,
227
+ detail
228
+ };
229
+ }
144
230
  /** The grants a principal's role gives in a space, whatever kind of role it is. */
145
231
  function grantsIn(principal, spaceId) {
146
232
  const role = principal.spaces[spaceId];
@@ -237,32 +323,64 @@ var UserService = class {
237
323
  }
238
324
  async create(input) {
239
325
  const email = normaliseEmail(input.email);
240
- return summary(await this.repos.users.create({
326
+ const user = await this.repos.users.create({
241
327
  name: input.name.trim(),
242
328
  email,
243
329
  role: input.role,
244
330
  passwordHash: await hashPassword(input.password)
245
- }).catch(emailConflict(email)));
331
+ }).catch(emailConflict(email));
332
+ await this.audit("user.create", user, diffRecords(null, summary(user)));
333
+ return summary(user);
246
334
  }
247
335
  async update(userId, input) {
336
+ const before = await this.require(userId);
248
337
  const data = {};
249
338
  if (input.name !== void 0) data.name = input.name.trim();
250
339
  if (input.email !== void 0) data.email = normaliseEmail(input.email);
251
- return summary(await this.repos.users.update(userId, data).catch(emailConflict(data.email)));
340
+ const user = await this.repos.users.update(userId, data).catch(emailConflict(data.email));
341
+ await this.audit("user.update", user, diffRecords(summary(before), summary(user)));
342
+ return summary(user);
252
343
  }
253
344
  /** Changing the instance role; the last superadmin cannot step down. */
254
345
  async setRole(userId, role) {
346
+ const before = await this.require(userId);
255
347
  if (role !== "superadmin") await this.assertNotLastSuperadmin(userId);
256
- return summary(await this.repos.users.setRole(userId, role));
348
+ const user = await this.repos.users.setRole(userId, role);
349
+ await this.audit("user.setRole", user, [{
350
+ path: "role",
351
+ from: before.role,
352
+ to: user.role
353
+ }]);
354
+ return summary(user);
257
355
  }
258
356
  /**
259
357
  * A new password, and every session gone with the old one: whoever held the account
260
358
  * before the reset does not keep it afterwards.
261
359
  */
262
360
  async setPassword(userId, password) {
263
- await this.require(userId);
361
+ const user = await this.require(userId);
264
362
  await this.repos.users.setPasswordHash(userId, await hashPassword(password));
265
363
  await this.repos.users.revokeSessions(userId);
364
+ await this.audit("user.setPassword", user, []);
365
+ }
366
+ /**
367
+ * The account holder changing their own password: the current one must be right, and
368
+ * unlike an administrator's reset the other sessions stay signed in, since the person
369
+ * holding the account is the one making the change.
370
+ */
371
+ async changePassword(userId, currentPassword, password) {
372
+ const user = await this.require(userId);
373
+ const stored = await this.repos.users.passwordHash(userId);
374
+ if (!stored || !await verifyPassword(stored, currentPassword)) throw ManabloxError.validation([{
375
+ key: "user.password.incorrect",
376
+ path: ["currentPassword"]
377
+ }], "user.validation.failed");
378
+ if (currentPassword === password) throw ManabloxError.validation([{
379
+ key: "user.password.sameAsCurrent",
380
+ path: ["password"]
381
+ }], "user.validation.failed");
382
+ await this.repos.users.setPasswordHash(userId, await hashPassword(password));
383
+ await this.audit("user.changePassword", user, []);
266
384
  }
267
385
  /** A banned user is signed out everywhere and refused on the next request. */
268
386
  async ban(actorId, userId, reason) {
@@ -270,21 +388,53 @@ var UserService = class {
270
388
  await this.assertNotLastSuperadmin(userId);
271
389
  const user = await this.repos.users.setBanned(userId, true, reason);
272
390
  await this.repos.users.revokeSessions(userId);
391
+ await this.audit("user.ban", user, [{
392
+ path: "banned",
393
+ from: false,
394
+ to: true
395
+ }, {
396
+ path: "banReason",
397
+ from: null,
398
+ to: reason
399
+ }]);
273
400
  return summary(user);
274
401
  }
275
402
  async unban(userId) {
276
- return summary(await this.repos.users.setBanned(userId, false, null));
403
+ const before = await this.require(userId);
404
+ const user = await this.repos.users.setBanned(userId, false, null);
405
+ await this.audit("user.unban", user, [{
406
+ path: "banned",
407
+ from: before.banned,
408
+ to: false
409
+ }, {
410
+ path: "banReason",
411
+ from: before.banReason,
412
+ to: null
413
+ }]);
414
+ return summary(user);
277
415
  }
278
416
  async delete(actorId, userId) {
279
417
  this.assertNotSelf(actorId, userId);
280
- await this.require(userId);
418
+ const user = await this.require(userId);
281
419
  await this.assertNotLastSuperadmin(userId);
282
420
  await this.repos.users.delete(userId);
421
+ await this.audit("user.delete", user, diffRecords(summary(user), null));
283
422
  }
284
423
  /** Signs the user out of every device without touching the account. */
285
424
  async revokeSessions(userId) {
286
- await this.require(userId);
425
+ const user = await this.require(userId);
287
426
  await this.repos.users.revokeSessions(userId);
427
+ await this.audit("user.revokeSessions", user, []);
428
+ }
429
+ /** Accounts are instance-wide, so their entries carry no space. */
430
+ audit(action, user, changes) {
431
+ return this.repos.audit.record({
432
+ action,
433
+ targetKind: "user",
434
+ targetId: user.id,
435
+ targetLabel: user.email,
436
+ changes
437
+ });
288
438
  }
289
439
  async require(userId) {
290
440
  const user = await this.repos.users.findById(userId);
@@ -354,15 +504,20 @@ function createAuth(config, db, callbacks = {}) {
354
504
  }
355
505
  },
356
506
  plugins: [bearer()],
357
- databaseHooks: { user: { create: {
358
- before: async (user) => {
359
- if (!callbacks.allowSignUp || await callbacks.allowSignUp()) return { data: user };
360
- throw new APIError("FORBIDDEN", { message: "auth.signUp.closed" });
361
- },
362
- after: async (user) => {
363
- await callbacks.onUserCreated?.(user.id);
364
- }
365
- } } },
507
+ databaseHooks: {
508
+ user: { create: {
509
+ before: async (user) => {
510
+ if (!callbacks.allowSignUp || await callbacks.allowSignUp()) return { data: user };
511
+ throw new APIError("FORBIDDEN", { message: "auth.signUp.closed" });
512
+ },
513
+ after: async (user) => {
514
+ await callbacks.onUserCreated?.(user.id);
515
+ }
516
+ } },
517
+ session: { create: { after: async (session) => {
518
+ await callbacks.onSessionCreated?.(session);
519
+ } } }
520
+ },
366
521
  advanced: { database: { generateId: () => crypto.randomUUID() } }
367
522
  });
368
523
  }
@@ -401,6 +556,18 @@ async function promoteFirstUser(manablox, repos, userId) {
401
556
  if (!user || user.role === "superadmin") return;
402
557
  await repos.users.setRole(userId, "superadmin");
403
558
  for (const space of await repos.spaces.all()) await repos.users.grant(userId, space.id, "owner");
559
+ await repos.audit.record({
560
+ actor: systemActor("first account"),
561
+ action: "user.promote",
562
+ targetKind: "user",
563
+ targetId: user.id,
564
+ targetLabel: user.email,
565
+ changes: [{
566
+ path: "role",
567
+ from: user.role,
568
+ to: "superadmin"
569
+ }]
570
+ });
404
571
  manablox.logger.info({ email: user.email }, "first account promoted to superadmin");
405
572
  }
406
573
  /** Covers an instance whose first account predates this behaviour. */
@@ -415,4 +582,4 @@ function attachBootstrapOwner(manablox, repos) {
415
582
  }, { source: "@manablox/auth" });
416
583
  }
417
584
  //#endregion
418
- export { ALL_PERMISSIONS, ApiKeyService, BUILT_IN_ROLES, CONTENT_ACTIONS, MIN_PASSWORD_LENGTH, PERMISSION_GROUPS, UserService, actorRoles, allowedTypeIds, assertCan, attachBootstrapOwner, can, createAuth, effectiveGrants, grantsCover, grantsIn, hashPassword, intersectGrants, isBuiltInRole, normaliseGrants, parseApiKey, parseGrant, permissionsFor, promoteFirstUser, resolvePrincipal, typesCoveredBy, verifyPassword };
585
+ export { ALL_PERMISSIONS, ApiKeyService, BUILT_IN_ROLES, CONTENT_ACTIONS, MIN_PASSWORD_LENGTH, PERMISSION_GROUPS, UserService, actorRoles, allowedTypeIds, assertCan, attachBootstrapOwner, auditActorFor, can, createAuth, effectiveGrants, grantsCover, grantsIn, hashPassword, intersectGrants, isBuiltInRole, normaliseGrants, parseApiKey, parseGrant, permissionsFor, promoteFirstUser, resolvePrincipal, typesCoveredBy, verifyPassword };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manablox/auth",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,8 +11,8 @@
11
11
  "main": "./dist/index.js",
12
12
  "types": "./dist/index.d.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.3.0",
15
- "@manablox/db": "0.3.0",
14
+ "@manablox/core": "0.4.0",
15
+ "@manablox/db": "0.4.0",
16
16
  "better-auth": "^1.7.2",
17
17
  "drizzle-orm": "^0.45.2",
18
18
  "@node-rs/argon2": "^2.2.0"