@objectstack/plugin-security 4.0.5 → 4.1.1

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;
@@ -639,8 +692,18 @@ function genId2(prefix) {
639
692
  const ts = Date.now().toString(36);
640
693
  return `${prefix}_${ts}${rand}`;
641
694
  }
642
- function slugify(input) {
643
- return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "workspace";
695
+ function slugify(input, fallback = "workspace") {
696
+ const cleaned = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
697
+ return cleaned || fallback;
698
+ }
699
+ function deriveSlugFallback(user) {
700
+ if (user.email) {
701
+ const local = user.email.split("@")[0] ?? "";
702
+ const localSlug = local.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
703
+ if (localSlug) return localSlug;
704
+ }
705
+ const idTail = user.id.replace(/[^a-z0-9]/gi, "").slice(-8).toLowerCase();
706
+ return idTail ? `user-${idTail}` : "user";
644
707
  }
645
708
  function deriveBaseName(user) {
646
709
  if (user.name && user.name.trim()) return user.name.trim();
@@ -671,7 +734,8 @@ async function ensureUserHasOrganization(ql, user, options = {}) {
671
734
  }
672
735
  const base = deriveBaseName(user);
673
736
  const orgName = `${base}'s Workspace`;
674
- const baseSlug = slugify(base);
737
+ const slugFallback = deriveSlugFallback(user);
738
+ const baseSlug = slugify(base, slugFallback);
675
739
  let slug = `${baseSlug}-workspace`;
676
740
  for (let attempt = 1; attempt <= 5; attempt += 1) {
677
741
  const collision = await tryFind2(ql, "sys_organization", { slug }, 1);
@@ -778,6 +842,17 @@ var SecurityPlugin = class {
778
842
  * invalidation — a kernel restart drops the cache.
779
843
  */
780
844
  this.fieldNamesCache = /* @__PURE__ */ new Map();
845
+ /**
846
+ * Per-object cache of tenancy opt-out. `true` means the schema
847
+ * explicitly disabled multi-tenancy (`tenancy.enabled === false` or
848
+ * `systemFields.tenant === false`). Wildcard policies that target
849
+ * the conventional tenant column (`organization_id`) are treated as
850
+ * *not applicable* on these tables instead of triggering the
851
+ * field-missing deny sentinel — without this, every read of a
852
+ * cross-org catalog (e.g. `sys_package`, the Marketplace) returns
853
+ * zero rows.
854
+ */
855
+ this.tenancyDisabledCache = /* @__PURE__ */ new Map();
781
856
  this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
782
857
  this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
783
858
  this.multiTenant = options.multiTenant !== false;
@@ -787,6 +862,8 @@ var SecurityPlugin = class {
787
862
  ctx.registerService("security.permissions", this.permissionEvaluator);
788
863
  ctx.registerService("security.rls", this.rlsCompiler);
789
864
  ctx.registerService("security.fieldMasker", this.fieldMasker);
865
+ ctx.registerService("security.bootstrapPermissionSets", this.bootstrapPermissionSets);
866
+ ctx.registerService("security.fallbackPermissionSet", this.fallbackPermissionSet);
790
867
  ctx.getService("manifest").register({
791
868
  ...securityPluginManifestHeader,
792
869
  objects: securityObjects,
@@ -814,6 +891,25 @@ var SecurityPlugin = class {
814
891
  ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
815
892
  return;
816
893
  }
894
+ const dbLoader = ql ? async (names) => {
895
+ let rows;
896
+ try {
897
+ rows = await ql.find(
898
+ "sys_permission_set",
899
+ { where: { name: { $in: names } }, limit: names.length },
900
+ { context: { isSystem: true } }
901
+ );
902
+ } catch {
903
+ rows = [];
904
+ }
905
+ const list = Array.isArray(rows) ? rows : rows?.records ?? [];
906
+ return list.map((r) => ({
907
+ name: r.name,
908
+ label: r.label,
909
+ objects: typeof r.object_permissions === "string" ? JSON.parse(r.object_permissions || "{}") : r.object_permissions ?? {},
910
+ fields: typeof r.field_permissions === "string" ? JSON.parse(r.field_permissions || "{}") : r.field_permissions ?? {}
911
+ }));
912
+ } : void 0;
817
913
  ql.registerMiddleware(async (opCtx, next) => {
818
914
  if (opCtx.context?.isSystem) {
819
915
  return next();
@@ -832,13 +928,15 @@ var SecurityPlugin = class {
832
928
  permissionSets = await this.permissionEvaluator.resolvePermissionSets(
833
929
  requested,
834
930
  metadata,
835
- this.bootstrapPermissionSets
931
+ this.bootstrapPermissionSets,
932
+ dbLoader
836
933
  );
837
934
  if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
838
935
  const fallback = await this.permissionEvaluator.resolvePermissionSets(
839
936
  [this.fallbackPermissionSet],
840
937
  metadata,
841
- this.bootstrapPermissionSets
938
+ this.bootstrapPermissionSets,
939
+ dbLoader
842
940
  );
843
941
  permissionSets = fallback;
844
942
  }
@@ -858,6 +956,30 @@ var SecurityPlugin = class {
858
956
  );
859
957
  }
860
958
  }
959
+ if ((opCtx.operation === "insert" || opCtx.operation === "update") && opCtx.data && permissionSets.length > 0) {
960
+ const fieldPerms = this.permissionEvaluator.getFieldPermissions(
961
+ opCtx.object,
962
+ permissionSets
963
+ );
964
+ if (Object.keys(fieldPerms).length > 0) {
965
+ const forbidden = this.fieldMasker.detectForbiddenWrites(
966
+ opCtx.data,
967
+ fieldPerms
968
+ );
969
+ if (forbidden.length > 0) {
970
+ throw new PermissionDeniedError(
971
+ `[Security] Field write denied: not permitted to edit [${forbidden.join(", ")}] on '${opCtx.object}'`,
972
+ {
973
+ operation: opCtx.operation,
974
+ object: opCtx.object,
975
+ roles,
976
+ permissionSets: explicitPermissionSets,
977
+ forbiddenFields: forbidden
978
+ }
979
+ );
980
+ }
981
+ }
982
+ }
861
983
  if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
862
984
  const needsTenant = this.multiTenant && !!opCtx.context?.tenantId;
863
985
  const needsOwner = !!opCtx.context?.userId;
@@ -877,12 +999,17 @@ var SecurityPlugin = class {
877
999
  const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
878
1000
  if (allRlsPolicies.length > 0 && opCtx.ast) {
879
1001
  const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
1002
+ const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
880
1003
  let dropped = 0;
881
1004
  const compilable = objectFields ? allRlsPolicies.filter((p) => {
882
1005
  const targetField = this.extractTargetField(p.using);
883
- const ok = targetField ? objectFields.has(targetField) : true;
884
- if (!ok) dropped++;
885
- return ok;
1006
+ if (!targetField) return true;
1007
+ if (objectFields.has(targetField)) return true;
1008
+ if (tenancyDisabled && targetField === "organization_id") {
1009
+ return false;
1010
+ }
1011
+ dropped++;
1012
+ return false;
886
1013
  }) : allRlsPolicies;
887
1014
  let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
888
1015
  if (rlsFilter == null && dropped > 0) {
@@ -955,6 +1082,14 @@ var SecurityPlugin = class {
955
1082
  }
956
1083
  const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
957
1084
  if (!newOrgId) return;
1085
+ const kernel = ctx.kernel ?? ctx;
1086
+ let datasets;
1087
+ try {
1088
+ const raw = kernel?.getService?.("seed-datasets");
1089
+ if (Array.isArray(raw) && raw.length > 0) datasets = raw;
1090
+ } catch {
1091
+ }
1092
+ let orgCount = 0;
958
1093
  try {
959
1094
  const allOrgs = await ql.find(
960
1095
  "sys_organization",
@@ -962,20 +1097,72 @@ var SecurityPlugin = class {
962
1097
  { context: { isSystem: true } }
963
1098
  );
964
1099
  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);
1100
+ orgCount = list.length;
1101
+ } catch (e) {
1102
+ ctx.logger.warn("[security] failed to count organizations", {
1103
+ error: e.message
1104
+ });
1105
+ }
1106
+ let replayed = false;
1107
+ try {
1108
+ const replayer = kernel?.getService?.("seed-replayer");
1109
+ if (typeof replayer === "function") {
1110
+ const summary = await replayer(newOrgId);
1111
+ const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
969
1112
  ctx.logger.info(
970
- `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
971
- { breakdown: claims }
1113
+ `[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
1114
+ {
1115
+ organizationId: newOrgId,
1116
+ errors: summary?.errors?.slice?.(0, 5)
1117
+ }
972
1118
  );
1119
+ if (total > 0) replayed = true;
1120
+ } else if (datasets) {
1121
+ ctx.logger.warn("[security] per-org seed: datasets present but no replayer registered", {
1122
+ organizationId: newOrgId
1123
+ });
973
1124
  }
974
1125
  } catch (e) {
975
- ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1126
+ ctx.logger.warn("[security] per-org seed replay failed, falling back", {
1127
+ organizationId: newOrgId,
976
1128
  error: e.message
977
1129
  });
978
1130
  }
1131
+ if (replayed) return;
1132
+ if (orgCount === 1) {
1133
+ try {
1134
+ const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
1135
+ if (claims.length > 0) {
1136
+ const total = claims.reduce((s, c) => s + c.count, 0);
1137
+ ctx.logger.info(
1138
+ `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
1139
+ { breakdown: claims }
1140
+ );
1141
+ return;
1142
+ }
1143
+ } catch (e) {
1144
+ ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1145
+ error: e.message
1146
+ });
1147
+ }
1148
+ }
1149
+ if (orgCount > 1) {
1150
+ try {
1151
+ const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
1152
+ if (summary.length > 0) {
1153
+ const total = summary.reduce((s, c) => s + c.count, 0);
1154
+ ctx.logger.info(
1155
+ `[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
1156
+ { breakdown: summary }
1157
+ );
1158
+ }
1159
+ } catch (e) {
1160
+ ctx.logger.warn("[security] clone-tenant-seed-data failed", {
1161
+ organizationId: newOrgId,
1162
+ error: e.message
1163
+ });
1164
+ }
1165
+ }
979
1166
  });
980
1167
  }
981
1168
  }
@@ -1019,6 +1206,8 @@ var SecurityPlugin = class {
1019
1206
  obj = await metadata?.get?.("object", objectName);
1020
1207
  }
1021
1208
  if (!obj || !obj.fields) return null;
1209
+ const tenancyDisabled = obj?.tenancy?.enabled === false || obj?.systemFields?.tenant === false;
1210
+ this.tenancyDisabledCache.set(objectName, !!tenancyDisabled);
1022
1211
  const set = /* @__PURE__ */ new Set(["id"]);
1023
1212
  if (Array.isArray(obj.fields)) {
1024
1213
  for (const f of obj.fields) {