@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,395 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { digest, hashPassword } from "./identity.password.js";
|
|
3
|
+
import { IdentityError } from "./identity.error.js";
|
|
4
|
+
import { IdentityRepository } from "./identity.repository.js";
|
|
5
|
+
import { checkIdentityRequest } from "./identity.request-context.js";
|
|
6
|
+
import { invitationSchema, recoverySchema, tokenCompletionSchema, } from "./identity.lifecycle-schema.js";
|
|
7
|
+
export class IdentityLifecycleService {
|
|
8
|
+
db;
|
|
9
|
+
config;
|
|
10
|
+
delivery;
|
|
11
|
+
constructor(db, config, delivery) {
|
|
12
|
+
this.db = db;
|
|
13
|
+
this.config = config;
|
|
14
|
+
this.delivery = delivery;
|
|
15
|
+
}
|
|
16
|
+
async invite(actor, raw) {
|
|
17
|
+
if (actor.portal === "user" ||
|
|
18
|
+
!actor.permissions.includes("identity.manage"))
|
|
19
|
+
throw new IdentityError(403, "Access denied.");
|
|
20
|
+
const input = invitationSchema.parse(raw);
|
|
21
|
+
const tenantId = input.tenantId ?? actor.tenant.id;
|
|
22
|
+
if (actor.portal !== "super-admin" &&
|
|
23
|
+
(tenantId !== actor.tenant.id || input.roleId !== "user"))
|
|
24
|
+
throw new IdentityError(403, "Access denied.");
|
|
25
|
+
this.requireDelivery();
|
|
26
|
+
if ((await new IdentityRepository(this.db).throttle(digest(`${actor.appId}:invitation:${actor.user.id}`))) > 20)
|
|
27
|
+
throw new IdentityError(429, "Too many invitations. Try again later.");
|
|
28
|
+
const tenant = await this.db
|
|
29
|
+
.selectFrom("identity_tenants")
|
|
30
|
+
.select("id")
|
|
31
|
+
.where("id", "=", tenantId)
|
|
32
|
+
.where("active", "=", 1)
|
|
33
|
+
.executeTakeFirst();
|
|
34
|
+
if (!tenant)
|
|
35
|
+
throw new IdentityError(422, "Organization is inactive or unavailable.");
|
|
36
|
+
if (await this.db
|
|
37
|
+
.selectFrom("identity_users")
|
|
38
|
+
.select("id")
|
|
39
|
+
.where("email", "=", input.email)
|
|
40
|
+
.executeTakeFirst())
|
|
41
|
+
throw new IdentityError(409, "This account already exists. Manage its membership instead.");
|
|
42
|
+
const row = await this.issue("invitation", input.email, input.name, input.roleId, tenantId, null, actor.user.id);
|
|
43
|
+
return this.present(row);
|
|
44
|
+
}
|
|
45
|
+
async requestRecovery(portal, raw, address) {
|
|
46
|
+
const input = recoverySchema
|
|
47
|
+
.refine((value) => this.config.mode !== "multi-tenant" || Boolean(value.tenantId), { path: ["tenantId"], message: "Enter an organization ID." })
|
|
48
|
+
.parse(raw);
|
|
49
|
+
this.requireDelivery();
|
|
50
|
+
const repository = new IdentityRepository(this.db);
|
|
51
|
+
if ((await repository.throttle(digest(`${this.config.appId}:recovery-ip:${address}`))) > 20)
|
|
52
|
+
throw new IdentityError(429, "Too many recovery requests. Try again later.");
|
|
53
|
+
if ((await repository.throttle(digest(`${this.config.appId}:recovery:${address}:${input.email}`))) > 5)
|
|
54
|
+
throw new IdentityError(429, "Too many recovery requests. Try again later.");
|
|
55
|
+
const tenantId = this.config.mode === "single-client"
|
|
56
|
+
? this.config.tenantId
|
|
57
|
+
: input.tenantId;
|
|
58
|
+
const user = await repository.user(input.email);
|
|
59
|
+
const principal = user?.active && tenantId
|
|
60
|
+
? await repository.principal(user.id, tenantId, portal, this.config.appId)
|
|
61
|
+
: null;
|
|
62
|
+
if (user?.active &&
|
|
63
|
+
tenantId &&
|
|
64
|
+
principal?.permissions.includes("identity.password")) {
|
|
65
|
+
await this.db
|
|
66
|
+
.updateTable("identity_tokens")
|
|
67
|
+
.set({ consumed_at: new Date().toISOString() })
|
|
68
|
+
.where("app_id", "=", this.config.appId)
|
|
69
|
+
.where("kind", "=", "recovery")
|
|
70
|
+
.where("user_id", "=", user.id)
|
|
71
|
+
.execute();
|
|
72
|
+
await this.issue("recovery", user.email, user.name, portal, tenantId, user.id);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
message: "If this account can recover access, an email will arrive.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
async complete(portal, kind, raw, address) {
|
|
79
|
+
const input = tokenCompletionSchema.parse(raw);
|
|
80
|
+
const repository = new IdentityRepository(this.db);
|
|
81
|
+
if ((await repository.throttle(digest(`${this.config.appId}:complete:${address}`))) > 10)
|
|
82
|
+
throw new IdentityError(429, "Too many account requests. Try again later.");
|
|
83
|
+
const available = await this.db
|
|
84
|
+
.selectFrom("identity_tokens")
|
|
85
|
+
.select("id")
|
|
86
|
+
.where("token_hash", "=", digest(input.token))
|
|
87
|
+
.where("app_id", "=", this.config.appId)
|
|
88
|
+
.where("kind", "=", kind)
|
|
89
|
+
.where("role_id", "=", portal)
|
|
90
|
+
.where("delivered", "=", 1)
|
|
91
|
+
.where("consumed_at", "is", null)
|
|
92
|
+
.where("expires_at", ">", new Date().toISOString())
|
|
93
|
+
.executeTakeFirst();
|
|
94
|
+
if (!available)
|
|
95
|
+
throw new IdentityError(422, "This link is invalid or expired.");
|
|
96
|
+
const passwordHash = await hashPassword(input.password);
|
|
97
|
+
checkIdentityRequest();
|
|
98
|
+
return this.db.transaction().execute(async (trx) => {
|
|
99
|
+
checkIdentityRequest();
|
|
100
|
+
const token = await trx
|
|
101
|
+
.selectFrom("identity_tokens")
|
|
102
|
+
.selectAll()
|
|
103
|
+
.where("token_hash", "=", digest(input.token))
|
|
104
|
+
.where("app_id", "=", this.config.appId)
|
|
105
|
+
.where("kind", "=", kind)
|
|
106
|
+
.where("role_id", "=", portal)
|
|
107
|
+
.where("delivered", "=", 1)
|
|
108
|
+
.where("consumed_at", "is", null)
|
|
109
|
+
.where("expires_at", ">", new Date().toISOString())
|
|
110
|
+
.executeTakeFirst();
|
|
111
|
+
if (!token ||
|
|
112
|
+
(this.config.mode === "single-client" &&
|
|
113
|
+
token.tenant_id !== this.config.tenantId))
|
|
114
|
+
throw new IdentityError(422, "This link is invalid or expired.");
|
|
115
|
+
const activeTenant = await trx
|
|
116
|
+
.selectFrom("identity_tenants")
|
|
117
|
+
.select("id")
|
|
118
|
+
.where("id", "=", token.tenant_id)
|
|
119
|
+
.where("active", "=", 1)
|
|
120
|
+
.executeTakeFirst();
|
|
121
|
+
if (!activeTenant)
|
|
122
|
+
throw new IdentityError(422, "This link is invalid or expired.");
|
|
123
|
+
const claimed = await trx
|
|
124
|
+
.updateTable("identity_tokens")
|
|
125
|
+
.set({ consumed_at: new Date().toISOString() })
|
|
126
|
+
.where("id", "=", token.id)
|
|
127
|
+
.where("consumed_at", "is", null)
|
|
128
|
+
.executeTakeFirst();
|
|
129
|
+
if (claimed.numUpdatedRows !== 1n)
|
|
130
|
+
throw new IdentityError(409, "This link was already used.");
|
|
131
|
+
const userId = token.user_id ?? randomUUID();
|
|
132
|
+
if (kind === "invitation") {
|
|
133
|
+
if (await trx
|
|
134
|
+
.selectFrom("identity_users")
|
|
135
|
+
.select("id")
|
|
136
|
+
.where("email", "=", token.email)
|
|
137
|
+
.executeTakeFirst())
|
|
138
|
+
throw new IdentityError(409, "Account already exists. Request a new invitation.");
|
|
139
|
+
await trx
|
|
140
|
+
.insertInto("identity_users")
|
|
141
|
+
.values({
|
|
142
|
+
id: userId,
|
|
143
|
+
email: token.email,
|
|
144
|
+
name: token.name,
|
|
145
|
+
password_hash: passwordHash,
|
|
146
|
+
active: 1,
|
|
147
|
+
})
|
|
148
|
+
.execute();
|
|
149
|
+
await trx
|
|
150
|
+
.insertInto("identity_memberships")
|
|
151
|
+
.values({
|
|
152
|
+
user_id: userId,
|
|
153
|
+
tenant_id: token.tenant_id,
|
|
154
|
+
role_id: token.role_id,
|
|
155
|
+
})
|
|
156
|
+
.execute();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
const principal = await new IdentityRepository(trx).principal(userId, token.tenant_id, portal, this.config.appId);
|
|
160
|
+
if (!principal?.permissions.includes("identity.password"))
|
|
161
|
+
throw new IdentityError(422, "This link is invalid or expired.");
|
|
162
|
+
await trx
|
|
163
|
+
.updateTable("identity_users")
|
|
164
|
+
.set({ password_hash: passwordHash })
|
|
165
|
+
.where("id", "=", userId)
|
|
166
|
+
.execute();
|
|
167
|
+
await trx
|
|
168
|
+
.deleteFrom("identity_sessions")
|
|
169
|
+
.where("user_id", "=", userId)
|
|
170
|
+
.execute();
|
|
171
|
+
await trx
|
|
172
|
+
.updateTable("identity_tokens")
|
|
173
|
+
.set({ consumed_at: new Date().toISOString() })
|
|
174
|
+
.where("user_id", "=", userId)
|
|
175
|
+
.where("kind", "=", "recovery")
|
|
176
|
+
.execute();
|
|
177
|
+
}
|
|
178
|
+
await trx
|
|
179
|
+
.insertInto("identity_audit_events")
|
|
180
|
+
.values({
|
|
181
|
+
id: randomUUID(),
|
|
182
|
+
app_id: this.config.appId,
|
|
183
|
+
actor_id: userId,
|
|
184
|
+
tenant_id: token.tenant_id,
|
|
185
|
+
action: `identity.${kind}.completed`,
|
|
186
|
+
resource_id: token.id,
|
|
187
|
+
created_at: new Date().toISOString(),
|
|
188
|
+
})
|
|
189
|
+
.execute();
|
|
190
|
+
checkIdentityRequest();
|
|
191
|
+
return { message: "Account access updated. Sign in to continue." };
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async list(actor, query) {
|
|
195
|
+
if (actor.portal === "user" ||
|
|
196
|
+
!actor.permissions.includes("identity.manage"))
|
|
197
|
+
throw new IdentityError(403, "Access denied.");
|
|
198
|
+
let select = this.db
|
|
199
|
+
.selectFrom("identity_tokens")
|
|
200
|
+
.where("app_id", "=", actor.appId)
|
|
201
|
+
.where("kind", "=", "invitation");
|
|
202
|
+
if (actor.portal !== "super-admin")
|
|
203
|
+
select = select.where("tenant_id", "=", actor.tenant.id);
|
|
204
|
+
if (query.search)
|
|
205
|
+
select = select.where((eb) => eb.or([
|
|
206
|
+
eb("email", "like", `%${query.search}%`),
|
|
207
|
+
eb("name", "like", `%${query.search}%`),
|
|
208
|
+
]));
|
|
209
|
+
const count = await select
|
|
210
|
+
.select((eb) => eb.fn.countAll().as("total"))
|
|
211
|
+
.executeTakeFirstOrThrow();
|
|
212
|
+
const rows = await select
|
|
213
|
+
.selectAll()
|
|
214
|
+
.orderBy(query.sort, query.direction)
|
|
215
|
+
.orderBy("id", "asc")
|
|
216
|
+
.limit(query.perPage)
|
|
217
|
+
.offset((query.page - 1) * query.perPage)
|
|
218
|
+
.execute();
|
|
219
|
+
const total = Number(count.total);
|
|
220
|
+
return {
|
|
221
|
+
data: rows.map((row) => this.present(row)),
|
|
222
|
+
meta: {
|
|
223
|
+
page: query.page,
|
|
224
|
+
perPage: query.perPage,
|
|
225
|
+
total,
|
|
226
|
+
lastPage: Math.max(1, Math.ceil(total / query.perPage)),
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
async show(actor, id) {
|
|
231
|
+
if (actor.portal === "user" ||
|
|
232
|
+
!actor.permissions.includes("identity.manage"))
|
|
233
|
+
throw new IdentityError(403, "Access denied.");
|
|
234
|
+
let query = this.db
|
|
235
|
+
.selectFrom("identity_tokens")
|
|
236
|
+
.selectAll()
|
|
237
|
+
.where("id", "=", id)
|
|
238
|
+
.where("app_id", "=", actor.appId)
|
|
239
|
+
.where("kind", "=", "invitation");
|
|
240
|
+
if (actor.portal !== "super-admin")
|
|
241
|
+
query = query.where("tenant_id", "=", actor.tenant.id);
|
|
242
|
+
const row = await query.executeTakeFirst();
|
|
243
|
+
if (!row)
|
|
244
|
+
throw new IdentityError(404, "Invitation not found.");
|
|
245
|
+
return this.present(row);
|
|
246
|
+
}
|
|
247
|
+
async resend(actor, id) {
|
|
248
|
+
const old = await this.show(actor, id);
|
|
249
|
+
if (old.status === "closed")
|
|
250
|
+
throw new IdentityError(409, "This invitation is closed.");
|
|
251
|
+
const result = await this.invite(actor, {
|
|
252
|
+
email: old.email,
|
|
253
|
+
name: old.name,
|
|
254
|
+
roleId: old.roleId,
|
|
255
|
+
tenantId: old.tenantId,
|
|
256
|
+
});
|
|
257
|
+
await this.revoke(actor, id);
|
|
258
|
+
return result;
|
|
259
|
+
}
|
|
260
|
+
async revoke(actor, id) {
|
|
261
|
+
if (actor.portal === "user" ||
|
|
262
|
+
!actor.permissions.includes("identity.manage"))
|
|
263
|
+
throw new IdentityError(403, "Access denied.");
|
|
264
|
+
const token = await this.db
|
|
265
|
+
.selectFrom("identity_tokens")
|
|
266
|
+
.select("tenant_id")
|
|
267
|
+
.where("id", "=", id)
|
|
268
|
+
.where("app_id", "=", actor.appId)
|
|
269
|
+
.where("kind", "=", "invitation")
|
|
270
|
+
.executeTakeFirst();
|
|
271
|
+
if (!token ||
|
|
272
|
+
(actor.portal !== "super-admin" && token.tenant_id !== actor.tenant.id))
|
|
273
|
+
throw new IdentityError(404, "Invitation not found.");
|
|
274
|
+
await this.db.transaction().execute(async (trx) => {
|
|
275
|
+
checkIdentityRequest();
|
|
276
|
+
let query = trx
|
|
277
|
+
.updateTable("identity_tokens")
|
|
278
|
+
.set({ consumed_at: new Date().toISOString() })
|
|
279
|
+
.where("id", "=", id)
|
|
280
|
+
.where("app_id", "=", actor.appId)
|
|
281
|
+
.where("kind", "=", "invitation");
|
|
282
|
+
if (actor.portal !== "super-admin")
|
|
283
|
+
query = query.where("tenant_id", "=", actor.tenant.id);
|
|
284
|
+
if ((await query.executeTakeFirst()).numUpdatedRows !== 1n)
|
|
285
|
+
throw new IdentityError(404, "Invitation not found.");
|
|
286
|
+
await trx
|
|
287
|
+
.insertInto("identity_audit_events")
|
|
288
|
+
.values({
|
|
289
|
+
id: randomUUID(),
|
|
290
|
+
app_id: actor.appId,
|
|
291
|
+
actor_id: actor.user.id,
|
|
292
|
+
tenant_id: token.tenant_id,
|
|
293
|
+
action: "identity.invitation.revoked",
|
|
294
|
+
resource_id: id,
|
|
295
|
+
created_at: new Date().toISOString(),
|
|
296
|
+
})
|
|
297
|
+
.execute();
|
|
298
|
+
checkIdentityRequest();
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
requireDelivery() {
|
|
302
|
+
if (!this.delivery)
|
|
303
|
+
throw new IdentityError(503, "Account email is unavailable. Contact your administrator.");
|
|
304
|
+
}
|
|
305
|
+
async issue(kind, email, name, role, tenantId, userId, actorId = userId ?? "system") {
|
|
306
|
+
const token = randomBytes(32).toString("base64url");
|
|
307
|
+
const row = {
|
|
308
|
+
id: randomUUID(),
|
|
309
|
+
token_hash: digest(token),
|
|
310
|
+
app_id: this.config.appId,
|
|
311
|
+
tenant_id: tenantId,
|
|
312
|
+
kind,
|
|
313
|
+
email,
|
|
314
|
+
name,
|
|
315
|
+
role_id: role,
|
|
316
|
+
user_id: userId,
|
|
317
|
+
expires_at: new Date(Date.now() + (kind === "recovery" ? 1800 : 86400) * 1000).toISOString(),
|
|
318
|
+
consumed_at: null,
|
|
319
|
+
delivered: 0,
|
|
320
|
+
};
|
|
321
|
+
checkIdentityRequest();
|
|
322
|
+
await this.db.insertInto("identity_tokens").values(row).execute();
|
|
323
|
+
const path = role === "user" ? "" : role === "admin" ? "/admin" : "/sa";
|
|
324
|
+
const link = `${this.config.origin}${path}/${kind === "invitation" ? "accept-invitation" : "reset-password"}#token=${token}`;
|
|
325
|
+
try {
|
|
326
|
+
const delivered = await this.delivery.send({
|
|
327
|
+
to: email,
|
|
328
|
+
subject: kind === "invitation"
|
|
329
|
+
? "Account invitation"
|
|
330
|
+
: "Recover account access",
|
|
331
|
+
text: `${name}, open this link to ${kind === "invitation" ? "set up your account" : "reset your password"}: ${link}\nThis link expires at ${row.expires_at}.`,
|
|
332
|
+
});
|
|
333
|
+
if (!delivered.messageId)
|
|
334
|
+
throw new Error("Missing delivery receipt.");
|
|
335
|
+
checkIdentityRequest();
|
|
336
|
+
await this.db.transaction().execute(async (trx) => {
|
|
337
|
+
await trx
|
|
338
|
+
.updateTable("identity_tokens")
|
|
339
|
+
.set({ delivered: 1 })
|
|
340
|
+
.where("id", "=", row.id)
|
|
341
|
+
.execute();
|
|
342
|
+
await trx
|
|
343
|
+
.insertInto("identity_audit_events")
|
|
344
|
+
.values({
|
|
345
|
+
id: randomUUID(),
|
|
346
|
+
app_id: this.config.appId,
|
|
347
|
+
actor_id: actorId,
|
|
348
|
+
tenant_id: tenantId,
|
|
349
|
+
action: `identity.${kind}.issued`,
|
|
350
|
+
resource_id: row.id,
|
|
351
|
+
created_at: new Date().toISOString(),
|
|
352
|
+
})
|
|
353
|
+
.execute();
|
|
354
|
+
checkIdentityRequest();
|
|
355
|
+
});
|
|
356
|
+
return { ...row, delivered: 1 };
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
await this.db
|
|
360
|
+
.deleteFrom("identity_tokens")
|
|
361
|
+
.where("id", "=", row.id)
|
|
362
|
+
.execute();
|
|
363
|
+
throw new IdentityError(503, "Account email is unavailable. Contact your administrator.");
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
present(row) {
|
|
367
|
+
return {
|
|
368
|
+
id: row.id,
|
|
369
|
+
email: row.email,
|
|
370
|
+
name: row.name,
|
|
371
|
+
roleId: row.role_id,
|
|
372
|
+
tenantId: row.tenant_id,
|
|
373
|
+
expiresAt: row.expires_at,
|
|
374
|
+
status: row.consumed_at
|
|
375
|
+
? "closed"
|
|
376
|
+
: row.expires_at <= new Date().toISOString()
|
|
377
|
+
? "expired"
|
|
378
|
+
: "pending",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
async audit(actor, action, resourceId, tenantId = actor.tenant.id) {
|
|
382
|
+
await this.db
|
|
383
|
+
.insertInto("identity_audit_events")
|
|
384
|
+
.values({
|
|
385
|
+
id: randomUUID(),
|
|
386
|
+
app_id: actor.appId,
|
|
387
|
+
actor_id: actor.user.id,
|
|
388
|
+
tenant_id: tenantId,
|
|
389
|
+
action,
|
|
390
|
+
resource_id: resourceId,
|
|
391
|
+
created_at: new Date().toISOString(),
|
|
392
|
+
})
|
|
393
|
+
.execute();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { IdentityError } from "./identity.error.js";
|
|
2
|
+
export async function protectAdministrators(trx, userId, tenantId, roleId) {
|
|
3
|
+
let query = trx
|
|
4
|
+
.selectFrom("identity_memberships")
|
|
5
|
+
.selectAll()
|
|
6
|
+
.where("user_id", "=", userId)
|
|
7
|
+
.where("active", "=", 1)
|
|
8
|
+
.where("role_id", "in", ["admin", "super-admin"]);
|
|
9
|
+
if (tenantId)
|
|
10
|
+
query = query.where("tenant_id", "=", tenantId);
|
|
11
|
+
if (roleId)
|
|
12
|
+
query = query.where("role_id", "=", roleId);
|
|
13
|
+
for (const member of await query.execute()) {
|
|
14
|
+
const peer = await trx
|
|
15
|
+
.selectFrom("identity_memberships as m")
|
|
16
|
+
.innerJoin("identity_users as u", "u.id", "m.user_id")
|
|
17
|
+
.select("u.id")
|
|
18
|
+
.where("m.tenant_id", "=", member.tenant_id)
|
|
19
|
+
.where("m.role_id", "=", member.role_id)
|
|
20
|
+
.where("m.active", "=", 1)
|
|
21
|
+
.where("u.active", "=", 1)
|
|
22
|
+
.where("u.id", "!=", userId)
|
|
23
|
+
.executeTakeFirst();
|
|
24
|
+
if (!peer)
|
|
25
|
+
throw new IdentityError(409, "Keep at least one active administrator for this role and organization.");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
export const identityMigration = {
|
|
2
|
+
async up(db) {
|
|
3
|
+
await db.schema
|
|
4
|
+
.createTable("identity_users")
|
|
5
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
6
|
+
.addColumn("email", "text", (c) => c.notNull().unique())
|
|
7
|
+
.addColumn("name", "text", (c) => c.notNull())
|
|
8
|
+
.addColumn("password_hash", "text", (c) => c.notNull())
|
|
9
|
+
.addColumn("active", "integer", (c) => c.notNull().defaultTo(1))
|
|
10
|
+
.execute();
|
|
11
|
+
await db.schema
|
|
12
|
+
.createTable("identity_tenants")
|
|
13
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
14
|
+
.addColumn("name", "text", (c) => c.notNull())
|
|
15
|
+
.addColumn("active", "integer", (c) => c.notNull().defaultTo(1))
|
|
16
|
+
.execute();
|
|
17
|
+
await db.schema
|
|
18
|
+
.createTable("identity_roles")
|
|
19
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
20
|
+
.execute();
|
|
21
|
+
await db.schema
|
|
22
|
+
.createTable("identity_permissions")
|
|
23
|
+
.addColumn("id", "text", (c) => c.primaryKey())
|
|
24
|
+
.execute();
|
|
25
|
+
await db.schema
|
|
26
|
+
.createTable("identity_role_permissions")
|
|
27
|
+
.addColumn("role_id", "text", (c) => c.notNull().references("identity_roles.id"))
|
|
28
|
+
.addColumn("permission_id", "text", (c) => c.notNull().references("identity_permissions.id"))
|
|
29
|
+
.addPrimaryKeyConstraint("identity_role_permission_pk", [
|
|
30
|
+
"role_id",
|
|
31
|
+
"permission_id",
|
|
32
|
+
])
|
|
33
|
+
.execute();
|
|
34
|
+
await db.schema
|
|
35
|
+
.createTable("identity_memberships")
|
|
36
|
+
.addColumn("user_id", "text", (c) => c.notNull().references("identity_users.id").onDelete("cascade"))
|
|
37
|
+
.addColumn("tenant_id", "text", (c) => c.notNull().references("identity_tenants.id").onDelete("cascade"))
|
|
38
|
+
.addColumn("role_id", "text", (c) => c.notNull().references("identity_roles.id"))
|
|
39
|
+
.addPrimaryKeyConstraint("identity_membership_pk", [
|
|
40
|
+
"user_id",
|
|
41
|
+
"tenant_id",
|
|
42
|
+
"role_id",
|
|
43
|
+
])
|
|
44
|
+
.execute();
|
|
45
|
+
await db.schema
|
|
46
|
+
.createTable("identity_sessions")
|
|
47
|
+
.addColumn("token_hash", "text", (c) => c.primaryKey())
|
|
48
|
+
.addColumn("user_id", "text", (c) => c.notNull().references("identity_users.id").onDelete("cascade"))
|
|
49
|
+
.addColumn("tenant_id", "text", (c) => c.notNull().references("identity_tenants.id").onDelete("cascade"))
|
|
50
|
+
.addColumn("app_id", "text", (c) => c.notNull())
|
|
51
|
+
.addColumn("portal", "text", (c) => c.notNull())
|
|
52
|
+
.addColumn("expires_at", "text", (c) => c.notNull())
|
|
53
|
+
.execute();
|
|
54
|
+
await db.schema
|
|
55
|
+
.createIndex("identity_sessions_expiry")
|
|
56
|
+
.on("identity_sessions")
|
|
57
|
+
.column("expires_at")
|
|
58
|
+
.execute();
|
|
59
|
+
await db.schema
|
|
60
|
+
.createTable("identity_throttles")
|
|
61
|
+
.addColumn("key", "text", (c) => c.primaryKey())
|
|
62
|
+
.addColumn("attempts", "integer", (c) => c.notNull())
|
|
63
|
+
.addColumn("expires_at", "text", (c) => c.notNull())
|
|
64
|
+
.execute();
|
|
65
|
+
},
|
|
66
|
+
async down(db) {
|
|
67
|
+
for (const table of [
|
|
68
|
+
"identity_throttles",
|
|
69
|
+
"identity_sessions",
|
|
70
|
+
"identity_memberships",
|
|
71
|
+
"identity_role_permissions",
|
|
72
|
+
"identity_permissions",
|
|
73
|
+
"identity_roles",
|
|
74
|
+
"identity_tenants",
|
|
75
|
+
"identity_users",
|
|
76
|
+
])
|
|
77
|
+
await db.schema.dropTable(table).execute();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomBytes, scrypt, timingSafeEqual, createHash } from "node:crypto";
|
|
2
|
+
const options = { N: 32768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 };
|
|
3
|
+
export async function hashPassword(password) {
|
|
4
|
+
const salt = randomBytes(16).toString("hex");
|
|
5
|
+
const key = await derive(password, salt);
|
|
6
|
+
return `scrypt$${salt}$${key.toString("hex")}`;
|
|
7
|
+
}
|
|
8
|
+
export async function verifyPassword(password, encoded) {
|
|
9
|
+
const [algorithm, salt, hex] = encoded.split("$");
|
|
10
|
+
if (algorithm !== "scrypt" ||
|
|
11
|
+
!/^[a-f0-9]{32}$/.test(salt ?? "") ||
|
|
12
|
+
!/^[a-f0-9]{128}$/.test(hex ?? ""))
|
|
13
|
+
return false;
|
|
14
|
+
return timingSafeEqual(await derive(password, salt), Buffer.from(hex, "hex"));
|
|
15
|
+
}
|
|
16
|
+
export function digest(value) {
|
|
17
|
+
return createHash("sha256").update(value).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
function derive(password, salt) {
|
|
20
|
+
return new Promise((resolve, reject) => scrypt(password, salt, 64, options, (error, key) => error ? reject(error) : resolve(key)));
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const identityPermissionDeclarationsMigration = {
|
|
2
|
+
async up(db) {
|
|
3
|
+
await db.schema.createTable("identity_permission_declarations")
|
|
4
|
+
.addColumn("permission_id", "text", (column) => column.primaryKey().references("identity_permissions.id"))
|
|
5
|
+
.addColumn("app_id", "text", (column) => column.notNull())
|
|
6
|
+
.addColumn("owner", "text", (column) => column.notNull())
|
|
7
|
+
.addColumn("portals", "text", (column) => column.notNull()).execute();
|
|
8
|
+
},
|
|
9
|
+
async down(db) {
|
|
10
|
+
const declared = await db.selectFrom("identity_permission_declarations").select("permission_id").executeTakeFirst();
|
|
11
|
+
if (declared)
|
|
12
|
+
throw new Error("Restore a compatible snapshot before removing permission ownership restrictions.");
|
|
13
|
+
await db.schema.dropTable("identity_permission_declarations").execute();
|
|
14
|
+
},
|
|
15
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { Kysely, Transaction } from "kysely";
|
|
3
|
+
import type { IdentitySchema, Portal } from "./identity.types.js";
|
|
4
|
+
declare const declarationSchema: z.ZodObject<{
|
|
5
|
+
owner: z.ZodString;
|
|
6
|
+
permissions: z.ZodArray<z.ZodObject<{
|
|
7
|
+
id: z.ZodString;
|
|
8
|
+
portals: z.ZodArray<z.ZodEnum<{
|
|
9
|
+
admin: "admin";
|
|
10
|
+
"super-admin": "super-admin";
|
|
11
|
+
user: "user";
|
|
12
|
+
}>>;
|
|
13
|
+
label: z.ZodOptional<z.ZodString>;
|
|
14
|
+
}, z.core.$strict>>;
|
|
15
|
+
}, z.core.$strict>;
|
|
16
|
+
export type IdentityPermissionDeclaration = z.input<typeof declarationSchema>;
|
|
17
|
+
export declare function registerIdentityPermissions(db: Kysely<IdentitySchema>, appId: string, raw: unknown): Promise<{
|
|
18
|
+
owner: string;
|
|
19
|
+
permissionIds: string[];
|
|
20
|
+
}>;
|
|
21
|
+
export declare function validateDeclaredPermissions(db: Kysely<IdentitySchema> | Transaction<IdentitySchema>, appId: string, portal: Portal, ids: string[]): Promise<void>;
|
|
22
|
+
export declare function filterDeclaredPermissions(db: Kysely<IdentitySchema>, appId: string, portal: Portal, ids: string[]): Promise<string[]>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { IdentityError } from "./identity.error.js";
|
|
3
|
+
import { portalSchema } from "./identity.schema.js";
|
|
4
|
+
const declarationSchema = z.object({
|
|
5
|
+
owner: z.string().regex(/^[a-z][a-z0-9-]{0,63}$/).refine((value) => !["identity", "desk"].includes(value)),
|
|
6
|
+
permissions: z.array(z.object({
|
|
7
|
+
id: z.string().regex(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*){2,8}$/).max(250),
|
|
8
|
+
portals: z.array(portalSchema).min(1).max(3),
|
|
9
|
+
label: z.string().trim().min(1).max(100).regex(/^[^\x00-\x1f\x7f]+$/).optional(),
|
|
10
|
+
}).strict()).min(1).max(100),
|
|
11
|
+
}).strict();
|
|
12
|
+
export async function registerIdentityPermissions(db, appId, raw) {
|
|
13
|
+
const declaration = declarationSchema.parse(raw);
|
|
14
|
+
const ids = new Set();
|
|
15
|
+
for (const permission of declaration.permissions) {
|
|
16
|
+
if (!permission.id.startsWith(`${appId}.${declaration.owner}.`) || ids.has(permission.id))
|
|
17
|
+
throw new IdentityError(422, "Use unique permission IDs in this app and owner namespace.");
|
|
18
|
+
ids.add(permission.id);
|
|
19
|
+
}
|
|
20
|
+
return db.transaction().execute(async (trx) => {
|
|
21
|
+
for (const permission of declaration.permissions) {
|
|
22
|
+
const portals = JSON.stringify([...new Set(permission.portals)].sort());
|
|
23
|
+
const previous = await trx.selectFrom("identity_permission_declarations").selectAll()
|
|
24
|
+
.where("permission_id", "=", permission.id).executeTakeFirst();
|
|
25
|
+
if (previous) {
|
|
26
|
+
if (previous.app_id !== appId || previous.owner !== declaration.owner || previous.portals !== portals)
|
|
27
|
+
throw new IdentityError(409, "Permission declaration conflicts with its registered owner or portal policy.");
|
|
28
|
+
if (permission.label !== undefined && permission.label !== previous.label)
|
|
29
|
+
await trx.updateTable("identity_permission_declarations").set({ label: permission.label })
|
|
30
|
+
.where("permission_id", "=", permission.id).execute();
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
if (await trx.selectFrom("identity_permissions").select("id").where("id", "=", permission.id).executeTakeFirst())
|
|
34
|
+
throw new IdentityError(409, "Permission already exists without this owner declaration.");
|
|
35
|
+
await trx.insertInto("identity_permissions").values({ id: permission.id }).execute();
|
|
36
|
+
await trx.insertInto("identity_permission_declarations").values({
|
|
37
|
+
permission_id: permission.id, app_id: appId, owner: declaration.owner, portals, label: permission.label ?? null,
|
|
38
|
+
}).execute();
|
|
39
|
+
}
|
|
40
|
+
return { owner: declaration.owner, permissionIds: [...ids] };
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export async function validateDeclaredPermissions(db, appId, portal, ids) {
|
|
44
|
+
const declarations = await db.selectFrom("identity_permission_declarations").selectAll().execute();
|
|
45
|
+
for (const id of ids) {
|
|
46
|
+
const declaration = declarations.find((row) => row.permission_id === id);
|
|
47
|
+
if (declaration && (declaration.app_id !== appId || !JSON.parse(declaration.portals).includes(portal)))
|
|
48
|
+
throw new IdentityError(403, "Permission is unavailable in this app or portal.");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export async function filterDeclaredPermissions(db, appId, portal, ids) {
|
|
52
|
+
const declarations = await db.selectFrom("identity_permission_declarations").selectAll().execute();
|
|
53
|
+
return ids.filter((id) => {
|
|
54
|
+
const declaration = declarations.find((row) => row.permission_id === id);
|
|
55
|
+
return !declaration || (declaration.app_id === appId && JSON.parse(declaration.portals).includes(portal));
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Additive migration. Do not rewrite the previously applied declaration schema.
|
|
2
|
+
export const identityPermissionLabelsMigration = {
|
|
3
|
+
async up(db) {
|
|
4
|
+
await db.schema.alterTable("identity_permission_declarations").addColumn("label", "text").execute();
|
|
5
|
+
},
|
|
6
|
+
async down(db) {
|
|
7
|
+
await db.schema.alterTable("identity_permission_declarations").dropColumn("label").execute();
|
|
8
|
+
},
|
|
9
|
+
};
|