@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/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 role names from metadata.
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(roles, metadataService) {
78
+ async resolvePermissionSets(identifiers, metadataService, bootstrapPermissionSets = []) {
79
+ if (identifiers.length === 0) return [];
60
80
  const result = [];
61
- const allPermSets = metadataService.list?.("permissions") || [];
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 (roles.includes(ps.name)) {
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
- tenant_id: executionContext?.tenantId,
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) return null;
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/objects/sys-role.object.ts
203
- import { ObjectSchema, Field } from "@objectstack/spec/data";
204
- var SysRole = ObjectSchema.create({
205
- namespace: "sys",
206
- name: "role",
207
- label: "Role",
208
- pluralLabel: "Roles",
209
- icon: "shield",
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/objects/sys-permission-set.object.ts
276
- import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
277
- var SysPermissionSet = ObjectSchema2.create({
278
- namespace: "sys",
279
- name: "permission_set",
280
- label: "Permission Set",
281
- pluralLabel: "Permission Sets",
282
- icon: "lock",
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
- id: "com.objectstack.security",
366
- name: "Security",
367
- version: "1.0.0",
368
- type: "plugin",
369
- namespace: "sys",
370
- objects: [SysRole, SysPermissionSet]
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
- if (roles.length === 0 && !opCtx.context?.userId) {
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
- permissionSets = this.permissionEvaluator.resolvePermissionSets(roles, metadata);
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 Error(
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 rlsFilter = this.rlsCompiler.compileFilter(allRlsPolicies, opCtx.context);
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
- allPolicies.push(...ps.rowLevelSecurity);
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
- SysPermissionSet,
471
- SysRole
1030
+ isPermissionDeniedError,
1031
+ securityDefaultPermissionSets,
1032
+ securityObjects,
1033
+ securityPluginManifestHeader
472
1034
  };
473
1035
  //# sourceMappingURL=index.mjs.map