@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.js
CHANGED
|
@@ -914,6 +914,7 @@ __export(index_exports, {
|
|
|
914
914
|
SECURITY_PLUGIN_ID: () => SECURITY_PLUGIN_ID,
|
|
915
915
|
SECURITY_PLUGIN_VERSION: () => SECURITY_PLUGIN_VERSION,
|
|
916
916
|
SecurityPlugin: () => SecurityPlugin,
|
|
917
|
+
appDefaultProfileName: () => appDefaultProfileName,
|
|
917
918
|
backfillOrgAdminGrants: () => backfillOrgAdminGrants,
|
|
918
919
|
bootstrapPlatformAdmin: () => bootstrapPlatformAdmin,
|
|
919
920
|
claimSeedOwnership: () => claimSeedOwnership,
|
|
@@ -1053,7 +1054,19 @@ var PermissionEvaluator = class {
|
|
|
1053
1054
|
var RLS_DENY_FILTER = Object.freeze({
|
|
1054
1055
|
id: "__rls_deny__:00000000-0000-0000-0000-000000000000"
|
|
1055
1056
|
});
|
|
1057
|
+
function isSupportedRlsExpression(expression) {
|
|
1058
|
+
if (!expression) return false;
|
|
1059
|
+
const e = expression.trim();
|
|
1060
|
+
if (/^\s*1\s*=\s*1\s*$/.test(e)) return true;
|
|
1061
|
+
if (/^\s*\w+\s*=\s*current_user\.\w+\s*$/.test(e)) return true;
|
|
1062
|
+
if (/^\s*\w+\s*=\s*'[^']*'\s*$/.test(e)) return true;
|
|
1063
|
+
if (/^\s*\w+\s+IN\s+\(\s*current_user\.\w+\s*\)\s*$/i.test(e)) return true;
|
|
1064
|
+
return false;
|
|
1065
|
+
}
|
|
1056
1066
|
var RLSCompiler = class {
|
|
1067
|
+
setLogger(logger) {
|
|
1068
|
+
this.logger = logger;
|
|
1069
|
+
}
|
|
1057
1070
|
/**
|
|
1058
1071
|
* Compile RLS policies into a query filter for the given user context.
|
|
1059
1072
|
* Multiple policies for the same object/operation are OR-combined (any match allows access).
|
|
@@ -1073,7 +1086,9 @@ var RLSCompiler = class {
|
|
|
1073
1086
|
id: executionContext?.userId,
|
|
1074
1087
|
organization_id: executionContext?.tenantId,
|
|
1075
1088
|
roles: executionContext?.roles,
|
|
1076
|
-
org_user_ids: executionContext?.org_user_ids
|
|
1089
|
+
org_user_ids: executionContext?.org_user_ids,
|
|
1090
|
+
// Unique identifier — safe for ownership predicates (see RLSUserContext).
|
|
1091
|
+
email: executionContext?.email
|
|
1077
1092
|
};
|
|
1078
1093
|
const membership = executionContext?.rlsMembership;
|
|
1079
1094
|
if (membership && typeof membership === "object") {
|
|
@@ -1089,6 +1104,10 @@ var RLSCompiler = class {
|
|
|
1089
1104
|
const filter = this.compileExpression(policy.using, userCtx);
|
|
1090
1105
|
if (filter) {
|
|
1091
1106
|
filters.push(filter);
|
|
1107
|
+
} else if (!isSupportedRlsExpression(policy.using)) {
|
|
1108
|
+
this.logger?.warn?.(
|
|
1109
|
+
`[RLS] policy '${policy.name ?? "(unnamed)"}' on '${policy.object ?? "?"}' has an uncompilable predicate and was DROPPED (no enforcement): ${policy.using}`
|
|
1110
|
+
);
|
|
1092
1111
|
}
|
|
1093
1112
|
}
|
|
1094
1113
|
if (filters.length === 0) {
|
|
@@ -2709,9 +2728,11 @@ var SecurityPlugin = class {
|
|
|
2709
2728
|
*/
|
|
2710
2729
|
this.metadata = null;
|
|
2711
2730
|
this.ql = null;
|
|
2731
|
+
/** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */
|
|
2732
|
+
this.cbpRelCache = /* @__PURE__ */ new Map();
|
|
2712
2733
|
this.logger = {};
|
|
2713
2734
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
2714
|
-
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? "member_default" : options.fallbackPermissionSet;
|
|
2735
|
+
this.fallbackPermissionSet = options.fallbackPermissionSet === void 0 ? this.bootstrapPermissionSets.find((p) => p.isDefault)?.name ?? "member_default" : options.fallbackPermissionSet;
|
|
2715
2736
|
}
|
|
2716
2737
|
async init(ctx) {
|
|
2717
2738
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -2778,6 +2799,7 @@ var SecurityPlugin = class {
|
|
|
2778
2799
|
this.metadata = metadata;
|
|
2779
2800
|
this.ql = ql;
|
|
2780
2801
|
this.logger = ctx.logger;
|
|
2802
|
+
this.rlsCompiler.setLogger?.(ctx.logger);
|
|
2781
2803
|
try {
|
|
2782
2804
|
const orgScoping = ctx.getService("org-scoping");
|
|
2783
2805
|
this.orgScopingEnabled = !!orgScoping;
|
|
@@ -2827,6 +2849,16 @@ var SecurityPlugin = class {
|
|
|
2827
2849
|
if (opCtx.context?.isSystem) {
|
|
2828
2850
|
return next();
|
|
2829
2851
|
}
|
|
2852
|
+
const formGrant = opCtx.context?.publicFormGrant;
|
|
2853
|
+
if (formGrant && typeof formGrant === "object" && formGrant.object) {
|
|
2854
|
+
const grantObject = formGrant.object;
|
|
2855
|
+
const allowed = opCtx.object === grantObject && ["insert", "find", "findOne", "count"].includes(opCtx.operation);
|
|
2856
|
+
if (allowed) return next();
|
|
2857
|
+
throw new PermissionDeniedError(
|
|
2858
|
+
`[Security] Access denied: public-form grant permits only create/read-back on '${grantObject}', not '${opCtx.operation}' on '${opCtx.object}'`,
|
|
2859
|
+
{ operation: opCtx.operation, object: opCtx.object }
|
|
2860
|
+
);
|
|
2861
|
+
}
|
|
2830
2862
|
const roles = opCtx.context?.roles ?? [];
|
|
2831
2863
|
const explicitPermissionSets = opCtx.context?.permissions ?? [];
|
|
2832
2864
|
if (roles.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId) {
|
|
@@ -2891,6 +2923,15 @@ var SecurityPlugin = class {
|
|
|
2891
2923
|
}
|
|
2892
2924
|
}
|
|
2893
2925
|
}
|
|
2926
|
+
if ((opCtx.operation === "insert" || opCtx.operation === "update" || opCtx.operation === "delete") && permissionSets.length > 0 && !!opCtx.context?.userId && this.ql) {
|
|
2927
|
+
await this.assertControlledByParentWrite(
|
|
2928
|
+
permissionSets,
|
|
2929
|
+
opCtx.object,
|
|
2930
|
+
opCtx.operation,
|
|
2931
|
+
opCtx,
|
|
2932
|
+
opCtx.context
|
|
2933
|
+
);
|
|
2934
|
+
}
|
|
2894
2935
|
if ((opCtx.operation === "insert" || opCtx.operation === "update") && opCtx.data && permissionSets.length > 0) {
|
|
2895
2936
|
const fieldPerms = this.permissionEvaluator.getFieldPermissions(
|
|
2896
2937
|
opCtx.object,
|
|
@@ -2925,18 +2966,22 @@ var SecurityPlugin = class {
|
|
|
2925
2966
|
}
|
|
2926
2967
|
}
|
|
2927
2968
|
if (opCtx.ast) {
|
|
2969
|
+
const extra = [];
|
|
2928
2970
|
const rlsFilter = await this.computeRlsFilter(
|
|
2929
2971
|
permissionSets,
|
|
2930
2972
|
opCtx.object,
|
|
2931
2973
|
opCtx.operation,
|
|
2932
2974
|
opCtx.context
|
|
2933
2975
|
);
|
|
2934
|
-
if (rlsFilter)
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2976
|
+
if (rlsFilter) extra.push(rlsFilter);
|
|
2977
|
+
const cbpFilter = await this.computeControlledByParentFilter(
|
|
2978
|
+
permissionSets,
|
|
2979
|
+
opCtx.object,
|
|
2980
|
+
opCtx.context
|
|
2981
|
+
);
|
|
2982
|
+
if (cbpFilter) extra.push(cbpFilter);
|
|
2983
|
+
if (extra.length) {
|
|
2984
|
+
opCtx.ast.where = opCtx.ast.where ? { $and: [opCtx.ast.where, ...extra] } : extra.length === 1 ? extra[0] : { $and: extra };
|
|
2940
2985
|
}
|
|
2941
2986
|
}
|
|
2942
2987
|
await next();
|
|
@@ -3132,6 +3177,122 @@ var SecurityPlugin = class {
|
|
|
3132
3177
|
}
|
|
3133
3178
|
return rlsFilter;
|
|
3134
3179
|
}
|
|
3180
|
+
/**
|
|
3181
|
+
* Resolve a controlled_by_parent object's master-detail relation (the FK field
|
|
3182
|
+
* key + the master object name), or null. Prefers a required `master_detail`
|
|
3183
|
+
* field; falls back to any `master_detail`, then a required `lookup`. Cached.
|
|
3184
|
+
*/
|
|
3185
|
+
resolveCbpRelation(object) {
|
|
3186
|
+
if (this.cbpRelCache.has(object)) return this.cbpRelCache.get(object) ?? null;
|
|
3187
|
+
let rel = null;
|
|
3188
|
+
const schema = typeof this.ql?.getSchema === "function" ? this.ql.getSchema(object) : null;
|
|
3189
|
+
const fields = schema?.fields;
|
|
3190
|
+
const entries = Array.isArray(fields) ? fields.map((f) => [f?.name, f]) : fields && typeof fields === "object" ? Object.entries(fields) : [];
|
|
3191
|
+
const ref = (f) => f?.reference ?? f?.reference_to ?? f?.referenceTo;
|
|
3192
|
+
const pick = (pred) => entries.find(([, f]) => pred(f) && ref(f));
|
|
3193
|
+
const found = pick((f) => f?.type === "master_detail" && f?.required) ?? pick((f) => f?.type === "master_detail") ?? pick((f) => f?.type === "lookup" && f?.required);
|
|
3194
|
+
if (found) rel = { fk: String(found[0]), master: String(ref(found[1])) };
|
|
3195
|
+
this.cbpRelCache.set(object, rel);
|
|
3196
|
+
return rel;
|
|
3197
|
+
}
|
|
3198
|
+
/**
|
|
3199
|
+
* ADR-0055 — master-detail "controlled by parent" READ derivation.
|
|
3200
|
+
*
|
|
3201
|
+
* For an object whose `sharingModel` is `controlled_by_parent`, access is
|
|
3202
|
+
* derived from the master: return a filter `masterFK IN (<master ids this user
|
|
3203
|
+
* can read>)`. The id set is resolved by running the MASTER's own read RLS
|
|
3204
|
+
* (reused via `computeRlsFilter`) under a system context — no middleware
|
|
3205
|
+
* re-entry, so no recursion. An empty set yields `{ masterFK: { $in: [] } }`,
|
|
3206
|
+
* which matches no rows (fail closed). A misconfigured object (no
|
|
3207
|
+
* master_detail/lookup to derive from) denies all reads (defense-in-depth;
|
|
3208
|
+
* spec validation should prevent authoring it). Returns null when the object is
|
|
3209
|
+
* not controlled_by_parent.
|
|
3210
|
+
*
|
|
3211
|
+
* v1 scope (ADR-0055): single level — the master's OWN controlled_by_parent is
|
|
3212
|
+
* not traversed transitively; master accessibility is the master's RLS filter
|
|
3213
|
+
* (sharing-service grants on the master are not folded in).
|
|
3214
|
+
*/
|
|
3215
|
+
async computeControlledByParentFilter(permissionSets, object, context) {
|
|
3216
|
+
if (!this.ql || !context?.userId) return null;
|
|
3217
|
+
const schema = typeof this.ql.getSchema === "function" ? this.ql.getSchema(object) : null;
|
|
3218
|
+
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
|
|
3219
|
+
if (sharingModel !== "controlled_by_parent") return null;
|
|
3220
|
+
const rel = this.resolveCbpRelation(object);
|
|
3221
|
+
if (!rel) return { ...RLS_DENY_FILTER };
|
|
3222
|
+
const masterFilter = await this.computeRlsFilter(permissionSets, rel.master, "find", context);
|
|
3223
|
+
let masterIds = [];
|
|
3224
|
+
try {
|
|
3225
|
+
const rows = await this.ql.find(rel.master, {
|
|
3226
|
+
where: masterFilter ?? {},
|
|
3227
|
+
fields: ["id"],
|
|
3228
|
+
context: { isSystem: true }
|
|
3229
|
+
});
|
|
3230
|
+
masterIds = (Array.isArray(rows) ? rows : []).map((r) => r?.id).filter((id) => id != null);
|
|
3231
|
+
} catch {
|
|
3232
|
+
masterIds = [];
|
|
3233
|
+
}
|
|
3234
|
+
return { [rel.fk]: { $in: masterIds } };
|
|
3235
|
+
}
|
|
3236
|
+
/**
|
|
3237
|
+
* ADR-0055 — master-detail "controlled by parent" WRITE enforcement.
|
|
3238
|
+
*
|
|
3239
|
+
* A by-id write (insert/update/delete) to a controlled_by_parent detail
|
|
3240
|
+
* requires EDIT access to its master: the caller must hold CRUD `update` on the
|
|
3241
|
+
* master object AND the master row must be visible under the master's write RLS.
|
|
3242
|
+
* This is the write-side companion to the read derivation — the RLS read filter
|
|
3243
|
+
* never applies to a by-id write (the #1994 class), so without this a member
|
|
3244
|
+
* could mutate a detail under a master they cannot edit. Throws on denial;
|
|
3245
|
+
* no-op when the object is not controlled_by_parent.
|
|
3246
|
+
*
|
|
3247
|
+
* v1 scope: single-id writes. Bulk writes flow through the AST and are already
|
|
3248
|
+
* scoped by the controlled-by-parent READ filter (to readable masters).
|
|
3249
|
+
*/
|
|
3250
|
+
async assertControlledByParentWrite(permissionSets, object, operation, opCtx, context) {
|
|
3251
|
+
const schema = typeof this.ql?.getSchema === "function" ? this.ql.getSchema(object) : null;
|
|
3252
|
+
const sharingModel = schema?.sharingModel ?? schema?.security?.sharingModel;
|
|
3253
|
+
if (sharingModel !== "controlled_by_parent") return;
|
|
3254
|
+
const deny = (reason, recordId) => {
|
|
3255
|
+
throw new PermissionDeniedError(
|
|
3256
|
+
`[Security] Access denied: ${operation} on '${object}' requires edit access to its master record (${reason})`,
|
|
3257
|
+
{ operation, object, recordId }
|
|
3258
|
+
);
|
|
3259
|
+
};
|
|
3260
|
+
const rel = this.resolveCbpRelation(object);
|
|
3261
|
+
if (!rel) deny("controlled_by_parent declared but no master_detail relation");
|
|
3262
|
+
let masterId;
|
|
3263
|
+
if (operation === "insert") {
|
|
3264
|
+
const data = opCtx.data;
|
|
3265
|
+
masterId = data && typeof data === "object" && !Array.isArray(data) ? data[rel.fk] : void 0;
|
|
3266
|
+
} else {
|
|
3267
|
+
const targetId = this.extractSingleId(opCtx);
|
|
3268
|
+
if (targetId == null) return;
|
|
3269
|
+
let row = null;
|
|
3270
|
+
try {
|
|
3271
|
+
row = await this.ql.findOne(object, { where: { id: targetId }, context: { isSystem: true } });
|
|
3272
|
+
} catch {
|
|
3273
|
+
row = null;
|
|
3274
|
+
}
|
|
3275
|
+
if (!row) deny("target record not found", targetId);
|
|
3276
|
+
masterId = row[rel.fk];
|
|
3277
|
+
}
|
|
3278
|
+
if (masterId == null) deny("detail record has no master reference");
|
|
3279
|
+
if (!this.permissionEvaluator.checkObjectPermission("update", rel.master, permissionSets)) {
|
|
3280
|
+
deny(`no edit permission on master '${rel.master}'`, masterId);
|
|
3281
|
+
}
|
|
3282
|
+
const masterWriteFilter = await this.computeRlsFilter(permissionSets, rel.master, "update", context);
|
|
3283
|
+
if (masterWriteFilter) {
|
|
3284
|
+
let visible = null;
|
|
3285
|
+
try {
|
|
3286
|
+
visible = await this.ql.findOne(rel.master, {
|
|
3287
|
+
where: { $and: [{ id: masterId }, masterWriteFilter] },
|
|
3288
|
+
context
|
|
3289
|
+
});
|
|
3290
|
+
} catch {
|
|
3291
|
+
visible = null;
|
|
3292
|
+
}
|
|
3293
|
+
if (!visible) deny(`master '${rel.master}' not editable by this user (row-level security)`, masterId);
|
|
3294
|
+
}
|
|
3295
|
+
}
|
|
3135
3296
|
/**
|
|
3136
3297
|
* Collect all RLS policies from permission sets applicable to the given object/operation.
|
|
3137
3298
|
*/
|
|
@@ -3202,6 +3363,20 @@ var SecurityPlugin = class {
|
|
|
3202
3363
|
return m ? m[1] : null;
|
|
3203
3364
|
}
|
|
3204
3365
|
};
|
|
3366
|
+
|
|
3367
|
+
// src/app-default-profile.ts
|
|
3368
|
+
function appDefaultProfileName(permissions) {
|
|
3369
|
+
if (!Array.isArray(permissions)) return void 0;
|
|
3370
|
+
for (const p of permissions) {
|
|
3371
|
+
if (p && typeof p === "object") {
|
|
3372
|
+
const ps = p;
|
|
3373
|
+
if (ps.isDefault === true && ps.isProfile !== false && typeof ps.name === "string" && ps.name.length > 0) {
|
|
3374
|
+
return ps.name;
|
|
3375
|
+
}
|
|
3376
|
+
}
|
|
3377
|
+
}
|
|
3378
|
+
return void 0;
|
|
3379
|
+
}
|
|
3205
3380
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3206
3381
|
0 && (module.exports = {
|
|
3207
3382
|
FieldMasker,
|
|
@@ -3212,6 +3387,7 @@ var SecurityPlugin = class {
|
|
|
3212
3387
|
SECURITY_PLUGIN_ID,
|
|
3213
3388
|
SECURITY_PLUGIN_VERSION,
|
|
3214
3389
|
SecurityPlugin,
|
|
3390
|
+
appDefaultProfileName,
|
|
3215
3391
|
backfillOrgAdminGrants,
|
|
3216
3392
|
bootstrapPlatformAdmin,
|
|
3217
3393
|
claimSeedOwnership,
|