← Blog
Platform Engineering·03. Oktober 2026·12 min Lesezeit

Model-driven App innen, Code Site außen

Teil 2 zum App-Builder: Wenn externe Dienstleister an dieselben Dataverse-Tabellen sollen, ohne Lizenz und ohne Maker-Zugang. Ein eigenes Portal als Power Pages Code Site — in 21 Minuten gebaut, aus dem Terminal veröffentlicht, mit allen Zahlen und allen Stolpersteinen.

In Teil 1 ist aus einem Satz eine Anwendung für die Prüfmittelverwaltung geworden: drei Tabellen, fünf Ansichten, drei Formulare, drei Sicherheitsrollen, gebaut in fünf Minuten sechsunddreißig. Eine Model-driven App, wie sie im Buche steht — für die eigene Mannschaft.

Nur werden Prüfmittel selten von der eigenen Mannschaft geprüft. Das machen Dienstleister. Die haben keine Power-Apps-Lizenz, sollen keine bekommen, und sie haben im Mandanten nichts verloren. Was sie brauchen, ist schmal: eine Seite mit den Geräten, für die sie beauftragt sind, und ein Formular, um das Ergebnis zu melden.

Dafür gibt es Power Pages Code Sites: eine Single-Page-Anwendung — React, Vue, Angular oder Astro — die auf der Power-Pages-Runtime läuft und direkt auf Dataverse zugreift. Kein Connector, keine Azure Function dazwischen, keine zweite Datenhaltung.

Dieser Beitrag baut so ein Portal auf dieselben drei Tabellen aus Teil 1 und sagt am Ende, wann man was nimmt. Alle Zahlen stammen aus diesem einen Durchlauf.

Erst die Abgrenzung, sonst redet man aneinander vorbei

Drei Dinge heißen ähnlich und sind verschieden:

  • Power Pages Code Site — eine SPA auf der Power-Pages-Runtime, für Leute außerhalb der Organisation. Anmeldung über Entra ID, Entra External ID, lokale Konten oder Partner-Anbieter. Darum geht es hier.
  • Power Apps Code App — eine SPA innerhalb von Power Apps, für lizenzierte Benutzer im Mandanten.
  • Klassisches Power-Pages-Portal — Liquid-Vorlagen, im Studio zusammengeklickt. Der ältere Weg.

Die Vorlage, die es nicht wird

Der Vorlagenkatalog führt genau eine Vorlage für Code Sites: ein Bürgerservice-Portal nach US-Muster. Für ein Prüfmittel-Portal in einem deutschen Werk bringt das nichts außer Beispielcode, den man wieder herausräumt. Also selbst erzeugen — was weniger dramatisch ist, als es klingt:

pruefmittel-portal/
  package.json            React 19, react-router, Vite 6
  vite.config.ts
  tsconfig.json
  index.html
  powerpages.config.json  ← das einzige Power-Pages-Spezifische
  src/

powerpages.config.json ist die ganze Verbindung zur Plattform:

{
  "siteName": "Pruefmittel-Portal",
  "compiledPath": "dist",
  "defaultLandingPage": "index.html"
}

npm install holt 73 Pakete in 12 Sekunden, der Build braucht 0,9 Sekunden für 45 Module. Die fertige Oberfläche sind 540 Zeilen: eine Liste mit Fälligkeitsampel, eine Detailseite mit Meldeformular und Prüfhistorie, der Datenzugriff und ein Stylesheet.

Die Übersicht des Portals mit echten Datensätzen aus Dataverse — die Fälligkeit wird aus dem hinterlegten Prüfintervall gerechnet.

Der Zugriff auf Dataverse

Im Portal liegt die Dataverse-Web-API unter /_api. Die Anmeldung trägt der Sitzungs-Cookie; für schreibende Aufrufe verlangt die Runtime zusätzlich ein Anti-Forgery-Token aus /_layout/tokenhtml.

let token: string | null = null;

async function holeToken(): Promise<string> {
  if (token) return token;
  const antwort = await fetch('/_layout/tokenhtml');
  const html = await antwort.text();
  const treffer = html.match(/value="([^"]+)"/);
  token = treffer ? treffer[1] : '';
  return token;
}

