@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.d.mts +1269 -25
- package/dist/index.d.ts +1269 -25
- package/dist/index.js +199 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +199 -21
- 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;
|
|
@@ -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
|
-
|
|
853
|
-
if (
|
|
854
|
-
|
|
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
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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]
|
|
940
|
-
{
|
|
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]
|
|
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) {
|