@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.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"
|
|
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
|
-
|
|
884
|
-
if (
|
|
885
|
-
|
|
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
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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]
|
|
971
|
-
{
|
|
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]
|
|
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) {
|