@devxcrew/platform 0.1.2
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/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +2 -0
- package/dist/modules/identity/identity.administration-migration.d.ts +2 -0
- package/dist/modules/identity/identity.administration-migration.js +75 -0
- package/dist/modules/identity/identity.administration-repository.d.ts +19 -0
- package/dist/modules/identity/identity.administration-repository.js +102 -0
- package/dist/modules/identity/identity.administration-schema.d.ts +113 -0
- package/dist/modules/identity/identity.administration-schema.js +121 -0
- package/dist/modules/identity/identity.administration-service.d.ts +97 -0
- package/dist/modules/identity/identity.administration-service.js +517 -0
- package/dist/modules/identity/identity.controller.d.ts +22 -0
- package/dist/modules/identity/identity.controller.js +278 -0
- package/dist/modules/identity/identity.error.d.ts +6 -0
- package/dist/modules/identity/identity.error.js +15 -0
- package/dist/modules/identity/identity.lifecycle-schema.d.ts +19 -0
- package/dist/modules/identity/identity.lifecycle-schema.js +29 -0
- package/dist/modules/identity/identity.lifecycle-service.d.ts +64 -0
- package/dist/modules/identity/identity.lifecycle-service.js +395 -0
- package/dist/modules/identity/identity.membership-policy.d.ts +3 -0
- package/dist/modules/identity/identity.membership-policy.js +27 -0
- package/dist/modules/identity/identity.migration.d.ts +2 -0
- package/dist/modules/identity/identity.migration.js +79 -0
- package/dist/modules/identity/identity.password.d.ts +3 -0
- package/dist/modules/identity/identity.password.js +21 -0
- package/dist/modules/identity/identity.permission-declarations-migration.d.ts +5 -0
- package/dist/modules/identity/identity.permission-declarations-migration.js +15 -0
- package/dist/modules/identity/identity.permission-declarations.d.ts +23 -0
- package/dist/modules/identity/identity.permission-declarations.js +57 -0
- package/dist/modules/identity/identity.permission-labels-migration.d.ts +5 -0
- package/dist/modules/identity/identity.permission-labels-migration.js +9 -0
- package/dist/modules/identity/identity.presentation-service.d.ts +23 -0
- package/dist/modules/identity/identity.presentation-service.js +51 -0
- package/dist/modules/identity/identity.provider.d.ts +78 -0
- package/dist/modules/identity/identity.provider.js +119 -0
- package/dist/modules/identity/identity.public-schemas.d.ts +4 -0
- package/dist/modules/identity/identity.public-schemas.js +3 -0
- package/dist/modules/identity/identity.repository.d.ts +40 -0
- package/dist/modules/identity/identity.repository.js +196 -0
- package/dist/modules/identity/identity.request-context.d.ts +2 -0
- package/dist/modules/identity/identity.request-context.js +10 -0
- package/dist/modules/identity/identity.roles-migration.d.ts +2 -0
- package/dist/modules/identity/identity.roles-migration.js +64 -0
- package/dist/modules/identity/identity.roles-service.d.ts +49 -0
- package/dist/modules/identity/identity.roles-service.js +327 -0
- package/dist/modules/identity/identity.routes.d.ts +22 -0
- package/dist/modules/identity/identity.routes.js +11 -0
- package/dist/modules/identity/identity.schema.d.ts +27 -0
- package/dist/modules/identity/identity.schema.js +34 -0
- package/dist/modules/identity/identity.seed.d.ts +3 -0
- package/dist/modules/identity/identity.seed.js +81 -0
- package/dist/modules/identity/identity.service.d.ts +32 -0
- package/dist/modules/identity/identity.service.js +83 -0
- package/dist/modules/identity/identity.types.d.ts +150 -0
- package/dist/modules/identity/identity.types.js +1 -0
- package/dist/modules/identity/index.d.ts +12 -0
- package/dist/modules/identity/index.js +9 -0
- package/package.json +62 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { validateDeclaredPermissions } from "./identity.permission-declarations.js";
|
|
3
|
+
import { IdentityError } from "./identity.error.js";
|
|
4
|
+
import { checkIdentityRequest } from "./identity.request-context.js";
|
|
5
|
+
import { protectAdministrators } from "./identity.membership-policy.js";
|
|
6
|
+
import { customRoleCreateSchema, customRoleUpdateSchema, membershipUpdateSchema, } from "./identity.administration-schema.js";
|
|
7
|
+
export class IdentityRolesService {
|
|
8
|
+
db;
|
|
9
|
+
constructor(db) {
|
|
10
|
+
this.db = db;
|
|
11
|
+
}
|
|
12
|
+
async resolve(database, actor, roleId, tenantId) {
|
|
13
|
+
if (["user", "admin", "super-admin"].includes(roleId)) {
|
|
14
|
+
if (actor.portal !== "super-admin" && roleId !== "user")
|
|
15
|
+
throw new IdentityError(403, "Only super administrators can assign privileged roles.");
|
|
16
|
+
return { portal: roleId, customRoleId: null };
|
|
17
|
+
}
|
|
18
|
+
const role = await database
|
|
19
|
+
.selectFrom("identity_custom_roles")
|
|
20
|
+
.select("id")
|
|
21
|
+
.where("id", "=", roleId)
|
|
22
|
+
.where("app_id", "=", actor.appId)
|
|
23
|
+
.where("tenant_id", "=", tenantId)
|
|
24
|
+
.where("active", "=", 1)
|
|
25
|
+
.executeTakeFirst();
|
|
26
|
+
if (!role)
|
|
27
|
+
throw new IdentityError(422, "Select an active role in this organization.");
|
|
28
|
+
return { portal: "user", customRoleId: role.id };
|
|
29
|
+
}
|
|
30
|
+
async create(actor, raw) {
|
|
31
|
+
this.manage(actor);
|
|
32
|
+
const input = customRoleCreateSchema.parse(raw);
|
|
33
|
+
const tenantId = input.tenantId ?? actor.tenant.id;
|
|
34
|
+
this.scope(actor, tenantId);
|
|
35
|
+
return this.db.transaction().execute(async (trx) => {
|
|
36
|
+
checkIdentityRequest();
|
|
37
|
+
const tenant = await trx
|
|
38
|
+
.selectFrom("identity_tenants")
|
|
39
|
+
.select("id")
|
|
40
|
+
.where("id", "=", tenantId)
|
|
41
|
+
.where("active", "=", 1)
|
|
42
|
+
.executeTakeFirst();
|
|
43
|
+
if (!tenant)
|
|
44
|
+
throw new IdentityError(422, "Organization is inactive or unavailable.");
|
|
45
|
+
await this.permissions(trx, input.permissionIds, actor.appId);
|
|
46
|
+
await this.uniqueName(trx, actor.appId, tenantId, input.name);
|
|
47
|
+
const id = randomUUID();
|
|
48
|
+
await trx
|
|
49
|
+
.insertInto("identity_custom_roles")
|
|
50
|
+
.values({
|
|
51
|
+
id,
|
|
52
|
+
app_id: actor.appId,
|
|
53
|
+
tenant_id: tenantId,
|
|
54
|
+
name: input.name,
|
|
55
|
+
})
|
|
56
|
+
.execute();
|
|
57
|
+
await this.replacePermissions(trx, id, input.permissionIds);
|
|
58
|
+
await this.audit(trx, actor, tenantId, "identity.role.created", id);
|
|
59
|
+
checkIdentityRequest();
|
|
60
|
+
return {
|
|
61
|
+
id,
|
|
62
|
+
name: input.name,
|
|
63
|
+
tenantId,
|
|
64
|
+
portal: "user",
|
|
65
|
+
system: false,
|
|
66
|
+
active: true,
|
|
67
|
+
version: 0,
|
|
68
|
+
permissionIds: [...new Set(input.permissionIds)],
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
async update(actor, id, raw) {
|
|
73
|
+
this.manage(actor);
|
|
74
|
+
const input = customRoleUpdateSchema.parse(raw);
|
|
75
|
+
return this.db.transaction().execute(async (trx) => {
|
|
76
|
+
checkIdentityRequest();
|
|
77
|
+
const role = await trx
|
|
78
|
+
.selectFrom("identity_custom_roles")
|
|
79
|
+
.selectAll()
|
|
80
|
+
.where("id", "=", id)
|
|
81
|
+
.where("app_id", "=", actor.appId)
|
|
82
|
+
.executeTakeFirst();
|
|
83
|
+
if (!role ||
|
|
84
|
+
(actor.portal !== "super-admin" && role.tenant_id !== actor.tenant.id))
|
|
85
|
+
throw new IdentityError(404, "Role not found.");
|
|
86
|
+
if (Number(role.version) !== input.expectedVersion)
|
|
87
|
+
throw new IdentityError(409, "This record changed. Reload before saving.");
|
|
88
|
+
if (input.permissionIds)
|
|
89
|
+
await this.permissions(trx, input.permissionIds, actor.appId);
|
|
90
|
+
if (input.name)
|
|
91
|
+
await this.uniqueName(trx, actor.appId, role.tenant_id, input.name, id);
|
|
92
|
+
const updated = await trx
|
|
93
|
+
.updateTable("identity_custom_roles")
|
|
94
|
+
.set({
|
|
95
|
+
name: input.name ?? role.name,
|
|
96
|
+
active: input.active === undefined ? role.active : Number(input.active),
|
|
97
|
+
version: input.expectedVersion + 1,
|
|
98
|
+
})
|
|
99
|
+
.where("id", "=", id)
|
|
100
|
+
.where("version", "=", input.expectedVersion)
|
|
101
|
+
.executeTakeFirst();
|
|
102
|
+
if (updated.numUpdatedRows !== 1n)
|
|
103
|
+
throw new IdentityError(409, "This record changed. Reload before saving.");
|
|
104
|
+
if (input.permissionIds)
|
|
105
|
+
await this.replacePermissions(trx, id, input.permissionIds);
|
|
106
|
+
await trx
|
|
107
|
+
.deleteFrom("identity_sessions")
|
|
108
|
+
.where("app_id", "=", actor.appId)
|
|
109
|
+
.where("tenant_id", "=", role.tenant_id)
|
|
110
|
+
.where("user_id", "in", trx
|
|
111
|
+
.selectFrom("identity_memberships")
|
|
112
|
+
.select("user_id")
|
|
113
|
+
.where("custom_role_id", "=", id))
|
|
114
|
+
.execute();
|
|
115
|
+
await this.audit(trx, actor, role.tenant_id, "identity.role.changed", id);
|
|
116
|
+
const permissions = await trx
|
|
117
|
+
.selectFrom("identity_custom_role_permissions")
|
|
118
|
+
.select("permission_id")
|
|
119
|
+
.where("role_id", "=", id)
|
|
120
|
+
.execute();
|
|
121
|
+
checkIdentityRequest();
|
|
122
|
+
return {
|
|
123
|
+
id,
|
|
124
|
+
name: input.name ?? role.name,
|
|
125
|
+
tenantId: role.tenant_id,
|
|
126
|
+
portal: "user",
|
|
127
|
+
system: false,
|
|
128
|
+
active: input.active ?? Boolean(role.active),
|
|
129
|
+
version: input.expectedVersion + 1,
|
|
130
|
+
permissionIds: permissions.map((row) => row.permission_id),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
async updateMembership(actor, id, raw) {
|
|
135
|
+
this.manage(actor);
|
|
136
|
+
const input = membershipUpdateSchema.parse(raw);
|
|
137
|
+
const [userId, tenantId, oldPortal, extra] = id.split("~");
|
|
138
|
+
if (!userId || !tenantId || !oldPortal || extra)
|
|
139
|
+
throw new IdentityError(404, "Membership not found.");
|
|
140
|
+
this.scope(actor, tenantId);
|
|
141
|
+
if (actor.portal !== "super-admin" && oldPortal !== "user")
|
|
142
|
+
throw new IdentityError(403, "Only super administrators can change privileged memberships.");
|
|
143
|
+
return this.db.transaction().execute(async (trx) => {
|
|
144
|
+
checkIdentityRequest();
|
|
145
|
+
const current = await trx
|
|
146
|
+
.selectFrom("identity_memberships")
|
|
147
|
+
.selectAll()
|
|
148
|
+
.where("user_id", "=", userId)
|
|
149
|
+
.where("tenant_id", "=", tenantId)
|
|
150
|
+
.where("role_id", "=", oldPortal)
|
|
151
|
+
.executeTakeFirst();
|
|
152
|
+
if (!current)
|
|
153
|
+
throw new IdentityError(404, "Membership not found.");
|
|
154
|
+
if (current.custom_role_id &&
|
|
155
|
+
!(await trx
|
|
156
|
+
.selectFrom("identity_custom_roles")
|
|
157
|
+
.select("id")
|
|
158
|
+
.where("id", "=", current.custom_role_id)
|
|
159
|
+
.where("app_id", "=", actor.appId)
|
|
160
|
+
.executeTakeFirst()))
|
|
161
|
+
throw new IdentityError(404, "Membership not found.");
|
|
162
|
+
if (input.active &&
|
|
163
|
+
!(await trx
|
|
164
|
+
.selectFrom("identity_tenants")
|
|
165
|
+
.select("id")
|
|
166
|
+
.where("id", "=", tenantId)
|
|
167
|
+
.where("active", "=", 1)
|
|
168
|
+
.executeTakeFirst()))
|
|
169
|
+
throw new IdentityError(422, "Organization is inactive or unavailable.");
|
|
170
|
+
if (Number(current.version) !== input.expectedVersion)
|
|
171
|
+
throw new IdentityError(409, "This record changed. Reload before saving.");
|
|
172
|
+
const selected = await this.resolve(trx, actor, input.roleId, tenantId);
|
|
173
|
+
if (!input.active || selected.portal !== oldPortal)
|
|
174
|
+
await protectAdministrators(trx, userId, tenantId, oldPortal);
|
|
175
|
+
if (selected.portal !== oldPortal &&
|
|
176
|
+
(await trx
|
|
177
|
+
.selectFrom("identity_memberships")
|
|
178
|
+
.select("user_id")
|
|
179
|
+
.where("user_id", "=", userId)
|
|
180
|
+
.where("tenant_id", "=", tenantId)
|
|
181
|
+
.where("role_id", "=", selected.portal)
|
|
182
|
+
.executeTakeFirst()))
|
|
183
|
+
throw new IdentityError(409, "This account already has that portal membership.");
|
|
184
|
+
const changed = await trx
|
|
185
|
+
.updateTable("identity_memberships")
|
|
186
|
+
.set({
|
|
187
|
+
role_id: selected.portal,
|
|
188
|
+
custom_role_id: selected.customRoleId,
|
|
189
|
+
active: Number(input.active),
|
|
190
|
+
version: input.expectedVersion + 1,
|
|
191
|
+
})
|
|
192
|
+
.where("user_id", "=", userId)
|
|
193
|
+
.where("tenant_id", "=", tenantId)
|
|
194
|
+
.where("role_id", "=", oldPortal)
|
|
195
|
+
.where("version", "=", input.expectedVersion)
|
|
196
|
+
.executeTakeFirst();
|
|
197
|
+
if (changed.numUpdatedRows !== 1n)
|
|
198
|
+
throw new IdentityError(409, "This record changed. Reload before saving.");
|
|
199
|
+
await trx
|
|
200
|
+
.deleteFrom("identity_sessions")
|
|
201
|
+
.where("app_id", "=", actor.appId)
|
|
202
|
+
.where("user_id", "=", userId)
|
|
203
|
+
.where("tenant_id", "=", tenantId)
|
|
204
|
+
.where("portal", "=", oldPortal)
|
|
205
|
+
.execute();
|
|
206
|
+
const nextId = `${userId}~${tenantId}~${selected.portal}`;
|
|
207
|
+
await this.audit(trx, actor, tenantId, "identity.membership.changed", nextId);
|
|
208
|
+
checkIdentityRequest();
|
|
209
|
+
return {
|
|
210
|
+
id: nextId,
|
|
211
|
+
userId,
|
|
212
|
+
tenantId,
|
|
213
|
+
roleId: selected.customRoleId ?? selected.portal,
|
|
214
|
+
portal: selected.portal,
|
|
215
|
+
active: input.active,
|
|
216
|
+
version: input.expectedVersion + 1,
|
|
217
|
+
};
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async removeMembership(actor, id, expectedVersion) {
|
|
221
|
+
this.manage(actor);
|
|
222
|
+
if (!Number.isInteger(expectedVersion) || Number(expectedVersion) < 0)
|
|
223
|
+
throw new IdentityError(422, "Provide the current membership version.");
|
|
224
|
+
const [userId, tenantId, portal, extra] = id.split("~");
|
|
225
|
+
if (!userId || !tenantId || !portal || extra)
|
|
226
|
+
throw new IdentityError(404, "Membership not found.");
|
|
227
|
+
this.scope(actor, tenantId);
|
|
228
|
+
if (actor.portal !== "super-admin" && portal !== "user")
|
|
229
|
+
throw new IdentityError(403, "Only super administrators can change privileged memberships.");
|
|
230
|
+
await this.db.transaction().execute(async (trx) => {
|
|
231
|
+
checkIdentityRequest();
|
|
232
|
+
const current = await trx
|
|
233
|
+
.selectFrom("identity_memberships")
|
|
234
|
+
.select(["custom_role_id", "version"])
|
|
235
|
+
.where("user_id", "=", userId)
|
|
236
|
+
.where("tenant_id", "=", tenantId)
|
|
237
|
+
.where("role_id", "=", portal)
|
|
238
|
+
.executeTakeFirst();
|
|
239
|
+
if (!current ||
|
|
240
|
+
(current.custom_role_id &&
|
|
241
|
+
!(await trx
|
|
242
|
+
.selectFrom("identity_custom_roles")
|
|
243
|
+
.select("id")
|
|
244
|
+
.where("id", "=", current.custom_role_id)
|
|
245
|
+
.where("app_id", "=", actor.appId)
|
|
246
|
+
.executeTakeFirst())))
|
|
247
|
+
throw new IdentityError(404, "Membership not found.");
|
|
248
|
+
if (Number(current.version) !== expectedVersion)
|
|
249
|
+
throw new IdentityError(409, "This record changed. Reload before saving.");
|
|
250
|
+
await protectAdministrators(trx, userId, tenantId, portal);
|
|
251
|
+
await trx
|
|
252
|
+
.deleteFrom("identity_memberships")
|
|
253
|
+
.where("user_id", "=", userId)
|
|
254
|
+
.where("tenant_id", "=", tenantId)
|
|
255
|
+
.where("role_id", "=", portal)
|
|
256
|
+
.execute();
|
|
257
|
+
await trx
|
|
258
|
+
.deleteFrom("identity_sessions")
|
|
259
|
+
.where("app_id", "=", actor.appId)
|
|
260
|
+
.where("user_id", "=", userId)
|
|
261
|
+
.where("tenant_id", "=", tenantId)
|
|
262
|
+
.where("portal", "=", portal)
|
|
263
|
+
.execute();
|
|
264
|
+
await this.audit(trx, actor, tenantId, "identity.membership.removed", id);
|
|
265
|
+
checkIdentityRequest();
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
manage(actor) {
|
|
269
|
+
if (actor.portal === "user" ||
|
|
270
|
+
!actor.permissions.includes("identity.manage"))
|
|
271
|
+
throw new IdentityError(403, "Access denied.");
|
|
272
|
+
}
|
|
273
|
+
scope(actor, tenantId) {
|
|
274
|
+
if (actor.portal !== "super-admin" && tenantId !== actor.tenant.id)
|
|
275
|
+
throw new IdentityError(403, "Access denied.");
|
|
276
|
+
}
|
|
277
|
+
async permissions(trx, ids, appId) {
|
|
278
|
+
await validateDeclaredPermissions(trx, appId, "user", ids);
|
|
279
|
+
if (!ids.includes("desk.user"))
|
|
280
|
+
throw new IdentityError(422, "Keep the user desk permission.");
|
|
281
|
+
if (ids.some((id) => id === "identity.manage" ||
|
|
282
|
+
(id.startsWith("desk.") && id !== "desk.user")))
|
|
283
|
+
throw new IdentityError(403, "Custom user roles cannot grant administration access.");
|
|
284
|
+
const known = await trx
|
|
285
|
+
.selectFrom("identity_permissions")
|
|
286
|
+
.select("id")
|
|
287
|
+
.execute();
|
|
288
|
+
if (ids.some((id) => !known.some((row) => row.id === id)))
|
|
289
|
+
throw new IdentityError(422, "Unknown permission.");
|
|
290
|
+
}
|
|
291
|
+
async uniqueName(trx, appId, tenantId, name, exceptId = "") {
|
|
292
|
+
if (await trx
|
|
293
|
+
.selectFrom("identity_custom_roles")
|
|
294
|
+
.select("id")
|
|
295
|
+
.where("app_id", "=", appId)
|
|
296
|
+
.where("tenant_id", "=", tenantId)
|
|
297
|
+
.where("name", "=", name)
|
|
298
|
+
.where("id", "!=", exceptId)
|
|
299
|
+
.executeTakeFirst())
|
|
300
|
+
throw new IdentityError(409, "A role with this name already exists.");
|
|
301
|
+
}
|
|
302
|
+
async replacePermissions(trx, id, permissions) {
|
|
303
|
+
await trx
|
|
304
|
+
.deleteFrom("identity_custom_role_permissions")
|
|
305
|
+
.where("role_id", "=", id)
|
|
306
|
+
.execute();
|
|
307
|
+
for (const permission of new Set(permissions))
|
|
308
|
+
await trx
|
|
309
|
+
.insertInto("identity_custom_role_permissions")
|
|
310
|
+
.values({ role_id: id, permission_id: permission })
|
|
311
|
+
.execute();
|
|
312
|
+
}
|
|
313
|
+
async audit(trx, actor, tenantId, action, resourceId) {
|
|
314
|
+
await trx
|
|
315
|
+
.insertInto("identity_audit_events")
|
|
316
|
+
.values({
|
|
317
|
+
id: randomUUID(),
|
|
318
|
+
app_id: actor.appId,
|
|
319
|
+
actor_id: actor.user.id,
|
|
320
|
+
tenant_id: tenantId,
|
|
321
|
+
action,
|
|
322
|
+
resource_id: resourceId,
|
|
323
|
+
created_at: new Date().toISOString(),
|
|
324
|
+
})
|
|
325
|
+
.execute();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Portal } from "./identity.types.js";
|
|
2
|
+
export declare const identityRoutes: readonly [{
|
|
3
|
+
readonly method: "POST";
|
|
4
|
+
readonly path: "sessions";
|
|
5
|
+
readonly action: "login";
|
|
6
|
+
}, {
|
|
7
|
+
readonly method: "GET";
|
|
8
|
+
readonly path: "sessions/current";
|
|
9
|
+
readonly action: "session";
|
|
10
|
+
}, {
|
|
11
|
+
readonly method: "DELETE";
|
|
12
|
+
readonly path: "sessions/current";
|
|
13
|
+
readonly action: "logout";
|
|
14
|
+
}, {
|
|
15
|
+
readonly method: "PATCH";
|
|
16
|
+
readonly path: "password";
|
|
17
|
+
readonly action: "password";
|
|
18
|
+
}];
|
|
19
|
+
export declare const portalRoutes: Record<Portal, {
|
|
20
|
+
login: string;
|
|
21
|
+
desk: string;
|
|
22
|
+
}>;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const identityRoutes = [
|
|
2
|
+
{ method: "POST", path: "sessions", action: "login" },
|
|
3
|
+
{ method: "GET", path: "sessions/current", action: "session" },
|
|
4
|
+
{ method: "DELETE", path: "sessions/current", action: "logout" },
|
|
5
|
+
{ method: "PATCH", path: "password", action: "password" },
|
|
6
|
+
];
|
|
7
|
+
export const portalRoutes = {
|
|
8
|
+
user: { login: "/login", desk: "/desk" },
|
|
9
|
+
admin: { login: "/admin/login", desk: "/admin/desk" },
|
|
10
|
+
"super-admin": { login: "/sa/login", desk: "/sa/desk" },
|
|
11
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const portalSchema: z.ZodEnum<{
|
|
3
|
+
admin: "admin";
|
|
4
|
+
"super-admin": "super-admin";
|
|
5
|
+
user: "user";
|
|
6
|
+
}>;
|
|
7
|
+
export declare const identityKeySchema: z.ZodString;
|
|
8
|
+
export declare const loginSchema: z.ZodObject<{
|
|
9
|
+
email: z.ZodPipe<z.ZodEmail, z.ZodTransform<string, string>>;
|
|
10
|
+
password: z.ZodString;
|
|
11
|
+
tenantId: z.ZodOptional<z.ZodString>;
|
|
12
|
+
}, z.core.$strict>;
|
|
13
|
+
export declare const passwordSchema: z.ZodObject<{
|
|
14
|
+
currentPassword: z.ZodString;
|
|
15
|
+
password: z.ZodString;
|
|
16
|
+
}, z.core.$strict>;
|
|
17
|
+
export declare const accountSchema: z.ZodObject<{
|
|
18
|
+
email: z.ZodPipe<z.ZodEmail, z.ZodTransform<string, string>>;
|
|
19
|
+
name: z.ZodString;
|
|
20
|
+
password: z.ZodString;
|
|
21
|
+
portal: z.ZodEnum<{
|
|
22
|
+
admin: "admin";
|
|
23
|
+
"super-admin": "super-admin";
|
|
24
|
+
user: "user";
|
|
25
|
+
}>;
|
|
26
|
+
tenantId: z.ZodString;
|
|
27
|
+
}, z.core.$strict>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const portalSchema = z.enum(["user", "admin", "super-admin"]);
|
|
3
|
+
export const identityKeySchema = z
|
|
4
|
+
.string()
|
|
5
|
+
.regex(/^[a-zA-Z0-9._-]{1,100}$/)
|
|
6
|
+
.refine((value) => !/^\.+$/.test(value), "Use an identifier with a letter or number.");
|
|
7
|
+
export const loginSchema = z
|
|
8
|
+
.object({
|
|
9
|
+
email: z
|
|
10
|
+
.email()
|
|
11
|
+
.max(254)
|
|
12
|
+
.transform((value) => value.trim().toLowerCase()),
|
|
13
|
+
password: z.string().min(1).max(256),
|
|
14
|
+
tenantId: identityKeySchema.optional(),
|
|
15
|
+
})
|
|
16
|
+
.strict();
|
|
17
|
+
export const passwordSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
currentPassword: z.string().min(1).max(256),
|
|
20
|
+
password: z.string().min(12).max(256),
|
|
21
|
+
})
|
|
22
|
+
.strict();
|
|
23
|
+
export const accountSchema = z
|
|
24
|
+
.object({
|
|
25
|
+
email: z
|
|
26
|
+
.email()
|
|
27
|
+
.max(254)
|
|
28
|
+
.transform((value) => value.toLowerCase()),
|
|
29
|
+
name: z.string().trim().min(1).max(100),
|
|
30
|
+
password: z.string().min(12).max(256),
|
|
31
|
+
portal: portalSchema,
|
|
32
|
+
tenantId: identityKeySchema,
|
|
33
|
+
})
|
|
34
|
+
.strict();
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { accountSchema } from "./identity.schema.js";
|
|
3
|
+
import { hashPassword } from "./identity.password.js";
|
|
4
|
+
export async function seedIdentity(db, env) {
|
|
5
|
+
const tenantId = env.IDENTITY_TENANT_ID ?? "default";
|
|
6
|
+
const accounts = ["user", "admin", "super-admin"].flatMap((portal) => {
|
|
7
|
+
const prefix = `IDENTITY_SEED_${portal.toUpperCase().replace("-", "_")}`;
|
|
8
|
+
const email = env[`${prefix}_EMAIL`];
|
|
9
|
+
const password = env[`${prefix}_PASSWORD`];
|
|
10
|
+
if (!email && !password)
|
|
11
|
+
return [];
|
|
12
|
+
return [
|
|
13
|
+
accountSchema.parse({
|
|
14
|
+
email,
|
|
15
|
+
password,
|
|
16
|
+
name: env[`${prefix}_NAME`]?.trim() || portal,
|
|
17
|
+
portal,
|
|
18
|
+
tenantId,
|
|
19
|
+
}),
|
|
20
|
+
];
|
|
21
|
+
});
|
|
22
|
+
await db.transaction().execute(async (trx) => {
|
|
23
|
+
await trx
|
|
24
|
+
.insertInto("identity_tenants")
|
|
25
|
+
.values({
|
|
26
|
+
id: tenantId,
|
|
27
|
+
name: env.IDENTITY_TENANT_NAME ?? "Default organization",
|
|
28
|
+
active: 1,
|
|
29
|
+
})
|
|
30
|
+
.onConflict((c) => c.column("id").doNothing())
|
|
31
|
+
.execute();
|
|
32
|
+
for (const portal of ["user", "admin", "super-admin"]) {
|
|
33
|
+
await trx
|
|
34
|
+
.insertInto("identity_roles")
|
|
35
|
+
.values({ id: portal })
|
|
36
|
+
.onConflict((c) => c.column("id").doNothing())
|
|
37
|
+
.execute();
|
|
38
|
+
for (const permission of [
|
|
39
|
+
`desk.${portal}`,
|
|
40
|
+
"identity.self",
|
|
41
|
+
"identity.password",
|
|
42
|
+
...(portal === "user" ? [] : ["identity.manage"]),
|
|
43
|
+
]) {
|
|
44
|
+
await trx
|
|
45
|
+
.insertInto("identity_permissions")
|
|
46
|
+
.values({ id: permission })
|
|
47
|
+
.onConflict((c) => c.column("id").doNothing())
|
|
48
|
+
.execute();
|
|
49
|
+
await trx
|
|
50
|
+
.insertInto("identity_role_permissions")
|
|
51
|
+
.values({ role_id: portal, permission_id: permission })
|
|
52
|
+
.onConflict((c) => c.columns(["role_id", "permission_id"]).doNothing())
|
|
53
|
+
.execute();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
for (const account of accounts) {
|
|
57
|
+
const existing = await trx
|
|
58
|
+
.selectFrom("identity_users")
|
|
59
|
+
.select("id")
|
|
60
|
+
.where("email", "=", account.email)
|
|
61
|
+
.executeTakeFirst();
|
|
62
|
+
if (existing)
|
|
63
|
+
continue;
|
|
64
|
+
const id = randomUUID();
|
|
65
|
+
await trx
|
|
66
|
+
.insertInto("identity_users")
|
|
67
|
+
.values({
|
|
68
|
+
id,
|
|
69
|
+
email: account.email,
|
|
70
|
+
name: account.name,
|
|
71
|
+
password_hash: await hashPassword(account.password),
|
|
72
|
+
active: 1,
|
|
73
|
+
})
|
|
74
|
+
.execute();
|
|
75
|
+
await trx
|
|
76
|
+
.insertInto("identity_memberships")
|
|
77
|
+
.values({ user_id: id, tenant_id: tenantId, role_id: account.portal })
|
|
78
|
+
.execute();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { IdentityRepository } from "./identity.repository.js";
|
|
2
|
+
import type { IdentityConfig, Portal, Principal } from "./identity.types.js";
|
|
3
|
+
import type { z } from "zod";
|
|
4
|
+
import type { loginSchema, passwordSchema } from "./identity.schema.js";
|
|
5
|
+
export declare class IdentityService {
|
|
6
|
+
private readonly repository;
|
|
7
|
+
private readonly config;
|
|
8
|
+
private readonly dummyHash;
|
|
9
|
+
constructor(repository: IdentityRepository, config: IdentityConfig);
|
|
10
|
+
login(portal: Portal, input: z.infer<typeof loginSchema>, address: string, previous?: string): Promise<{
|
|
11
|
+
token: string;
|
|
12
|
+
sessionSeconds: number;
|
|
13
|
+
principal: {
|
|
14
|
+
user: {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
name: string;
|
|
18
|
+
};
|
|
19
|
+
tenant: {
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
};
|
|
23
|
+
permissions: string[];
|
|
24
|
+
appId: string;
|
|
25
|
+
portal: Portal;
|
|
26
|
+
};
|
|
27
|
+
}>;
|
|
28
|
+
authenticate(portal: Portal, token?: string): Promise<Principal>;
|
|
29
|
+
requirePermission(principal: Principal, permission: string): void;
|
|
30
|
+
logout(portal: Portal, token?: string): Promise<void>;
|
|
31
|
+
changePassword(principal: Principal, input: z.infer<typeof passwordSchema>): Promise<void>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { digest, hashPassword, verifyPassword } from "./identity.password.js";
|
|
3
|
+
import { IdentityError } from "./identity.error.js";
|
|
4
|
+
export class IdentityService {
|
|
5
|
+
repository;
|
|
6
|
+
config;
|
|
7
|
+
dummyHash = hashPassword(randomBytes(32).toString("hex"));
|
|
8
|
+
constructor(repository, config) {
|
|
9
|
+
this.repository = repository;
|
|
10
|
+
this.config = config;
|
|
11
|
+
}
|
|
12
|
+
async login(portal, input, address, previous) {
|
|
13
|
+
const key = digest(`${this.config.appId}:${portal}:${address}:${input.email}`);
|
|
14
|
+
const ipKey = digest(`${this.config.appId}:ip:${address}`);
|
|
15
|
+
if ((await this.repository.throttle(ipKey)) > 100 ||
|
|
16
|
+
(await this.repository.throttle(key)) > 10)
|
|
17
|
+
throw new IdentityError(429, "Too many sign-in attempts. Try again later.");
|
|
18
|
+
const user = await this.repository.user(input.email);
|
|
19
|
+
const valid = await verifyPassword(input.password, user?.password_hash ?? (await this.dummyHash));
|
|
20
|
+
const tenantId = this.config.mode === "single-client"
|
|
21
|
+
? this.config.tenantId
|
|
22
|
+
: input.tenantId;
|
|
23
|
+
if (!valid ||
|
|
24
|
+
!user ||
|
|
25
|
+
!Number(user.active) ||
|
|
26
|
+
!tenantId ||
|
|
27
|
+
(this.config.mode === "single-client" &&
|
|
28
|
+
input.tenantId &&
|
|
29
|
+
input.tenantId !== tenantId))
|
|
30
|
+
throw new IdentityError(401, "Invalid credentials or portal access.");
|
|
31
|
+
const principal = await this.repository.principal(user.id, tenantId, portal, this.config.appId);
|
|
32
|
+
if (!principal?.permissions.includes(`desk.${portal}`))
|
|
33
|
+
throw new IdentityError(401, "Invalid credentials or portal access.");
|
|
34
|
+
const token = randomBytes(32).toString("base64url");
|
|
35
|
+
const sessionSeconds = await this.repository.sessionSeconds(this.config.appId, this.config.sessionSeconds);
|
|
36
|
+
const created = await this.repository.createSession({
|
|
37
|
+
token_hash: digest(token),
|
|
38
|
+
user_id: user.id,
|
|
39
|
+
tenant_id: tenantId,
|
|
40
|
+
app_id: this.config.appId,
|
|
41
|
+
portal,
|
|
42
|
+
expires_at: new Date(Date.now() + sessionSeconds * 1000).toISOString(),
|
|
43
|
+
}, user.password_hash, previous ? digest(previous) : undefined);
|
|
44
|
+
if (!created)
|
|
45
|
+
throw new IdentityError(401, "Invalid credentials or portal access.");
|
|
46
|
+
await this.repository.clearThrottle(key);
|
|
47
|
+
return {
|
|
48
|
+
token,
|
|
49
|
+
sessionSeconds,
|
|
50
|
+
principal: { ...principal, appId: this.config.appId, portal },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
async authenticate(portal, token) {
|
|
54
|
+
if (!token || !/^[A-Za-z0-9_-]{43}$/.test(token))
|
|
55
|
+
throw new IdentityError(401, "Sign in to continue.");
|
|
56
|
+
const session = await this.repository.session(digest(token), this.config.appId, portal, new Date().toISOString());
|
|
57
|
+
if (!session ||
|
|
58
|
+
(this.config.mode === "single-client" &&
|
|
59
|
+
session.tenant_id !== this.config.tenantId))
|
|
60
|
+
throw new IdentityError(401, "Sign in to continue.");
|
|
61
|
+
const principal = await this.repository.principal(session.user_id, session.tenant_id, portal, this.config.appId);
|
|
62
|
+
if (!principal)
|
|
63
|
+
throw new IdentityError(401, "Sign in to continue.");
|
|
64
|
+
return { ...principal, appId: this.config.appId, portal };
|
|
65
|
+
}
|
|
66
|
+
requirePermission(principal, permission) {
|
|
67
|
+
if (!principal.permissions.includes(permission))
|
|
68
|
+
throw new IdentityError(403, "Access denied.");
|
|
69
|
+
}
|
|
70
|
+
async logout(portal, token) {
|
|
71
|
+
if (token)
|
|
72
|
+
await this.repository.revoke(digest(token), this.config.appId, portal);
|
|
73
|
+
}
|
|
74
|
+
async changePassword(principal, input) {
|
|
75
|
+
this.requirePermission(principal, "identity.password");
|
|
76
|
+
const user = await this.repository.user(principal.user.email);
|
|
77
|
+
if (!user ||
|
|
78
|
+
!(await verifyPassword(input.currentPassword, user.password_hash)))
|
|
79
|
+
throw new IdentityError(422, "Current password is incorrect.");
|
|
80
|
+
if (!(await this.repository.changePassword(user.id, user.password_hash, await hashPassword(input.password))))
|
|
81
|
+
throw new IdentityError(409, "Account changed. Sign in again.");
|
|
82
|
+
}
|
|
83
|
+
}
|