feat(model-apps): verify what persona security roles grant, + public-repo hygiene - #425
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves the model-apps verifier/lint gates by (1) validating that persona security roles actually grant the privileges implied by the App Spec and (2) making personas[].jobs[].surfaces[] resolvable/checkable so missing deployed artifacts can be attributed back to the impacted job.
Changes:
- Add a
role-privilegesverification check that compares declared persona privileges against the deployed role’s held privileges at required depth (subset semantics, fail-closed). - Add
surface-resolver+ spec-lint wiring to warn when a job surface matches nothing authored in the spec, and add ajob-surfaceverify rollup for business-impact reporting. - Update docs/roadmap/changelog and add focused unit tests for both new behaviors.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| plugins/model-apps/scripts/verify-model-app.js | Adds reader support for role privilege and entity privilege metadata reads (but currently builds an invalid URL for the raw HttpClient). |
| plugins/model-apps/scripts/lib/verify-spec.js | Wires in role-privileges verification and adds job-surface rollup over existing checks. |
| plugins/model-apps/scripts/lib/role-privileges.js | Implements declared privilege flattening and subset depth comparison logic (pure). |
| plugins/model-apps/scripts/lib/surface-resolver.js | Resolves jobs[].surfaces[] entries to spec-authored artifacts (pure). |
| plugins/model-apps/scripts/lib/spec-lint.js | Adds warning when a declared surface matches nothing authored in the spec. |
| plugins/model-apps/scripts/tests/role-privileges.test.js | Adds unit tests for privilege declaration/compare logic and verifySpec wiring. |
| plugins/model-apps/scripts/tests/surface-resolver.test.js | Adds unit tests for surface resolution, lint wiring, and verifySpec job rollup behavior. |
| plugins/model-apps/references/app-spec-schema.md | Documents new surface resolution warning + new verify checks. |
| plugins/model-apps/docs/app-builder-roadmap.md | Notes completion of security-role + JTBD verification improvements. |
| plugins/model-apps/CHANGELOG.md | Records new verification/lint features in Unreleased notes. |
| plugins/model-apps/AGENTS.md | Updates file tree mapping to include new libs. |
Suppressed comments (1)
plugins/model-apps/scripts/verify-model-app.js:222
- readerFor() now relies on opts.envUrl to build an absolute Dataverse Web API URL for entityPrivileges(); it isn't currently passed, so the reader has no way to construct a valid absolute URL.
const r = await verifySpec(spec, readerFor(sdk, appUniqueName(spec), { genpageCli, workspaceDir, httpClient }));
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
e850df8 to
087492c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
plugins/model-apps/scripts/verify-model-app.js:128
entityPrivilegescallsopts.httpClient.get()with a relative URL (starts with/EntityDefinitions...).createAzHttpClientrequires an absolute URL (it rejects non-absolute URLs to avoid token leakage), so this will throw and make the privilege read always fail. Build the full Dataverse Web API URL (e.g.${env}/api/data/v9.2/...) before calling the client.
entityPrivileges: async (logical) => {
const name = String(logical).toLowerCase();
const url = `/EntityDefinitions(LogicalName='${odataLit(name)}')?$select=LogicalName,Privileges`;
const res = await opts.httpClient.get(url);
if (!res || res.status < 200 || res.status >= 300) return null;
return (res.body && res.body.Privileges) || null;
plugins/model-apps/scripts/verify-model-app.js:222
entityPrivilegesexpectsopts.envUrlto build an absolute/api/data/v9.2/...URL. ThereaderFor(...)call currently passeshttpClientbut not the environment URL, soentityPrivilegescannot construct the correct request URL.
const { sdk, httpClient } = makeProvision(env, workspaceDir);
const genpageCli = makeGenpageCli(env);
const r = await verifySpec(spec, readerFor(sdk, appUniqueName(spec), { genpageCli, workspaceDir, httpClient }));
plugins/model-apps/scripts/lib/role-privileges.js:8
- There appears to be an extra leading
//in this header comment (//// SUBSET, not equality.). This reads like a typo and makes the documentation a bit harder to scan.
//// SUBSET, not equality. We assert the role holds AT LEAST every declared privilege at AT LEAST the
fbccaeb to
216ff81
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (1)
plugins/model-apps/scripts/lib/role-privileges.js:28
compareRolePrivileges()lower-casesd.accessbefore indexingACCESS_TYPE, butACCESS_TYPEcurrently uses the mixed-case keyappendTo. That means a declaredappendToprivilege will look upACCESS_TYPE['appendto'](undefined) and be incorrectly reported as “exposes no 'appendTo' privilege” even when the entity exposes it.
const SCOPE_DEPTH = { user: 'Basic', businessUnit: 'Local', parentChild: 'Deep', organization: 'Global' };
const DEPTH_RANK = { basic: 1, local: 2, deep: 3, global: 4 };
// App Spec access token -> Dataverse PrivilegeType, again mirroring the SDK.
const ACCESS_TYPE = { read: 'Read', create: 'Create', write: 'Write', delete: 'Delete', append: 'Append', appendTo: 'AppendTo', assign: 'Assign', share: 'Share' };
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.
Suppressed comments (3)
plugins/model-apps/scripts/verify-model-app.js:127
entityPrivilegescallshttpClient.get()with a relative URL (/EntityDefinitions...).createAzHttpClientrefuses non-absolute URLs (it validates vianew URL(url)), so this will throw and make the newrole-privilegescheck fail closed even on healthy deployments. Build an absolute Dataverse Web API URL (e.g.${apiBase}/EntityDefinitions(...)).
entityPrivileges: async (logical) => {
const name = String(logical).toLowerCase();
const url = `/EntityDefinitions(LogicalName='${odataLit(name)}')?$select=LogicalName,Privileges`;
const res = await opts.httpClient.get(url);
if (!res || res.status < 200 || res.status >= 300) return null;
plugins/model-apps/scripts/verify-model-app.js:222
- The
entityPrivilegesreader needs an absolute Dataverse Web API base (used to construct absolute URLs forcreateAzHttpClient). Pass a stableapiBaseintoreaderForso the reader doesn't have to guess the org URL or API version.
const genpageCli = makeGenpageCli(env);
const r = await verifySpec(spec, readerFor(sdk, appUniqueName(spec), { genpageCli, workspaceDir, httpClient }));
plugins/model-apps/scripts/lib/role-privileges.js:28
compareRolePrivileges()lower-casesd.accessbefore indexingACCESS_TYPE, butACCESS_TYPEcurrently uses the mixed-case keyappendTo. That means a declaredappendToprivilege will look upACCESS_TYPE['appendto']and fail to resolve, producing a false "exposes no 'appendTo' privilege" finding.
const ACCESS_TYPE = { read: 'Read', create: 'Create', write: 'Write', delete: 'Delete', append: 'Append', appendTo: 'AppendTo', assign: 'Assign', share: 'Share' };
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated no new comments.
Suppressed comments (3)
plugins/model-apps/scripts/verify-model-app.js:128
- entityPrivileges() calls createAzHttpClient.get() with a relative URL ("/EntityDefinitions..."). createAzHttpClient rejects non-absolute URLs, so this will throw and the role-privileges verification will fail closed for every entity.
const name = String(logical).toLowerCase();
const url = `/EntityDefinitions(LogicalName='${odataLit(name)}')?$select=LogicalName,Privileges`;
const res = await opts.httpClient.get(url);
if (!res || res.status < 200 || res.status >= 300) return null;
return (res.body && res.body.Privileges) || null;
plugins/model-apps/scripts/verify-model-app.js:222
- readerFor() now needs the environment base URL to build an absolute Web API URL for entityPrivileges(); the call site currently doesn't provide it, so entityPrivileges() would return null and make role-privileges fail closed.
const { sdk, httpClient } = makeProvision(env, workspaceDir);
const genpageCli = makeGenpageCli(env);
const r = await verifySpec(spec, readerFor(sdk, appUniqueName(spec), { genpageCli, workspaceDir, httpClient }));
plugins/model-apps/scripts/lib/role-privileges.js:77
- compareRolePrivileges() lowercases access tokens before looking them up in ACCESS_TYPE. This breaks the camel-cased App Spec token
appendTo(it becomesappendto), causing a false "exposes no 'appendTo' privilege" finding even when the table exposes AppendTo.
const type = ACCESS_TYPE[d.access.toLowerCase()];
const p = type && privs.find((x) => x && x.PrivilegeType === type);
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated no new comments.
Suppressed comments (2)
plugins/model-apps/scripts/lib/role-privileges.js:29
compareRolePrivileges()lowercases the access token before looking it up (ACCESS_TYPE[d.access.toLowerCase()]), butACCESS_TYPEdefines theappendTokey with a capitalT. That means a declaredappendToprivilege is never recognized and will be reported as missing even when present.
const DEPTH_RANK = { basic: 1, local: 2, deep: 3, global: 4 };
// App Spec access token -> Dataverse PrivilegeType, again mirroring the SDK.
const ACCESS_TYPE = { read: 'Read', create: 'Create', write: 'Write', delete: 'Delete', append: 'Append', appendTo: 'AppendTo', assign: 'Assign', share: 'Share' };
plugins/model-apps/scripts/verify-model-app.js:99
rolePrivileges()decodesprivilegedepthmaskas a 1/2/4/8 bitmask, but Dataverse Web API commonly represents privilege depth as an enum (0=Basic, 1=Local, 2=Deep, 3=Global). With the current mapping, depths will be misinterpreted (or become ""), causing false failures or incorrect comparisons.
// rolePrivileges(roleId): what the deployed role actually GRANTS, as [{ privilegeId, depth }].
// The `roleprivileges` intersect row carries `privilegedepthmask` — a BITMASK (1 Basic /
// 2 Local / 4 Deep / 8 Global), which is NOT the same encoding as the `Depth` name the SDK
// writes via ReplacePrivilegesRole, so it is translated here into the depth NAME the pure
// comparison speaks in. A role with no privileges legitimately returns []; a READ FAILURE
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 26 changed files in this pull request and generated no new comments.
Suppressed comments (5)
plugins/model-apps/docs/app-builder-roadmap.md:262
- This section still references an internal review process by name (“Sol PROD-readiness review”). For public-doc hygiene, keep the fact of review but remove internal reviewer/model naming.
- 🔲 **KNOWN BEHAVIOR — an authored view named identically to a stock default view unions onto it.** An authored `views[]` entry whose name equals the Dataverse stock default ("Active/Inactive <PluralName>") is matched by `findArtifact('view', {name,entity})` and **reconciled (unioned) onto that stock default** rather than created as a new view. `reconcileView` (`sdk-build.js`) updates only `/columns`, so the authored **filters and sort are silently ignored** and the stock `createdon` is kept. Live-observed on `a scratch environment` (2026-07). **Mitigated:** `spec-lint` now WARNS when a view name matches the stock-default pattern (recommending a distinct name). **Still to decide (design call):** whether to also (a) fully reconcile a detected default view (columns + filters + sort) or (b) hard-reject the collision — deferred pending a decision; the warning makes it loud in the meantime.
- 🔲 **Review follow-ups deferred from the 2026-07-27 Sol PROD-readiness review (pre-prod-acceptable, documented here so they aren't lost).**
- **Existing old-style sub-grid migration.** `addSubgrids` is idempotent by relationship (`hasSubgrid`), so a rebuild does NOT move a sub-grid that a *previous, pre-#5 build* placed as a half-width cell into the new full-width section, nor re-apply a changed label/view. No deployed apps predate #5 (the skill is pre-prod and the edit path is teardown+rebuild-fresh), so there is nothing to migrate today; this is consistent with the documented additive-build limitation (edits aren't re-applied in place). Revisit if in-place convergence lands.
plugins/model-apps/docs/app-builder-design.md:646
- This design record still includes internal review provenance (“final Sol pass”). In a public repo, keep the review conclusions but avoid naming internal reviewers/models.
plugins/model-apps/scripts/lib/role-privileges.js:28 - ACCESS_TYPE is keyed by
appendTo, but compareRolePrivileges doesd.access.toLowerCase()when looking up the map. That turnsappendTointoappendto, so AppendTo privileges will be treated as if the table exposes no privilege type and will always fail verification.
const DEPTH_RANK = { basic: 1, local: 2, deep: 3, global: 4 };
// App Spec access token -> Dataverse PrivilegeType, again mirroring the SDK.
const ACCESS_TYPE = { read: 'Read', create: 'Create', write: 'Write', delete: 'Delete', append: 'Append', appendTo: 'AppendTo', assign: 'Assign', share: 'Share' };
plugins/model-apps/scripts/verify-model-app.js:113
privilegedepthmaskis a bitmask, but the current mapping assumes it will be exactly 1/2/4/8. If Dataverse returns combined masks (e.g. 15 for all depths), this will translate to''and make role-privileges verification fail even when the role has sufficient depth.
});
return (rows || []).map((r) => ({
privilegeId: String((r && r.privilegeid) || ''),
depth: DEPTH_BY_MASK[Number(r && r.privilegedepthmask)] || '',
}));
plugins/model-apps/docs/app-builder-roadmap.md:135
- This doc still names internal review provenance (“Sol + Opus …”). In a public repo, keep the “adversarially reviewed” claim but remove which models/reviewers were involved.
This issue also appears on line 260 of the same file.
loses the icon. Adversarially reviewed (Sol + Opus — Opus caught a residual area-icon case-corruption,
fixed); **live-verified** (entity-subarea `VectorIcon` lands in the deployed sitemap
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 58 out of 58 changed files in this pull request and generated no new comments.
Suppressed comments (3)
plugins/model-apps/scripts/verify-model-app.js:127
entityPrivilegescallsopts.httpClient.get()with a relative URL (/EntityDefinitions...).createAzHttpClientrefuses non-absolute URLs (same-origin guard), so this reader will throw and therole-privilegescheck will fail even on healthy environments.
Build a full Dataverse Web API URL (e.g. ${envUrl}/api/data/v9.2/...) before calling httpClient.get, and pass the env URL into the reader options so it can do so reliably.
plugins/model-apps/scripts/verify-model-app.js:222
entityPrivileges(added above) expectsopts.envUrlso it can build an absolute/api/data/v9.2/...URL forcreateAzHttpClient. Right now the reader is constructed without that value, so the privilege metadata read will fail-closed.
Pass envUrl: env into readerFor(...) options.
plugins/model-apps/scripts/lib/role-privileges.js:29
compareRolePrivileges()indexesACCESS_TYPEbyd.access.toLowerCase(). The map currently has the keyappendTo(mixed case), so a declaredappendToprivilege becomes'appendto'and won’t resolve toPrivilegeType: 'AppendTo'. That makes anyappendTodeclaration always fail as "exposes no 'appendTo' privilege".
Make the map keys lowercase (at least appendto) to match the .toLowerCase() lookup.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 60 out of 60 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
plugins/model-apps/scripts/lib/role-privileges.js:8
- Comment typo: this line starts with "////" (four slashes) which reads like an accidental extra comment marker.
An earlier commit in this PR called the missing privilege read "a CONTRACT, not
a gap waiting to be closed", and its message said not to wait for "a fix that is
never coming". That was true when written and is now wrong: the SDK is gaining a
dedicated `getEntityPrivileges(logicalName)` -- the READ it previously lacked,
having been able to create and delete roles but never to read what a table
exposes. Leaving that text would tell the next reader to stop looking for the
thing they should switch to.
What was right stays: `Privileges` remain deliberately OUT of the entity-metadata
projection, which is disk-cached and documents its enrichments as best-effort --
the wrong contract for a security read -- and a guardrail test pins that. So the
correction is narrow: the projection's omission is permanent; the absence of ANY
SDK read is not.
Records the switch as a TODO with the two differences that make it a mapping
change rather than a behaviour change: the SDK returns camelCased
`{ name, privilegeId, privilegeType }` where `compareRolePrivileges` reads the
PascalCase rows, and it throws when a table exposes no privileges where this
returns null -- which verify-spec already handles, since it treats a per-entity
throw as a finding.
Not switching in this PR: the SDK method is not on master yet, and re-vendoring
the bundle from unmerged source would ship an API that can still change in
review. The swap is a follow-up once it lands and the bundle is bumped.
Documentation only; no behaviour change. 1480 tests still pass.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 60 out of 60 changed files in this pull request and generated no new comments.
Suppressed comments (2)
plugins/model-apps/scripts/lib/role-privileges.js:8
- Comment typo: the line starts with
//// SUBSET, which looks accidental and makes the comment harder to read/search. It should be a normal//comment prefix.
scripts/validate-no-real-environments.js:90 isPlaceholder()currently treats any subdomain of length <= 6 as a placeholder. That means a real environment/tenant like "dev", "prod", or "uat" would not be flagged, weakening the validator’s stated goal of preventing real identifiers from being committed. Consider replacing the length heuristic with an explicit allowlist of short placeholder tokens (e.g. org/x/a/b/stg/other) and otherwise requiring a known placeholder root (contoso/fabrikam/example/...).
…through Caught by Copilot in review. `PLACEHOLDER_ROOTS` is matched as a PREFIX, so every entry waved through an unbounded family of names -- and four of the entries were generic English words. `test`, `demo`, `sample` and `my-` meant an environment genuinely called `TestEnv01`, `demo-prod-01`, `sampleorg99` or a tenant `my-real-tenant` was declared a placeholder and its live URL could be committed to this public repo. That is the failure direction that matters for this guard. A false NEGATIVE is the leak it exists to prevent; a false positive is a one-line fix by whoever hits it. Generic words are also exactly the prefix real Power Platform environments carry, so these were the worst possible entries in a prefix list. Removed all four. Nothing in tree depended on them: the only in-scope subdomain that matched was bare `test`, which is four characters and already passes via the length ceiling. `contoso`, `fabrikam` and `example` stay -- they are Microsoft's documented fictitious organizations, so a name built on them reads as fake even when long -- along with `your-`, whose trailing hyphen marks it as an instructional template rather than a name anyone would deploy. Four tests, red-green verified: restoring the generic roots fails the two that pin the new behaviour. They assert both directions -- `testenv12345` and `testtenant0042` are now rejected, while bare `test`/`demo`/`dev` still pass via the length rule and the fictional brands still match as prefixes. 1480 tests, 6/6 validators, and the guard still exits 0 against the repo. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
3682e1d to
4f1d80e
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 60 out of 60 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
plugins/model-apps/scripts/lib/role-privileges.js:8
- Comment typo: the line starts with
//// SUBSET(four slashes), which looks unintended and reads like a formatting error in the module header comment.
plugins/model-apps/scripts/lib/verify-spec.js:395 - The
job-surfacerollup always reportssurface "…" is not deployed, butbrokencan be a non-existence failure (e.g.view-columnsdrift) where the surface is deployed but failed verification. This wording can mislead operators into chasing a missing artifact instead of the actual failure (drift/mismatch). Consider changing the message to something like "surface "…" failed verification" or "surface "…" is not healthy" and include the failing check(s) in the detail.
evals/model-apps/genpage/fixtures/5-kanban-task-board/workflow-log.md:15 - This eval fixture still contains a real Microsoft UPN (
akmaloo@microsoft.com). Even though the new validator can’t reliably detect local parts of UPNs, this is still a real user identifier in a public repo and should be replaced with a role-based placeholder (e.g.maker@contoso.onmicrosoft.com) while keeping the fixed-widthpac auth listtable readable.
[1] UNIVERSAL akmaloo@microsoft.com Public OperatingSystem
[2] UNIVERSAL fabrikamusr1@fabrikamtenant01.onmicrosoft.com Test User FabrikamEnv001 https://fabrikamenv001.crmtest.dynamics.com/
evals/model-apps/genpage/fixtures/11-recruitment-pages-real/workflow-log.md:15
- This eval fixture still contains a real Microsoft UPN (
akmaloo@microsoft.com). Even though the new validator can’t reliably detect local parts of UPNs, this is still a real user identifier in a public repo and should be replaced with a role-based placeholder (e.g.maker@contoso.onmicrosoft.com) while keeping the fixed-widthpac auth listtable readable.
[1] UNIVERSAL akmaloo@microsoft.com Public OperatingSystem
[2] UNIVERSAL fabrikamusr1@fabrikamtenant01.onmicrosoft.com Test User FabrikamEnv001 https://fabrikamenv001.crmtest.dynamics.com/
evals/model-apps/genpage/fixtures/15-support-tickets-real/workflow-log.md:125
- This fixture still contains a real Microsoft UPN (
az=akmaloo@microsoft.com). Since this repo is public, please scrub real user identifiers even when the tenant/environment are placeholders. A role-based placeholder likemaker@contoso.onmicrosoft.comkeeps the example meaningful without referencing a real account.
- `node D:/Projects/power-platform-skills/plugins/model-apps/scripts/check-auth.js` (first run)
- Result: ok=false, blocker=whoami_403, az=akmaloo@microsoft.com, pac=Contoso-User001
- User instructed to run `az login --username Contoso-User001@contosotest1.onmicrosoft.com`
Caught by Copilot in review. `roleprivileges.privilegedepthmask` was decoded with
an exact-match lookup, `{1:'Basic',2:'Local',4:'Deep',8:'Global'}[mask]`, as
though the column held exactly one of those four values.
It is a BITMASK, so a single value can carry several bits: 3 (Basic|Local), 7
(Basic|Local|Deep) and 15 (all four) are all legal, because privilege depth is
CUMULATIVE -- a role granted Global can also do everything Basic allows. Every
combined value fell through to '', which ranks 0, which made
`compareRolePrivileges` report a CORRECTLY configured role as missing or too
shallow. That is the same cry-wolf failure the absolute-URL fix earlier in this
PR was about: a check that fires on healthy input trains the operator to ignore
verify.
Decodes the HIGHEST set bit instead, which is the effective depth. This is right
under both readings of the column: if only single bits are ever stored, the
highest set bit IS that bit and behaviour is unchanged; if combined values occur,
it is the only correct answer. Still fails closed on 0, a negative, a non-number,
or a value carrying only unrecognised bits -- an undecodable mask is not evidence
of a grant.
Lives in lib/role-privileges.js rather than at the call site, since that module
already owns the depth vocabulary (SCOPE_DEPTH, DEPTH_RANK) and it makes the
decoder testable on its own.
Five tests, red-green verified: restoring the exact-match lookup fails three,
including one that drives a mask of 15 through compareRolePrivileges end to end
to prove a correctly configured role now passes.
Also fixes the VM test harness, which the new import exposed: its fallback
resolved relative ids against the TEST file rather than the script under test, so
`./lib/role-privileges.js` failed to load and took five CLI tests down with it.
Resolving against the script's directory fixes the seam rather than adding a
per-module intercept, so the next `./lib/...` import does not repeat this. The
isolated test file passed throughout -- only the full suite caught it.
1485 tests, 6/6 validators.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 60 out of 60 changed files in this pull request and generated no new comments.
Suppressed comments (2)
plugins/model-apps/scripts/lib/role-privileges.js:8
- Comment typo: the line starts with
//// SUBSET, which renders oddly and looks like an accidental extra//in the module header comments.
plugins/model-apps/scripts/verify-model-app.js:118 rolePrivileges()usestop: 5000, which will only ever read a single Dataverse page. If a role has >5000 roleprivileges rows (common in orgs with lots of custom tables), this will truncate the grant set and can produce false failures inrole-privileges. Usepaginate:true(and removetop) to guarantee a complete read.
…at 5000 Found by a LIVE run against a real environment, which is the only place it could have shown up: with `top: 5000`, reading a System Administrator role returned EXACTLY 5000 rows. Paginated, the same role returns 7119 -- so 2119 privileges, 30% of the role, were being silently dropped. A truncated page is the worst possible shape for this check. `compareRolePrivileges` asks whether the role holds each DECLARED privilege; a privilege that merely fell off the end of page one is indistinguishable from one the role does not hold, so verify reports a correctly configured role as MISSING privileges. Same cry-wolf class as the two earlier fixes in this PR, but this one is silent: nothing in the response says the list was cut short. Follows @odata.nextLink to completion instead, matching what fetchAppsForPages already does for the same reason. Deliberately NOT combined with `top`: Dataverse honors $top as a hard cap and omits @odata.nextLink when it is present, and the SDK rejects paginate+top for exactly that reason -- so asserting the ABSENCE of top matters as much as asserting pagination. Cost is not a concern: the paginated read of 7119 rows completed in 692ms. Red-green verified; restoring `top: 5000` fails the new test. CI could not have caught this -- every unit test stubs queryRecords and returns a short list, so the cap was invisible until a real role exceeded it. The test now pins the query OPTIONS rather than the result, which is the part a stub cannot fake. 1486 tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 60 out of 60 changed files in this pull request and generated no new comments.
Suppressed comments (2)
plugins/model-apps/scripts/lib/role-privileges.js:28
- ACCESS_TYPE uses a mixed-case key
appendTo, but compareRolePrivileges looks up byd.access.toLowerCase(). For a spec access tokenappendTo(documented in the schema),toLowerCase()becomesappendtoand the mapping returns undefined, causing'... exposes no ... privilege'false failures for AppendTo checks.
plugins/model-apps/scripts/lib/role-privileges.js:8 - Typo in comment prefix:
//// SUBSET, not equality.has an extra//, which reads like an accidental formatting error in the module header.
Found by a LIVE end-to-end run, and invisible to CI. Against the same deployed app, the standalone verifier ran 10 checks while `build --apply --verify` ran 8 -- the two missing ones being `role-privileges` for each persona. `deps.verify` constructed its reader without `httpClient` or `envUrl`, so the `entityPrivileges` reader was never wired. verify-spec skips role-privileges unless BOTH readers are functions, so the check was silently absent and the build reported a clean `verify PASS` having never checked what any persona role actually grants. The graceful degradation is deliberate -- an unwired reader must not fire a false failure -- but it also means a caller that simply forgets to wire it gets silence rather than an error. `--apply --verify` is the primary path, so the feature was effectively off for most users while looking green. `makeSdk` now returns its `httpClient` and main threads it plus `envUrl` into the reader. Returning the SAME instance rather than constructing a second one keeps token acquisition and retry state shared. Verified live end to end: build (10 steps) -> `verify PASS (10/10)` where it had been 8/8, then a negative spec declaring a privilege the role does not hold fails correctly with `role-privileges: Live Probe Operator -- lpr_widget.delete: role does not hold prvDeletelpr_widget` and exit 1, then teardown left 0 leftovers. An unresolvable job surface is reported by spec-lint as designed. Tests assert against SOURCE because the wiring lives inside main(), which is not exported, and a behavioural test would need a live SDK. Red-green verified: removing the two arguments fails the wiring test. 1488 tests. NOTE: `telemetry-hook-pretool` is flaky under the full runner (it spawns a process against a local HTTPS probe); it passed on re-run and in isolation, and is untouched by this change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 62 out of 62 changed files in this pull request and generated no new comments.
Suppressed comments (2)
plugins/model-apps/scripts/lib/role-privileges.js:100
- ACCESS_TYPE lookup lowercases the access token (d.access.toLowerCase()), but the App Spec access enum includes the camel-cased token "appendTo". Lowercasing turns it into "appendto", which won’t match ACCESS_TYPE’s "appendTo" key and will falsely report that AppendTo privileges don’t exist/aren’t held.
plugins/model-apps/scripts/tests/verify-model-app.test.js:491 - Comment text looks garbled (a stray tab and a missing "$top"), which makes the rationale harder to read and search for later.
…e guard republishing what it protects Found by an adversarial production-readiness review, and both halves are the same mistake: the scrub reasoned about infrastructure and forgot about people. LEAK. A real Microsoft employee work UPN survived in three committed fixture logs -- the `pac auth list` line for the corp profile, which the earlier scrub walked straight past because it targeted environment/tenant tokens and an address does not look like infrastructure. The guard did not catch it either: it matched `*.onmicrosoft.com` tenants and Dataverse hosts, and an `@microsoft.com` address is neither. Replaced with an equal-length placeholder so the fixed-width tables stay aligned. GUARD ADDS an e-mail rule: any address whose domain is not obviously illustrative is a violation. Two carve-outs, both load-bearing. OData annotations (`_ownerid_value@OData.Community.Display.V1.FormattedValue`, `x@odata.bind`) parse as addresses but are payload syntax, and the eval fixtures are full of them, so flagging those would make the guard unusable. `*.onmicrosoft.com` is already judged by the tenant rule with the same placeholder logic, so the e-mail rule defers rather than reporting one identifier twice. GUARD STOPS REPUBLISHING. The denylist of previously-committed identifiers was itself a plaintext copy, in the same public repo, of every environment and tenant the scrub removed -- and the tests reconstructed a full former environment URL and tenant UPN as "realistic" negative cases. The denylist is also redundant: the shape rules already reject every one of those names, because a real environment name is neither a documented fictitious brand nor short enough to be a generic stand-in. Removed it, and rewrote the negative fixtures to invented values that were never committed here. Same for a real org id used as an example in a comment. Documents why `scripts/**` cannot simply be added to the scan: this guard's own tests must contain non-placeholder hosts BY CONSTRUCTION, so scanning them would report the guard against itself. Red-green verified: the missed line now yields `real-looking e-mail domain "microsoft.com"`. 17 guard tests, 1488 plugin tests, guard exits 0 against the tree. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 42626da2-b66f-4162-acaa-b1127ef23d89
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 62 out of 62 changed files in this pull request and generated no new comments.
Suppressed comments (4)
scripts/validate-no-real-environments.js:21
- The header comment still claims there are two rules including a "Token rule" denylist, but the implementation explicitly avoids a denylist (and adds an e-mail-domain shape rule instead). This mismatch can mislead future edits/reviews of the validator’s intended coverage.
scripts/validate-no-real-environments.js:14 - This comment hard-codes a historical count ("53 occurrences") that can drift and may already conflict with other repo text. Consider avoiding exact numbers here so the motivation stays true over time.
This issue also appears on line 17 of the same file.
scripts/tests/validate-no-real-environments.test.js:5
- The test header says every "real" case below was genuinely committed and removed, but the file later notes many examples are invented (and the validator intentionally avoids embedding scrubbed identifiers). This wording risks implying the tests re-publish real identifiers.
plugins/model-apps/scripts/lib/role-privileges.js:8 - Minor typo in the comment header: the line starts with four slashes ("//// SUBSET") instead of a normal comment marker, which reads like accidental noise in a public design/contract comment.
Four related changes to
/app-builder, combined so they review and merge together. #426 was folded in here.1.
verifynow proves what a persona security role GRANTSThe
rolecheck asserted only that a role row exists carrying the SDK ownership marker. It never looked at privileges — so a role created with the wrong access, or one whose privilege write failed after the row landed, verified clean.The new
role-privilegescheck resolves each declared(entity, access)to its DataversePrivilegeIdfrom the same metadata source the SDK writes against, and asserts the role holds it at at least the declared depth.Subset, not equality —
lib/role-privileges.jsrecords why. Equality would false-fail on three legitimate causes:appAccessinjectsappmoduleread; unioned jobs escalate a shared entity+access to the max declared scope; distinct entities can share one Dataverse privilege (a role holds one depth per privilege). Fails closed on an unreadable role or table.2.
personas[].jobs[].surfaces[]is checked instead of documentaryapp-spec.jsvalidated each entry as a non-empty string and stopped;spec-lintwarned only when the array was empty. So a job could name"My Open Work Orders"when no such view existed anywhere in the spec — and every gate passed.lib/surface-resolver.jsresolves each entry against the spec's own views, forms, pages (key or name), dashboards, tables and sitemap titles.spec-lintwarns on no match — a warning, never an error, becauseapp-spec.jsis loose on purpose: a surface may legitimately name an out-of-the-box artifact this spec does not author.verifyadds ajob-surfacerollup — a pure rollup over checks already computed, so no extra reads — reporting a deployed failure as the job it broke ("persona P can no longer do job J") rather than only "view X is missing".Both wire into
verifySpec's existing reader-gated seam (the patternentityRelationships/commandBaralready use), so an existence-only reader behaves exactly as before.2a. Review finding: the privilege read never worked
Caught by Copilot in review.
entityPrivilegesbuilt a relative URL and handed it to the raw Az http client — which takes full request URLs and validates them withnew URL(url)for its same-origin guard, so it threw:verify-speccatches that per entity, so nothing crashed — every entity would have reported its privileges as unreadable androle-privilegeswould have failed on every live run. A check that can only cry wolf is worse than no check.Why no test caught it: the tests injected a fake
entityPrivilegesintoverifySpec, so the real reader was never executed by any test. Now 6 tests drive the reader directly, one of them through the realcreateAzHttpClientvia its request seam. Reverting the fix turns them red with the exact error above.3. Public-repo hygiene across the docs
This is a public repo, and several docs pointed at things only Microsoft employees can see. All technical content is preserved — only internal provenance is removed.
live-verified on <env>). The verification claim is kept; the environment identity is not.spec rank N,Group N P1, and 15 ×(resolves C2, I1). Publicly these are noise.The two design docs were later merged into one
docs/app-builder-design.md(Part I / Part II) with all 14 references repointed, after checking what actually cites each doc rather than judging by title.4. Real environment identifiers removed, and guarded
The genpage eval fixtures are captured agent transcripts, so they recorded whatever live environment each eval ran against — including
pac auth listoutput carrying the operator's UPN, tenant and environment URL. That had reached 98 occurrences of three environments, two tenants and two user accounts across 28 files, plus provenance comments in four scripts.Two different treatments, deliberately:
pac auth listtables, so a shorter name would misalign every following column and make a captured artifact look hand-edited. The diff is exactly 65 insertions / 65 deletions, no line-ending drift (fixtures are LF, scripts CRLF).<env>" is a factual claim; swapping in a fake environment name would keep it readable but make it false. So the claim stays and the environment goes.Guarded, not just fixed:
scripts/validate-no-real-environments.js(wired intovalidate-repository-metadata, with 9 tests). Pasting a fresh live transcript is the realistic regression path and it looks correct to a reviewer. It matches on shape, not only on the strings removed here —org<8 hex>is what Dataverse auto-generates, so it is rejected even though it starts with the otherwise-allowed wordorg. Scoped to model-apps, because other plugins carry pre-existing references of this class; the limit is documented in the script header andAGENTS.mdrather than left implicit.Also adds a
## This Repo Is PUBLICsection to the rootAGENTS.mdrecording the rule, so this does not get re-added.5. Troubleshooting: a generative page that fails to render
troubleshooting.mdcovered a page that fails to upload and one that does not appear, but nothing for a page that deploys and lists correctly then fails at runtime with a cryptic OData message ('Edm.Guid' and 'Edm.String' for operator kind 'Equal'). The maker sees "Failed to load generative page" whilepac model genpage listand the sitemap both look right, so the obvious next steps are all wrong.The entry leads with the diagnostic rather than a fix, because the dialog shows the server's message but not the request — and the failing
$filteris the only thing that distinguishes the three documented causes.Verification
org<8hex>by shape alone6. The Dataverse-access policy, written down
AGENTS.mdasserted "All Dataverse access is via the SDK" inside the build paragraph, where it is true. Read as a global rule it is not: eight scripts calldataverseRequest()directly, the file tree already describesdataverse-request.jsas an "escape hatch", and §1'sentityPrivilegesreader goes straight to the raw http client. So the rule as written made every legitimate direct call look like a violation, and gave a reviewer no way to tell a sanctioned exception from an accident.Records the policy actually being followed — SDK by default for anything the SDK models (it persists workspace metadata, resolves artifact identity the same way the build does, and owns retry/pagination, so a read that bypasses it can disagree with the write about which artifact it means) — plus the two sanctioned hatches and what each is for. The maker SDK models the maker surface;
WhoAmI,customapisandconnectionreferencesare simply not in it.It also states the four things a direct call must do, each of which is a bug this plugin has actually hit: say why the SDK cannot serve it, use an absolute URL with the API prefix, leave
Edm.Guidvalues unquoted, and test the reader itself rather than only an injected stub — which is precisely how §2a's bug reached review with a green suite.The build paragraph's claim is scoped to the build so the two do not contradict.