async function anfrage<T>(pfad: string, init: RequestInit = {}): Promise<T | null> {
  const kopf = new Headers(init.headers);
  kopf.set('Accept', 'application/json');
  kopf.set('Prefer', 'odata.include-annotations="OData.Community.Display.V1.FormattedValue"');
  if (init.method && init.method !== 'GET') {
    kopf.set('Content-Type', 'application/json');
    kopf.set('__RequestVerificationToken', await holeToken());
  }
  const antwort = await fetch(`/_api/${pfad}`, { ...init, headers: kopf });
  // …
}

Zwei Dinge daran sind bequemer, als man erwartet. In diesem Code steht kein Authorization-Header und kein Schlüssel — die Sitzung reicht. Und die Pfade sind exakt die der Dataverse-Web-API:

const daten = await anfrage<{ value: Pruefmittel[] }>(
  `wk_pruefmittels?$select=${FELDER}&$orderby=wk_naechstepruefungfaellig asc`
);

Wer OData für Dataverse kann, kann es hier. Der Prefer-Header spart die Auflösung von Optionssets: wk_status@OData.Community.Display.V1.FormattedValue liefert „In Betrieb” statt 100000000.

Damit /_api antwortet, braucht jede Tabelle zwei Einstellungen:

Webapi/wk_pruefmittel/enabled = true
Webapi/wk_pruefmittel/fields  = wk_pruefmittelid,wk_bezeichnung,wk_inventarnummer,…

Die Spaltenliste ist keine Bequemlichkeit, sondern eine Freigabe: Was nicht drinsteht, kommt nicht heraus und geht nicht hinein.

Rollen und Rechte sind Dateien — das ist der eigentliche Gewinn

Beim ersten Uploadversuch legt die CLI lokal .powerpages-site/ an: 200 Dateien, jede mit fester GUID — 60 Webseiten, 51 Site Settings, 26 Webvorlagen, 22 Textbausteine, 16 Webdateien, drei Webrollen, zwei Publishing States. Das vollständige Website-Modell als Ordnerbaum.

Daneben schreibt man die eigenen Sachen. Eine Webrolle:

anonymoususersrole: false
authenticatedusersrole: false
description: Externe Prüfdienstleister — sehen die ihnen zugewiesenen Prüfmittel
  und melden Prüfergebnisse.
id: 32e7e978-59a6-425c-b32d-23fec5ba009d
name: Externe Prüfdienstleister

Und die Berechtigungen, über ein Skript des Plugins statt von Hand:

node create-table-permission.js --projectRoot . \
  --permissionName "Pruefungen melden" --tableName wk_pruefung \
  --webRoleIds "32e7e978-…" --scope Global --read --create
adx_entitypermission_webrole:
- 32e7e978-59a6-425c-b32d-23fec5ba009d
entitylogicalname: wk_pruefung
entityname: Pruefungen melden
read: true
create: true
write: false
delete: false
scope: 756150000

Das ist der Unterschied zum klassischen Portal und auch zur Model-driven App: Wer was darf, steht im Repository neben dem Code, geht durch dasselbe Review und dasselbe Deployment. Man sieht es im Diff statt in einem Dialog.

Und es greift serverseitig. Ohne passende Webrolle antwortet die Portal-Web-API klar:

GET /_api/wk_pruefmittels?$select=wk_bezeichnung&$top=1

403
{"error":{"code":"90040120",
  "message":"You don't have permission to read the wk_pruefmittel…"}}

Kein stilles leeres Ergebnis, sondern eine benannte Verweigerung, bevor eine Zeile Seitencode gelaufen ist. Anonym kommt ebenfalls nichts durch: Die Startseite leitet zur Anmeldung um, /_api antwortet mit 302.

Was diese Berechtigung nicht leistet: Sie entscheidet, ob jemand eine Tabelle schreiben darf — nicht, was ein gültiger Wert ist. Die fachliche Regel aus Teil 1 (kein Prüfdatum in der Zukunft) ist hier genauso umgehbar wie dort: Wer create auf wk_pruefung hat, kann per POST auf /_api/wk_pruefungs einen Satz mit Datum 2027 anlegen. Wo solche Regeln hingehören, damit sie niemand umgeht, steht in einem eigenen Beitrag — nämlich in der Datenschicht, als Plug-in oder Custom API.

