Skip to content

feat(model-apps): verify what persona security roles grant, + public-repo hygiene - #425

Merged
Akshay Maloo (akshaymaloo) merged 17 commits into
mainfrom
users/akmaloo/verify-role-privileges
Aug 18, 2026
Merged

feat(model-apps): verify what persona security roles grant, + public-repo hygiene#425
Akshay Maloo (akshaymaloo) merged 17 commits into
mainfrom
users/akmaloo/verify-role-privileges

Conversation

@akshaymaloo

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

Copy link
Copy Markdown
Contributor

Four related changes to /app-builder, combined so they review and merge together. #426 was folded in here.

Stacked follow-up: #432 (probe-persona) is based on this branch and will retarget to main when this merges. It is not part of this PR.

1. verify now proves what a persona security role GRANTS

The role check 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-privileges check resolves each declared (entity, access) to its Dataverse PrivilegeId from the same metadata source the SDK writes against, and asserts the role holds it at at least the declared depth.

Subset, not equalitylib/role-privileges.js records why. Equality would false-fail on three legitimate causes: appAccess injects appmodule read; 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.

Implementation note: the read deliberately does not go through sdk.fetchEntityMetadata — that returns a projected shape which drops Privileges entirely, so routing through it would have silently reported every privilege as unreadable.

2. personas[].jobs[].surfaces[] is checked instead of documentary

app-spec.js validated each entry as a non-empty string and stopped; spec-lint warned 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.js resolves each entry against the spec's own views, forms, pages (key or name), dashboards, tables and sitemap titles.

  • spec-lint warns on no match — a warning, never an error, because app-spec.js is loose on purpose: a surface may legitimately name an out-of-the-box artifact this spec does not author.
  • verify adds a job-surface rollup — 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 pattern entityRelationships / commandBar already use), so an existence-only reader behaves exactly as before.

2a. Review finding: the privilege read never worked

Caught by Copilot in review. entityPrivileges built a relative URL and handed it to the raw Az http client — which takes full request URLs and validates them with new URL(url) for its same-origin guard, so it threw:

Refusing to send the Dataverse token to a non-absolute URL:
/EntityDefinitions(LogicalName='co_ticket')?$select=LogicalName,Privileges

verify-spec catches that per entity, so nothing crashed — every entity would have reported its privileges as unreadable and role-privileges would 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 entityPrivileges into verifySpec, so the real reader was never executed by any test. Now 6 tests drive the reader directly, one of them through the real createAzHttpClient via 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.

  • An internal repo, named with its full doc path, plus a paragraph of internal process about keeping its status column in sync. (I introduced that paragraph in docs(model-apps): sync the roadmap's spec-status section #410 — fixing my own leak.)
  • An internal Dataverse test environment, named 5× (live-verified on <env>). The verification claim is kept; the environment identity is not.
  • Internal review provenance — which models ran the adversarial reviews, how many rounds. That it was adversarially reviewed stays; the process detail goes.
  • Dangling indexes into unavailable documentsspec rank N, Group N P1, and 15 × (resolves C2, I1). Publicly these are noise.
  • Two files cited but not in the repo, and two whole sections of the staged-flow doc (§16 doc-sync checklist, §17 internal review findings).

⚠️ §16 and §17 were removed WITHOUT renumbering — the numbering deliberately skips them, because code cites §6 / §7.1 / §7.3 / §9 / §11 / §13.2 / §14. Verified with a checker that extracts every heading the doc defines and every citation in the plugin: all citations resolve.

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 list output 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:

  • Fixtures — swapped for equal-length placeholders. The fixtures embed fixed-width pac auth list tables, 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).
  • Provenance commentsgeneralised, not substituted. "Verified live on <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 into validate-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 word org. Scoped to model-apps, because other plugins carry pre-existing references of this class; the limit is documented in the script header and AGENTS.md rather than left implicit.

Also adds a ## This Repo Is PUBLIC section to the root AGENTS.md recording the rule, so this does not get re-added.

5. Troubleshooting: a generative page that fails to render

troubleshooting.md covered 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" while pac model genpage list and 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 $filter is the only thing that distinguishes the three documented causes.

Verification

  • 1480 plugin tests pass (1446 → 1480; 34 new), 0 fail
  • Both features red-green verified: disabling the lint wiring fails 2 tests; disabling the privilege check fails 3; reverting the URL fix fails 2
  • The env-identifier guard is red-green proven end to end — a planted leak exits 1, and it catches a previously-unseen org<8hex> by shape alone
  • 159 eval tests, 12/12 layer-1 fixtures, 6/6 repo validators
  • Docs updated per the AGENTS.md map: schema, roadmap, CHANGELOG, file tree

6. The Dataverse-access policy, written down

