@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.js CHANGED
@@ -112,7 +112,7 @@ var PermissionEvaluator = class {
112
112
  * SecurityPlugin would never resolve the defaults and all enforcement
113
113
  * would be silently disabled for authenticated requests.
114
114
  */
115
- async resolvePermissionSets(identifiers, metadataService, bootstrapPermissionSets = []) {
115
+ async resolvePermissionSets(identifiers, metadataService, bootstrapPermissionSets = [], dbLoader) {
116
116
  if (identifiers.length === 0) return [];
117
117
  const result = [];
118
118
  const seen = /* @__PURE__ */ new Set();
@@ -137,6 +137,21 @@ var PermissionEvaluator = class {
137
137
  result.push(ps);
138
138
  }
139
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
+ }
140
155
  return result;
141
156
  }
142
157
  };
@@ -276,6 +291,40 @@ var FieldMasker = class {
276
291
  }
277
292
  return result;
278
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
+ }
279
328
  maskRecord(record, hiddenFields) {
280
329
  if (!record || typeof record !== "object") return record;
281
330
  const result = { ...record };
@@ -467,7 +516,7 @@ var SKIP_COPY_FIELDS = /* @__PURE__ */ new Set([
467
516
  "updated_at",
468
517
  "organization_id"
469
518
  ]);
470
- var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary", "autonumber"]);
519
+ var SKIP_COPY_TYPES = /* @__PURE__ */ new Set(["formula", "summary"]);
471
520
  function fieldList(schema) {
472
521
  const fields = schema?.fields;
473
522
  if (!fields) return [];
@@ -606,16 +655,20 @@ async function cloneTenantSeedData(ql, targetOrgId, options = {}) {
606
655
  const oldVal = item.record[f.name];
607
656
  if (oldVal == null) continue;
608
657
  const targetMap = remap[f.reference];
609
- if (!targetMap) continue;
610
658
  if (Array.isArray(oldVal)) {
611
- const next = oldVal.map((v) => typeof v === "string" && targetMap[v] || v);
612
- if (next.some((v, i) => v !== oldVal[i])) {
613
- patch[f.name] = next;
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;
614
670
  dirty = true;
615
671
  }
616
- } else if (typeof oldVal === "string" && targetMap[oldVal]) {
617
- patch[f.name] = targetMap[oldVal];
618
- dirty = true;
619
672
  }
620
673
  }
621
674
  if (!dirty) continue;
@@ -778,6 +831,17 @@ var SecurityPlugin = class {
778
831
  * invalidation — a kernel restart drops the cache.
779
832
  */
780
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();
781
845
  this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
782
846
  this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
783
847
  this.multiTenant = options.multiTenant !== false;
@@ -787,6 +851,8 @@ var SecurityPlugin = class {
787
851
  ctx.registerService("security.permissions", this.permissionEvaluator);
788
852
  ctx.registerService("security.rls", this.rlsCompiler);
789
853
  ctx.registerService("security.fieldMasker", this.fieldMasker);
854
+ ctx.registerService("security.bootstrapPermissionSets", this.bootstrapPermissionSets);
855
+ ctx.registerService("security.fallbackPermissionSet", this.fallbackPermissionSet);
790
856
  ctx.getService("manifest").register({
791
857
  ...securityPluginManifestHeader,
792
858
  objects: securityObjects,
@@ -814,6 +880,25 @@ var SecurityPlugin = class {
814
880
  ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
815
881
  return;
816
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;
817
902
  ql.registerMiddleware(async (opCtx, next) => {
818
903
  if (opCtx.context?.isSystem) {
819
904
  return next();
@@ -832,13 +917,15 @@ var SecurityPlugin = class {
832
917
  permissionSets = await this.permissionEvaluator.resolvePermissionSets(
833
918
  requested,
834
919
  metadata,
835
- this.bootstrapPermissionSets
920
+ this.bootstrapPermissionSets,
921
+ dbLoader
836
922
  );
837
923
  if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
838
924
  const fallback = await this.permissionEvaluator.resolvePermissionSets(
839
925
  [this.fallbackPermissionSet],
840
926
  metadata,
841
- this.bootstrapPermissionSets
927
+ this.bootstrapPermissionSets,
928
+ dbLoader
842
929
  );
843
930
  permissionSets = fallback;
844
931
  }
@@ -858,6 +945,30 @@ var SecurityPlugin = class {
858
945
  );
859
946
  }
860
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
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
+ }
861
972
  if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
862
973
  const needsTenant = this.multiTenant && !!opCtx.context?.tenantId;
863
974
  const needsOwner = !!opCtx.context?.userId;
@@ -877,12 +988,17 @@ var SecurityPlugin = class {
877
988
  const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
878
989
  if (allRlsPolicies.length > 0 && opCtx.ast) {
879
990
  const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
991
+ const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
880
992
  let dropped = 0;
881
993
  const compilable = objectFields ? allRlsPolicies.filter((p) => {
882
994
  const targetField = this.extractTargetField(p.using);
883
- const ok = targetField ? objectFields.has(targetField) : true;
884
- if (!ok) dropped++;
885
- return ok;
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;
886
1002
  }) : allRlsPolicies;
887
1003
  let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
888
1004
  if (rlsFilter == null && dropped > 0) {
@@ -955,6 +1071,14 @@ var SecurityPlugin = class {
955
1071
  }
956
1072
  const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
957
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;
958
1082
  try {
959
1083
  const allOrgs = await ql.find(
960
1084
  "sys_organization",
@@ -962,20 +1086,72 @@ var SecurityPlugin = class {
962
1086
  { context: { isSystem: true } }
963
1087
  );
964
1088
  const list = Array.isArray(allOrgs) ? allOrgs : Array.isArray(allOrgs?.records) ? allOrgs.records : [];
965
- if (list.length !== 1) return;
966
- const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
967
- if (claims.length > 0) {
968
- const total = claims.reduce((s, c) => s + c.count, 0);
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);
969
1101
  ctx.logger.info(
970
- `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
971
- { breakdown: claims }
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
+ }
972
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
+ });
973
1113
  }
974
1114
  } catch (e) {
975
- ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1115
+ ctx.logger.warn("[security] per-org seed replay failed, falling back", {
1116
+ organizationId: newOrgId,
976
1117
  error: e.message
977
1118
  });
978
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
+ }
979
1155
  });
980
1156
  }
981
1157
  }
@@ -1019,6 +1195,8 @@ var SecurityPlugin = class {
1019
1195
  obj = await metadata?.get?.("object", objectName);
1020
1196
  }
1021
1197
  if (!obj || !obj.fields) return null;
1198
+ const tenancyDisabled = obj?.tenancy?.enabled === false || obj?.systemFields?.tenant === false;
1199
+ this.tenancyDisabledCache.set(objectName, !!tenancyDisabled);
1022
1200
  const set = /* @__PURE__ */ new Set(["id"]);
1023
1201
  if (Array.isArray(obj.fields)) {
1024
1202
  for (const f of obj.fields) {