@objectstack/plugin-security 9.9.1 → 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 +253 -22
- package/dist/index.d.ts +253 -22
- package/dist/index.js +310 -22
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +307 -22
- 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,14 +1051,28 @@ 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
|
};
|
|
1058
|
+
const membership = executionContext?.rlsMembership;
|
|
1059
|
+
if (membership && typeof membership === "object") {
|
|
1060
|
+
for (const [key, value] of Object.entries(membership)) {
|
|
1061
|
+
if (Array.isArray(value) && userCtx[key] === void 0) {
|
|
1062
|
+
userCtx[key] = value;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1044
1066
|
const filters = [];
|
|
1045
1067
|
for (const policy of policies) {
|
|
1046
1068
|
if (!policy.using) continue;
|
|
1047
1069
|
const filter = this.compileExpression(policy.using, userCtx);
|
|
1048
1070
|
if (filter) {
|
|
1049
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
|
+
);
|
|
1050
1076
|
}
|
|
1051
1077
|
}
|
|
1052
1078
|
if (filters.length === 0) {
|
|
@@ -1057,14 +1083,24 @@ var RLSCompiler = class {
|
|
|
1057
1083
|
}
|
|
1058
1084
|
/**
|
|
1059
1085
|
* Compile a single RLS expression into a query filter.
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
* -
|
|
1064
|
-
* -
|
|
1086
|
+
*
|
|
1087
|
+
* This reference compiler recognizes exactly four forms — anything else
|
|
1088
|
+
* returns `null` and (via {@link compileFilter}) fails closed:
|
|
1089
|
+
* - `field = current_user.property` → `{ field: <value> }`
|
|
1090
|
+
* - `field = 'literal_value'` → `{ field: 'literal_value' }`
|
|
1091
|
+
* - `field IN (current_user.array)` → `{ field: { $in: [...] } }`
|
|
1092
|
+
* (the array may be a §7.3.1 pre-resolved membership set)
|
|
1093
|
+
* - `1 = 1` → `{}` (always-true / no restriction)
|
|
1094
|
+
*
|
|
1095
|
+
* There is intentionally no support for subqueries, `LIKE`/`ILIKE`,
|
|
1096
|
+
* regex, `ANY`/`ALL`, `AND`/`OR`/`NOT`, or `NULL` checks — express those
|
|
1097
|
+
* needs as a `current_user.*` property the runtime pre-resolves instead.
|
|
1065
1098
|
*/
|
|
1066
1099
|
compileExpression(expression, userCtx) {
|
|
1067
1100
|
if (!expression) return null;
|
|
1101
|
+
if (/^\s*1\s*=\s*1\s*$/.test(expression)) {
|
|
1102
|
+
return {};
|
|
1103
|
+
}
|
|
1068
1104
|
const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/);
|
|
1069
1105
|
if (eqMatch) {
|
|
1070
1106
|
const [, field, prop] = eqMatch;
|
|
@@ -1212,11 +1248,91 @@ function isPermissionDeniedError(e) {
|
|
|
1212
1248
|
}
|
|
1213
1249
|
|
|
1214
1250
|
// src/bootstrap-platform-admin.ts
|
|
1251
|
+
import { SystemUserId as SystemUserId2 } from "@objectstack/spec/system";
|
|
1252
|
+
|
|
1253
|
+
// src/claim-seed-ownership.ts
|
|
1215
1254
|
import { SystemUserId } from "@objectstack/spec/system";
|
|
1216
1255
|
var SYSTEM_CTX = { isSystem: true };
|
|
1256
|
+
function hasOwnerField(schema) {
|
|
1257
|
+
const fields = schema?.fields;
|
|
1258
|
+
if (!fields) return false;
|
|
1259
|
+
if (Array.isArray(fields)) {
|
|
1260
|
+
return fields.some((f) => f?.name === "owner_id");
|
|
1261
|
+
}
|
|
1262
|
+
return Object.prototype.hasOwnProperty.call(fields, "owner_id");
|
|
1263
|
+
}
|
|
1264
|
+
async function claimSeedOwnership(ql, adminUserId, options = {}) {
|
|
1265
|
+
const logger = options.logger;
|
|
1266
|
+
if (!adminUserId || adminUserId === SystemUserId.SYSTEM) return [];
|
|
1267
|
+
if (!ql || typeof ql.update !== "function" || typeof ql.find !== "function") {
|
|
1268
|
+
return [];
|
|
1269
|
+
}
|
|
1270
|
+
const registry = ql.registry;
|
|
1271
|
+
if (!registry || typeof registry.getAllObjects !== "function") {
|
|
1272
|
+
logger?.warn?.("[security] claimSeedOwnership: registry unavailable");
|
|
1273
|
+
return [];
|
|
1274
|
+
}
|
|
1275
|
+
const schemas = registry.getAllObjects();
|
|
1276
|
+
const results = [];
|
|
1277
|
+
for (const schema of schemas) {
|
|
1278
|
+
if (!schema?.name) continue;
|
|
1279
|
+
if (schema.managedBy) continue;
|
|
1280
|
+
if (schema.name.startsWith("sys_")) continue;
|
|
1281
|
+
if (!hasOwnerField(schema)) continue;
|
|
1282
|
+
try {
|
|
1283
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1284
|
+
const ids = [];
|
|
1285
|
+
for (const where of [{ owner_id: null }, { owner_id: SystemUserId.SYSTEM }]) {
|
|
1286
|
+
const rows = await ql.find(
|
|
1287
|
+
schema.name,
|
|
1288
|
+
{ where, limit: 1e4, fields: ["id"] },
|
|
1289
|
+
{ context: SYSTEM_CTX }
|
|
1290
|
+
);
|
|
1291
|
+
const list = Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
|
|
1292
|
+
for (const r of list) {
|
|
1293
|
+
if (r?.id && !seen.has(r.id)) {
|
|
1294
|
+
seen.add(r.id);
|
|
1295
|
+
ids.push(r.id);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
if (ids.length === 0) continue;
|
|
1300
|
+
let updated = 0;
|
|
1301
|
+
for (const id of ids) {
|
|
1302
|
+
try {
|
|
1303
|
+
await ql.update(
|
|
1304
|
+
schema.name,
|
|
1305
|
+
{ id, owner_id: adminUserId },
|
|
1306
|
+
{ context: SYSTEM_CTX }
|
|
1307
|
+
);
|
|
1308
|
+
updated += 1;
|
|
1309
|
+
} catch (e) {
|
|
1310
|
+
logger?.warn?.(`[security] claimSeedOwnership failed for ${schema.name}:${id}`, {
|
|
1311
|
+
error: e.message
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (updated > 0) results.push({ object: schema.name, count: updated });
|
|
1316
|
+
} catch (e) {
|
|
1317
|
+
logger?.warn?.(`[security] claimSeedOwnership scan failed for ${schema.name}`, {
|
|
1318
|
+
error: e.message
|
|
1319
|
+
});
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
if (results.length > 0) {
|
|
1323
|
+
const total = results.reduce((s, r) => s + r.count, 0);
|
|
1324
|
+
logger?.info?.(`[security] handed ${total} seeded record(s) to first admin ${adminUserId}`, {
|
|
1325
|
+
breakdown: results
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
return results;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// src/bootstrap-platform-admin.ts
|
|
1332
|
+
var SYSTEM_CTX2 = { isSystem: true };
|
|
1217
1333
|
async function tryFind(ql, object, where, limit = 100) {
|
|
1218
1334
|
try {
|
|
1219
|
-
const rows = await ql.find(object, { where, limit }, { context:
|
|
1335
|
+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX2 });
|
|
1220
1336
|
return Array.isArray(rows) ? rows : [];
|
|
1221
1337
|
} catch {
|
|
1222
1338
|
return [];
|
|
@@ -1224,7 +1340,7 @@ async function tryFind(ql, object, where, limit = 100) {
|
|
|
1224
1340
|
}
|
|
1225
1341
|
async function tryInsert(ql, object, data) {
|
|
1226
1342
|
try {
|
|
1227
|
-
return await ql.insert(object, data, { context:
|
|
1343
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX2 });
|
|
1228
1344
|
} catch {
|
|
1229
1345
|
return null;
|
|
1230
1346
|
}
|
|
@@ -1252,6 +1368,9 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
1252
1368
|
id,
|
|
1253
1369
|
name: ps.name,
|
|
1254
1370
|
label: ps.label ?? ps.name,
|
|
1371
|
+
// `description` is not part of the typed PermissionSet shape (name/label
|
|
1372
|
+
// only); read it defensively so a runtime-provided description still
|
|
1373
|
+
// persists without tripping the dts typecheck.
|
|
1255
1374
|
description: ps.description ?? null,
|
|
1256
1375
|
object_permissions: JSON.stringify(ps.objects ?? {}),
|
|
1257
1376
|
field_permissions: JSON.stringify(ps.fields ?? {}),
|
|
@@ -1281,12 +1400,12 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
1281
1400
|
{ permission_set_id: adminPsId },
|
|
1282
1401
|
50
|
|
1283
1402
|
);
|
|
1284
|
-
if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !==
|
|
1403
|
+
if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !== SystemUserId2.SYSTEM)) {
|
|
1285
1404
|
return { seeded: seededCount, adminPromoted: false, reason: "already_have_admin" };
|
|
1286
1405
|
}
|
|
1287
1406
|
const allUsers = await tryFind(ql, "sys_user", {}, 50);
|
|
1288
1407
|
const humanUsers = allUsers.filter(
|
|
1289
|
-
(u) => u.id !==
|
|
1408
|
+
(u) => u.id !== SystemUserId2.SYSTEM && u.role !== "system"
|
|
1290
1409
|
);
|
|
1291
1410
|
if (humanUsers.length === 0) {
|
|
1292
1411
|
logger?.info?.("[security] no human users yet \u2014 first sign-up will be promoted to platform admin");
|
|
@@ -1310,11 +1429,18 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
1310
1429
|
return { seeded: seededCount, adminPromoted: false, reason: "insert_failed" };
|
|
1311
1430
|
}
|
|
1312
1431
|
logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
|
|
1313
|
-
|
|
1432
|
+
let ownershipClaimed = 0;
|
|
1433
|
+
try {
|
|
1434
|
+
const claims = await claimSeedOwnership(ql, target.id, { logger });
|
|
1435
|
+
ownershipClaimed = claims.reduce((s, c) => s + c.count, 0);
|
|
1436
|
+
} catch (e) {
|
|
1437
|
+
logger?.warn?.("[security] seed ownership handoff failed", { error: e.message });
|
|
1438
|
+
}
|
|
1439
|
+
return { seeded: seededCount, adminPromoted: true, ownershipClaimed };
|
|
1314
1440
|
}
|
|
1315
1441
|
|
|
1316
1442
|
// src/auto-org-admin-grant.ts
|
|
1317
|
-
var
|
|
1443
|
+
var SYSTEM_CTX3 = { isSystem: true };
|
|
1318
1444
|
var PERMISSION_SET_NAME = "organization_admin";
|
|
1319
1445
|
function genId2(prefix) {
|
|
1320
1446
|
const rand = Math.random().toString(36).slice(2, 10);
|
|
@@ -1323,7 +1449,7 @@ function genId2(prefix) {
|
|
|
1323
1449
|
}
|
|
1324
1450
|
async function tryFind2(ql, object, where, limit = 50) {
|
|
1325
1451
|
try {
|
|
1326
|
-
const rows = await ql.find(object, { where, limit }, { context:
|
|
1452
|
+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX3 });
|
|
1327
1453
|
return Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
|
|
1328
1454
|
} catch {
|
|
1329
1455
|
return [];
|
|
@@ -1331,14 +1457,14 @@ async function tryFind2(ql, object, where, limit = 50) {
|
|
|
1331
1457
|
}
|
|
1332
1458
|
async function tryInsert2(ql, object, data) {
|
|
1333
1459
|
try {
|
|
1334
|
-
return await ql.insert(object, data, { context:
|
|
1460
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX3 });
|
|
1335
1461
|
} catch {
|
|
1336
1462
|
return null;
|
|
1337
1463
|
}
|
|
1338
1464
|
}
|
|
1339
1465
|
async function tryDelete(ql, object, id) {
|
|
1340
1466
|
try {
|
|
1341
|
-
await ql.delete(object, id, { context:
|
|
1467
|
+
await ql.delete(object, id, { context: SYSTEM_CTX3 });
|
|
1342
1468
|
return true;
|
|
1343
1469
|
} catch {
|
|
1344
1470
|
return false;
|
|
@@ -2567,9 +2693,11 @@ var SecurityPlugin = class {
|
|
|
2567
2693
|
*/
|
|
2568
2694
|
this.metadata = null;
|
|
2569
2695
|
this.ql = null;
|
|
2696
|
+
/** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */
|
|
2697
|
+
this.cbpRelCache = /* @__PURE__ */ new Map();
|
|
2570
2698
|
this.logger = {};
|
|
2571
2699
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
2572
|
-
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;
|
|
2573
2701
|
}
|
|
2574
2702
|
async init(ctx) {
|
|
2575
2703
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -2636,6 +2764,7 @@ var SecurityPlugin = class {
|
|
|
2636
2764
|
this.metadata = metadata;
|
|
2637
2765
|
this.ql = ql;
|
|
2638
2766
|
this.logger = ctx.logger;
|
|
2767
|
+
this.rlsCompiler.setLogger?.(ctx.logger);
|
|
2639
2768
|
try {
|
|
2640
2769
|
const orgScoping = ctx.getService("org-scoping");
|
|
2641
2770
|
this.orgScopingEnabled = !!orgScoping;
|
|
@@ -2685,6 +2814,16 @@ var SecurityPlugin = class {
|
|
|
2685
2814
|
if (opCtx.context?.isSystem) {
|
|
2686
2815
|
return next();
|
|
2687
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
|
+
}
|
|
2688
2827
|
const roles = opCtx.context?.roles ?? [];
|
|
2689
2828
|
const explicitPermissionSets = opCtx.context?.permissions ?? [];
|
|
2690
2829
|
if (roles.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId) {
|
|
@@ -2749,6 +2888,15 @@ var SecurityPlugin = class {
|
|
|
2749
2888
|
}
|
|
2750
2889
|
}
|
|
2751
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
|
+
}
|
|
2752
2900
|
if ((opCtx.operation === "insert" || opCtx.operation === "update") && opCtx.data && permissionSets.length > 0) {
|
|
2753
2901
|
const fieldPerms = this.permissionEvaluator.getFieldPermissions(
|
|
2754
2902
|
opCtx.object,
|
|
@@ -2783,18 +2931,22 @@ var SecurityPlugin = class {
|
|
|
2783
2931
|
}
|
|
2784
2932
|
}
|
|
2785
2933
|
if (opCtx.ast) {
|
|
2934
|
+
const extra = [];
|
|
2786
2935
|
const rlsFilter = await this.computeRlsFilter(
|
|
2787
2936
|
permissionSets,
|
|
2788
2937
|
opCtx.object,
|
|
2789
2938
|
opCtx.operation,
|
|
2790
2939
|
opCtx.context
|
|
2791
2940
|
);
|
|
2792
|
-
if (rlsFilter)
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
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 };
|
|
2798
2950
|
}
|
|
2799
2951
|
}
|
|
2800
2952
|
await next();
|
|
@@ -2990,6 +3142,122 @@ var SecurityPlugin = class {
|
|
|
2990
3142
|
}
|
|
2991
3143
|
return rlsFilter;
|
|
2992
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
|
+
}
|
|
2993
3261
|
/**
|
|
2994
3262
|
* Collect all RLS policies from permission sets applicable to the given object/operation.
|
|
2995
3263
|
*/
|
|
@@ -3060,6 +3328,20 @@ var SecurityPlugin = class {
|
|
|
3060
3328
|
return m ? m[1] : null;
|
|
3061
3329
|
}
|
|
3062
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
|
+
}
|
|
3063
3345
|
export {
|
|
3064
3346
|
FieldMasker,
|
|
3065
3347
|
PermissionDeniedError,
|
|
@@ -3069,7 +3351,10 @@ export {
|
|
|
3069
3351
|
SECURITY_PLUGIN_ID,
|
|
3070
3352
|
SECURITY_PLUGIN_VERSION,
|
|
3071
3353
|
SecurityPlugin,
|
|
3354
|
+
appDefaultProfileName,
|
|
3072
3355
|
backfillOrgAdminGrants,
|
|
3356
|
+
bootstrapPlatformAdmin,
|
|
3357
|
+
claimSeedOwnership,
|
|
3073
3358
|
isPermissionDeniedError,
|
|
3074
3359
|
reconcileOrgAdminGrant,
|
|
3075
3360
|
securityDefaultPermissionSets,
|