AGENTS.md asserted "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 call dataverseRequest() directly, the file tree already describes dataverse-request.js as an "escape hatch", and §1's entityPrivileges reader 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, customapis and connectionreferences are 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.Guid values 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.

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

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-privileges verification 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 a job-surface verify 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.

Comment thread plugins/model-apps/scripts/verify-model-app.js Outdated

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 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

  • entityPrivileges calls opts.httpClient.get() with a relative URL (starts with /EntityDefinitions...). createAzHttpClient requires 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

  • entityPrivileges expects opts.envUrl to build an absolute /api/data/v9.2/... URL. The readerFor(...) call currently passes httpClient but not the environment URL, so entityPrivileges cannot 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

Copilot AI review requested due to automatic review settings August 15, 2026 00:15
@akshaymaloo
Akshay Maloo (akshaymaloo) force-pushed the users/akmaloo/verify-role-privileges branch from fbccaeb to 216ff81 Compare August 15, 2026 00:17
@akshaymaloo Akshay Maloo (akshaymaloo) changed the title feat(model-apps): verify what persona security roles actually grant feat(model-apps): verify what persona security roles grant, + public-repo doc hygiene Aug 15, 2026

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 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-cases d.access before indexing ACCESS_TYPE, but ACCESS_TYPE currently uses the mixed-case key appendTo. That means a declared appendTo privilege will look up ACCESS_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' };

Copilot AI review requested due to automatic review settings August 15, 2026 00:18

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 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

  • entityPrivileges calls httpClient.get() with a relative URL (/EntityDefinitions...). createAzHttpClient refuses non-absolute URLs (it validates via new URL(url)), so this will throw and make the new role-privileges check 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 entityPrivileges reader needs an absolute Dataverse Web API base (used to construct absolute URLs for createAzHttpClient). Pass a stable apiBase into readerFor so 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-cases d.access before indexing ACCESS_TYPE, but ACCESS_TYPE currently uses the mixed-case key appendTo. That means a declared appendTo privilege will look up ACCESS_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' };

Copilot AI review requested due to automatic review settings August 15, 2026 00:33

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 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 becomes appendto), 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);

Copilot AI review requested due to automatic review settings August 15, 2026 00:45

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 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()]), but ACCESS_TYPE defines the appendTo key with a capital T. That means a declared appendTo privilege 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() decodes privilegedepthmask as 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

Copilot AI review requested due to automatic review settings August 15, 2026 00:57

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 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 &lt;PluralName&gt;") 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 does d.access.toLowerCase() when looking up the map. That turns appendTo into appendto, 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

  • privilegedepthmask is 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

Copilot AI review requested due to automatic review settings August 15, 2026 01:41

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 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

  • entityPrivileges calls opts.httpClient.get() with a relative URL (/EntityDefinitions...). createAzHttpClient refuses non-absolute URLs (same-origin guard), so this reader will throw and the role-privileges check 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) expects opts.envUrl so it can build an absolute /api/data/v9.2/... URL for createAzHttpClient. 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() indexes ACCESS_TYPE by d.access.toLowerCase(). The map currently has the key appendTo (mixed case), so a declared appendTo privilege becomes 'appendto' and won’t resolve to PrivilegeType: 'AppendTo'. That makes any appendTo declaration always fail as "exposes no 'appendTo' privilege".

Make the map keys lowercase (at least appendto) to match the .toLowerCase() lookup.

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 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.

Comment thread scripts/validate-no-real-environments.js
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
Copilot AI review requested due to automatic review settings August 17, 2026 22:46

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 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/...).

Copilot AI review requested due to automatic review settings August 17, 2026 22:57
…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

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 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-surface rollup always reports surface "…" is not deployed, but broken can be a non-existence failure (e.g. view-columns drift) 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-width pac auth list table 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-width pac auth list table 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 like maker@contoso.onmicrosoft.com keeps 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`

Comment thread plugins/model-apps/scripts/verify-model-app.js
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
Copilot AI review requested due to automatic review settings August 18, 2026 00:05

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 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() uses top: 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 in role-privileges. Use paginate:true (and remove top) 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
Copilot AI review requested due to automatic review settings August 18, 2026 00:58

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 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 by d.access.toLowerCase(). For a spec access token appendTo (documented in the schema), toLowerCase() becomes appendto and 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
Copilot AI review requested due to automatic review settings August 18, 2026 01:14

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 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
Copilot AI review requested due to automatic review settings August 18, 2026 01:38

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 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.

@akshaymaloo
Akshay Maloo (akshaymaloo) merged commit 8251d2c into main Aug 18, 2026
11 checks passed
@akshaymaloo
Akshay Maloo (akshaymaloo) deleted the users/akmaloo/verify-role-privileges branch August 18, 2026 14:40
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.

3 participants