Server Logic: der Komfortgewinn

Für den Schreibweg gibt es Server Logic — serverseitiges JavaScript auf der Power-Pages-Runtime, abgelegt im selben Ordnerbaum:

.powerpages-site/server-logic/pruefung-melden/
  pruefung-melden.js
  pruefung-melden.serverlogic.yml

Die Metadaten-Datei bindet die Logik an Webrollen, der Endpunkt heißt /_api/serverlogics/pruefung-melden. Erlaubt sind genau fünf Funktionen auf oberster Ebene — get, post, put, patch, del —, jede muss eine Zeichenkette zurückgeben. Kein fetch, kein setTimeout, keine npm-Pakete.

Der Gewinn liegt nicht in der Sicherheit, sondern im Zuschnitt. Eine Prüfmeldung besteht aus vier Schritten: Eingabe prüfen, Prüfung anlegen, Folgetermin aus dem Intervall rechnen, bei „Nicht bestanden” das Gerät sperren. Ohne Server Logic macht der Browser daraus vier Aufrufe und muss zwischendurch wissen, wie gerechnet wird. Mit Server Logic ist es einer:

function post() {
    try {
        Server.Logger.Log("pruefung-melden POST aufgerufen");
        var eingabe = JSON.parse(Server.Context.Body);

        // Ist-Stand aus Dataverse lesen, nicht der Client-Sicht trauen.
        var geraet = Server.Connector.Dataverse.RetrieveRecord(
            "wk_pruefmittel", eingabe.pruefmittelId,
            "?$select=wk_bezeichnung,wk_pruefintervallmonate,wk_status"
        );
        // … Prüfung anlegen, Folgetermin aus dem Intervall rechnen,
        //     bei „Nicht bestanden" das Gerät sperren
    } catch (err) {
        Server.Logger.Error("pruefung-melden POST fehlgeschlagen: " + err.message);
        return JSON.stringify({ ok: false, meldung: err.message });
    }
}

Zwei Punkte, die man beim Entwurf kennen sollte. Erstens: Server.Connector.Dataverse umgeht Tabellenberechtigungen nicht, sondern arbeitet mit den Rechten der Webrolle — ohne Berechtigung liefert er stillschweigend null Datensätze. Zweitens, und das folgt daraus: Sobald die Rolle schreiben darf, kann der Browser denselben Schreibvorgang auch direkt machen. Server Logic hält Code und Geheimnisse vom Browser fern und bündelt mehrere Schritte zu einem Aufruf. Ein Schreibschutz ist sie nicht.

Dass die Server Logic auf der Runtime wirklich anläuft, konnte ich nicht nachweisen — dafür hätte es eine Anmeldung am Portal gebraucht, und genau die war der Haken. Dazu gleich.

Die Detailseite mit Meldeformular und Prüfhistorie. Der zweite Knopf schickt dieselben Daten direkt über die Web-API, an der Server Logic vorbei.

Was der Weg kostet

Was aus dem Terminal ging — und wo Schluss war.

SchrittDauer
Umgebung erheben (Tabellen, Spalten, Optionssets)7 min
Gerüst, npm install, erster Build3 min
Erster Uploadversuch — Abbruch an der Blockliste1 min
Webrolle, drei Tabellenberechtigungen, sieben Site Settings4 min
Server Logic schreiben5 min
Lokaler Betrieb gegen echte Daten8 min
Projekt fertig28 min
pac pages upload-code-site (140 Datensätze, 48 Entitäten)28 s
Aktivierung bis zur erreichbaren Adresse84 s

