Building with Power Platform at CMS — Power Pages / Dataverse / Power Automate
Self-contained build reference for Power Pages sites (plus Dataverse and Power Automate) in the CMS GCC / US Gov tenant: environment, tooling, patterns, limitations, and the gotchas that bite. Most important: a Private site blocks all client-side reads — read-heavy sites must run Public + Entra-required.
Comprehensive, self-contained reference for building Power Pages sites (and supporting Dataverse / Power Automate) in the CMS GCC / US Gov tenant. Captures the environment, tooling, patterns, limitations, and hard-won gotchas. Keep this current as we learn more.
Audience: engineers and makers standing up Power Platform solutions for CMS employees. Identity runs through GCC Entra — see ../02-iam/overview.md. For acronyms (GCC, Dataverse, Entra, PIM), see ../00-foundations/glossary.md.
#0. TL;DR — the things that will bite you
- Request access early — System Administrator on the environment, Cloud Application Administrator (PIM-activated), and a Power Apps Premium license. These gate everything (§2).
- GCC is US Gov but uses commercial Entra (
login.microsoftonline.com), Dataverse oncrm9.dynamics.com, and gov Power Platform API endpoints (§1). - A Private site has no authenticated web role. Its Web API allows writes (form submits) but
blocks ALL reads (
90040120). A read-centric site (lists/grids) must be Public + Entra-required to read live data. This is the single biggest surprise (§7). ⭐ - Code sites need React 18, not 19 — the CMS Design System relies on function-component
defaultProps, which React 19 removed (§4). .jsuploads are blocked by default — removejsfrom the envblockedattachmentsto deploy code sites (§5).- The role↔permission link is studio-only on Enhanced sites (the
mspp_M:N reverts via API); it is API-writable on Standard (adx_) sites (§6). - Activation / site-create returns
A011when Cloud App Admin isn't PIM-active (§5).
#1. The environment (GCC specifics)
CMS Power Pages work currently lives in a GCC pilot environment. The shape (not necessarily the exact GUIDs, which can change between environments) is:
| Thing | Value / pattern |
|---|---|
| Environment type | GCC / US Gov, Regular |
| Dataverse org URL | https://<org>.crm9.dynamics.com |
| Sign-in (identity) | Commercial Entra — login.microsoftonline.com |
| Publisher / prefix | a publisher carrying the cms customization prefix |
| Live site domain | *.powerappsportals.us |
| Power Platform API (gov) | https://api.gov.powerplatform.microsoft.us |
| Power Pages maker (gov) | https://make.gov.powerpages.microsoft.us |
| Power Automate maker (gov) | https://make.gov.powerautomate.us |
| Admin center (gov) | https://admin.powerplatform.microsoft.us |
Key nuance: GCC authenticates against commercial Entra even though Dataverse and the Power
Platform APIs are on gov endpoints. PAC CLI auth uses --cloud UsGov; az tokens are minted against
the crm9.dynamics.com org and the gov PP API resource. Record your environment's org/environment/
tenant IDs and the cms-prefix publisher ID once and reuse them in scripts.
#2. Access, roles & licenses (request these FIRST)
| You need | For | Why / notes |
|---|---|---|
| System Administrator (Dataverse security role on the env) | Deploying a Power Pages site; configuring auth / web roles / table permissions / Web API | The deploy needs the prvBypassCustomPlugins privilege — only System Administrator has it. Environment Maker / System Customizer are not enough. Time-boxing it to a project is a reasonable ask. |
| Cloud Application Administrator (Entra role) | Creating a site + the Entra auth app registration | Provisioning creates an Entra app reg; a locked tenant blocks ordinary app registration, and this role overrides that block. Usually PIM-eligible, not active — you must activate it in PIM before site creation/activation, or you get A011 / 401 on the POST. |
Power Apps Premium (per-user, e.g. POWERAPPS_PER_USER_GCC) |
Model-driven apps, Dataverse-backed/premium-connector canvas apps, AI Builder | Standard canvas apps on M365 connectors are seeded; model-driven always needs Premium. |
System Customizer is enough to create Dataverse tables (via the OData EntityDefinitions API).
Only the deploy of a Power Pages site needs System Administrator.
#3. Tooling & automation
#CLIs / runtimes
- PAC CLI (
Microsoft.PowerApps.CLI.Tool). Often not on PATH after install; the executable lives at…\AppData\Local\Microsoft\PowerAppsCLI\Microsoft.PowerApps.CLI.<ver>\tools\pac.exe.- GCC auth (interactive, human-run):
pac auth create --url https://<org>.crm9.dynamics.com --cloud UsGov. Verify withpac auth who.
- GCC auth (interactive, human-run):
- Azure CLI (
az) — mints Dataverse + Power Platform tokens for scripts.az login; token:az account get-access-token --resource https://<org>.crm9.dynamics.com.- Gov PP API resource:
https://api.gov.powerplatform.microsoft.us.
- Node + npm — Vite SPA builds and helper scripts (Node 22+ has a global
WebSocket, handy for §10).
#Web / management APIs used directly
- Dataverse Web API
…/api/data/v9.2/— tables (EntityDefinitions), data, and site config (mspp_*/powerpagecomponentfor Enhanced sites,adx_*for Standard sites). - Power Pages management API
…/powerpages/environments/{envId}/websites[/{mgmtId}]?api-version=2022-03-01-preview— GET site state, create/activate (POST), andsiteVisibility(PATCH).
#UI tools that can't be fully scripted
- Power Pages design studio → Security workspace — the only reliable place to attach web roles to table permissions on Enhanced sites (§6).
- Power Automate maker — automated cloud flows for notifications (§9).
- Power Platform admin center — site visibility governance and site restarts.
#Automation runbooks
Microsoft publishes the power-platform-skills pack at
https://github.com/microsoft/power-platform-skills. It provides the power-pages:* skills
(create-site, setup-datamodel, setup-auth, create-webroles, audit-permissions,
integrate-webapi, add-cloud-flow, deploy-site, activate-site, test-site, scan-site,
security-review), supporting agents (data-model-architect, table-permissions-architect,
webapi-settings-architect), and a bundled Playwright MCP plus the Microsoft Learn docs MCP. The
deterministic helper scripts referenced throughout this guide (create-table-permission.js,
create-site-setting.js, clear-site-cache.js, activate-site.js) live inside those skills.
Gotcha: installing/enabling a plugin mid-session does NOT register it. Reloading skill dirs only re-scans skills, not new plugins — fully reload the editor window so plugins load at startup.
On top of the Microsoft pack, CMS maintains a cms-power-pages skill — the canonical CMS playbook
that layers in the GCC values, the CMS Design System integration, the gotchas below, and a
requirement-driven Playwright verification loop (§10). Treat that skill as the authoritative runbook
and read it before starting a site; this page is the human-readable companion to it.
#4. CMS Design System (for code sites)
Use @cmsgov/design-system (npm) — the Section-508 system; classes are ds-*.
- ⚠ Pin React 18, not 19. CMSDS 12.x relies on function-component
defaultProps, which React 19 removed → the whole component tree crashes (Element type is invalid … got: undefinedfromLabel). Usereact@^18.3,react-dom@^18.3,@types/react@^18. React Router 7 works with 18. - Imports (both, order matters):
Wrap the app root inimport '@cmsgov/design-system/css/core-theme.css' // tokens: --color-* on :root import '@cmsgov/design-system/css/index.css' // components: ds-c-* + Open Sans/Bitter fonts import './styles/theme.css' // your brand overlay, loads lastclassName="ds-base". - CMS brand palette (Brand Strategy & Graphic Standards): CMS Blue
#015390(primary), CMS Gold#f3ce3b(accent — dark text only; fails AA as a text color on white), Bright Blue#009ad0. Override the primary ramp (--color-primary*) in the overlay so links/buttons/focus re-point. - Three gotchas that make a site look broken (fix in your overlay):
- Native headings are unstyled — restore an
h1–h4scale scoped to.ds-baseand out of component headings (.ds-c-alert__heading,.ds-c-accordion__heading). paddingshorthand on a section clobbers.ds-l-container'spadding-inline→ usepadding-blockfor vertical-only spacing.ds-c-cardhas no padding/border/background of its own — style your card surfaces explicitly (the ReactCardcomponent isn't even exported in 12.x; use<div className="ds-c-card …">).
- Native headings are unstyled — restore an
- Logo: don't recreate the CMS logo — use the approved asset. A "transparent" stock PNG may actually have a baked-in checkerboard; key out near-white pixels and crop before using it on a white header.
- Forms: labels above fields, error summary (
ds-c-alerterror) with focus moved to it after a failed submit via a post-render effect (focusing before the summary renders is a no-op).
#5. Build → deploy → activate (code sites)
# in the site dir (contains powerpages.config.json)
npm run build
pac pages upload-code-site --rootPath . # NEVER `pac pages upload` — that's for studio sites
- Blocked
.js(PortalFileContentUploadFailed): code sites are JS and the env blocks.jsuploads by default. Fix (env-wide, reversible, get owner consent — it relaxes a security control):
(It sometimes succeeds without this — try first, unblock only if the upload fails.)pac env list-settings # copy blockedattachments pac env update-settings --name blockedattachments --value "<list-without-js>" # remove ONLY 'js' - Activate (provisions the live URL + Entra app reg — needs Cloud App Admin PIM-active):
activate-site.js --siteName "<Name>" --subdomain "<sub>" \ --organizationId "<orgId>" --environmentId "<envId>" --cloud "UsGov" --websiteRecordId "<id>"- GCC cloud → site domain:
UsGov→powerappsportals.us. 401 / A011on the POST (a GET works) → Cloud App Admin isn't PIM-active. Activate in PIM, retry.- A 30 s POST timeout can fire while provisioning continues server-side — re-check the management API / live URL before assuming failure.
- GCC cloud → site domain:
- After any deploy, clear the runtime cache (
clear-site-cache.js). The site cold-starts ~30–60 s; the first navigation may time out — wait and retry. - Re-uploading does NOT re-push attribute changes to existing table permissions (read / append / roles). Upload registers new permissions; to change an existing one, delete + recreate, or edit it in the studio.
#6. Data model, permissions & web roles
#6.1 Data model = ENHANCED on code/traditional sites here
Config lives in mspp_* (mspp_entitypermission, mspp_webrole, mspp_sitesetting) backed by
powerpagecomponent. The legacy adx_* entity sets are used only by Standard data-model sites.
#6.2 Creating tables (publisher prefix)
To get a cms_-prefixed schema you need a solution whose publisher carries the cms prefix. Create
tables via the OData EntityDefinitions deep-insert with the MSCRM.SolutionUniqueName header. Keep
each app/site in its own solution (sharing only the publisher) for clean ALM.
- Lookup nav-property casing: a lookup
cms_Appexposes the single-valued navigation propertycms_App(Pascal-cased schema name), so binds/expands usecms_App@odata.bind/$expand=cms_App— not the lowercase_cms_app_value(that's the value field, used in$filter). - Entity-set names are the pluralized logical names (
cms_pp_widget→cms_pp_widgets).
#6.3 Table permissions + web roles
- Create permission records + Web API site settings with deterministic helper scripts.
- Web API settings (per table):
Webapi/<logical>/enabled = true,Webapi/<logical>/fields = *(field names are lowercase logical names, case-sensitive → 403 otherwise). - ⚠ Attaching a web role to a permission:
- Enhanced (
mspp_) — the role↔permission M:N reverts when set via the Web API ($refassociate returns 204, then a sync clears it). Only the design-studio Security workspace persists it. Two-step in the UI: Add roles → check role → confirm the picker → Save the panel (closing the picker without saving the panel is a no-op).- …but note:
mspp_entitypermission_webroleis empty for every site here — the runtime reads role links frompowerpagecomponent.content(theadx_entitypermission_webrolearray), which is writable (the deploy/upload writes it). The emptymspp_M:N is normal, not the bug.
- …but note:
- Standard (
adx_) — the M:N is writable via the Web API ($ref→ 204, persists).
- Enhanced (
- Lookup creates need Append/AppendTo: to create a child row with a parent lookup, grant Append
on the child table and AppendTo on the parent; otherwise the associate fails with
90040106.
#6.4 Web API from the frontend (CSRF)
Get the antiforgery token from /_layout/tokenhtml (NOT /_api/...), then send it as
__RequestVerificationToken with X-Requested-With: XMLHttpRequest on POSTs. Use a dev-vs-prod guard
(import.meta.env.DEV) to simulate submits on localhost (no /_api there). Build the data layer to
fall back to bundled sample data if a read fails, so the UI degrades gracefully.
#7. ⭐ Site visibility & the Private-site limitations (the big one)
Site visibility is private by default. On a Private site:
- The Entra visibility gate signs the user in, but does NOT provision a portal contact or web
role —
window["Microsoft"].Dynamic365.Portal.User.userNameis empty anduserRolesis[]. (Matches the docs: "users granted access to a private site aren't automatically authenticated.") So the caller is effectively Anonymous, with no Authenticated Users role.
Live-tested consequences on a Private site (code-site Web API):
| Operation | Result |
|---|---|
POST /_api/<set> (no lookup) |
204 — row created ✅ |
POST with lookup (@odata.bind) |
403 90040106 (associate needs to read the parent, which is blocked) |
GET /_api/<set> (any table) |
403 90040120 "no permission to read" ❌ — even with an Anonymous read permission |
Conclusion: the Anonymous-Users workaround on a Private site enables form submits (writes) only, never reads. A read-centric site (browse grids, lists, per-user views) cannot work on a Private site — there is no permission config that fixes it. Write-only forms work fine Private; read-heavy UIs must run on sample data until the site goes Public.
The fix (and the documented go-live path): set site visibility = Public — which keeps Entra sign-in required (no anonymous internet access) so auth routes through the portal identity provider, the signed-in user becomes a contact with the Authenticated Users role, and permissions apply. Then revert permissions to Authenticated Users and strip Anonymous (never ship Anonymous-create to a Public site). "Whole site behind Entra ID" = Public + Entra-required, not Private.
Set visibility via the management API (reversible):
PATCH …/powerpages/environments/{envId}/websites/{mgmtId}?api-version=2022-03-01-preview
{ "siteVisibility": "public" } # keep disableAnonymousAccessInPowerPages on for Entra-required
…or admin center → Power Pages sites → site → Security → Site visibility.
Native / server-rendered sites differ: Liquid {% fetchxml %}, lists, and forms render
server-side, so data permitted to the Anonymous Users role does display in Private mode (this
is how private previews show list data). The read block above is specific to the client-side Web API
path. Anything genuinely per-user still needs the Authenticated role (→ Public).
#8. Authentication
Activation auto-configures a default Entra ID OpenID Connect provider (the app reg created during
activation). For "all CMS employees, no anonymous," the access model is Public + Entra-required (§7),
not Private. The classic identity-provider, web-role, and table/page-permission model applies. Read
the current user in a code site via window["Microsoft"].Dynamic365.Portal.User
(userName/firstName/email/contactId/userRoles) — empty userName ⇒ unauthenticated.
CMS GCC identity is the same Entra tenant described in ../02-iam/overview.md.
#9. Notifications = automated Power Automate cloud flows
Row-triggered email is a standard automated cloud flow (built in the maker, make.gov.powerautomate.us),
not a site-invoked flow. One flow per table (the trigger binds to one table):
- Trigger: Dataverse When a row is added (Change type: Added, Scope: Organization, Table:
).
- To email a related record's owner (e.g. an app owner from a request), add Get a row by ID on the parent table using the trigger's lookup value, then Send an email (V2).
- Sender = a service account / shared mailbox (the Outlook connection sends as its owner). Build inside the site's solution for ALM. Keep dev-safe (test mailbox / disabled) until go-live.
#10. Verification & testing
- Build:
npm run build(tsc + vite). A11y: run axe-core across every route — fix all critical/serious; keep heading order (h1→h2→h3, no skips), landmarks, labels, alt text; check 375 px for horizontal overflow (responsive nav). - Dev loop: run the Vite dev server; a bundled sample-data fallback makes pages render without
/_api, so you can verify UI/UX/a11y fast before deploying. - Authenticated live testing (the only way to test a gated site): a headless/automation browser
isn't signed in. Launch Edge with remote debugging (
msedge --remote-debugging-port=9222, close all Edge first) and sign in; drive it over the Chrome DevTools Protocol (Node 22+ has a globalWebSocket, so you can speak CDP directly —GET http://localhost:9222/jsonfor tabs, thenRuntime.evaluateto runfetch('/_api/...')from inside the authenticated page). This is how the Private-read limits in §7 were proven. - Dataverse checks: OData GET with an
aztoken; confirm rows landed. Reading prod contact PII is (rightly) guardrail-blocked — assert via app-data tables instead.
#Requirement-driven verification loop ("test until ready")
The
cms-power-pagesskill (§3) runs a requirement-driven Playwright loop: turn each requirement into a machine-checkable assertion, drive the site, capture evidence, judge pass/fail, fix failures, and re-verify until everything is green (or until N rounds make no progress — then report blockers).- Two environments, two browsers. Iterate fast on the dev server (
npm run dev, localhost) with a plain Playwright browser (no auth needed; forms simulate submit via the dev guard). Do the final pass on the live site viaplaywright-coreconnectOverCDPto the user's signed-in debug Edge (§ above) — the only way to exercise auth-gated Web API writes. UseNetwork.setCacheDisabled(true)on live to dodge a stale bundle after a deploy. - Make requirements checkable. Functional ("form submit creates a Dataverse row" → POST 2xx + OData
row appears), routing ("links resolve, deep links don't 404" → visit route, assert
h1), UI/UX (computed-style assertions catch the §4 gotchas — navflexDirection,.ds-l-containerleft vs body left,.ds-c-cardbody padding), a11y (axe: 0 critical/serious per route), responsive (375 px). - Capture evidence, don't judge blind. Accessibility snapshot, screenshot read back for genuine
visual review,
evaluatefor computed styles/DOM, a networkresponselistener for/_apiand/_layout/tokenhtmlstatuses (survives navigations, unlike a post-submit DOM read), console errors clean, and an OData query to confirm writes landed. - Loop discipline. On localhost, HMR applies fixes instantly; on live, rebuild → upload → clear cache → wait for warmup. If no failure flips to pass for two rounds, stop and report what's blocked rather than spinning.
#11. Gotchas cheat-sheet & error codes
Symptom Cause / fix pac pages uploaddoes nothing usefulUse upload-code-sitefor code sites.Upload fails PortalFileContentUploadFailed.jsblocked → removejsfromblockedattachments(env-wide; consent).A011/ 401 on activate or site-createCloud App Admin not PIM-active → activate in PIM, retry. 90040103(create) on Web APIWeb role not attached (studio, Enhanced) or Private-site-anonymous. 90040120(read) on Web APIPrivate site blocks reads entirely — only writes work → site must be Public (§7). 90040106(associate) on createMissing Append/AppendTo for the lookup, or the parent read is blocked (Private). Role link won't persist via API Enhanced mspp_M:N is studio-only; Standardadx_M:N is API-writable.Whole React tree crashes ( Labelundefined)React 19 + CMSDS — pin React 18. Site stuck on "Portal provisioning", packageInstallStatus=InstallingA Standard-model site doesn't provision in an enhanced-only env — use enhanced (maker-created traditional, or a code site). Deep links / refresh 404 on a code site They don't — SPA routing works on the Power Pages host. Web API field returns 403 Field name must be the lowercase logical name in Webapi/<table>/fields.
#12. Recommended build order (new site)
- Secure access / roles / licenses (§2);
pac auth create --cloud UsGov;az login. - Decide site type: read-heavy → code-site SPA, plan for Public + Entra; write-only forms or server-rendered content → traditional works Private.
- Scaffold (React + Vite), wire the CMS Design System (React 18; apply the §4 fixes).
- Build pages; the data layer calls the Web API with a CSRF token and falls back to sample data.
- Create the Dataverse model in a dedicated solution (publisher carrying the
cmsprefix). npm run build→pac pages upload-code-site→ (unblock.jsif needed) → activate → clear cache.- Web API site settings + table permissions; attach roles in the studio (Enhanced).
- Set visibility = Public (Entra-required) for live data; revert permissions to Authenticated Users and strip Anonymous.
- Notification flows (one automated cloud flow per request table).
- Verify: a11y, dev loop, then authenticated CDP testing on the live site.
#13. Tooling & Microsoft Learn references
#Tooling
- Microsoft power-platform-skills — the
power-pages:*skills, agents, and helper scripts (§3).https://github.com/microsoft/power-platform-skills cms-power-pagesskill — the CMS playbook layered on top (GCC values, CMSDS integration, gotchas, verification loop). The authoritative runbook; this page is its companion.
#Authoritative Microsoft Learn references
- Site visibility in Power Pages — private vs public; "users granted access to a private site
aren't automatically authenticated."
https://learn.microsoft.com/power-pages/security/site-visibility - Create and deploy a single-page application in Power Pages — "enforces table permissions and web
roles on Web API calls"; setup step "Set site visibility to Public"; CSRF via
/_layout/tokenhtml;window["Microsoft"].Dynamic365.Portal.User.https://learn.microsoft.com/power-pages/configure/create-code-sites - Power Pages security — the web-role / table-permission model.
https://learn.microsoft.com/power-pages/security/power-pages-security - Create and assign web roles — Authenticated Users vs Anonymous Users default roles.
https://learn.microsoft.com/power-pages/security/create-web-roles - Securing lists — table permissions enforced on lists; Anonymous access via the Anonymous Users role.
https://learn.microsoft.com/power-pages/configure/securing-lists - Go-live checklist — visibility, auth, performance, finalization.
https://learn.microsoft.com/power-pages/go-live/checklist