@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.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;
@@ -602,8 +655,18 @@ function genId2(prefix) {
602
655
  const ts = Date.now().toString(36);
603
656
  return `${prefix}_${ts}${rand}`;
604
657
  }
605
- function slugify(input) {
606
- return input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "workspace";
658
+ function slugify(input, fallback = "workspace") {
659
+ const cleaned = input.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
660
+ return cleaned || fallback;
661
+ }
662
+ function deriveSlugFallback(user) {
663
+ if (user.email) {
664
+ const local = user.email.split("@")[0] ?? "";
665
+ const localSlug = local.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40);
666
+ if (localSlug) return localSlug;
667
+ }
668
+ const idTail = user.id.replace(/[^a-z0-9]/gi, "").slice(-8).toLowerCase();
669
+ return idTail ? `user-${idTail}` : "user";
607
670
  }
608
671
  function deriveBaseName(user) {
609
672
  if (user.name && user.name.trim()) return user.name.trim();
@@ -634,7 +697,8 @@ async function ensureUserHasOrganization(ql, user, options = {}) {
634
697
  }
635
698
  const base = deriveBaseName(user);
636
699
  const orgName = `${base}'s Workspace`;
637
- const baseSlug = slugify(base);
700
+ const slugFallback = deriveSlugFallback(user);
701
+ const baseSlug = slugify(base, slugFallback);
638
702
  let slug = `${baseSlug}-workspace`;
639
703
  for (let attempt = 1; attempt <= 5; attempt += 1) {
640
704
  const collision = await tryFind2(ql, "sys_organization", { slug }, 1);
@@ -747,6 +811,17 @@ var SecurityPlugin = class {
747
811
  * invalidation — a kernel restart drops the cache.
748
812
  */
749
813
  this.fieldNamesCache = /* @__PURE__ */ new Map();
814
+ /**
815
+ * Per-object cache of tenancy opt-out. `true` means the schema
816
+ * explicitly disabled multi-tenancy (`tenancy.enabled === false` or
817
+ * `systemFields.tenant === false`). Wildcard policies that target
818
+ * the conventional tenant column (`organization_id`) are treated as
819
+ * *not applicable* on these tables instead of triggering the
820
+ * field-missing deny sentinel — without this, every read of a
821
+ * cross-org catalog (e.g. `sys_package`, the Marketplace) returns
822
+ * zero rows.
823
+ */
824
+ this.tenancyDisabledCache = /* @__PURE__ */ new Map();
750
825
  this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
751
826
  this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
752
827
  this.multiTenant = options.multiTenant !== false;
@@ -756,6 +831,8 @@ var SecurityPlugin = class {
756
831
  ctx.registerService("security.permissions", this.permissionEvaluator);
757
832
  ctx.registerService("security.rls", this.rlsCompiler);
758
833
  ctx.registerService("security.fieldMasker", this.fieldMasker);
834
+ ctx.registerService("security.bootstrapPermissionSets", this.bootstrapPermissionSets);
835
+ ctx.registerService("security.fallbackPermissionSet", this.fallbackPermissionSet);
759
836
  ctx.getService("manifest").register({
760
837
  ...securityPluginManifestHeader,
761
838
  objects: securityObjects,
@@ -783,6 +860,25 @@ var SecurityPlugin = class {
783
860
  ctx.logger.warn("ObjectQL engine does not support middleware, security middleware not registered");
784
861
  return;
785
862
  }
863
+ const dbLoader = ql ? async (names) => {
864
+ let rows;
865
+ try {
866
+ rows = await ql.find(
867
+ "sys_permission_set",
868
+ { where: { name: { $in: names } }, limit: names.length },
869
+ { context: { isSystem: true } }
870
+ );
871
+ } catch {
872
+ rows = [];
873
+ }
874
+ const list = Array.isArray(rows) ? rows : rows?.records ?? [];
875
+ return list.map((r) => ({
876
+ name: r.name,
877
+ label: r.label,
878
+ objects: typeof r.object_permissions === "string" ? JSON.parse(r.object_permissions || "{}") : r.object_permissions ?? {},
879
+ fields: typeof r.field_permissions === "string" ? JSON.parse(r.field_permissions || "{}") : r.field_permissions ?? {}
880
+ }));
881
+ } : void 0;
786
882
  ql.registerMiddleware(async (opCtx, next) => {
787
883
  if (opCtx.context?.isSystem) {
788
884
  return next();
@@ -801,13 +897,15 @@ var SecurityPlugin = class {
801
897
  permissionSets = await this.permissionEvaluator.resolvePermissionSets(
802
898
  requested,
803
899
  metadata,
804
- this.bootstrapPermissionSets
900
+ this.bootstrapPermissionSets,
901
+ dbLoader
805
902
  );
806
903
  if (permissionSets.length === 0 && opCtx.context?.userId && this.fallbackPermissionSet) {
807
904
  const fallback = await this.permissionEvaluator.resolvePermissionSets(
808
905
  [this.fallbackPermissionSet],
809
906
  metadata,
810
- this.bootstrapPermissionSets
907
+ this.bootstrapPermissionSets,
908
+ dbLoader
811
909
  );
812
910
  permissionSets = fallback;
813
911
  }
@@ -827,6 +925,30 @@ var SecurityPlugin = class {
827
925
  );
828
926
  }
829
927
  }
928
+ if ((opCtx.operation === "insert" || opCtx.operation === "update") && opCtx.data && permissionSets.length > 0) {
929
+ const fieldPerms = this.permissionEvaluator.getFieldPermissions(
930
+ opCtx.object,
931
+ permissionSets
932
+ );
933
+ if (Object.keys(fieldPerms).length > 0) {
934
+ const forbidden = this.fieldMasker.detectForbiddenWrites(
935
+ opCtx.data,
936
+ fieldPerms
937
+ );
938
+ if (forbidden.length > 0) {
939
+ throw new PermissionDeniedError(
940
+ `[Security] Field write denied: not permitted to edit [${forbidden.join(", ")}] on '${opCtx.object}'`,
941
+ {
942
+ operation: opCtx.operation,
943
+ object: opCtx.object,
944
+ roles,
945
+ permissionSets: explicitPermissionSets,
946
+ forbiddenFields: forbidden
947
+ }
948
+ );
949
+ }
950
+ }
951
+ }
830
952
  if (opCtx.operation === "insert" && opCtx.data && typeof opCtx.data === "object" && !Array.isArray(opCtx.data)) {
831
953
  const needsTenant = this.multiTenant && !!opCtx.context?.tenantId;
832
954
  const needsOwner = !!opCtx.context?.userId;
@@ -846,12 +968,17 @@ var SecurityPlugin = class {
846
968
  const allRlsPolicies = this.collectRLSPolicies(permissionSets, opCtx.object, opCtx.operation);
847
969
  if (allRlsPolicies.length > 0 && opCtx.ast) {
848
970
  const objectFields = await this.getObjectFieldNames(metadata, opCtx.object, ql);
971
+ const tenancyDisabled = this.tenancyDisabledCache.get(opCtx.object) === true;
849
972
  let dropped = 0;
850
973
  const compilable = objectFields ? allRlsPolicies.filter((p) => {
851
974
  const targetField = this.extractTargetField(p.using);
852
- const ok = targetField ? objectFields.has(targetField) : true;
853
- if (!ok) dropped++;
854
- return ok;
975
+ if (!targetField) return true;
976
+ if (objectFields.has(targetField)) return true;
977
+ if (tenancyDisabled && targetField === "organization_id") {
978
+ return false;
979
+ }
980
+ dropped++;
981
+ return false;
855
982
  }) : allRlsPolicies;
856
983
  let rlsFilter = this.rlsCompiler.compileFilter(compilable, opCtx.context);
857
984
  if (rlsFilter == null && dropped > 0) {
@@ -924,6 +1051,14 @@ var SecurityPlugin = class {
924
1051
  }
925
1052
  const newOrgId = opCtx?.result?.id ?? opCtx?.data?.id;
926
1053
  if (!newOrgId) return;
1054
+ const kernel = ctx.kernel ?? ctx;
1055
+ let datasets;
1056
+ try {
1057
+ const raw = kernel?.getService?.("seed-datasets");
1058
+ if (Array.isArray(raw) && raw.length > 0) datasets = raw;
1059
+ } catch {
1060
+ }
1061
+ let orgCount = 0;
927
1062
  try {
928
1063
  const allOrgs = await ql.find(
929
1064
  "sys_organization",
@@ -931,20 +1066,72 @@ var SecurityPlugin = class {
931
1066
  { context: { isSystem: true } }
932
1067
  );
933
1068
  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);
1069
+ orgCount = list.length;
1070
+ } catch (e) {
1071
+ ctx.logger.warn("[security] failed to count organizations", {
1072
+ error: e.message
1073
+ });
1074
+ }
1075
+ let replayed = false;
1076
+ try {
1077
+ const replayer = kernel?.getService?.("seed-replayer");
1078
+ if (typeof replayer === "function") {
1079
+ const summary = await replayer(newOrgId);
1080
+ const total = (summary?.inserted ?? 0) + (summary?.updated ?? 0);
938
1081
  ctx.logger.info(
939
- `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
940
- { breakdown: claims }
1082
+ `[security] per-org seed replay for ${newOrgId}: +${summary?.inserted ?? 0} inserted, ${summary?.updated ?? 0} updated, ${summary?.errors?.length ?? 0} error(s)`,
1083
+ {
1084
+ organizationId: newOrgId,
1085
+ errors: summary?.errors?.slice?.(0, 5)
1086
+ }
941
1087
  );
1088
+ if (total > 0) replayed = true;
1089
+ } else if (datasets) {
1090
+ ctx.logger.warn("[security] per-org seed: datasets present but no replayer registered", {
1091
+ organizationId: newOrgId
1092
+ });
942
1093
  }
943
1094
  } catch (e) {
944
- ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1095
+ ctx.logger.warn("[security] per-org seed replay failed, falling back", {
1096
+ organizationId: newOrgId,
945
1097
  error: e.message
946
1098
  });
947
1099
  }
1100
+ if (replayed) return;
1101
+ if (orgCount === 1) {
1102
+ try {
1103
+ const claims = await claimOrphanTenantRows(ql, newOrgId, { logger: ctx.logger });
1104
+ if (claims.length > 0) {
1105
+ const total = claims.reduce((s, c) => s + c.count, 0);
1106
+ ctx.logger.info(
1107
+ `[security] claimed ${total} orphan seed row(s) for first organization ${newOrgId}`,
1108
+ { breakdown: claims }
1109
+ );
1110
+ return;
1111
+ }
1112
+ } catch (e) {
1113
+ ctx.logger.warn("[security] claim-orphan-tenant-rows failed", {
1114
+ error: e.message
1115
+ });
1116
+ }
1117
+ }
1118
+ if (orgCount > 1) {
1119
+ try {
1120
+ const summary = await cloneTenantSeedData(ql, newOrgId, { logger: ctx.logger });
1121
+ if (summary.length > 0) {
1122
+ const total = summary.reduce((s, c) => s + c.count, 0);
1123
+ ctx.logger.info(
1124
+ `[security] cloned ${total} seed row(s) for new organization ${newOrgId}`,
1125
+ { breakdown: summary }
1126
+ );
1127
+ }
1128
+ } catch (e) {
1129
+ ctx.logger.warn("[security] clone-tenant-seed-data failed", {
1130
+ organizationId: newOrgId,
1131
+ error: e.message
1132
+ });
1133
+ }
1134
+ }
948
1135
  });
949
1136
  }
950
1137
  }
@@ -988,6 +1175,8 @@ var SecurityPlugin = class {
988
1175
  obj = await metadata?.get?.("object", objectName);
989
1176
  }
990
1177
  if (!obj || !obj.fields) return null;
1178
+ const tenancyDisabled = obj?.tenancy?.enabled === false || obj?.systemFields?.tenant === false;
1179
+ this.tenancyDisabledCache.set(objectName, !!tenancyDisabled);
991
1180
  const set = /* @__PURE__ */ new Set(["id"]);
992
1181
  if (Array.isArray(obj.fields)) {
993
1182
  for (const f of obj.fields) {