Wie gemessen: Das sind Zeitstempel aus dem Arbeitsprotokoll dieses Durchlaufs, an jedem Phasenwechsel notiert — Wanduhrzeit, in der ein Agent im Terminal gearbeitet hat, einschließlich Lesen, Schreiben und Überlegen. Nicht enthalten ist die Wartezeit auf die Freigabe der Dateiendung, die dazwischen über Nacht lag. Wer selbst tippt, kommt auf andere Zahlen; aussagekräftig ist das Verhältnis der Phasen zueinander — die Hälfte der Zeit ging für Erhebung und lokalen Betrieb drauf, nicht für das Schreiben der Oberfläche.

Ein Vergleich mit Teil 1 liegt nahe, führt aber in die Irre, wenn man einfach die Zahlen nebeneinanderlegt. Die 5 Minuten 36 der Model-driven App waren die reine Laufzeit der Engine — Schema-Build und Vollbau. Das Aushandeln der Spezifikation, zwei Prüfdurchgänge mit vierzehn Beanstandungen und das Nachbessern kamen obendrauf; sie waren dort der eigentliche Aufwand, und gestoppt habe ich sie nicht. Die 21 Minuten hier sind dagegen die gesamte Zeit, in der die Anwendung entstand — Erheben der Spalten, Schreiben der Komponenten, Prüfen gegen echte Daten. Auf Maschinenebene steht die Code Site bei rund zwei Minuten: 0,9 Sekunden Build, 28 Sekunden Upload, 84 Sekunden Aktivierung.

Und noch etwas sollte man nicht verwechseln: Beide Oberflächen sind erzeugt worden, keine davon hat jemand von Hand getippt. Der Unterschied liegt darin, womit. Für die Model-driven App gibt es ein Werkzeug — der Agent füllt eine Spezifikation aus, eine deterministische Engine baut daraus Tabellen, Ansichten und Formulare. Für eine Code Site gibt es das nicht: Dort schreibt der Agent den Code selbst, Komponente für Komponente, und das Plugin liefert nur die Randstücke wie Tabellenberechtigungen und Site Settings.

Was sich daraus ehrlich sagen lässt: Beide Wege führen an einem Vormittag zum Ziel, und keiner der beiden ist der schnelle. Die Model-driven App nimmt einem die Gestaltung ab, die Code Site gibt einem die Kontrolle darüber.

Fünf Stellen, an denen es klemmt:

JavaScript steht auf der Blockliste. Eine Code Site besteht aus .js-Dateien, und die sind in jeder Umgebung als Anhangstyp gesperrt. Der Upload bricht nach sechs Sekunden ab:

Error: Upload aborted. Your site data contains file(s) with extension(s) .js
that are blocked in this environment, so no changes were uploaded.

Die Liste hat 96 Einträge und gilt für die ganze Umgebung. Freigeben lässt sie sich nur im Admin Center unter Datenschutz + Sicherheit. Wer das nicht darf, kommt keinen Schritt weiter — das sollte man vor dem ersten Termin klären.

Die Environment-ID braucht ein Präfix. pac env list und pac admin list geben die ID der Standardumgebung nackt aus. Die Aktivierungs-API antwortet darauf:

{"status":"Failed","statusCode":404,"errorCode":"ResourceNotExists",
 "error":"Environment c530e80c-… does not exist."}

Sie will Default-c530e80c-…. Dieselbe Ursache legt zwei weitere Skripte lahm, die still Websites API call failed oder HTTP 404 melden.

Englisch steht zweimal fest im Code. website.yml trägt website_language: 1033, und im Aktivierungsskript steht selectedBaseLanguage: 1033 als Konstante. Die Umgebung führt nur Deutsch (1031) — die Website wurde trotzdem englisch angelegt, ohne Warnung.

Das Berechtigungsskript prüft den falschen Umstand. create-table-permission.js bricht ab mit „The site must be deployed at least once…”. Tatsächlich prüft es nur, ob der Ordner table-permissions/ existiert. Ein mkdir, und es läuft.

Die Startseite bleibt die Standardseite. Nach der Aktivierung liefert / das Standard-Template, die eigene Anwendung liegt unter /index.html. Ein zweiter Upload ändert daran nichts.

Und im Enhanced Data Model liegt alles woanders: adx_webroles ist leer, Webrollen, Tabellenberechtigungen, Site Settings und Server Logic stecken in powerpagecomponents, unterschieden über einen Typ-Code — 129 Einträge für diese Site. Wer die alten adx_-Tabellen abfragt, hält das Deployment für gescheitert.

