@objectstack/plugin-security 4.0.5 → 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
@@ -75,7 +75,7 @@ var PermissionEvaluator = class {
75
75
  * SecurityPlugin would never resolve the defaults and all enforcement
76
76
  * would be silently disabled for authenticated requests.
77
77
  */
78
- async resolvePermissionSets(identifiers, metadataService, bootstrapPermissionSets = []) {
78
+ async resolvePermissionSets(identifiers, metadataService, bootstrapPermissionSets = [], dbLoader) {
79
79
  if (identifiers.length === 0) return [];
80
80
  const result = [];
81
81
  const seen = /* @__PURE__ */ new Set();
@@ -100,6 +100,21 @@ var PermissionEvaluator = class {
100
100
  result.push(ps);
101
101
  }
102
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
+ }
103
118
  return result;
104
119
  }
105
120
  };
@@ -239,6 +254,40 @@ var FieldMasker = class {
239
254
  }
240
255
  return result;
241
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
+ }
242
291
  maskRecord(record, hiddenFields) {
243
292
  if (!record || typeof record !== "object") return record;
244
293
  const result = { ...record };
@@ -430,7 +479,7 @@ var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
430
479
  "updated_at",
431
480
  "organization_id"
432
481
  ]);
433
- var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary", "autonumber"]);
482
+ var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary"]);
434
483
  function fieldList(schema) {
435
484
  const fields = schema?.fields;
436
485
  if (!fields) return [];
@@ -569,16 +618,20 @@ async function cloneTenantSeedData(ql, targetOrgId, options = {}) {
569
618
  const oldVal = item.record[f.name];
570
619
  if (oldVal == null) continue;
571
620
  const targetMap = remap[f.reference];
572
- if (!targetMap) continue;
573
621
  if (Array.isArray(oldVal)) {
574
- const next = oldVal.map((v) => typeof v === "string" && targetMap[v] || v);
575
- if (next.some((v, i) => v !== oldVal[i])) {
576
- patch[f.name] = next;
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;
577
633
  dirty = true;
578
634
  }
579
- } else if (typeof oldVal === "string" && targetMap[oldVal]) {
580
- patch[f.name] = targetMap[oldVal];
581
- dirty = true;
582
635
  }
583
636
  }
584
637
  if (!dirty) continue;
@@ -747,6 +800,17 @@ var SecurityPlugin = class {
747
800
  * invalidation — a kernel restart drops the cache.
748
801
  */
749
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();
750
814
  this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
751
815
  this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
752
816
  this.multiTenant = options.multiTenant !== false;
@@ -756,6 +820,8 @@ var SecurityPlugin = class {
756
820
  ctx.registerService("security.permissions", this.permissionEvaluator);
757
821
  ctx.registerService("security.rls", this.rlsCompiler);
758
822
  ctx.registerService("security.fieldMasker", this.fieldMasker);
823
+ ctx.registerService("security.bootstrapPermissionSets", this.bootstrapPermissionSets);
824
+ ctx.registerService("security.fallbackPermissionSet", this.fallbackPermissionSet);
759
825
  ctx.getService("manifest").register({
760
826
  ...securityPluginManifestHeader,
761
827
  objects: securityObjects,
@@ -783,6 +849,25 @@ var SecurityPlugin = class {
783
849
  ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
784
850
  return;
785
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;
786
871
  ql.registerMiddleware(async (opCtx, next) => {
787
872
  if (opCtx.context?.isSystem) {
788
873
  return next();
@@ -801,13 +886,15 @@ var SecurityPlugin = class {
801
886
  permissionSets = await this.permissionEvaluator.resolvePermissionSets(
802
887
  requested,
803
888
  metadata,
804
- this.bootstrapPermissionSets
889
+ this.bootstrapPermissionSets,
890
+ dbLoader
805
891
  );
806
892
  if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
807
893
  const fallback = await this.permissionEvaluator.resolvePermissionSets(
808
894
  [this.fallbackPermissionSet],
809
895
  metadata,
810
- this.bootstrapPermissionSets
896
+ this.bootstrapPermissionSets,
897
+ dbLoader
811
898
  );
812
899
  permissionSets = fallback;
813
900
  }
@@ -827,6 +914,30 @@ var SecurityPlugin = class {
827
914
  );
828
915
  }
829
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
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
+ }
830
941
  if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
831
942
  const needsTenant = this.multiTenant && !!opCtx.context?.tenantId;
832
943
  const needsOwner = !!opCtx.context?.userId;
@@ -846,12 +957,17 @@ var SecurityPlugin = class {
846
957
  const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
847
958
  if (allRlsPolicies.length > 0 && opCtx.ast) {
848
959
  const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
960
+ const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
849
961
  let dropped = 0;
850
962
  const compilable = objectFields ? allRlsPolicies.filter((p) => {
851
963
  const targetField = this.extractTargetField(p.using);
852
- const ok = targetField ? objectFields.has(targetField) : true;
853
- if (!ok) dropped++;
854
- return ok;
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;
855
971
  }) : allRlsPolicies;
856
972
  let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
857
973
  if (rlsFilter == null && dropped > 0) {
@@ -924,6 +1040,14 @@ var SecurityPlugin = class {
924
1040
  }
925
1041
  const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
926
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;
927
1051
  try {
928
1052
  const allOrgs = await ql.find(
929
1053
  "sys_organization",
@@ -931,20 +1055,72 @@ var SecurityPlugin = class {
931
1055
  { context: { isSystem: true } }
932
1056
  );
933
1057
  const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
934
- if (list.length !== 1) return;
935
- const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
936
- if (claims.length > 0) {
937
- const total = claims.reduce((s, c) => s + c.count, 0);
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);
938
1070
  ctx.logger.info(
939
- `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
940
- { breakdown: claims }
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
+ }
941
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
+ });
942
1082
  }
943
1083
  } catch (e) {
944
- ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1084
+ ctx.logger.warn("[security] per-org seed replay failed, falling back", {
1085
+ organizationId: newOrgId,
945
1086
  error: e.message
946
1087
  });
947
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
+ }
948
1124
  });
949
1125
  }
950
1126
  }
@@ -988,6 +1164,8 @@ var SecurityPlugin = class {
988
1164
  obj = await metadata?.get?.("object", objectName);
989
1165
  }
990
1166
  if (!obj || !obj.fields) return null;
1167
+ const tenancyDisabled = obj?.tenancy?.enabled === false || obj?.systemFields?.tenant === false;
1168
+ this.tenancyDisabledCache.set(objectName, !!tenancyDisabled);
991
1169
  const set = /* @__PURE__ */ new Set(["id"]);
992
1170
  if (Array.isArray(obj.fields)) {
993
1171
  for (const f of obj.fields) {