@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.d.mts +1277 -29
- package/dist/index.d.ts +1277 -29
- package/dist/index.js +213 -24
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +213 -24
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -6
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"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
853
|
-
if (
|
|
854
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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]
|
|
940
|
-
{
|
|
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]
|
|
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) {
|