@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.js
CHANGED
|
@@ -914,7 +914,10 @@ __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,
|
|
919
|
+
bootstrapPlatformAdmin: () => bootstrapPlatformAdmin,
|
|
920
|
+
claimSeedOwnership: () => claimSeedOwnership,
|
|
918
921
|
isPermissionDeniedError: () => isPermissionDeniedError,
|
|
919
922
|
reconcileOrgAdminGrant: () => reconcileOrgAdminGrant,
|
|
920
923
|
securityDefaultPermissionSets: () => securityDefaultPermissionSets,
|
|
@@ -1051,7 +1054,19 @@ var PermissionEvaluator = class {
|
|
|
1051
1054
|
var RLS_DENY_FILTER = Object.freeze({
|
|
1052
1055
|
id: "__rls_deny__:00000000-0000-0000-0000-000000000000"
|
|
1053
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
|
+
}
|
|
1054
1066
|
var RLSCompiler = class {
|
|
1067
|
+
setLogger(logger) {
|
|
1068
|
+
this.logger = logger;
|
|
1069
|
+
}
|
|
1055
1070
|
/**
|
|
1056
1071
|
* Compile RLS policies into a query filter for the given user context.
|
|
1057
1072
|
* Multiple policies for the same object/operation are OR-combined (any match allows access).
|
|
@@ -1071,14 +1086,28 @@ var RLSCompiler = class {
|
|
|
1071
1086
|
id: executionContext?.userId,
|
|
1072
1087
|
organization_id: executionContext?.tenantId,
|
|
1073
1088
|
roles: executionContext?.roles,
|
|
1074
|
-
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
|
|
1075
1092
|
};
|
|
1093
|
+
const membership = executionContext?.rlsMembership;
|
|
1094
|
+
if (membership && typeof membership === "object") {
|
|
1095
|
+
for (const [key, value] of Object.entries(membership)) {
|
|
1096
|
+
if (Array.isArray(value) && userCtx[key] === void 0) {
|
|
1097
|
+
userCtx[key] = value;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1076
1101
|
const filters = [];
|
|
1077
1102
|
for (const policy of policies) {
|
|
1078
1103
|
if (!policy.using) continue;
|
|
1079
1104
|
const filter = this.compileExpression(policy.using, userCtx);
|
|
1080
1105
|
if (filter) {
|
|
1081
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
|
+
);
|
|
1082
1111
|
}
|
|
1083
1112
|
}
|
|
1084
1113
|
if (filters.length === 0) {
|
|
@@ -1089,14 +1118,24 @@ var RLSCompiler = class {
|
|
|
1089
1118
|
}
|
|
1090
1119
|
/**
|
|
1091
1120
|
* Compile a single RLS expression into a query filter.
|
|
1092
|
-
*
|
|
1093
|
-
*
|
|
1094
|
-
*
|
|
1095
|
-
* -
|
|
1096
|
-
* -
|
|
1121
|
+
*
|
|
1122
|
+
* This reference compiler recognizes exactly four forms — anything else
|
|
1123
|
+
* returns `null` and (via {@link compileFilter}) fails closed:
|
|
1124
|
+
* - `field = current_user.property` → `{ field: <value> }`
|
|
1125
|
+
* - `field = 'literal_value'` → `{ field: 'literal_value' }`
|
|
1126
|
+
* - `field IN (current_user.array)` → `{ field: { $in: [...] } }`
|
|
1127
|
+
* (the array may be a §7.3.1 pre-resolved membership set)
|
|
1128
|
+
* - `1 = 1` → `{}` (always-true / no restriction)
|
|
1129
|
+
*
|
|
1130
|
+
* There is intentionally no support for subqueries, `LIKE`/`ILIKE`,
|
|
1131
|
+
* regex, `ANY`/`ALL`, `AND`/`OR`/`NOT`, or `NULL` checks — express those
|
|
1132
|
+
* needs as a `current_user.*` property the runtime pre-resolves instead.
|
|
1097
1133
|
*/
|
|
1098
1134
|
compileExpression(expression, userCtx) {
|
|
1099
1135
|
if (!expression) return null;
|
|
1136
|
+
if (/^\s*1\s*=\s*1\s*$/.test(expression)) {
|
|
1137
|
+
return {};
|
|
1138
|
+
}
|
|
1100
1139
|
const eqMatch = expression.match(/^\s*(\w+)\s*=\s*current_user\.(\w+)\s*$/);
|
|
1101
1140
|
if (eqMatch) {
|
|
1102
1141
|
const [, field, prop] = eqMatch;
|
|
@@ -1244,11 +1283,91 @@ function isPermissionDeniedError(e) {
|
|
|
1244
1283
|
}
|
|
1245
1284
|
|
|
1246
1285
|
// src/bootstrap-platform-admin.ts
|
|
1286
|
+
var import_system2 = require("@objectstack/spec/system");
|
|
1287
|
+
|
|
1288
|
+
// src/claim-seed-ownership.ts
|
|
1247
1289
|
var import_system = require("@objectstack/spec/system");
|
|
1248
1290
|
var SYSTEM_CTX = { isSystem: true };
|
|
1291
|
+
function hasOwnerField(schema) {
|
|
1292
|
+
const fields = schema?.fields;
|
|
1293
|
+
if (!fields) return false;
|
|
1294
|
+
if (Array.isArray(fields)) {
|
|
1295
|
+
return fields.some((f) => f?.name === "owner_id");
|
|
1296
|
+
}
|
|
1297
|
+
return Object.prototype.hasOwnProperty.call(fields, "owner_id");
|
|
1298
|
+
}
|
|
1299
|
+
async function claimSeedOwnership(ql, adminUserId, options = {}) {
|
|
1300
|
+
const logger = options.logger;
|
|
1301
|
+
if (!adminUserId || adminUserId === import_system.SystemUserId.SYSTEM) return [];
|
|
1302
|
+
if (!ql || typeof ql.update !== "function" || typeof ql.find !== "function") {
|
|
1303
|
+
return [];
|
|
1304
|
+
}
|
|
1305
|
+
const registry = ql.registry;
|
|
1306
|
+
if (!registry || typeof registry.getAllObjects !== "function") {
|
|
1307
|
+
logger?.warn?.("[security] claimSeedOwnership: registry unavailable");
|
|
1308
|
+
return [];
|
|
1309
|
+
}
|
|
1310
|
+
const schemas = registry.getAllObjects();
|
|
1311
|
+
const results = [];
|
|
1312
|
+
for (const schema of schemas) {
|
|
1313
|
+
if (!schema?.name) continue;
|
|
1314
|
+
if (schema.managedBy) continue;
|
|
1315
|
+
if (schema.name.startsWith("sys_")) continue;
|
|
1316
|
+
if (!hasOwnerField(schema)) continue;
|
|
1317
|
+
try {
|
|
1318
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1319
|
+
const ids = [];
|
|
1320
|
+
for (const where of [{ owner_id: null }, { owner_id: import_system.SystemUserId.SYSTEM }]) {
|
|
1321
|
+
const rows = await ql.find(
|
|
1322
|
+
schema.name,
|
|
1323
|
+
{ where, limit: 1e4, fields: ["id"] },
|
|
1324
|
+
{ context: SYSTEM_CTX }
|
|
1325
|
+
);
|
|
1326
|
+
const list = Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
|
|
1327
|
+
for (const r of list) {
|
|
1328
|
+
if (r?.id && !seen.has(r.id)) {
|
|
1329
|
+
seen.add(r.id);
|
|
1330
|
+
ids.push(r.id);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
if (ids.length === 0) continue;
|
|
1335
|
+
let updated = 0;
|
|
1336
|
+
for (const id of ids) {
|
|
1337
|
+
try {
|
|
1338
|
+
await ql.update(
|
|
1339
|
+
schema.name,
|
|
1340
|
+
{ id, owner_id: adminUserId },
|
|
1341
|
+
{ context: SYSTEM_CTX }
|
|
1342
|
+
);
|
|
1343
|
+
updated += 1;
|
|
1344
|
+
} catch (e) {
|
|
1345
|
+
logger?.warn?.(`[security] claimSeedOwnership failed for ${schema.name}:${id}`, {
|
|
1346
|
+
error: e.message
|
|
1347
|
+
});
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
if (updated > 0) results.push({ object: schema.name, count: updated });
|
|
1351
|
+
} catch (e) {
|
|
1352
|
+
logger?.warn?.(`[security] claimSeedOwnership scan failed for ${schema.name}`, {
|
|
1353
|
+
error: e.message
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (results.length > 0) {
|
|
1358
|
+
const total = results.reduce((s, r) => s + r.count, 0);
|
|
1359
|
+
logger?.info?.(`[security] handed ${total} seeded record(s) to first admin ${adminUserId}`, {
|
|
1360
|
+
breakdown: results
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
return results;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// src/bootstrap-platform-admin.ts
|
|
1367
|
+
var SYSTEM_CTX2 = { isSystem: true };
|
|
1249
1368
|
async function tryFind(ql, object, where, limit = 100) {
|
|
1250
1369
|
try {
|
|
1251
|
-
const rows = await ql.find(object, { where, limit }, { context:
|
|
1370
|
+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX2 });
|
|
1252
1371
|
return Array.isArray(rows) ? rows : [];
|
|
1253
1372
|
} catch {
|
|
1254
1373
|
return [];
|
|
@@ -1256,7 +1375,7 @@ async function tryFind(ql, object, where, limit = 100) {
|
|
|
1256
1375
|
}
|
|
1257
1376
|
async function tryInsert(ql, object, data) {
|
|
1258
1377
|
try {
|
|
1259
|
-
return await ql.insert(object, data, { context:
|
|
1378
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX2 });
|
|
1260
1379
|
} catch {
|
|
1261
1380
|
return null;
|
|
1262
1381
|
}
|
|
@@ -1284,6 +1403,9 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
1284
1403
|
id,
|
|
1285
1404
|
name: ps.name,
|
|
1286
1405
|
label: ps.label ?? ps.name,
|
|
1406
|
+
// `description` is not part of the typed PermissionSet shape (name/label
|
|
1407
|
+
// only); read it defensively so a runtime-provided description still
|
|
1408
|
+
// persists without tripping the dts typecheck.
|
|
1287
1409
|
description: ps.description ?? null,
|
|
1288
1410
|
object_permissions: JSON.stringify(ps.objects ?? {}),
|
|
1289
1411
|
field_permissions: JSON.stringify(ps.fields ?? {}),
|
|
@@ -1313,12 +1435,12 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
1313
1435
|
{ permission_set_id: adminPsId },
|
|
1314
1436
|
50
|
|
1315
1437
|
);
|
|
1316
|
-
if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !==
|
|
1438
|
+
if (existingAdminLinks.some((r) => !r.organization_id && r.user_id !== import_system2.SystemUserId.SYSTEM)) {
|
|
1317
1439
|
return { seeded: seededCount, adminPromoted: false, reason: "already_have_admin" };
|
|
1318
1440
|
}
|
|
1319
1441
|
const allUsers = await tryFind(ql, "sys_user", {}, 50);
|
|
1320
1442
|
const humanUsers = allUsers.filter(
|
|
1321
|
-
(u) => u.id !==
|
|
1443
|
+
(u) => u.id !== import_system2.SystemUserId.SYSTEM && u.role !== "system"
|
|
1322
1444
|
);
|
|
1323
1445
|
if (humanUsers.length === 0) {
|
|
1324
1446
|
logger?.info?.("[security] no human users yet \u2014 first sign-up will be promoted to platform admin");
|
|
@@ -1342,11 +1464,18 @@ async function bootstrapPlatformAdmin(ql, bootstrapPermissionSets, options = {})
|
|
|
1342
1464
|
return { seeded: seededCount, adminPromoted: false, reason: "insert_failed" };
|
|
1343
1465
|
}
|
|
1344
1466
|
logger?.info?.(`[security] first user promoted to platform admin: ${target.email ?? target.id}`);
|
|
1345
|
-
|
|
1467
|
+
let ownershipClaimed = 0;
|
|
1468
|
+
try {
|
|
1469
|
+
const claims = await claimSeedOwnership(ql, target.id, { logger });
|
|
1470
|
+
ownershipClaimed = claims.reduce((s, c) => s + c.count, 0);
|
|
1471
|
+
} catch (e) {
|
|
1472
|
+
logger?.warn?.("[security] seed ownership handoff failed", { error: e.message });
|
|
1473
|
+
}
|
|
1474
|
+
return { seeded: seededCount, adminPromoted: true, ownershipClaimed };
|
|
1346
1475
|
}
|
|
1347
1476
|
|
|
1348
1477
|
// src/auto-org-admin-grant.ts
|
|
1349
|
-
var
|
|
1478
|
+
var SYSTEM_CTX3 = { isSystem: true };
|
|
1350
1479
|
var PERMISSION_SET_NAME = "organization_admin";
|
|
1351
1480
|
function genId2(prefix) {
|
|
1352
1481
|
const rand = Math.random().toString(36).slice(2, 10);
|
|
@@ -1355,7 +1484,7 @@ function genId2(prefix) {
|
|
|
1355
1484
|
}
|
|
1356
1485
|
async function tryFind2(ql, object, where, limit = 50) {
|
|
1357
1486
|
try {
|
|
1358
|
-
const rows = await ql.find(object, { where, limit }, { context:
|
|
1487
|
+
const rows = await ql.find(object, { where, limit }, { context: SYSTEM_CTX3 });
|
|
1359
1488
|
return Array.isArray(rows) ? rows : Array.isArray(rows?.records) ? rows.records : [];
|
|
1360
1489
|
} catch {
|
|
1361
1490
|
return [];
|
|
@@ -1363,14 +1492,14 @@ async function tryFind2(ql, object, where, limit = 50) {
|
|
|
1363
1492
|
}
|
|
1364
1493
|
async function tryInsert2(ql, object, data) {
|
|
1365
1494
|
try {
|
|
1366
|
-
return await ql.insert(object, data, { context:
|
|
1495
|
+
return await ql.insert(object, data, { context: SYSTEM_CTX3 });
|
|
1367
1496
|
} catch {
|
|
1368
1497
|
return null;
|
|
1369
1498
|
}
|
|
1370
1499
|
}
|
|
1371
1500
|
async function tryDelete(ql, object, id) {
|
|
1372
1501
|
try {
|
|
1373
|
-
await ql.delete(object, id, { context:
|
|
1502
|
+
await ql.delete(object, id, { context: SYSTEM_CTX3 });
|
|
1374
1503
|
return true;
|
|
1375
1504
|
} catch {
|
|
1376
1505
|
return false;
|
|
@@ -2599,9 +2728,11 @@ var SecurityPlugin = class {
|
|
|
2599
2728
|
*/
|
|
2600
2729
|
this.metadata = null;
|
|
2601
2730
|
this.ql = null;
|
|
2731
|
+
/** ADR-0055: cache the resolved master-detail relation per controlled_by_parent object. */
|
|
2732
|
+
this.cbpRelCache = /* @__PURE__ */ new Map();
|
|
2602
2733
|
this.logger = {};
|
|
2603
2734
|
this.bootstrapPermissionSets = options.defaultPermissionSets ?? securityDefaultPermissionSets;
|
|
2604
|
-
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;
|
|
2605
2736
|
}
|
|
2606
2737
|
async init(ctx) {
|
|
2607
2738
|
ctx.logger.info("Initializing Security Plugin...");
|
|
@@ -2668,6 +2799,7 @@ var SecurityPlugin = class {
|
|
|
2668
2799
|
this.metadata = metadata;
|
|
2669
2800
|
this.ql = ql;
|
|
2670
2801
|
this.logger = ctx.logger;
|
|
2802
|
+
this.rlsCompiler.setLogger?.(ctx.logger);
|
|
2671
2803
|
try {
|
|
2672
2804
|
const orgScoping = ctx.getService("org-scoping");
|
|
2673
2805
|
this.orgScopingEnabled = !!orgScoping;
|
|
@@ -2717,6 +2849,16 @@ var SecurityPlugin = class {
|
|
|
2717
2849
|
if (opCtx.context?.isSystem) {
|
|
2718
2850
|
return next();
|
|
2719
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
|
+
}
|
|
2720
2862
|
const roles = opCtx.context?.roles ?? [];
|
|
2721
2863
|
const explicitPermissionSets = opCtx.context?.permissions ?? [];
|
|
2722
2864
|
if (roles.length === 0 && explicitPermissionSets.length === 0 && !opCtx.context?.userId) {
|
|
@@ -2781,6 +2923,15 @@ var SecurityPlugin = class {
|
|
|
2781
2923
|
}
|
|
2782
2924
|
}
|
|
2783
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
|
+
}
|
|
2784
2935
|
if ((opCtx.operation === "insert" || opCtx.operation === "update") && opCtx.data && permissionSets.length > 0) {
|
|
2785
2936
|
const fieldPerms = this.permissionEvaluator.getFieldPermissions(
|
|
2786
2937
|
opCtx.object,
|
|
@@ -2815,18 +2966,22 @@ var SecurityPlugin = class {
|
|
|
2815
2966
|
}
|
|
2816
2967
|
}
|
|
2817
2968
|
if (opCtx.ast) {
|
|
2969
|
+
const extra = [];
|
|
2818
2970
|
const rlsFilter = await this.computeRlsFilter(
|
|
2819
2971
|
permissionSets,
|
|
2820
2972
|
opCtx.object,
|
|
2821
2973
|
opCtx.operation,
|
|
2822
2974
|
opCtx.context
|
|
2823
2975
|
);
|
|
2824
|
-
if (rlsFilter)
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
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 };
|
|
2830
2985
|
}
|
|
2831
2986
|
}
|
|
2832
2987
|
await next();
|
|
@@ -3022,6 +3177,122 @@ var SecurityPlugin = class {
|
|
|
3022
3177
|
}
|
|
3023
3178
|
return rlsFilter;
|
|
3024
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
|
+
}
|
|
3025
3296
|
/**
|
|
3026
3297
|
* Collect all RLS policies from permission sets applicable to the given object/operation.
|
|
3027
3298
|
*/
|
|
@@ -3092,6 +3363,20 @@ var SecurityPlugin = class {
|
|
|
3092
3363
|
return m ? m[1] : null;
|
|
3093
3364
|
}
|
|
3094
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
|
+
}
|
|
3095
3380
|
// Annotate the CommonJS export names for ESM import in node:
|
|
3096
3381
|
0 && (module.exports = {
|
|
3097
3382
|
FieldMasker,
|
|
@@ -3102,7 +3387,10 @@ var SecurityPlugin = class {
|
|
|
3102
3387
|
SECURITY_PLUGIN_ID,
|
|
3103
3388
|
SECURITY_PLUGIN_VERSION,
|
|
3104
3389
|
SecurityPlugin,
|
|
3390
|
+
appDefaultProfileName,
|
|
3105
3391
|
backfillOrgAdminGrants,
|
|
3392
|
+
bootstrapPlatformAdmin,
|
|
3393
|
+
claimSeedOwnership,
|
|
3106
3394
|
isPermissionDeniedError,
|
|
3107
3395
|
reconcileOrgAdminGrant,
|
|
3108
3396
|
securityDefaultPermissionSets,
|