@objectstack/plugin-security 4.0.4 → 4.0.5
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/README.md +97 -27
- package/dist/index.d.mts +4160 -563
- package/dist/index.d.ts +4160 -563
- package/dist/index.js +745 -183
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +743 -181
- package/dist/index.mjs.map +1 -1
- package/package.json +33 -6
- package/.turbo/turbo-build.log +0 -22
- package/CHANGELOG.md +0 -264
- package/src/field-masker.ts +0 -75
- package/src/index.ts +0 -16
- package/src/objects/index.ts +0 -10
- package/src/objects/sys-permission-set.object.ts +0 -94
- package/src/objects/sys-role.object.ts +0 -93
- package/src/permission-evaluator.ts +0 -112
- package/src/rls-compiler.ts +0 -143
- package/src/security-plugin.test.ts +0 -302
- package/src/security-plugin.ts +0 -181
- package/tsconfig.json +0 -18
package/dist/index.mjs
CHANGED
|
@@ -17,7 +17,7 @@ var PermissionEvaluator = class {
|
|
|
17
17
|
const permKey = OPERATION_TO_PERMISSION[operation];
|
|
18
18
|
if (!permKey) return true;
|
|
19
19
|
for (const ps of permissionSets) {
|
|
20
|
-
const objPerm = ps.objects?.[objectName];
|
|
20
|
+
const objPerm = ps.objects?.[objectName] ?? ps.objects?.["*"];
|
|
21
21
|
if (objPerm) {
|
|
22
22
|
if (["allowEdit", "allowDelete"].includes(permKey) && objPerm.modifyAllRecords) {
|
|
23
23
|
return true;
|
|
@@ -54,13 +54,49 @@ var PermissionEvaluator = class {
|
|
|
54
54
|
return result;
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
|
-
* Resolve permission sets for a list of
|
|
57
|
+
* Resolve permission sets for a list of identifier names from metadata.
|
|
58
|
+
*
|
|
59
|
+
* Identifiers are matched to `PermissionSet.name`. The names may be
|
|
60
|
+
* either role names (when `sys_role.name` is reused as a permission set
|
|
61
|
+
* name — common for default admin/member/viewer roles) or explicit
|
|
62
|
+
* permission set names supplied through `ExecutionContext.permissions[]`
|
|
63
|
+
* (resolved by `resolveExecutionContext` from `sys_user_permission_set`
|
|
64
|
+
* and `sys_role_permission_set`).
|
|
65
|
+
*
|
|
66
|
+
* Async because the underlying metadata service exposes `list()` as a
|
|
67
|
+
* Promise — synchronous iteration would silently yield zero results
|
|
68
|
+
* (the historical SecurityPlugin behaviour, masking all enforcement).
|
|
69
|
+
*
|
|
70
|
+
* `bootstrapPermissionSets` is a fallback list of plugin-owned permission
|
|
71
|
+
* sets (typically the platform defaults: admin_full_access /
|
|
72
|
+
* member_default / viewer_readonly) that are registered via
|
|
73
|
+
* `manifest.register({ permissions })` but do not currently propagate
|
|
74
|
+
* into the metadata service's `list()` index. Without this fallback,
|
|
75
|
+
* SecurityPlugin would never resolve the defaults and all enforcement
|
|
76
|
+
* would be silently disabled for authenticated requests.
|
|
58
77
|
*/
|
|
59
|
-
resolvePermissionSets(
|
|
78
|
+
async resolvePermissionSets(identifiers, metadataService, bootstrapPermissionSets = []) {
|
|
79
|
+
if (identifiers.length === 0) return [];
|
|
60
80
|
const result = [];
|
|
61
|
-
const
|
|
81
|
+
const seen = /* @__PURE__ */ new Set();
|
|
82
|
+
let allPermSets = [];
|
|
83
|
+
try {
|
|
84
|
+
const listed = metadataService?.list?.("permission") ?? metadataService?.list?.("permissions") ?? [];
|
|
85
|
+
allPermSets = typeof listed?.then === "function" ? await listed : listed;
|
|
86
|
+
} catch {
|
|
87
|
+
allPermSets = [];
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(allPermSets)) allPermSets = [];
|
|
90
|
+
const wanted = new Set(identifiers);
|
|
62
91
|
for (const ps of allPermSets) {
|
|
63
|
-
if (
|
|
92
|
+
if (wanted.has(ps.name) && !seen.has(ps.name)) {
|
|
93
|
+
seen.add(ps.name);
|
|
94
|
+
result.push(ps);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
for (const ps of bootstrapPermissionSets) {
|
|
98
|
+
if (wanted.has(ps.name) && !seen.has(ps.name)) {
|
|
99
|
+
seen.add(ps.name);
|
|
64
100
|
result.push(ps);
|
|
65
101
|
}
|
|
66
102
|
}
|
|
@@ -69,16 +105,28 @@ var PermissionEvaluator = class {
|
|
|
69
105
|
};
|
|
70
106
|
|
|
71
107
|
// src/rls-compiler.ts
|
|
108
|
+
var RLS_DENY_FILTER = Object.freeze({
|
|
109
|
+
id: "__rls_deny__:00000000-0000-0000-0000-000000000000"
|
|
110
|
+
});
|
|
72
111
|
var RLSCompiler = class {
|
|
73
112
|
/**
|
|
74
113
|
* Compile RLS policies into a query filter for the given user context.
|
|
75
114
|
* Multiple policies for the same object/operation are OR-combined (any match allows access).
|
|
115
|
+
*
|
|
116
|
+
* Return-value semantics:
|
|
117
|
+
* - `null` → no policies applicable → caller applies no RLS filter.
|
|
118
|
+
* - non-null → caller AND's it onto the existing where clause.
|
|
119
|
+
* - {@link RLS_DENY_FILTER} → policies were defined but none could be
|
|
120
|
+
* compiled (e.g. wildcard `tenant_isolation` against a user with no
|
|
121
|
+
* active organization). The caller must treat this as "deny by
|
|
122
|
+
* default" — its `id` comparison naturally yields zero rows on
|
|
123
|
+
* select/update/delete, which is the safe fail-closed answer.
|
|
76
124
|
*/
|
|
77
125
|
compileFilter(policies, executionContext) {
|
|
78
126
|
if (policies.length === 0) return null;
|
|
79
127
|
const userCtx = {
|
|
80
128
|
id: executionContext?.userId,
|
|
81
|
-
|
|
129
|
+
organization_id: executionContext?.tenantId,
|
|
82
130
|
roles: executionContext?.roles
|
|
83
131
|
};
|
|
84
132
|
const filters = [];
|
|
@@ -89,7 +137,9 @@ var RLSCompiler = class {
|
|
|
89
137
|
filters.push(filter);
|
|
90
138
|
}
|
|
91
139
|
}
|
|
92
|
-
if (filters.length === 0)
|
|
140
|
+
if (filters.length === 0) {
|
|
141
|
+
return RLS_DENY_FILTER;
|
|
142
|
+
}
|
|
93
143
|
if (filters.length === 1) return filters[0];
|
|
94
144
|
return { $or: filters };
|
|
95
145
|
}
|
|
@@ -107,7 +157,7 @@ var RLSCompiler = class {
|
|
|
107
157
|
if (eqMatch) {
|
|
108
158
|
const [, field, prop] = eqMatch;
|
|
109
159
|
const value = userCtx[prop];
|
|
110
|
-
if (value === void 0) return null;
|
|
160
|
+
if (value === void 0 || value === null) return null;
|
|
111
161
|
return { [field]: value };
|
|
112
162
|
}
|
|
113
163
|
const litMatch = expression.match(/^\s*(\w+)\s*=\s*'([^']*)'\s*$/);
|
|
@@ -119,7 +169,7 @@ var RLSCompiler = class {
|
|
|
119
169
|
if (inMatch) {
|
|
120
170
|
const [, field, prop] = inMatch;
|
|
121
171
|
const value = userCtx[prop];
|
|
122
|
-
if (!Array.isArray(value)) return null;
|
|
172
|
+
if (!Array.isArray(value) || value.length === 0) return null;
|
|
123
173
|
return { [field]: { $in: value } };
|
|
124
174
|
}
|
|
125
175
|
return null;
|
|
@@ -199,155 +249,489 @@ var FieldMasker = class {
|
|
|
199
249
|
}
|
|
200
250
|
};
|
|
201
251
|
|
|
202
|
-
// src/
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
isSystem: true,
|
|
211
|
-
description: "Role definitions for RBAC access control",
|
|
212
|
-
titleFormat: "{name}",
|
|
213
|
-
compactLayout: ["name", "label", "active"],
|
|
214
|
-
fields: {
|
|
215
|
-
id: Field.text({
|
|
216
|
-
label: "Role ID",
|
|
217
|
-
required: true,
|
|
218
|
-
readonly: true
|
|
219
|
-
}),
|
|
220
|
-
created_at: Field.datetime({
|
|
221
|
-
label: "Created At",
|
|
222
|
-
defaultValue: "NOW()",
|
|
223
|
-
readonly: true
|
|
224
|
-
}),
|
|
225
|
-
updated_at: Field.datetime({
|
|
226
|
-
label: "Updated At",
|
|
227
|
-
defaultValue: "NOW()",
|
|
228
|
-
readonly: true
|
|
229
|
-
}),
|
|
230
|
-
name: Field.text({
|
|
231
|
-
label: "API Name",
|
|
232
|
-
required: true,
|
|
233
|
-
searchable: true,
|
|
234
|
-
maxLength: 100,
|
|
235
|
-
description: "Unique machine name for the role (e.g. admin, editor, viewer)"
|
|
236
|
-
}),
|
|
237
|
-
label: Field.text({
|
|
238
|
-
label: "Display Name",
|
|
239
|
-
required: true,
|
|
240
|
-
maxLength: 255
|
|
241
|
-
}),
|
|
242
|
-
description: Field.textarea({
|
|
243
|
-
label: "Description",
|
|
244
|
-
required: false
|
|
245
|
-
}),
|
|
246
|
-
permissions: Field.textarea({
|
|
247
|
-
label: "Permissions",
|
|
248
|
-
required: false,
|
|
249
|
-
description: "JSON-serialized array of permission strings"
|
|
250
|
-
}),
|
|
251
|
-
active: Field.boolean({
|
|
252
|
-
label: "Active",
|
|
253
|
-
defaultValue: true
|
|
254
|
-
}),
|
|
255
|
-
is_default: Field.boolean({
|
|
256
|
-
label: "Default Role",
|
|
257
|
-
defaultValue: false,
|
|
258
|
-
description: "Automatically assigned to new users"
|
|
259
|
-
})
|
|
260
|
-
},
|
|
261
|
-
indexes: [
|
|
262
|
-
{ fields: ["name"], unique: true },
|
|
263
|
-
{ fields: ["active"] }
|
|
264
|
-
],
|
|
265
|
-
enable: {
|
|
266
|
-
trackHistory: true,
|
|
267
|
-
searchable: true,
|
|
268
|
-
apiEnabled: true,
|
|
269
|
-
apiMethods: ["get", "list", "create", "update", "delete"],
|
|
270
|
-
trash: true,
|
|
271
|
-
mru: true
|
|
252
|
+
// src/errors.ts
|
|
253
|
+
var PermissionDeniedError = class extends Error {
|
|
254
|
+
constructor(message, details) {
|
|
255
|
+
super(message);
|
|
256
|
+
this.code = "PERMISSION_DENIED";
|
|
257
|
+
this.statusCode = 403;
|
|
258
|
+
this.name = "PermissionDeniedError";
|
|
259
|
+
this.details = details;
|
|
272
260
|
}
|
|
273
|
-
}
|
|
261
|
+
};
|
|
262
|
+
function isPermissionDeniedError(e) {
|
|
263
|
+
if (!e || typeof e !== "object") return false;
|
|
264
|
+
const anyE = e;
|
|
265
|
+
return anyE.name === "PermissionDeniedError" || anyE.code === "PERMISSION_DENIED" || typeof anyE.message === "string" && anyE.message.startsWith("[Security] Access denied");
|
|
266
|
+
}
|
|
274
267
|
|
|
275
|
-
// src/
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
isSystem: true,
|
|
284
|
-
description: "Named permission groupings for fine-grained access control",
|
|
285
|
-
titleFormat: "{name}",
|
|
286
|
-
compactLayout: ["name", "label", "active"],
|
|
287
|
-
fields: {
|
|
288
|
-
id: Field2.text({
|
|
289
|
-
label: "Permission Set ID",
|
|
290
|
-
required: true,
|
|
291
|
-
readonly: true
|
|
292
|
-
}),
|
|
293
|
-
created_at: Field2.datetime({
|
|
294
|
-
label: "Created At",
|
|
295
|
-
defaultValue: "NOW()",
|
|
296
|
-
readonly: true
|
|
297
|
-
}),
|
|
298
|
-
updated_at: Field2.datetime({
|
|
299
|
-
label: "Updated At",
|
|
300
|
-
defaultValue: "NOW()",
|
|
301
|
-
readonly: true
|
|
302
|
-
}),
|
|
303
|
-
name: Field2.text({
|
|
304
|
-
label: "API Name",
|
|
305
|
-
required: true,
|
|
306
|
-
searchable: true,
|
|
307
|
-
maxLength: 100,
|
|
308
|
-
description: "Unique machine name for the permission set"
|
|
309
|
-
}),
|
|
310
|
-
label: Field2.text({
|
|
311
|
-
label: "Display Name",
|
|
312
|
-
required: true,
|
|
313
|
-
maxLength: 255
|
|
314
|
-
}),
|
|
315
|
-
description: Field2.textarea({
|
|
316
|
-
label: "Description",
|
|
317
|
-
required: false
|
|
318
|
-
}),
|
|
319
|
-
object_permissions: Field2.textarea({
|
|
320
|
-
label: "Object Permissions",
|
|
321
|
-
required: false,
|
|
322
|
-
description: "JSON-serialized object-level CRUD permissions"
|
|
323
|
-
}),
|
|
324
|
-
field_permissions: Field2.textarea({
|
|
325
|
-
label: "Field Permissions",
|
|
326
|
-
required: false,
|
|
327
|
-
description: "JSON-serialized field-level read/write permissions"
|
|
328
|
-
}),
|
|
329
|
-
active: Field2.boolean({
|
|
330
|
-
label: "Active",
|
|
331
|
-
defaultValue: true
|
|
332
|
-
})
|
|
333
|
-
},
|
|
334
|
-
indexes: [
|
|
335
|
-
{ fields: ["name"], unique: true },
|
|
336
|
-
{ fields: ["active"] }
|
|
337
|
-
],
|
|
338
|
-
enable: {
|
|
339
|
-
trackHistory: true,
|
|
340
|
-
searchable: true,
|
|
341
|
-
apiEnabled: true,
|
|
342
|
-
apiMethods: ["get", "list", "create", "update", "delete"],
|
|
343
|
-
trash: true,
|
|
344
|
-
mru: true
|
|
268
|
+
// src/bootstrap-platform-admin.ts
|
|
269
|
+
var SYSTEM_CTX = { isSystem: true };
|
|
270
|
+
async function tryFind(ql, object, where, limit = 100) {
|
|
271
|
+
try {
|
|
272
|
+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
|
|
273
|
+
return Array.isArray(rows) ? rows : [];
|
|
274
|
+
} catch {
|
|
275
|
+
return [];
|
|
345
276
|
}
|
|
346
|
-
}
|
|
277
|
+
}
|
|
278
|
+
async function tryInsert(ql, object, data) {
|
|
279
|
+
try {
|
|
280
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX });
|
|
281
|
+
} catch {
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function genId(prefix) {
|
|
286
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
287
|
+
const ts = Date.now().toString(36);
|
|
288
|
+
return `${prefix}_${ts}${rand}`;
|
|
289
|
+
}
|
|
290
|
+
async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {}) {
|
|
291
|
+
const logger = options.logger;
|
|
292
|
+
if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
|
|
293
|
+
return { seeded: 0, adminPromoted: false, reason: "objectql_unavailable" };
|
|
294
|
+
}
|
|
295
|
+
const seeded = {};
|
|
296
|
+
for (const ps of bootstrapPermissionSets) {
|
|
297
|
+
if (!ps.name) continue;
|
|
298
|
+
const existing = await tryFind(ql, "sys_permission_set", { name: ps.name }, 1);
|
|
299
|
+
if (existing.length > 0 && existing[0].id) {
|
|
300
|
+
seeded[ps.name] = existing[0].id;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const id = genId("ps");
|
|
304
|
+
const created = await tryInsert(ql, "sys_permission_set", {
|
|
305
|
+
id,
|
|
306
|
+
name: ps.name,
|
|
307
|
+
label: ps.label ?? ps.name,
|
|
308
|
+
description: ps.description ?? null,
|
|
309
|
+
object_permissions: JSON.stringify(ps.objects ?? {}),
|
|
310
|
+
field_permissions: JSON.stringify(ps.fields ?? {}),
|
|
311
|
+
active: true
|
|
312
|
+
});
|
|
313
|
+
if (created?.id) seeded[ps.name] = created.id;
|
|
314
|
+
else if (created) seeded[ps.name] = id;
|
|
315
|
+
}
|
|
316
|
+
const seededCount = Object.keys(seeded).length;
|
|
317
|
+
const adminPsId = seeded["admin_full_access"];
|
|
318
|
+
if (!adminPsId) {
|
|
319
|
+
return { seeded: seededCount, adminPromoted: false, reason: "admin_permission_set_missing" };
|
|
320
|
+
}
|
|
321
|
+
const existingAdminLinks = await tryFind(
|
|
322
|
+
ql,
|
|
323
|
+
"sys_user_permission_set",
|
|
324
|
+
{ permission_set_id: adminPsId },
|
|
325
|
+
5
|
|
326
|
+
);
|
|
327
|
+
if (existingAdminLinks.some((r) => !r.organization_id)) {
|
|
328
|
+
return { seeded: seededCount, adminPromoted: false, reason: "already_have_admin" };
|
|
329
|
+
}
|
|
330
|
+
const allUsers = await tryFind(ql, "sys_user", {}, 50);
|
|
331
|
+
if (allUsers.length === 0) {
|
|
332
|
+
logger?.info?.("[security] no users yet \u2014 first sign-up will be promoted to platform admin");
|
|
333
|
+
return { seeded: seededCount, adminPromoted: false, reason: "no_users" };
|
|
334
|
+
}
|
|
335
|
+
const sorted = [...allUsers].sort((a, b) => {
|
|
336
|
+
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
|
|
337
|
+
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
|
|
338
|
+
return ta - tb;
|
|
339
|
+
});
|
|
340
|
+
const target = sorted[0];
|
|
341
|
+
const inserted = await tryInsert(ql, "sys_user_permission_set", {
|
|
342
|
+
id: genId("ups"),
|
|
343
|
+
user_id: target.id,
|
|
344
|
+
permission_set_id: adminPsId,
|
|
345
|
+
organization_id: null,
|
|
346
|
+
granted_by: null
|
|
347
|
+
});
|
|
348
|
+
if (!inserted) {
|
|
349
|
+
logger?.warn?.(`[security] failed to grant admin_full_access to first user ${target.email ?? target.id}`);
|
|
350
|
+
return { seeded: seededCount, adminPromoted: false, reason: "insert_failed" };
|
|
351
|
+
}
|
|
352
|
+
logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
|
|
353
|
+
return { seeded: seededCount, adminPromoted: true };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// src/claim-orphan-tenant-rows.ts
|
|
357
|
+
var SYSTEM_CTX2 = { isSystem: true };
|
|
358
|
+
function hasOrganizationField(schema) {
|
|
359
|
+
const fields = schema?.fields;
|
|
360
|
+
if (!fields) return false;
|
|
361
|
+
if (Array.isArray(fields)) {
|
|
362
|
+
return fields.some((f) => f?.name === "organization_id");
|
|
363
|
+
}
|
|
364
|
+
return Object.prototype.hasOwnProperty.call(fields, "organization_id");
|
|
365
|
+
}
|
|
366
|
+
async function claimOrphanTenantRows(ql, organizationId, options = {}) {
|
|
367
|
+
const logger = options.logger;
|
|
368
|
+
if (!ql || typeof ql.update !== "function" || typeof ql.find !== "function") {
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
const registry = ql.registry;
|
|
372
|
+
if (!registry || typeof registry.getAllObjects !== "function") {
|
|
373
|
+
logger?.warn?.("[security] claimOrphanTenantRows: registry unavailable");
|
|
374
|
+
return [];
|
|
375
|
+
}
|
|
376
|
+
const schemas = registry.getAllObjects();
|
|
377
|
+
const results = [];
|
|
378
|
+
for (const schema of schemas) {
|
|
379
|
+
if (!schema?.name) continue;
|
|
380
|
+
if (schema.managedBy) continue;
|
|
381
|
+
if (schema.name.startsWith("sys_")) continue;
|
|
382
|
+
if (!hasOrganizationField(schema)) continue;
|
|
383
|
+
try {
|
|
384
|
+
const orphans = await ql.find(
|
|
385
|
+
schema.name,
|
|
386
|
+
{ where: { organization_id: null }, limit: 1e4, fields: ["id"] },
|
|
387
|
+
{ context: SYSTEM_CTX2 }
|
|
388
|
+
);
|
|
389
|
+
const list = Array.isArray(orphans) ? orphans : Array.isArray(orphans?.records) ? orphans.records : [];
|
|
390
|
+
if (list.length === 0) continue;
|
|
391
|
+
let updated = 0;
|
|
392
|
+
for (const row of list) {
|
|
393
|
+
if (!row?.id) continue;
|
|
394
|
+
try {
|
|
395
|
+
await ql.update(
|
|
396
|
+
schema.name,
|
|
397
|
+
{ id: row.id, organization_id: organizationId },
|
|
398
|
+
{ context: SYSTEM_CTX2 }
|
|
399
|
+
);
|
|
400
|
+
updated += 1;
|
|
401
|
+
} catch (e) {
|
|
402
|
+
logger?.warn?.(`[security] claim failed for ${schema.name}:${row.id}`, {
|
|
403
|
+
error: e.message
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (updated > 0) {
|
|
408
|
+
results.push({ object: schema.name, count: updated });
|
|
409
|
+
}
|
|
410
|
+
} catch (e) {
|
|
411
|
+
logger?.warn?.(`[security] claim scan failed for ${schema.name}`, {
|
|
412
|
+
error: e.message
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (results.length > 0) {
|
|
417
|
+
const total = results.reduce((s, r) => s + r.count, 0);
|
|
418
|
+
logger?.info?.(`[security] claimed ${total} orphan seed row(s) for organization ${organizationId}`, {
|
|
419
|
+
breakdown: results
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return results;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/clone-tenant-seed-data.ts
|
|
426
|
+
var SYSTEM_CTX3 = { isSystem: true };
|
|
427
|
+
var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
|
|
428
|
+
"id",
|
|
429
|
+
"created_at",
|
|
430
|
+
"updated_at",
|
|
431
|
+
"organization_id"
|
|
432
|
+
]);
|
|
433
|
+
var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary", "autonumber"]);
|
|
434
|
+
function fieldList(schema) {
|
|
435
|
+
const fields = schema?.fields;
|
|
436
|
+
if (!fields) return [];
|
|
437
|
+
if (Array.isArray(fields)) {
|
|
438
|
+
return fields.map((f) => ({
|
|
439
|
+
name: f?.name,
|
|
440
|
+
type: f?.type,
|
|
441
|
+
reference: f?.reference,
|
|
442
|
+
multiple: f?.multiple,
|
|
443
|
+
unique: f?.unique
|
|
444
|
+
}));
|
|
445
|
+
}
|
|
446
|
+
return Object.entries(fields).map(([name, f]) => ({
|
|
447
|
+
name,
|
|
448
|
+
type: f?.type,
|
|
449
|
+
reference: f?.reference,
|
|
450
|
+
multiple: f?.multiple,
|
|
451
|
+
unique: f?.unique
|
|
452
|
+
}));
|
|
453
|
+
}
|
|
454
|
+
function isLookupField(f) {
|
|
455
|
+
return (f.type === "lookup" || f.type === "master_detail" || f.type === "tree") && !!f.reference;
|
|
456
|
+
}
|
|
457
|
+
function hasOrgField(schema) {
|
|
458
|
+
return fieldList(schema).some((f) => f.name === "organization_id");
|
|
459
|
+
}
|
|
460
|
+
function shortId() {
|
|
461
|
+
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
|
|
462
|
+
let out = "";
|
|
463
|
+
for (let i = 0; i < 16; i++) {
|
|
464
|
+
out += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
async function findDonorOrgId(ql) {
|
|
469
|
+
try {
|
|
470
|
+
const res = await ql.find(
|
|
471
|
+
"sys_organization",
|
|
472
|
+
{ orderBy: { created_at: "asc" }, limit: 1, fields: ["id"] },
|
|
473
|
+
{ context: SYSTEM_CTX3 }
|
|
474
|
+
);
|
|
475
|
+
const list = Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : [];
|
|
476
|
+
return list[0]?.id ?? null;
|
|
477
|
+
} catch {
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
async function cloneTenantSeedData(ql, targetOrgId, options = {}) {
|
|
482
|
+
const logger = options.logger;
|
|
483
|
+
if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
|
|
484
|
+
return [];
|
|
485
|
+
}
|
|
486
|
+
const registry = ql.registry;
|
|
487
|
+
if (!registry || typeof registry.getAllObjects !== "function") {
|
|
488
|
+
logger?.warn?.("[security] cloneTenantSeedData: registry unavailable");
|
|
489
|
+
return [];
|
|
490
|
+
}
|
|
491
|
+
const donorOrgId = await findDonorOrgId(ql);
|
|
492
|
+
if (!donorOrgId) return [];
|
|
493
|
+
if (donorOrgId === targetOrgId) return [];
|
|
494
|
+
const schemas = registry.getAllObjects().filter(
|
|
495
|
+
(s) => s?.name && !s.managedBy && !s.name.startsWith("sys_") && hasOrgField(s)
|
|
496
|
+
);
|
|
497
|
+
const remap = {};
|
|
498
|
+
const summary = [];
|
|
499
|
+
const inserted = [];
|
|
500
|
+
for (const schema of schemas) {
|
|
501
|
+
const objectName = schema.name;
|
|
502
|
+
try {
|
|
503
|
+
const existing = await ql.find(
|
|
504
|
+
objectName,
|
|
505
|
+
{ where: { organization_id: targetOrgId }, limit: 1, fields: ["id"] },
|
|
506
|
+
{ context: SYSTEM_CTX3 }
|
|
507
|
+
);
|
|
508
|
+
const existingList = Array.isArray(existing) ? existing : Array.isArray(existing?.records) ? existing.records : [];
|
|
509
|
+
if (existingList.length > 0) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const donorRows = await ql.find(
|
|
513
|
+
objectName,
|
|
514
|
+
{ where: { organization_id: donorOrgId }, limit: 1e4 },
|
|
515
|
+
{ context: SYSTEM_CTX3 }
|
|
516
|
+
);
|
|
517
|
+
const rows = Array.isArray(donorRows) ? donorRows : Array.isArray(donorRows?.records) ? donorRows.records : [];
|
|
518
|
+
if (rows.length === 0) continue;
|
|
519
|
+
const fields = fieldList(schema);
|
|
520
|
+
const lookups = fields.filter(isLookupField);
|
|
521
|
+
const uniqueFields = fields.filter((f) => f.unique && !SKIP_COPY_FIELDS.has(f.name));
|
|
522
|
+
const objectRemap = remap[objectName] ?? (remap[objectName] = {});
|
|
523
|
+
let cloned = 0;
|
|
524
|
+
for (const row of rows) {
|
|
525
|
+
const newId = shortId();
|
|
526
|
+
const data = { id: newId, organization_id: targetOrgId };
|
|
527
|
+
for (const f of fields) {
|
|
528
|
+
if (SKIP_COPY_FIELDS.has(f.name)) continue;
|
|
529
|
+
if (f.type && SKIP_COPY_TYPES.has(f.type)) continue;
|
|
530
|
+
if (row[f.name] === void 0) continue;
|
|
531
|
+
data[f.name] = row[f.name];
|
|
532
|
+
}
|
|
533
|
+
const suffix = `+${targetOrgId.slice(-6)}`;
|
|
534
|
+
for (const uf of uniqueFields) {
|
|
535
|
+
const v = data[uf.name];
|
|
536
|
+
if (typeof v !== "string" || !v) continue;
|
|
537
|
+
if (uf.type === "email" && v.includes("@")) {
|
|
538
|
+
const [local, domain] = v.split("@");
|
|
539
|
+
data[uf.name] = `clone-${targetOrgId.slice(-6)}-${local}@${domain}`;
|
|
540
|
+
} else {
|
|
541
|
+
data[uf.name] = `${v}${suffix}`;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
try {
|
|
545
|
+
await ql.insert(objectName, data, { context: SYSTEM_CTX3 });
|
|
546
|
+
objectRemap[row.id] = newId;
|
|
547
|
+
inserted.push({ object: objectName, newId, record: data, lookups });
|
|
548
|
+
cloned++;
|
|
549
|
+
} catch (e) {
|
|
550
|
+
logger?.warn?.("[security] cloneTenantSeedData: insert failed", {
|
|
551
|
+
object: objectName,
|
|
552
|
+
error: e.message
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (cloned > 0) summary.push({ object: objectName, count: cloned });
|
|
557
|
+
} catch (e) {
|
|
558
|
+
logger?.warn?.("[security] cloneTenantSeedData: object failed", {
|
|
559
|
+
object: objectName,
|
|
560
|
+
error: e.message
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const item of inserted) {
|
|
565
|
+
if (item.lookups.length === 0) continue;
|
|
566
|
+
const patch = {};
|
|
567
|
+
let dirty = false;
|
|
568
|
+
for (const f of item.lookups) {
|
|
569
|
+
const oldVal = item.record[f.name];
|
|
570
|
+
if (oldVal == null) continue;
|
|
571
|
+
const targetMap = remap[f.reference];
|
|
572
|
+
if (!targetMap) continue;
|
|
573
|
+
if (Array.isArray(oldVal)) {
|
|
574
|
+
const next = oldVal.map((v) => typeof v === "string" && targetMap[v] || v);
|
|
575
|
+
if (next.some((v, i) => v !== oldVal[i])) {
|
|
576
|
+
patch[f.name] = next;
|
|
577
|
+
dirty = true;
|
|
578
|
+
}
|
|
579
|
+
} else if (typeof oldVal === "string" && targetMap[oldVal]) {
|
|
580
|
+
patch[f.name] = targetMap[oldVal];
|
|
581
|
+
dirty = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
if (!dirty) continue;
|
|
585
|
+
try {
|
|
586
|
+
await ql.update(item.object, { id: item.newId, ...patch }, { context: SYSTEM_CTX3 });
|
|
587
|
+
} catch (e) {
|
|
588
|
+
logger?.warn?.("[security] cloneTenantSeedData: lookup remap failed", {
|
|
589
|
+
object: item.object,
|
|
590
|
+
id: item.newId,
|
|
591
|
+
error: e.message
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
return summary;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
// src/ensure-user-has-organization.ts
|
|
599
|
+
var SYSTEM_CTX4 = { isSystem: true };
|
|
600
|
+
function genId2(prefix) {
|
|
601
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
602
|
+
const ts = Date.now().toString(36);
|
|
603
|
+
return `${prefix}_${ts}${rand}`;
|
|
604
|
+
}
|
|
605
|
+
function slugify(input) {
|
|
606
|
+
return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "workspace";
|
|
607
|
+
}
|
|
608
|
+
function deriveBaseName(user) {
|
|
609
|
+
if (user.name && user.name.trim()) return user.name.trim();
|
|
610
|
+
if (user.email) {
|
|
611
|
+
const local = user.email.split("@")[0];
|
|
612
|
+
if (local) return local;
|
|
613
|
+
}
|
|
614
|
+
return user.id;
|
|
615
|
+
}
|
|
616
|
+
async function tryFind2(ql, object, where, limit = 1) {
|
|
617
|
+
try {
|
|
618
|
+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX4 });
|
|
619
|
+
return Array.isArray(rows) ? rows : [];
|
|
620
|
+
} catch {
|
|
621
|
+
return [];
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
async function ensureUserHasOrganization(ql, user, options = {}) {
|
|
625
|
+
const logger = options.logger;
|
|
626
|
+
const cloneSeedData = options.cloneSeedData;
|
|
627
|
+
if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
|
|
628
|
+
return { created: false, reason: "objectql_unavailable" };
|
|
629
|
+
}
|
|
630
|
+
if (!user?.id) return { created: false, reason: "invalid_user" };
|
|
631
|
+
const existing = await tryFind2(ql, "sys_member", { user_id: user.id }, 1);
|
|
632
|
+
if (existing.length > 0) {
|
|
633
|
+
return { created: false, reason: "already_member" };
|
|
634
|
+
}
|
|
635
|
+
const base = deriveBaseName(user);
|
|
636
|
+
const orgName = `${base}'s Workspace`;
|
|
637
|
+
const baseSlug = slugify(base);
|
|
638
|
+
let slug = `${baseSlug}-workspace`;
|
|
639
|
+
for (let attempt = 1; attempt <= 5; attempt += 1) {
|
|
640
|
+
const collision = await tryFind2(ql, "sys_organization", { slug }, 1);
|
|
641
|
+
if (collision.length === 0) break;
|
|
642
|
+
slug = `${baseSlug}-workspace-${attempt + 1}`;
|
|
643
|
+
if (attempt === 5) {
|
|
644
|
+
logger?.warn?.(
|
|
645
|
+
`[security] could not find a free slug for personal org of ${user.email ?? user.id}`
|
|
646
|
+
);
|
|
647
|
+
return { created: false, reason: "slug_exhausted" };
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
const orgId = genId2("org");
|
|
651
|
+
let orgRow = null;
|
|
652
|
+
try {
|
|
653
|
+
orgRow = await ql.insert(
|
|
654
|
+
"sys_organization",
|
|
655
|
+
{ id: orgId, name: orgName, slug, logo: null, metadata: null },
|
|
656
|
+
{ context: SYSTEM_CTX4 }
|
|
657
|
+
);
|
|
658
|
+
} catch (e) {
|
|
659
|
+
logger?.warn?.(`[security] failed to create personal org for ${user.email ?? user.id}`, {
|
|
660
|
+
error: e.message
|
|
661
|
+
});
|
|
662
|
+
return { created: false, reason: "org_insert_failed" };
|
|
663
|
+
}
|
|
664
|
+
const finalOrgId = orgRow?.id ?? orgId;
|
|
665
|
+
try {
|
|
666
|
+
await ql.insert(
|
|
667
|
+
"sys_member",
|
|
668
|
+
{
|
|
669
|
+
id: genId2("mem"),
|
|
670
|
+
organization_id: finalOrgId,
|
|
671
|
+
user_id: user.id,
|
|
672
|
+
role: "owner"
|
|
673
|
+
},
|
|
674
|
+
{ context: SYSTEM_CTX4 }
|
|
675
|
+
);
|
|
676
|
+
} catch (e) {
|
|
677
|
+
logger?.warn?.(`[security] failed to create owner-member row for ${user.email ?? user.id}`, {
|
|
678
|
+
error: e.message
|
|
679
|
+
});
|
|
680
|
+
return { created: false, reason: "member_insert_failed", organizationId: finalOrgId };
|
|
681
|
+
}
|
|
682
|
+
logger?.info?.(
|
|
683
|
+
`[security] created personal organization "${orgName}" (${finalOrgId}) for ${user.email ?? user.id}`
|
|
684
|
+
);
|
|
685
|
+
if (cloneSeedData) {
|
|
686
|
+
try {
|
|
687
|
+
const summary = await cloneSeedData(ql, finalOrgId, { logger });
|
|
688
|
+
if (summary.length > 0) {
|
|
689
|
+
const total = summary.reduce((s, c) => s + c.count, 0);
|
|
690
|
+
logger?.info?.(
|
|
691
|
+
`[security] cloned ${total} seed row(s) into personal organization ${finalOrgId}`,
|
|
692
|
+
{ breakdown: summary }
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
} catch (e) {
|
|
696
|
+
logger?.warn?.("[security] cloneTenantSeedData failed", {
|
|
697
|
+
error: e.message
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
return { created: true, organizationId: finalOrgId };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// src/manifest.ts
|
|
705
|
+
import {
|
|
706
|
+
SysPermissionSet,
|
|
707
|
+
SysRole,
|
|
708
|
+
SysUserPermissionSet,
|
|
709
|
+
SysRolePermissionSet,
|
|
710
|
+
defaultPermissionSets
|
|
711
|
+
} from "@objectstack/platform-objects/security";
|
|
712
|
+
var SECURITY_PLUGIN_ID = "com.objectstack.plugin-security";
|
|
713
|
+
var SECURITY_PLUGIN_VERSION = "1.0.0";
|
|
714
|
+
var securityObjects = [
|
|
715
|
+
SysRole,
|
|
716
|
+
SysPermissionSet,
|
|
717
|
+
SysUserPermissionSet,
|
|
718
|
+
SysRolePermissionSet
|
|
719
|
+
];
|
|
720
|
+
var securityDefaultPermissionSets = defaultPermissionSets;
|
|
721
|
+
var securityPluginManifestHeader = {
|
|
722
|
+
id: SECURITY_PLUGIN_ID,
|
|
723
|
+
namespace: "sys",
|
|
724
|
+
version: SECURITY_PLUGIN_VERSION,
|
|
725
|
+
type: "plugin",
|
|
726
|
+
scope: "system",
|
|
727
|
+
defaultDatasource: "cloud",
|
|
728
|
+
name: "Security Plugin",
|
|
729
|
+
description: "RBAC roles and permission sets for ObjectStack (Role, PermissionSet)"
|
|
730
|
+
};
|
|
347
731
|
|
|
348
732
|
// src/security-plugin.ts
|
|
349
733
|
var SecurityPlugin = class {
|
|
350
|
-
constructor() {
|
|
734
|
+
constructor(options = {}) {
|
|
351
735
|
this.name = "com.objectstack.security";
|
|
352
736
|
this.type = "standard";
|
|
353
737
|
this.version = "1.0.0";
|
|
@@ -355,6 +739,17 @@ var SecurityPlugin = class {
|
|
|
355
739
|
this.permissionEvaluator = new PermissionEvaluator();
|
|
356
740
|
this.rlsCompiler = new RLSCompiler();
|
|
357
741
|
this.fieldMasker = new FieldMasker();
|
|
742
|
+
/**
|
|
743
|
+
* Per-object field-name cache. Populated lazily from the metadata
|
|
744
|
+
* service / ObjectQL registry on first access per object. Schemas are
|
|
745
|
+
* effectively immutable for the lifetime of the kernel today (hot
|
|
746
|
+
* reload tears the kernel down), so we don't bother with
|
|
747
|
+
* invalidation — a kernel restart drops the cache.
|
|
748
|
+
*/
|
|
749
|
+
this.fieldNamesCache = /* @__PURE__ */ new Map();
|
|
750
|
+
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
751
|
+
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
752
|
+
this.multiTenant = options.multiTenant !== false;
|
|
358
753
|
}
|
|
359
754
|
async init(ctx) {
|
|
360
755
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -362,28 +757,16 @@ var SecurityPlugin = class {
|
|
|
362
757
|
ctx.registerService("security.rls", this.rlsCompiler);
|
|
363
758
|
ctx.registerService("security.fieldMasker", this.fieldMasker);
|
|
364
759
|
ctx.getService("manifest").register({
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
760
|
+
...securityPluginManifestHeader,
|
|
761
|
+
objects: securityObjects,
|
|
762
|
+
// Permission sets ride along on the manifest so the metadata service
|
|
763
|
+
// can resolve them by name when SecurityPlugin middleware queries
|
|
764
|
+
// `metadata.list('permissions')`.
|
|
765
|
+
permissions: this.bootstrapPermissionSets
|
|
766
|
+
});
|
|
767
|
+
ctx.logger.info("Security Plugin initialized", {
|
|
768
|
+
defaultPermissionSets: this.bootstrapPermissionSets.map((p) => p.name)
|
|
371
769
|
});
|
|
372
|
-
try {
|
|
373
|
-
const setupNav = ctx.getService("setupNav");
|
|
374
|
-
if (setupNav) {
|
|
375
|
-
setupNav.contribute({
|
|
376
|
-
areaId: "area_administration",
|
|
377
|
-
items: [
|
|
378
|
-
{ id: "nav_roles", type: "object", label: "Roles", objectName: "role", icon: "shield-check", order: 60 },
|
|
379
|
-
{ id: "nav_permission_sets", type: "object", label: "Permission Sets", objectName: "permission_set", icon: "lock", order: 70 }
|
|
380
|
-
]
|
|
381
|
-
});
|
|
382
|
-
ctx.logger.info("Security navigation items contributed to Setup App");
|
|
383
|
-
}
|
|
384
|
-
} catch {
|
|
385
|
-
}
|
|
386
|
-
ctx.logger.info("Security Plugin initialized");
|
|
387
770
|
}
|
|
388
771
|
async start(ctx) {
|
|
389
772
|
ctx.logger.info("Starting Security Plugin...");
|
|
@@ -405,12 +788,29 @@ var SecurityPlugin = class {
|
|
|
405
788
|
return next();
|
|
406
789
|
}
|
|
407
790
|
const roles = opCtx.context?.roles ?? [];
|
|
408
|
-
|
|
791
|
+
const explicitPermissionSets = opCtx.context?.permissions ?? [];
|
|
792
|
+
if (roles.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId) {
|
|
409
793
|
return next();
|
|
410
794
|
}
|
|
411
795
|
let permissionSets = [];
|
|
412
796
|
try {
|
|
413
|
-
|
|
797
|
+
const requested = [...roles, ...explicitPermissionSets];
|
|
798
|
+
if (requested.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
|
|
799
|
+
requested.push(this.fallbackPermissionSet);
|
|
800
|
+
}
|
|
801
|
+
permissionSets = await this.permissionEvaluator.resolvePermissionSets(
|
|
802
|
+
requested,
|
|
803
|
+
metadata,
|
|
804
|
+
this.bootstrapPermissionSets
|
|
805
|
+
);
|
|
806
|
+
if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
|
|
807
|
+
const fallback = await this.permissionEvaluator.resolvePermissionSets(
|
|
808
|
+
[this.fallbackPermissionSet],
|
|
809
|
+
metadata,
|
|
810
|
+
this.bootstrapPermissionSets
|
|
811
|
+
);
|
|
812
|
+
permissionSets = fallback;
|
|
813
|
+
}
|
|
414
814
|
} catch (e) {
|
|
415
815
|
return next();
|
|
416
816
|
}
|
|
@@ -421,14 +821,42 @@ var SecurityPlugin = class {
|
|
|
421
821
|
permissionSets
|
|
422
822
|
);
|
|
423
823
|
if (!allowed) {
|
|
424
|
-
throw new
|
|
425
|
-
`[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' is not permitted for roles [${roles.join(", ")}]
|
|
824
|
+
throw new PermissionDeniedError(
|
|
825
|
+
`[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' is not permitted for roles [${roles.join(", ")}]`,
|
|
826
|
+
{ operation: opCtx.operation, object: opCtx.object, roles, permissionSets: explicitPermissionSets }
|
|
426
827
|
);
|
|
427
828
|
}
|
|
428
829
|
}
|
|
830
|
+
if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
|
|
831
|
+
const needsTenant = this.multiTenant && !!opCtx.context?.tenantId;
|
|
832
|
+
const needsOwner = !!opCtx.context?.userId;
|
|
833
|
+
if (needsTenant || needsOwner) {
|
|
834
|
+
const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
|
|
835
|
+
if (fields) {
|
|
836
|
+
const data = opCtx.data;
|
|
837
|
+
if (needsTenant && fields.has("organization_id") && (data.organization_id == null || data.organization_id === "")) {
|
|
838
|
+
data.organization_id = opCtx.context.tenantId;
|
|
839
|
+
}
|
|
840
|
+
if (needsOwner && fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
|
|
841
|
+
data.owner_id = opCtx.context.userId;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
429
846
|
const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
|
|
430
847
|
if (allRlsPolicies.length > 0 && opCtx.ast) {
|
|
431
|
-
const
|
|
848
|
+
const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
|
|
849
|
+
let dropped = 0;
|
|
850
|
+
const compilable = objectFields ? allRlsPolicies.filter((p) => {
|
|
851
|
+
const targetField = this.extractTargetField(p.using);
|
|
852
|
+
const ok = targetField ? objectFields.has(targetField) : true;
|
|
853
|
+
if (!ok) dropped++;
|
|
854
|
+
return ok;
|
|
855
|
+
}) : allRlsPolicies;
|
|
856
|
+
let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
|
|
857
|
+
if (rlsFilter == null && dropped > 0) {
|
|
858
|
+
rlsFilter = { ...RLS_DENY_FILTER };
|
|
859
|
+
}
|
|
432
860
|
if (rlsFilter) {
|
|
433
861
|
if (opCtx.ast.where) {
|
|
434
862
|
opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] };
|
|
@@ -446,6 +874,79 @@ var SecurityPlugin = class {
|
|
|
446
874
|
}
|
|
447
875
|
});
|
|
448
876
|
ctx.logger.info("Security middleware registered on ObjectQL engine");
|
|
877
|
+
let bootstrapRanOnce = false;
|
|
878
|
+
const runBootstrap = async () => {
|
|
879
|
+
try {
|
|
880
|
+
const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, {
|
|
881
|
+
logger: ctx.logger
|
|
882
|
+
});
|
|
883
|
+
bootstrapRanOnce = true;
|
|
884
|
+
ctx.logger.info("[security] platform bootstrap complete", report);
|
|
885
|
+
return report;
|
|
886
|
+
} catch (e) {
|
|
887
|
+
ctx.logger.warn("[security] platform bootstrap failed", { error: e.message });
|
|
888
|
+
return void 0;
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
if (typeof ctx.hook === "function") {
|
|
892
|
+
ctx.hook("kernel:ready", runBootstrap);
|
|
893
|
+
} else {
|
|
894
|
+
void runBootstrap();
|
|
895
|
+
}
|
|
896
|
+
ql.registerMiddleware(async (opCtx, next) => {
|
|
897
|
+
await next();
|
|
898
|
+
if (opCtx?.object === "sys_user" && (opCtx?.operation === "create" || opCtx?.operation === "insert")) {
|
|
899
|
+
if (bootstrapRanOnce) {
|
|
900
|
+
await runBootstrap();
|
|
901
|
+
}
|
|
902
|
+
if (this.multiTenant) {
|
|
903
|
+
const newUser = opCtx?.result ?? opCtx?.data;
|
|
904
|
+
if (newUser?.id) {
|
|
905
|
+
try {
|
|
906
|
+
await ensureUserHasOrganization(ql, newUser, {
|
|
907
|
+
logger: ctx.logger,
|
|
908
|
+
cloneSeedData: cloneTenantSeedData
|
|
909
|
+
});
|
|
910
|
+
} catch (e) {
|
|
911
|
+
ctx.logger.warn("[security] ensure-user-has-organization failed", {
|
|
912
|
+
error: e.message
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
if (this.multiTenant) {
|
|
920
|
+
ql.registerMiddleware(async (opCtx, next) => {
|
|
921
|
+
await next();
|
|
922
|
+
if (opCtx?.object !== "sys_organization" || opCtx?.operation !== "create" && opCtx?.operation !== "insert") {
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
|
|
926
|
+
if (!newOrgId) return;
|
|
927
|
+
try {
|
|
928
|
+
const allOrgs = await ql.find(
|
|
929
|
+
"sys_organization",
|
|
930
|
+
{ limit: 2, fields: ["id"] },
|
|
931
|
+
{ context: { isSystem: true } }
|
|
932
|
+
);
|
|
933
|
+
const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
|
|
934
|
+
if (list.length !== 1) return;
|
|
935
|
+
const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
|
|
936
|
+
if (claims.length > 0) {
|
|
937
|
+
const total = claims.reduce((s, c) => s + c.count, 0);
|
|
938
|
+
ctx.logger.info(
|
|
939
|
+
`[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
|
|
940
|
+
{ breakdown: claims }
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
} catch (e) {
|
|
944
|
+
ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
|
|
945
|
+
error: e.message
|
|
946
|
+
});
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
}
|
|
449
950
|
}
|
|
450
951
|
async destroy() {
|
|
451
952
|
}
|
|
@@ -456,18 +957,79 @@ var SecurityPlugin = class {
|
|
|
456
957
|
const allPolicies = [];
|
|
457
958
|
for (const ps of permissionSets) {
|
|
458
959
|
if (ps.rowLevelSecurity) {
|
|
459
|
-
|
|
960
|
+
for (const policy of ps.rowLevelSecurity) {
|
|
961
|
+
if (!this.multiTenant && policy.using && policy.using.includes("current_user.organization_id")) {
|
|
962
|
+
continue;
|
|
963
|
+
}
|
|
964
|
+
allPolicies.push(policy);
|
|
965
|
+
}
|
|
460
966
|
}
|
|
461
967
|
}
|
|
462
968
|
return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies);
|
|
463
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* Resolve the column-name set for an object (lowercased). Returns
|
|
972
|
+
* `null` if the schema can't be loaded — caller should fail-closed.
|
|
973
|
+
*/
|
|
974
|
+
async getObjectFieldNames(metadata, objectName, ql) {
|
|
975
|
+
if (this.fieldNamesCache.has(objectName)) {
|
|
976
|
+
return this.fieldNamesCache.get(objectName) ?? null;
|
|
977
|
+
}
|
|
978
|
+
const result = await this.loadObjectFieldNames(metadata, objectName, ql);
|
|
979
|
+
if (result) {
|
|
980
|
+
this.fieldNamesCache.set(objectName, result);
|
|
981
|
+
}
|
|
982
|
+
return result;
|
|
983
|
+
}
|
|
984
|
+
async loadObjectFieldNames(metadata, objectName, ql) {
|
|
985
|
+
try {
|
|
986
|
+
let obj = typeof ql?.getSchema === "function" ? ql.getSchema(objectName) : null;
|
|
987
|
+
if (!obj || !obj.fields) {
|
|
988
|
+
obj = await metadata?.get?.("object", objectName);
|
|
989
|
+
}
|
|
990
|
+
if (!obj || !obj.fields) return null;
|
|
991
|
+
const set = /* @__PURE__ */ new Set(["id"]);
|
|
992
|
+
if (Array.isArray(obj.fields)) {
|
|
993
|
+
for (const f of obj.fields) {
|
|
994
|
+
if (f?.name) set.add(String(f.name));
|
|
995
|
+
}
|
|
996
|
+
} else if (typeof obj.fields === "object") {
|
|
997
|
+
for (const key of Object.keys(obj.fields)) {
|
|
998
|
+
set.add(key);
|
|
999
|
+
const v = obj.fields[key];
|
|
1000
|
+
if (v && typeof v === "object" && v.name) set.add(String(v.name));
|
|
1001
|
+
}
|
|
1002
|
+
} else {
|
|
1003
|
+
return null;
|
|
1004
|
+
}
|
|
1005
|
+
return set;
|
|
1006
|
+
} catch {
|
|
1007
|
+
return null;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Extract the left-hand field name from a simple RLS expression like
|
|
1012
|
+
* `field = current_user.x` or `field IN (current_user.y)`. Returns
|
|
1013
|
+
* `null` for unsupported shapes (in which case we keep the policy).
|
|
1014
|
+
*/
|
|
1015
|
+
extractTargetField(using) {
|
|
1016
|
+
if (!using) return null;
|
|
1017
|
+
const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(?:=|IN|in)(?=\s|\()/);
|
|
1018
|
+
return m ? m[1] : null;
|
|
1019
|
+
}
|
|
464
1020
|
};
|
|
465
1021
|
export {
|
|
466
1022
|
FieldMasker,
|
|
1023
|
+
PermissionDeniedError,
|
|
467
1024
|
PermissionEvaluator,
|
|
468
1025
|
RLSCompiler,
|
|
1026
|
+
RLS_DENY_FILTER,
|
|
1027
|
+
SECURITY_PLUGIN_ID,
|
|
1028
|
+
SECURITY_PLUGIN_VERSION,
|
|
469
1029
|
SecurityPlugin,
|
|
470
|
-
|
|
471
|
-
|
|
1030
|
+
isPermissionDeniedError,
|
|
1031
|
+
securityDefaultPermissionSets,
|
|
1032
|
+
securityObjects,
|
|
1033
|
+
securityPluginManifestHeader
|
|
472
1034
|
};
|
|
473
1035
|
//# sourceMappingURL=index.mjs.map
|