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