@objectstack/plugin-security 4.0.4 → 4.1.0

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.js CHANGED
@@ -21,11 +21,17 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  FieldMasker: () => FieldMasker,
24
+ PermissionDeniedError: () => PermissionDeniedError,
24
25
  PermissionEvaluator: () => PermissionEvaluator,
25
26
  RLSCompiler: () => RLSCompiler,
27
+ RLS_DENY_FILTER: () => RLS_DENY_FILTER,
28
+ SECURITY_PLUGIN_ID: () => SECURITY_PLUGIN_ID,
29
+ SECURITY_PLUGIN_VERSION: () => SECURITY_PLUGIN_VERSION,
26
30
  SecurityPlugin: () => SecurityPlugin,
27
- SysPermissionSet: () => SysPermissionSet,
28
- SysRole: () => SysRole
31
+ isPermissionDeniedError: () => isPermissionDeniedError,
32
+ securityDefaultPermissionSets: () => securityDefaultPermissionSets,
33
+ securityObjects: () => securityObjects,
34
+ securityPluginManifestHeader: () => securityPluginManifestHeader
29
35
  });
30
36
  module.exports = __toCommonJS(index_exports);
31
37
 
@@ -48,7 +54,7 @@ var PermissionEvaluator = class {
48
54
  const permKey = OPERATION_TO_PERMISSION[operation];
49
55
  if (!permKey) return true;
50
56
  for (const ps of permissionSets) {
51
- const objPerm = ps.objects?.[objectName];
57
+ const objPerm = ps.objects?.[objectName] ?? ps.objects?.["*"];
52
58
  if (objPerm) {
53
59
  if (["allowEdit", "allowDelete"].includes(permKey) && objPerm.modifyAllRecords) {
54
60
  return true;
@@ -85,31 +91,94 @@ var PermissionEvaluator = class {
85
91
  return result;
86
92
  }
87
93
  /**
88
- * Resolve permission sets for a list of role names from metadata.
94
+ * Resolve permission sets for a list of identifier names from metadata.
95
+ *
96
+ * Identifiers are matched to `PermissionSet.name`. The names may be
97
+ * either role names (when `sys_role.name` is reused as a permission set
98
+ * name — common for default admin/member/viewer roles) or explicit
99
+ * permission set names supplied through `ExecutionContext.permissions[]`
100
+ * (resolved by `resolveExecutionContext` from `sys_user_permission_set`
101
+ * and `sys_role_permission_set`).
102
+ *
103
+ * Async because the underlying metadata service exposes `list()` as a
104
+ * Promise — synchronous iteration would silently yield zero results
105
+ * (the historical SecurityPlugin behaviour, masking all enforcement).
106
+ *
107
+ * `bootstrapPermissionSets` is a fallback list of plugin-owned permission
108
+ * sets (typically the platform defaults: admin_full_access /
109
+ * member_default / viewer_readonly) that are registered via
110
+ * `manifest.register({ permissions })` but do not currently propagate
111
+ * into the metadata service's `list()` index. Without this fallback,
112
+ * SecurityPlugin would never resolve the defaults and all enforcement
113
+ * would be silently disabled for authenticated requests.
89
114
  */
90
- resolvePermissionSets(roles, metadataService) {
115
+ async resolvePermissionSets(identifiers, metadataService, bootstrapPermissionSets = [], dbLoader) {
116
+ if (identifiers.length === 0) return [];
91
117
  const result = [];
92
- const allPermSets = metadataService.list?.("permissions") || [];
118
+ const seen = /* @__PURE__ */ new Set();
119
+ let allPermSets = [];
120
+ try {
121
+ const listed = metadataService?.list?.("permission") ?? metadataService?.list?.("permissions") ?? [];
122
+ allPermSets = typeof listed?.then === "function" ? await listed : listed;
123
+ } catch {
124
+ allPermSets = [];
125
+ }
126
+ if (!Array.isArray(allPermSets)) allPermSets = [];
127
+ const wanted = new Set(identifiers);
93
128
  for (const ps of allPermSets) {
94
- if (roles.includes(ps.name)) {
129
+ if (wanted.has(ps.name) && !seen.has(ps.name)) {
130
+ seen.add(ps.name);
131
+ result.push(ps);
132
+ }
133
+ }
134
+ for (const ps of bootstrapPermissionSets) {
135
+ if (wanted.has(ps.name) && !seen.has(ps.name)) {
136
+ seen.add(ps.name);
95
137
  result.push(ps);
96
138
  }
97
139
  }
140
+ if (dbLoader) {
141
+ const unresolved = identifiers.filter((n) => !seen.has(n));
142
+ if (unresolved.length > 0) {
143
+ try {
144
+ const dbRows = await dbLoader(unresolved);
145
+ for (const ps of dbRows ?? []) {
146
+ if (ps?.name && !seen.has(ps.name)) {
147
+ seen.add(ps.name);
148
+ result.push(ps);
149
+ }
150
+ }
151
+ } catch {
152
+ }
153
+ }
154
+ }
98
155
  return result;
99
156
  }
100
157
  };
101
158
 
102
159
  // src/rls-compiler.ts
160
+ var RLS_DENY_FILTER = Object.freeze({
161
+ id: "__rls_deny__:00000000-0000-0000-0000-000000000000"
162
+ });
103
163
  var RLSCompiler = class {
104
164
  /**
105
165
  * Compile RLS policies into a query filter for the given user context.
106
166
  * Multiple policies for the same object/operation are OR-combined (any match allows access).
167
+ *
168
+ * Return-value semantics:
169
+ * - `null` → no policies applicable → caller applies no RLS filter.
170
+ * - non-null → caller AND's it onto the existing where clause.
171
+ * - {@link RLS_DENY_FILTER} → policies were defined but none could be
172
+ * compiled (e.g. wildcard `tenant_isolation` against a user with no
173
+ * active organization). The caller must treat this as "deny by
174
+ * default" — its `id` comparison naturally yields zero rows on
175
+ * select/update/delete, which is the safe fail-closed answer.
107
176
  */
108
177
  compileFilter(policies, executionContext) {
109
178
  if (policies.length === 0) return null;
110
179
  const userCtx = {
111
180
  id: executionContext?.userId,
112
- tenant_id: executionContext?.tenantId,
181
+ organization_id: executionContext?.tenantId,
113
182
  roles: executionContext?.roles
114
183
  };
115
184
  const filters = [];
@@ -120,7 +189,9 @@ var RLSCompiler = class {
120
189
  filters.push(filter);
121
190
  }
122
191
  }
123
- if (filters.length === 0) return null;
192
+ if (filters.length === 0) {
193
+ return RLS_DENY_FILTER;
194
+ }
124
195
  if (filters.length === 1) return filters[0];
125
196
  return { $or: filters };
126
197
  }
@@ -138,7 +209,7 @@ var RLSCompiler = class {
138
209
  if (eqMatch) {
139
210
  const [, field, prop] = eqMatch;
140
211
  const value = userCtx[prop];
141
- if (value === void 0) return null;
212
+ if (value === void 0 || value === null) return null;
142
213
  return { [field]: value };
143
214
  }
144
215
  const litMatch = expression.match(/^\s*(\w+)\s*=\s*'([^']*)'\s*$/);
@@ -150,7 +221,7 @@ var RLSCompiler = class {
150
221
  if (inMatch) {
151
222
  const [, field, prop] = inMatch;
152
223
  const value = userCtx[prop];
153
- if (!Array.isArray(value)) return null;
224
+ if (!Array.isArray(value) || value.length === 0) return null;
154
225
  return { [field]: { $in: value } };
155
226
  }
156
227
  return null;
@@ -220,6 +291,40 @@ var FieldMasker = class {
220
291
  }
221
292
  return result;
222
293
  }
294
+ /**
295
+ * Detect which fields in the caller's write payload would touch a
296
+ * field they are not allowed to edit. Returns the set of offending
297
+ * field names (no duplicates, sorted for stable error messages).
298
+ *
299
+ * Used by the security middleware on insert/update to fail-closed
300
+ * with an explicit 403 rather than silently dropping fields — a
301
+ * silent drop hides the security boundary from honest clients
302
+ * (their update partially "doesn't save") and gives an attacker no
303
+ * negative signal that the field exists. Throwing makes the
304
+ * boundary observable in both directions.
305
+ *
306
+ * `data` may be a single record or an array of records (bulk insert);
307
+ * either way the returned list is the union across all rows.
308
+ *
309
+ * Fields without a permission entry pass through — permission sets
310
+ * are an allow-list at the field level only for fields they
311
+ * explicitly enumerate. Most objects do not declare per-field rules
312
+ * and remain fully editable.
313
+ */
314
+ detectForbiddenWrites(data, fieldPermissions) {
315
+ if (Object.keys(fieldPermissions).length === 0) return [];
316
+ const nonEditable = new Set(this.getNonEditableFields(fieldPermissions));
317
+ if (nonEditable.size === 0) return [];
318
+ const offenders = /* @__PURE__ */ new Set();
319
+ const rows = Array.isArray(data) ? data : [data];
320
+ for (const row of rows) {
321
+ if (!row || typeof row !== "object") continue;
322
+ for (const field of Object.keys(row)) {
323
+ if (nonEditable.has(field)) offenders.add(field);
324
+ }
325
+ }
326
+ return Array.from(offenders).sort();
327
+ }
223
328
  maskRecord(record, hiddenFields) {
224
329
  if (!record || typeof record !== "object") return record;
225
330
  const result = { ...record };
@@ -230,155 +335,487 @@ var FieldMasker = class {
230
335
  }
231
336
  };
232
337
 
233
- // src/objects/sys-role.object.ts
234
- var import_data = require("@objectstack/spec/data");
235
- var SysRole = import_data.ObjectSchema.create({
236
- namespace: "sys",
237
- name: "role",
238
- label: "Role",
239
- pluralLabel: "Roles",
240
- icon: "shield",
241
- isSystem: true,
242
- description: "Role definitions for RBAC access control",
243
- titleFormat: "{name}",
244
- compactLayout: ["name", "label", "active"],
245
- fields: {
246
- id: import_data.Field.text({
247
- label: "Role ID",
248
- required: true,
249
- readonly: true
250
- }),
251
- created_at: import_data.Field.datetime({
252
- label: "Created At",
253
- defaultValue: "NOW()",
254
- readonly: true
255
- }),
256
- updated_at: import_data.Field.datetime({
257
- label: "Updated At",
258
- defaultValue: "NOW()",
259
- readonly: true
260
- }),
261
- name: import_data.Field.text({
262
- label: "API Name",
263
- required: true,
264
- searchable: true,
265
- maxLength: 100,
266
- description: "Unique machine name for the role (e.g. admin, editor, viewer)"
267
- }),
268
- label: import_data.Field.text({
269
- label: "Display Name",
270
- required: true,
271
- maxLength: 255
272
- }),
273
- description: import_data.Field.textarea({
274
- label: "Description",
275
- required: false
276
- }),
277
- permissions: import_data.Field.textarea({
278
- label: "Permissions",
279
- required: false,
280
- description: "JSON-serialized array of permission strings"
281
- }),
282
- active: import_data.Field.boolean({
283
- label: "Active",
284
- defaultValue: true
285
- }),
286
- is_default: import_data.Field.boolean({
287
- label: "Default Role",
288
- defaultValue: false,
289
- description: "Automatically assigned to new users"
290
- })
291
- },
292
- indexes: [
293
- { fields: ["name"], unique: true },
294
- { fields: ["active"] }
295
- ],
296
- enable: {
297
- trackHistory: true,
298
- searchable: true,
299
- apiEnabled: true,
300
- apiMethods: ["get", "list", "create", "update", "delete"],
301
- trash: true,
302
- mru: true
338
+ // src/errors.ts
339
+ var PermissionDeniedError = class extends Error {
340
+ constructor(message, details) {
341
+ super(message);
342
+ this.code = "PERMISSION_DENIED";
343
+ this.statusCode = 403;
344
+ this.name = "PermissionDeniedError";
345
+ this.details = details;
303
346
  }
304
- });
347
+ };
348
+ function isPermissionDeniedError(e) {
349
+ if (!e || typeof e !== "object") return false;
350
+ const anyE = e;
351
+ return anyE.name === "PermissionDeniedError" || anyE.code === "PERMISSION_DENIED" || typeof anyE.message === "string" && anyE.message.startsWith("[Security] Access denied");
352
+ }
305
353
 
306
- // src/objects/sys-permission-set.object.ts
307
- var import_data2 = require("@objectstack/spec/data");
308
- var SysPermissionSet = import_data2.ObjectSchema.create({
309
- namespace: "sys",
310
- name: "permission_set",
311
- label: "Permission Set",
312
- pluralLabel: "Permission Sets",
313
- icon: "lock",
314
- isSystem: true,
315
- description: "Named permission groupings for fine-grained access control",
316
- titleFormat: "{name}",
317
- compactLayout: ["name", "label", "active"],
318
- fields: {
319
- id: import_data2.Field.text({
320
- label: "Permission Set ID",
321
- required: true,
322
- readonly: true
323
- }),
324
- created_at: import_data2.Field.datetime({
325
- label: "Created At",
326
- defaultValue: "NOW()",
327
- readonly: true
328
- }),
329
- updated_at: import_data2.Field.datetime({
330
- label: "Updated At",
331
- defaultValue: "NOW()",
332
- readonly: true
333
- }),
334
- name: import_data2.Field.text({
335
- label: "API Name",
336
- required: true,
337
- searchable: true,
338
- maxLength: 100,
339
- description: "Unique machine name for the permission set"
340
- }),
341
- label: import_data2.Field.text({
342
- label: "Display Name",
343
- required: true,
344
- maxLength: 255
345
- }),
346
- description: import_data2.Field.textarea({
347
- label: "Description",
348
- required: false
349
- }),
350
- object_permissions: import_data2.Field.textarea({
351
- label: "Object Permissions",
352
- required: false,
353
- description: "JSON-serialized object-level CRUD permissions"
354
- }),
355
- field_permissions: import_data2.Field.textarea({
356
- label: "Field Permissions",
357
- required: false,
358
- description: "JSON-serialized field-level read/write permissions"
359
- }),
360
- active: import_data2.Field.boolean({
361
- label: "Active",
362
- defaultValue: true
363
- })
364
- },
365
- indexes: [
366
- { fields: ["name"], unique: true },
367
- { fields: ["active"] }
368
- ],
369
- enable: {
370
- trackHistory: true,
371
- searchable: true,
372
- apiEnabled: true,
373
- apiMethods: ["get", "list", "create", "update", "delete"],
374
- trash: true,
375
- mru: true
354
+ // src/bootstrap-platform-admin.ts
355
+ var SYSTEM_CTX = { isSystem: true };
356
+ async function tryFind(ql, object, where, limit = 100) {
357
+ try {
358
+ const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX });
359
+ return Array.isArray(rows) ? rows : [];
360
+ } catch {
361
+ return [];
376
362
  }
377
- });
363
+ }
364
+ async function tryInsert(ql, object, data) {
365
+ try {
366
+ return await ql.insert(object, data, { context: SYSTEM_CTX });
367
+ } catch {
368
+ return null;
369
+ }
370
+ }
371
+ function genId(prefix) {
372
+ const rand = Math.random().toString(36).slice(2, 10);
373
+ const ts = Date.now().toString(36);
374
+ return `${prefix}_${ts}${rand}`;
375
+ }
376
+ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {}) {
377
+ const logger = options.logger;
378
+ if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
379
+ return { seeded: 0, adminPromoted: false, reason: "objectql_unavailable" };
380
+ }
381
+ const seeded = {};
382
+ for (const ps of bootstrapPermissionSets) {
383
+ if (!ps.name) continue;
384
+ const existing = await tryFind(ql, "sys_permission_set", { name: ps.name }, 1);
385
+ if (existing.length > 0 && existing[0].id) {
386
+ seeded[ps.name] = existing[0].id;
387
+ continue;
388
+ }
389
+ const id = genId("ps");
390
+ const created = await tryInsert(ql, "sys_permission_set", {
391
+ id,
392
+ name: ps.name,
393
+ label: ps.label ?? ps.name,
394
+ description: ps.description ?? null,
395
+ object_permissions: JSON.stringify(ps.objects ?? {}),
396
+ field_permissions: JSON.stringify(ps.fields ?? {}),
397
+ active: true
398
+ });
399
+ if (created?.id) seeded[ps.name] = created.id;
400
+ else if (created) seeded[ps.name] = id;
401
+ }
402
+ const seededCount = Object.keys(seeded).length;
403
+ const adminPsId = seeded["admin_full_access"];
404
+ if (!adminPsId) {
405
+ return { seeded: seededCount, adminPromoted: false, reason: "admin_permission_set_missing" };
406
+ }
407
+ const existingAdminLinks = await tryFind(
408
+ ql,
409
+ "sys_user_permission_set",
410
+ { permission_set_id: adminPsId },
411
+ 5
412
+ );
413
+ if (existingAdminLinks.some((r) => !r.organization_id)) {
414
+ return { seeded: seededCount, adminPromoted: false, reason: "already_have_admin" };
415
+ }
416
+ const allUsers = await tryFind(ql, "sys_user", {}, 50);
417
+ if (allUsers.length === 0) {
418
+ logger?.info?.("[security] no users yet \u2014 first sign-up will be promoted to platform admin");
419
+ return { seeded: seededCount, adminPromoted: false, reason: "no_users" };
420
+ }
421
+ const sorted = [...allUsers].sort((a, b) => {
422
+ const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
423
+ const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
424
+ return ta - tb;
425
+ });
426
+ const target = sorted[0];
427
+ const inserted = await tryInsert(ql, "sys_user_permission_set", {
428
+ id: genId("ups"),
429
+ user_id: target.id,
430
+ permission_set_id: adminPsId,
431
+ organization_id: null,
432
+ granted_by: null
433
+ });
434
+ if (!inserted) {
435
+ logger?.warn?.(`[security] failed to grant admin_full_access to first user ${target.email ?? target.id}`);
436
+ return { seeded: seededCount, adminPromoted: false, reason: "insert_failed" };
437
+ }
438
+ logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
439
+ return { seeded: seededCount, adminPromoted: true };
440
+ }
441
+
442
+ // src/claim-orphan-tenant-rows.ts
443
+ var SYSTEM_CTX2 = { isSystem: true };
444
+ function hasOrganizationField(schema) {
445
+ const fields = schema?.fields;
446
+ if (!fields) return false;
447
+ if (Array.isArray(fields)) {
448
+ return fields.some((f) => f?.name === "organization_id");
449
+ }
450
+ return Object.prototype.hasOwnProperty.call(fields, "organization_id");
451
+ }
452
+ async function claimOrphanTenantRows(ql, organizationId, options = {}) {
453
+ const logger = options.logger;
454
+ if (!ql || typeof ql.update !== "function" || typeof ql.find !== "function") {
455
+ return [];
456
+ }
457
+ const registry = ql.registry;
458
+ if (!registry || typeof registry.getAllObjects !== "function") {
459
+ logger?.warn?.("[security] claimOrphanTenantRows: registry unavailable");
460
+ return [];
461
+ }
462
+ const schemas = registry.getAllObjects();
463
+ const results = [];
464
+ for (const schema of schemas) {
465
+ if (!schema?.name) continue;
466
+ if (schema.managedBy) continue;
467
+ if (schema.name.startsWith("sys_")) continue;
468
+ if (!hasOrganizationField(schema)) continue;
469
+ try {
470
+ const orphans = await ql.find(
471
+ schema.name,
472
+ { where: { organization_id: null }, limit: 1e4, fields: ["id"] },
473
+ { context: SYSTEM_CTX2 }
474
+ );
475
+ const list = Array.isArray(orphans) ? orphans : Array.isArray(orphans?.records) ? orphans.records : [];
476
+ if (list.length === 0) continue;
477
+ let updated = 0;
478
+ for (const row of list) {
479
+ if (!row?.id) continue;
480
+ try {
481
+ await ql.update(
482
+ schema.name,
483
+ { id: row.id, organization_id: organizationId },
484
+ { context: SYSTEM_CTX2 }
485
+ );
486
+ updated += 1;
487
+ } catch (e) {
488
+ logger?.warn?.(`[security] claim failed for ${schema.name}:${row.id}`, {
489
+ error: e.message
490
+ });
491
+ }
492
+ }
493
+ if (updated > 0) {
494
+ results.push({ object: schema.name, count: updated });
495
+ }
496
+ } catch (e) {
497
+ logger?.warn?.(`[security] claim scan failed for ${schema.name}`, {
498
+ error: e.message
499
+ });
500
+ }
501
+ }
502
+ if (results.length > 0) {
503
+ const total = results.reduce((s, r) => s + r.count, 0);
504
+ logger?.info?.(`[security] claimed ${total} orphan seed row(s) for organization ${organizationId}`, {
505
+ breakdown: results
506
+ });
507
+ }
508
+ return results;
509
+ }
510
+
511
+ // src/clone-tenant-seed-data.ts
512
+ var SYSTEM_CTX3 = { isSystem: true };
513
+ var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
514
+ "id",
515
+ "created_at",
516
+ "updated_at",
517
+ "organization_id"
518
+ ]);
519
+ var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary"]);
520
+ function fieldList(schema) {
521
+ const fields = schema?.fields;
522
+ if (!fields) return [];
523
+ if (Array.isArray(fields)) {
524
+ return fields.map((f) => ({
525
+ name: f?.name,
526
+ type: f?.type,
527
+ reference: f?.reference,
528
+ multiple: f?.multiple,
529
+ unique: f?.unique
530
+ }));
531
+ }
532
+ return Object.entries(fields).map(([name, f]) => ({
533
+ name,
534
+ type: f?.type,
535
+ reference: f?.reference,
536
+ multiple: f?.multiple,
537
+ unique: f?.unique
538
+ }));
539
+ }
540
+ function isLookupField(f) {
541
+ return (f.type === "lookup" || f.type === "master_detail" || f.type === "tree") && !!f.reference;
542
+ }
543
+ function hasOrgField(schema) {
544
+ return fieldList(schema).some((f) => f.name === "organization_id");
545
+ }
546
+ function shortId() {
547
+ const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-";
548
+ let out = "";
549
+ for (let i = 0; i < 16; i++) {
550
+ out += alphabet[Math.floor(Math.random() * alphabet.length)];
551
+ }
552
+ return out;
553
+ }
554
+ async function findDonorOrgId(ql) {
555
+ try {
556
+ const res = await ql.find(
557
+ "sys_organization",
558
+ { orderBy: { created_at: "asc" }, limit: 1, fields: ["id"] },
559
+ { context: SYSTEM_CTX3 }
560
+ );
561
+ const list = Array.isArray(res) ? res : Array.isArray(res?.records) ? res.records : [];
562
+ return list[0]?.id ?? null;
563
+ } catch {
564
+ return null;
565
+ }
566
+ }
567
+ async function cloneTenantSeedData(ql, targetOrgId, options = {}) {
568
+ const logger = options.logger;
569
+ if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
570
+ return [];
571
+ }
572
+ const registry = ql.registry;
573
+ if (!registry || typeof registry.getAllObjects !== "function") {
574
+ logger?.warn?.("[security] cloneTenantSeedData: registry unavailable");
575
+ return [];
576
+ }
577
+ const donorOrgId = await findDonorOrgId(ql);
578
+ if (!donorOrgId) return [];
579
+ if (donorOrgId === targetOrgId) return [];
580
+ const schemas = registry.getAllObjects().filter(
581
+ (s) => s?.name && !s.managedBy && !s.name.startsWith("sys_") && hasOrgField(s)
582
+ );
583
+ const remap = {};
584
+ const summary = [];
585
+ const inserted = [];
586
+ for (const schema of schemas) {
587
+ const objectName = schema.name;
588
+ try {
589
+ const existing = await ql.find(
590
+ objectName,
591
+ { where: { organization_id: targetOrgId }, limit: 1, fields: ["id"] },
592
+ { context: SYSTEM_CTX3 }
593
+ );
594
+ const existingList = Array.isArray(existing) ? existing : Array.isArray(existing?.records) ? existing.records : [];
595
+ if (existingList.length > 0) {
596
+ continue;
597
+ }
598
+ const donorRows = await ql.find(
599
+ objectName,
600
+ { where: { organization_id: donorOrgId }, limit: 1e4 },
601
+ { context: SYSTEM_CTX3 }
602
+ );
603
+ const rows = Array.isArray(donorRows) ? donorRows : Array.isArray(donorRows?.records) ? donorRows.records : [];
604
+ if (rows.length === 0) continue;
605
+ const fields = fieldList(schema);
606
+ const lookups = fields.filter(isLookupField);
607
+ const uniqueFields = fields.filter((f) => f.unique && !SKIP_COPY_FIELDS.has(f.name));
608
+ const objectRemap = remap[objectName] ?? (remap[objectName] = {});
609
+ let cloned = 0;
610
+ for (const row of rows) {
611
+ const newId = shortId();
612
+ const data = { id: newId, organization_id: targetOrgId };
613
+ for (const f of fields) {
614
+ if (SKIP_COPY_FIELDS.has(f.name)) continue;
615
+ if (f.type && SKIP_COPY_TYPES.has(f.type)) continue;
616
+ if (row[f.name] === void 0) continue;
617
+ data[f.name] = row[f.name];
618
+ }
619
+ const suffix = `+${targetOrgId.slice(-6)}`;
620
+ for (const uf of uniqueFields) {
621
+ const v = data[uf.name];
622
+ if (typeof v !== "string" || !v) continue;
623
+ if (uf.type === "email" && v.includes("@")) {
624
+ const [local, domain] = v.split("@");
625
+ data[uf.name] = `clone-${targetOrgId.slice(-6)}-${local}@${domain}`;
626
+ } else {
627
+ data[uf.name] = `${v}${suffix}`;
628
+ }
629
+ }
630
+ try {
631
+ await ql.insert(objectName, data, { context: SYSTEM_CTX3 });
632
+ objectRemap[row.id] = newId;
633
+ inserted.push({ object: objectName, newId, record: data, lookups });
634
+ cloned++;
635
+ } catch (e) {
636
+ logger?.warn?.("[security] cloneTenantSeedData: insert failed", {
637
+ object: objectName,
638
+ error: e.message
639
+ });
640
+ }
641
+ }
642
+ if (cloned > 0) summary.push({ object: objectName, count: cloned });
643
+ } catch (e) {
644
+ logger?.warn?.("[security] cloneTenantSeedData: object failed", {
645
+ object: objectName,
646
+ error: e.message
647
+ });
648
+ }
649
+ }
650
+ for (const item of inserted) {
651
+ if (item.lookups.length === 0) continue;
652
+ const patch = {};
653
+ let dirty = false;
654
+ for (const f of item.lookups) {
655
+ const oldVal = item.record[f.name];
656
+ if (oldVal == null) continue;
657
+ const targetMap = remap[f.reference];
658
+ if (Array.isArray(oldVal)) {
659
+ const next = oldVal.map((v) => typeof v === "string" && targetMap?.[v] || null).filter((v) => v != null);
660
+ if (next.length !== oldVal.length || next.some((v, i) => v !== oldVal[i])) {
661
+ patch[f.name] = next.length > 0 ? next : null;
662
+ dirty = true;
663
+ }
664
+ } else if (typeof oldVal === "string") {
665
+ if (targetMap && targetMap[oldVal]) {
666
+ patch[f.name] = targetMap[oldVal];
667
+ dirty = true;
668
+ } else {
669
+ patch[f.name] = null;
670
+ dirty = true;
671
+ }
672
+ }
673
+ }
674
+ if (!dirty) continue;
675
+ try {
676
+ await ql.update(item.object, { id: item.newId, ...patch }, { context: SYSTEM_CTX3 });
677
+ } catch (e) {
678
+ logger?.warn?.("[security] cloneTenantSeedData: lookup remap failed", {
679
+ object: item.object,
680
+ id: item.newId,
681
+ error: e.message
682
+ });
683
+ }
684
+ }
685
+ return summary;
686
+ }
687
+
688
+ // src/ensure-user-has-organization.ts
689
+ var SYSTEM_CTX4 = { isSystem: true };
690
+ function genId2(prefix) {
691
+ const rand = Math.random().toString(36).slice(2, 10);
692
+ const ts = Date.now().toString(36);
693
+ return `${prefix}_${ts}${rand}`;
694
+ }
695
+ function slugify(input) {
696
+ return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "workspace";
697
+ }
698
+ function deriveBaseName(user) {
699
+ if (user.name && user.name.trim()) return user.name.trim();
700
+ if (user.email) {
701
+ const local = user.email.split("@")[0];
702
+ if (local) return local;
703
+ }
704
+ return user.id;
705
+ }
706
+ async function tryFind2(ql, object, where, limit = 1) {
707
+ try {
708
+ const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX4 });
709
+ return Array.isArray(rows) ? rows : [];
710
+ } catch {
711
+ return [];
712
+ }
713
+ }
714
+ async function ensureUserHasOrganization(ql, user, options = {}) {
715
+ const logger = options.logger;
716
+ const cloneSeedData = options.cloneSeedData;
717
+ if (!ql || typeof ql.find !== "function" || typeof ql.insert !== "function") {
718
+ return { created: false, reason: "objectql_unavailable" };
719
+ }
720
+ if (!user?.id) return { created: false, reason: "invalid_user" };
721
+ const existing = await tryFind2(ql, "sys_member", { user_id: user.id }, 1);
722
+ if (existing.length > 0) {
723
+ return { created: false, reason: "already_member" };
724
+ }
725
+ const base = deriveBaseName(user);
726
+ const orgName = `${base}'s Workspace`;
727
+ const baseSlug = slugify(base);
728
+ let slug = `${baseSlug}-workspace`;
729
+ for (let attempt = 1; attempt <= 5; attempt += 1) {
730
+ const collision = await tryFind2(ql, "sys_organization", { slug }, 1);
731
+ if (collision.length === 0) break;
732
+ slug = `${baseSlug}-workspace-${attempt + 1}`;
733
+ if (attempt === 5) {
734
+ logger?.warn?.(
735
+ `[security] could not find a free slug for personal org of ${user.email ?? user.id}`
736
+ );
737
+ return { created: false, reason: "slug_exhausted" };
738
+ }
739
+ }
740
+ const orgId = genId2("org");
741
+ let orgRow = null;
742
+ try {
743
+ orgRow = await ql.insert(
744
+ "sys_organization",
745
+ { id: orgId, name: orgName, slug, logo: null, metadata: null },
746
+ { context: SYSTEM_CTX4 }
747
+ );
748
+ } catch (e) {
749
+ logger?.warn?.(`[security] failed to create personal org for ${user.email ?? user.id}`, {
750
+ error: e.message
751
+ });
752
+ return { created: false, reason: "org_insert_failed" };
753
+ }
754
+ const finalOrgId = orgRow?.id ?? orgId;
755
+ try {
756
+ await ql.insert(
757
+ "sys_member",
758
+ {
759
+ id: genId2("mem"),
760
+ organization_id: finalOrgId,
761
+ user_id: user.id,
762
+ role: "owner"
763
+ },
764
+ { context: SYSTEM_CTX4 }
765
+ );
766
+ } catch (e) {
767
+ logger?.warn?.(`[security] failed to create owner-member row for ${user.email ?? user.id}`, {
768
+ error: e.message
769
+ });
770
+ return { created: false, reason: "member_insert_failed", organizationId: finalOrgId };
771
+ }
772
+ logger?.info?.(
773
+ `[security] created personal organization "${orgName}" (${finalOrgId}) for ${user.email ?? user.id}`
774
+ );
775
+ if (cloneSeedData) {
776
+ try {
777
+ const summary = await cloneSeedData(ql, finalOrgId, { logger });
778
+ if (summary.length > 0) {
779
+ const total = summary.reduce((s, c) => s + c.count, 0);
780
+ logger?.info?.(
781
+ `[security] cloned ${total} seed row(s) into personal organization ${finalOrgId}`,
782
+ { breakdown: summary }
783
+ );
784
+ }
785
+ } catch (e) {
786
+ logger?.warn?.("[security] cloneTenantSeedData failed", {
787
+ error: e.message
788
+ });
789
+ }
790
+ }
791
+ return { created: true, organizationId: finalOrgId };
792
+ }
793
+
794
+ // src/manifest.ts
795
+ var import_security = require("@objectstack/platform-objects/security");
796
+ var SECURITY_PLUGIN_ID = "com.objectstack.plugin-security";
797
+ var SECURITY_PLUGIN_VERSION = "1.0.0";
798
+ var securityObjects = [
799
+ import_security.SysRole,
800
+ import_security.SysPermissionSet,
801
+ import_security.SysUserPermissionSet,
802
+ import_security.SysRolePermissionSet
803
+ ];
804
+ var securityDefaultPermissionSets = import_security.defaultPermissionSets;
805
+ var securityPluginManifestHeader = {
806
+ id: SECURITY_PLUGIN_ID,
807
+ namespace: "sys",
808
+ version: SECURITY_PLUGIN_VERSION,
809
+ type: "plugin",
810
+ scope: "system",
811
+ defaultDatasource: "cloud",
812
+ name: "Security Plugin",
813
+ description: "RBAC roles and permission sets for ObjectStack (Role, PermissionSet)"
814
+ };
378
815
 
