@objectstack/plugin-security 9.10.0 → 9.11.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 +81 -9
- package/dist/index.d.ts +81 -9
- package/dist/index.js +184 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +183 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -1019,7 +1019,19 @@ var PermissionEvaluator = class {
|
|
|
1019
1019
|
var RLS_DENY_FILTER = Object.freeze({
|
|
1020
1020
|
id: "__rls_deny__:00000000-0000-0000-0000-000000000000"
|
|
1021
1021
|
});
|
|
1022
|
+
function isSupportedRlsExpression(expression) {
|
|
1023
|
+
if (!expression) return false;
|
|
1024
|
+
const e = expression.trim();
|
|
1025
|
+
if (/^\s*1\s*=\s*1\s*$/.test(e)) return true;
|
|
1026
|
+
if (/^\s*\w+\s*=\s*current_user\.\w+\s*$/.test(e)) return true;
|
|
1027
|
+
if (/^\s*\w+\s*=\s*'[^']*'\s*$/.test(e)) return true;
|
|
1028
|
+
if (/^\s*\w+\s+IN\s+\(\s*current_user\.\w+\s*\)\s*$/i.test(e)) return true;
|
|
1029
|
+
return false;
|
|
1030
|
+
}
|
|
1022
1031
|
var RLSCompiler = class {
|
|
1032
|
+
setLogger(logger) {
|
|
1033
|
+
this.logger = logger;
|
|
1034
|
+
}
|
|
1023
1035
|
/**
|
|
1024
1036
|
* Compile RLS policies into a query filter for the given user context.
|
|
1025
1037
|
* Multiple policies for the same object/operation are OR-combined (any match allows access).
|
|
@@ -1039,7 +1051,9 @@ var RLSCompiler = class {
|
|
|
1039
1051
|
id: executionContext?.userId,
|
|
1040
1052
|
organization_id: executionContext?.tenantId,
|
|
1041
1053
|
roles: executionContext?.roles,
|
|
1042
|
-
org_user_ids: executionContext?.org_user_ids
|
|
1054
|
+
org_user_ids: executionContext?.org_user_ids,
|
|
1055
|
+
// Unique identifier — safe for ownership predicates (see RLSUserContext).
|
|
1056
|
+
email: executionContext?.email
|
|
1043
1057
|
};
|
|
1044
1058
|
const membership = executionContext?.rlsMembership;
|
|
1045
1059
|
if (membership && typeof membership === "object") {
|
|
@@ -1055,6 +1069,10 @@ var RLSCompiler = class {
|
|
|
1055
1069
|
const filter = this.compileExpression(policy.using, userCtx);
|
|
1056
1070
|
if (filter) {
|
|
1057
1071
|
filters.push(filter);
|
|
1072
|
+
} else if (!isSupportedRlsExpression(policy.using)) {
|
|
1073
|
+
this.logger?.warn?.(
|
|
1074
|
+
`[RLS] policy '${policy.name ?? "(unnamed)"}' on '${policy.object ?? "?"}' has an uncompilable predicate and was DROPPED (no enforcement): ${policy.using}`
|
|
1075
|
+
);
|
|
1058
1076
|
}
|
|
1059
1077
|
}
|
|
1060
1078
|
if (filters.length === 0) {
|
|
@@ -2675,9 +2693,11 @@ var SecurityPlugin = class {
|
|
|
2675
2693
|
*/
|
|
2676
2694
|
this.metadata = null;
|
|
2677
2695
|
this.ql = null;
|
|
2696
|
+
/** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */
|
|
2697
|
+
this.cbpRelCache = /* @__PURE__ */ new Map();
|
|
2678
2698
|
this.logger = {};
|
|
2679
2699
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
2680
|
-
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
2700
|
+
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? this.bootstrapPermissionSets.find((p) => p.isDefault)?.name ?? "member_default" : options.fallbackPermissionSet;
|
|
2681
2701
|
}
|
|
2682
2702
|
async init(ctx) {
|
|
2683
2703
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -2744,6 +2764,7 @@ var SecurityPlugin = class {
|
|
|
2744
2764
|
this.metadata = metadata;
|
|
2745
2765
|
this.ql = ql;
|
|
2746
2766
|
this.logger = ctx.logger;
|
|
2767
|
+
this.rlsCompiler.setLogger?.(ctx.logger);
|
|
2747
2768
|
try {
|
|
2748
2769
|
const orgScoping = ctx.getService("org-scoping");
|
|
2749
2770
|
this.orgScopingEnabled = !!orgScoping;
|
|
@@ -2793,6 +2814,16 @@ var SecurityPlugin = class {
|
|
|
2793
2814
|
if (opCtx.context?.isSystem) {
|
|
2794
2815
|
return next();
|
|
2795
2816
|
}
|
|
2817
|
+
const formGrant = opCtx.context?.publicFormGrant;
|
|
2818
|
+
if (formGrant && typeof formGrant === "object" && formGrant.object) {
|
|
2819
|
+
const grantObject = formGrant.object;
|
|
2820
|
+
const allowed = opCtx.object === grantObject && ["insert", "find", "findOne", "count"].includes(opCtx.operation);
|
|
2821
|
+
if (allowed) return next();
|
|
2822
|
+
throw new PermissionDeniedError(
|
|
2823
|
+
`[Security] Access denied: public-form grant permits only create/read-back on '${grantObject}', not '${opCtx.operation}' on '${opCtx.object}'`,
|
|
2824
|
+
{ operation: opCtx.operation, object: opCtx.object }
|
|
2825
|
+
);
|
|
2826
|
+
}
|
|
2796
2827
|
const roles = opCtx.context?.roles ?? [];
|
|
2797
2828
|
const explicitPermissionSets = opCtx.context?.permissions ?? [];
|
|
2798
2829
|
if (roles.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId) {
|
|
@@ -2857,6 +2888,15 @@ var SecurityPlugin = class {
|
|
|
2857
2888
|
}
|
|
2858
2889
|
}
|
|
2859
2890
|
}
|
|
2891
|
+
if ((opCtx.operation === "insert" || opCtx.operation === "update" || opCtx.operation === "delete") && permissionSets.length > 0 && !!opCtx.context?.userId && this.ql) {
|
|
2892
|
+
await this.assertControlledByParentWrite(
|
|
2893
|
+
permissionSets,
|
|
2894
|
+
opCtx.object,
|
|
2895
|
+
opCtx.operation,
|
|
2896
|
+
opCtx,
|
|
2897
|
+
opCtx.context
|
|
2898
|
+
);
|
|
2899
|
+
}
|
|
2860
2900
|
if ((opCtx.operation === "insert" || opCtx.operation === "update") && opCtx.data && permissionSets.length > 0) {
|
|
2861
2901
|
const fieldPerms = this.permissionEvaluator.getFieldPermissions(
|
|
2862
2902
|
opCtx.object,
|
|
@@ -2891,18 +2931,22 @@ var SecurityPlugin = class {
|
|
|
2891
2931
|
}
|
|
2892
2932
|
}
|
|
2893
2933
|
if (opCtx.ast) {
|
|
2934
|
+
const extra = [];
|
|
2894
2935
|
const rlsFilter = await this.computeRlsFilter(
|
|
2895
2936
|
permissionSets,
|
|
2896
2937
|
opCtx.object,
|
|
2897
2938
|
opCtx.operation,
|
|
2898
2939
|
opCtx.context
|
|
2899
2940
|
);
|
|
2900
|
-
if (rlsFilter)
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2941
|
+
if (rlsFilter) extra.push(rlsFilter);
|
|
2942
|
+
const cbpFilter = await this.computeControlledByParentFilter(
|
|
2943
|
+
permissionSets,
|
|
2944
|
+
opCtx.object,
|
|
2945
|
+
opCtx.context
|
|
2946
|
+
);
|
|
2947
|
+
if (cbpFilter) extra.push(cbpFilter);
|
|
2948
|
+
if (extra.length) {
|
|
2949
|
+
opCtx.ast.where = opCtx.ast.where ? { $and: [opCtx.ast.where, ...extra] } : extra.length === 1 ? extra[0] : { $and: extra };
|
|
2906
2950
|
}
|
|
2907
2951
|
}
|
|
2908
2952
|
await next();
|
|
@@ -3098,6 +3142,122 @@ var SecurityPlugin = class {
|
|
|
3098
3142
|
}
|
|
3099
3143
|
return rlsFilter;
|
|
3100
3144
|
}
|
|
3145
|
+
/**
|
|
3146
|
+
* Resolve a controlled_by_parent object's master-detail relation (the FK field
|
|
3147
|
+
* key + the master object name), or null. Prefers a required `master_detail`
|
|
3148
|
+
* field; falls back to any `master_detail`, then a required `lookup`. Cached.
|
|
3149
|
+
*/
|
|
3150
|
+
resolveCbpRelation(object) {
|
|
3151
|
+
if (this.cbpRelCache.has(object)) return this.cbpRelCache.get(object) ?? null;
|
|
3152
|
+
let rel = null;
|
|
3153
|
+
const schema = typeof this.ql?.getSchema === "function" ? this.ql.getSchema(object) : null;
|
|
3154
|
+
const fields = schema?.fields;
|
|
3155
|
+
const entries = Array.isArray(fields) ? fields.map((f) => [f?.name, f]) : fields && typeof fields === "object" ? Object.entries(fields) : [];
|
|
3156
|
+
const ref = (f) => f?.reference ?? f?.reference_to ?? f?.referenceTo;
|
|
3157
|
+
const pick = (pred) => entries.find(([, f]) => pred(f) && ref(f));
|
|
3158
|
+
const found = pick((f) => f?.type === "master_detail" && f?.required) ?? pick((f) => f?.type === "master_detail") ?? pick((f) => f?.type === "lookup" && f?.required);
|
|
3159
|
+
if (found) rel = { fk: String(found[0]), master: String(ref(found[1])) };
|
|
3160
|
+
this.cbpRelCache.set(object, rel);
|
|
3161
|
+
return rel;
|
|
3162
|
+
}
|
|
3163
|
+
/**
|
|
3164
|
+
* ADR-0055 — master-detail "controlled by parent" READ derivation.
|
|
3165
|
+
*
|
|
3166
|
+
* For an object whose `sharingModel` is `controlled_by_parent`, access is
|
|
3167
|
+
* derived from the master: return a filter `masterFK IN (<master ids this user
|
|
3168
|
+
* can read>)`. The id set is resolved by running the MASTER's own read RLS
|
|
3169
|
+
* (reused via `computeRlsFilter`) under a system context — no middleware
|
|
3170
|
+
* re-entry, so no recursion. An empty set yields `{ masterFK: { $in: [] } }`,
|
|
3171
|
+
* which matches no rows (fail closed). A misconfigured object (no
|
|
3172
|
+
* master_detail/lookup to derive from) denies all reads (defense-in-depth;
|
|
3173
|
+
* spec validation should prevent authoring it). Returns null when the object is
|
|
3174
|
+
* not controlled_by_parent.
|
|
3175
|
+
*
|
|
3176
|
+
* v1 scope (ADR-0055): single level — the master's OWN controlled_by_parent is
|
|
3177
|
+
* not traversed transitively; master accessibility is the master's RLS filter
|
|
3178
|
+
* (sharing-service grants on the master are not folded in).
|
|
3179
|
+
*/
|
|
3180
|
+
async computeControlledByParentFilter(permissionSets, object, context) {
|
|
3181
|
+
if (!this.ql || !context?.userId) return null;
|
|
3182
|
+
const schema = typeof this.ql.getSchema === "function" ? this.ql.getSchema(object) : null;
|
|
3183
|
+
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
|
|
3184
|
+
if (sharingModel !== "controlled_by_parent") return null;
|
|
3185
|
+
const rel = this.resolveCbpRelation(object);
|
|
3186
|
+
if (!rel) return { ...RLS_DENY_FILTER };
|
|
3187
|
+
const masterFilter = await this.computeRlsFilter(permissionSets, rel.master, "find", context);
|
|
3188
|
+
let masterIds = [];
|
|
3189
|
+
try {
|
|
3190
|
+
const rows = await this.ql.find(rel.master, {
|
|
3191
|
+
where: masterFilter ?? {},
|
|
3192
|
+
fields: ["id"],
|
|
3193
|
+
context: { isSystem: true }
|
|
3194
|
+
});
|
|
3195
|
+
masterIds = (Array.isArray(rows) ? rows : []).map((r) => r?.id).filter((id) => id != null);
|
|
3196
|
+
} catch {
|
|
3197
|
+
masterIds = [];
|
|
3198
|
+
}
|
|
3199
|
+
return { [rel.fk]: { $in: masterIds } };
|
|
3200
|
+
}
|
|
3201
|
+
/**
|
|
3202
|
+
* ADR-0055 — master-detail "controlled by parent" WRITE enforcement.
|
|
3203
|
+
*
|
|
3204
|
+
* A by-id write (insert/update/delete) to a controlled_by_parent detail
|
|
3205
|
+
* requires EDIT access to its master: the caller must hold CRUD `update` on the
|
|
3206
|
+
* master object AND the master row must be visible under the master's write RLS.
|
|
3207
|
+
* This is the write-side companion to the read derivation — the RLS read filter
|
|
3208
|
+
* never applies to a by-id write (the #1994 class), so without this a member
|
|
3209
|
+
* could mutate a detail under a master they cannot edit. Throws on denial;
|
|
3210
|
+
* no-op when the object is not controlled_by_parent.
|
|
3211
|
+
*
|
|
3212
|
+
* v1 scope: single-id writes. Bulk writes flow through the AST and are already
|
|
3213
|
+
* scoped by the controlled-by-parent READ filter (to readable masters).
|
|
3214
|
+
*/
|
|
3215
|
+
async assertControlledByParentWrite(permissionSets, object, operation, opCtx, context) {
|
|
3216
|
+
const schema = typeof this.ql?.getSchema === "function" ? this.ql.getSchema(object) : null;
|
|
3217
|
+
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
|
|
3218
|
+
if (sharingModel !== "controlled_by_parent") return;
|
|
3219
|
+
const deny = (reason, recordId) => {
|
|
3220
|
+
throw new PermissionDeniedError(
|
|
3221
|
+
`[Security] Access denied: ${operation} on '${object}' requires edit access to its master record (${reason})`,
|
|
3222
|
+
{ operation, object, recordId }
|
|
3223
|
+
);
|
|
3224
|
+
};
|
|
3225
|
+
const rel = this.resolveCbpRelation(object);
|
|
3226
|
+
if (!rel) deny("controlled_by_parent declared but no master_detail relation");
|
|
3227
|
+
let masterId;
|
|
3228
|
+
if (operation === "insert") {
|
|
3229
|
+
const data = opCtx.data;
|
|
3230
|
+
masterId = data && typeof data === "object" && !Array.isArray(data) ? data[rel.fk] : void 0;
|
|
3231
|
+
} else {
|
|
3232
|
+
const targetId = this.extractSingleId(opCtx);
|
|
3233
|
+
if (targetId == null) return;
|
|
3234
|
+
let row = null;
|
|
3235
|
+
try {
|
|
3236
|
+
row = await this.ql.findOne(object, { where: { id: targetId }, context: { isSystem: true } });
|
|
3237
|
+
} catch {
|
|
3238
|
+
row = null;
|
|
3239
|
+
}
|
|
3240
|
+
if (!row) deny("target record not found", targetId);
|
|
3241
|
+
masterId = row[rel.fk];
|
|
3242
|
+
}
|
|
3243
|
+
if (masterId == null) deny("detail record has no master reference");
|
|
3244
|
+
if (!this.permissionEvaluator.checkObjectPermission("update", rel.master, permissionSets)) {
|
|
3245
|
+
deny(`no edit permission on master '${rel.master}'`, masterId);
|
|
3246
|
+
}
|
|
3247
|
+
const masterWriteFilter = await this.computeRlsFilter(permissionSets, rel.master, "update", context);
|
|
3248
|
+
if (masterWriteFilter) {
|
|
3249
|
+
let visible = null;
|
|
3250
|
+
try {
|
|
3251
|
+
visible = await this.ql.findOne(rel.master, {
|
|
3252
|
+
where: { $and: [{ id: masterId }, masterWriteFilter] },
|
|
3253
|
+
context
|
|
3254
|
+
});
|
|
3255
|
+
} catch {
|
|
3256
|
+
visible = null;
|
|
3257
|
+
}
|
|
3258
|
+
if (!visible) deny(`master '${rel.master}' not editable by this user (row-level security)`, masterId);
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3101
3261
|
/**
|
|
3102
3262
|
* Collect all RLS policies from permission sets applicable to the given object/operation.
|
|
3103
3263
|
*/
|
|
@@ -3168,6 +3328,20 @@ var SecurityPlugin = class {
|
|
|
3168
3328
|
return m ? m[1] : null;
|
|
3169
3329
|
}
|
|
3170
3330
|
};
|
|
3331
|
+
|
|
3332
|
+
// src/app-default-profile.ts
|
|
3333
|
+
function appDefaultProfileName(permissions) {
|
|
3334
|
+
if (!Array.isArray(permissions)) return void 0;
|
|
3335
|
+
for (const p of permissions) {
|
|
3336
|
+
if (p && typeof p === "object") {
|
|
3337
|
+
const ps = p;
|
|
3338
|
+
if (ps.isDefault === true && ps.isProfile !== false && typeof ps.name === "string" && ps.name.length > 0) {
|
|
3339
|
+
return ps.name;
|
|
3340
|
+
}
|
|
3341
|
+
}
|
|
3342
|
+
}
|
|
3343
|
+
return void 0;
|
|
3344
|
+
}
|
|
3171
3345
|
export {
|
|
3172
3346
|
FieldMasker,
|
|
3173
3347
|
PermissionDeniedError,
|
|
@@ -3177,6 +3351,7 @@ export {
|
|
|
3177
3351
|
SECURITY_PLUGIN_ID,
|
|
3178
3352
|
SECURITY_PLUGIN_VERSION,
|
|
3179
3353
|
SecurityPlugin,
|
|
3354
|
+
appDefaultProfileName,
|
|
3180
3355
|
backfillOrgAdminGrants,
|
|
3181
3356
|
bootstrapPlatformAdmin,
|
|
3182
3357
|
claimSeedOwnership,
|