Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions plugins/model-apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,34 @@ the pipeline and delegates each script's **behavioral spec** to the entries belo
nothing. This is the F5 "convergence" mitigation: the build is additive (edits to existing artifacts
aren't re-applied in place — teardown + rebuild to converge), and verify makes any resulting
divergence **loud**.
- **`scripts/probe-persona.js` → `scripts/lib/persona-probe.js`** — read-only **authorization** probes run
AS each persona via Dataverse impersonation. `role-privileges` (above, in verify) compares metadata: it
proves the role HOLDS the declared privileges. 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 this executes real reads and
reports what happens. Impersonation makes it human-free: 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 `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 preferred `CallerObjectId` when `azureactivedirectoryobjectid`
is readable. Three things carry the design:
(1) It probes the **negative** direction — for each persona it reads an entity another persona declares
and this one does not. An over-broad role is invisible from the inside because everything the user tries
succeeds, so it is only detectable by trying something that should fail. `appmodule` is never probed
negatively (the build injects it for every persona, so it would fail on every run).
(2) An empty `200` on a negative probe is **inconclusive, never a pass**: Dataverse returns 403 for *no
privilege* but a filtered 200 for *a narrower scope*, and an empty result is indistinguishable from
"authorized but the table is empty". Calling that a pass would manufacture confidence in the one
direction that matters. Inconclusive results do not fail the run but 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 (loud) but the header being
accepted and IGNORED — every probe would then run as the signed-in admin and report false passes.
`WhoAmI` returns the effective user id, so comparing it to the impersonated id detects that silently.
A 403 there reports the real cause: the caller needs `prvActOnBehalfOfAnotherUser`, assigned **directly**
(a team-inherited grant does not satisfy it). Read-only by default; `--allow-mutations` only *plans*
write probes. **Scope limit, stated so results are not over-read:** this exercises the Web API, so 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.
- **`scripts/ai-preflight.js`** — standalone preflight report: prints each AI feature's on/off status
and the exact admin action needed (Power Platform Admin Center → Environments → Settings → Product →
Features) for anything off. Never fails. The `ai-features` build phase calls this logic internally and
Expand Down Expand Up @@ -428,6 +456,7 @@ references/ ← Shared reference docs
localization.md ← Multi-language + RTL pattern (loaded conditionally)
supported-dependencies.md ← Versioned package list for generated pages
troubleshooting.md ← Deployment/runtime/env issues
persona-validation.md ← app-builder: probe-persona prerequisites, reading `inconclusive`, scope limit
verified-icons.txt ← ~5000 Fluent UI icon names; Grep-validated by page-builder
samples/ ← Example .tsx files (13 samples) plus app-builder spec samples
scripts/
Expand All @@ -448,6 +477,7 @@ scripts/
download-model-app.js ← app-builder: pull a deployed app into an editable spec (edit flow)
teardown-model-app.js ← app-builder: classifier-safe reverse-of-build teardown
verify-model-app.js ← app-builder: reconcile the spec against the deployed app
probe-persona.js ← app-builder: run authorization probes AS each persona via Dataverse impersonation (read-only)
preview-form.js ← app-builder: ASCII form wireframe for authoring review
preview-app.js ← app-builder: ASCII whole-app design preview (data model + sitemap + forms + page-intents + design)
write-app-spec-doc.js ← app-builder: renders the readable model-app-plan.md design doc from app-spec.json
Expand All @@ -474,6 +504,7 @@ scripts/
spec-shape.js ← shared structural normalization for both authoring gates
surface-resolver.js ← pure: resolve personas[].jobs[].surfaces[] to the spec artifacts that satisfy them
role-privileges.js ← pure: declared persona privileges + subset comparison against a deployed role
persona-probe.js ← pure: plan/interpret impersonated authorization probes (allow + deny) per persona
odata.js ← OData literal escaping helpers
genpage-cli.js ← pac model genpage upload/list/download wrapper
hydrate-spec.js ← reconstruct an App Spec from a deployed app (edit flow)
Expand Down Expand Up @@ -865,6 +896,7 @@ az account set --subscription <sub-id>
node scripts/check-auth.js --env <envUrl> # az token + WhoAmI preflight (pac optional; --require-pac for genpage)
node scripts/build-model-app.js --env <envUrl> --spec @<dir>/app-spec.json [--sample-data --publish] --apply --verify
node scripts/verify-model-app.js --env <envUrl> --spec @<dir>/app-spec.json
node scripts/probe-persona.js --env <envUrl> --spec @<dir>/app-spec.json # authorization AS each persona (read-only)
node scripts/teardown-model-app.js --env <envUrl> --spec @<dir>/app-spec.json --apply
```

Expand Down
21 changes: 21 additions & 0 deletions plugins/model-apps/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@ smoke-eval assertion that could never pass live.
serializers that hardcode 1033 with no caller override
([#455](https://github.com/microsoft/power-platform-skills/issues/455)).

- **`probe-persona.js` — authorization probes run AS each persona.** The
`role-privileges` check below compares metadata and stops there; whether a
persona can actually perform an operation also depends on record ownership,
business-unit placement, team membership, sharing and server-side plug-ins,
none of which appear in `roleprivileges`. This runs real reads under Dataverse
impersonation, so it needs no human and no application user: effective
privileges are the intersection of caller and target, and
`personas[].assignTo.users[]` already carries the `systemuserid` the legacy
`MSCRMCallerID` header takes (upgrading to `CallerObjectId` when the Entra
object id is readable). It also probes the **negative** direction — reading an
entity another persona declares and this one does not — because an over-broad
role is invisible from the inside, where everything the user tries succeeds.
An empty `200` on a negative probe is reported **inconclusive, never a pass**
(Dataverse answers "no privilege" with 403 but "narrower scope" with a
filtered 200, which is indistinguishable from an empty table), and a `WhoAmI`
canary runs first to catch the impersonation header being accepted and
silently IGNORED — which would otherwise run every probe as the signed-in
admin and report false passes. Read-only by default. It exercises the Web API,
so it says nothing about UCI navigation, form visibility, client script or
layout: a green run means the data operations are authorized, not that the app
works.
- **`verify` now proves what a persona security role GRANTS, not just that it
exists.** The `role` check only asserted a role row carrying the SDK ownership
marker, so a role built with the wrong access — or one whose privilege write
Expand Down
73 changes: 73 additions & 0 deletions plugins/model-apps/references/persona-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Persona validation — what each persona can actually DO

Loaded on demand by `/app-builder` Phase 3. Verifying a build proves the app matches its spec; this
page is about the separate question of whether each **persona** can actually work in it.

## Two layers, deliberately separate

| | `verify-model-app.js` → `role-privileges` | `probe-persona.js` |
|---|---|---|
| Proves | the role **holds** the declared privileges | the persona can **actually perform** the operation |
| Also depends on | nothing | record ownership, business unit, team membership, sharing, plug-ins |
| Cost | free — metadata reads during a verify that already runs | N × M round trips |
| Prerequisites | none | a test user + `prvActOnBehalfOfAnotherUser` |
| Answer shape | binary | can legitimately be **inconclusive** |
| So it is | part of the **build gate**, always on | **opt-in**, run when you want it |

They are not redundant. A role can hold every declared privilege and still leave the persona unable
to work — depth interacts with who owns the records, which business unit they sit in, what teams the
user belongs to, what has been shared, and what server-side plug-ins reject. `roleprivileges` shows
none of that, so only executing a real operation answers it.

Keeping the metadata check in the build gate is what makes it free and unconditional; moving it out
would restore the hole it was added to close — a role row exists, verify reports clean, and nothing
checks what it grants.

## Running it

Read-only. It changes nothing.

```bash
node "${PLUGIN_ROOT}/scripts/probe-persona.js" --env <envUrl> --spec @<working-dir>/app-spec.json
```

`--allow-mutations` additionally *plans* create/write/delete probes. It does **not** execute them —
exercising a write to verify it needs fixture creation and cleanup, which is a separate design.

## Prerequisites

It reports clearly and exits rather than guessing when these are unmet:

- the persona declares `assignTo.users[]` (already a `systemuserid`, which is what the impersonation
header takes — no directory lookup and no application user needed);
- the signed-in user holds **`prvActOnBehalfOfAnotherUser`**, assigned **directly** — a
team-inherited grant does not satisfy it.

## Reading the output

- **`pass`** — the operation behaved as declared.
- **`fail`** — a declared privilege did not work, or an entity the persona never declared *was*
readable (an over-broad role).
- **`inconclusive`** — the probe **proved nothing either way**. It is *not* a pass. Inconclusive
results do not fail the run, because they are genuine unknowns and failing on them would train you
to ignore the tool — but they are always counted, so an all-inconclusive run cannot look clean.

The most common inconclusive is an empty `200` on a negative probe: Dataverse answers *"no privilege"*
with `403` but *"narrower scope"* with a filtered `200`, which is indistinguishable from an authorized
read of an empty table. Seed a row owned by another user to disambiguate.

## Why it probes the negative direction

For each persona it also 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 simply succeeds. It
can only be detected by trying something that *should* fail. `appmodule` is never probed negatively —
the build injects it for every persona, so it would report a failure on every run.

## What a green run does NOT mean

This exercises the **Web API**. It says nothing about UCI navigation, which form opens, field 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.** Those still need a
browser pass or a human.
Loading
Loading