@happyvertical/smrt-users 0.37.1 → 0.37.3
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/AGENTS.md +2 -1
- package/README.md +63 -0
- package/dist/chunks/TerminalAuthService-BXAAuaXf.js +4531 -0
- package/dist/chunks/TerminalAuthService-BXAAuaXf.js.map +1 -0
- package/dist/collections/AccessRequestCollection.d.ts +28 -0
- package/dist/collections/AccessRequestCollection.d.ts.map +1 -0
- package/dist/collections/index.d.ts +1 -0
- package/dist/collections/index.d.ts.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1571 -1322
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +421 -2
- package/dist/models/AccessRequest.d.ts +131 -0
- package/dist/models/AccessRequest.d.ts.map +1 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.d.ts.map +1 -1
- package/dist/playground.js +114 -136
- package/dist/playground.js.map +1 -1
- package/dist/services/AccessRequestService.d.ts +326 -0
- package/dist/services/AccessRequestService.d.ts.map +1 -0
- package/dist/services/index.d.ts +1 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/smrt-knowledge.json +129 -15
- package/dist/sveltekit.js +685 -881
- package/dist/sveltekit.js.map +1 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.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-bY1oWeAh.js +0 -5261
- package/dist/chunks/TerminalAuthService-bY1oWeAh.js.map +0 -1
- package/dist/chunks/index-DBpq-WMK.js +0 -169
- package/dist/chunks/index-DBpq-WMK.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,1414 +1,1663 @@
|
|
|
1
|
-
import { D as DEFAULT_ROLES,
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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";
|
|
3
|
+
import { createLogger } from "@happyvertical/logger";
|
|
4
4
|
import { getPackageConfig } from "@happyvertical/smrt-config";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
//#region src/models/AccessRequest.ts
|
|
7
|
+
var __defProp$2 = Object.defineProperty;
|
|
8
|
+
var __getOwnPropDesc$2 = Object.getOwnPropertyDescriptor;
|
|
9
|
+
var __decorateClass$2 = (decorators, target, key, kind) => {
|
|
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;
|
|
14
|
+
};
|
|
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
|
+
}
|
|
96
|
+
};
|
|
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
|
|
8
168
|
var __defProp$1 = Object.defineProperty;
|
|
9
169
|
var __getOwnPropDesc$1 = Object.getOwnPropertyDescriptor;
|
|
10
170
|
var __decorateClass$1 = (decorators, target, key, kind) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (kind && result) __defProp$1(target, key, result);
|
|
16
|
-
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;
|
|
17
175
|
};
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
return !this.used && !this.isExpired();
|
|
43
|
-
}
|
|
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
|
+
}
|
|
44
200
|
};
|
|
45
|
-
__decorateClass$1([
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
UsersMagicLinkToken
|
|
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
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
76
|
-
const { rowCount } = await this.db.query(
|
|
77
|
-
`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}
|
|
78
231
|
SET used = ?, updated_at = ?
|
|
79
|
-
WHERE nonce = ? AND used = ? AND expires_at > ?`,
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
let count = 0;
|
|
99
|
-
for (const token of tokens) {
|
|
100
|
-
await token.delete();
|
|
101
|
-
count++;
|
|
102
|
-
}
|
|
103
|
-
return count;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
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
|
|
106
251
|
var __defProp = Object.defineProperty;
|
|
107
252
|
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
108
253
|
var __decorateClass = (decorators, target, key, kind) => {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (kind && result) __defProp(target, key, result);
|
|
114
|
-
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;
|
|
115
258
|
};
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
`Cannot delete system role '${this.slug}'. System roles are protected.`
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
return super.delete();
|
|
169
|
-
}
|
|
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
|
+
}
|
|
170
308
|
};
|
|
171
|
-
__decorateClass([
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
/**
|
|
194
|
-
* Find roles available for a tenant (system + tenant-specific)
|
|
195
|
-
*/
|
|
196
|
-
async findByTenant(tenantId) {
|
|
197
|
-
return await this.query(
|
|
198
|
-
`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}
|
|
199
330
|
WHERE tenant_id IS NULL OR tenant_id = ?
|
|
200
|
-
ORDER BY is_system DESC, name ASC`,
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
+
}
|
|
382
|
+
};
|
|
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
|
|
371
938
|
function getRuntimePermissionRegistrations() {
|
|
372
|
-
|
|
373
|
-
|
|
939
|
+
globalThis.__smrtUsersPermissionRegistrations ??= /* @__PURE__ */ new Map();
|
|
940
|
+
return globalThis.__smrtUsersPermissionRegistrations;
|
|
374
941
|
}
|
|
375
942
|
function toSnakeCase$1(value) {
|
|
376
|
-
|
|
943
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
|
|
377
944
|
}
|
|
378
945
|
function pluralize(word) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (word.endsWith("s") || word.endsWith("x") || word.endsWith("z") || word.endsWith("ch") || word.endsWith("sh")) {
|
|
383
|
-
return `${word}es`;
|
|
384
|
-
}
|
|
385
|
-
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`;
|
|
386
949
|
}
|
|
387
950
|
function deriveCollectionName(className) {
|
|
388
|
-
|
|
951
|
+
return pluralize(toSnakeCase$1(className));
|
|
389
952
|
}
|
|
390
953
|
function humanizeResource(resource) {
|
|
391
|
-
|
|
954
|
+
return resource.replace(/[_-]+/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
392
955
|
}
|
|
393
956
|
function capitalize(value) {
|
|
394
|
-
|
|
957
|
+
return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
|
|
395
958
|
}
|
|
396
959
|
function defaultPermissionName(slug) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
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)}`;
|
|
402
963
|
}
|
|
403
964
|
function defaultPermissionDescription(slug) {
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
}
|
|
408
|
-
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()}`;
|
|
409
968
|
}
|
|
410
969
|
function isCollectionManifestEntry(objectDef) {
|
|
411
|
-
|
|
970
|
+
return objectDef?.extends === "SmrtCollection" || objectDef?.extendsTypeArg !== void 0;
|
|
412
971
|
}
|
|
413
972
|
function getPublicCustomMethodNames(methodEntries, standardActions) {
|
|
414
|
-
|
|
415
|
-
new Set(
|
|
416
|
-
methodEntries.filter(
|
|
417
|
-
(method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)
|
|
418
|
-
).map((method) => method.name)
|
|
419
|
-
)
|
|
420
|
-
);
|
|
973
|
+
return Array.from(new Set(methodEntries.filter((method) => Boolean(method?.name) && method?.isPublic === true && !standardActions.includes(method.name)).map((method) => method.name)));
|
|
421
974
|
}
|
|
422
975
|
function getCustomMethodExposureNames(config, availableCustomMethods) {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const exclude = Array.isArray(rawExclude) ? [...rawExclude] : [];
|
|
433
|
-
if (!include) {
|
|
434
|
-
return new Set(
|
|
435
|
-
availableCustomMethods.filter(
|
|
436
|
-
(methodName) => !exclude.includes(methodName)
|
|
437
|
-
)
|
|
438
|
-
);
|
|
439
|
-
}
|
|
440
|
-
const baseMethods = include.filter(
|
|
441
|
-
(methodName) => availableCustomMethods.includes(methodName)
|
|
442
|
-
);
|
|
443
|
-
return new Set(
|
|
444
|
-
baseMethods.filter((methodName) => !exclude.includes(methodName))
|
|
445
|
-
);
|
|
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)));
|
|
446
985
|
}
|
|
447
986
|
function isOperationEnabled(config, action) {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
if (exclude.includes(action)) {
|
|
459
|
-
return false;
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
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;
|
|
463
996
|
}
|
|
464
997
|
function normalizePostgresAction(action) {
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
}
|
|
469
|
-
throw new Error(
|
|
470
|
-
`Unsupported Postgres permission action '${action}'. Expected SELECT, INSERT, UPDATE, or DELETE.`
|
|
471
|
-
);
|
|
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.`);
|
|
472
1001
|
}
|
|
473
1002
|
function normalizeBinding(binding, fallbackPermission) {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
+
};
|
|
481
1010
|
}
|
|
482
1011
|
function mergeStringField(fieldName, existing, incoming, slug) {
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
}
|
|
492
|
-
if (existingValue !== incomingValue) {
|
|
493
|
-
throw new Error(
|
|
494
|
-
`Conflicting permission metadata for '${slug}' field '${fieldName}': '${existingValue}' !== '${incomingValue}'`
|
|
495
|
-
);
|
|
496
|
-
}
|
|
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}'`);
|
|
497
1020
|
}
|
|
498
1021
|
function mergeBindings(existing, incoming) {
|
|
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
|
-
if (!seen.has(key)) {
|
|
526
|
-
seen.add(key);
|
|
527
|
-
mergedBindings.push(normalized);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
existing.postgres = {
|
|
531
|
-
bindings: mergedBindings
|
|
532
|
-
};
|
|
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 };
|
|
533
1048
|
}
|
|
534
1049
|
function normalizeDefinition(definition, source) {
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
bindings: definition.postgres.bindings.map(
|
|
549
|
-
(binding) => normalizeBinding(binding, slug)
|
|
550
|
-
)
|
|
551
|
-
} : void 0,
|
|
552
|
-
qualifiedName: definition.qualifiedName,
|
|
553
|
-
slug,
|
|
554
|
-
source
|
|
555
|
-
};
|
|
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
|
+
};
|
|
556
1063
|
}
|
|
557
1064
|
function mergeDefinitionSet(current, incomingDefinitions, source) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
+
}
|
|
573
1080
|
}
|
|
574
1081
|
function registerPermissionDefinitions(definitions) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
}
|
|
582
|
-
class PermissionCatalogService {
|
|
583
|
-
constructor(options = {}) {
|
|
584
|
-
this.options = options;
|
|
585
|
-
}
|
|
586
|
-
options;
|
|
587
|
-
getUsersConfig() {
|
|
588
|
-
return getPackageConfig("users", {});
|
|
589
|
-
}
|
|
590
|
-
getRuntimePermissionDefinitions() {
|
|
591
|
-
return Array.from(getRuntimePermissionRegistrations().values()).flat();
|
|
592
|
-
}
|
|
593
|
-
getCustomPermissionDefinitions() {
|
|
594
|
-
return this.getUsersConfig().permissions?.custom ?? [];
|
|
595
|
-
}
|
|
596
|
-
getCatalog() {
|
|
597
|
-
const manifestPermissions = this.getManifestPermissionDefinitions();
|
|
598
|
-
const customPermissions = this.getCustomPermissionDefinitions();
|
|
599
|
-
const runtimePermissions = this.getRuntimePermissionDefinitions();
|
|
600
|
-
const merged = /* @__PURE__ */ new Map();
|
|
601
|
-
mergeDefinitionSet(merged, manifestPermissions, "manifest");
|
|
602
|
-
mergeDefinitionSet(merged, customPermissions, "config");
|
|
603
|
-
mergeDefinitionSet(merged, runtimePermissions, "runtime");
|
|
604
|
-
return {
|
|
605
|
-
customPermissions: customPermissions.map(
|
|
606
|
-
(definition) => normalizeDefinition(definition, "config")
|
|
607
|
-
),
|
|
608
|
-
manifestPermissions: manifestPermissions.map(
|
|
609
|
-
(definition) => normalizeDefinition(definition, "manifest")
|
|
610
|
-
),
|
|
611
|
-
permissions: Array.from(merged.values()).sort(
|
|
612
|
-
(left, right) => left.slug.localeCompare(right.slug)
|
|
613
|
-
),
|
|
614
|
-
runtimePermissions: runtimePermissions.map(
|
|
615
|
-
(definition) => normalizeDefinition(definition, "runtime")
|
|
616
|
-
)
|
|
617
|
-
};
|
|
618
|
-
}
|
|
619
|
-
async syncPermissionCatalog() {
|
|
620
|
-
const catalog = this.getCatalog();
|
|
621
|
-
const permissions = await PermissionCollection.create(this.options);
|
|
622
|
-
const created = [];
|
|
623
|
-
const unchanged = [];
|
|
624
|
-
const updated = [];
|
|
625
|
-
for (const definition of catalog.permissions) {
|
|
626
|
-
const existing = await permissions.findBySlug(definition.slug);
|
|
627
|
-
if (!existing) {
|
|
628
|
-
const permission = await permissions.create({
|
|
629
|
-
category: definition.category ?? parsePermissionSlug(definition.slug).resource,
|
|
630
|
-
description: definition.description ?? "",
|
|
631
|
-
name: definition.name ?? definition.slug,
|
|
632
|
-
slug: definition.slug
|
|
633
|
-
});
|
|
634
|
-
await permission.save();
|
|
635
|
-
created.push(definition.slug);
|
|
636
|
-
continue;
|
|
637
|
-
}
|
|
638
|
-
const nextName = definition.name ?? existing.name;
|
|
639
|
-
const nextDescription = definition.description ?? existing.description;
|
|
640
|
-
const nextCategory = definition.category ?? existing.category;
|
|
641
|
-
if (existing.name === nextName && existing.description === nextDescription && existing.category === nextCategory) {
|
|
642
|
-
unchanged.push(definition.slug);
|
|
643
|
-
continue;
|
|
644
|
-
}
|
|
645
|
-
existing.name = nextName;
|
|
646
|
-
existing.description = nextDescription;
|
|
647
|
-
existing.category = nextCategory;
|
|
648
|
-
await existing.save();
|
|
649
|
-
updated.push(definition.slug);
|
|
650
|
-
}
|
|
651
|
-
return {
|
|
652
|
-
catalog,
|
|
653
|
-
created,
|
|
654
|
-
unchanged,
|
|
655
|
-
updated
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
getManifestPermissionDefinitions() {
|
|
659
|
-
const standardActions = ["list", "get", "create", "update", "delete"];
|
|
660
|
-
const definitions = /* @__PURE__ */ new Map();
|
|
661
|
-
for (const metadata of ObjectRegistry.getAllObjectMetadata()) {
|
|
662
|
-
const registered = ObjectRegistry.getClassByConstructor(metadata.constructor) ?? ObjectRegistry.getClass(metadata.name);
|
|
663
|
-
const manifestEntry = registered?.qualifiedName ? findManifestEntryByQualifiedName(registered.qualifiedName) : void 0;
|
|
664
|
-
if (isCollectionManifestEntry(manifestEntry)) {
|
|
665
|
-
continue;
|
|
666
|
-
}
|
|
667
|
-
const className = metadata.name;
|
|
668
|
-
const qualifiedName = registered?.qualifiedName;
|
|
669
|
-
const objectConfig = manifestEntry?.decoratorConfig ?? metadata.config;
|
|
670
|
-
const rawCollection = objectConfig?.collection;
|
|
671
|
-
const configuredCollection = typeof rawCollection === "string" && rawCollection.length > 0 ? rawCollection : void 0;
|
|
672
|
-
const collection = configuredCollection ?? manifestEntry?.collection ?? deriveCollectionName(metadata.name);
|
|
673
|
-
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");
|
|
674
|
-
if (readExposed) {
|
|
675
|
-
definitions.set(`${collection}.read`, {
|
|
676
|
-
className,
|
|
677
|
-
collection,
|
|
678
|
-
qualifiedName,
|
|
679
|
-
slug: `${collection}.read`
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
for (const action of ["create", "update", "delete"]) {
|
|
683
|
-
const exposed = isOperationEnabled(objectConfig.api, action) || isOperationEnabled(objectConfig.cli, action) || isOperationEnabled(objectConfig.mcp, action);
|
|
684
|
-
if (!exposed) {
|
|
685
|
-
continue;
|
|
686
|
-
}
|
|
687
|
-
definitions.set(`${collection}.${action}`, {
|
|
688
|
-
className,
|
|
689
|
-
collection,
|
|
690
|
-
qualifiedName,
|
|
691
|
-
slug: `${collection}.${action}`
|
|
692
|
-
});
|
|
693
|
-
}
|
|
694
|
-
const methodEntries = manifestEntry?.methods ? Object.values(manifestEntry.methods) : Array.from(metadata.methods.values());
|
|
695
|
-
const publicCustomMethodNames = getPublicCustomMethodNames(
|
|
696
|
-
methodEntries,
|
|
697
|
-
standardActions
|
|
698
|
-
);
|
|
699
|
-
const customApiMethods = /* @__PURE__ */ new Set();
|
|
700
|
-
const customCliMethods = getCustomMethodExposureNames(
|
|
701
|
-
objectConfig.cli,
|
|
702
|
-
publicCustomMethodNames
|
|
703
|
-
);
|
|
704
|
-
const customMcpMethods = getCustomMethodExposureNames(
|
|
705
|
-
objectConfig.mcp,
|
|
706
|
-
publicCustomMethodNames
|
|
707
|
-
);
|
|
708
|
-
for (const methodName of publicCustomMethodNames) {
|
|
709
|
-
if (isOperationEnabled(objectConfig.api, methodName)) {
|
|
710
|
-
customApiMethods.add(methodName);
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
const customMethods = /* @__PURE__ */ new Set([
|
|
714
|
-
...customApiMethods,
|
|
715
|
-
...customCliMethods,
|
|
716
|
-
...customMcpMethods
|
|
717
|
-
]);
|
|
718
|
-
for (const methodName of customMethods) {
|
|
719
|
-
definitions.set(`${collection}.${methodName}`, {
|
|
720
|
-
className,
|
|
721
|
-
collection,
|
|
722
|
-
description: `Allows ${methodName} on ${humanizeResource(collection).toLowerCase()}`,
|
|
723
|
-
name: `${capitalize(methodName)} ${humanizeResource(collection)}`,
|
|
724
|
-
qualifiedName,
|
|
725
|
-
slug: `${collection}.${methodName}`
|
|
726
|
-
});
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
return Array.from(definitions.values()).sort(
|
|
730
|
-
(left, right) => left.slug.localeCompare(right.slug)
|
|
731
|
-
);
|
|
732
|
-
}
|
|
733
|
-
static create(options = {}) {
|
|
734
|
-
return new PermissionCatalogService(options);
|
|
735
|
-
}
|
|
1082
|
+
globalThis.__smrtUsersPermissionRegistrationCounter = (globalThis.__smrtUsersPermissionRegistrationCounter ?? 0) + 1;
|
|
1083
|
+
const registrationId = globalThis.__smrtUsersPermissionRegistrationCounter;
|
|
1084
|
+
getRuntimePermissionRegistrations().set(registrationId, definitions);
|
|
1085
|
+
return () => {
|
|
1086
|
+
getRuntimePermissionRegistrations().delete(registrationId);
|
|
1087
|
+
};
|
|
736
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
|
+
};
|
|
737
1218
|
async function syncPermissionCatalog(options = {}) {
|
|
738
|
-
|
|
1219
|
+
return PermissionCatalogService.create(options).syncPermissionCatalog();
|
|
739
1220
|
}
|
|
1221
|
+
//#endregion
|
|
1222
|
+
//#region src/services/PostgresPermissionPolicies.ts
|
|
740
1223
|
function toSnakeCase(value) {
|
|
741
|
-
|
|
1224
|
+
return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[-\s]+/g, "_").toLowerCase();
|
|
742
1225
|
}
|
|
743
1226
|
function quoteIdent(identifier) {
|
|
744
|
-
|
|
1227
|
+
return `"${identifier.replaceAll("\"", "\"\"")}"`;
|
|
745
1228
|
}
|
|
746
1229
|
function quoteLiteral(value) {
|
|
747
|
-
|
|
1230
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
748
1231
|
}
|
|
749
1232
|
function isProbablyPostgres(configDb, database) {
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
if (typeof database.url === "string" && database.url.startsWith("postgres")) {
|
|
754
|
-
return true;
|
|
755
|
-
}
|
|
756
|
-
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");
|
|
757
1236
|
}
|
|
758
1237
|
function normalizePostgresPermissionAction(action) {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
}
|
|
763
|
-
throw new Error(
|
|
764
|
-
`Invalid Postgres permission binding action "${action}". Expected one of SELECT, INSERT, UPDATE, DELETE.`
|
|
765
|
-
);
|
|
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.`);
|
|
766
1241
|
}
|
|
767
1242
|
function normalizePostgresPermissionBinding(binding, fallbackPermission) {
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
}
|
|
780
|
-
return {
|
|
781
|
-
action: normalizePostgresPermissionAction(binding.action),
|
|
782
|
-
permission,
|
|
783
|
-
schemaName: binding.schemaName,
|
|
784
|
-
tableName,
|
|
785
|
-
tenantField: binding.tenantField
|
|
786
|
-
};
|
|
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
|
+
};
|
|
787
1254
|
}
|
|
788
1255
|
function parseTableReference(binding) {
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
+
};
|
|
800
1267
|
}
|
|
801
1268
|
function buildPolicyName(tableName, action) {
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const tableSegment = (sanitizedTable || "table").slice(
|
|
809
|
-
0,
|
|
810
|
-
Math.max(maxTableSegmentLength, 1)
|
|
811
|
-
);
|
|
812
|
-
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}`;
|
|
813
1275
|
}
|
|
814
1276
|
function buildPermissionExpression(permissionSlugs) {
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
}
|
|
818
|
-
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 ");
|
|
819
1279
|
}
|
|
820
1280
|
function buildTenantMatchExpression(tenantField) {
|
|
821
|
-
|
|
1281
|
+
return `${quoteIdent(tenantField)}::text = smrt_current_tenant_id()`;
|
|
822
1282
|
}
|
|
823
1283
|
function buildSelectPolicySql(target, permissions) {
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
829
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR SELECT USING (${condition})`
|
|
830
|
-
];
|
|
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})`];
|
|
831
1288
|
}
|
|
832
1289
|
function buildInsertPolicySql(target, permissions) {
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
838
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR INSERT WITH CHECK (${condition})`
|
|
839
|
-
];
|
|
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})`];
|
|
840
1294
|
}
|
|
841
1295
|
function buildUpdatePolicySql(target, permissions) {
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
847
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR UPDATE USING (${condition}) WITH CHECK (${condition})`
|
|
848
|
-
];
|
|
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})`];
|
|
849
1300
|
}
|
|
850
1301
|
function buildDeletePolicySql(target, permissions) {
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
`DROP POLICY IF EXISTS ${quoteIdent(policyName)} ON ${qualifiedTable}`,
|
|
856
|
-
`CREATE POLICY ${quoteIdent(policyName)} ON ${qualifiedTable} FOR DELETE USING (${condition})`
|
|
857
|
-
];
|
|
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})`];
|
|
858
1306
|
}
|
|
859
1307
|
function buildHelperStatements() {
|
|
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
|
-
|
|
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
|
+
];
|
|
891
1339
|
}
|
|
892
1340
|
function addBindingToTarget(targets, binding, source = {}) {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
const permissions = target.actions.get(action) ?? /* @__PURE__ */ new Set();
|
|
911
|
-
if (binding.permission) {
|
|
912
|
-
permissions.add(binding.permission);
|
|
913
|
-
}
|
|
914
|
-
target.actions.set(action, permissions);
|
|
915
|
-
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);
|
|
916
1358
|
}
|
|
917
1359
|
function generatePostgresPermissionSql(options = {}) {
|
|
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
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
explicitBindings.push(normalizePostgresPermissionBinding(binding));
|
|
1042
|
-
}
|
|
1043
|
-
for (const binding of explicitBindings) {
|
|
1044
|
-
addBindingToTarget(candidateTargets, binding);
|
|
1045
|
-
}
|
|
1046
|
-
const statements = [...buildHelperStatements()];
|
|
1047
|
-
const targets = Array.from(candidateTargets.values()).sort(
|
|
1048
|
-
(left, right) => `${left.schemaName}.${left.tableName}`.localeCompare(
|
|
1049
|
-
`${right.schemaName}.${right.tableName}`
|
|
1050
|
-
)
|
|
1051
|
-
).map((target) => ({
|
|
1052
|
-
actions: Object.fromEntries(
|
|
1053
|
-
Array.from(target.actions.entries()).map(([action, permissions]) => [
|
|
1054
|
-
action,
|
|
1055
|
-
Array.from(permissions).sort()
|
|
1056
|
-
])
|
|
1057
|
-
),
|
|
1058
|
-
className: target.className,
|
|
1059
|
-
collection: target.collection,
|
|
1060
|
-
qualifiedName: target.qualifiedName,
|
|
1061
|
-
schemaName: target.schemaName,
|
|
1062
|
-
tableName: target.tableName,
|
|
1063
|
-
tenantField: target.tenantField
|
|
1064
|
-
}));
|
|
1065
|
-
for (const target of Array.from(candidateTargets.values())) {
|
|
1066
|
-
const qualifiedTable = `${quoteIdent(target.schemaName)}.${quoteIdent(target.tableName)}`;
|
|
1067
|
-
statements.push(`ALTER TABLE ${qualifiedTable} ENABLE ROW LEVEL SECURITY`);
|
|
1068
|
-
statements.push(`ALTER TABLE ${qualifiedTable} FORCE ROW LEVEL SECURITY`);
|
|
1069
|
-
const selectPermissions = Array.from(
|
|
1070
|
-
target.actions.get("SELECT") ?? []
|
|
1071
|
-
).sort();
|
|
1072
|
-
const insertPermissions = Array.from(
|
|
1073
|
-
target.actions.get("INSERT") ?? []
|
|
1074
|
-
).sort();
|
|
1075
|
-
const updatePermissions = Array.from(
|
|
1076
|
-
target.actions.get("UPDATE") ?? []
|
|
1077
|
-
).sort();
|
|
1078
|
-
const deletePermissions = Array.from(
|
|
1079
|
-
target.actions.get("DELETE") ?? []
|
|
1080
|
-
).sort();
|
|
1081
|
-
if (selectPermissions.length > 0) {
|
|
1082
|
-
statements.push(...buildSelectPolicySql(target, selectPermissions));
|
|
1083
|
-
}
|
|
1084
|
-
if (insertPermissions.length > 0) {
|
|
1085
|
-
statements.push(...buildInsertPolicySql(target, insertPermissions));
|
|
1086
|
-
}
|
|
1087
|
-
if (updatePermissions.length > 0) {
|
|
1088
|
-
statements.push(...buildUpdatePolicySql(target, updatePermissions));
|
|
1089
|
-
}
|
|
1090
|
-
if (deletePermissions.length > 0) {
|
|
1091
|
-
statements.push(...buildDeletePolicySql(target, deletePermissions));
|
|
1092
|
-
}
|
|
1093
|
-
}
|
|
1094
|
-
return {
|
|
1095
|
-
bindings: explicitBindings,
|
|
1096
|
-
skipped,
|
|
1097
|
-
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")};
|
|
1098
1483
|
`,
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1484
|
+
statements,
|
|
1485
|
+
targets
|
|
1486
|
+
};
|
|
1102
1487
|
}
|
|
1103
1488
|
async function applyPostgresPermissionPolicies(options = {}) {
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
await permissions.db.query(statement);
|
|
1115
|
-
} catch (error) {
|
|
1116
|
-
throw new Error(
|
|
1117
|
-
`Failed to apply Postgres permission policy statement:
|
|
1118
|
-
${statement}`,
|
|
1119
|
-
{
|
|
1120
|
-
cause: error
|
|
1121
|
-
}
|
|
1122
|
-
);
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
return result;
|
|
1126
|
-
}
|
|
1127
|
-
class TenantService {
|
|
1128
|
-
options;
|
|
1129
|
-
policy;
|
|
1130
|
-
tenantCollection;
|
|
1131
|
-
membershipCollection;
|
|
1132
|
-
roleCollection;
|
|
1133
|
-
constructor(options, policy) {
|
|
1134
|
-
this.options = options;
|
|
1135
|
-
this.policy = policy ?? DEFAULT_TENANT_POLICY;
|
|
1136
|
-
}
|
|
1137
|
-
/**
|
|
1138
|
-
* Initialize collections
|
|
1139
|
-
*/
|
|
1140
|
-
async initialize() {
|
|
1141
|
-
this.tenantCollection = await TenantCollection.create(this.options);
|
|
1142
|
-
this.membershipCollection = await MembershipCollection.create(this.options);
|
|
1143
|
-
this.roleCollection = await RoleCollection.create(this.options);
|
|
1144
|
-
await this.roleCollection.seedSystemRoles();
|
|
1145
|
-
}
|
|
1146
|
-
/**
|
|
1147
|
-
* Get the current policy
|
|
1148
|
-
*/
|
|
1149
|
-
getPolicy() {
|
|
1150
|
-
return { ...this.policy };
|
|
1151
|
-
}
|
|
1152
|
-
/**
|
|
1153
|
-
* Create a tenant and make the user the owner
|
|
1154
|
-
*
|
|
1155
|
-
* @param userId - The user to make owner
|
|
1156
|
-
* @param name - Tenant name
|
|
1157
|
-
* @param options - Optional slug override
|
|
1158
|
-
* @returns The created tenant and membership
|
|
1159
|
-
*/
|
|
1160
|
-
async createTenantWithOwnership(userId, name, options) {
|
|
1161
|
-
if (!await this.canCreateTenant(userId)) {
|
|
1162
|
-
throw new Error(
|
|
1163
|
-
`User has reached maximum tenant limit (${this.policy.maxTenants})`
|
|
1164
|
-
);
|
|
1165
|
-
}
|
|
1166
|
-
const tenant = await this.tenantCollection.create({
|
|
1167
|
-
name,
|
|
1168
|
-
slug: options?.slug
|
|
1169
|
-
});
|
|
1170
|
-
await tenant.save();
|
|
1171
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
1172
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
1173
|
-
);
|
|
1174
|
-
if (!ownerRole) {
|
|
1175
|
-
throw new Error("Owner role not found - run seedSystemRoles first");
|
|
1176
|
-
}
|
|
1177
|
-
const membership = await this.membershipCollection.create({
|
|
1178
|
-
userId,
|
|
1179
|
-
tenantId: tenant.id,
|
|
1180
|
-
roleId: ownerRole.id,
|
|
1181
|
-
status: MembershipStatus.ACTIVE
|
|
1182
|
-
});
|
|
1183
|
-
await membership.save();
|
|
1184
|
-
return { tenant, membership };
|
|
1185
|
-
}
|
|
1186
|
-
/**
|
|
1187
|
-
* Check if a user can create a new tenant
|
|
1188
|
-
*
|
|
1189
|
-
* Returns false if maxTenants limit is reached (0 = unlimited)
|
|
1190
|
-
*/
|
|
1191
|
-
async canCreateTenant(userId) {
|
|
1192
|
-
if (this.policy.maxTenants === 0) {
|
|
1193
|
-
return true;
|
|
1194
|
-
}
|
|
1195
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
1196
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
1197
|
-
);
|
|
1198
|
-
if (!ownerRole) {
|
|
1199
|
-
return false;
|
|
1200
|
-
}
|
|
1201
|
-
const memberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1202
|
-
const ownedCount = memberships.filter(
|
|
1203
|
-
(m2) => m2.roleId === ownerRole.id
|
|
1204
|
-
).length;
|
|
1205
|
-
return ownedCount < this.policy.maxTenants;
|
|
1206
|
-
}
|
|
1207
|
-
/**
|
|
1208
|
-
* Get a specific error message explaining why a tenant cannot be deleted.
|
|
1209
|
-
*
|
|
1210
|
-
* @returns Error message if deletion is not allowed, or null if allowed.
|
|
1211
|
-
*/
|
|
1212
|
-
async getDeleteTenantError(userId, tenantId) {
|
|
1213
|
-
const membership = await this.membershipCollection.findByUserAndTenant(
|
|
1214
|
-
userId,
|
|
1215
|
-
tenantId
|
|
1216
|
-
);
|
|
1217
|
-
if (!membership || membership.status !== MembershipStatus.ACTIVE) {
|
|
1218
|
-
return "You are not a member of this tenant or it does not exist.";
|
|
1219
|
-
}
|
|
1220
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
1221
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
1222
|
-
);
|
|
1223
|
-
if (!ownerRole) {
|
|
1224
|
-
return "Owner role is not configured. Cannot determine deletion permissions.";
|
|
1225
|
-
}
|
|
1226
|
-
if (membership.roleId !== ownerRole.id) {
|
|
1227
|
-
return "Only the tenant owner can delete this tenant.";
|
|
1228
|
-
}
|
|
1229
|
-
if (this.policy.mode === "required") {
|
|
1230
|
-
const allMemberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1231
|
-
const ownerMemberships = allMemberships.filter(
|
|
1232
|
-
(m2) => m2.roleId === ownerRole.id
|
|
1233
|
-
);
|
|
1234
|
-
if (ownerMemberships.length <= 1) {
|
|
1235
|
-
return "Cannot delete your last tenant. Policy requires at least one tenant.";
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
return null;
|
|
1239
|
-
}
|
|
1240
|
-
/**
|
|
1241
|
-
* Check if a user can delete a specific tenant
|
|
1242
|
-
*
|
|
1243
|
-
* Returns false if:
|
|
1244
|
-
* - User is not an active member
|
|
1245
|
-
* - User is not the owner
|
|
1246
|
-
* - Policy is 'required' and this is the last tenant
|
|
1247
|
-
*/
|
|
1248
|
-
async canDeleteTenant(userId, tenantId) {
|
|
1249
|
-
const error = await this.getDeleteTenantError(userId, tenantId);
|
|
1250
|
-
return error === null;
|
|
1251
|
-
}
|
|
1252
|
-
/**
|
|
1253
|
-
* Delete a tenant
|
|
1254
|
-
*
|
|
1255
|
-
* Note: This does not cascade delete related records (memberships, etc.).
|
|
1256
|
-
* The caller should handle cleanup of related data if needed.
|
|
1257
|
-
*
|
|
1258
|
-
* @throws Error if user cannot delete the tenant (with specific reason)
|
|
1259
|
-
*/
|
|
1260
|
-
async deleteTenant(userId, tenantId) {
|
|
1261
|
-
const error = await this.getDeleteTenantError(userId, tenantId);
|
|
1262
|
-
if (error) {
|
|
1263
|
-
throw new Error(error);
|
|
1264
|
-
}
|
|
1265
|
-
const tenant = await this.tenantCollection.get(tenantId);
|
|
1266
|
-
if (tenant) {
|
|
1267
|
-
await tenant.delete();
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
/**
|
|
1271
|
-
* Ensure a tenant exists for the user based on policy
|
|
1272
|
-
*
|
|
1273
|
-
* Called during OIDC login to apply tenant policy:
|
|
1274
|
-
* - `flexible`: Returns first existing tenant or null (no auto-create)
|
|
1275
|
-
* - `personal`/`required`: Creates default tenant if none exists
|
|
1276
|
-
*
|
|
1277
|
-
* @param userId - The user ID
|
|
1278
|
-
* @param userInfo - User info for naming the auto-created tenant
|
|
1279
|
-
* @returns Tenant and membership (may be null in flexible mode)
|
|
1280
|
-
*/
|
|
1281
|
-
async ensureTenantForUser(userId, userInfo) {
|
|
1282
|
-
const memberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1283
|
-
if (memberships.length > 0) {
|
|
1284
|
-
const firstMembership = memberships[0];
|
|
1285
|
-
const tenant2 = await this.tenantCollection.get(
|
|
1286
|
-
firstMembership.tenantId
|
|
1287
|
-
);
|
|
1288
|
-
return {
|
|
1289
|
-
tenant: tenant2 ?? null,
|
|
1290
|
-
membership: firstMembership,
|
|
1291
|
-
created: false
|
|
1292
|
-
};
|
|
1293
|
-
}
|
|
1294
|
-
if (this.policy.mode === "flexible") {
|
|
1295
|
-
return {
|
|
1296
|
-
tenant: null,
|
|
1297
|
-
membership: null,
|
|
1298
|
-
created: false
|
|
1299
|
-
};
|
|
1300
|
-
}
|
|
1301
|
-
const tenantName = userInfo.name ? `${userInfo.name}'s Workspace` : this.policy.defaultName;
|
|
1302
|
-
const { tenant, membership } = await this.createTenantWithOwnership(
|
|
1303
|
-
userId,
|
|
1304
|
-
tenantName
|
|
1305
|
-
);
|
|
1306
|
-
return {
|
|
1307
|
-
tenant,
|
|
1308
|
-
membership,
|
|
1309
|
-
created: true
|
|
1310
|
-
};
|
|
1311
|
-
}
|
|
1312
|
-
/**
|
|
1313
|
-
* Get all tenants for a user (where they are owner)
|
|
1314
|
-
*/
|
|
1315
|
-
async getOwnedTenants(userId) {
|
|
1316
|
-
const ownerRole = await this.roleCollection.findBySlug(
|
|
1317
|
-
DEFAULT_ROLE_SLUGS.OWNER
|
|
1318
|
-
);
|
|
1319
|
-
if (!ownerRole) {
|
|
1320
|
-
return [];
|
|
1321
|
-
}
|
|
1322
|
-
const memberships = await this.membershipCollection.findActiveByUser(userId);
|
|
1323
|
-
const ownerMemberships = memberships.filter(
|
|
1324
|
-
(m2) => m2.roleId === ownerRole.id
|
|
1325
|
-
);
|
|
1326
|
-
const tenantPromises = ownerMemberships.map(
|
|
1327
|
-
(m2) => this.tenantCollection.get(m2.tenantId)
|
|
1328
|
-
);
|
|
1329
|
-
const tenantsOrNull = await Promise.all(tenantPromises);
|
|
1330
|
-
return tenantsOrNull.filter((tenant) => tenant !== null);
|
|
1331
|
-
}
|
|
1332
|
-
/**
|
|
1333
|
-
* Static factory method
|
|
1334
|
-
*/
|
|
1335
|
-
static async create(options, policy) {
|
|
1336
|
-
const service = new TenantService(options, policy);
|
|
1337
|
-
await service.initialize();
|
|
1338
|
-
return service;
|
|
1339
|
-
}
|
|
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;
|
|
1340
1499
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
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
|
+
}
|
|
1413
1659
|
};
|
|
1414
|
-
//#
|
|
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
|