Skip to content

feat(model-apps): probe what each persona can actually DO, as that persona - #432

Open
Akshay Maloo (akshaymaloo) wants to merge 6 commits into
mainfrom
users/akmaloo/persona-probe
Open

feat(model-apps): probe what each persona can actually DO, as that persona#432
Akshay Maloo (akshaymaloo) wants to merge 6 commits into
mainfrom
users/akmaloo/persona-probe

Conversation

@akshaymaloo

@akshaymaloo Akshay Maloo (akshaymaloo) commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Ready to review — #425 has merged, so this is now based on main and is a standalone 3-commit change. It depends on lib/role-privileges.js, which #425 delivered.

Rebuilt on main after #425 was squash-merged: the branch previously carried #425's commits, which conflicted with the squashed version now on main. Only the three probe commits remain — the model-apps content is byte-identical to what was reviewed.

The gap this closes

#425 added a role-privileges check 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.js executes 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:

  1. personas[].assignTo.users[] already holds systemuserid GUIDs — exactly what 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.
  2. The existing transport already carries a per-call header — verified empirically rather than assumed: 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). Docs

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 — every operation 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 and it would fail on 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. 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. WhoAmI returns the effective user id, so comparing it against the impersonated id catches that. A 403 there reports the real cause rather than a wall of role findings.

Safety

  • Read-only by default. --allow-mutations only plans write probes; it does not execute them. Writing to someone's environment in order to verify it deserves its own explicit design.
  • Every un-runnable probe (no test user, unresolvable entity set, transport error) degrades to inconclusive, never to a role failure — otherwise the operator is sent to fix a security role when the real problem is a missing test user.

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, the CHANGELOG and the skill entry, not just here.

Placement

role-privileges stays in verify; 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. Moving role-privileges out 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

  • 1511 plugin tests (1480 → 1511; 31 new), 0 fail
  • Orchestration lives behind injected IO (mirroring readerFor/verifySpec), so principal resolution, entity-set resolution, caching and error containment are all tested without a network
  • Tests cover the load-bearing judgements directly: the empty-200 inconclusive rule, the silently-ignored-header canary, 403 → missing-privilege message, and that appmodule is never probed negatively
  • Docs updated per the AGENTS.md map: file tree, script section, CHANGELOG, skill entry

@akshaymaloo
Akshay Maloo (akshaymaloo) requested a review from a team as a code owner August 17, 2026 20:03
@akshaymaloo
Akshay Maloo (akshaymaloo) force-pushed the users/akmaloo/verify-role-privileges branch from be15d4e to 2d6ddad Compare August 17, 2026 22:07
@akshaymaloo
Akshay Maloo (akshaymaloo) force-pushed the users/akmaloo/persona-probe branch 6 times, most recently from 02f4118 to e0cf6e6 Compare August 18, 2026 01:42
Base automatically changed from users/akmaloo/verify-role-privileges to main August 18, 2026 14:40
Copilot AI lite review requested due to automatic review settings August 19, 2026 20:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.js plus a pure planning/execution library scripts/lib/persona-probe.js to 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.

Comment thread plugins/model-apps/scripts/probe-persona.js Outdated
Comment thread plugins/model-apps/scripts/lib/persona-probe.js Outdated
Copilot AI review requested due to automatic review settings August 19, 2026 20:32

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • checkImpersonation treats effectiveId === callerId as 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, checkImpersonation can misdiagnose “header ignored” and block the probe run even though impersonation may be working. Prefer using a known systemuserid (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`);

akshay-viz and others added 6 commits August 21, 2026 13:47
`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
Copilot AI review requested due to automatic review settings August 21, 2026 20:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Comment on lines +124 to +130
for (const id of ids) {
try {
const rows = await sdk.queryRecords('systemuser', {
select: ['systemuserid', 'azureactivedirectoryobjectid'],
filter: `systemuserid eq ${id}`, // Edm.Guid — UNQUOTED
top: 1,
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants