@manablox/auth 0.1.0 → 0.2.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/README.md ADDED
@@ -0,0 +1,20 @@
1
+ # `@manablox/auth`
2
+
3
+ Sessions, API keys and permissions. better-auth owns sign-in and sessions; this package wraps it, resolves a `Principal` from a request, checks a permission against the role table, and issues and verifies API keys, including ones restricted to a set of spaces.
4
+
5
+ ## Exports
6
+
7
+ - `createAuth`, `resolvePrincipal`
8
+ - `can`, `assertCan`, `actorRoles`, `Permission`, `Principal`
9
+ - `ApiKeyService`
10
+ - `promoteFirstUser`, `attachBootstrapOwner` — the first account is the superadmin
11
+
12
+ ## Depends on
13
+
14
+ @manablox/db, better-auth
15
+
16
+ ## Test
17
+
18
+ ```sh
19
+ pnpm --filter @manablox/auth test
20
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@manablox/auth",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -11,8 +11,8 @@
11
11
  "main": "./src/index.ts",
12
12
  "types": "./src/index.ts",
13
13
  "dependencies": {
14
- "@manablox/core": "0.1.0",
15
- "@manablox/db": "0.1.0",
14
+ "@manablox/core": "0.2.0",
15
+ "@manablox/db": "0.2.0",
16
16
  "better-auth": "^1.7.2",
17
17
  "drizzle-orm": "^0.45.2",
18
18
  "@node-rs/argon2": "^2.2.0"
package/src/api-key.ts CHANGED
@@ -16,10 +16,23 @@ export interface IssueApiKeyOptions {
16
16
  expiresAt?: Date | undefined;
17
17
  /** Spaces the key may act in. `null`/omitted issues an unrestricted key. */
18
18
  spaceIds?: string[] | null | undefined;
19
+ /** Grants the key may use. `null`/omitted leaves the owner's role as the limit. */
20
+ permissions?: string[] | null | undefined;
19
21
  }
20
22
 
21
23
  const PREFIX = 'mbx';
22
24
 
25
+ /**
26
+ * Takes a presented key apart. The secret is base64url and may itself contain `_`, so
27
+ * the key is not split on it: the prefix is a fixed twelve hex characters and the
28
+ * secret is whatever follows.
29
+ */
30
+ export function parseApiKey(presented: string): { prefix: string; secret: string } | null {
31
+ const match = /^([a-z]+)_([0-9a-f]{12})_([A-Za-z0-9_-]+)$/.exec(presented);
32
+ if (!match || match[1] !== PREFIX) return null;
33
+ return { prefix: match[2] as string, secret: match[3] as string };
34
+ }
35
+
23
36
  /**
24
37
  * Long-lived credentials for headless consumers.
25
38
  *
@@ -52,6 +65,7 @@ export class ApiKeyService {
52
65
  key: digest(secret),
53
66
  expiresAt: options.expiresAt ?? null,
54
67
  spaceIds: options.spaceIds?.length ? options.spaceIds : null,
68
+ permissions: options.permissions ?? null,
55
69
  })
56
70
  .returning();
57
71
 
@@ -77,6 +91,7 @@ export class ApiKeyService {
77
91
  expiresAt: schema.apikeys.expiresAt,
78
92
  lastRequest: schema.apikeys.lastRequest,
79
93
  spaceIds: schema.apikeys.spaceIds,
94
+ permissions: schema.apikeys.permissions,
80
95
  createdAt: schema.apikeys.createdAt,
81
96
  })
82
97
  .from(schema.apikeys)
@@ -84,9 +99,9 @@ export class ApiKeyService {
84
99
  }
85
100
 
86
101
  async resolve(presented: string): Promise<Principal | null> {
87
- const parts = presented.split('_');
88
- if (parts.length !== 3 || parts[0] !== PREFIX) return null;
89
- const [, prefix, secret] = parts as [string, string, string];
102
+ const parsed = parseApiKey(presented);
103
+ if (!parsed) return null;
104
+ const { prefix, secret } = parsed;
90
105
 
91
106
  const rows = await this.db
92
107
  .select()
@@ -112,12 +127,16 @@ export class ApiKeyService {
112
127
  const user = await this.repos.users.findById(row.userId);
113
128
  if (!user || user.banned) return null;
114
129
 
115
- const memberships = await this.repos.users.memberships(user.id);
130
+ const resolved = await this.repos.users.principal(user.id);
131
+ if (!resolved) return null;
116
132
  const allowed = row.spaceIds?.length ? new Set(row.spaceIds) : null;
117
133
  const spaces: Record<string, SpaceRole> = {};
118
- for (const membership of memberships) {
119
- if (allowed && !allowed.has(membership.spaceId)) continue;
120
- spaces[membership.spaceId] = membership.role;
134
+ const permissions: Record<string, string[]> = {};
135
+ for (const [spaceId, role] of Object.entries(resolved.spaces)) {
136
+ if (allowed && !allowed.has(spaceId)) continue;
137
+ spaces[spaceId] = role;
138
+ const grants = resolved.permissions[spaceId];
139
+ if (grants) permissions[spaceId] = grants;
121
140
  }
122
141
 
123
142
  return {
@@ -125,8 +144,10 @@ export class ApiKeyService {
125
144
  email: user.email,
126
145
  role: user.role,
127
146
  spaces,
147
+ permissions,
128
148
  viaApiKey: true,
129
149
  allowedSpaceIds: allowed ? [...allowed] : null,
150
+ allowedGrants: row.permissions,
130
151
  };
131
152
  }
132
153
 
package/src/index.ts CHANGED
@@ -3,12 +3,16 @@ import type { Database, Repositories } from '@manablox/db';
3
3
  import { schema } from '@manablox/db';
4
4
  import { betterAuth } from 'better-auth';
5
5
  import { drizzleAdapter } from 'better-auth/adapters/drizzle';
6
+ import { APIError } from 'better-auth/api';
6
7
  import { bearer } from 'better-auth/plugins';
7
8
  import type { ApiKeyService } from './api-key.js';
9
+ import { hashPassword, MIN_PASSWORD_LENGTH, verifyPassword } from './password.js';
8
10
  import type { Principal } from './rbac.js';
9
11
 
10
12
  export * from './api-key.js';
13
+ export * from './password.js';
11
14
  export * from './rbac.js';
15
+ export * from './user.service.js';
12
16
 
13
17
  export type ManabloxAuth = ReturnType<typeof createAuth>;
14
18
 
@@ -17,6 +21,12 @@ export type ManabloxAuth = ReturnType<typeof createAuth>;
17
21
  export interface AuthCallbacks {
18
22
  /** Runs after a user row is created, inside better-auth's own transaction path. */
19
23
  onUserCreated?: (userId: string) => Promise<void>;
24
+ /**
25
+ * Whether the public sign-up endpoint may create an account right now. Absent means
26
+ * always. The host closes it once the first account exists, so every later account is
27
+ * created by an administrator rather than by whoever finds the login page.
28
+ */
29
+ allowSignUp?: () => Promise<boolean>;
20
30
  }
21
31
 
22
32
  export function createAuth(config: AuthConfig, db: Database, callbacks: AuthCallbacks = {}) {
@@ -38,18 +48,10 @@ export function createAuth(config: AuthConfig, db: Database, callbacks: AuthCall
38
48
 
39
49
  emailAndPassword: {
40
50
  enabled: config.emailAndPassword ?? true,
41
- minPasswordLength: 12,
42
- // Argon2id: memory-hard, and the current OWASP recommendation over bcrypt, which
43
- // also silently truncates passwords at 72 bytes.
51
+ minPasswordLength: MIN_PASSWORD_LENGTH,
44
52
  password: {
45
- hash: async (password) => {
46
- const { hash } = await import('@node-rs/argon2');
47
- return hash(password, { memoryCost: 19456, timeCost: 2, parallelism: 1 });
48
- },
49
- verify: async ({ hash: stored, password }) => {
50
- const { verify } = await import('@node-rs/argon2');
51
- return verify(stored, password);
52
- },
53
+ hash: hashPassword,
54
+ verify: ({ hash: stored, password }) => verifyPassword(stored, password),
53
55
  },
54
56
  },
55
57
 
@@ -67,6 +69,12 @@ export function createAuth(config: AuthConfig, db: Database, callbacks: AuthCall
67
69
  databaseHooks: {
68
70
  user: {
69
71
  create: {
72
+ // Sign-up is the only path that creates a user through better-auth; accounts
73
+ // an administrator creates are written by `UserService` and never pass here.
74
+ before: async (user) => {
75
+ if (!callbacks.allowSignUp || (await callbacks.allowSignUp())) return { data: user };
76
+ throw new APIError('FORBIDDEN', { message: 'auth.signUp.closed' });
77
+ },
70
78
  after: async (user) => {
71
79
  await callbacks.onUserCreated?.(user.id);
72
80
  },
@@ -110,6 +118,7 @@ export async function resolvePrincipal(
110
118
  email: session.user.email,
111
119
  role: principal.role,
112
120
  spaces: principal.spaces,
121
+ permissions: principal.permissions,
113
122
  };
114
123
  }
115
124
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Argon2id, the current OWASP recommendation over bcrypt — which also silently truncates
3
+ * passwords at 72 bytes. One definition serves better-auth's own sign-in path and the
4
+ * accounts an administrator creates, so both write the same hash format.
5
+ */
6
+ export async function hashPassword(password: string): Promise<string> {
7
+ const { hash } = await import('@node-rs/argon2');
8
+ return hash(password, { memoryCost: 19456, timeCost: 2, parallelism: 1 });
9
+ }
10
+
11
+ export async function verifyPassword(stored: string, password: string): Promise<boolean> {
12
+ const { verify } = await import('@node-rs/argon2');
13
+ return verify(stored, password);
14
+ }
15
+
16
+ /** Matches better-auth's `minPasswordLength`, so a password set here signs in there. */
17
+ export const MIN_PASSWORD_LENGTH = 12;
package/src/rbac.ts CHANGED
@@ -1,99 +1,48 @@
1
- import { ManabloxError } from '@manablox/core';
1
+ import {
2
+ ALL_PERMISSIONS,
3
+ type ContentPermission,
4
+ grantsCover,
5
+ intersectGrants,
6
+ ManabloxError,
7
+ type Permission,
8
+ permissionsFor,
9
+ type SpaceRole,
10
+ typesCoveredBy,
11
+ } from '@manablox/core';
2
12
 
3
- export type SpaceRole = 'owner' | 'admin' | 'editor' | 'author' | 'viewer';
4
-
5
- export type Permission =
6
- | 'space:read'
7
- | 'space:write'
8
- | 'space:delete'
9
- | 'contentType:read'
10
- | 'contentType:write'
11
- | 'contentType:delete'
12
- | 'content:read'
13
- | 'content:write'
14
- | 'content:delete'
15
- | 'content:publish'
16
- | 'asset:read'
17
- | 'asset:write'
18
- | 'asset:delete'
19
- | 'user:read'
20
- | 'user:write'
21
- | 'webhook:read'
22
- | 'webhook:write';
23
-
24
- /** Role → permission mapping. */
25
- const ROLE_PERMISSIONS: Record<SpaceRole, Permission[]> = {
26
- owner: [
27
- 'space:read',
28
- 'space:write',
29
- 'space:delete',
30
- 'contentType:read',
31
- 'contentType:write',
32
- 'contentType:delete',
33
- 'content:read',
34
- 'content:write',
35
- 'content:delete',
36
- 'content:publish',
37
- 'asset:read',
38
- 'asset:write',
39
- 'asset:delete',
40
- 'user:read',
41
- 'user:write',
42
- 'webhook:read',
43
- 'webhook:write',
44
- ],
45
- admin: [
46
- 'space:read',
47
- 'space:write',
48
- 'contentType:read',
49
- 'contentType:write',
50
- 'contentType:delete',
51
- 'content:read',
52
- 'content:write',
53
- 'content:delete',
54
- 'content:publish',
55
- 'asset:read',
56
- 'asset:write',
57
- 'asset:delete',
58
- 'user:read',
59
- 'user:write',
60
- 'webhook:read',
61
- 'webhook:write',
62
- ],
63
- editor: [
64
- 'space:read',
65
- 'contentType:read',
66
- 'content:read',
67
- 'content:write',
68
- 'content:delete',
69
- 'content:publish',
70
- 'asset:read',
71
- 'asset:write',
72
- 'asset:delete',
73
- 'user:read',
74
- 'webhook:read',
75
- ],
76
- // An author may write and delete, but never publish — the reason the role exists.
77
- author: [
78
- 'space:read',
79
- 'contentType:read',
80
- 'content:read',
81
- 'content:write',
82
- 'content:delete',
83
- 'asset:read',
84
- 'asset:write',
85
- 'user:read',
86
- ],
87
- viewer: ['space:read', 'contentType:read', 'content:read', 'asset:read', 'user:read'],
88
- };
13
+ export {
14
+ ALL_PERMISSIONS,
15
+ BUILT_IN_ROLES,
16
+ type BuiltInRole,
17
+ CONTENT_ACTIONS,
18
+ type ContentAction,
19
+ type ContentPermission,
20
+ type Grant,
21
+ grantsCover,
22
+ intersectGrants,
23
+ isBuiltInRole,
24
+ normaliseGrants,
25
+ PERMISSION_GROUPS,
26
+ type Permission,
27
+ type PermissionGroup,
28
+ parseGrant,
29
+ permissionsFor,
30
+ type SpaceRole,
31
+ typesCoveredBy,
32
+ } from '@manablox/core';
89
33
 
90
34
  export interface Principal {
91
35
  userId: string;
92
36
  email: string;
93
37
  /** Instance-wide role; `superadmin` short-circuits every space check. */
94
38
  role: string;
95
- /** Space id → role in that space. */
39
+ /** Space id → the name of the role held there. */
96
40
  spaces: Record<string, SpaceRole>;
41
+ /**
42
+ * Space id → the grants that role carries, resolved when the principal is. Absent for
43
+ * a space whose role is built in: those are answered from the table above.
44
+ */
45
+ permissions?: Record<string, readonly string[]>;
97
46
  /** True when the request authenticated with an API key rather than a session. */
98
47
  viaApiKey?: boolean;
99
48
  /**
@@ -102,16 +51,36 @@ export interface Principal {
102
51
  * owner's own access and, unlike a role, it also binds a superadmin.
103
52
  */
104
53
  allowedSpaceIds?: string[] | null;
54
+ /**
55
+ * Grants this principal is confined to, or `null`/absent for no confinement. Set by an
56
+ * API key issued with a permission restriction: like `allowedSpaceIds` it only ever
57
+ * narrows the owner's own access, and it binds a superadmin too.
58
+ */
59
+ allowedGrants?: readonly string[] | null;
60
+ }
61
+
62
+ /** The grants a principal's role gives in a space, whatever kind of role it is. */
63
+ export function grantsIn(principal: Principal, spaceId: string): readonly string[] {
64
+ const role = principal.spaces[spaceId];
65
+ if (!role) return [];
66
+ return principal.permissions?.[spaceId] ?? permissionsFor(role);
105
67
  }
106
68
 
107
- export function permissionsFor(role: SpaceRole): readonly Permission[] {
108
- return ROLE_PERMISSIONS[role] ?? [];
69
+ /**
70
+ * What a principal can actually do in a space: the role's grants (everything, for a
71
+ * superadmin) narrowed by an API key's restriction, if the request came through one.
72
+ */
73
+ export function effectiveGrants(principal: Principal, spaceId: string): readonly string[] {
74
+ if (principal.allowedSpaceIds && !principal.allowedSpaceIds.includes(spaceId)) return [];
75
+ const held = principal.role === 'superadmin' ? ALL_PERMISSIONS : grantsIn(principal, spaceId);
76
+ return principal.allowedGrants ? intersectGrants(held, principal.allowedGrants) : held;
109
77
  }
110
78
 
111
79
  export function can(
112
80
  principal: Principal | null,
113
81
  spaceId: string | null,
114
82
  permission: Permission,
83
+ typeId?: string | null,
115
84
  ): boolean {
116
85
  if (!principal) return false;
117
86
  // Checked ahead of the superadmin short-circuit: a restricted key must not reach
@@ -119,22 +88,41 @@ export function can(
119
88
  if (principal.allowedSpaceIds && (!spaceId || !principal.allowedSpaceIds.includes(spaceId))) {
120
89
  return false;
121
90
  }
91
+ if (principal.allowedGrants && !grantsCover(principal.allowedGrants, permission, typeId)) {
92
+ return false;
93
+ }
122
94
  if (principal.role === 'superadmin') return true;
123
95
  if (!spaceId) return false;
124
-
125
- const role = principal.spaces[spaceId];
126
- if (!role) return false;
127
- return ROLE_PERMISSIONS[role].includes(permission);
96
+ return grantsCover(grantsIn(principal, spaceId), permission, typeId);
128
97
  }
129
98
 
130
99
  export function assertCan(
131
100
  principal: Principal | null,
132
101
  spaceId: string | null,
133
102
  permission: Permission,
103
+ typeId?: string | null,
134
104
  ): void {
135
- if (can(principal, spaceId, permission)) return;
105
+ if (can(principal, spaceId, permission, typeId)) return;
136
106
  if (!principal) throw ManabloxError.unauthorized();
137
- throw ManabloxError.forbidden('auth.forbidden', { permission, spaceId });
107
+ throw ManabloxError.forbidden('auth.forbidden', {
108
+ permission,
109
+ spaceId,
110
+ ...(typeId ? { typeId } : {}),
111
+ });
112
+ }
113
+
114
+ /**
115
+ * The content types a principal may perform an action on in a space, or `null` for
116
+ * every type — what a listing narrows its filter to.
117
+ */
118
+ export function allowedTypeIds(
119
+ principal: Principal | null,
120
+ spaceId: string,
121
+ permission: ContentPermission,
122
+ ): string[] | null {
123
+ if (!principal) return [];
124
+ if (principal.role === 'superadmin' && !principal.allowedGrants) return null;
125
+ return typesCoveredBy(effectiveGrants(principal, spaceId), permission);
138
126
  }
139
127
 
140
128
  /** Roles used by field-level `readRoles`/`writeRoles` checks. */
@@ -0,0 +1,192 @@
1
+ import { ManabloxError } from '@manablox/core';
2
+ import {
3
+ type MembershipRow,
4
+ type Repositories,
5
+ rethrowUniqueViolation,
6
+ type SpaceRow,
7
+ type UserRow,
8
+ type UserUpdateData,
9
+ } from '@manablox/db';
10
+ import { hashPassword } from './password.js';
11
+
12
+ export type InstanceRole = 'superadmin' | 'editor';
13
+
14
+ export interface CreateUserInput {
15
+ name: string;
16
+ email: string;
17
+ password: string;
18
+ role: InstanceRole;
19
+ }
20
+
21
+ export interface UpdateUserInput {
22
+ name?: string | undefined;
23
+ email?: string | undefined;
24
+ }
25
+
26
+ /** A user row without anything a directory listing should not carry. */
27
+ export interface UserSummary {
28
+ id: string;
29
+ name: string;
30
+ email: string;
31
+ image: string | null;
32
+ role: string;
33
+ banned: boolean;
34
+ banReason: string | null;
35
+ createdAt: Date;
36
+ updatedAt: Date;
37
+ }
38
+
39
+ export interface UserDetail extends UserSummary {
40
+ memberships: Array<{ spaceId: string; role: MembershipRow['role']; space: SpaceRow }>;
41
+ }
42
+
43
+ /**
44
+ * `users_email_key` is enforced in the database, so a taken address arrives as a
45
+ * Postgres unique violation and would surface as an opaque 500.
46
+ */
47
+ const emailConflict = (email: string | undefined) => (error: unknown) =>
48
+ rethrowUniqueViolation(error, {
49
+ constraint: 'email',
50
+ key: 'user.email.taken',
51
+ path: ['email'],
52
+ params: { email: email ?? '' },
53
+ errorKey: 'user.validation.failed',
54
+ });
55
+
56
+ /**
57
+ * Instance-wide user administration: the accounts, their instance role, and whether they
58
+ * may sign in at all. Space membership stays with `SpaceService`, because it is a
59
+ * property of the space.
60
+ *
61
+ * Every rule here exists to keep the instance reachable: an administrator cannot lock
62
+ * themself out, and the instance always keeps at least one superadmin.
63
+ */
64
+ export class UserService {
65
+ constructor(private readonly repos: Repositories) {}
66
+
67
+ async get(userId: string): Promise<UserDetail> {
68
+ const user = await this.repos.users.findById(userId);
69
+ if (!user) throw ManabloxError.notFound('user.notFound', { id: userId });
70
+ const memberships = await this.repos.users.membershipsWithSpaces(userId);
71
+ return {
72
+ ...summary(user),
73
+ memberships: memberships.map((row) => ({
74
+ spaceId: row.spaceId,
75
+ role: row.role,
76
+ space: row.space,
77
+ })),
78
+ };
79
+ }
80
+
81
+ async list(pagination: { limit: number; offset: number }, search?: string) {
82
+ const page = await this.repos.users.list(pagination, search);
83
+ return { ...page, items: page.items.map(summary) };
84
+ }
85
+
86
+ async create(input: CreateUserInput): Promise<UserSummary> {
87
+ const email = normaliseEmail(input.email);
88
+ const user = await this.repos.users
89
+ .create({
90
+ name: input.name.trim(),
91
+ email,
92
+ role: input.role,
93
+ passwordHash: await hashPassword(input.password),
94
+ })
95
+ .catch(emailConflict(email));
96
+ return summary(user);
97
+ }
98
+
99
+ async update(userId: string, input: UpdateUserInput): Promise<UserSummary> {
100
+ const data: UserUpdateData = {};
101
+ if (input.name !== undefined) data.name = input.name.trim();
102
+ if (input.email !== undefined) data.email = normaliseEmail(input.email);
103
+ const user = await this.repos.users.update(userId, data).catch(emailConflict(data.email));
104
+ return summary(user);
105
+ }
106
+
107
+ /** Changing the instance role; the last superadmin cannot step down. */
108
+ async setRole(userId: string, role: InstanceRole): Promise<UserSummary> {
109
+ if (role !== 'superadmin') await this.assertNotLastSuperadmin(userId);
110
+ return summary(await this.repos.users.setRole(userId, role));
111
+ }
112
+
113
+ /**
114
+ * A new password, and every session gone with the old one: whoever held the account
115
+ * before the reset does not keep it afterwards.
116
+ */
117
+ async setPassword(userId: string, password: string): Promise<void> {
118
+ await this.require(userId);
119
+ await this.repos.users.setPasswordHash(userId, await hashPassword(password));
120
+ await this.repos.users.revokeSessions(userId);
121
+ }
122
+
123
+ /** A banned user is signed out everywhere and refused on the next request. */
124
+ async ban(actorId: string, userId: string, reason: string | null): Promise<UserSummary> {
125
+ this.assertNotSelf(actorId, userId);
126
+ await this.assertNotLastSuperadmin(userId);
127
+ const user = await this.repos.users.setBanned(userId, true, reason);
128
+ await this.repos.users.revokeSessions(userId);
129
+ return summary(user);
130
+ }
131
+
132
+ async unban(userId: string): Promise<UserSummary> {
133
+ return summary(await this.repos.users.setBanned(userId, false, null));
134
+ }
135
+
136
+ async delete(actorId: string, userId: string): Promise<void> {
137
+ this.assertNotSelf(actorId, userId);
138
+ await this.require(userId);
139
+ await this.assertNotLastSuperadmin(userId);
140
+ await this.repos.users.delete(userId);
141
+ }
142
+
143
+ /** Signs the user out of every device without touching the account. */
144
+ async revokeSessions(userId: string): Promise<void> {
145
+ await this.require(userId);
146
+ await this.repos.users.revokeSessions(userId);
147
+ }
148
+
149
+ // -------------------------------------------------------------------------
150
+ // Internals
151
+ // -------------------------------------------------------------------------
152
+
153
+ private async require(userId: string): Promise<UserRow> {
154
+ const user = await this.repos.users.findById(userId);
155
+ if (!user) throw ManabloxError.notFound('user.notFound', { id: userId });
156
+ return user;
157
+ }
158
+
159
+ private assertNotSelf(actorId: string, userId: string): void {
160
+ if (actorId === userId) throw ManabloxError.badRequest('user.self.protected', { id: userId });
161
+ }
162
+
163
+ /**
164
+ * Whatever happens to `userId`, one superadmin must remain — otherwise the instance
165
+ * has no one left who can create a space or manage users, and no way back.
166
+ */
167
+ private async assertNotLastSuperadmin(userId: string): Promise<void> {
168
+ const user = await this.require(userId);
169
+ if (user.role !== 'superadmin') return;
170
+ const count = await this.repos.users.countByRole('superadmin');
171
+ if (count <= 1) throw ManabloxError.badRequest('user.lastSuperadmin', { id: userId });
172
+ }
173
+ }
174
+
175
+ function summary(user: UserRow): UserSummary {
176
+ return {
177
+ id: user.id,
178
+ name: user.name,
179
+ email: user.email,
180
+ image: user.image,
181
+ role: user.role,
182
+ banned: user.banned,
183
+ banReason: user.banReason,
184
+ createdAt: user.createdAt,
185
+ updatedAt: user.updatedAt,
186
+ };
187
+ }
188
+
189
+ /** Lower-cased and trimmed, as better-auth stores it, so two spellings cannot coexist. */
190
+ function normaliseEmail(email: string): string {
191
+ return email.trim().toLowerCase();
192
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseApiKey } from '../src/api-key.js';
3
+
4
+ describe('parseApiKey', () => {
5
+ it('keeps a secret that itself contains the separator', () => {
6
+ expect(parseApiKey('mbx_0123456789ab_ab_cd-ef_gh')).toEqual({
7
+ prefix: '0123456789ab',
8
+ secret: 'ab_cd-ef_gh',
9
+ });
10
+ });
11
+
12
+ it('refuses another prefix, a malformed lookup part, or an empty secret', () => {
13
+ expect(parseApiKey('abc_0123456789ab_secret')).toBeNull();
14
+ expect(parseApiKey('mbx_0123_secret')).toBeNull();
15
+ expect(parseApiKey('mbx_0123456789ab_')).toBeNull();
16
+ expect(parseApiKey('not a key')).toBeNull();
17
+ });
18
+ });
package/test/rbac.test.ts CHANGED
@@ -1,5 +1,12 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { actorRoles, assertCan, can, type Principal, permissionsFor } from '../src/rbac.js';
2
+ import {
3
+ actorRoles,
4
+ allowedTypeIds,
5
+ assertCan,
6
+ can,
7
+ type Principal,
8
+ permissionsFor,
9
+ } from '../src/rbac.js';
3
10
 
4
11
  const SPACE = 'space-1';
5
12
 
@@ -55,4 +62,67 @@ describe('rbac', () => {
55
62
  expect(permission.endsWith(':read')).toBe(true);
56
63
  }
57
64
  });
65
+
66
+ describe('a custom role', () => {
67
+ const TYPE = 'type-1';
68
+ const blogger = principal({
69
+ spaces: { [SPACE]: 'blogger' },
70
+ permissions: { [SPACE]: ['space:read', 'content:read', `content:write:${TYPE}`] },
71
+ });
72
+
73
+ it('answers from its own grants rather than the built-in table', () => {
74
+ expect(can(blogger, SPACE, 'content:read')).toBe(true);
75
+ expect(can(blogger, SPACE, 'asset:read')).toBe(false);
76
+ });
77
+
78
+ it('holds a content action for every type, or for the types named', () => {
79
+ expect(can(blogger, SPACE, 'content:read', 'type-2')).toBe(true);
80
+ expect(can(blogger, SPACE, 'content:write', TYPE)).toBe(true);
81
+ expect(can(blogger, SPACE, 'content:write', 'type-2')).toBe(false);
82
+ // Asked without a type: does the role write anything at all?
83
+ expect(can(blogger, SPACE, 'content:write')).toBe(true);
84
+ expect(can(blogger, SPACE, 'content:publish')).toBe(false);
85
+ });
86
+
87
+ it('tells a listing which types to narrow to', () => {
88
+ expect(allowedTypeIds(blogger, SPACE, 'content:read')).toBeNull();
89
+ expect(allowedTypeIds(blogger, SPACE, 'content:write')).toEqual([TYPE]);
90
+ expect(allowedTypeIds(blogger, SPACE, 'content:publish')).toEqual([]);
91
+ expect(allowedTypeIds(principal({ role: 'superadmin' }), SPACE, 'content:write')).toBeNull();
92
+ });
93
+
94
+ it('is nothing in a space where the name is unknown and no grants came along', () => {
95
+ const ghost = principal({ spaces: { [SPACE]: 'ghost' } });
96
+ expect(can(ghost, SPACE, 'content:read')).toBe(false);
97
+ });
98
+ });
99
+
100
+ describe('an API key confined to grants', () => {
101
+ const TYPE = 'type-1';
102
+ const key = principal({
103
+ spaces: { [SPACE]: 'editor' },
104
+ viaApiKey: true,
105
+ allowedGrants: ['content:read', `content:write:${TYPE}`, 'space:delete'],
106
+ });
107
+
108
+ it('never widens the owner: a grant the role lacks stays refused', () => {
109
+ expect(can(key, SPACE, 'space:delete')).toBe(false);
110
+ });
111
+
112
+ it('narrows the owner to the grants named', () => {
113
+ expect(can(key, SPACE, 'content:read')).toBe(true);
114
+ expect(can(key, SPACE, 'content:write', TYPE)).toBe(true);
115
+ expect(can(key, SPACE, 'content:write', 'type-2')).toBe(false);
116
+ expect(can(key, SPACE, 'asset:read')).toBe(false);
117
+ expect(allowedTypeIds(key, SPACE, 'content:write')).toEqual([TYPE]);
118
+ });
119
+
120
+ it('binds a superadmin too', () => {
121
+ const root = principal({ role: 'superadmin', spaces: {}, allowedGrants: ['content:read'] });
122
+ expect(can(root, SPACE, 'content:read')).toBe(true);
123
+ expect(can(root, SPACE, 'space:delete')).toBe(false);
124
+ expect(allowedTypeIds(root, SPACE, 'content:read')).toBeNull();
125
+ expect(allowedTypeIds(root, SPACE, 'content:write')).toEqual([]);
126
+ });
127
+ });
58
128
  });