Die Anmeldung muss man einplanen

Auf /SignIn steht ein Knopf „Microsoft Entra ID”. Er führt in einen Serverfehler.

Der Grund lässt sich nachzählen: Von 28 Authentication/*-Einstellungen der Site betreffen genau zwei den Anbieter — die Beschriftung und ein Hinweistext. Authority, ClientId und RedirectUri fehlen. Eine per CLI erzeugte Code Site hat den Anmeldeknopf, aber keine Anmeldung dahinter.

Nachrüsten lässt sich einiges: App-Registrierung anlegen, die drei Einstellungen setzen, Berechtigungen ergänzen, Zustimmung erteilen. Danach läuft der Ablauf bis zum Rückkanal und endet dort in einer generischen Fehlerseite mit Vorgangsnummer; ein Portal-Kontakt entsteht nicht.

Die praktische Lehre daraus: Wer eine Code Site produktiv aufsetzt, legt die Website einmal im Design Studio an — dort wird die Anmeldung mit eingerichtet — und arbeitet danach aus dem Terminal weiter. Der reine Terminalweg trägt vom Gerüst bis zur erreichbaren Adresse, aber nicht über die Anmeldeschwelle.

Zwei Beobachtungen am Rand, die beim Nachbauen Zeit sparen: Die Bearbeitungsmaske des Anbieters im Design Studio hat keine Felder für Anwendungs-ID oder Mandant — nur Beschriftung, zwei Anspruchsabbildungen und vier Schalter. Und beim Speichern legt sie neue Einträge an, statt die vorhandenen zu ändern; danach standen in der Umgebung Dubletten mit gegensätzlichen Werten:

Authentication/OpenIdConnect/AzureAD/RegistrationEnabled          = false
Authentication/OpenIdConnect/AzureAD/RegistrationEnabled          = true
Authentication/OpenIdConnect/AzureAD/AllowContactMappingWithEmail = false
Authentication/OpenIdConnect/AzureAD/AllowContactMappingWithEmail = true

Welcher davon gilt, ist nicht bestimmt.

Wann nimmt man was

Model-driven App innen, Code Site außen — beides auf denselben Tabellen.

Beide Wege liegen auf denselben drei Tabellen, in derselben Umgebung. Die Frage ist nicht, welcher besser ist, sondern wer die Oberfläche benutzt.

Model-driven App, wenn die Leute ohnehin im Haus sind. Sie entsteht aus einer Beschreibung, bringt Ansichten, Formulare, Suche, Diagramme und Sicherheitsrollen mit, und man muss nichts gestalten. Der Preis: Jeder Benutzer braucht eine Lizenz und ein Konto im Mandanten, und er sieht die Anwendung so, wie die Plattform sie baut.

Code Site, wenn Außenstehende einen Ausschnitt brauchen. Der Preis ist, dass es dafür kein fertiges Werkzeug gibt: Die Oberfläche entsteht Zeile für Zeile — hier in 21 Minuten, bei mehr Umfang entsprechend mehr. Dafür bekommt man drei Dinge, die die Model-driven App nicht bietet: eine Oberfläche, die genau den Ausschnitt zeigt und sonst nichts; Zugriffsrechte als versionierte Dateien statt als Klickpfad; und Benutzer, die keinen Platz im Mandanten brauchen.

Drei Dinge, die man vorher klären sollte, weil sie den Zeitplan bestimmen: Wer darf .js in der Umgebung freigeben. Wer legt die Website an, damit die Anmeldung steht. Und wo sollen die fachlichen Regeln liegen — denn weder das Formular der Model-driven App noch die Tabellenberechtigung der Code Site erzwingen, dass ein Wert plausibel ist. Das bleibt Sache der Datenschicht, in beiden Welten gleich.


Siehe auch: Ich wollte eine App aus einem Satz. Bekommen habe ich ein Pflichtenheft. · Ersetzt Power Fx das C#-Plugin? Eine Messung an 1000 Zeilen