@objectstack/lint 12.6.0 → 14.3.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.cjs +593 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +124 -23
- package/dist/index.d.ts +124 -23
- package/dist/index.js +576 -0
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1192,7 +1192,570 @@ function validateFormLayout(stack) {
|
|
|
1192
1192
|
}
|
|
1193
1193
|
return findings;
|
|
1194
1194
|
}
|
|
1195
|
+
|
|
1196
|
+
// src/validate-capability-references.ts
|
|
1197
|
+
import { PLATFORM_CAPABILITY_NAMES } from "@objectstack/spec/security";
|
|
1198
|
+
var CAPABILITY_REFERENCE_UNKNOWN = "capability-reference-unknown";
|
|
1199
|
+
function asArray12(v) {
|
|
1200
|
+
if (Array.isArray(v)) return v;
|
|
1201
|
+
if (v && typeof v === "object") {
|
|
1202
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
1203
|
+
}
|
|
1204
|
+
return [];
|
|
1205
|
+
}
|
|
1206
|
+
function asCapArray(v) {
|
|
1207
|
+
return Array.isArray(v) ? v.filter((s) => typeof s === "string" && s.length > 0) : [];
|
|
1208
|
+
}
|
|
1209
|
+
function flattenObjectRequired(v) {
|
|
1210
|
+
if (Array.isArray(v)) return asCapArray(v).map((cap) => ({ cap }));
|
|
1211
|
+
if (v && typeof v === "object") {
|
|
1212
|
+
const out = [];
|
|
1213
|
+
for (const [key, val] of Object.entries(v)) {
|
|
1214
|
+
for (const cap of asCapArray(val)) out.push({ cap, key });
|
|
1215
|
+
}
|
|
1216
|
+
return out;
|
|
1217
|
+
}
|
|
1218
|
+
return [];
|
|
1219
|
+
}
|
|
1220
|
+
function validateCapabilityReferences(stack) {
|
|
1221
|
+
const findings = [];
|
|
1222
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
1223
|
+
const known = new Set(PLATFORM_CAPABILITY_NAMES);
|
|
1224
|
+
for (const ps of asArray12(stack.permissions)) {
|
|
1225
|
+
for (const cap of asCapArray(ps.systemPermissions)) known.add(cap);
|
|
1226
|
+
}
|
|
1227
|
+
for (const seed of asArray12(stack.data)) {
|
|
1228
|
+
if (seed.object !== "sys_capability") continue;
|
|
1229
|
+
for (const rec of Array.isArray(seed.records) ? seed.records : []) {
|
|
1230
|
+
const name = rec?.name;
|
|
1231
|
+
if (typeof name === "string" && name.length > 0) known.add(name);
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
const hint = "Fix the capability name, declare it on a permission set\u2019s systemPermissions, ship a sys_capability seed row, or ignore this if the capability is provided by another installed package (references fail closed at runtime).";
|
|
1235
|
+
const flag = (cap, where, path) => {
|
|
1236
|
+
if (known.has(cap)) return;
|
|
1237
|
+
findings.push({
|
|
1238
|
+
severity: "warning",
|
|
1239
|
+
rule: CAPABILITY_REFERENCE_UNKNOWN,
|
|
1240
|
+
where,
|
|
1241
|
+
path,
|
|
1242
|
+
message: `requiredPermissions references capability "${cap}" which is registered nowhere \u2014 no built-in capability, no permission set in this package grants it via systemPermissions, and no sys_capability seed declares it`,
|
|
1243
|
+
hint
|
|
1244
|
+
});
|
|
1245
|
+
};
|
|
1246
|
+
const objects = asArray12(stack.objects);
|
|
1247
|
+
for (let i = 0; i < objects.length; i++) {
|
|
1248
|
+
const obj = objects[i];
|
|
1249
|
+
if (!obj || typeof obj !== "object") continue;
|
|
1250
|
+
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
1251
|
+
const objPath = `objects[${i}]`;
|
|
1252
|
+
for (const { cap, key } of flattenObjectRequired(obj.requiredPermissions)) {
|
|
1253
|
+
flag(cap, `object "${objName}"`, `${objPath}.requiredPermissions${key ? `.${key}` : ""}`);
|
|
1254
|
+
}
|
|
1255
|
+
const fields = asArray12(obj.fields);
|
|
1256
|
+
for (const f of fields) {
|
|
1257
|
+
const fname = typeof f.name === "string" ? f.name : "(field)";
|
|
1258
|
+
for (const cap of asCapArray(f.requiredPermissions)) {
|
|
1259
|
+
flag(cap, `field "${objName}.${fname}"`, `${objPath}.fields.${fname}.requiredPermissions`);
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
for (const [ai, action] of asArray12(obj.actions).entries()) {
|
|
1263
|
+
const aName = typeof action.name === "string" ? action.name : `(action ${ai})`;
|
|
1264
|
+
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
1265
|
+
flag(cap, `action "${objName}.${aName}"`, `${objPath}.actions[${ai}].requiredPermissions`);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
for (const [i, action] of asArray12(stack.actions).entries()) {
|
|
1270
|
+
const aName = typeof action.name === "string" ? action.name : `(action ${i})`;
|
|
1271
|
+
for (const cap of asCapArray(action.requiredPermissions)) {
|
|
1272
|
+
flag(cap, `action "${aName}"`, `actions[${i}].requiredPermissions`);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
const apps = asArray12(stack.apps);
|
|
1276
|
+
for (let i = 0; i < apps.length; i++) {
|
|
1277
|
+
const app = apps[i];
|
|
1278
|
+
if (!app || typeof app !== "object") continue;
|
|
1279
|
+
const appName = typeof app.name === "string" ? app.name : `(app ${i})`;
|
|
1280
|
+
const walk = (node, path) => {
|
|
1281
|
+
if (!node || typeof node !== "object") return;
|
|
1282
|
+
if (Array.isArray(node)) {
|
|
1283
|
+
node.forEach((child, ci) => walk(child, `${path}[${ci}]`));
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const rec = node;
|
|
1287
|
+
for (const cap of asCapArray(rec.requiredPermissions)) {
|
|
1288
|
+
flag(cap, `app "${appName}"`, `${path}.requiredPermissions`);
|
|
1289
|
+
}
|
|
1290
|
+
if (rec.navigation) walk(rec.navigation, `${path}.navigation`);
|
|
1291
|
+
if (rec.areas) walk(rec.areas, `${path}.areas`);
|
|
1292
|
+
if (rec.tabs) walk(rec.tabs, `${path}.tabs`);
|
|
1293
|
+
if (rec.children) walk(rec.children, `${path}.children`);
|
|
1294
|
+
if (rec.items) walk(rec.items, `${path}.items`);
|
|
1295
|
+
};
|
|
1296
|
+
walk(app, `apps[${i}]`);
|
|
1297
|
+
}
|
|
1298
|
+
return findings;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
// src/validate-approval-approvers.ts
|
|
1302
|
+
import { ApproverType, APPROVAL_NODE_TYPE } from "@objectstack/spec/automation";
|
|
1303
|
+
var APPROVAL_ROLE_NOT_MEMBERSHIP_TIER = "approval-role-not-membership-tier";
|
|
1304
|
+
var APPROVAL_APPROVER_TYPE_UNKNOWN = "approval-approver-type-unknown";
|
|
1305
|
+
var APPROVAL_ESCALATION_REASSIGN_NO_TARGET = "approval-escalation-reassign-no-target";
|
|
1306
|
+
var MEMBERSHIP_TIERS = /* @__PURE__ */ new Set(["owner", "admin", "member", "guest"]);
|
|
1307
|
+
var TYPE_FIX = {
|
|
1308
|
+
business_unit: "department",
|
|
1309
|
+
bu: "department"
|
|
1310
|
+
};
|
|
1311
|
+
function asArray13(v) {
|
|
1312
|
+
if (Array.isArray(v)) return v;
|
|
1313
|
+
if (v && typeof v === "object") {
|
|
1314
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
1315
|
+
}
|
|
1316
|
+
return [];
|
|
1317
|
+
}
|
|
1318
|
+
function validateApprovalApprovers(stack) {
|
|
1319
|
+
const findings = [];
|
|
1320
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
1321
|
+
const flows = asArray13(stack.flows);
|
|
1322
|
+
const validTypes = new Set(ApproverType.options);
|
|
1323
|
+
for (let fi = 0; fi < flows.length; fi++) {
|
|
1324
|
+
const flow = flows[fi];
|
|
1325
|
+
if (!flow || typeof flow !== "object") continue;
|
|
1326
|
+
const flowName = typeof flow.name === "string" ? flow.name : `(flow ${fi})`;
|
|
1327
|
+
const nodes = Array.isArray(flow.nodes) ? flow.nodes : [];
|
|
1328
|
+
for (let ni = 0; ni < nodes.length; ni++) {
|
|
1329
|
+
const node = nodes[ni];
|
|
1330
|
+
if (!node || node.type !== APPROVAL_NODE_TYPE) continue;
|
|
1331
|
+
const nodeId = typeof node.id === "string" ? node.id : `(node ${ni})`;
|
|
1332
|
+
const cfg = node.config ?? {};
|
|
1333
|
+
const approvers = Array.isArray(cfg.approvers) ? cfg.approvers : [];
|
|
1334
|
+
const where = `flow "${flowName}" \xB7 node "${nodeId}"`;
|
|
1335
|
+
for (let ai = 0; ai < approvers.length; ai++) {
|
|
1336
|
+
const a = approvers[ai];
|
|
1337
|
+
if (!a || typeof a !== "object") continue;
|
|
1338
|
+
const type = typeof a.type === "string" ? a.type : "";
|
|
1339
|
+
const value = typeof a.value === "string" ? a.value : "";
|
|
1340
|
+
const path = `flows[${fi}].nodes[${ni}].config.approvers[${ai}]`;
|
|
1341
|
+
if (type && !validTypes.has(type)) {
|
|
1342
|
+
const fix = TYPE_FIX[type];
|
|
1343
|
+
findings.push({
|
|
1344
|
+
severity: "warning",
|
|
1345
|
+
rule: APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
1346
|
+
where,
|
|
1347
|
+
path: `${path}.type`,
|
|
1348
|
+
message: `approver type '${type}' is not an ApproverType (${ApproverType.options.join(" | ")}).`,
|
|
1349
|
+
hint: fix ? `Use the spec value: { type: '${fix}', value: '${value}' }.` : `Pick one of the spec values; unmapped types degrade to an inert '${type}:${value}' literal at runtime.`
|
|
1350
|
+
});
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
if (type === "role" && value && !MEMBERSHIP_TIERS.has(value.toLowerCase())) {
|
|
1354
|
+
findings.push({
|
|
1355
|
+
severity: "warning",
|
|
1356
|
+
rule: APPROVAL_ROLE_NOT_MEMBERSHIP_TIER,
|
|
1357
|
+
where,
|
|
1358
|
+
path: `${path}.value`,
|
|
1359
|
+
message: `approver { type: 'role', value: '${value}' } resolves against the better-auth org-membership tier (sys_member.role: owner/admin/member) \u2014 '${value}' is not a membership tier, so this approver matches nobody and the request stalls.`,
|
|
1360
|
+
hint: `If '${value}' is an org position, author { type: 'position', value: '${value}' } (resolved via sys_user_position, ADR-0090 D3). Keep type 'role' only for membership tiers (owner/admin/member).`
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
const escalation = cfg.escalation ?? null;
|
|
1365
|
+
if (escalation && typeof escalation === "object" && escalation.action === "reassign") {
|
|
1366
|
+
const target = typeof escalation.escalateTo === "string" ? escalation.escalateTo.trim() : "";
|
|
1367
|
+
if (!target) {
|
|
1368
|
+
findings.push({
|
|
1369
|
+
severity: "warning",
|
|
1370
|
+
rule: APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
|
|
1371
|
+
where,
|
|
1372
|
+
path: `flows[${fi}].nodes[${ni}].config.escalation.escalateTo`,
|
|
1373
|
+
message: `escalation.action is 'reassign' but escalateTo is empty \u2014 at runtime the escalation degrades to a notify and the request stays with the original approvers.`,
|
|
1374
|
+
hint: `Set escalateTo to a position machine name (expanded via sys_user_position, ADR-0090 D3) or a specific user id, or change action to 'notify'.`
|
|
1375
|
+
});
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
return findings;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
// src/validate-security-posture.ts
|
|
1384
|
+
import { describeAnchorForbiddenBits } from "@objectstack/spec/security";
|
|
1385
|
+
var SECURITY_OWD_UNSET = "security-owd-unset";
|
|
1386
|
+
var SECURITY_OWD_ALIAS = "security-owd-alias";
|
|
1387
|
+
var SECURITY_EXTERNAL_WIDER = "security-external-wider-than-internal";
|
|
1388
|
+
var SECURITY_WILDCARD_VAMA = "security-wildcard-vama";
|
|
1389
|
+
var SECURITY_ANCHOR_HIGH_PRIVILEGE = "security-anchor-high-privilege";
|
|
1390
|
+
var SECURITY_ROLE_WORD = "security-role-word";
|
|
1391
|
+
var SECURITY_BOOK_AUDIENCE_UNKNOWN_SET = "security-book-audience-unknown-set";
|
|
1392
|
+
var SECURITY_PRIVATE_NO_READSCOPE = "security-private-no-readscope";
|
|
1393
|
+
var SECURITY_MASTER_DETAIL_UNGRANTED = "security-master-detail-ungranted";
|
|
1394
|
+
var CANONICAL_OWD = ["private", "public_read", "public_read_write", "controlled_by_parent"];
|
|
1395
|
+
var OWD_ALIAS_FIX = {
|
|
1396
|
+
read: "public_read",
|
|
1397
|
+
read_write: "public_read_write",
|
|
1398
|
+
full: "public_read_write",
|
|
1399
|
+
public: "public_read_write"
|
|
1400
|
+
};
|
|
1401
|
+
var OWD_WIDTH = {
|
|
1402
|
+
private: 0,
|
|
1403
|
+
public_read: 1,
|
|
1404
|
+
public_read_write: 2
|
|
1405
|
+
};
|
|
1406
|
+
function asArray14(v) {
|
|
1407
|
+
if (Array.isArray(v)) return v;
|
|
1408
|
+
if (v && typeof v === "object") {
|
|
1409
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
1410
|
+
}
|
|
1411
|
+
return [];
|
|
1412
|
+
}
|
|
1413
|
+
function owdOf(obj) {
|
|
1414
|
+
return obj.sharingModel ?? obj.security?.sharingModel;
|
|
1415
|
+
}
|
|
1416
|
+
function isSystemObject(obj) {
|
|
1417
|
+
return obj.isSystem === true || String(obj.name ?? "").startsWith("sys_");
|
|
1418
|
+
}
|
|
1419
|
+
function identifierHasRoleToken(name) {
|
|
1420
|
+
if (typeof name !== "string") return false;
|
|
1421
|
+
return name.toLowerCase().split(/[^a-z0-9]+/).some((tok) => tok === "role" || tok === "roles");
|
|
1422
|
+
}
|
|
1423
|
+
function labelHasRoleWord(label) {
|
|
1424
|
+
if (typeof label !== "string") return false;
|
|
1425
|
+
return /\brole(s)?\b/i.test(label);
|
|
1426
|
+
}
|
|
1427
|
+
function refOf(def) {
|
|
1428
|
+
const r = def.reference ?? def.reference_to;
|
|
1429
|
+
return typeof r === "string" && r ? r : void 0;
|
|
1430
|
+
}
|
|
1431
|
+
function firstMasterDetailField(obj) {
|
|
1432
|
+
for (const f of asArray14(obj.fields)) {
|
|
1433
|
+
if (f.type === "master_detail") {
|
|
1434
|
+
return { name: String(f.name ?? "?"), parent: refOf(f) };
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return void 0;
|
|
1438
|
+
}
|
|
1439
|
+
function grantsObjectAccess(p) {
|
|
1440
|
+
return p.allowRead === true || p.allowCreate === true || p.allowEdit === true || p.allowDelete === true || p.viewAllRecords === true || p.modifyAllRecords === true;
|
|
1441
|
+
}
|
|
1442
|
+
function validateSecurityPosture(stack) {
|
|
1443
|
+
const findings = [];
|
|
1444
|
+
if (!stack || typeof stack !== "object") return findings;
|
|
1445
|
+
const objects = asArray14(stack.objects);
|
|
1446
|
+
const permissionSets = asArray14(stack.permissions);
|
|
1447
|
+
for (let i = 0; i < objects.length; i++) {
|
|
1448
|
+
const obj = objects[i];
|
|
1449
|
+
if (!obj || typeof obj !== "object") continue;
|
|
1450
|
+
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
1451
|
+
const objPath = `objects[${i}]`;
|
|
1452
|
+
const owd = owdOf(obj);
|
|
1453
|
+
const external = obj.externalSharingModel;
|
|
1454
|
+
if (!isSystemObject(obj)) {
|
|
1455
|
+
if (owd == null) {
|
|
1456
|
+
findings.push({
|
|
1457
|
+
severity: "error",
|
|
1458
|
+
rule: SECURITY_OWD_UNSET,
|
|
1459
|
+
where: `object "${objName}"`,
|
|
1460
|
+
path: `${objPath}.sharingModel`,
|
|
1461
|
+
message: `custom object "${objName}" declares no sharingModel (OWD). The runtime fails CLOSED to 'private' (ADR-0090 D1), but the baseline must be an authored decision, not an accident \u2014 this is the exact shape of the leave_request incident (objectui#2348).`,
|
|
1462
|
+
hint: `Declare sharingModel explicitly: 'private' (owner + shares; recommended default), 'public_read', 'public_read_write', or 'controlled_by_parent' (master-detail children).`
|
|
1463
|
+
});
|
|
1464
|
+
} else if (typeof owd === "string" && OWD_ALIAS_FIX[owd]) {
|
|
1465
|
+
findings.push({
|
|
1466
|
+
severity: "error",
|
|
1467
|
+
rule: SECURITY_OWD_ALIAS,
|
|
1468
|
+
where: `object "${objName}"`,
|
|
1469
|
+
path: `${objPath}.sharingModel`,
|
|
1470
|
+
message: `sharingModel '${owd}' is a retired alias (ADR-0090 D4). The runtime fails CLOSED to 'private' on unknown values, so this object is NOT ${owd === "read" ? "readable" : "writable"} org-wide.`,
|
|
1471
|
+
hint: `Replace with the canonical value: sharingModel: '${OWD_ALIAS_FIX[owd]}'.`
|
|
1472
|
+
});
|
|
1473
|
+
} else if (typeof owd === "string" && !CANONICAL_OWD.includes(owd)) {
|
|
1474
|
+
findings.push({
|
|
1475
|
+
severity: "error",
|
|
1476
|
+
rule: SECURITY_OWD_ALIAS,
|
|
1477
|
+
where: `object "${objName}"`,
|
|
1478
|
+
path: `${objPath}.sharingModel`,
|
|
1479
|
+
message: `sharingModel '${owd}' is not a canonical OWD value; the runtime fails CLOSED to 'private'.`,
|
|
1480
|
+
hint: `Use one of: ${CANONICAL_OWD.join(", ")}.`
|
|
1481
|
+
});
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
if (typeof external === "string") {
|
|
1485
|
+
if (OWD_ALIAS_FIX[external]) {
|
|
1486
|
+
findings.push({
|
|
1487
|
+
severity: "error",
|
|
1488
|
+
rule: SECURITY_OWD_ALIAS,
|
|
1489
|
+
where: `object "${objName}"`,
|
|
1490
|
+
path: `${objPath}.externalSharingModel`,
|
|
1491
|
+
message: `externalSharingModel '${external}' is a retired alias (ADR-0090 D4).`,
|
|
1492
|
+
hint: `Replace with the canonical value: externalSharingModel: '${OWD_ALIAS_FIX[external]}'.`
|
|
1493
|
+
});
|
|
1494
|
+
} else if (typeof owd === "string" && external in OWD_WIDTH && owd in OWD_WIDTH && OWD_WIDTH[external] > OWD_WIDTH[owd]) {
|
|
1495
|
+
findings.push({
|
|
1496
|
+
severity: "error",
|
|
1497
|
+
rule: SECURITY_EXTERNAL_WIDER,
|
|
1498
|
+
where: `object "${objName}"`,
|
|
1499
|
+
path: `${objPath}.externalSharingModel`,
|
|
1500
|
+
message: `externalSharingModel '${external}' is WIDER than the internal sharingModel '${owd}' \u2014 the external baseline must never exceed the internal one (ADR-0090 D11).`,
|
|
1501
|
+
hint: `Narrow externalSharingModel to '${owd}' or below (ordering: private < public_read < public_read_write).`
|
|
1502
|
+
});
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
for (let i = 0; i < permissionSets.length; i++) {
|
|
1507
|
+
const ps = permissionSets[i];
|
|
1508
|
+
if (!ps || typeof ps !== "object") continue;
|
|
1509
|
+
const psName = typeof ps.name === "string" ? ps.name : `(permission set ${i})`;
|
|
1510
|
+
const psPath = `permissions[${i}]`;
|
|
1511
|
+
const objectsMap = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
|
|
1512
|
+
const wildcard = objectsMap["*"];
|
|
1513
|
+
if (wildcard && (wildcard.viewAllRecords === true || wildcard.modifyAllRecords === true)) {
|
|
1514
|
+
findings.push({
|
|
1515
|
+
severity: "error",
|
|
1516
|
+
rule: SECURITY_WILDCARD_VAMA,
|
|
1517
|
+
where: `permission set "${psName}"`,
|
|
1518
|
+
path: `${psPath}.objects.*`,
|
|
1519
|
+
message: `'*' wildcard carrying View All / Modify All Data \u2014 a package-authored superuser. Only the platform's own admin set may combine the wildcard with VAMA (ADR-0066).`,
|
|
1520
|
+
hint: `Enumerate the objects this set really needs, or drop viewAllRecords/modifyAllRecords from the wildcard entry. App-level admins belong in an ordinary set the customer binds to a position of their choosing (ADR-0090 D9).`
|
|
1521
|
+
});
|
|
1522
|
+
}
|
|
1523
|
+
if (ps.isDefault === true) {
|
|
1524
|
+
const offending = describeAnchorForbiddenBits(ps, "everyone");
|
|
1525
|
+
if (offending) {
|
|
1526
|
+
findings.push({
|
|
1527
|
+
severity: "error",
|
|
1528
|
+
rule: SECURITY_ANCHOR_HIGH_PRIVILEGE,
|
|
1529
|
+
where: `permission set "${psName}"`,
|
|
1530
|
+
path: `${psPath}.isDefault`,
|
|
1531
|
+
message: `isDefault:true suggests binding this set to the 'everyone' audience anchor, but it carries ${offending} \u2014 the runtime will refuse the binding (ADR-0090 D5/D9).`,
|
|
1532
|
+
hint: `Split the powerful bits into a separate set granted through ordinary positions, and keep the everyone-suggested set low-privilege.`
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
const flagRole = (kind, name, label, where, path) => {
|
|
1538
|
+
if (identifierHasRoleToken(name)) {
|
|
1539
|
+
findings.push({
|
|
1540
|
+
severity: "error",
|
|
1541
|
+
rule: SECURITY_ROLE_WORD,
|
|
1542
|
+
where,
|
|
1543
|
+
path,
|
|
1544
|
+
message: `${kind} name "${String(name)}" uses the reserved word "role" \u2014 the platform vocabulary is permission_set (capability), position (distribution), business_unit (hierarchy) (ADR-0090 D3).`,
|
|
1545
|
+
hint: `Rename using 'position' for distribution groups or a domain word (e.g. 'function', 'duty').`
|
|
1546
|
+
});
|
|
1547
|
+
} else if (labelHasRoleWord(label)) {
|
|
1548
|
+
findings.push({
|
|
1549
|
+
severity: "error",
|
|
1550
|
+
rule: SECURITY_ROLE_WORD,
|
|
1551
|
+
where,
|
|
1552
|
+
path: `${path.replace(/\.name$/, "")}.label`,
|
|
1553
|
+
message: `${kind} label "${String(label)}" uses the reserved word "role" (ADR-0090 D3).`,
|
|
1554
|
+
hint: `Relabel with 'Position' (distribution) or a domain word \u2014 admins must meet ONE vocabulary.`
|
|
1555
|
+
});
|
|
1556
|
+
}
|
|
1557
|
+
};
|
|
1558
|
+
for (let i = 0; i < objects.length; i++) {
|
|
1559
|
+
const obj = objects[i];
|
|
1560
|
+
if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
|
|
1561
|
+
const objName = typeof obj.name === "string" ? obj.name : `(object ${i})`;
|
|
1562
|
+
flagRole("object", obj.name, obj.label, `object "${objName}"`, `objects[${i}].name`);
|
|
1563
|
+
for (const f of asArray14(obj.fields)) {
|
|
1564
|
+
flagRole("field", f.name, f.label, `field "${objName}.${String(f.name ?? "?")}"`, `objects[${i}].fields.${String(f.name ?? "?")}.name`);
|
|
1565
|
+
}
|
|
1566
|
+
for (const [ai, action] of asArray14(obj.actions).entries()) {
|
|
1567
|
+
flagRole("action", action.name, action.label, `action "${objName}.${String(action.name ?? "?")}"`, `objects[${i}].actions[${ai}].name`);
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
for (let i = 0; i < permissionSets.length; i++) {
|
|
1571
|
+
const ps = permissionSets[i];
|
|
1572
|
+
if (!ps || typeof ps !== "object") continue;
|
|
1573
|
+
flagRole("permission set", ps.name, ps.label, `permission set "${String(ps.name ?? i)}"`, `permissions[${i}].name`);
|
|
1574
|
+
}
|
|
1575
|
+
for (const [i, pos] of asArray14(stack.positions).entries()) {
|
|
1576
|
+
flagRole("position", pos.name, pos.label, `position "${String(pos.name ?? i)}"`, `positions[${i}].name`);
|
|
1577
|
+
}
|
|
1578
|
+
for (const [i, app] of asArray14(stack.apps).entries()) {
|
|
1579
|
+
flagRole("app", app.name, app.label, `app "${String(app.name ?? i)}"`, `apps[${i}].name`);
|
|
1580
|
+
}
|
|
1581
|
+
for (const [i, book] of asArray14(stack.books).entries()) {
|
|
1582
|
+
flagRole("book", book.name, book.label, `book "${String(book.name ?? i)}"`, `books[${i}].name`);
|
|
1583
|
+
}
|
|
1584
|
+
const stackSetNames = new Set(
|
|
1585
|
+
permissionSets.map((ps) => typeof ps.name === "string" ? ps.name : void 0).filter((n) => !!n)
|
|
1586
|
+
);
|
|
1587
|
+
for (const [i, book] of asArray14(stack.books).entries()) {
|
|
1588
|
+
const audience = book.audience;
|
|
1589
|
+
if (!audience || typeof audience !== "object") continue;
|
|
1590
|
+
const setName = audience.permissionSet;
|
|
1591
|
+
if (typeof setName !== "string" || setName.length === 0) continue;
|
|
1592
|
+
if (!stackSetNames.has(setName)) {
|
|
1593
|
+
findings.push({
|
|
1594
|
+
severity: "warning",
|
|
1595
|
+
rule: SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
|
|
1596
|
+
where: `book "${String(book.name ?? i)}"`,
|
|
1597
|
+
path: `books[${i}].audience.permissionSet`,
|
|
1598
|
+
message: `book audience references permission set "${setName}", which this stack does not declare. The runtime fails closed \u2014 no holder means NO reader can open the book.`,
|
|
1599
|
+
hint: `Gate the book on one of this package's own permission sets (ADR-0090 D9, e.g. its admin set), or fix the typo. Ignore if the set is intentionally provided by another installed package.`
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
const privateObjects = new Set(
|
|
1604
|
+
objects.filter((o) => o && typeof o === "object" && !isSystemObject(o)).filter((o) => {
|
|
1605
|
+
const owd = owdOf(o);
|
|
1606
|
+
return owd == null || owd === "private";
|
|
1607
|
+
}).map((o) => String(o.name ?? ""))
|
|
1608
|
+
);
|
|
1609
|
+
if (privateObjects.size > 0) {
|
|
1610
|
+
for (let i = 0; i < permissionSets.length; i++) {
|
|
1611
|
+
const ps = permissionSets[i];
|
|
1612
|
+
if (!ps || typeof ps !== "object") continue;
|
|
1613
|
+
const psName = typeof ps.name === "string" ? ps.name : `(permission set ${i})`;
|
|
1614
|
+
const objectsMap = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
|
|
1615
|
+
for (const [objName, rawPerm] of Object.entries(objectsMap)) {
|
|
1616
|
+
if (!privateObjects.has(objName)) continue;
|
|
1617
|
+
const p = rawPerm ?? {};
|
|
1618
|
+
if (p.allowRead === true && p.readScope == null && p.viewAllRecords !== true) {
|
|
1619
|
+
findings.push({
|
|
1620
|
+
severity: "info",
|
|
1621
|
+
rule: SECURITY_PRIVATE_NO_READSCOPE,
|
|
1622
|
+
where: `permission set "${psName}"`,
|
|
1623
|
+
path: `permissions[${i}].objects.${objName}.readScope`,
|
|
1624
|
+
message: `"${objName}" is private (OWD) and this set grants allowRead without a readScope \u2014 holders see ONLY records they own (plus explicit shares).`,
|
|
1625
|
+
hint: `If that is intended (personal data), ignore this. Otherwise add readScope: 'own_and_reports' | 'unit' | 'unit_and_below' | 'org', or widen the object's sharingModel.`
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
if (permissionSets.length > 0) {
|
|
1632
|
+
const wildcardGrantsAll = permissionSets.some(
|
|
1633
|
+
(ps) => grantsObjectAccess(ps.objects?.["*"] ?? {})
|
|
1634
|
+
);
|
|
1635
|
+
if (!wildcardGrantsAll) {
|
|
1636
|
+
const grantedObjects = /* @__PURE__ */ new Set();
|
|
1637
|
+
for (const ps of permissionSets) {
|
|
1638
|
+
const objectsMap = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
|
|
1639
|
+
for (const [objName, rawPerm] of Object.entries(objectsMap)) {
|
|
1640
|
+
if (objName === "*") continue;
|
|
1641
|
+
if (grantsObjectAccess(rawPerm ?? {})) grantedObjects.add(objName);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
for (let i = 0; i < objects.length; i++) {
|
|
1645
|
+
const obj = objects[i];
|
|
1646
|
+
if (!obj || typeof obj !== "object" || isSystemObject(obj)) continue;
|
|
1647
|
+
const objName = typeof obj.name === "string" ? obj.name : "";
|
|
1648
|
+
if (!objName || grantedObjects.has(objName)) continue;
|
|
1649
|
+
const md = firstMasterDetailField(obj);
|
|
1650
|
+
if (!md) continue;
|
|
1651
|
+
const parentText = md.parent ? ` \u2192 "${md.parent}"` : "";
|
|
1652
|
+
findings.push({
|
|
1653
|
+
severity: "warning",
|
|
1654
|
+
rule: SECURITY_MASTER_DETAIL_UNGRANTED,
|
|
1655
|
+
where: `object "${objName}"`,
|
|
1656
|
+
path: `objects[${i}].fields.${md.name}`,
|
|
1657
|
+
message: `detail object "${objName}" (master_detail "${md.name}"${parentText}) has no object-level CRUD grant in any permission set. A master-detail child derives its RECORD-level access from the master (ADR-0055 controlled_by_parent), but object-level CRUD is a SEPARATE gate that is never derived \u2014 role-bound non-admin users are denied (403) before the parent-derived access is ever consulted (the silent "can't submit the subtable" trap).`,
|
|
1658
|
+
hint: `Grant "${objName}" in at least one permission set that already grants its master${md.parent ? ` "${md.parent}"` : ""} \u2014 e.g. permissions[i].objects.${objName} = { allowRead: true, allowCreate: true, allowEdit: true }. If no role should ever touch it (a pure system/internal table), name it sys_* or set isSystem: true.`
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
return findings;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/build-access-matrix.ts
|
|
1667
|
+
function asArray15(v) {
|
|
1668
|
+
if (Array.isArray(v)) return v;
|
|
1669
|
+
if (v && typeof v === "object") {
|
|
1670
|
+
return Object.entries(v).map(([name, def]) => ({ name, ...def }));
|
|
1671
|
+
}
|
|
1672
|
+
return [];
|
|
1673
|
+
}
|
|
1674
|
+
function buildAccessMatrix(stack) {
|
|
1675
|
+
const entries = [];
|
|
1676
|
+
if (!stack || typeof stack !== "object") return { version: 1, entries };
|
|
1677
|
+
const owdByObject = /* @__PURE__ */ new Map();
|
|
1678
|
+
for (const obj of asArray15(stack.objects)) {
|
|
1679
|
+
const name = typeof obj.name === "string" ? obj.name : "";
|
|
1680
|
+
if (!name) continue;
|
|
1681
|
+
const owd = obj.sharingModel ?? obj.security?.sharingModel;
|
|
1682
|
+
if (typeof owd === "string") owdByObject.set(name, owd);
|
|
1683
|
+
}
|
|
1684
|
+
for (const ps of asArray15(stack.permissions)) {
|
|
1685
|
+
const psName = typeof ps.name === "string" ? ps.name : "";
|
|
1686
|
+
if (!psName) continue;
|
|
1687
|
+
const objects = ps.objects && typeof ps.objects === "object" ? ps.objects : {};
|
|
1688
|
+
for (const [objName, rawPerm] of Object.entries(objects)) {
|
|
1689
|
+
const p = rawPerm ?? {};
|
|
1690
|
+
const entry = {
|
|
1691
|
+
permissionSet: psName,
|
|
1692
|
+
object: objName,
|
|
1693
|
+
create: p.allowCreate === true,
|
|
1694
|
+
read: p.allowRead === true || p.viewAllRecords === true || p.modifyAllRecords === true,
|
|
1695
|
+
edit: p.allowEdit === true || p.modifyAllRecords === true,
|
|
1696
|
+
delete: p.allowDelete === true || p.modifyAllRecords === true,
|
|
1697
|
+
viewAllRecords: p.viewAllRecords === true,
|
|
1698
|
+
modifyAllRecords: p.modifyAllRecords === true
|
|
1699
|
+
};
|
|
1700
|
+
if (typeof p.readScope === "string") entry.readScope = p.readScope;
|
|
1701
|
+
if (typeof p.writeScope === "string") entry.writeScope = p.writeScope;
|
|
1702
|
+
const owd = owdByObject.get(objName);
|
|
1703
|
+
if (owd) entry.sharingModel = owd;
|
|
1704
|
+
entries.push(entry);
|
|
1705
|
+
}
|
|
1706
|
+
}
|
|
1707
|
+
entries.sort(
|
|
1708
|
+
(a, b) => a.permissionSet === b.permissionSet ? a.object.localeCompare(b.object) : a.permissionSet.localeCompare(b.permissionSet)
|
|
1709
|
+
);
|
|
1710
|
+
return { version: 1, entries };
|
|
1711
|
+
}
|
|
1712
|
+
var BIT_LABELS = [
|
|
1713
|
+
["create", "create"],
|
|
1714
|
+
["read", "read"],
|
|
1715
|
+
["edit", "edit"],
|
|
1716
|
+
["delete", "delete"],
|
|
1717
|
+
["viewAllRecords", "View All Data"],
|
|
1718
|
+
["modifyAllRecords", "Modify All Data"]
|
|
1719
|
+
];
|
|
1720
|
+
function diffAccessMatrix(before, after) {
|
|
1721
|
+
const lines = [];
|
|
1722
|
+
const key = (e) => `${e.permissionSet}\0${e.object}`;
|
|
1723
|
+
const beforeMap = new Map((before?.entries ?? []).map((e) => [key(e), e]));
|
|
1724
|
+
const afterMap = new Map((after?.entries ?? []).map((e) => [key(e), e]));
|
|
1725
|
+
for (const [k, b] of beforeMap) {
|
|
1726
|
+
if (!afterMap.has(k)) {
|
|
1727
|
+
lines.push(`'${b.permissionSet}' loses ALL access to '${b.object}' (entry removed)`);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
for (const [k, a] of afterMap) {
|
|
1731
|
+
const b = beforeMap.get(k);
|
|
1732
|
+
if (!b) {
|
|
1733
|
+
const grants = BIT_LABELS.filter(([bit]) => a[bit] === true).map(([, label]) => label);
|
|
1734
|
+
lines.push(`'${a.permissionSet}' gains access to '${a.object}' (${grants.join(", ") || "no bits set"})`);
|
|
1735
|
+
continue;
|
|
1736
|
+
}
|
|
1737
|
+
for (const [bit, label] of BIT_LABELS) {
|
|
1738
|
+
if (b[bit] !== a[bit]) {
|
|
1739
|
+
lines.push(`'${a.permissionSet}' ${a[bit] ? "gains" : "loses"} ${label} on '${a.object}'`);
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
if ((b.readScope ?? "own") !== (a.readScope ?? "own")) {
|
|
1743
|
+
lines.push(`'${a.permissionSet}' read depth on '${a.object}': ${b.readScope ?? "own"} \u2192 ${a.readScope ?? "own"}`);
|
|
1744
|
+
}
|
|
1745
|
+
if ((b.writeScope ?? "own") !== (a.writeScope ?? "own")) {
|
|
1746
|
+
lines.push(`'${a.permissionSet}' write depth on '${a.object}': ${b.writeScope ?? "own"} \u2192 ${a.writeScope ?? "own"}`);
|
|
1747
|
+
}
|
|
1748
|
+
if ((b.sharingModel ?? "") !== (a.sharingModel ?? "")) {
|
|
1749
|
+
lines.push(`'${a.object}' record baseline (OWD): ${b.sharingModel ?? "(unset)"} \u2192 ${a.sharingModel ?? "(unset)"} (affects every principal)`);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
return lines;
|
|
1753
|
+
}
|
|
1195
1754
|
export {
|
|
1755
|
+
APPROVAL_APPROVER_TYPE_UNKNOWN,
|
|
1756
|
+
APPROVAL_ESCALATION_REASSIGN_NO_TARGET,
|
|
1757
|
+
APPROVAL_ROLE_NOT_MEMBERSHIP_TIER,
|
|
1758
|
+
CAPABILITY_REFERENCE_UNKNOWN,
|
|
1196
1759
|
CHART_CONFIG_MISSING,
|
|
1197
1760
|
CHART_FIELD_UNKNOWN,
|
|
1198
1761
|
FIELD_GROUP_EMPTY,
|
|
@@ -1202,6 +1765,14 @@ export {
|
|
|
1202
1765
|
LIST_VIEW_FILTERS_IN_VIEWS_MODE,
|
|
1203
1766
|
MEASURE_AGGREGATE_INCOHERENT,
|
|
1204
1767
|
PAGE_SOURCE_CLASSNAME,
|
|
1768
|
+
SECURITY_ANCHOR_HIGH_PRIVILEGE,
|
|
1769
|
+
SECURITY_BOOK_AUDIENCE_UNKNOWN_SET,
|
|
1770
|
+
SECURITY_EXTERNAL_WIDER,
|
|
1771
|
+
SECURITY_OWD_ALIAS,
|
|
1772
|
+
SECURITY_OWD_UNSET,
|
|
1773
|
+
SECURITY_PRIVATE_NO_READSCOPE,
|
|
1774
|
+
SECURITY_ROLE_WORD,
|
|
1775
|
+
SECURITY_WILDCARD_VAMA,
|
|
1205
1776
|
SEMANTIC_ROLE_FIELD_UNKNOWN,
|
|
1206
1777
|
STYLE_CLASSNAME_TAILWIND,
|
|
1207
1778
|
STYLE_NODE_MISSING_ID,
|
|
@@ -1214,6 +1785,10 @@ export {
|
|
|
1214
1785
|
WIDGET_DATASET_UNKNOWN,
|
|
1215
1786
|
WIDGET_DIMENSION_UNKNOWN,
|
|
1216
1787
|
WIDGET_MEASURE_UNKNOWN,
|
|
1788
|
+
buildAccessMatrix,
|
|
1789
|
+
diffAccessMatrix,
|
|
1790
|
+
validateApprovalApprovers,
|
|
1791
|
+
validateCapabilityReferences,
|
|
1217
1792
|
validateFormLayout,
|
|
1218
1793
|
validateJsxPages,
|
|
1219
1794
|
validateListViewMode,
|
|
@@ -1222,6 +1797,7 @@ export {
|
|
|
1222
1797
|
validateReactPages,
|
|
1223
1798
|
validateRecordTitle,
|
|
1224
1799
|
validateResponsiveStyles,
|
|
1800
|
+
validateSecurityPosture,
|
|
1225
1801
|
validateSemanticRoles,
|
|
1226
1802
|
validateStackExpressions,
|
|
1227
1803
|
validateWidgetBindings
|