Skip to main content
referenceliving

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.

Reviewed Sat Jun 20 2026 00:00:00 GMT+0000 (Coordinated Universal Time) · Sensitivity: internal

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

  1. Request access earlySystem Administrator on the environment, Cloud Application Administrator (PIM-activated), and a Power Apps Premium license. These gate everything (§2).
  2. GCC is US Gov but uses commercial Entra (login.microsoftonline.com), Dataverse on crm9.dynamics.com, and gov Power Platform API endpoints (§1).
  3. 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). ⭐
  4. Code sites need React 18, not 19 — the CMS Design System relies on function-component defaultProps, which React 19 removed (§4).
  5. .js uploads are blocked by default — remove js from the env blockedattachments to deploy code sites (§5).
  6. 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).
  7. Activation / site-create returns A011 when 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 Entralogin.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 with pac auth who.
  • 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_* / powerpagecomponent for 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), and siteVisibility (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: undefined from Label). Use react@^18.3, react-dom@^18.3, @types/react@^18. React Router 7 works with 18.
  • Imports (both, order matters):
    import '@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 last
    
    Wrap the app root in className="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):
    1. Native headings are unstyled — restore an h1h4 scale scoped to .ds-base and out of component headings (.ds-c-alert__heading, .ds-c-accordion__heading).
    2. padding shorthand on a section clobbers .ds-l-container's padding-inline → use padding-block for vertical-only spacing.
    3. ds-c-card has no padding/border/background of its own — style your card surfaces explicitly (the React Card component isn't even exported in 12.x; use <div className="ds-c-card …">).
  • 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-alert error) 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 .js uploads by default. Fix (env-wide, reversible, get owner consent — it relaxes a security control):
    pac env list-settings                      # copy blockedattachments
    pac env update-settings --name blockedattachments --value "<list-without-js>"   # remove ONLY 'js'
    
    (It sometimes succeeds without this — try first, unblock only if the upload fails.)
  • 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: UsGovpowerappsportals.us.
    • 401 / A011 on 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.
  • 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_App exposes the single-valued navigation property cms_App (Pascal-cased schema name), so binds/expands use cms_App@odata.bind / $expand=cms_Appnot the lowercase _cms_app_value (that's the value field, used in $filter).
  • Entity-set names are the pluralized logical names (cms_pp_widgetcms_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 ($ref associate 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_webrole is empty for every site here — the runtime reads role links from powerpagecomponent.content (the adx_entitypermission_webrole array), which is writable (the deploy/upload writes it). The empty mspp_ M:N is normal, not the bug.
    • Standard (adx_) — the M:N is writable via the Web API ($ref → 204, persists).
  • 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 rolewindow["Microsoft"].Dynamic365.Portal.User.userName is empty and userRoles is []. (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):

  1. Trigger: Dataverse When a row is added (Change type: Added, Scope: Organization, Table:
    ).
  2. 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).
  3. 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.

  4. #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 global WebSocket, so you can speak CDP directly — GET http://localhost:9222/json for tabs, then Runtime.evaluate to run fetch('/_api/...') from inside the authenticated page). This is how the Private-read limits in §7 were proven.
    • Dataverse checks: OData GET with an az token; 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-pages skill (§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 via playwright-core connectOverCDP to the user's signed-in debug Edge (§ above) — the only way to exercise auth-gated Web API writes. Use Network.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 — nav flexDirection, .ds-l-container left vs body left, .ds-c-card body 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, evaluate for computed styles/DOM, a network response listener for /_api and /_layout/tokenhtml statuses (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 upload does nothing useful Use upload-code-site for code sites.
    Upload fails PortalFileContentUploadFailed .js blocked → remove js from blockedattachments (env-wide; consent).
    A011 / 401 on activate or site-create Cloud App Admin not PIM-active → activate in PIM, retry.
    90040103 (create) on Web API Web role not attached (studio, Enhanced) or Private-site-anonymous.
    90040120 (read) on Web API Private site blocks reads entirely — only writes work → site must be Public (§7).
    90040106 (associate) on create Missing 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; Standard adx_ M:N is API-writable.
    Whole React tree crashes (Label undefined) React 19 + CMSDS — pin React 18.
    Site stuck on "Portal provisioning", packageInstallStatus=Installing A 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.

    1. Secure access / roles / licenses (§2); pac auth create --cloud UsGov; az login.
    2. Decide site type: read-heavy → code-site SPA, plan for Public + Entra; write-only forms or server-rendered content → traditional works Private.
    3. Scaffold (React + Vite), wire the CMS Design System (React 18; apply the §4 fixes).
    4. Build pages; the data layer calls the Web API with a CSRF token and falls back to sample data.
    5. Create the Dataverse model in a dedicated solution (publisher carrying the cms prefix).
    6. npm run buildpac pages upload-code-site → (unblock .js if needed) → activate → clear cache.
    7. Web API site settings + table permissions; attach roles in the studio (Enhanced).
    8. Set visibility = Public (Entra-required) for live data; revert permissions to Authenticated Users and strip Anonymous.
    9. Notification flows (one automated cloud flow per request table).
    10. 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-pages skill — 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
power-platformpower-pagesdataversepower-automategcccms-design-systemreactweb-api