@happyvertical/smrt-users 0.37.2 → 0.37.4
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/chunks/TerminalAuthService-BXAAuaXf.js +4531 -0
- package/dist/chunks/TerminalAuthService-BXAAuaXf.js.map +1 -0
- package/dist/index.js +1564 -2024
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +2 -2
- package/dist/playground.js +114 -136
- package/dist/playground.js.map +1 -1
- package/dist/smrt-knowledge.json +12 -12
- package/dist/sveltekit.js +685 -881
- package/dist/sveltekit.js.map +1 -1
- package/dist/ui.js +79 -73
- package/dist/ui.js.map +1 -1
- package/package.json +19 -19
- package/dist/chunks/TerminalAuthService-DcgimQYo.js +0 -5261
- package/dist/chunks/TerminalAuthService-DcgimQYo.js.map +0 -1
- package/dist/chunks/index-C3E55ikp.js +0 -169
- package/dist/chunks/index-C3E55ikp.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,2123 +1,1663 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import { field, smrt, SmrtObject, SmrtCollection, foreignKey, ObjectRegistry, findManifestEntryByQualifiedName } from "@happyvertical/smrt-core";
|
|
4
|
-
import { AccessRequestStatus, UserStatus, MembershipStatus } from "@happyvertical/smrt-types";
|
|
5
|
-
import { AccessRequestStatus as AccessRequestStatus2, MembershipStatus as MembershipStatus2, OverrideEffect, SessionStatus, TenantPermissionEffect, TenantStatus, UserStatus as UserStatus2 } from "@happyvertical/smrt-types";
|
|
1
|
+
import { $ as DEFAULT_ROLE_SLUGS, A as RolePermissionCollection, B as GroupRoleCollection, C as TenantHierarchyError, D as DEFAULT_SESSION_TTL, E as SessionCollection, F as parsePermissionSlug, G as Group, H as GroupMemberCollection, I as MembershipOverrideCollection, J as User, K as UsersCliAuthRequestCollection, L as MembershipOverride, M as PermissionCollection, N as Permission, O as Session, P as isValidPermissionSlug, Q as DEFAULT_ROLES, R as MembershipCollection, S as TenantCollection, T as Tenant, U as GroupMember, V as GroupRole, W as GroupCollection, X as normalizeEmail, Y as isValidEmail, Z as AccessRequestStatus, _ as resolveOidcProviderConfig, a as TerminalAuthRateLimitError, at as TenantStatus, b as TenantPermissionOverrideCollection, c as getRequestScopedDatabase, d as PermissionResolver, et as DEFAULT_TENANT_POLICY, f as OidcLoginError, g as getUsersOidcConfig, h as encodeOidcTransaction, i as TerminalAuthError, it as TenantPermissionEffect, j as RolePermission, k as generateSessionId, l as withSessionPermissionContext, m as decodeOidcTransaction, n as DEFAULT_CLI_AUTH_REQUEST_TTL_SECONDS, nt as OverrideEffect, o as TerminalAuthService, ot as UserStatus, p as OidcLoginService, q as UsersCliAuthRequest, r as DEFAULT_CLI_SESSION_TTL_SECONDS, rt as SessionStatus, s as getCurrentSessionPermissionContext, t as DEFAULT_CLI_AUTH_POLL_INTERVAL_SECONDS, tt as MembershipStatus, u as SessionService, w as MAX_TENANT_HIERARCHY_DEPTH, x as TenantPermissionOverride, y as UserCollection, z as Membership } from "./chunks/TerminalAuthService-BXAAuaXf.js";
|
|
2
|
+
import { ObjectRegistry, SmrtCollection, SmrtObject, field, findManifestEntryByQualifiedName, foreignKey, smrt } from "@happyvertical/smrt-core";
|
|
6
3
|
import { createLogger } from "@happyvertical/logger";
|
|
7
4
|
import { getPackageConfig } from "@happyvertical/smrt-config";
|
|
8
5
|
import { createHash } from "node:crypto";
|
|
6
|
+
//#region src/models/AccessRequest.ts
|
|
9
7
|
var __defProp$2 = Object.defineProperty;
|
|
10
8
|
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
11
9
|
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
if (kind && result) __defProp$2(target, key, result);
|
|
17
|
-
return result;
|
|
10
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$2(target, key) : target;
|
|
11
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
12
|
+
if (kind && result) __defProp$2(target, key, result);
|
|
13
|
+
return result;
|
|
18
14
|
};
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
isTerminal() {
|
|
101
|
-
return this.status === AccessRequestStatus.DECLINED || this.status === AccessRequestStatus.GRADUATED || this.status === AccessRequestStatus.CANCELED;
|
|
102
|
-
}
|
|
15
|
+
var AccessRequest = class extends SmrtObject {
|
|
16
|
+
email = "";
|
|
17
|
+
name = null;
|
|
18
|
+
status = AccessRequestStatus.REQUESTED;
|
|
19
|
+
source = "";
|
|
20
|
+
requestContext = "{}";
|
|
21
|
+
note = null;
|
|
22
|
+
requestedAt = /* @__PURE__ */ new Date();
|
|
23
|
+
decidedAt = null;
|
|
24
|
+
decidedBy = null;
|
|
25
|
+
resultingUserId = null;
|
|
26
|
+
tenantHint = null;
|
|
27
|
+
constructor(options = {}) {
|
|
28
|
+
super(options);
|
|
29
|
+
if (options.email !== void 0) this.email = normalizeEmail(options.email);
|
|
30
|
+
if (options.name !== void 0) this.name = options.name;
|
|
31
|
+
if (options.status !== void 0) this.status = options.status;
|
|
32
|
+
if (options.source !== void 0) this.source = options.source;
|
|
33
|
+
if (options.requestContext !== void 0) this.requestContext = options.requestContext;
|
|
34
|
+
if (options.note !== void 0) this.note = options.note;
|
|
35
|
+
if (options.requestedAt !== void 0) this.requestedAt = options.requestedAt;
|
|
36
|
+
if (options.decidedAt !== void 0) this.decidedAt = options.decidedAt;
|
|
37
|
+
if (options.decidedBy !== void 0) this.decidedBy = options.decidedBy;
|
|
38
|
+
if (options.resultingUserId !== void 0) this.resultingUserId = options.resultingUserId;
|
|
39
|
+
if (options.tenantHint !== void 0) this.tenantHint = options.tenantHint;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parse {@link requestContext} into an object. Returns `{}` on missing or
|
|
43
|
+
* malformed JSON (graceful — never throws).
|
|
44
|
+
*/
|
|
45
|
+
getRequestContext() {
|
|
46
|
+
try {
|
|
47
|
+
const parsed = JSON.parse(this.requestContext);
|
|
48
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
49
|
+
} catch {
|
|
50
|
+
return {};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Serialize and store {@link requestContext} from an object.
|
|
55
|
+
*/
|
|
56
|
+
setRequestContext(value) {
|
|
57
|
+
this.requestContext = JSON.stringify(value ?? {});
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse {@link tenantHint} into an object, or `null` when unset / malformed.
|
|
61
|
+
*/
|
|
62
|
+
getTenantHint() {
|
|
63
|
+
if (!this.tenantHint) return null;
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(this.tenantHint);
|
|
66
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Serialize and store {@link tenantHint} from an object (or clear with null).
|
|
73
|
+
*/
|
|
74
|
+
setTenantHint(value) {
|
|
75
|
+
this.tenantHint = value ? JSON.stringify(value) : null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Whether the request is still open (awaiting a decision).
|
|
79
|
+
*/
|
|
80
|
+
isOpen() {
|
|
81
|
+
return this.status === AccessRequestStatus.REQUESTED;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Whether the request has been approved (and not yet graduated).
|
|
85
|
+
*/
|
|
86
|
+
isApproved() {
|
|
87
|
+
return this.status === AccessRequestStatus.APPROVED;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether the request reached a terminal state (declined, graduated, or
|
|
91
|
+
* canceled) and can no longer transition.
|
|
92
|
+
*/
|
|
93
|
+
isTerminal() {
|
|
94
|
+
return this.status === AccessRequestStatus.DECLINED || this.status === AccessRequestStatus.GRADUATED || this.status === AccessRequestStatus.CANCELED;
|
|
95
|
+
}
|
|
103
96
|
};
|
|
104
|
-
__decorateClass$2([
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
], AccessRequest.prototype, "status", 2);
|
|
113
|
-
__decorateClass$2([
|
|
114
|
-
|
|
115
|
-
], AccessRequest.prototype, "
|
|
116
|
-
__decorateClass$2([
|
|
117
|
-
|
|
118
|
-
], AccessRequest.prototype, "
|
|
119
|
-
__decorateClass$2([
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
]
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
], AccessRequest
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
limit: 1,
|
|
176
|
-
orderBy: "created_at DESC"
|
|
177
|
-
});
|
|
178
|
-
return results.length > 0 ? results[0] : null;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Find access requests by status, newest first.
|
|
182
|
-
*/
|
|
183
|
-
async findByStatus(status) {
|
|
184
|
-
return await this.list({
|
|
185
|
-
where: { status },
|
|
186
|
-
orderBy: "created_at DESC"
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Find all open (`REQUESTED`) access requests — the operator triage queue.
|
|
191
|
-
*/
|
|
192
|
-
async findOpen() {
|
|
193
|
-
return await this.findByStatus(AccessRequestStatus.REQUESTED);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
97
|
+
__decorateClass$2([field({
|
|
98
|
+
required: true,
|
|
99
|
+
indexed: true
|
|
100
|
+
})], AccessRequest.prototype, "email", 2);
|
|
101
|
+
__decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "name", 2);
|
|
102
|
+
__decorateClass$2([field({
|
|
103
|
+
type: "text",
|
|
104
|
+
indexed: true
|
|
105
|
+
})], AccessRequest.prototype, "status", 2);
|
|
106
|
+
__decorateClass$2([field({ indexed: true })], AccessRequest.prototype, "source", 2);
|
|
107
|
+
__decorateClass$2([field()], AccessRequest.prototype, "requestContext", 2);
|
|
108
|
+
__decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "note", 2);
|
|
109
|
+
__decorateClass$2([field()], AccessRequest.prototype, "requestedAt", 2);
|
|
110
|
+
__decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "decidedAt", 2);
|
|
111
|
+
__decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "decidedBy", 2);
|
|
112
|
+
__decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "resultingUserId", 2);
|
|
113
|
+
__decorateClass$2([field({ nullable: true })], AccessRequest.prototype, "tenantHint", 2);
|
|
114
|
+
AccessRequest = __decorateClass$2([smrt({
|
|
115
|
+
tableName: "access_requests",
|
|
116
|
+
conflictColumns: ["id"],
|
|
117
|
+
api: { include: [] },
|
|
118
|
+
mcp: { include: [] },
|
|
119
|
+
cli: { include: [] }
|
|
120
|
+
})], AccessRequest);
|
|
121
|
+
//#endregion
|
|
122
|
+
//#region src/collections/AccessRequestCollection.ts
|
|
123
|
+
var AccessRequestCollection = class extends SmrtCollection {
|
|
124
|
+
static _itemClass = AccessRequest;
|
|
125
|
+
/**
|
|
126
|
+
* Find all access requests for an email (any status), newest first. The email
|
|
127
|
+
* is normalized before querying so callers can pass any case.
|
|
128
|
+
*/
|
|
129
|
+
async findByEmail(email) {
|
|
130
|
+
return await this.list({
|
|
131
|
+
where: { email: normalizeEmail(email) },
|
|
132
|
+
orderBy: "created_at DESC"
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Find the single open (`REQUESTED`) request for an email, if any. This is
|
|
137
|
+
* the dedup key used by {@link AccessRequestService.createAccessRequest}.
|
|
138
|
+
*/
|
|
139
|
+
async findOpenByEmail(email) {
|
|
140
|
+
const results = await this.list({
|
|
141
|
+
where: {
|
|
142
|
+
email: normalizeEmail(email),
|
|
143
|
+
status: AccessRequestStatus.REQUESTED
|
|
144
|
+
},
|
|
145
|
+
limit: 1,
|
|
146
|
+
orderBy: "created_at DESC"
|
|
147
|
+
});
|
|
148
|
+
return results.length > 0 ? results[0] : null;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Find access requests by status, newest first.
|
|
152
|
+
*/
|
|
153
|
+
async findByStatus(status) {
|
|
154
|
+
return await this.list({
|
|
155
|
+
where: { status },
|
|
156
|
+
orderBy: "created_at DESC"
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Find all open (`REQUESTED`) access requests — the operator triage queue.
|
|
161
|
+
*/
|
|
162
|
+
async findOpen() {
|
|
163
|
+
return await this.findByStatus(AccessRequestStatus.REQUESTED);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
//#endregion
|
|
167
|
+
//#region src/models/MagicLinkToken.ts
|
|
196
168
|
var __defProp$1 = Object.defineProperty;
|
|
197
169
|
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
198
170
|
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (kind && result) __defProp$1(target, key, result);
|
|
204
|
-
return result;
|
|
171
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc$1(target, key) : target;
|
|
172
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
173
|
+
if (kind && result) __defProp$1(target, key, result);
|
|
174
|
+
return result;
|
|
205
175
|
};
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return !this.used && !this.isExpired();
|
|
231
|
-
}
|
|
176
|
+
var DEFAULT_TOKEN_EXPIRY_SECONDS = 600;
|
|
177
|
+
var UsersMagicLinkToken = class extends SmrtObject {
|
|
178
|
+
nonce = "";
|
|
179
|
+
/** Email address this token was generated for */
|
|
180
|
+
email = "";
|
|
181
|
+
/** Whether this token has been used */
|
|
182
|
+
used = false;
|
|
183
|
+
/** When this token expires */
|
|
184
|
+
expiresAt = new Date(Date.now() + 600 * 1e3);
|
|
185
|
+
constructor(options = {}) {
|
|
186
|
+
super(options);
|
|
187
|
+
if (options.nonce !== void 0) this.nonce = options.nonce;
|
|
188
|
+
if (options.email !== void 0) this.email = options.email;
|
|
189
|
+
if (options.used !== void 0) this.used = options.used;
|
|
190
|
+
if (options.expiresAt !== void 0) this.expiresAt = options.expiresAt instanceof Date ? options.expiresAt : new Date(options.expiresAt);
|
|
191
|
+
}
|
|
192
|
+
/** Check if the token has expired */
|
|
193
|
+
isExpired() {
|
|
194
|
+
return /* @__PURE__ */ new Date() > this.expiresAt;
|
|
195
|
+
}
|
|
196
|
+
/** Check if the token is still valid (unused and not expired) */
|
|
197
|
+
isValid() {
|
|
198
|
+
return !this.used && !this.isExpired();
|
|
199
|
+
}
|
|
232
200
|
};
|
|
233
|
-
__decorateClass$1([
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
UsersMagicLinkToken
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
264
|
-
const { rowCount } = await this.db.query(
|
|
265
|
-
`UPDATE ${this.tableName}
|
|
201
|
+
__decorateClass$1([field({
|
|
202
|
+
required: true,
|
|
203
|
+
unique: true
|
|
204
|
+
})], UsersMagicLinkToken.prototype, "nonce", 2);
|
|
205
|
+
UsersMagicLinkToken = __decorateClass$1([smrt({
|
|
206
|
+
tableName: "users_magic_link_tokens",
|
|
207
|
+
api: { include: [] },
|
|
208
|
+
mcp: { include: [] },
|
|
209
|
+
cli: true
|
|
210
|
+
})], UsersMagicLinkToken);
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/collections/MagicLinkTokenCollection.ts
|
|
213
|
+
var UsersMagicLinkTokenCollection = class extends SmrtCollection {
|
|
214
|
+
static _itemClass = UsersMagicLinkToken;
|
|
215
|
+
/**
|
|
216
|
+
* Find a token by its nonce
|
|
217
|
+
*/
|
|
218
|
+
async findByNonce(nonce) {
|
|
219
|
+
return this.findOne({ where: { nonce } });
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Atomically mark a token as used (single-use enforcement).
|
|
223
|
+
*
|
|
224
|
+
* Returns true if the nonce was successfully claimed (transitioned from
|
|
225
|
+
* unused to used). Returns false if the nonce was already used, expired,
|
|
226
|
+
* or doesn't exist — preventing race conditions in concurrent verify() calls.
|
|
227
|
+
*/
|
|
228
|
+
async markUsed(nonce) {
|
|
229
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
230
|
+
const { rowCount } = await this.db.query(`UPDATE ${this.tableName}
|
|
266
231
|
SET used = ?, updated_at = ?
|
|
267
|
-
WHERE nonce = ? AND used = ? AND expires_at > ?`,
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
let count = 0;
|
|
287
|
-
for (const token of tokens) {
|
|
288
|
-
await token.delete();
|
|
289
|
-
count++;
|
|
290
|
-
}
|
|
291
|
-
return count;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
232
|
+
WHERE nonce = ? AND used = ? AND expires_at > ?`, true, now, nonce, false, now);
|
|
233
|
+
return rowCount > 0;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Delete expired tokens (cleanup job)
|
|
237
|
+
*/
|
|
238
|
+
async deleteExpired() {
|
|
239
|
+
const now = /* @__PURE__ */ new Date();
|
|
240
|
+
const tokens = await this.list({ where: { "expiresAt <": now.toISOString() } });
|
|
241
|
+
let count = 0;
|
|
242
|
+
for (const token of tokens) {
|
|
243
|
+
await token.delete();
|
|
244
|
+
count++;
|
|
245
|
+
}
|
|
246
|
+
return count;
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
//#endregion
|
|
250
|
+
//#region src/models/Role.ts
|
|
294
251
|
var __defProp = Object.defineProperty;
|
|
295
252
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
296
253
|
var __decorateClass = (decorators, target, key, kind) => {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
if (kind && result) __defProp(target, key, result);
|
|
302
|
-
return result;
|
|
254
|
+
var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
|
|
255
|
+
for (var i = decorators.length - 1, decorator; i >= 0; i--) if (decorator = decorators[i]) result = (kind ? decorator(target, key, result) : decorator(result)) || result;
|
|
256
|
+
if (kind && result) __defProp(target, key, result);
|
|
257
|
+
return result;
|
|
303
258
|
};
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
`Cannot delete system role '${this.slug}'. System roles are protected.`
|
|
354
|
-
);
|
|
355
|
-
}
|
|
356
|
-
return super.delete();
|
|
357
|
-
}
|
|
259
|
+
var Role = class extends SmrtObject {
|
|
260
|
+
tenantId;
|
|
261
|
+
/**
|
|
262
|
+
* Display name for the role
|
|
263
|
+
*/
|
|
264
|
+
name = "";
|
|
265
|
+
/**
|
|
266
|
+
* Description of the role
|
|
267
|
+
*/
|
|
268
|
+
description = "";
|
|
269
|
+
/**
|
|
270
|
+
* Whether this is a system role (cannot be deleted)
|
|
271
|
+
*/
|
|
272
|
+
isSystem = false;
|
|
273
|
+
constructor(options = {}) {
|
|
274
|
+
super(options);
|
|
275
|
+
if (options.tenantId !== void 0) this.tenantId = options.tenantId;
|
|
276
|
+
if (options.name !== void 0) this.name = options.name;
|
|
277
|
+
if (options.description !== void 0) this.description = options.description;
|
|
278
|
+
if (options.isSystem !== void 0) this.isSystem = options.isSystem;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Check if this is a system-wide role
|
|
282
|
+
*/
|
|
283
|
+
isSystemRole() {
|
|
284
|
+
return this.tenantId === null || this.tenantId === void 0;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Check if this is a tenant-specific role
|
|
288
|
+
*/
|
|
289
|
+
isTenantRole() {
|
|
290
|
+
return this.tenantId !== null && this.tenantId !== void 0;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Check if this role can be deleted.
|
|
294
|
+
* System roles (isSystem = true) cannot be deleted.
|
|
295
|
+
* @returns true if the role can be deleted
|
|
296
|
+
*/
|
|
297
|
+
canDelete() {
|
|
298
|
+
return !this.isSystem;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Delete guard - prevents deletion of system roles.
|
|
302
|
+
* Override the delete method to check isSystem flag first.
|
|
303
|
+
*/
|
|
304
|
+
async delete() {
|
|
305
|
+
if (this.isSystem) throw new Error(`Cannot delete system role '${this.slug}'. System roles are protected.`);
|
|
306
|
+
return super.delete();
|
|
307
|
+
}
|
|
358
308
|
};
|
|
359
|
-
__decorateClass([
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* Find roles available for a tenant (system + tenant-specific)
|
|
383
|
-
*/
|
|
384
|
-
async findByTenant(tenantId) {
|
|
385
|
-
return await this.query(
|
|
386
|
-
`SELECT * FROM ${this.tableName}
|
|
309
|
+
__decorateClass([foreignKey("Tenant", { nullable: true })], Role.prototype, "tenantId", 2);
|
|
310
|
+
Role = __decorateClass([smrt({
|
|
311
|
+
api: { include: ["list", "get"] },
|
|
312
|
+
mcp: { include: ["list", "get"] },
|
|
313
|
+
cli: true
|
|
314
|
+
})], Role);
|
|
315
|
+
//#endregion
|
|
316
|
+
//#region src/collections/RoleCollection.ts
|
|
317
|
+
var RoleCollection = class extends SmrtCollection {
|
|
318
|
+
static _itemClass = Role;
|
|
319
|
+
/**
|
|
320
|
+
* Find all system roles (tenantId is null)
|
|
321
|
+
*/
|
|
322
|
+
async findSystemRoles() {
|
|
323
|
+
return await this.query(`SELECT * FROM ${this.tableName} WHERE tenant_id IS NULL ORDER BY name ASC`);
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Find roles available for a tenant (system + tenant-specific)
|
|
327
|
+
*/
|
|
328
|
+
async findByTenant(tenantId) {
|
|
329
|
+
return await this.query(`SELECT * FROM ${this.tableName}
|
|
387
330
|
WHERE tenant_id IS NULL OR tenant_id = ?
|
|
388
|
-
ORDER BY is_system DESC, name ASC`,
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
roles.push(role);
|
|
440
|
-
}
|
|
441
|
-
return roles;
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
const logger = createLogger({ level: "info" });
|
|
445
|
-
const ACCESS_REQUEST_CAPABILITIES = {
|
|
446
|
-
/** Read the access-request queue (`list` / `get`). */
|
|
447
|
-
READ: "access-requests:read",
|
|
448
|
-
/** Decide requests (`approve` / `decline` / `cancel` / `graduate`). */
|
|
449
|
-
MANAGE: "access-requests:manage"
|
|
331
|
+
ORDER BY is_system DESC, name ASC`, [tenantId]);
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Find tenant-specific roles only
|
|
335
|
+
*/
|
|
336
|
+
async findTenantRoles(tenantId) {
|
|
337
|
+
return await this.list({
|
|
338
|
+
where: { tenantId },
|
|
339
|
+
orderBy: "name ASC"
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Find role by slug within a tenant context
|
|
344
|
+
*/
|
|
345
|
+
async findBySlug(slug, tenantId) {
|
|
346
|
+
if (tenantId) {
|
|
347
|
+
const tenantRoles = await this.list({
|
|
348
|
+
where: {
|
|
349
|
+
slug,
|
|
350
|
+
tenantId
|
|
351
|
+
},
|
|
352
|
+
limit: 1
|
|
353
|
+
});
|
|
354
|
+
if (tenantRoles.length > 0) return tenantRoles[0];
|
|
355
|
+
}
|
|
356
|
+
const systemRoles = await this.query(`SELECT * FROM ${this.tableName} WHERE slug = ? AND tenant_id IS NULL LIMIT 1`, [slug]);
|
|
357
|
+
return systemRoles.length > 0 ? systemRoles[0] : null;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Seed default system roles
|
|
361
|
+
*/
|
|
362
|
+
async seedSystemRoles() {
|
|
363
|
+
const roles = [];
|
|
364
|
+
for (const roleDef of DEFAULT_ROLES) {
|
|
365
|
+
const existing = await this.findBySlug(roleDef.slug);
|
|
366
|
+
if (existing) {
|
|
367
|
+
roles.push(existing);
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
const role = await this.create({
|
|
371
|
+
slug: roleDef.slug,
|
|
372
|
+
name: roleDef.name,
|
|
373
|
+
description: roleDef.description,
|
|
374
|
+
tenantId: null,
|
|
375
|
+
isSystem: true
|
|
376
|
+
});
|
|
377
|
+
await role.save();
|
|
378
|
+
roles.push(role);
|
|
379
|
+
}
|
|
380
|
+
return roles;
|
|
381
|
+
}
|
|
450
382
|
};
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
const key = await this.getSigningKey();
|
|
1007
|
-
const nonce = crypto.randomUUID();
|
|
1008
|
-
const normalizedEmail = normalizeEmail(email);
|
|
1009
|
-
if (!isValidEmail(normalizedEmail)) {
|
|
1010
|
-
throw new MagicLinkError("Invalid email address");
|
|
1011
|
-
}
|
|
1012
|
-
const expiresAt = new Date(Date.now() + this.tokenExpiry * 1e3);
|
|
1013
|
-
await this.tokenCollection.create({
|
|
1014
|
-
nonce,
|
|
1015
|
-
email: normalizedEmail,
|
|
1016
|
-
used: false,
|
|
1017
|
-
expiresAt
|
|
1018
|
-
});
|
|
1019
|
-
const token = await new SignJWT({
|
|
1020
|
-
email: normalizedEmail,
|
|
1021
|
-
nonce
|
|
1022
|
-
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(`${this.tokenExpiry}s`).setIssuer(this.issuer).sign(key);
|
|
1023
|
-
return { token, expiresAt };
|
|
1024
|
-
}
|
|
1025
|
-
/**
|
|
1026
|
-
* Verify a magic link token.
|
|
1027
|
-
*
|
|
1028
|
-
* Checks JWT signature, expiry, and that the nonce hasn't been used.
|
|
1029
|
-
* Marks the nonce as used on success (single-use enforcement).
|
|
1030
|
-
*
|
|
1031
|
-
* @throws {MagicLinkError} If the token is invalid, expired, or already used
|
|
1032
|
-
*/
|
|
1033
|
-
async verify(token) {
|
|
1034
|
-
const { jwtVerify, errors } = await import("./chunks/index-C3E55ikp.js");
|
|
1035
|
-
const key = await this.getSigningKey();
|
|
1036
|
-
let payload;
|
|
1037
|
-
try {
|
|
1038
|
-
const result = await jwtVerify(token, key, {
|
|
1039
|
-
issuer: this.issuer
|
|
1040
|
-
});
|
|
1041
|
-
payload = result.payload;
|
|
1042
|
-
} catch (err) {
|
|
1043
|
-
if (err instanceof errors.JWTExpired) {
|
|
1044
|
-
throw new MagicLinkError("Token has expired");
|
|
1045
|
-
}
|
|
1046
|
-
throw new MagicLinkError("Invalid token");
|
|
1047
|
-
}
|
|
1048
|
-
const email = payload.email;
|
|
1049
|
-
const nonce = payload.nonce;
|
|
1050
|
-
if (typeof email !== "string" || typeof nonce !== "string") {
|
|
1051
|
-
throw new MagicLinkError("Invalid token payload");
|
|
1052
|
-
}
|
|
1053
|
-
const claimed = await this.tokenCollection.markUsed(nonce);
|
|
1054
|
-
if (!claimed) {
|
|
1055
|
-
throw new MagicLinkError("Token has already been used or has expired");
|
|
1056
|
-
}
|
|
1057
|
-
return { email: normalizeEmail(email), nonce };
|
|
1058
|
-
}
|
|
1059
|
-
/**
|
|
1060
|
-
* Clean up expired tokens (run periodically)
|
|
1061
|
-
*/
|
|
1062
|
-
async cleanupExpiredTokens() {
|
|
1063
|
-
return this.tokenCollection.deleteExpired();
|
|
1064
|
-
}
|
|
1065
|
-
/**
|
|
1066
|
-
* Static factory method
|
|
1067
|
-
*/
|
|
1068
|
-
static async create(options) {
|
|
1069
|
-
const service = new MagicLinkService(options);
|
|
1070
|
-
await service.initialize();
|
|
1071
|
-
return service;
|
|
1072
|
-
}
|
|
1073
|
-
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/services/AccessRequestService.ts
|
|
385
|
+
var logger = createLogger({ level: "info" });
|
|
386
|
+
var ACCESS_REQUEST_CAPABILITIES = {
|
|
387
|
+
/** Read the access-request queue (`list` / `get`). */
|
|
388
|
+
READ: "access-requests:read",
|
|
389
|
+
/** Decide requests (`approve` / `decline` / `cancel` / `graduate`). */
|
|
390
|
+
MANAGE: "access-requests:manage"
|
|
391
|
+
};
|
|
392
|
+
var AccessRequestError = class extends Error {
|
|
393
|
+
code;
|
|
394
|
+
constructor(message, code) {
|
|
395
|
+
super(message);
|
|
396
|
+
this.name = "AccessRequestError";
|
|
397
|
+
this.code = code;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
var APPROVE_FROM = [AccessRequestStatus.REQUESTED];
|
|
401
|
+
var DECLINE_FROM = [AccessRequestStatus.REQUESTED, AccessRequestStatus.APPROVED];
|
|
402
|
+
var CANCEL_FROM = [AccessRequestStatus.REQUESTED, AccessRequestStatus.APPROVED];
|
|
403
|
+
var AccessRequestService = class AccessRequestService {
|
|
404
|
+
#options;
|
|
405
|
+
#authorize;
|
|
406
|
+
#onEvent;
|
|
407
|
+
#requests;
|
|
408
|
+
#users;
|
|
409
|
+
#tenants;
|
|
410
|
+
#memberships;
|
|
411
|
+
#roles;
|
|
412
|
+
#rolesSeeded = false;
|
|
413
|
+
constructor(options) {
|
|
414
|
+
this.#options = options;
|
|
415
|
+
this.#authorize = options.authorize;
|
|
416
|
+
this.#onEvent = options.onEvent;
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Initialize the backing collections (creates/verifies their tables).
|
|
420
|
+
*/
|
|
421
|
+
async initialize() {
|
|
422
|
+
this.#requests = await AccessRequestCollection.create(this.#options);
|
|
423
|
+
this.#users = await UserCollection.create(this.#options);
|
|
424
|
+
this.#tenants = await TenantCollection.create(this.#options);
|
|
425
|
+
this.#memberships = await MembershipCollection.create(this.#options);
|
|
426
|
+
this.#roles = await RoleCollection.create(this.#options);
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Static factory — construct and initialize in one call.
|
|
430
|
+
*/
|
|
431
|
+
static async create(options) {
|
|
432
|
+
const service = new AccessRequestService(options);
|
|
433
|
+
await service.initialize();
|
|
434
|
+
return service;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The underlying collection, for advanced read scenarios. Prefer the service
|
|
438
|
+
* methods, which apply normalization, the state machine, capability gating,
|
|
439
|
+
* and events.
|
|
440
|
+
*/
|
|
441
|
+
get collection() {
|
|
442
|
+
return this.#requests;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Create an access request. **Public-safe**: no capability check — meant to be
|
|
446
|
+
* callable unauthenticated by apps (which add their own rate-limiting).
|
|
447
|
+
*
|
|
448
|
+
* Validates and normalizes the email, then de-duplicates: if an open
|
|
449
|
+
* (`REQUESTED`) request already exists for the email, this merges any newly
|
|
450
|
+
* supplied context/name/source/hint into it and returns it instead of
|
|
451
|
+
* creating a duplicate (no second `created` event).
|
|
452
|
+
*
|
|
453
|
+
* @remarks
|
|
454
|
+
* De-duplication is **best-effort, not atomic**: it is a read-then-write
|
|
455
|
+
* (`findOpenByEmail` → `create`) with no DB-level partial-unique constraint
|
|
456
|
+
* (the table is append-style, keyed on `id`, because the same email may
|
|
457
|
+
* accumulate many requests over its lifetime). Two requests for the same
|
|
458
|
+
* email racing concurrently can therefore both create an open row. This is by
|
|
459
|
+
* design — the spec makes dedup configurable and pushes abuse control to the
|
|
460
|
+
* app (rate-limiting on the public endpoint). Operators triaging two open rows
|
|
461
|
+
* for one email is benign; apps needing a hard single-open-request guarantee
|
|
462
|
+
* should add a partial unique index (`UNIQUE(email) WHERE status='requested'`)
|
|
463
|
+
* in their migration.
|
|
464
|
+
*
|
|
465
|
+
* @throws {@link AccessRequestError} (`INVALID_EMAIL`) when the email is invalid.
|
|
466
|
+
*/
|
|
467
|
+
async createAccessRequest(input) {
|
|
468
|
+
const email = normalizeEmail(input.email);
|
|
469
|
+
if (!isValidEmail(email)) throw new AccessRequestError("A valid email address is required to request access.", "INVALID_EMAIL");
|
|
470
|
+
const existing = await this.#requests.findOpenByEmail(email);
|
|
471
|
+
if (existing) {
|
|
472
|
+
let changed = false;
|
|
473
|
+
if (input.context && Object.keys(input.context).length > 0) {
|
|
474
|
+
existing.setRequestContext({
|
|
475
|
+
...existing.getRequestContext(),
|
|
476
|
+
...input.context
|
|
477
|
+
});
|
|
478
|
+
changed = true;
|
|
479
|
+
}
|
|
480
|
+
if (input.tenantHint) {
|
|
481
|
+
existing.setTenantHint({
|
|
482
|
+
...existing.getTenantHint() ?? {},
|
|
483
|
+
...input.tenantHint
|
|
484
|
+
});
|
|
485
|
+
changed = true;
|
|
486
|
+
}
|
|
487
|
+
if (input.name && !existing.name) {
|
|
488
|
+
existing.name = input.name;
|
|
489
|
+
changed = true;
|
|
490
|
+
}
|
|
491
|
+
if (input.source && !existing.source) {
|
|
492
|
+
existing.source = input.source;
|
|
493
|
+
changed = true;
|
|
494
|
+
}
|
|
495
|
+
if (changed) await existing.save();
|
|
496
|
+
return existing;
|
|
497
|
+
}
|
|
498
|
+
const request = await this.#requests.create({
|
|
499
|
+
email,
|
|
500
|
+
name: input.name ?? null,
|
|
501
|
+
source: input.source ?? "",
|
|
502
|
+
status: AccessRequestStatus.REQUESTED,
|
|
503
|
+
requestedAt: /* @__PURE__ */ new Date(),
|
|
504
|
+
requestContext: JSON.stringify(input.context ?? {}),
|
|
505
|
+
tenantHint: input.tenantHint ? JSON.stringify(input.tenantHint) : null,
|
|
506
|
+
note: input.note ?? null
|
|
507
|
+
});
|
|
508
|
+
await this.#emit({
|
|
509
|
+
type: "access-request.created",
|
|
510
|
+
accessRequest: request,
|
|
511
|
+
at: /* @__PURE__ */ new Date()
|
|
512
|
+
});
|
|
513
|
+
return request;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* List access requests (operator-facing). Requires the `access-requests:read`
|
|
517
|
+
* capability when an authorizer is configured.
|
|
518
|
+
*/
|
|
519
|
+
async listAccessRequests(filter = {}) {
|
|
520
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.READ, { by: filter.by });
|
|
521
|
+
const where = {};
|
|
522
|
+
if (filter.status !== void 0) where.status = filter.status;
|
|
523
|
+
if (filter.email !== void 0) where.email = normalizeEmail(filter.email);
|
|
524
|
+
if (filter.source !== void 0) where.source = filter.source;
|
|
525
|
+
return await this.#requests.list({
|
|
526
|
+
where,
|
|
527
|
+
limit: filter.limit,
|
|
528
|
+
offset: filter.offset,
|
|
529
|
+
orderBy: filter.orderBy ?? "created_at DESC"
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* Get a single access request by id (operator-facing). Requires the
|
|
534
|
+
* `access-requests:read` capability when an authorizer is configured.
|
|
535
|
+
*/
|
|
536
|
+
async getAccessRequest(id, options = {}) {
|
|
537
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.READ, {
|
|
538
|
+
by: options.by,
|
|
539
|
+
accessRequestId: id
|
|
540
|
+
});
|
|
541
|
+
return await this.#requests.get(id);
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Approve a request: `REQUESTED → APPROVED`. Idempotent (re-approving an
|
|
545
|
+
* already-`APPROVED` request is a no-op returning it). Requires
|
|
546
|
+
* `access-requests:manage`.
|
|
547
|
+
*
|
|
548
|
+
* @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
|
|
549
|
+
*/
|
|
550
|
+
async approveAccessRequest(id, options = {}) {
|
|
551
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
552
|
+
by: options.by,
|
|
553
|
+
accessRequestId: id
|
|
554
|
+
});
|
|
555
|
+
const request = await this.#load(id);
|
|
556
|
+
if (request.status === AccessRequestStatus.APPROVED) return request;
|
|
557
|
+
this.#assertTransition(request, APPROVE_FROM, "approve");
|
|
558
|
+
request.status = AccessRequestStatus.APPROVED;
|
|
559
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
560
|
+
if (options.by) request.decidedBy = options.by;
|
|
561
|
+
if (options.note != null) request.note = options.note;
|
|
562
|
+
await request.save();
|
|
563
|
+
await this.#emit({
|
|
564
|
+
type: "access-request.approved",
|
|
565
|
+
accessRequest: request,
|
|
566
|
+
at: /* @__PURE__ */ new Date(),
|
|
567
|
+
by: options.by
|
|
568
|
+
});
|
|
569
|
+
return request;
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* Decline a request: `REQUESTED | APPROVED → DECLINED`. Idempotent. Requires
|
|
573
|
+
* `access-requests:manage`.
|
|
574
|
+
*
|
|
575
|
+
* @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
|
|
576
|
+
*/
|
|
577
|
+
async declineAccessRequest(id, options = {}) {
|
|
578
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
579
|
+
by: options.by,
|
|
580
|
+
accessRequestId: id
|
|
581
|
+
});
|
|
582
|
+
const request = await this.#load(id);
|
|
583
|
+
if (request.status === AccessRequestStatus.DECLINED) return request;
|
|
584
|
+
this.#assertTransition(request, DECLINE_FROM, "decline");
|
|
585
|
+
request.status = AccessRequestStatus.DECLINED;
|
|
586
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
587
|
+
if (options.by) request.decidedBy = options.by;
|
|
588
|
+
if (options.reason != null) request.note = options.reason;
|
|
589
|
+
await request.save();
|
|
590
|
+
await this.#emit({
|
|
591
|
+
type: "access-request.declined",
|
|
592
|
+
accessRequest: request,
|
|
593
|
+
at: /* @__PURE__ */ new Date(),
|
|
594
|
+
by: options.by
|
|
595
|
+
});
|
|
596
|
+
return request;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Cancel a request: `REQUESTED | APPROVED → CANCELED`. Idempotent. Requires
|
|
600
|
+
* `access-requests:manage`.
|
|
601
|
+
*
|
|
602
|
+
* @throws {@link AccessRequestError} (`INVALID_TRANSITION`) from a terminal state.
|
|
603
|
+
*/
|
|
604
|
+
async cancelAccessRequest(id, options = {}) {
|
|
605
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
606
|
+
by: options.by,
|
|
607
|
+
accessRequestId: id
|
|
608
|
+
});
|
|
609
|
+
const request = await this.#load(id);
|
|
610
|
+
if (request.status === AccessRequestStatus.CANCELED) return request;
|
|
611
|
+
this.#assertTransition(request, CANCEL_FROM, "cancel");
|
|
612
|
+
request.status = AccessRequestStatus.CANCELED;
|
|
613
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
614
|
+
if (options.by) request.decidedBy = options.by;
|
|
615
|
+
if (options.reason != null) request.note = options.reason;
|
|
616
|
+
await request.save();
|
|
617
|
+
await this.#emit({
|
|
618
|
+
type: "access-request.canceled",
|
|
619
|
+
accessRequest: request,
|
|
620
|
+
at: /* @__PURE__ */ new Date(),
|
|
621
|
+
by: options.by
|
|
622
|
+
});
|
|
623
|
+
return request;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Graduate an approved request into a `User`, optionally attaching a tenant.
|
|
627
|
+
*
|
|
628
|
+
* Valid from `APPROVED` (or from `REQUESTED` when
|
|
629
|
+
* {@link GraduateAccessRequestOptions.allowFromRequested} is set). Creates a
|
|
630
|
+
* user when none exists for the email, or **links** the existing one
|
|
631
|
+
* otherwise (reusing {@link UserCollection}). Idempotent: a second call on an
|
|
632
|
+
* already-`GRADUATED` request returns the same user (and an existing
|
|
633
|
+
* membership for the requested tenant, if any) without re-creating anything.
|
|
634
|
+
*
|
|
635
|
+
* Requires `access-requests:manage`.
|
|
636
|
+
*
|
|
637
|
+
* @throws {@link AccessRequestError} — `NOT_FOUND` (unknown id),
|
|
638
|
+
* `INVALID_TRANSITION` (terminal/declined/canceled or `REQUESTED` without
|
|
639
|
+
* `allowFromRequested`), `TENANT_NOT_FOUND`, or `ROLE_NOT_FOUND`.
|
|
640
|
+
*/
|
|
641
|
+
async graduateAccessRequest(id, options = {}) {
|
|
642
|
+
await this.#requireCapability(ACCESS_REQUEST_CAPABILITIES.MANAGE, {
|
|
643
|
+
by: options.by,
|
|
644
|
+
accessRequestId: id
|
|
645
|
+
});
|
|
646
|
+
const request = await this.#load(id);
|
|
647
|
+
const tenantOption = options.tenant ?? "none";
|
|
648
|
+
if (request.status === AccessRequestStatus.GRADUATED && request.resultingUserId) {
|
|
649
|
+
const existingUser = await this.#users.get(request.resultingUserId);
|
|
650
|
+
if (existingUser) return {
|
|
651
|
+
user: existingUser,
|
|
652
|
+
membership: await this.#resolveExistingMembership(existingUser.id, tenantOption) ?? void 0,
|
|
653
|
+
accessRequest: request,
|
|
654
|
+
created: false
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
if (request.status !== AccessRequestStatus.GRADUATED) {
|
|
658
|
+
const allowFromRequested = options.allowFromRequested ?? false;
|
|
659
|
+
if (!(request.status === AccessRequestStatus.APPROVED || request.status === AccessRequestStatus.REQUESTED && allowFromRequested)) {
|
|
660
|
+
const hint = request.status === AccessRequestStatus.REQUESTED ? " (approve it first, or pass allowFromRequested)" : "";
|
|
661
|
+
throw new AccessRequestError(`Cannot graduate an access request in status "${request.status}"${hint}.`, "INVALID_TRANSITION");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
if (tenantOption !== "none") {
|
|
665
|
+
await this.#ensureRolesSeeded();
|
|
666
|
+
await this.#validateTenantOption(tenantOption);
|
|
667
|
+
}
|
|
668
|
+
const email = normalizeEmail(request.email);
|
|
669
|
+
let user = await this.#users.findByEmail(email);
|
|
670
|
+
let created = false;
|
|
671
|
+
if (!user) {
|
|
672
|
+
user = await this.#users.create({
|
|
673
|
+
email,
|
|
674
|
+
status: options.activate ?? UserStatus.ACTIVE
|
|
675
|
+
});
|
|
676
|
+
created = true;
|
|
677
|
+
} else if (options.activate !== void 0 && user.status !== options.activate) {
|
|
678
|
+
user.status = options.activate;
|
|
679
|
+
await user.save();
|
|
680
|
+
}
|
|
681
|
+
let membership;
|
|
682
|
+
let tenant;
|
|
683
|
+
if (tenantOption !== "none") {
|
|
684
|
+
const attached = await this.#attachTenant(user.id, tenantOption);
|
|
685
|
+
membership = attached.membership;
|
|
686
|
+
tenant = attached.tenant;
|
|
687
|
+
}
|
|
688
|
+
request.status = AccessRequestStatus.GRADUATED;
|
|
689
|
+
request.resultingUserId = user.id;
|
|
690
|
+
request.decidedAt = /* @__PURE__ */ new Date();
|
|
691
|
+
if (options.by) request.decidedBy = options.by;
|
|
692
|
+
if (options.note != null) request.note = options.note;
|
|
693
|
+
await request.save();
|
|
694
|
+
await this.#emit({
|
|
695
|
+
type: "access-request.graduated",
|
|
696
|
+
accessRequest: request,
|
|
697
|
+
at: /* @__PURE__ */ new Date(),
|
|
698
|
+
by: options.by,
|
|
699
|
+
user,
|
|
700
|
+
membership,
|
|
701
|
+
tenant
|
|
702
|
+
});
|
|
703
|
+
return {
|
|
704
|
+
user,
|
|
705
|
+
membership,
|
|
706
|
+
tenant,
|
|
707
|
+
accessRequest: request,
|
|
708
|
+
created
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Load a request or throw `NOT_FOUND`.
|
|
713
|
+
*/
|
|
714
|
+
async #load(id) {
|
|
715
|
+
const request = await this.#requests.get(id);
|
|
716
|
+
if (!request) throw new AccessRequestError("Access request not found.", "NOT_FOUND");
|
|
717
|
+
return request;
|
|
718
|
+
}
|
|
719
|
+
/**
|
|
720
|
+
* Guard a state transition; throw `INVALID_TRANSITION` if the current status
|
|
721
|
+
* is not an allowed source.
|
|
722
|
+
*/
|
|
723
|
+
#assertTransition(request, allowedFrom, action) {
|
|
724
|
+
if (!allowedFrom.includes(request.status)) throw new AccessRequestError(`Cannot ${action} an access request in status "${request.status}".`, "INVALID_TRANSITION");
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Run the configured authorizer (if any). Absent an authorizer, operator
|
|
728
|
+
* methods are ungated — see the class-level security note.
|
|
729
|
+
*/
|
|
730
|
+
async #requireCapability(capability, context) {
|
|
731
|
+
if (!this.#authorize) return;
|
|
732
|
+
await this.#authorize({
|
|
733
|
+
capability,
|
|
734
|
+
by: context.by ?? null,
|
|
735
|
+
accessRequestId: context.accessRequestId
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Best-effort event delivery — a throwing handler is logged and swallowed so
|
|
740
|
+
* it cannot roll back an already-persisted transition.
|
|
741
|
+
*/
|
|
742
|
+
async #emit(event) {
|
|
743
|
+
if (!this.#onEvent) return;
|
|
744
|
+
try {
|
|
745
|
+
await this.#onEvent(event);
|
|
746
|
+
} catch (error) {
|
|
747
|
+
logger.error(`AccessRequest event handler threw for "${event.type}" (request ${event.accessRequest.id})`, { error });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
/**
|
|
751
|
+
* Throw `ROLE_NOT_FOUND` if no role with `roleSlug` is resolvable (tenant-
|
|
752
|
+
* specific first, then system). Roles must already be seeded.
|
|
753
|
+
*/
|
|
754
|
+
async #assertRoleExists(roleSlug, tenantId) {
|
|
755
|
+
if (!await this.#roles.findBySlug(roleSlug, tenantId)) throw new AccessRequestError(`Role "${roleSlug}" not found \u2014 seed system roles or pass a valid role slug.`, "ROLE_NOT_FOUND");
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Validate a graduation tenant option with NO side effects: the target tenant
|
|
759
|
+
* must exist (existing-tenant variant) and the role slug must resolve. Run
|
|
760
|
+
* before any persistence so a bad option can't leave orphan rows.
|
|
761
|
+
*/
|
|
762
|
+
async #validateTenantOption(option) {
|
|
763
|
+
if ("tenantId" in option) {
|
|
764
|
+
if (!await this.#tenants.get(option.tenantId)) throw new AccessRequestError(`Target tenant "${option.tenantId}" not found.`, "TENANT_NOT_FOUND");
|
|
765
|
+
await this.#assertRoleExists(option.role ?? DEFAULT_ROLE_SLUGS.MEMBER, option.tenantId);
|
|
766
|
+
} else await this.#assertRoleExists(option.role ?? DEFAULT_ROLE_SLUGS.OWNER);
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Create-or-attach the requester to a tenant, reusing the tenant / role /
|
|
770
|
+
* membership collections (no duplication of user/membership logic).
|
|
771
|
+
*/
|
|
772
|
+
async #attachTenant(userId, option) {
|
|
773
|
+
await this.#ensureRolesSeeded();
|
|
774
|
+
if ("tenantId" in option) {
|
|
775
|
+
const tenant2 = await this.#tenants.get(option.tenantId);
|
|
776
|
+
if (!tenant2) throw new AccessRequestError(`Target tenant "${option.tenantId}" not found.`, "TENANT_NOT_FOUND");
|
|
777
|
+
return {
|
|
778
|
+
tenant: tenant2,
|
|
779
|
+
membership: await this.#getOrCreateMembership(userId, tenant2.id, option.role ?? DEFAULT_ROLE_SLUGS.MEMBER)
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
const tenant = await this.#tenants.create({
|
|
783
|
+
name: option.create.name,
|
|
784
|
+
slug: option.create.slug,
|
|
785
|
+
description: option.create.description,
|
|
786
|
+
status: option.create.status
|
|
787
|
+
});
|
|
788
|
+
return {
|
|
789
|
+
tenant,
|
|
790
|
+
membership: await this.#getOrCreateMembership(userId, tenant.id, option.role ?? DEFAULT_ROLE_SLUGS.OWNER)
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Resolve (creating if needed) the user's membership in a tenant with the
|
|
795
|
+
* given role slug. Idempotent — returns any existing membership for the pair.
|
|
796
|
+
*/
|
|
797
|
+
async #getOrCreateMembership(userId, tenantId, roleSlug) {
|
|
798
|
+
const existing = await this.#memberships.findByUserAndTenant(userId, tenantId);
|
|
799
|
+
if (existing) return existing;
|
|
800
|
+
const role = await this.#roles.findBySlug(roleSlug, tenantId);
|
|
801
|
+
if (!role) throw new AccessRequestError(`Role "${roleSlug}" not found \u2014 seed system roles or pass a valid role slug.`, "ROLE_NOT_FOUND");
|
|
802
|
+
return await this.#memberships.create({
|
|
803
|
+
userId,
|
|
804
|
+
tenantId,
|
|
805
|
+
roleId: role.id,
|
|
806
|
+
status: MembershipStatus.ACTIVE
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* For idempotent re-graduation: find an existing membership for the requested
|
|
811
|
+
* tenant. Only the existing-tenant variant is resolvable (a `{ create }`
|
|
812
|
+
* variant has no known tenant id on a re-call).
|
|
813
|
+
*/
|
|
814
|
+
async #resolveExistingMembership(userId, tenantOption) {
|
|
815
|
+
if (tenantOption === "none" || !("tenantId" in tenantOption)) return null;
|
|
816
|
+
return await this.#memberships.findByUserAndTenant(userId, tenantOption.tenantId);
|
|
817
|
+
}
|
|
818
|
+
/**
|
|
819
|
+
* Lazily seed the default system roles (owner/admin/member/viewer) the first
|
|
820
|
+
* time graduation attaches a tenant — so the public create path never incurs
|
|
821
|
+
* the write.
|
|
822
|
+
*/
|
|
823
|
+
async #ensureRolesSeeded() {
|
|
824
|
+
if (this.#rolesSeeded) return;
|
|
825
|
+
await this.#roles.seedSystemRoles();
|
|
826
|
+
this.#rolesSeeded = true;
|
|
827
|
+
}
|
|
828
|
+
};
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region src/services/MagicLinkService.ts
|
|
831
|
+
var MagicLinkError = class extends Error {
|
|
832
|
+
constructor(message) {
|
|
833
|
+
super(message);
|
|
834
|
+
this.name = "MagicLinkError";
|
|
835
|
+
}
|
|
836
|
+
};
|
|
837
|
+
var MagicLinkService = class MagicLinkService {
|
|
838
|
+
tokenCollection;
|
|
839
|
+
signingKey = null;
|
|
840
|
+
secret;
|
|
841
|
+
tokenExpiry;
|
|
842
|
+
issuer;
|
|
843
|
+
options;
|
|
844
|
+
constructor(options) {
|
|
845
|
+
if (!options.secret) throw new Error("MagicLinkService requires a secret for token signing");
|
|
846
|
+
this.secret = options.secret;
|
|
847
|
+
this.tokenExpiry = options.tokenExpiry ?? 600;
|
|
848
|
+
this.issuer = options.issuer ?? "smrt:magiclink";
|
|
849
|
+
this.options = options;
|
|
850
|
+
}
|
|
851
|
+
/**
|
|
852
|
+
* Initialize collections
|
|
853
|
+
*/
|
|
854
|
+
async initialize() {
|
|
855
|
+
this.tokenCollection = await UsersMagicLinkTokenCollection.create(this.options);
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Derive the HMAC signing key from the secret
|
|
859
|
+
*/
|
|
860
|
+
async getSigningKey() {
|
|
861
|
+
if (this.signingKey) return this.signingKey;
|
|
862
|
+
const data = new TextEncoder().encode(`magiclink:${this.secret}`);
|
|
863
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
|
|
864
|
+
this.signingKey = new Uint8Array(hashBuffer);
|
|
865
|
+
return this.signingKey;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Generate a magic link token for the given email.
|
|
869
|
+
*
|
|
870
|
+
* Stores a nonce in the database for replay protection.
|
|
871
|
+
* The caller is responsible for emailing the token to the user.
|
|
872
|
+
*/
|
|
873
|
+
async generate(email) {
|
|
874
|
+
const { SignJWT } = await import("./chunks/TerminalAuthService-BXAAuaXf.js").then((n) => n.v);
|
|
875
|
+
const key = await this.getSigningKey();
|
|
876
|
+
const nonce = crypto.randomUUID();
|
|
877
|
+
const normalizedEmail = normalizeEmail(email);
|
|
878
|
+
if (!isValidEmail(normalizedEmail)) throw new MagicLinkError("Invalid email address");
|
|
879
|
+
const expiresAt = new Date(Date.now() + this.tokenExpiry * 1e3);
|
|
880
|
+
await this.tokenCollection.create({
|
|
881
|
+
nonce,
|
|
882
|
+
email: normalizedEmail,
|
|
883
|
+
used: false,
|
|
884
|
+
expiresAt
|
|
885
|
+
});
|
|
886
|
+
return {
|
|
887
|
+
token: await new SignJWT({
|
|
888
|
+
email: normalizedEmail,
|
|
889
|
+
nonce
|
|
890
|
+
}).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(`${this.tokenExpiry}s`).setIssuer(this.issuer).sign(key),
|
|
891
|
+
expiresAt
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* Verify a magic link token.
|
|
896
|
+
*
|
|
897
|
+
* Checks JWT signature, expiry, and that the nonce hasn't been used.
|
|
898
|
+
* Marks the nonce as used on success (single-use enforcement).
|
|
899
|
+
*
|
|
900
|
+
* @throws {MagicLinkError} If the token is invalid, expired, or already used
|
|
901
|
+
*/
|
|
902
|
+
async verify(token) {
|
|
903
|
+
const { jwtVerify, errors } = await import("./chunks/TerminalAuthService-BXAAuaXf.js").then((n) => n.v);
|
|
904
|
+
const key = await this.getSigningKey();
|
|
905
|
+
let payload;
|
|
906
|
+
try {
|
|
907
|
+
payload = (await jwtVerify(token, key, { issuer: this.issuer })).payload;
|
|
908
|
+
} catch (err) {
|
|
909
|
+
if (err instanceof errors.JWTExpired) throw new MagicLinkError("Token has expired");
|
|
910
|
+
throw new MagicLinkError("Invalid token");
|
|
911
|
+
}
|
|
912
|
+
const email = payload.email;
|
|
913
|
+
const nonce = payload.nonce;
|
|
914
|
+
if (typeof email !== "string" || typeof nonce !== "string") throw new MagicLinkError("Invalid token payload");
|
|
915
|
+
if (!await this.tokenCollection.markUsed(nonce)) throw new MagicLinkError("Token has already been used or has expired");
|
|
916
|
+
return {
|
|
917
|
+
email: normalizeEmail(email),
|
|
918
|
+
nonce
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Clean up expired tokens (run periodically)
|
|
923
|
+
*/
|
|
924
|
+
async cleanupExpiredTokens() {
|
|
925
|
+
return this.tokenCollection.deleteExpired();
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Static factory method
|
|
929
|
+
*/
|
|
930
|
+
static async create(options) {
|
|
931
|
+
const service = new MagicLinkService(options);
|
|
932
|
+
await service.initialize();
|
|
933
|
+
return service;
|
|
934
|
+
}
|
|
935
|
+
};
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region src/services/PermissionCatalogService.ts
|
|
1074
938
|
function getRuntimePermissionRegistrations() {
|
|
1075
|
-
|
|
1076
|
-
|
|
939
|
+
globalThis.__smrtUsersPermissionRegistrations ??= /* @__PURE__ */ new Map();
|
|
940
|
+
return globalThis.__smrtUsersPermissionRegistrations;
|
|
1077
941
|
}
|
|
1078
942
|
function toSnakeCase$1(value) {
|
|
1079
|
-
|
|
943
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
|
|
1080
944
|
}
|
|
1081
945
|
function pluralize(word) {
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) {
|
|
1086
|
-
return `${word}es`;
|
|
1087
|
-
}
|
|
1088
|
-
return `${word}s`;
|
|
946
|
+
if (word.endsWith("y") && !/[aeiou]y$/i.test(word)) return `${word.slice(0, -1)}ies`;
|
|
947
|
+
if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) return `${word}es`;
|
|
948
|
+
return `${word}s`;
|
|
1089
949
|
}
|
|
1090
950
|
function deriveCollectionName(className) {
|
|
1091
|
-
|
|
951
|
+
return pluralize(toSnakeCase$1(className));
|
|
1092
952
|
}
|
|
1093
953
|
function humanizeResource(resource) {
|
|
1094
|
-
|
|
954
|
+
return resource.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
1095
955
|
}
|
|
1096
956
|
function capitalize(value) {
|
|
1097
|
-
|
|
957
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
1098
958
|
}
|
|
1099
959
|
function defaultPermissionName(slug) {
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
}
|
|
1104
|
-
return `${capitalize(parsed.action)} ${humanizeResource(parsed.resource)}`;
|
|
960
|
+
const parsed = parsePermissionSlug(slug);
|
|
961
|
+
if (!parsed.isValid) return humanizeResource(slug);
|
|
962
|
+
return `${capitalize(parsed.action)} ${humanizeResource(parsed.resource)}`;
|
|
1105
963
|
}
|
|
1106
964
|
function defaultPermissionDescription(slug) {
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
}
|
|
1111
|
-
return `Allows ${parsed.action} access for ${humanizeResource(parsed.resource).toLowerCase()}`;
|
|
965
|
+
const parsed = parsePermissionSlug(slug);
|
|
966
|
+
if (!parsed.isValid) return `Allows ${slug}`;
|
|
967
|
+
return `Allows ${parsed.action} access for ${humanizeResource(parsed.resource).toLowerCase()}`;
|
|
1112
968
|
}
|
|
1113
969
|
function isCollectionManifestEntry(objectDef) {
|
|
1114
|
-
|
|
970
|
+
return objectDef?.extends === "SmrtCollection" || objectDef?.extendsTypeArg !== void 0;
|
|
1115
971
|
}
|
|
1116
972
|
function getPublicCustomMethodNames(methodEntries, standardActions) {
|
|
1117
|
-
|
|
1118
|
-
new Set(
|
|
1119
|
-
methodEntries.filter(
|
|
1120
|
-
(method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)
|
|
1121
|
-
).map((method) => method.name)
|
|
1122
|
-
)
|
|
1123
|
-
);
|
|
973
|
+
return Array.from(new Set(methodEntries.filter((method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)).map((method) => method.name)));
|
|
1124
974
|
}
|
|
1125
975
|
function getCustomMethodExposureNames(config, availableCustomMethods) {
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
|
|
1136
|
-
if (!include) {
|
|
1137
|
-
return new Set(
|
|
1138
|
-
availableCustomMethods.filter(
|
|
1139
|
-
(methodName) => !exclude.includes(methodName)
|
|
1140
|
-
)
|
|
1141
|
-
);
|
|
1142
|
-
}
|
|
1143
|
-
const baseMethods = include.filter(
|
|
1144
|
-
(methodName) => availableCustomMethods.includes(methodName)
|
|
1145
|
-
);
|
|
1146
|
-
return new Set(
|
|
1147
|
-
baseMethods.filter((methodName) => !exclude.includes(methodName))
|
|
1148
|
-
);
|
|
976
|
+
if (!config || config === false) return /* @__PURE__ */ new Set();
|
|
977
|
+
if (config === true || typeof config !== "object") return new Set(availableCustomMethods);
|
|
978
|
+
const rawInclude = config.include;
|
|
979
|
+
const include = Array.isArray(rawInclude) ? [...rawInclude] : void 0;
|
|
980
|
+
const rawExclude = config.exclude;
|
|
981
|
+
const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
|
|
982
|
+
if (!include) return new Set(availableCustomMethods.filter((methodName) => !exclude.includes(methodName)));
|
|
983
|
+
const baseMethods = include.filter((methodName) => availableCustomMethods.includes(methodName));
|
|
984
|
+
return new Set(baseMethods.filter((methodName) => !exclude.includes(methodName)));
|
|
1149
985
|
}
|
|
1150
986
|
function isOperationEnabled(config, action) {
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
}
|
|
1161
|
-
if (exclude.includes(action)) {
|
|
1162
|
-
return false;
|
|
1163
|
-
}
|
|
1164
|
-
}
|
|
1165
|
-
return true;
|
|
987
|
+
if (config === false) return false;
|
|
988
|
+
if (config && typeof config === "object") {
|
|
989
|
+
const include = Array.isArray(config.include) ? config.include : void 0;
|
|
990
|
+
const rawExclude = config.exclude;
|
|
991
|
+
const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
|
|
992
|
+
if (include && !include.includes(action)) return false;
|
|
993
|
+
if (exclude.includes(action)) return false;
|
|
994
|
+
}
|
|
995
|
+
return true;
|
|
1166
996
|
}
|
|
1167
997
|
function normalizePostgresAction(action) {
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
}
|
|
1172
|
-
throw new Error(
|
|
1173
|
-
`Unsupported Postgres permission action '${action}'. Expected SELECT, INSERT, UPDATE, or DELETE.`
|
|
1174
|
-
);
|
|
998
|
+
const normalized = action.toUpperCase();
|
|
999
|
+
if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") return normalized;
|
|
1000
|
+
throw new Error(`Unsupported Postgres permission action '${action}'. Expected SELECT, INSERT, UPDATE, or DELETE.`);
|
|
1175
1001
|
}
|
|
1176
1002
|
function normalizeBinding(binding, fallbackPermission) {
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1003
|
+
return {
|
|
1004
|
+
action: normalizePostgresAction(binding.action),
|
|
1005
|
+
permission: binding.permission || fallbackPermission,
|
|
1006
|
+
schemaName: binding.schemaName,
|
|
1007
|
+
tableName: binding.tableName,
|
|
1008
|
+
tenantField: binding.tenantField
|
|
1009
|
+
};
|
|
1184
1010
|
}
|
|
1185
1011
|
function mergeStringField(fieldName, existing, incoming, slug) {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
}
|
|
1195
|
-
if (existingValue !== incomingValue) {
|
|
1196
|
-
throw new Error(
|
|
1197
|
-
`Conflicting permission metadata for '${slug}' field '${fieldName}': '${existingValue}' !== '${incomingValue}'`
|
|
1198
|
-
);
|
|
1199
|
-
}
|
|
1012
|
+
const existingValue = existing[fieldName];
|
|
1013
|
+
const incomingValue = incoming[fieldName];
|
|
1014
|
+
if (!incomingValue) return;
|
|
1015
|
+
if (!existingValue) {
|
|
1016
|
+
existing[fieldName] = incomingValue;
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (existingValue !== incomingValue) throw new Error(`Conflicting permission metadata for '${slug}' field '${fieldName}': '${existingValue}' !== '${incomingValue}'`);
|
|
1200
1020
|
}
|
|
1201
1021
|
function mergeBindings(existing, incoming) {
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
if (!seen.has(key)) {
|
|
1229
|
-
seen.add(key);
|
|
1230
|
-
mergedBindings.push(normalized);
|
|
1231
|
-
}
|
|
1232
|
-
}
|
|
1233
|
-
existing.postgres = {
|
|
1234
|
-
bindings: mergedBindings
|
|
1235
|
-
};
|
|
1022
|
+
const existingBindings = existing.postgres?.bindings ?? [];
|
|
1023
|
+
const incomingBindings = incoming.postgres?.bindings ?? [];
|
|
1024
|
+
if (incomingBindings.length === 0) return;
|
|
1025
|
+
const seen = new Set(existingBindings.map((binding) => [
|
|
1026
|
+
binding.permission,
|
|
1027
|
+
binding.action,
|
|
1028
|
+
binding.schemaName ?? "",
|
|
1029
|
+
binding.tableName,
|
|
1030
|
+
binding.tenantField ?? ""
|
|
1031
|
+
].join("|")));
|
|
1032
|
+
const mergedBindings = [...existingBindings];
|
|
1033
|
+
for (const binding of incomingBindings) {
|
|
1034
|
+
const normalized = normalizeBinding(binding, incoming.slug);
|
|
1035
|
+
const key = [
|
|
1036
|
+
normalized.permission,
|
|
1037
|
+
normalized.action,
|
|
1038
|
+
normalized.schemaName ?? "",
|
|
1039
|
+
normalized.tableName,
|
|
1040
|
+
normalized.tenantField ?? ""
|
|
1041
|
+
].join("|");
|
|
1042
|
+
if (!seen.has(key)) {
|
|
1043
|
+
seen.add(key);
|
|
1044
|
+
mergedBindings.push(normalized);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
existing.postgres = { bindings: mergedBindings };
|
|
1236
1048
|
}
|
|
1237
1049
|
function normalizeDefinition(definition, source) {
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
bindings: definition.postgres.bindings.map(
|
|
1252
|
-
(binding) => normalizeBinding(binding, slug)
|
|
1253
|
-
)
|
|
1254
|
-
} : void 0,
|
|
1255
|
-
qualifiedName: definition.qualifiedName,
|
|
1256
|
-
slug,
|
|
1257
|
-
source
|
|
1258
|
-
};
|
|
1050
|
+
if (!definition.slug || !isValidPermissionSlug(definition.slug.trim())) throw new Error(`Invalid permission slug '${definition.slug}'. Expected 'resource.action'.`);
|
|
1051
|
+
const slug = definition.slug.trim();
|
|
1052
|
+
return {
|
|
1053
|
+
category: definition.category ?? parsePermissionSlug(slug).resource,
|
|
1054
|
+
className: definition.className,
|
|
1055
|
+
collection: definition.collection,
|
|
1056
|
+
description: definition.description ?? defaultPermissionDescription(slug),
|
|
1057
|
+
name: definition.name ?? defaultPermissionName(slug),
|
|
1058
|
+
postgres: definition.postgres?.bindings ? { bindings: definition.postgres.bindings.map((binding) => normalizeBinding(binding, slug)) } : void 0,
|
|
1059
|
+
qualifiedName: definition.qualifiedName,
|
|
1060
|
+
slug,
|
|
1061
|
+
source
|
|
1062
|
+
};
|
|
1259
1063
|
}
|
|
1260
1064
|
function mergeDefinitionSet(current, incomingDefinitions, source) {
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1065
|
+
for (const rawDefinition of incomingDefinitions) {
|
|
1066
|
+
const definition = normalizeDefinition(rawDefinition, source);
|
|
1067
|
+
const existing = current.get(definition.slug);
|
|
1068
|
+
if (!existing) {
|
|
1069
|
+
current.set(definition.slug, definition);
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
mergeStringField("category", existing, definition, definition.slug);
|
|
1073
|
+
mergeStringField("className", existing, definition, definition.slug);
|
|
1074
|
+
mergeStringField("collection", existing, definition, definition.slug);
|
|
1075
|
+
mergeStringField("description", existing, definition, definition.slug);
|
|
1076
|
+
mergeStringField("name", existing, definition, definition.slug);
|
|
1077
|
+
mergeStringField("qualifiedName", existing, definition, definition.slug);
|
|
1078
|
+
mergeBindings(existing, definition);
|
|
1079
|
+
}
|
|
1276
1080
|
}
|
|
1277
1081
|
function registerPermissionDefinitions(definitions) {
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
}
|
|
1285
|
-
class PermissionCatalogService {
|
|
1286
|
-
constructor(options = {}) {
|
|
1287
|
-
this.options = options;
|
|
1288
|
-
}
|
|
1289
|
-
options;
|
|
1290
|
-
getUsersConfig() {
|
|
1291
|
-
return getPackageConfig("users", {});
|
|
1292
|
-
}
|
|
1293
|
-
getRuntimePermissionDefinitions() {
|
|
1294
|
-
return Array.from(getRuntimePermissionRegistrations().values()).flat();
|
|
1295
|
-
}
|
|
1296
|
-
getCustomPermissionDefinitions() {
|
|
1297
|
-
return this.getUsersConfig().permissions?.custom ?? [];
|
|
1298
|
-
}
|
|
1299
|
-
getCatalog() {
|
|
1300
|
-
const manifestPermissions = this.getManifestPermissionDefinitions();
|
|
1301
|
-
const customPermissions = this.getCustomPermissionDefinitions();
|
|
1302
|
-
const runtimePermissions = this.getRuntimePermissionDefinitions();
|
|
1303
|
-
const merged = /* @__PURE__ */ new Map();
|
|
1304
|
-
mergeDefinitionSet(merged, manifestPermissions, "manifest");
|
|
1305
|
-
mergeDefinitionSet(merged, customPermissions, "config");
|
|
1306
|
-
mergeDefinitionSet(merged, runtimePermissions, "runtime");
|
|
1307
|
-
return {
|
|
1308
|
-
customPermissions: customPermissions.map(
|
|
1309
|
-
(definition) => normalizeDefinition(definition, "config")
|
|
1310
|
-
),
|
|
1311
|
-
manifestPermissions: manifestPermissions.map(
|
|
1312
|
-
(definition) => normalizeDefinition(definition, "manifest")
|
|
1313
|
-
),
|
|
1314
|
-
permissions: Array.from(merged.values()).sort(
|
|
1315
|
-
(left, right) => left.slug.localeCompare(right.slug)
|
|
1316
|
-
),
|
|
1317
|
-
runtimePermissions: runtimePermissions.map(
|
|
1318
|
-
(definition) => normalizeDefinition(definition, "runtime")
|
|
1319
|
-
)
|
|
1320
|
-
};
|
|
1321
|
-
}
|
|
1322
|
-
async syncPermissionCatalog() {
|
|
1323
|
-
const catalog = this.getCatalog();
|
|
1324
|
-
const permissions = await PermissionCollection.create(this.options);
|
|
1325
|
-
const created = [];
|
|
1326
|
-
const unchanged = [];
|
|
1327
|
-
const updated = [];
|
|
1328
|
-
for (const definition of catalog.permissions) {
|
|
1329
|
-
const existing = await permissions.findBySlug(definition.slug);
|
|
1330
|
-
if (!existing) {
|
|
1331
|
-
const permission = await permissions.create({
|
|
1332
|
-
category: definition.category ?? parsePermissionSlug(definition.slug).resource,
|
|
1333
|
-
description: definition.description ?? "",
|
|
1334
|
-
name: definition.name ?? definition.slug,
|
|
1335
|
-
slug: definition.slug
|
|
1336
|
-
});
|
|
1337
|
-
await permission.save();
|
|
1338
|
-
created.push(definition.slug);
|
|
1339
|
-
continue;
|
|
1340
|
-
}
|
|
1341
|
-
const nextName = definition.name ?? existing.name;
|
|
1342
|
-
const nextDescription = definition.description ?? existing.description;
|
|
1343
|
-
const nextCategory = definition.category ?? existing.category;
|
|
1344
|
-
if (existing.name === nextName && existing.description === nextDescription && existing.category === nextCategory) {
|
|
1345
|
-
unchanged.push(definition.slug);
|
|
1346
|
-
continue;
|
|
1347
|
-
}
|
|
1348
|
-
existing.name = nextName;
|
|
1349
|
-
existing.description = nextDescription;
|
|
1350
|
-
existing.category = nextCategory;
|
|
1351
|
-
await existing.save();
|
|
1352
|
-
updated.push(definition.slug);
|
|
1353
|
-
}
|
|
1354
|
-
return {
|
|
1355
|
-
catalog,
|
|
1356
|
-
created,
|
|
1357
|
-
unchanged,
|
|
1358
|
-
updated
|
|
1359
|
-
};
|
|
1360
|
-
}
|
|
1361
|
-
getManifestPermissionDefinitions() {
|
|
1362
|
-
const standardActions = ["list", "get", "create", "update", "delete"];
|
|
1363
|
-
const definitions = /* @__PURE__ */ new Map();
|
|
1364
|
-
for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
|
|
1365
|
-
const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
|
|
1366
|
-
const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
|
|
1367
|
-
if (isCollectionManifestEntry(manifestEntry)) {
|
|
1368
|
-
continue;
|
|
1369
|
-
}
|
|
1370
|
-
const className = metadata.name;
|
|
1371
|
-
const qualifiedName = registered?.qualifiedName;
|
|
1372
|
-
const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
|
|
1373
|
-
const rawCollection = objectConfig?.collection;
|
|
1374
|
-
const configuredCollection = typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0;
|
|
1375
|
-
const collection = configuredCollection ?? manifestEntry?.collection ?? deriveCollectionName(metadata.name);
|
|
1376
|
-
const readExposed = isOperationEnabled(objectConfig.api, "list") || isOperationEnabled(objectConfig.api, "get") || isOperationEnabled(objectConfig.cli, "list") || isOperationEnabled(objectConfig.cli, "get") || isOperationEnabled(objectConfig.mcp, "list") || isOperationEnabled(objectConfig.mcp, "get");
|
|
1377
|
-
if (readExposed) {
|
|
1378
|
-
definitions.set(`${collection}.read`, {
|
|
1379
|
-
className,
|
|
1380
|
-
collection,
|
|
1381
|
-
qualifiedName,
|
|
1382
|
-
slug: `${collection}.read`
|
|
1383
|
-
});
|
|
1384
|
-
}
|
|
1385
|
-
for (const action of ["create", "update", "delete"]) {
|
|
1386
|
-
const exposed = isOperationEnabled(objectConfig.api, action) || isOperationEnabled(objectConfig.cli, action) || isOperationEnabled(objectConfig.mcp, action);
|
|
1387
|
-
if (!exposed) {
|
|
1388
|
-
continue;
|
|
1389
|
-
}
|
|
1390
|
-
definitions.set(`${collection}.${action}`, {
|
|
1391
|
-
className,
|
|
1392
|
-
collection,
|
|
1393
|
-
qualifiedName,
|
|
1394
|
-
slug: `${collection}.${action}`
|
|
1395
|
-
});
|
|
1396
|
-
}
|
|
1397
|
-
const methodEntries = manifestEntry?.methods ? Object.values(manifestEntry.methods) : Array.from(metadata.methods.values());
|
|
1398
|
-
const publicCustomMethodNames = getPublicCustomMethodNames(
|
|
1399
|
-
methodEntries,
|
|
1400
|
-
standardActions
|
|
1401
|
-
);
|
|
1402
|
-
const customApiMethods = /* @__PURE__ */ new Set();
|
|
1403
|
-
const customCliMethods = getCustomMethodExposureNames(
|
|
1404
|
-
objectConfig.cli,
|
|
1405
|
-
publicCustomMethodNames
|
|
1406
|
-
);
|
|
1407
|
-
const customMcpMethods = getCustomMethodExposureNames(
|
|
1408
|
-
objectConfig.mcp,
|
|
1409
|
-
publicCustomMethodNames
|
|
1410
|
-
);
|
|
1411
|
-
for (const methodName of publicCustomMethodNames) {
|
|
1412
|
-
if (isOperationEnabled(objectConfig.api, methodName)) {
|
|
1413
|
-
customApiMethods.add(methodName);
|
|
1414
|
-
}
|
|
1415
|
-
}
|
|
1416
|
-
const customMethods = /* @__PURE__ */ new Set([
|
|
1417
|
-
...customApiMethods,
|
|
1418
|
-
...customCliMethods,
|
|
1419
|
-
...customMcpMethods
|
|
1420
|
-
]);
|
|
1421
|
-
for (const methodName of customMethods) {
|
|
1422
|
-
definitions.set(`${collection}.${methodName}`, {
|
|
1423
|
-
className,
|
|
1424
|
-
collection,
|
|
1425
|
-
description: `Allows ${methodName} on ${humanizeResource(collection).toLowerCase()}`,
|
|
1426
|
-
name: `${capitalize(methodName)} ${humanizeResource(collection)}`,
|
|
1427
|
-
qualifiedName,
|
|
1428
|
-
slug: `${collection}.${methodName}`
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1432
|
-
return Array.from(definitions.values()).sort(
|
|
1433
|
-
(left, right) => left.slug.localeCompare(right.slug)
|
|
1434
|
-
);
|
|
1435
|
-
}
|
|
1436
|
-
static create(options = {}) {
|
|
1437
|
-
return new PermissionCatalogService(options);
|
|
1438
|
-
}
|
|
1082
|
+
globalThis.__smrtUsersPermissionRegistrationCounter = (globalThis.__smrtUsersPermissionRegistrationCounter ?? 0) + 1;
|
|
1083
|
+
const registrationId = globalThis.__smrtUsersPermissionRegistrationCounter;
|
|
1084
|
+
getRuntimePermissionRegistrations().set(registrationId, definitions);
|
|
1085
|
+
return () => {
|
|
1086
|
+
getRuntimePermissionRegistrations().delete(registrationId);
|
|
1087
|
+
};
|
|
1439
1088
|
}
|
|
1089
|
+
var PermissionCatalogService = class PermissionCatalogService {
|
|
1090
|
+
constructor(options = {}) {
|
|
1091
|
+
this.options = options;
|
|
1092
|
+
}
|
|
1093
|
+
options;
|
|
1094
|
+
getUsersConfig() {
|
|
1095
|
+
return getPackageConfig("users", {});
|
|
1096
|
+
}
|
|
1097
|
+
getRuntimePermissionDefinitions() {
|
|
1098
|
+
return Array.from(getRuntimePermissionRegistrations().values()).flat();
|
|
1099
|
+
}
|
|
1100
|
+
getCustomPermissionDefinitions() {
|
|
1101
|
+
return this.getUsersConfig().permissions?.custom ?? [];
|
|
1102
|
+
}
|
|
1103
|
+
getCatalog() {
|
|
1104
|
+
const manifestPermissions = this.getManifestPermissionDefinitions();
|
|
1105
|
+
const customPermissions = this.getCustomPermissionDefinitions();
|
|
1106
|
+
const runtimePermissions = this.getRuntimePermissionDefinitions();
|
|
1107
|
+
const merged = /* @__PURE__ */ new Map();
|
|
1108
|
+
mergeDefinitionSet(merged, manifestPermissions, "manifest");
|
|
1109
|
+
mergeDefinitionSet(merged, customPermissions, "config");
|
|
1110
|
+
mergeDefinitionSet(merged, runtimePermissions, "runtime");
|
|
1111
|
+
return {
|
|
1112
|
+
customPermissions: customPermissions.map((definition) => normalizeDefinition(definition, "config")),
|
|
1113
|
+
manifestPermissions: manifestPermissions.map((definition) => normalizeDefinition(definition, "manifest")),
|
|
1114
|
+
permissions: Array.from(merged.values()).sort((left, right) => left.slug.localeCompare(right.slug)),
|
|
1115
|
+
runtimePermissions: runtimePermissions.map((definition) => normalizeDefinition(definition, "runtime"))
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
async syncPermissionCatalog() {
|
|
1119
|
+
const catalog = this.getCatalog();
|
|
1120
|
+
const permissions = await PermissionCollection.create(this.options);
|
|
1121
|
+
const created = [];
|
|
1122
|
+
const unchanged = [];
|
|
1123
|
+
const updated = [];
|
|
1124
|
+
for (const definition of catalog.permissions) {
|
|
1125
|
+
const existing = await permissions.findBySlug(definition.slug);
|
|
1126
|
+
if (!existing) {
|
|
1127
|
+
await (await permissions.create({
|
|
1128
|
+
category: definition.category ?? parsePermissionSlug(definition.slug).resource,
|
|
1129
|
+
description: definition.description ?? "",
|
|
1130
|
+
name: definition.name ?? definition.slug,
|
|
1131
|
+
slug: definition.slug
|
|
1132
|
+
})).save();
|
|
1133
|
+
created.push(definition.slug);
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
const nextName = definition.name ?? existing.name;
|
|
1137
|
+
const nextDescription = definition.description ?? existing.description;
|
|
1138
|
+
const nextCategory = definition.category ?? existing.category;
|
|
1139
|
+
if (existing.name === nextName && existing.description === nextDescription && existing.category === nextCategory) {
|
|
1140
|
+
unchanged.push(definition.slug);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
existing.name = nextName;
|
|
1144
|
+
existing.description = nextDescription;
|
|
1145
|
+
existing.category = nextCategory;
|
|
1146
|
+
await existing.save();
|
|
1147
|
+
updated.push(definition.slug);
|
|
1148
|
+
}
|
|
1149
|
+
return {
|
|
1150
|
+
catalog,
|
|
1151
|
+
created,
|
|
1152
|
+
unchanged,
|
|
1153
|
+
updated
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
getManifestPermissionDefinitions() {
|
|
1157
|
+
const standardActions = [
|
|
1158
|
+
"list",
|
|
1159
|
+
"get",
|
|
1160
|
+
"create",
|
|
1161
|
+
"update",
|
|
1162
|
+
"delete"
|
|
1163
|
+
];
|
|
1164
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
1165
|
+
for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
|
|
1166
|
+
const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
|
|
1167
|
+
const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
|
|
1168
|
+
if (isCollectionManifestEntry(manifestEntry)) continue;
|
|
1169
|
+
const className = metadata.name;
|
|
1170
|
+
const qualifiedName = registered?.qualifiedName;
|
|
1171
|
+
const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
|
|
1172
|
+
const rawCollection = objectConfig?.collection;
|
|
1173
|
+
const collection = (typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0) ?? manifestEntry?.collection ?? deriveCollectionName(metadata.name);
|
|
1174
|
+
if (isOperationEnabled(objectConfig.api, "list") || isOperationEnabled(objectConfig.api, "get") || isOperationEnabled(objectConfig.cli, "list") || isOperationEnabled(objectConfig.cli, "get") || isOperationEnabled(objectConfig.mcp, "list") || isOperationEnabled(objectConfig.mcp, "get")) definitions.set(`${collection}.read`, {
|
|
1175
|
+
className,
|
|
1176
|
+
collection,
|
|
1177
|
+
qualifiedName,
|
|
1178
|
+
slug: `${collection}.read`
|
|
1179
|
+
});
|
|
1180
|
+
for (const action of [
|
|
1181
|
+
"create",
|
|
1182
|
+
"update",
|
|
1183
|
+
"delete"
|
|
1184
|
+
]) {
|
|
1185
|
+
if (!(isOperationEnabled(objectConfig.api, action) || isOperationEnabled(objectConfig.cli, action) || isOperationEnabled(objectConfig.mcp, action))) continue;
|
|
1186
|
+
definitions.set(`${collection}.${action}`, {
|
|
1187
|
+
className,
|
|
1188
|
+
collection,
|
|
1189
|
+
qualifiedName,
|
|
1190
|
+
slug: `${collection}.${action}`
|
|
1191
|
+
});
|
|
1192
|
+
}
|
|
1193
|
+
const publicCustomMethodNames = getPublicCustomMethodNames(manifestEntry?.methods ? Object.values(manifestEntry.methods) : Array.from(metadata.methods.values()), standardActions);
|
|
1194
|
+
const customApiMethods = /* @__PURE__ */ new Set();
|
|
1195
|
+
const customCliMethods = getCustomMethodExposureNames(objectConfig.cli, publicCustomMethodNames);
|
|
1196
|
+
const customMcpMethods = getCustomMethodExposureNames(objectConfig.mcp, publicCustomMethodNames);
|
|
1197
|
+
for (const methodName of publicCustomMethodNames) if (isOperationEnabled(objectConfig.api, methodName)) customApiMethods.add(methodName);
|
|
1198
|
+
const customMethods = /* @__PURE__ */ new Set([
|
|
1199
|
+
...customApiMethods,
|
|
1200
|
+
...customCliMethods,
|
|
1201
|
+
...customMcpMethods
|
|
1202
|
+
]);
|
|
1203
|
+
for (const methodName of customMethods) definitions.set(`${collection}.${methodName}`, {
|
|
1204
|
+
className,
|
|
1205
|
+
collection,
|
|
1206
|
+
description: `Allows ${methodName} on ${humanizeResource(collection).toLowerCase()}`,
|
|
1207
|
+
name: `${capitalize(methodName)} ${humanizeResource(collection)}`,
|
|
1208
|
+
qualifiedName,
|
|
1209
|
+
slug: `${collection}.${methodName}`
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
return Array.from(definitions.values()).sort((left, right) => left.slug.localeCompare(right.slug));
|
|
1213
|
+
}
|
|
1214
|
+
static create(options = {}) {
|
|
1215
|
+
return new PermissionCatalogService(options);
|
|
1216
|
+
}
|
|
1217
|
+
};
|
|
1440
1218
|
async function syncPermissionCatalog(options = {}) {
|
|
1441
|
-
|
|
1219
|
+
return PermissionCatalogService.create(options).syncPermissionCatalog();
|
|
1442
1220
|
}
|
|
1221
|
+
//#endregion
|
|
1222
|
+
//#region src/services/PostgresPermissionPolicies.ts
|
|
1443
1223
|
function toSnakeCase(value) {
|
|
1444
|
-
|
|
1224
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
|
|
1445
1225
|
}
|
|
1446
1226
|
function quoteIdent(identifier) {
|
|
1447
|
-
|
|
1227
|
+
return `"${identifier.replaceAll("\"", "\"\"")}"`;
|
|
1448
1228
|
}
|
|
1449
1229
|
function quoteLiteral(value) {
|
|
1450
|
-
|
|
1230
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
1451
1231
|
}
|
|
1452
1232
|
function isProbablyPostgres(configDb, database) {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
if (typeof database.url === "string" && database.url.startsWith("postgres")) {
|
|
1457
|
-
return true;
|
|
1458
|
-
}
|
|
1459
|
-
return (database.constructor?.name || "").toLowerCase().includes("postgres");
|
|
1233
|
+
if (configDb && typeof configDb === "object" && !("query" in configDb) && "type" in configDb && configDb.type === "postgres") return true;
|
|
1234
|
+
if (typeof database.url === "string" && database.url.startsWith("postgres")) return true;
|
|
1235
|
+
return (database.constructor?.name || "").toLowerCase().includes("postgres");
|
|
1460
1236
|
}
|
|
1461
1237
|
function normalizePostgresPermissionAction(action) {
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
}
|
|
1466
|
-
throw new Error(
|
|
1467
|
-
`Invalid Postgres permission binding action "${action}". Expected one of SELECT, INSERT, UPDATE, DELETE.`
|
|
1468
|
-
);
|
|
1238
|
+
const normalized = action.toUpperCase();
|
|
1239
|
+
if (normalized === "SELECT" || normalized === "INSERT" || normalized === "UPDATE" || normalized === "DELETE") return normalized;
|
|
1240
|
+
throw new Error(`Invalid Postgres permission binding action "${action}". Expected one of SELECT, INSERT, UPDATE, DELETE.`);
|
|
1469
1241
|
}
|
|
1470
1242
|
function normalizePostgresPermissionBinding(binding, fallbackPermission) {
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
}
|
|
1483
|
-
return {
|
|
1484
|
-
action: normalizePostgresPermissionAction(binding.action),
|
|
1485
|
-
permission,
|
|
1486
|
-
schemaName: binding.schemaName,
|
|
1487
|
-
tableName,
|
|
1488
|
-
tenantField: binding.tenantField
|
|
1489
|
-
};
|
|
1243
|
+
const tableName = binding.tableName?.trim();
|
|
1244
|
+
if (!tableName) throw new Error("Postgres permission binding is missing a tableName value.");
|
|
1245
|
+
const permission = binding.permission ?? fallbackPermission;
|
|
1246
|
+
if (!permission) throw new Error("Postgres permission binding is missing a permission value.");
|
|
1247
|
+
return {
|
|
1248
|
+
action: normalizePostgresPermissionAction(binding.action),
|
|
1249
|
+
permission,
|
|
1250
|
+
schemaName: binding.schemaName,
|
|
1251
|
+
tableName,
|
|
1252
|
+
tenantField: binding.tenantField
|
|
1253
|
+
};
|
|
1490
1254
|
}
|
|
1491
1255
|
function parseTableReference(binding) {
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1256
|
+
if (binding.tableName.includes(".")) {
|
|
1257
|
+
const [schemaName, tableName] = binding.tableName.split(".", 2);
|
|
1258
|
+
return {
|
|
1259
|
+
schemaName,
|
|
1260
|
+
tableName
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
return {
|
|
1264
|
+
schemaName: binding.schemaName ?? "public",
|
|
1265
|
+
tableName: binding.tableName
|
|
1266
|
+
};
|
|
1503
1267
|
}
|
|
1504
1268
|
function buildPolicyName(tableName, action) {
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
const tableSegment = (sanitizedTable || "table").slice(
|
|
1512
|
-
0,
|
|
1513
|
-
Math.max(maxTableSegmentLength, 1)
|
|
1514
|
-
);
|
|
1515
|
-
return `${prefix}${tableSegment}_${actionSegment}_${hash}`;
|
|
1269
|
+
const actionSegment = action.toLowerCase();
|
|
1270
|
+
const hash = createHash("sha1").update(`${tableName}:${action}`).digest("hex").slice(0, 8);
|
|
1271
|
+
const sanitizedTable = tableName.replace(/[^a-zA-Z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
|
|
1272
|
+
const prefix = "smrt_";
|
|
1273
|
+
const maxTableSegmentLength = 58 - actionSegment.length - hash.length - 2;
|
|
1274
|
+
return `${prefix}${(sanitizedTable || "table").slice(0, Math.max(maxTableSegmentLength, 1))}_${actionSegment}_${hash}`;
|
|
1516
1275
|
}
|
|
1517
1276
|
function buildPermissionExpression(permissionSlugs) {
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
}
|
|
1521
|
-
return permissionSlugs.map((permission) => `smrt_has_permission(${quoteLiteral(permission)})`).join(" OR ");
|
|
1277
|
+
if (permissionSlugs.length === 0) return "FALSE";
|
|
1278
|
+
return permissionSlugs.map((permission) => `smrt_has_permission(${quoteLiteral(permission)})`).join(" OR ");
|
|
1522
1279
|
}
|
|
1523
1280
|
function buildTenantMatchExpression(tenantField) {
|
|
1524
|
-
|
|
1281
|
+
return `${quoteIdent(tenantField)}::text = smrt_current_tenant_id()`;
|
|
1525
1282
|
}
|
|
1526
1283
|
function buildSelectPolicySql(target, permissions) {
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
1532
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR SELECT USING (${condition})`
|
|
1533
|
-
];
|
|
1284
|
+
const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
|
|
1285
|
+
const policyName = buildPolicyName(target.tableName, "SELECT");
|
|
1286
|
+
const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
|
|
1287
|
+
return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR SELECT USING (${condition})`];
|
|
1534
1288
|
}
|
|
1535
1289
|
function buildInsertPolicySql(target, permissions) {
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
1541
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR INSERT WITH CHECK (${condition})`
|
|
1542
|
-
];
|
|
1290
|
+
const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
|
|
1291
|
+
const policyName = buildPolicyName(target.tableName, "INSERT");
|
|
1292
|
+
const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
|
|
1293
|
+
return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR INSERT WITH CHECK (${condition})`];
|
|
1543
1294
|
}
|
|
1544
1295
|
function buildUpdatePolicySql(target, permissions) {
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
1550
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR UPDATE USING (${condition}) WITH CHECK (${condition})`
|
|
1551
|
-
];
|
|
1296
|
+
const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
|
|
1297
|
+
const policyName = buildPolicyName(target.tableName, "UPDATE");
|
|
1298
|
+
const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
|
|
1299
|
+
return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR UPDATE USING (${condition}) WITH CHECK (${condition})`];
|
|
1552
1300
|
}
|
|
1553
1301
|
function buildDeletePolicySql(target, permissions) {
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
1559
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR DELETE USING (${condition})`
|
|
1560
|
-
];
|
|
1302
|
+
const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
|
|
1303
|
+
const policyName = buildPolicyName(target.tableName, "DELETE");
|
|
1304
|
+
const condition = `smrt_rls_bypass() OR ((${buildTenantMatchExpression(target.tenantField)}) AND (${buildPermissionExpression(permissions)}))`;
|
|
1305
|
+
return [`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`, `CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR DELETE USING (${condition})`];
|
|
1561
1306
|
}
|
|
1562
1307
|
function buildHelperStatements() {
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1308
|
+
return [
|
|
1309
|
+
[
|
|
1310
|
+
"CREATE OR REPLACE FUNCTION smrt_rls_bypass()",
|
|
1311
|
+
"RETURNS boolean",
|
|
1312
|
+
"LANGUAGE sql",
|
|
1313
|
+
"STABLE",
|
|
1314
|
+
"AS $$",
|
|
1315
|
+
" SELECT COALESCE(NULLIF(current_setting('smrt.system_context', true), ''), 'false')::boolean",
|
|
1316
|
+
" OR COALESCE(NULLIF(current_setting('smrt.super_admin_bypass', true), ''), 'false')::boolean",
|
|
1317
|
+
"$$"
|
|
1318
|
+
].join("\n"),
|
|
1319
|
+
[
|
|
1320
|
+
"CREATE OR REPLACE FUNCTION smrt_current_tenant_id()",
|
|
1321
|
+
"RETURNS text",
|
|
1322
|
+
"LANGUAGE sql",
|
|
1323
|
+
"STABLE",
|
|
1324
|
+
"AS $$",
|
|
1325
|
+
" SELECT NULLIF(current_setting('smrt.tenant_id', true), '')",
|
|
1326
|
+
"$$"
|
|
1327
|
+
].join("\n"),
|
|
1328
|
+
[
|
|
1329
|
+
"CREATE OR REPLACE FUNCTION smrt_has_permission(required_permission text)",
|
|
1330
|
+
"RETURNS boolean",
|
|
1331
|
+
"LANGUAGE sql",
|
|
1332
|
+
"STABLE",
|
|
1333
|
+
"AS $$",
|
|
1334
|
+
" SELECT smrt_rls_bypass()",
|
|
1335
|
+
" OR jsonb_exists(COALESCE(NULLIF(current_setting('smrt.permissions', true), ''), '[]')::jsonb, required_permission)",
|
|
1336
|
+
"$$"
|
|
1337
|
+
].join("\n")
|
|
1338
|
+
];
|
|
1594
1339
|
}
|
|
1595
1340
|
function addBindingToTarget(targets, binding, source = {}) {
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
const permissions = target.actions.get(action) ?? /* @__PURE__ */ new Set();
|
|
1614
|
-
if (binding.permission) {
|
|
1615
|
-
permissions.add(binding.permission);
|
|
1616
|
-
}
|
|
1617
|
-
target.actions.set(action, permissions);
|
|
1618
|
-
targets.set(targetKey, target);
|
|
1341
|
+
const { schemaName, tableName } = parseTableReference(binding);
|
|
1342
|
+
const targetKey = `${schemaName}.${tableName}`;
|
|
1343
|
+
const existing = targets.get(targetKey);
|
|
1344
|
+
const tenantField = binding.tenantField ?? "tenant_id";
|
|
1345
|
+
if (existing && existing.tenantField !== tenantField) throw new Error(`Conflicting tenant fields for table '${targetKey}': '${existing.tenantField}' !== '${tenantField}'`);
|
|
1346
|
+
const target = existing ?? {
|
|
1347
|
+
actions: /* @__PURE__ */ new Map(),
|
|
1348
|
+
...source,
|
|
1349
|
+
schemaName,
|
|
1350
|
+
tableName,
|
|
1351
|
+
tenantField
|
|
1352
|
+
};
|
|
1353
|
+
const action = normalizePostgresPermissionAction(binding.action);
|
|
1354
|
+
const permissions = target.actions.get(action) ?? /* @__PURE__ */ new Set();
|
|
1355
|
+
if (binding.permission) permissions.add(binding.permission);
|
|
1356
|
+
target.actions.set(action, permissions);
|
|
1357
|
+
targets.set(targetKey, target);
|
|
1619
1358
|
}
|
|
1620
1359
|
function generatePostgresPermissionSql(options = {}) {
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
explicitBindings.push(normalizePostgresPermissionBinding(binding));
|
|
1745
|
-
}
|
|
1746
|
-
for (const binding of explicitBindings) {
|
|
1747
|
-
addBindingToTarget(candidateTargets, binding);
|
|
1748
|
-
}
|
|
1749
|
-
const statements = [...buildHelperStatements()];
|
|
1750
|
-
const targets = Array.from(candidateTargets.values()).sort(
|
|
1751
|
-
(left, right) => `${left.schemaName}.${left.tableName}`.localeCompare(
|
|
1752
|
-
`${right.schemaName}.${right.tableName}`
|
|
1753
|
-
)
|
|
1754
|
-
).map((target) => ({
|
|
1755
|
-
actions: Object.fromEntries(
|
|
1756
|
-
Array.from(target.actions.entries()).map(([action, permissions]) => [
|
|
1757
|
-
action,
|
|
1758
|
-
Array.from(permissions).sort()
|
|
1759
|
-
])
|
|
1760
|
-
),
|
|
1761
|
-
className: target.className,
|
|
1762
|
-
collection: target.collection,
|
|
1763
|
-
qualifiedName: target.qualifiedName,
|
|
1764
|
-
schemaName: target.schemaName,
|
|
1765
|
-
tableName: target.tableName,
|
|
1766
|
-
tenantField: target.tenantField
|
|
1767
|
-
}));
|
|
1768
|
-
for (const target of Array.from(candidateTargets.values())) {
|
|
1769
|
-
const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
|
|
1770
|
-
statements.push(`ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY`);
|
|
1771
|
-
statements.push(`ALTER TABLE ${qualifiedTable} FORCE ROW LEVEL SECURITY`);
|
|
1772
|
-
const selectPermissions = Array.from(
|
|
1773
|
-
target.actions.get("SELECT") ?? []
|
|
1774
|
-
).sort();
|
|
1775
|
-
const insertPermissions = Array.from(
|
|
1776
|
-
target.actions.get("INSERT") ?? []
|
|
1777
|
-
).sort();
|
|
1778
|
-
const updatePermissions = Array.from(
|
|
1779
|
-
target.actions.get("UPDATE") ?? []
|
|
1780
|
-
).sort();
|
|
1781
|
-
const deletePermissions = Array.from(
|
|
1782
|
-
target.actions.get("DELETE") ?? []
|
|
1783
|
-
).sort();
|
|
1784
|
-
if (selectPermissions.length > 0) {
|
|
1785
|
-
statements.push(...buildSelectPolicySql(target, selectPermissions));
|
|
1786
|
-
}
|
|
1787
|
-
if (insertPermissions.length > 0) {
|
|
1788
|
-
statements.push(...buildInsertPolicySql(target, insertPermissions));
|
|
1789
|
-
}
|
|
1790
|
-
if (updatePermissions.length > 0) {
|
|
1791
|
-
statements.push(...buildUpdatePolicySql(target, updatePermissions));
|
|
1792
|
-
}
|
|
1793
|
-
if (deletePermissions.length > 0) {
|
|
1794
|
-
statements.push(...buildDeletePolicySql(target, deletePermissions));
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
return {
|
|
1798
|
-
bindings: explicitBindings,
|
|
1799
|
-
skipped,
|
|
1800
|
-
sql: `${statements.join(";\n")};
|
|
1360
|
+
const catalogService = PermissionCatalogService.create(options);
|
|
1361
|
+
const catalog = catalogService.getCatalog();
|
|
1362
|
+
const config = catalogService.getUsersConfig();
|
|
1363
|
+
const candidateTargets = /* @__PURE__ */ new Map();
|
|
1364
|
+
const skipped = [];
|
|
1365
|
+
const autoCandidates = /* @__PURE__ */ new Map();
|
|
1366
|
+
for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
|
|
1367
|
+
const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
|
|
1368
|
+
const tenantScoped = registered?.tenantScopedConfig;
|
|
1369
|
+
const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
|
|
1370
|
+
if (!tenantScoped) {
|
|
1371
|
+
skipped.push({
|
|
1372
|
+
className: metadata.name,
|
|
1373
|
+
qualifiedName: registered?.qualifiedName,
|
|
1374
|
+
reason: "not tenant-scoped"
|
|
1375
|
+
});
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
if (tenantScoped.mode !== "required") {
|
|
1379
|
+
skipped.push({
|
|
1380
|
+
className: metadata.name,
|
|
1381
|
+
qualifiedName: registered?.qualifiedName,
|
|
1382
|
+
reason: `tenant mode '${tenantScoped.mode}' is not supported for automatic Postgres RLS generation`
|
|
1383
|
+
});
|
|
1384
|
+
continue;
|
|
1385
|
+
}
|
|
1386
|
+
const rawTableName = registered?.schema?.tableName ?? manifestEntry?.schema?.tableName;
|
|
1387
|
+
if (!rawTableName) {
|
|
1388
|
+
skipped.push({
|
|
1389
|
+
className: metadata.name,
|
|
1390
|
+
qualifiedName: registered?.qualifiedName,
|
|
1391
|
+
reason: "no schema table name available"
|
|
1392
|
+
});
|
|
1393
|
+
continue;
|
|
1394
|
+
}
|
|
1395
|
+
const parsedTable = parseTableReference({ tableName: rawTableName });
|
|
1396
|
+
const tableKey = `${parsedTable.schemaName}.${parsedTable.tableName}`;
|
|
1397
|
+
const rawCollection = (manifestEntry?.decoratorConfig ?? metadata.config)?.collection;
|
|
1398
|
+
const collection = (typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0) ?? manifestEntry?.collection ?? `${toSnakeCase(metadata.name)}s`;
|
|
1399
|
+
const entries = autoCandidates.get(tableKey) ?? [];
|
|
1400
|
+
entries.push({
|
|
1401
|
+
className: metadata.name,
|
|
1402
|
+
collection,
|
|
1403
|
+
qualifiedName: registered?.qualifiedName,
|
|
1404
|
+
schemaName: parsedTable.schemaName,
|
|
1405
|
+
tableName: parsedTable.tableName,
|
|
1406
|
+
tenantField: toSnakeCase(tenantScoped.field)
|
|
1407
|
+
});
|
|
1408
|
+
autoCandidates.set(tableKey, entries);
|
|
1409
|
+
}
|
|
1410
|
+
for (const [tableKey, entries] of autoCandidates) {
|
|
1411
|
+
if (entries.length > 1) {
|
|
1412
|
+
for (const entry2 of entries) skipped.push({
|
|
1413
|
+
className: entry2.className,
|
|
1414
|
+
collection: entry2.collection,
|
|
1415
|
+
qualifiedName: entry2.qualifiedName,
|
|
1416
|
+
reason: `table '${tableKey}' is shared by multiple objects, so automatic policy generation was skipped`,
|
|
1417
|
+
schemaName: entry2.schemaName,
|
|
1418
|
+
tableName: entry2.tableName
|
|
1419
|
+
});
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
const entry = entries[0];
|
|
1423
|
+
addBindingToTarget(candidateTargets, {
|
|
1424
|
+
action: "SELECT",
|
|
1425
|
+
permission: `${entry.collection}.read`,
|
|
1426
|
+
schemaName: entry.schemaName,
|
|
1427
|
+
tableName: entry.tableName,
|
|
1428
|
+
tenantField: entry.tenantField
|
|
1429
|
+
}, entry);
|
|
1430
|
+
addBindingToTarget(candidateTargets, {
|
|
1431
|
+
action: "INSERT",
|
|
1432
|
+
permission: `${entry.collection}.create`,
|
|
1433
|
+
schemaName: entry.schemaName,
|
|
1434
|
+
tableName: entry.tableName,
|
|
1435
|
+
tenantField: entry.tenantField
|
|
1436
|
+
}, entry);
|
|
1437
|
+
addBindingToTarget(candidateTargets, {
|
|
1438
|
+
action: "UPDATE",
|
|
1439
|
+
permission: `${entry.collection}.update`,
|
|
1440
|
+
schemaName: entry.schemaName,
|
|
1441
|
+
tableName: entry.tableName,
|
|
1442
|
+
tenantField: entry.tenantField
|
|
1443
|
+
}, entry);
|
|
1444
|
+
addBindingToTarget(candidateTargets, {
|
|
1445
|
+
action: "DELETE",
|
|
1446
|
+
permission: `${entry.collection}.delete`,
|
|
1447
|
+
schemaName: entry.schemaName,
|
|
1448
|
+
tableName: entry.tableName,
|
|
1449
|
+
tenantField: entry.tenantField
|
|
1450
|
+
}, entry);
|
|
1451
|
+
}
|
|
1452
|
+
const explicitBindings = [];
|
|
1453
|
+
for (const definition of catalog.permissions) for (const binding of definition.postgres?.bindings ?? []) explicitBindings.push(normalizePostgresPermissionBinding(binding, definition.slug));
|
|
1454
|
+
for (const binding of config.permissions?.postgres?.bindings ?? []) explicitBindings.push(normalizePostgresPermissionBinding(binding));
|
|
1455
|
+
for (const binding of explicitBindings) addBindingToTarget(candidateTargets, binding);
|
|
1456
|
+
const statements = [...buildHelperStatements()];
|
|
1457
|
+
const targets = Array.from(candidateTargets.values()).sort((left, right) => `${left.schemaName}.${left.tableName}`.localeCompare(`${right.schemaName}.${right.tableName}`)).map((target) => ({
|
|
1458
|
+
actions: Object.fromEntries(Array.from(target.actions.entries()).map(([action, permissions]) => [action, Array.from(permissions).sort()])),
|
|
1459
|
+
className: target.className,
|
|
1460
|
+
collection: target.collection,
|
|
1461
|
+
qualifiedName: target.qualifiedName,
|
|
1462
|
+
schemaName: target.schemaName,
|
|
1463
|
+
tableName: target.tableName,
|
|
1464
|
+
tenantField: target.tenantField
|
|
1465
|
+
}));
|
|
1466
|
+
for (const target of Array.from(candidateTargets.values())) {
|
|
1467
|
+
const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
|
|
1468
|
+
statements.push(`ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY`);
|
|
1469
|
+
statements.push(`ALTER TABLE ${qualifiedTable} FORCE ROW LEVEL SECURITY`);
|
|
1470
|
+
const selectPermissions = Array.from(target.actions.get("SELECT") ?? []).sort();
|
|
1471
|
+
const insertPermissions = Array.from(target.actions.get("INSERT") ?? []).sort();
|
|
1472
|
+
const updatePermissions = Array.from(target.actions.get("UPDATE") ?? []).sort();
|
|
1473
|
+
const deletePermissions = Array.from(target.actions.get("DELETE") ?? []).sort();
|
|
1474
|
+
if (selectPermissions.length > 0) statements.push(...buildSelectPolicySql(target, selectPermissions));
|
|
1475
|
+
if (insertPermissions.length > 0) statements.push(...buildInsertPolicySql(target, insertPermissions));
|
|
1476
|
+
if (updatePermissions.length > 0) statements.push(...buildUpdatePolicySql(target, updatePermissions));
|
|
1477
|
+
if (deletePermissions.length > 0) statements.push(...buildDeletePolicySql(target, deletePermissions));
|
|
1478
|
+
}
|
|
1479
|
+
return {
|
|
1480
|
+
bindings: explicitBindings,
|
|
1481
|
+
skipped,
|
|
1482
|
+
sql: `${statements.join(";\n")};
|
|
1801
1483
|
`,
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1484
|
+
statements,
|
|
1485
|
+
targets
|
|
1486
|
+
};
|
|
1805
1487
|
}
|
|
1806
1488
|
async function applyPostgresPermissionPolicies(options = {}) {
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
await permissions.db.query(statement);
|
|
1818
|
-
} catch (error) {
|
|
1819
|
-
throw new Error(
|
|
1820
|
-
`Failed to apply Postgres permission policy statement:
|
|
1821
|
-
${statement}`,
|
|
1822
|
-
{
|
|
1823
|
-
cause: error
|
|
1824
|
-
}
|
|
1825
|
-
);
|
|
1826
|
-
}
|
|
1827
|
-
}
|
|
1828
|
-
return result;
|
|
1829
|
-
}
|
|
1830
|
-
class TenantService {
|
|
1831
|
-
options;
|
|
1832
|
-
policy;
|
|
1833
|
-
tenantCollection;
|
|
1834
|
-
membershipCollection;
|
|
1835
|
-
roleCollection;
|
|
1836
|
-
constructor(options, policy) {
|
|
1837
|
-
this.options = options;
|
|
1838
|
-
this.policy = policy ?? DEFAULT_TENANT_POLICY;
|
|
1839
|
-
}
|
|
1840
|
-
/**
|
|
1841
|
-
* Initialize collections
|
|
1842
|
-
*/
|
|
1843
|
-
async initialize() {
|
|
1844
|
-
this.tenantCollection = await TenantCollection.create(this.options);
|
|
1845
|
-
this.membershipCollection = await MembershipCollection.create(this.options);
|
|
1846
|
-
this.roleCollection = await RoleCollection.create(this.options);
|
|
1847
|
-
await this.roleCollection.seedSystemRoles();
|
|
1848
|
-
}
|
|
1849
|
-
/**
|
|
1850
|
-
* Get the current policy
|
|
1851
|
-
*/
|
|
1852
|
-
getPolicy() {
|
|
1853
|
-
return { ...this.policy };
|
|
1854
|
-
}
|
|
1855
|
-
/**
|
|
1856
|
-
* Create a tenant and make the user the owner
|
|
1857
|
-
*
|
|
1858
|
-
* @param userId - The user to make owner
|
|
1859
|
-
* @param name - Tenant name
|
|
1860
|
-
* @param options - Optional slug override
|
|
1861
|
-
* @returns The created tenant and membership
|
|
1862
|
-
*/
|
|
1863
|
-
async createTenantWithOwnership(userId, name, options) {
|
|
1864
|
-
if (!await this.canCreateTenant(userId)) {
|
|
1865
|
-
throw new Error(
|
|
1866
|
-
`User has reached maximum tenant limit (${this.policy.maxTenants})`
|
|
1867
|
-
);
|
|
1868
|
-
}
|
|
1869
|
-
const tenant = await this.tenantCollection.create({
|
|
1870
|
-
name,
|
|
1871
|
-
slug: options?.slug
|
|
1872
|
-
});
|
|
1873
|
-
await tenant.save();
|
|
1874
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
1875
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
1876
|
-
);
|
|
1877
|
-
if (!ownerRole) {
|
|
1878
|
-
throw new Error("Owner role not found - run seedSystemRoles first");
|
|
1879
|
-
}
|
|
1880
|
-
const membership = await this.membershipCollection.create({
|
|
1881
|
-
userId,
|
|
1882
|
-
tenantId: tenant.id,
|
|
1883
|
-
roleId: ownerRole.id,
|
|
1884
|
-
status: MembershipStatus.ACTIVE
|
|
1885
|
-
});
|
|
1886
|
-
await membership.save();
|
|
1887
|
-
return { tenant, membership };
|
|
1888
|
-
}
|
|
1889
|
-
/**
|
|
1890
|
-
* Check if a user can create a new tenant
|
|
1891
|
-
*
|
|
1892
|
-
* Returns false if maxTenants limit is reached (0 = unlimited)
|
|
1893
|
-
*/
|
|
1894
|
-
async canCreateTenant(userId) {
|
|
1895
|
-
if (this.policy.maxTenants === 0) {
|
|
1896
|
-
return true;
|
|
1897
|
-
}
|
|
1898
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
1899
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
1900
|
-
);
|
|
1901
|
-
if (!ownerRole) {
|
|
1902
|
-
return false;
|
|
1903
|
-
}
|
|
1904
|
-
const memberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1905
|
-
const ownedCount = memberships.filter(
|
|
1906
|
-
(m2) => m2.roleId === ownerRole.id
|
|
1907
|
-
).length;
|
|
1908
|
-
return ownedCount < this.policy.maxTenants;
|
|
1909
|
-
}
|
|
1910
|
-
/**
|
|
1911
|
-
* Get a specific error message explaining why a tenant cannot be deleted.
|
|
1912
|
-
*
|
|
1913
|
-
* @returns Error message if deletion is not allowed, or null if allowed.
|
|
1914
|
-
*/
|
|
1915
|
-
async getDeleteTenantError(userId, tenantId) {
|
|
1916
|
-
const membership = await this.membershipCollection.findByUserAndTenant(
|
|
1917
|
-
userId,
|
|
1918
|
-
tenantId
|
|
1919
|
-
);
|
|
1920
|
-
if (!membership || membership.status !== MembershipStatus.ACTIVE) {
|
|
1921
|
-
return "You are not a member of this tenant or it does not exist.";
|
|
1922
|
-
}
|
|
1923
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
1924
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
1925
|
-
);
|
|
1926
|
-
if (!ownerRole) {
|
|
1927
|
-
return "Owner role is not configured. Cannot determine deletion permissions.";
|
|
1928
|
-
}
|
|
1929
|
-
if (membership.roleId !== ownerRole.id) {
|
|
1930
|
-
return "Only the tenant owner can delete this tenant.";
|
|
1931
|
-
}
|
|
1932
|
-
if (this.policy.mode === "required") {
|
|
1933
|
-
const allMemberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1934
|
-
const ownerMemberships = allMemberships.filter(
|
|
1935
|
-
(m2) => m2.roleId === ownerRole.id
|
|
1936
|
-
);
|
|
1937
|
-
if (ownerMemberships.length <= 1) {
|
|
1938
|
-
return "Cannot delete your last tenant. Policy requires at least one tenant.";
|
|
1939
|
-
}
|
|
1940
|
-
}
|
|
1941
|
-
return null;
|
|
1942
|
-
}
|
|
1943
|
-
/**
|
|
1944
|
-
* Check if a user can delete a specific tenant
|
|
1945
|
-
*
|
|
1946
|
-
* Returns false if:
|
|
1947
|
-
* - User is not an active member
|
|
1948
|
-
* - User is not the owner
|
|
1949
|
-
* - Policy is 'required' and this is the last tenant
|
|
1950
|
-
*/
|
|
1951
|
-
async canDeleteTenant(userId, tenantId) {
|
|
1952
|
-
const error = await this.getDeleteTenantError(userId, tenantId);
|
|
1953
|
-
return error === null;
|
|
1954
|
-
}
|
|
1955
|
-
/**
|
|
1956
|
-
* Delete a tenant
|
|
1957
|
-
*
|
|
1958
|
-
* Note: This does not cascade delete related records (memberships, etc.).
|
|
1959
|
-
* The caller should handle cleanup of related data if needed.
|
|
1960
|
-
*
|
|
1961
|
-
* @throws Error if user cannot delete the tenant (with specific reason)
|
|
1962
|
-
*/
|
|
1963
|
-
async deleteTenant(userId, tenantId) {
|
|
1964
|
-
const error = await this.getDeleteTenantError(userId, tenantId);
|
|
1965
|
-
if (error) {
|
|
1966
|
-
throw new Error(error);
|
|
1967
|
-
}
|
|
1968
|
-
const tenant = await this.tenantCollection.get(tenantId);
|
|
1969
|
-
if (tenant) {
|
|
1970
|
-
await tenant.delete();
|
|
1971
|
-
}
|
|
1972
|
-
}
|
|
1973
|
-
/**
|
|
1974
|
-
* Ensure a tenant exists for the user based on policy
|
|
1975
|
-
*
|
|
1976
|
-
* Called during OIDC login to apply tenant policy:
|
|
1977
|
-
* - `flexible`: Returns first existing tenant or null (no auto-create)
|
|
1978
|
-
* - `personal`/`required`: Creates default tenant if none exists
|
|
1979
|
-
*
|
|
1980
|
-
* @param userId - The user ID
|
|
1981
|
-
* @param userInfo - User info for naming the auto-created tenant
|
|
1982
|
-
* @returns Tenant and membership (may be null in flexible mode)
|
|
1983
|
-
*/
|
|
1984
|
-
async ensureTenantForUser(userId, userInfo) {
|
|
1985
|
-
const memberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1986
|
-
if (memberships.length > 0) {
|
|
1987
|
-
const firstMembership = memberships[0];
|
|
1988
|
-
const tenant2 = await this.tenantCollection.get(
|
|
1989
|
-
firstMembership.tenantId
|
|
1990
|
-
);
|
|
1991
|
-
return {
|
|
1992
|
-
tenant: tenant2 ?? null,
|
|
1993
|
-
membership: firstMembership,
|
|
1994
|
-
created: false
|
|
1995
|
-
};
|
|
1996
|
-
}
|
|
1997
|
-
if (this.policy.mode === "flexible") {
|
|
1998
|
-
return {
|
|
1999
|
-
tenant: null,
|
|
2000
|
-
membership: null,
|
|
2001
|
-
created: false
|
|
2002
|
-
};
|
|
2003
|
-
}
|
|
2004
|
-
const tenantName = userInfo.name ? `${userInfo.name}'s Workspace` : this.policy.defaultName;
|
|
2005
|
-
const { tenant, membership } = await this.createTenantWithOwnership(
|
|
2006
|
-
userId,
|
|
2007
|
-
tenantName
|
|
2008
|
-
);
|
|
2009
|
-
return {
|
|
2010
|
-
tenant,
|
|
2011
|
-
membership,
|
|
2012
|
-
created: true
|
|
2013
|
-
};
|
|
2014
|
-
}
|
|
2015
|
-
/**
|
|
2016
|
-
* Get all tenants for a user (where they are owner)
|
|
2017
|
-
*/
|
|
2018
|
-
async getOwnedTenants(userId) {
|
|
2019
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
2020
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
2021
|
-
);
|
|
2022
|
-
if (!ownerRole) {
|
|
2023
|
-
return [];
|
|
2024
|
-
}
|
|
2025
|
-
const memberships = await this.membershipCollection.findActiveByUser(userId);
|
|
2026
|
-
const ownerMemberships = memberships.filter(
|
|
2027
|
-
(m2) => m2.roleId === ownerRole.id
|
|
2028
|
-
);
|
|
2029
|
-
const tenantPromises = ownerMemberships.map(
|
|
2030
|
-
(m2) => this.tenantCollection.get(m2.tenantId)
|
|
2031
|
-
);
|
|
2032
|
-
const tenantsOrNull = await Promise.all(tenantPromises);
|
|
2033
|
-
return tenantsOrNull.filter((tenant) => tenant !== null);
|
|
2034
|
-
}
|
|
2035
|
-
/**
|
|
2036
|
-
* Static factory method
|
|
2037
|
-
*/
|
|
2038
|
-
static async create(options, policy) {
|
|
2039
|
-
const service = new TenantService(options, policy);
|
|
2040
|
-
await service.initialize();
|
|
2041
|
-
return service;
|
|
2042
|
-
}
|
|
1489
|
+
const permissions = await PermissionCollection.create(options);
|
|
1490
|
+
if (!isProbablyPostgres(options.db ?? options.persistence, permissions.db)) throw new Error("applyPostgresPermissionPolicies() requires a Postgres database connection.");
|
|
1491
|
+
const result = generatePostgresPermissionSql(options);
|
|
1492
|
+
for (const statement of result.statements) try {
|
|
1493
|
+
await permissions.db.query(statement);
|
|
1494
|
+
} catch (error) {
|
|
1495
|
+
throw new Error(`Failed to apply Postgres permission policy statement:
|
|
1496
|
+
${statement}`, { cause: error });
|
|
1497
|
+
}
|
|
1498
|
+
return result;
|
|
2043
1499
|
}
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
1500
|
+
//#endregion
|
|
1501
|
+
//#region src/services/TenantService.ts
|
|
1502
|
+
var TenantService = class TenantService {
|
|
1503
|
+
options;
|
|
1504
|
+
policy;
|
|
1505
|
+
tenantCollection;
|
|
1506
|
+
membershipCollection;
|
|
1507
|
+
roleCollection;
|
|
1508
|
+
constructor(options, policy) {
|
|
1509
|
+
this.options = options;
|
|
1510
|
+
this.policy = policy ?? DEFAULT_TENANT_POLICY;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Initialize collections
|
|
1514
|
+
*/
|
|
1515
|
+
async initialize() {
|
|
1516
|
+
this.tenantCollection = await TenantCollection.create(this.options);
|
|
1517
|
+
this.membershipCollection = await MembershipCollection.create(this.options);
|
|
1518
|
+
this.roleCollection = await RoleCollection.create(this.options);
|
|
1519
|
+
await this.roleCollection.seedSystemRoles();
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Get the current policy
|
|
1523
|
+
*/
|
|
1524
|
+
getPolicy() {
|
|
1525
|
+
return { ...this.policy };
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* Create a tenant and make the user the owner
|
|
1529
|
+
*
|
|
1530
|
+
* @param userId - The user to make owner
|
|
1531
|
+
* @param name - Tenant name
|
|
1532
|
+
* @param options - Optional slug override
|
|
1533
|
+
* @returns The created tenant and membership
|
|
1534
|
+
*/
|
|
1535
|
+
async createTenantWithOwnership(userId, name, options) {
|
|
1536
|
+
if (!await this.canCreateTenant(userId)) throw new Error(`User has reached maximum tenant limit (${this.policy.maxTenants})`);
|
|
1537
|
+
const tenant = await this.tenantCollection.create({
|
|
1538
|
+
name,
|
|
1539
|
+
slug: options?.slug
|
|
1540
|
+
});
|
|
1541
|
+
await tenant.save();
|
|
1542
|
+
const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
|
|
1543
|
+
if (!ownerRole) throw new Error("Owner role not found - run seedSystemRoles first");
|
|
1544
|
+
const membership = await this.membershipCollection.create({
|
|
1545
|
+
userId,
|
|
1546
|
+
tenantId: tenant.id,
|
|
1547
|
+
roleId: ownerRole.id,
|
|
1548
|
+
status: MembershipStatus.ACTIVE
|
|
1549
|
+
});
|
|
1550
|
+
await membership.save();
|
|
1551
|
+
return {
|
|
1552
|
+
tenant,
|
|
1553
|
+
membership
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Check if a user can create a new tenant
|
|
1558
|
+
*
|
|
1559
|
+
* Returns false if maxTenants limit is reached (0 = unlimited)
|
|
1560
|
+
*/
|
|
1561
|
+
async canCreateTenant(userId) {
|
|
1562
|
+
if (this.policy.maxTenants === 0) return true;
|
|
1563
|
+
const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
|
|
1564
|
+
if (!ownerRole) return false;
|
|
1565
|
+
return (await this.membershipCollection.findActiveByUser(userId)).filter((m) => m.roleId === ownerRole.id).length < this.policy.maxTenants;
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* Get a specific error message explaining why a tenant cannot be deleted.
|
|
1569
|
+
*
|
|
1570
|
+
* @returns Error message if deletion is not allowed, or null if allowed.
|
|
1571
|
+
*/
|
|
1572
|
+
async getDeleteTenantError(userId, tenantId) {
|
|
1573
|
+
const membership = await this.membershipCollection.findByUserAndTenant(userId, tenantId);
|
|
1574
|
+
if (!membership || membership.status !== MembershipStatus.ACTIVE) return "You are not a member of this tenant or it does not exist.";
|
|
1575
|
+
const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
|
|
1576
|
+
if (!ownerRole) return "Owner role is not configured. Cannot determine deletion permissions.";
|
|
1577
|
+
if (membership.roleId !== ownerRole.id) return "Only the tenant owner can delete this tenant.";
|
|
1578
|
+
if (this.policy.mode === "required") {
|
|
1579
|
+
if ((await this.membershipCollection.findActiveByUser(userId)).filter((m) => m.roleId === ownerRole.id).length <= 1) return "Cannot delete your last tenant. Policy requires at least one tenant.";
|
|
1580
|
+
}
|
|
1581
|
+
return null;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* Check if a user can delete a specific tenant
|
|
1585
|
+
*
|
|
1586
|
+
* Returns false if:
|
|
1587
|
+
* - User is not an active member
|
|
1588
|
+
* - User is not the owner
|
|
1589
|
+
* - Policy is 'required' and this is the last tenant
|
|
1590
|
+
*/
|
|
1591
|
+
async canDeleteTenant(userId, tenantId) {
|
|
1592
|
+
return await this.getDeleteTenantError(userId, tenantId) === null;
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* Delete a tenant
|
|
1596
|
+
*
|
|
1597
|
+
* Note: This does not cascade delete related records (memberships, etc.).
|
|
1598
|
+
* The caller should handle cleanup of related data if needed.
|
|
1599
|
+
*
|
|
1600
|
+
* @throws Error if user cannot delete the tenant (with specific reason)
|
|
1601
|
+
*/
|
|
1602
|
+
async deleteTenant(userId, tenantId) {
|
|
1603
|
+
const error = await this.getDeleteTenantError(userId, tenantId);
|
|
1604
|
+
if (error) throw new Error(error);
|
|
1605
|
+
const tenant = await this.tenantCollection.get(tenantId);
|
|
1606
|
+
if (tenant) await tenant.delete();
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Ensure a tenant exists for the user based on policy
|
|
1610
|
+
*
|
|
1611
|
+
* Called during OIDC login to apply tenant policy:
|
|
1612
|
+
* - `flexible`: Returns first existing tenant or null (no auto-create)
|
|
1613
|
+
* - `personal`/`required`: Creates default tenant if none exists
|
|
1614
|
+
*
|
|
1615
|
+
* @param userId - The user ID
|
|
1616
|
+
* @param userInfo - User info for naming the auto-created tenant
|
|
1617
|
+
* @returns Tenant and membership (may be null in flexible mode)
|
|
1618
|
+
*/
|
|
1619
|
+
async ensureTenantForUser(userId, userInfo) {
|
|
1620
|
+
const memberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1621
|
+
if (memberships.length > 0) {
|
|
1622
|
+
const firstMembership = memberships[0];
|
|
1623
|
+
return {
|
|
1624
|
+
tenant: await this.tenantCollection.get(firstMembership.tenantId) ?? null,
|
|
1625
|
+
membership: firstMembership,
|
|
1626
|
+
created: false
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
if (this.policy.mode === "flexible") return {
|
|
1630
|
+
tenant: null,
|
|
1631
|
+
membership: null,
|
|
1632
|
+
created: false
|
|
1633
|
+
};
|
|
1634
|
+
const tenantName = userInfo.name ? `${userInfo.name}'s Workspace` : this.policy.defaultName;
|
|
1635
|
+
const { tenant, membership } = await this.createTenantWithOwnership(userId, tenantName);
|
|
1636
|
+
return {
|
|
1637
|
+
tenant,
|
|
1638
|
+
membership,
|
|
1639
|
+
created: true
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
/**
|
|
1643
|
+
* Get all tenants for a user (where they are owner)
|
|
1644
|
+
*/
|
|
1645
|
+
async getOwnedTenants(userId) {
|
|
1646
|
+
const ownerRole = await this.roleCollection.findBySlug(DEFAULT_ROLE_SLUGS.OWNER);
|
|
1647
|
+
if (!ownerRole) return [];
|
|
1648
|
+
const tenantPromises = (await this.membershipCollection.findActiveByUser(userId)).filter((m) => m.roleId === ownerRole.id).map((m) => this.tenantCollection.get(m.tenantId));
|
|
1649
|
+
return (await Promise.all(tenantPromises)).filter((tenant) => tenant !== null);
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Static factory method
|
|
1653
|
+
*/
|
|
1654
|
+
static async create(options, policy) {
|
|
1655
|
+
const service = new TenantService(options, policy);
|
|
1656
|
+
await service.initialize();
|
|
1657
|
+
return service;
|
|
1658
|
+
}
|
|
2122
1659
|
};
|
|
2123
|
-
//#
|
|
1660
|
+
//#endregion
|
|
1661
|
+
export { ACCESS_REQUEST_CAPABILITIES, AccessRequest, AccessRequestCollection, AccessRequestError, AccessRequestService, AccessRequestStatus, UsersCliAuthRequest as CliAuthRequest, UsersCliAuthRequest, UsersCliAuthRequestCollection as CliAuthRequestCollection, UsersCliAuthRequestCollection, DEFAULT_CLI_AUTH_POLL_INTERVAL_SECONDS, DEFAULT_CLI_AUTH_REQUEST_TTL_SECONDS, DEFAULT_CLI_SESSION_TTL_SECONDS, DEFAULT_ROLES, DEFAULT_ROLE_SLUGS, DEFAULT_SESSION_TTL, DEFAULT_TENANT_POLICY, DEFAULT_TOKEN_EXPIRY_SECONDS, Group, GroupCollection, GroupMember, GroupMemberCollection, GroupRole, GroupRoleCollection, MAX_TENANT_HIERARCHY_DEPTH, MagicLinkError, MagicLinkService, UsersMagicLinkToken as MagicLinkToken, UsersMagicLinkToken, UsersMagicLinkTokenCollection as MagicLinkTokenCollection, UsersMagicLinkTokenCollection, Membership, MembershipCollection, MembershipOverride, MembershipOverrideCollection, MembershipStatus, OidcLoginError, OidcLoginService, OverrideEffect, Permission, PermissionCatalogService, PermissionCollection, PermissionResolver, Role, RoleCollection, RolePermission, RolePermissionCollection, Session, SessionCollection, SessionService, SessionStatus, Tenant, TenantCollection, TenantHierarchyError, TenantPermissionEffect, TenantPermissionOverride, TenantPermissionOverrideCollection, TenantService, TenantStatus, TerminalAuthError, TerminalAuthRateLimitError, TerminalAuthService, User, UserCollection, UserStatus, applyPostgresPermissionPolicies, decodeOidcTransaction, encodeOidcTransaction, generatePostgresPermissionSql, generateSessionId, getCurrentSessionPermissionContext, getRequestScopedDatabase, getUsersOidcConfig, registerPermissionDefinitions, resolveOidcProviderConfig, syncPermissionCatalog, withSessionPermissionContext };
|
|
1662
|
+
|
|
1663
|
+
//# sourceMappingURL=index.js.map
|