379
816
  // src/security-plugin.ts
380
817
  var SecurityPlugin = class {
381
- constructor() {
818
+ constructor(options = {}) {
382
819
  this.name = "com.objectstack.security";
383
820
  this.type = "standard";
384
821
  this.version = "1.0.0";
@@ -386,35 +823,47 @@ var SecurityPlugin = class {
386
823
  this.permissionEvaluator = new PermissionEvaluator();
387
824
  this.rlsCompiler = new RLSCompiler();
388
825
  this.fieldMasker = new FieldMasker();
826
+ /**
827
+ * Per-object field-name cache. Populated lazily from the metadata
828
+ * service / ObjectQL registry on first access per object. Schemas are
829
+ * effectively immutable for the lifetime of the kernel today (hot
830
+ * reload tears the kernel down), so we don't bother with
831
+ * invalidation — a kernel restart drops the cache.
832
+ */
833
+ this.fieldNamesCache = /* @__PURE__ */ new Map();
834
+ /**
835
+ * Per-object cache of tenancy opt-out. `true` means the schema
836
+ * explicitly disabled multi-tenancy (`tenancy.enabled === false` or
837
+ * `systemFields.tenant === false`). Wildcard policies that target
838
+ * the conventional tenant column (`organization_id`) are treated as
839
+ * *not applicable* on these tables instead of triggering the
840
+ * field-missing deny sentinel — without this, every read of a
841
+ * cross-org catalog (e.g. `sys_package`, the Marketplace) returns
842
+ * zero rows.
843
+ */
844
+ this.tenancyDisabledCache = /* @__PURE__ */ new Map();
845
+ this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
846
+ this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
847
+ this.multiTenant = options.multiTenant !== false;
389
848
  }
390
849
  async init(ctx) {
391
850
  ctx.logger.info("Initializing Security Plugin...");
392
851
  ctx.registerService("security.permissions", this.permissionEvaluator);
393
852
  ctx.registerService("security.rls", this.rlsCompiler);
394
853
  ctx.registerService("security.fieldMasker", this.fieldMasker);
854
+ ctx.registerService("security.bootstrapPermissionSets", this.bootstrapPermissionSets);
855
+ ctx.registerService("security.fallbackPermissionSet", this.fallbackPermissionSet);
395
856
  ctx.getService("manifest").register({
396
- id: "com.objectstack.security",
397
- name: "Security",
398
- version: "1.0.0",
399
- type: "plugin",
400
- namespace: "sys",
401
- objects: [SysRole, SysPermissionSet]
857
+ ...securityPluginManifestHeader,
858
+ objects: securityObjects,
859
+ // Permission sets ride along on the manifest so the metadata service
860
+ // can resolve them by name when SecurityPlugin middleware queries
861
+ // `metadata.list('permissions')`.
862
+ permissions: this.bootstrapPermissionSets
863
+ });
864
+ ctx.logger.info("Security Plugin initialized", {
865
+ defaultPermissionSets: this.bootstrapPermissionSets.map((p) => p.name)
402
866
  });
403
- try {
404
- const setupNav = ctx.getService("setupNav");
405
- if (setupNav) {
406
- setupNav.contribute({
407
- areaId: "area_administration",
408
- items: [
409
- { id: "nav_roles", type: "object", label: "Roles", objectName: "role", icon: "shield-check", order: 60 },
410
- { id: "nav_permission_sets", type: "object", label: "Permission Sets", objectName: "permission_set", icon: "lock", order: 70 }
411
- ]
412
- });
413
- ctx.logger.info("Security navigation items contributed to Setup App");
414
- }
415
- } catch {
416
- }
417
- ctx.logger.info("Security Plugin initialized");
418
867
  }
419
868
  async start(ctx) {
420
869
  ctx.logger.info("Starting Security Plugin...");
@@ -431,17 +880,55 @@ var SecurityPlugin = class {
431
880
  ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
432
881
  return;
433
882
  }
883
+ const dbLoader = ql ? async (names) => {
884
+ let rows;
885
+ try {
886
+ rows = await ql.find(
887
+ "sys_permission_set",
888
+ { where: { name: { $in: names } }, limit: names.length },
889
+ { context: { isSystem: true } }
890
+ );
891
+ } catch {
892
+ rows = [];
893
+ }
894
+ const list = Array.isArray(rows) ? rows : rows?.records ?? [];
895
+ return list.map((r) => ({
896
+ name: r.name,
897
+ label: r.label,
898
+ objects: typeof r.object_permissions === "string" ? JSON.parse(r.object_permissions || "{}") : r.object_permissions ?? {},
899
+ fields: typeof r.field_permissions === "string" ? JSON.parse(r.field_permissions || "{}") : r.field_permissions ?? {}
900
+ }));
901
+ } : void 0;
434
902
  ql.registerMiddleware(async (opCtx, next) => {
435
903
  if (opCtx.context?.isSystem) {
436
904
  return next();
437
905
  }
438
906
  const roles = opCtx.context?.roles ?? [];
439
- if (roles.length === 0 && !opCtx.context?.userId) {
907
+ const explicitPermissionSets = opCtx.context?.permissions ?? [];
908
+ if (roles.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId) {
440
909
  return next();
441
910
  }
442
911
  let permissionSets = [];
443
912
  try {
444
- permissionSets = this.permissionEvaluator.resolvePermissionSets(roles, metadata);
913
+ const requested = [...roles, ...explicitPermissionSets];
914
+ if (requested.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
915
+ requested.push(this.fallbackPermissionSet);
916
+ }
917
+ permissionSets = await this.permissionEvaluator.resolvePermissionSets(
918
+ requested,
919
+ metadata,
920
+ this.bootstrapPermissionSets,
921
+ dbLoader
922
+ );
923
+ if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
924
+ const fallback = await this.permissionEvaluator.resolvePermissionSets(
925
+ [this.fallbackPermissionSet],
926
+ metadata,
927
+ this.bootstrapPermissionSets,
928
+ dbLoader
929
+ );
930
+ permissionSets = fallback;
931
+ }
445
932
  } catch (e) {
446
933
  return next();
447
934
  }
@@ -452,14 +939,71 @@ var SecurityPlugin = class {
452
939
  permissionSets
453
940
  );
454
941
  if (!allowed) {
455
- throw new Error(
456
- `[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' is not permitted for roles [${roles.join(", ")}]`
942
+ throw new PermissionDeniedError(
943
+ `[Security] Access denied: operation '${opCtx.operation}' on object '${opCtx.object}' is not permitted for roles [${roles.join(", ")}]`,
944
+ { operation: opCtx.operation, object: opCtx.object, roles, permissionSets: explicitPermissionSets }
945
+ );
946
+ }
947
+ }
948
+ if ((opCtx.operation === "insert" || opCtx.operation === "update") && opCtx.data && permissionSets.length > 0) {
949
+ const fieldPerms = this.permissionEvaluator.getFieldPermissions(
950
+ opCtx.object,
951
+ permissionSets
952
+ );
953
+ if (Object.keys(fieldPerms).length > 0) {
954
+ const forbidden = this.fieldMasker.detectForbiddenWrites(
955
+ opCtx.data,
956
+ fieldPerms
457
957
  );
958
+ if (forbidden.length > 0) {
959
+ throw new PermissionDeniedError(
960
+ `[Security] Field write denied: not permitted to edit [${forbidden.join(", ")}] on '${opCtx.object}'`,
961
+ {
962
+ operation: opCtx.operation,
963
+ object: opCtx.object,
964
+ roles,
965
+ permissionSets: explicitPermissionSets,
966
+ forbiddenFields: forbidden
967
+ }
968
+ );
969
+ }
970
+ }
971
+ }
972
+ if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
973
+ const needsTenant = this.multiTenant && !!opCtx.context?.tenantId;
974
+ const needsOwner = !!opCtx.context?.userId;
975
+ if (needsTenant || needsOwner) {
976
+ const fields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
977
+ if (fields) {
978
+ const data = opCtx.data;
979
+ if (needsTenant && fields.has("organization_id") && (data.organization_id == null || data.organization_id === "")) {
980
+ data.organization_id = opCtx.context.tenantId;
981
+ }
982
+ if (needsOwner && fields.has("owner_id") && (data.owner_id == null || data.owner_id === "")) {
983
+ data.owner_id = opCtx.context.userId;
984
+ }
985
+ }
458
986
  }
459
987
  }
460
988
  const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
461
989
  if (allRlsPolicies.length > 0 && opCtx.ast) {
462
- const rlsFilter = this.rlsCompiler.compileFilter(allRlsPolicies, opCtx.context);
990
+ const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
991
+ const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
992
+ let dropped = 0;
993
+ const compilable = objectFields ? allRlsPolicies.filter((p) => {
994
+ const targetField = this.extractTargetField(p.using);
995
+ if (!targetField) return true;
996
+ if (objectFields.has(targetField)) return true;
997
+ if (tenancyDisabled && targetField === "organization_id") {
998
+ return false;
999
+ }
1000
+ dropped++;
1001
+ return false;
1002
+ }) : allRlsPolicies;
1003
+ let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
1004
+ if (rlsFilter == null && dropped > 0) {
1005
+ rlsFilter = { ...RLS_DENY_FILTER };
1006
+ }
463
1007
  if (rlsFilter) {
464
1008
  if (opCtx.ast.where) {
465
1009
  opCtx.ast.where = { $and: [opCtx.ast.where, rlsFilter] };
@@ -477,6 +1021,139 @@ var SecurityPlugin = class {
477
1021
  }
478
1022
  });
479
1023
  ctx.logger.info("Security middleware registered on ObjectQL engine");
1024
+ let bootstrapRanOnce = false;
1025
+ const runBootstrap = async () => {
1026
+ try {
1027
+ const report = await bootstrapPlatformAdmin(ql, this.bootstrapPermissionSets, {
1028
+ logger: ctx.logger
1029
+ });
1030
+ bootstrapRanOnce = true;
1031
+ ctx.logger.info("[security] platform bootstrap complete", report);
1032
+ return report;
1033
+ } catch (e) {
1034
+ ctx.logger.warn("[security] platform bootstrap failed", { error: e.message });
1035
+ return void 0;
1036
+ }
1037
+ };
1038
+ if (typeof ctx.hook === "function") {
1039
+ ctx.hook("kernel:ready", runBootstrap);
1040
+ } else {
1041
+ void runBootstrap();
1042
+ }
1043
+ ql.registerMiddleware(async (opCtx, next) => {
1044
+ await next();
1045
+ if (opCtx?.object === "sys_user" && (opCtx?.operation === "create" || opCtx?.operation === "insert")) {
1046
+ if (bootstrapRanOnce) {
1047
+ await runBootstrap();
1048
+ }
1049
+ if (this.multiTenant) {
1050
+ const newUser = opCtx?.result ?? opCtx?.data;
1051
+ if (newUser?.id) {
1052
+ try {
1053
+ await ensureUserHasOrganization(ql, newUser, {
1054
+ logger: ctx.logger,
1055
+ cloneSeedData: cloneTenantSeedData
1056
+ });
1057
+ } catch (e) {
1058
+ ctx.logger.warn("[security] ensure-user-has-organization failed", {
1059
+ error: e.message
1060
+ });
1061
+ }
1062
+ }
1063
+ }
1064
+ }
1065
+ });
1066
+ if (this.multiTenant) {
1067
+ ql.registerMiddleware(async (opCtx, next) => {
1068
+ await next();
1069
+ if (opCtx?.object !== "sys_organization" || opCtx?.operation !== "create" && opCtx?.operation !== "insert") {
1070
+ return;
1071
+ }
1072
+ const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
1073
+ if (!newOrgId) return;
1074
+ const kernel = ctx.kernel ?? ctx;
1075
+ let datasets;
1076
+ try {
1077
+ const raw = kernel?.getService?.("seed-datasets");
1078
+ if (Array.isArray(raw) && raw.length > 0) datasets = raw;
1079
+ } catch {
1080
+ }
1081
+ let orgCount = 0;
1082
+ try {
1083
+ const allOrgs = await ql.find(
1084
+ "sys_organization",
1085
+ { limit: 2, fields: ["id"] },
1086
+ { context: { isSystem: true } }
1087
+ );
1088
+ const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
1089
+ orgCount = list.length;
1090
+ } catch (e) {
1091
+ ctx.logger.warn("[security] failed to count organizations", {
1092
+ error: e.message
1093
+ });
1094
+ }
1095
+ let replayed = false;
1096
+ try {
1097
+ const replayer = kernel?.getService?.("seed-replayer");
1098
+ if (typeof replayer === "function") {
1099
+ const summary = await replayer(newOrgId);
1100
+ const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
1101
+ ctx.logger.info(
1102
+ `[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
1103
+ {
1104
+ organizationId: newOrgId,
1105
+ errors: summary?.errors?.slice?.(0, 5)
1106
+ }
1107
+ );
1108
+ if (total > 0) replayed = true;
1109
+ } else if (datasets) {
1110
+ ctx.logger.warn("[security] per-org seed: datasets present but no replayer registered", {
1111
+ organizationId: newOrgId
1112
+ });
1113
+ }
1114
+ } catch (e) {
1115
+ ctx.logger.warn("[security] per-org seed replay failed, falling back", {
1116
+ organizationId: newOrgId,
1117
+ error: e.message
1118
+ });
1119
+ }
1120
+ if (replayed) return;
1121
+ if (orgCount === 1) {
1122
+ try {
1123
+ const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
1124
+ if (claims.length > 0) {
1125
+ const total = claims.reduce((s, c) => s + c.count, 0);
1126
+ ctx.logger.info(
1127
+ `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
1128
+ { breakdown: claims }
1129
+ );
1130
+ return;
1131
+ }
1132
+ } catch (e) {
1133
+ ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1134
+ error: e.message
1135
+ });
1136
+ }
1137
+ }
1138
+ if (orgCount > 1) {
1139
+ try {
1140
+ const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
1141
+ if (summary.length > 0) {
1142
+ const total = summary.reduce((s, c) => s + c.count, 0);
1143
+ ctx.logger.info(
1144
+ `[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
1145
+ { breakdown: summary }
1146
+ );
1147
+ }
1148
+ } catch (e) {
1149
+ ctx.logger.warn("[security] clone-tenant-seed-data failed", {
1150
+ organizationId: newOrgId,
1151
+ error: e.message
1152
+ });
1153
+ }
1154
+ }
1155
+ });
1156
+ }
480
1157
  }
481
1158
  async destroy() {
482
1159
  }
@@ -487,19 +1164,82 @@ var SecurityPlugin = class {
487
1164
  const allPolicies = [];
488
1165
  for (const ps of permissionSets) {
489
1166
  if (ps.rowLevelSecurity) {
490
- allPolicies.push(...ps.rowLevelSecurity);
1167
+ for (const policy of ps.rowLevelSecurity) {
1168
+ if (!this.multiTenant && policy.using && policy.using.includes("current_user.organization_id")) {
1169
+ continue;
1170
+ }
1171
+ allPolicies.push(policy);
1172
+ }
491
1173
  }
492
1174
  }
493
1175
  return this.rlsCompiler.getApplicablePolicies(objectName, operation, allPolicies);
494
1176
  }
1177
+ /**
1178
+ * Resolve the column-name set for an object (lowercased). Returns
1179
+ * `null` if the schema can't be loaded — caller should fail-closed.
1180
+ */
1181
+ async getObjectFieldNames(metadata, objectName, ql) {
1182
+ if (this.fieldNamesCache.has(objectName)) {
1183
+ return this.fieldNamesCache.get(objectName) ?? null;
1184
+ }
1185
+ const result = await this.loadObjectFieldNames(metadata, objectName, ql);
1186
+ if (result) {
1187
+ this.fieldNamesCache.set(objectName, result);
1188
+ }
1189
+ return result;
1190
+ }
1191
+ async loadObjectFieldNames(metadata, objectName, ql) {
1192
+ try {
1193
+ let obj = typeof ql?.getSchema === "function" ? ql.getSchema(objectName) : null;
1194
+ if (!obj || !obj.fields) {
1195
+ obj = await metadata?.get?.("object", objectName);
1196
+ }
1197
+ if (!obj || !obj.fields) return null;
1198
+ const tenancyDisabled = obj?.tenancy?.enabled === false || obj?.systemFields?.tenant === false;
1199
+ this.tenancyDisabledCache.set(objectName, !!tenancyDisabled);
1200
+ const set = /* @__PURE__ */ new Set(["id"]);
1201
+ if (Array.isArray(obj.fields)) {
1202
+ for (const f of obj.fields) {
1203
+ if (f?.name) set.add(String(f.name));
1204
+ }
1205
+ } else if (typeof obj.fields === "object") {
1206
+ for (const key of Object.keys(obj.fields)) {
1207
+ set.add(key);
1208
+ const v = obj.fields[key];
1209
+ if (v && typeof v === "object" && v.name) set.add(String(v.name));
1210
+ }
1211
+ } else {
1212
+ return null;
1213
+ }
1214
+ return set;
1215
+ } catch {
1216
+ return null;
1217
+ }
1218
+ }
1219
+ /**
1220
+ * Extract the left-hand field name from a simple RLS expression like
1221
+ * `field = current_user.x` or `field IN (current_user.y)`. Returns
1222
+ * `null` for unsupported shapes (in which case we keep the policy).
1223
+ */
1224
+ extractTargetField(using) {
1225
+ if (!using) return null;
1226
+ const m = using.match(/^\s*([a-z_][a-z0-9_]*)\s*(?:=|IN|in)(?=\s|\()/);
1227
+ return m ? m[1] : null;
1228
+ }
495
1229
  };
496
1230
  // Annotate the CommonJS export names for ESM import in node:
497
1231
  0 && (module.exports = {
498
1232
  FieldMasker,
1233
+ PermissionDeniedError,
499
1234
  PermissionEvaluator,
500
1235
  RLSCompiler,
1236
+ RLS_DENY_FILTER,
1237
+ SECURITY_PLUGIN_ID,
1238
+ SECURITY_PLUGIN_VERSION,
501
1239
  SecurityPlugin,
502
- SysPermissionSet,
503
- SysRole
1240
+ isPermissionDeniedError,
1241
+ securityDefaultPermissionSets,
1242
+ securityObjects,
1243
+ securityPluginManifestHeader
504
1244
  });
505
1245
  //# sourceMappingURL=index.js.map