@manablox/auth 0.2.0 → 0.3.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 +305 -0
- package/dist/index.js +418 -0
- package/package.json +14 -7
- package/src/api-key.ts +0 -164
- package/src/index.ts +0 -164
- package/src/password.ts +0 -17
- package/src/rbac.ts +0 -134
- package/src/user.service.ts +0 -192
- package/test/api-key.test.ts +0 -18
- package/test/rbac.test.ts +0 -128
- package/tsconfig.json +0 -1
- package/vitest.config.ts +0 -2
package/src/index.ts
DELETED
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import type { AuthConfig, Manablox } from '@manablox/core';
|
|
2
|
-
import type { Database, Repositories } from '@manablox/db';
|
|
3
|
-
import { schema } from '@manablox/db';
|
|
4
|
-
import { betterAuth } from 'better-auth';
|
|
5
|
-
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
|
|
6
|
-
import { APIError } from 'better-auth/api';
|
|
7
|
-
import { bearer } from 'better-auth/plugins';
|
|
8
|
-
import type { ApiKeyService } from './api-key.js';
|
|
9
|
-
import { hashPassword, MIN_PASSWORD_LENGTH, verifyPassword } from './password.js';
|
|
10
|
-
import type { Principal } from './rbac.js';
|
|
11
|
-
|
|
12
|
-
export * from './api-key.js';
|
|
13
|
-
export * from './password.js';
|
|
14
|
-
export * from './rbac.js';
|
|
15
|
-
export * from './user.service.js';
|
|
16
|
-
|
|
17
|
-
export type ManabloxAuth = ReturnType<typeof createAuth>;
|
|
18
|
-
|
|
19
|
-
/** better-auth, wired to the Drizzle schema. Sessions are rows, so concurrent devices
|
|
20
|
-
* each hold their own. */
|
|
21
|
-
export interface AuthCallbacks {
|
|
22
|
-
/** Runs after a user row is created, inside better-auth's own transaction path. */
|
|
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>;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export function createAuth(config: AuthConfig, db: Database, callbacks: AuthCallbacks = {}) {
|
|
33
|
-
return betterAuth({
|
|
34
|
-
secret: config.secret,
|
|
35
|
-
...(config.baseUrl ? { baseURL: config.baseUrl } : {}),
|
|
36
|
-
trustedOrigins: config.trustedOrigins ?? [],
|
|
37
|
-
|
|
38
|
-
database: drizzleAdapter(db, {
|
|
39
|
-
provider: 'pg',
|
|
40
|
-
schema: {
|
|
41
|
-
user: schema.users,
|
|
42
|
-
session: schema.sessions,
|
|
43
|
-
account: schema.accounts,
|
|
44
|
-
verification: schema.verifications,
|
|
45
|
-
apikey: schema.apikeys,
|
|
46
|
-
},
|
|
47
|
-
}),
|
|
48
|
-
|
|
49
|
-
emailAndPassword: {
|
|
50
|
-
enabled: config.emailAndPassword ?? true,
|
|
51
|
-
minPasswordLength: MIN_PASSWORD_LENGTH,
|
|
52
|
-
password: {
|
|
53
|
-
hash: hashPassword,
|
|
54
|
-
verify: ({ hash: stored, password }) => verifyPassword(stored, password),
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
|
|
58
|
-
session: {
|
|
59
|
-
expiresIn: config.sessionMaxAge ?? 60 * 60 * 24 * 7,
|
|
60
|
-
updateAge: 60 * 60 * 24,
|
|
61
|
-
cookieCache: { enabled: true, maxAge: 60 * 5 },
|
|
62
|
-
},
|
|
63
|
-
|
|
64
|
-
// `bearer` lets a non-browser client present the session token as an Authorization
|
|
65
|
-
// header instead of a cookie. Long-lived machine credentials are handled separately
|
|
66
|
-
// by `ApiKeyService` below — better-auth 1.7 ships no api-key plugin.
|
|
67
|
-
plugins: [bearer()],
|
|
68
|
-
|
|
69
|
-
databaseHooks: {
|
|
70
|
-
user: {
|
|
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
|
-
},
|
|
78
|
-
after: async (user) => {
|
|
79
|
-
await callbacks.onUserCreated?.(user.id);
|
|
80
|
-
},
|
|
81
|
-
},
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
|
|
85
|
-
advanced: { database: { generateId: () => crypto.randomUUID() } },
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Resolves a request's session into a `Principal`, including its space memberships.
|
|
91
|
-
* Returns `null` for anonymous requests rather than throwing — route guards decide.
|
|
92
|
-
*/
|
|
93
|
-
export async function resolvePrincipal(
|
|
94
|
-
auth: ManabloxAuth,
|
|
95
|
-
repos: Repositories,
|
|
96
|
-
headers: Headers,
|
|
97
|
-
apiKeys?: ApiKeyService,
|
|
98
|
-
): Promise<Principal | null> {
|
|
99
|
-
// An `x-api-key` header takes precedence: it identifies a machine consumer and never
|
|
100
|
-
// carries a browser session's ambient authority.
|
|
101
|
-
const presented = headers.get('x-api-key');
|
|
102
|
-
if (presented && apiKeys) {
|
|
103
|
-
const principal = await apiKeys.resolve(presented);
|
|
104
|
-
if (principal) return principal;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
const session = await auth.api.getSession({ headers });
|
|
108
|
-
if (!session?.user) return null;
|
|
109
|
-
|
|
110
|
-
// Role and memberships come from the database, not from the session payload, so a
|
|
111
|
-
// permission change takes effect on the next request rather than when better-auth's
|
|
112
|
-
// session cache happens to expire.
|
|
113
|
-
const principal = await repos.users.principal(session.user.id);
|
|
114
|
-
if (!principal || principal.banned) return null;
|
|
115
|
-
|
|
116
|
-
return {
|
|
117
|
-
userId: session.user.id,
|
|
118
|
-
email: session.user.email,
|
|
119
|
-
role: principal.role,
|
|
120
|
-
spaces: principal.spaces,
|
|
121
|
-
permissions: principal.permissions,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* Promotes the very first account to `superadmin` and grants it ownership of every
|
|
127
|
-
* existing space, so a fresh install is reachable.
|
|
128
|
-
*
|
|
129
|
-
* Called from better-auth's user-create hook rather than at startup, so it fires for an
|
|
130
|
-
* account created after the server is already running.
|
|
131
|
-
*/
|
|
132
|
-
export async function promoteFirstUser(
|
|
133
|
-
manablox: Manablox,
|
|
134
|
-
repos: Repositories,
|
|
135
|
-
userId: string,
|
|
136
|
-
): Promise<void> {
|
|
137
|
-
const count = await repos.users.count();
|
|
138
|
-
if (count !== 1) return;
|
|
139
|
-
|
|
140
|
-
const user = await repos.users.findById(userId);
|
|
141
|
-
if (!user || user.role === 'superadmin') return;
|
|
142
|
-
|
|
143
|
-
await repos.users.setRole(userId, 'superadmin');
|
|
144
|
-
for (const space of await repos.spaces.all()) {
|
|
145
|
-
await repos.users.grant(userId, space.id, 'owner');
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
manablox.logger.info({ email: user.email }, 'first account promoted to superadmin');
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/** Covers an instance whose first account predates this behaviour. */
|
|
152
|
-
export function attachBootstrapOwner(manablox: Manablox, repos: Repositories): void {
|
|
153
|
-
manablox.hooks.on(
|
|
154
|
-
'after:start',
|
|
155
|
-
async () => {
|
|
156
|
-
const { items } = await repos.users.list({ limit: 1, offset: 0 });
|
|
157
|
-
const first = items[0];
|
|
158
|
-
if (first && (await repos.users.count()) === 1) {
|
|
159
|
-
await promoteFirstUser(manablox, repos, first.id);
|
|
160
|
-
}
|
|
161
|
-
},
|
|
162
|
-
{ source: '@manablox/auth' },
|
|
163
|
-
);
|
|
164
|
-
}
|
package/src/password.ts
DELETED
|
@@ -1,17 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
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';
|
|
12
|
-
|
|
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';
|
|
33
|
-
|
|
34
|
-
export interface Principal {
|
|
35
|
-
userId: string;
|
|
36
|
-
email: string;
|
|
37
|
-
/** Instance-wide role; `superadmin` short-circuits every space check. */
|
|
38
|
-
role: string;
|
|
39
|
-
/** Space id → the name of the role held there. */
|
|
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[]>;
|
|
46
|
-
/** True when the request authenticated with an API key rather than a session. */
|
|
47
|
-
viaApiKey?: boolean;
|
|
48
|
-
/**
|
|
49
|
-
* Spaces this principal is confined to, or `null`/absent for no confinement. Set by an
|
|
50
|
-
* API key that was issued with a space restriction: it narrows the key below its
|
|
51
|
-
* owner's own access and, unlike a role, it also binds a superadmin.
|
|
52
|
-
*/
|
|
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);
|
|
67
|
-
}
|
|
68
|
-
|
|
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;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function can(
|
|
80
|
-
principal: Principal | null,
|
|
81
|
-
spaceId: string | null,
|
|
82
|
-
permission: Permission,
|
|
83
|
-
typeId?: string | null,
|
|
84
|
-
): boolean {
|
|
85
|
-
if (!principal) return false;
|
|
86
|
-
// Checked ahead of the superadmin short-circuit: a restricted key must not reach
|
|
87
|
-
// outside its spaces, and an instance-wide operation has no space to be inside.
|
|
88
|
-
if (principal.allowedSpaceIds && (!spaceId || !principal.allowedSpaceIds.includes(spaceId))) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
if (principal.allowedGrants && !grantsCover(principal.allowedGrants, permission, typeId)) {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
if (principal.role === 'superadmin') return true;
|
|
95
|
-
if (!spaceId) return false;
|
|
96
|
-
return grantsCover(grantsIn(principal, spaceId), permission, typeId);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function assertCan(
|
|
100
|
-
principal: Principal | null,
|
|
101
|
-
spaceId: string | null,
|
|
102
|
-
permission: Permission,
|
|
103
|
-
typeId?: string | null,
|
|
104
|
-
): void {
|
|
105
|
-
if (can(principal, spaceId, permission, typeId)) return;
|
|
106
|
-
if (!principal) throw ManabloxError.unauthorized();
|
|
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);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Roles used by field-level `readRoles`/`writeRoles` checks. */
|
|
129
|
-
export function actorRoles(principal: Principal | null, spaceId: string | null): string[] {
|
|
130
|
-
if (!principal) return [];
|
|
131
|
-
const roles = [principal.role];
|
|
132
|
-
if (spaceId && principal.spaces[spaceId]) roles.push(principal.spaces[spaceId]);
|
|
133
|
-
return roles;
|
|
134
|
-
}
|
package/src/user.service.ts
DELETED
|
@@ -1,192 +0,0 @@
|
|
|
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
|
-
}
|
package/test/api-key.test.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
actorRoles,
|
|
4
|
-
allowedTypeIds,
|
|
5
|
-
assertCan,
|
|
6
|
-
can,
|
|
7
|
-
type Principal,
|
|
8
|
-
permissionsFor,
|
|
9
|
-
} from '../src/rbac.js';
|
|
10
|
-
|
|
11
|
-
const SPACE = 'space-1';
|
|
12
|
-
|
|
13
|
-
const principal = (over: Partial<Principal> = {}): Principal => ({
|
|
14
|
-
userId: 'u1',
|
|
15
|
-
email: 'u@example.com',
|
|
16
|
-
role: 'editor',
|
|
17
|
-
spaces: { [SPACE]: 'editor' },
|
|
18
|
-
...over,
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe('rbac', () => {
|
|
22
|
-
it('denies everything to an anonymous caller', () => {
|
|
23
|
-
expect(can(null, SPACE, 'content:read')).toBe(false);
|
|
24
|
-
expect(() => assertCan(null, SPACE, 'content:read')).toThrow(/unauthorized/);
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it('lets an author write but not publish — the reason the role exists', () => {
|
|
28
|
-
const author = principal({ spaces: { [SPACE]: 'author' } });
|
|
29
|
-
expect(can(author, SPACE, 'content:write')).toBe(true);
|
|
30
|
-
expect(can(author, SPACE, 'content:publish')).toBe(false);
|
|
31
|
-
expect(() => assertCan(author, SPACE, 'content:publish')).toThrow(/forbidden/);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it('confines a viewer to reads', () => {
|
|
35
|
-
const viewer = principal({ spaces: { [SPACE]: 'viewer' } });
|
|
36
|
-
expect(can(viewer, SPACE, 'content:read')).toBe(true);
|
|
37
|
-
expect(can(viewer, SPACE, 'content:write')).toBe(false);
|
|
38
|
-
expect(can(viewer, SPACE, 'asset:write')).toBe(false);
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
it('grants nothing in a space the user is not a member of', () => {
|
|
42
|
-
expect(can(principal(), 'other-space', 'content:read')).toBe(false);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it('short-circuits every check for a superadmin', () => {
|
|
46
|
-
const root = principal({ role: 'superadmin', spaces: {} });
|
|
47
|
-
expect(can(root, 'any-space', 'space:delete')).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it('reserves space deletion for the owner', () => {
|
|
51
|
-
expect(can(principal({ spaces: { [SPACE]: 'admin' } }), SPACE, 'space:delete')).toBe(false);
|
|
52
|
-
expect(can(principal({ spaces: { [SPACE]: 'owner' } }), SPACE, 'space:delete')).toBe(true);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('exposes the roles a field-level permission check needs', () => {
|
|
56
|
-
expect(actorRoles(principal({ role: 'editor' }), SPACE)).toEqual(['editor', 'editor']);
|
|
57
|
-
expect(actorRoles(null, SPACE)).toEqual([]);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('never grants a write permission through a read-only role', () => {
|
|
61
|
-
for (const permission of permissionsFor('viewer')) {
|
|
62
|
-
expect(permission.endsWith(':read')).toBe(true);
|
|
63
|
-
}
|
|
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
|
-
});
|
|
128
|
-
});
|
package/tsconfig.json
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{ "extends": "@manablox/config-typescript/library.json", "include": ["src", "test"] }
|
package/vitest.config.ts
DELETED