|
| 1 | +// plugins/model-apps/scripts/lib/role-privileges.js |
| 2 | +// PURE: what privileges a persona's security role MUST hold, and whether a deployed role holds them. |
| 3 | +// |
| 4 | +// WHY this exists. `verifySpec`'s role check proved only that a role ROW exists carrying the SDK |
| 5 | +// ownership marker. It never looked at the role's privileges — so a role created with the wrong |
| 6 | +// access, or one whose privilege write silently failed after the row was created, verified clean. |
| 7 | +// That is a metadata read, which is why it is cheap enough to run on every verify. |
| 8 | +//// SUBSET, not equality. We assert the role holds AT LEAST every declared privilege at AT LEAST the |
| 9 | +// declared depth. Extra privileges are never a finding, and that is deliberate — three legitimate |
| 10 | +// sources add privileges the spec does not literally list: |
| 11 | +// 1. `appAccess` injects `appmodule` read (see personaRoleSpecFor). |
| 12 | +// 2. Several jobs unioned together escalate a shared entity+access to the MAX declared scope. |
| 13 | +// 3. Distinct entities can share ONE Dataverse privilege, and a role holds one depth per |
| 14 | +// privilege — so the SDK raises that privilege to the highest scope any of them asked for. |
| 15 | +// An equality check would fail on all three while telling us nothing true. |
| 16 | +// |
| 17 | +// Dataverse reference — privilege depth (`Depth` on ReplacePrivilegesRole, `RolePrivilegeDepth`): |
| 18 | +// Basic (user) < Local (business unit) < Deep (parent/child) < Global (organization). |
| 19 | +// https://learn.microsoft.com/en-us/power-apps/developer/data-platform/security-model |
| 20 | +'use strict'; |
| 21 | + |
| 22 | +// App Spec scope -> Dataverse depth name, and its ORDER. The order is what makes this a subset |
| 23 | +// check: a role holding Global satisfies a declared Basic. Mirrors the vendored SDK's own mapping |
| 24 | +// (verified against scripts/vendor/cds-maker-sdk.cjs) so a comparison cannot disagree with the write. |
| 25 | +const SCOPE_DEPTH = { user: 'Basic', businessUnit: 'Local', parentChild: 'Deep', organization: 'Global' }; |
| 26 | +const DEPTH_RANK = { basic: 1, local: 2, deep: 3, global: 4 }; |
| 27 | +// App Spec access token -> Dataverse PrivilegeType, again mirroring the SDK. |
| 28 | +const ACCESS_TYPE = { read: 'Read', create: 'Create', write: 'Write', delete: 'Delete', append: 'Append', appendTo: 'AppendTo', assign: 'Assign', share: 'Share' }; |
| 29 | + |
| 30 | +const rankOf = (depth) => DEPTH_RANK[String(depth == null ? '' : depth).trim().toLowerCase()] || 0; |
| 31 | + |
| 32 | +// Flatten a persona to the (entity, access, scope) triples its role must satisfy, taking the MAX |
| 33 | +// scope per (entity, access) exactly as the builder's union does. `appAccess` is folded in here so |
| 34 | +// the expectation matches what the build actually writes rather than what the author typed. |
| 35 | +function declaredPrivileges(persona) { |
| 36 | + const byKey = new Map(); // "<entity>|<access>" -> { entity, access, scope } |
| 37 | + const addAll = (list) => { |
| 38 | + for (const pr of list || []) { |
| 39 | + if (!pr || !pr.entity) continue; |
| 40 | + const entity = String(pr.entity).trim().toLowerCase(); |
| 41 | + const scope = pr.scope || 'user'; |
| 42 | + for (const a of pr.access || []) { |
| 43 | + const access = String(a).trim(); |
| 44 | + if (!access) continue; |
| 45 | + const key = `${entity}|${access.toLowerCase()}`; |
| 46 | + const prev = byKey.get(key); |
| 47 | + // Max scope wins — the same rule the builder applies when unioning jobs into one role. |
| 48 | + if (!prev || rankOf(SCOPE_DEPTH[scope]) > rankOf(SCOPE_DEPTH[prev.scope])) byKey.set(key, { entity, access, scope }); |
| 49 | + } |
| 50 | + } |
| 51 | + }; |
| 52 | + for (const j of (persona && persona.jobs) || []) addAll(j && j.privileges); |
| 53 | + addAll(persona && persona.additionalPrivileges); |
| 54 | + // Mirrors personaRoleSpecFor: unless the persona opts out, the build grants appmodule read so the |
| 55 | + // app actually opens for them. Verifying it matters — without it the role exists but the app does not. |
| 56 | + if (!persona || persona.appAccess !== false) addAll([{ entity: 'appmodule', access: ['read'], scope: 'organization' }]); |
| 57 | + return [...byKey.values()]; |
| 58 | +} |
| 59 | + |
| 60 | +// Compare declared privileges against what the role actually holds. |
| 61 | +// `entityPrivileges`: Map<entityLogical, [{ Name, PrivilegeId, PrivilegeType }]> — from |
| 62 | +// EntityDefinitions(LogicalName='x')?$select=Privileges, the SAME source the |
| 63 | +// SDK resolves against when it writes. |
| 64 | +// `actualByPrivilegeId`: Map<privilegeId(lowercased), depthName> |
| 65 | +// Returns { ok, missing:[{ entity, access, scope, reason, privilegeName? }] }. |
| 66 | +// An entity whose metadata could not be read is reported as a finding, never skipped — a read |
| 67 | +// failure must not read as "nothing missing" (fail closed). |
| 68 | +function compareRolePrivileges(declared, entityPrivileges, actualByPrivilegeId) { |
| 69 | + const missing = []; |
| 70 | + for (const d of declared) { |
| 71 | + const privs = entityPrivileges.get(d.entity); |
| 72 | + if (!privs) { |
| 73 | + missing.push({ ...d, reason: `could not read privilege metadata for '${d.entity}'` }); |
| 74 | + continue; |
| 75 | + } |
| 76 | + const type = ACCESS_TYPE[d.access.toLowerCase()]; |
| 77 | + const p = type && privs.find((x) => x && x.PrivilegeType === type); |
| 78 | + if (!p) { |
| 79 | + missing.push({ ...d, reason: `'${d.entity}' exposes no '${d.access}' privilege` }); |
| 80 | + continue; |
| 81 | + } |
| 82 | + const held = actualByPrivilegeId.get(String(p.PrivilegeId || '').trim().toLowerCase()); |
| 83 | + if (!held) { |
| 84 | + missing.push({ ...d, privilegeName: p.Name, reason: `role does not hold ${p.Name}` }); |
| 85 | + continue; |
| 86 | + } |
| 87 | + const want = SCOPE_DEPTH[d.scope] || 'Basic'; |
| 88 | + if (rankOf(held) < rankOf(want)) { |
| 89 | + missing.push({ ...d, privilegeName: p.Name, reason: `role holds ${p.Name} at ${held}, below the declared ${want}` }); |
| 90 | + } |
| 91 | + } |
| 92 | + return { ok: missing.length === 0, missing }; |
| 93 | +} |
| 94 | + |
| 95 | +module.exports = { declaredPrivileges, compareRolePrivileges, SCOPE_DEPTH, ACCESS_TYPE, DEPTH_RANK }; |
0 commit comments