feat(model-apps): probe what each persona can actually DO, as that persona - #432
feat(model-apps): probe what each persona can actually DO, as that persona#432Akshay Maloo (akshaymaloo) wants to merge 6 commits into
Conversation
9a7bac5 to
890ade7
Compare
be15d4e to
2d6ddad
Compare
02f4118 to
e0cf6e6
Compare
e0cf6e6 to
5b79b95
Compare
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “probe persona” capability to the model-apps app-builder plugin that executes real Dataverse read operations under impersonation to validate what each persona can actually do (including negative probes to detect over-broad roles), complementing the existing metadata-only role-privileges verification.
Changes:
- Introduces
scripts/probe-persona.jsplus a pure planning/execution libraryscripts/lib/persona-probe.jsto run allow/deny authorization probes via Dataverse impersonation. - Adds a dedicated test suite covering probe planning, outcome interpretation, execution caching, and the impersonation (WhoAmI) canary.
- Documents the new optional step in the app-builder skill docs, plugin AGENTS map, and CHANGELOG.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| plugins/model-apps/skills/app-builder/SKILL.md | Documents the optional probe-persona.js invocation and its limitations. |
| plugins/model-apps/scripts/tests/verify-model-app.test.js | Fixes a couple comment formatting issues in an existing test. |
| plugins/model-apps/scripts/tests/persona-probe.test.js | Adds tests for probe planning/execution logic and CLI canary seams. |
| plugins/model-apps/scripts/probe-persona.js | New CLI that provisions SDK/http client, validates spec, runs WhoAmI canary, and executes probes. |
| plugins/model-apps/scripts/lib/persona-probe.js | New pure module: plan allow/deny probes, interpret outcomes, summarize results, execute probes with injected IO. |
| plugins/model-apps/CHANGELOG.md | Announces probe-persona.js and explains the key design decisions/limitations. |
| plugins/model-apps/AGENTS.md | Adds the new script/library to the documented script map and usage examples. |
Suppressed comments (2)
plugins/model-apps/scripts/probe-persona.js:88
- makePrincipalResolver() drops the persona’s systemuserid when it upgrades to CallerObjectId. That makes it impossible for the WhoAmI canary to validate the effective user id (and avoid false negatives when the test user matches the signed-in user). Include the systemuserid alongside the header/value so the preflight can pass it into checkImpersonation().
const systemUserId = users.find((u) => typeof u === 'string' && u.trim());
if (!systemUserId) return null;
const oid = cache.get(String(systemUserId).toLowerCase());
return oid
? { header: 'CallerObjectId', value: oid }
: { header: 'MSCRMCallerID', value: String(systemUserId).trim() };
plugins/model-apps/scripts/probe-persona.js:156
- After enhancing checkImpersonation() to accept an expected target systemuserid and potentially return a warning, the preflight call should pass firstPrincipal.systemUserId (when available) and surface any canary warning in the emitted warnings list.
const pre = await checkImpersonation(httpClient, apiRoot, firstPrincipal.header, firstPrincipal.value);
if (!pre.ok) { emitResult(false, { ok: false, preflight: pre.detail, warnings }); return; }
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
plugins/model-apps/scripts/probe-persona.js:69
checkImpersonationtreatseffectiveId === callerIdas proof the impersonation header was ignored. That’s a false failure when the selected target user is actually the signed-in user (self-impersonation), which makes the canary unable to prove anything either way but shouldn’t block the run.
const effectiveId = String((asUser.body && asUser.body.UserId) || '').toLowerCase();
if (effectiveId && callerId && effectiveId === callerId) {
return { ok: false, detail: `the ${header} header was accepted but IGNORED — every probe would run as the signed-in admin and report false passes` };
}
plugins/model-apps/scripts/probe-persona.js:156
- The preflight currently uses the first resolved persona principal from
principalFor(...), which can be the signed-in user (common when using yourself as the test user). In that case,checkImpersonationcan misdiagnose “header ignored” and block the probe run even though impersonation may be working. Prefer using a knownsystemuserid(MSCRMCallerID) for the canary, independent of whether probes later use CallerObjectId.
// Preflight once, on the first persona that actually has a principal. Impersonation is an
// environment-wide capability, so a second check would cost a round trip and tell us nothing new.
const firstPrincipal = [...new Set(probes.map((p) => p.persona))].map(principalFor).find(Boolean);
if (!firstPrincipal) {
emitResult(false, {
plugins/model-apps/scripts/lib/persona-probe.js:171
- For negative (
expect: 'deny') probes, a 404 currently falls into the generic “unexpected status 404” bucket. 404 is common/meaningful (wrong entity-set name, entity removed, metadata mismatch) and it doesn’t indicate an over-broad role; it should be called out explicitly as inconclusive with a clearer message.
// expect === 'deny'
if (status === 403) return finding('pass', 'correctly denied (403)');
if (typeof status === 'number' && status >= 200 && status < 300) {
if (rowCount > 0) {
return finding('fail', `readable (${rowCount} row(s) visible) but no job declares this entity — the role is broader than the spec`);
`role-privileges` (this PR) proves a deployed role HOLDS the declared privileges. That is a metadata comparison and it stops there. Whether the persona can actually perform the operation also depends on record ownership, business-unit placement, team membership, sharing, and server-side plug-ins -- none of which appear in `roleprivileges`. So a role can verify clean and still leave the persona unable to work. `probe-persona.js` closes that gap by executing real reads AS the persona. Dataverse impersonation makes it human-free and needs no new auth: effective privileges are the INTERSECTION of caller and target, so a System Administrator driving it cannot mask a permission the persona lacks. The principal comes from `personas[].assignTo.users[]`, which already holds the `systemuserid` that the legacy `MSCRMCallerID` header takes -- so the common case needs no directory lookup and no application user. It upgrades to the documented-preferred `CallerObjectId` when `azureactivedirectoryobjectid` is readable. Verified empirically that the existing transport carries a per-call header with `Authorization` and the OData headers intact, so nothing in the client changed. Three decisions carry the design: 1. It probes the NEGATIVE direction. For each persona it reads an entity that another persona declares and this one does not. An over-broad role is invisible from the inside -- everything the user tries succeeds -- so it can only be found by trying something that should fail. `appmodule` is excluded because the build injects it for every persona, so it would fail every run. 2. An empty 200 on a negative probe is INCONCLUSIVE, never a pass. Dataverse answers "no privilege" with 403 but "narrower scope" with a filtered 200, which is indistinguishable from an authorized read of an empty table. Reporting that as a pass would manufacture confidence in the one direction that matters. Inconclusive results do not fail the run -- they are genuine unknowns and failing on them would train the operator to ignore the tool -- but they are always counted and listed so an all-inconclusive run cannot masquerade as clean. 3. A `WhoAmI` canary runs first. The dangerous failure is not a 403, which is loud, but the header being accepted and IGNORED: every probe would then run as the signed-in admin, every allow-probe would pass, and the report would look authoritative while proving nothing. `WhoAmI` returns the EFFECTIVE user id, so comparing it against the impersonated id catches that silently. A 403 there reports the real cause -- the caller needs `prvActOnBehalfOfAnotherUser`, assigned DIRECTLY, since a team-inherited grant does not satisfy it. Read-only by default; `--allow-mutations` only PLANS write probes rather than executing them, because writing to someone's environment to verify it deserves its own explicit design. Scope limit, documented in the script header, AGENTS.md and the CHANGELOG so the output is never over-read: this exercises the Web API. It says nothing about UCI navigation, form/control visibility, client script, the command bar, layout, or accessibility. A green run means the data operations are authorized, never that the app works. Orchestration lives in the pure lib behind injected IO, mirroring readerFor/verifySpec, so principal resolution, entity-set resolution and error containment are all testable without a network. 31 new tests; 1511 total. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
`probe-persona.js` shipped in the previous commit with no entry point: no skill
referenced it, so nothing would ever have run it. A script an agent cannot
discover is dead code.
Placed in app-builder Phase 3 (Verify & iterate) rather than folded into verify
itself, because the two answer different questions at different costs and that
separation is the point:
verify / role-privileges metadata reconcile. Proves the deployed role HOLDS
the declared privileges. Free -- a couple of reads
during a verify that is already running -- needs no
test user, and is binary, so it belongs in the build
gate that already exits non-zero on a partial build.
probe-persona runtime authorization. Proves the persona can
actually perform the operation, which also depends
on ownership, business unit, teams, sharing and
plug-ins. Costs N x M round trips, REQUIRES a test
principal and prvActOnBehalfOfAnotherUser, and can
legitimately return "inconclusive" -- which does not
fit a gate that must answer yes or no.
So role-privileges stays where it is: moving it out would restore exactly the
hole this PR closed, where a role row exists, verify reports clean, and nothing
checks what the role grants. The probe is opt-in beside it instead.
The entry documents the prerequisites, that inconclusive results are not passes,
and the scope limit -- authorized data operations, not a working app -- so the
output is not over-read at the point of use rather than only in AGENTS.md.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Caught by Copilot in review. `executeProbes` called `readOne` for every planned probe without consulting `probe.mutating`, and `readOne` is the only operation it performs. So with `--allow-mutations`, a `write`/`create`/`delete` probe was exercised as a GET, the 200 came back, and `interpretOutcome` reported PASS. That is a false pass on the privilege that matters most, on the exact path a maker opts into specifically to test it -- and it contradicted what the script header, AGENTS.md and the CHANGELOG all already claimed, which is that `--allow-mutations` PLANS mutating probes without executing them. The docs were right; the code was wrong. Mutating probes now short-circuit before any IO and are reported `inconclusive` with the reason, so they can never be counted as passes and never add round trips. `planProbes` still includes them, because showing what WOULD be exercised is the point of the flag; actually exercising them needs fixture creation and cleanup, which is a separate design. Three regression tests, red-green verified: disabling the guard fails all three, including one asserting an all-mutating run reports zero passes so it cannot look like success. 34 probe tests, 1514 total. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
Caught by an adversarial production-readiness review. The pagination test's comment was written through a PowerShell here-string, and PowerShell consumed the backticks as escape characters: ` `top: 5000` ` became a literal TAB, and ` `\\` ` was swallowed entirely, leaving "Dataverse honors as a hard cap". The comment explains WHY the query must not combine paginate with top, which is the whole point of the test, so a reader hitting the corrupted line loses the reason and may "simplify" the query back. Comment text only; no behaviour change. Swept the other four test files appended the same way -- no further corruption. 1522 tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
…ference Asked whether adding a step to app-builder was the right place for this. The step itself is right -- without an entry point the script is undiscoverable, and there is no validation skill to host it (deferred by design until the loop it would wrap actually exists). The 19 lines of prose in SKILL.md were not. `skills/app-builder/SKILL.md` was already 508 lines against the plugin's own "keep SKILL.md under 500 lines" guideline before this feature, and the block pushed it to 527. The same guideline names the fix: "use progressive disclosure -- SKILL.md for workflow, reference files for details". So the invocation and the one-line reason stay in the phase; prerequisites, how to read `inconclusive`, why it probes the negative direction, and the scope limit move to `references/persona-validation.md`. That page also states the two-layer split -- why the metadata check stays in the build gate and the probe does not -- which is the actual architectural answer and had no home in either file. Net -6 lines in SKILL.md, so this does NOT get the file under 500; it was over budget beforehand and that is a pre-existing condition worth fixing separately. Docs only; no behaviour change. 1522 tests still pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
… what was actually probed Two review findings, both real. 1. `checkImpersonation` treated "effective user == caller" as proof the header had been dropped. That is not sound: a persona whose test user IS the signed-in user is a legitimate configuration -- a single-account dev environment, or an admin validating their own persona -- and the canary hard-failed it, blocking the tool for no reason. The target's `systemuserid` is the real oracle and is directly comparable to `WhoAmI().UserId`, so it is now checked FIRST and is the only positive proof. Equality with the caller is treated as a dropped header only when the target differs. An effective user that is neither the caller nor the target is now also caught -- previously it passed silently, and every finding would have described the wrong user. The target travels with the principal rather than being derived from the header value, because under `CallerObjectId` that value is an Entra object id and is not comparable to a systemuserid. `assignTo.users[]` always carries the systemuserid, so it is available on both header paths. 2. The read-only warning said mutating privileges were "planned but NOT executed". Nothing was planned -- in a read-only run they are never turned into probes at all -- so that misdescribed the run to the operator reading it. It now states what actually happened and what was therefore not verified. Also rebased onto main: #444 and #450 landed as squash merges, which orphaned this branch (CONFLICTING). Conflicts were confined to three doc files both changes touch; resolved by keeping both entries, with the persona-probe changelog entry kept above the `verify` one it refers to as "below". Tests 34 -> 37, red-green verified: dropping the expected-target check turns all three impersonation tests red. Full suite 1556 pass, 0 fail; 7 validators green; no mixed line endings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
73d1181 to
1aa2da6
Compare
| for (const id of ids) { | ||
| try { | ||
| const rows = await sdk.queryRecords('systemuser', { | ||
| select: ['systemuserid', 'azureactivedirectoryobjectid'], | ||
| filter: `systemuserid eq ${id}`, // Edm.Guid — UNQUOTED | ||
| top: 1, | ||
| }); |
The gap this closes
#425 added a
role-privilegescheck that proves a deployed security role holds the privileges the spec declares. That is a metadata comparison, and it stops there.Whether the persona can actually perform the operation additionally depends on record ownership, business-unit placement, team membership, sharing, and server-side plug-ins — none of which appear in
roleprivileges. So a role can verify perfectly clean and still leave the persona unable to do their job.probe-persona.jsexecutes real reads as the persona and reports what actually happens.Why this needs no human, and no new auth
Dataverse impersonation. Effective privileges are the intersection of caller and target, so a System Administrator driving the probe cannot mask a permission the persona lacks.
Two findings made this far cheaper than expected:
personas[].assignTo.users[]already holdssystemuseridGUIDs — exactly what the legacyMSCRMCallerIDheader takes. So the common case needs no directory lookup and no application user. It upgrades to the documented-preferredCallerObjectIdwhenazureactivedirectoryobjectidis readable.Authorization, Accept, OData-MaxVersion, OData-Version, CallerObjectId, with auth intact. No change to the http client.Caller needs
prvActOnBehalfOfAnotherUser, assigned directly (a team-inherited grant does not satisfy it). DocsThree decisions carry the design
1. It probes the NEGATIVE direction. For each persona it reads an entity that another persona declares and this one does not. An over-broad role is invisible from the inside — every operation the user tries succeeds — so it can only be found by trying something that should fail.
appmoduleis excluded, because the build injects it for every persona and it would fail on every run.2. An empty
200on a negative probe isinconclusive, never a pass. Dataverse answers "no privilege" with403but "narrower scope" with a filtered200, which is indistinguishable from an authorized read of an empty table. Reporting that as a pass would manufacture confidence in the one direction that matters. Inconclusive results do not fail the run — they are genuine unknowns, and failing on them would train the operator to ignore the tool — but they are always counted and listed, so an all-inconclusive run cannot masquerade as clean.3. A
WhoAmIcanary runs first. The dangerous failure is not a403, which is loud. It is the header being accepted and silently ignored: every probe would then run as the signed-in admin, every allow-probe would pass, and the report would look authoritative while proving nothing.WhoAmIreturns the effective user id, so comparing it against the impersonated id catches that. A403there reports the real cause rather than a wall of role findings.Safety
--allow-mutationsonly plans write probes; it does not execute them. Writing to someone's environment in order to verify it deserves its own explicit design.Scope limit — stated so results are not over-read
This exercises the Web API. It says nothing about UCI navigation, form or control visibility, client-side script, the command bar, layout, or accessibility. A green run means the data operations are authorized — not that the app works. That limit is recorded in the script header,
AGENTS.md, theCHANGELOGand the skill entry, not just here.Placement
role-privilegesstays inverify; this sits beside it as an opt-in step in app-builder Phase 3. They are different questions at different costs: verify is free, binary, prerequisite-free and belongs in the build gate; the probe costs N × M round trips, needs a test principal, and can legitimately answer "inconclusive" — which cannot serve a gate that must exit zero or non-zero. Movingrole-privilegesout would restore exactly the hole #425 closes.Invocation is a script call, not a skill-to-skill call — the plugin forbids the latter (
AGENTS.md), while shared scripts are the intended reuse path.Verification
readerFor/verifySpec), so principal resolution, entity-set resolution, caching and error containment are all tested without a network200inconclusive rule, the silently-ignored-header canary,403→ missing-privilege message, and thatappmoduleis never probed negativelyAGENTS.mdmap: file tree, script section, CHANGELOG, skill entry