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