Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions plugins/model-apps/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,9 @@ scripts/
sdk-teardown.js ← app-builder teardown engine (planTeardown is pure)
sdk-http-client.js ← az-token HttpClient for the vendored SDK
spec-lint.js / app-spec.js ← App Spec guardrail lint + validation
spec-shape.js ← shared structural normalization for both authoring gates
surface-resolver.js ← pure: resolve personas[].jobs[].surfaces[] to the spec artifacts that satisfy them
role-privileges.js ← pure: declared persona privileges + subset comparison against a deployed role
odata.js ← OData literal escaping helpers
genpage-cli.js ← pac model genpage upload/list/download wrapper
hydrate-spec.js ← reconstruct an App Spec from a deployed app (edit flow)
Expand Down
23 changes: 21 additions & 2 deletions plugins/model-apps/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,29 @@ All notable changes to the **model-apps** plugin.

## [Unreleased] — 2.4.4

Adds plugin update notices, fixes four crash paths, and corrects a smoke-eval
assertion that could never pass live.
Adds plugin update notices, proves what persona roles actually grant, makes
jobs-to-be-done surfaces checkable, fixes four crash paths, and corrects a
smoke-eval assertion that could never pass live.

### Added
- **`verify` now proves what a persona security role GRANTS, not just that it
exists.** The `role` check only asserted a role row carrying the SDK ownership
marker, so a role built with the wrong access — or one whose privilege write
failed after the row landed — verified clean. The new `role-privileges` check
resolves every 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. A **subset** check by design: extra
privileges are never a finding, because `appAccess` injects `appmodule` read,
unioned jobs escalate a shared entity+access to the max declared scope, and
distinct entities can share one Dataverse privilege. Fails **closed** on an
unreadable role or table. Reader-gated, so existence-only callers are unchanged.
- **`personas[].jobs[].surfaces[]` is checked instead of documentary.** Each entry
is now resolved against the spec's own views, forms, pages, dashboards, tables
and sitemap titles. `spec-lint` **warns** when a surface matches nothing — a
warning, not an error, because a surface may legitimately name an out-of-the-box
artifact this spec never authors. `verify` adds a `job-surface` rollup that
reports a deployed failure as the job it broke ("persona P can no longer do job
J"), rather than only "view X is missing".
- **Automatic plugin update notice.** Every user-invocable skill now runs the
non-blocking `scripts/check-version.js` preflight, which compares the installed
Model Apps version with `origin/main` and shows update commands for the active
Expand Down
15 changes: 15 additions & 0 deletions plugins/model-apps/docs/app-builder-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,21 @@ Source: `IMPROVEMENTS-07-15-app-builder.md` (Project Management V1/V2 diff + a s
- 🔲 **Spec templates** — domain starters (support desk, CRM, asset tracking) as one-shot scaffolds.

### Phase: Quality & docs
- ✅ **Security-role and jobs-to-be-done verification (2026-08-14)** — both **metadata-only**:
- **`verify` now proves what a persona role GRANTS, not just that it exists.** The `role` check
only asserted a row carrying the SDK marker, so a role built 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. Deliberately a **subset** check (`lib/role-privileges.js` explains why equality
would false-fail on `appAccess` injection, max-scope union, and shared privileges), and
**fail-closed** on an unreadable role or table.
- **`surfaces[]` is no longer documentary.** `lib/surface-resolver.js` resolves every
`personas[].jobs[].surfaces[]` entry against the spec's own views/forms/pages/dashboards/tables
/sitemap titles; `spec-lint` **warns** when one matches nothing (a warning, not an error — a
surface may legitimately name an OOB artifact), and `verify` adds a `job-surface` rollup that
reports a *deployed* failure as the job it broke ("persona P can no longer do job J") rather
than only "view X is missing".
- ✅ **Sample-run UX fixes (2026-07-27, from a live Property-Listings build).**
- ✅ **#1 live build status.** A long build now writes `<workspace>/.maker-workspace/build-status.json` (a single-object snapshot — `state`/`steps`/`lastPhase`/`lastLabel` — overwritten every step) alongside the `build-log.jsonl` trace, and prints a `▸ live progress:` path at start. So a multi-minute build is observable even when the launching shell buffers stdout. SKILL.md now tells the agent to stream (Tee, not Select-Object) and read the status file.
- ✅ **#2 wireframes shown.** SKILL.md Phase-1 preview step now REQUIRES pasting the `preview-app.js`/`preview-form.js` wireframe output to the user (not summarizing "looks right") before the approval gate — the user must see the forms/sitemap/pages they approve.
Expand Down
11 changes: 10 additions & 1 deletion plugins/model-apps/references/app-spec-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,7 +605,7 @@ privilege removes it — the role converges to the spec).
**Field reference**
- `persona` (**required**) — the security role's display name; also its idempotency key. Must be unique across `personas[]`.
- `jobs[]` (**required**, ≥1) — `{ name, description?, surfaces?, privileges[] }`. `privileges[]` is required and non-empty per job.
- `jobs[].surfaces[]` (optional) — the view/form/page names (or page `key`s) that let this persona **do** the job. Documentary only: it is never applied to Dataverse. It renders the jobs→surfaces traceability table in `model-app-plan.md`, and a job with no `surfaces[]` is flagged by `spec-lint.js` as a design gapnothing in the app demonstrably lets that persona do that job.
- `jobs[].surfaces[]` (optional) — the view/form/page names (or page `key`s) that let this persona **do** the job. Never applied to Dataverse. It renders the jobs→surfaces traceability table in `model-app-plan.md`; a job with no `surfaces[]` is flagged by `spec-lint.js` as a design gap, and a surface that **matches nothing this spec builds** is flagged too (`lib/surface-resolver.js` resolves each entry against `views[]` / `forms[]` / `pages[]` (key **or** name) / `dashboards[]` / `entities[]` / sitemap subarea titles, case-insensitively). Both are **warnings**, never errors — a surface may legitimately name an out-of-the-box artifact this spec does not author. `verify-model-app` additionally rolls a *deployed* failure up to the job that depended on it (`job-surface`), so "view X is missing" also reads as "persona P can no longer do job J".
- `privileges[].entity` (**required**) — a table **logical name** (e.g. `account`, `msdyn_workorder`). May be a table this spec doesn't author (standard/system tables are common); existence is resolved against live metadata by the build, not at lint time.
- `privileges[].access` (**required**) — one or more of `read · create · write · delete · append · appendTo · assign · share`.
- `privileges[].scope` (optional, default `user`) — `user` (Basic) · `businessUnit` (Local) · `parentChild` (Deep) · `organization` (Global), least→most permissive.
Expand All @@ -627,6 +627,15 @@ of the **same name in the same business unit** share one role by design (the sec
first's) — give personas distinct names, or a distinct `businessUnitId`, if you need separate roles. In
`--changed-only` mode a persona change forces a **full build** (there is no partial security apply yet).

**Verification.** `verify-model-app` proves the role **row** exists carrying the SDK ownership marker
(`role`) *and* — when the reader supplies role/entity privilege access — that the role actually
**grants** every declared privilege at **at least** the declared depth (`role-privileges`). The depth
comparison is a **subset** check by design: extra privileges are never a finding, because `appAccess`
injects `appmodule` read, unioned jobs escalate a shared entity+access to the max declared scope, and
distinct entities can share one Dataverse privilege (a role holds one depth per privilege). It fails
**closed** — an unreadable role, or a table whose privilege metadata cannot be read, is reported
rather than skipped.

**Validation rules** (`validateAppSpec`): `persona` required + unique; each job needs a `name` and a
non-empty `privileges[]`; `access` values and `scope` must be valid tokens; `appAccess` must be a
boolean; `businessUnitId` and `assignTo` ids must be GUIDs. Two apply-time checks need live metadata and
Expand Down
95 changes: 95 additions & 0 deletions plugins/model-apps/scripts/lib/role-privileges.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// plugins/model-apps/scripts/lib/role-privileges.js
// PURE: what privileges a persona's security role MUST hold, and whether a deployed role holds them.
//
// WHY this exists. `verifySpec`'s role check proved only that a role ROW exists carrying the SDK
// ownership marker. It never looked at the role's privileges — so a role created with the wrong
// access, or one whose privilege write silently failed after the row was created, verified clean.
// That is a metadata read, which is why it is cheap enough to run on every verify.
//// SUBSET, not equality. We assert the role holds AT LEAST every declared privilege at AT LEAST the
// declared depth. Extra privileges are never a finding, and that is deliberate — three legitimate
// sources add privileges the spec does not literally list:
// 1. `appAccess` injects `appmodule` read (see personaRoleSpecFor).
// 2. Several jobs unioned together escalate a shared entity+access to the MAX declared scope.
// 3. Distinct entities can share ONE Dataverse privilege, and a role holds one depth per
// privilege — so the SDK raises that privilege to the highest scope any of them asked for.
// An equality check would fail on all three while telling us nothing true.
//
// Dataverse reference — privilege depth (`Depth` on ReplacePrivilegesRole, `RolePrivilegeDepth`):
// Basic (user) < Local (business unit) < Deep (parent/child) < Global (organization).
// https://learn.microsoft.com/en-us/power-apps/developer/data-platform/security-model
'use strict';

// App Spec scope -> Dataverse depth name, and its ORDER. The order is what makes this a subset
// check: a role holding Global satisfies a declared Basic. Mirrors the vendored SDK's own mapping
// (verified against scripts/vendor/cds-maker-sdk.cjs) so a comparison cannot disagree with the write.
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' };

const rankOf = (depth) => DEPTH_RANK[String(depth == null ? '' : depth).trim().toLowerCase()] || 0;

// Flatten a persona to the (entity, access, scope) triples its role must satisfy, taking the MAX
// scope per (entity, access) exactly as the builder's union does. `appAccess` is folded in here so
// the expectation matches what the build actually writes rather than what the author typed.
function declaredPrivileges(persona) {
const byKey = new Map(); // "<entity>|<access>" -> { entity, access, scope }
const addAll = (list) => {
for (const pr of list || []) {
if (!pr || !pr.entity) continue;
const entity = String(pr.entity).trim().toLowerCase();
const scope = pr.scope || 'user';
for (const a of pr.access || []) {
const access = String(a).trim();
if (!access) continue;
const key = `${entity}|${access.toLowerCase()}`;
const prev = byKey.get(key);
// Max scope wins — the same rule the builder applies when unioning jobs into one role.
if (!prev || rankOf(SCOPE_DEPTH[scope]) > rankOf(SCOPE_DEPTH[prev.scope])) byKey.set(key, { entity, access, scope });
}
}
};
for (const j of (persona && persona.jobs) || []) addAll(j && j.privileges);
addAll(persona && persona.additionalPrivileges);
// Mirrors personaRoleSpecFor: unless the persona opts out, the build grants appmodule read so the
// app actually opens for them. Verifying it matters — without it the role exists but the app does not.
if (!persona || persona.appAccess !== false) addAll([{ entity: 'appmodule', access: ['read'], scope: 'organization' }]);
return [...byKey.values()];
}

// Compare declared privileges against what the role actually holds.
// `entityPrivileges`: Map<entityLogical, [{ Name, PrivilegeId, PrivilegeType }]> — from
// EntityDefinitions(LogicalName='x')?$select=Privileges, the SAME source the
// SDK resolves against when it writes.
// `actualByPrivilegeId`: Map<privilegeId(lowercased), depthName>
// Returns { ok, missing:[{ entity, access, scope, reason, privilegeName? }] }.
// An entity whose metadata could not be read is reported as a finding, never skipped — a read
// failure must not read as "nothing missing" (fail closed).
function compareRolePrivileges(declared, entityPrivileges, actualByPrivilegeId) {
const missing = [];
for (const d of declared) {
const privs = entityPrivileges.get(d.entity);
if (!privs) {
missing.push({ ...d, reason: `could not read privilege metadata for '${d.entity}'` });
continue;
}
const type = ACCESS_TYPE[d.access.toLowerCase()];
const p = type && privs.find((x) => x && x.PrivilegeType === type);
if (!p) {
missing.push({ ...d, reason: `'${d.entity}' exposes no '${d.access}' privilege` });
continue;
}
const held = actualByPrivilegeId.get(String(p.PrivilegeId || '').trim().toLowerCase());
if (!held) {
missing.push({ ...d, privilegeName: p.Name, reason: `role does not hold ${p.Name}` });
continue;
}
const want = SCOPE_DEPTH[d.scope] || 'Basic';
if (rankOf(held) < rankOf(want)) {
missing.push({ ...d, privilegeName: p.Name, reason: `role holds ${p.Name} at ${held}, below the declared ${want}` });
}
}
return { ok: missing.length === 0, missing };
}

module.exports = { declaredPrivileges, compareRolePrivileges, SCOPE_DEPTH, ACCESS_TYPE, DEPTH_RANK };
6 changes: 6 additions & 0 deletions plugins/model-apps/scripts/lib/spec-lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// relationship schema-name vs lookup-name collision Dataverse rejects.
const { relationshipSchemaName, relationshipFor, invalidChoiceSampleTokens, isPlatformIconRef } = require('./app-spec.js');
const { normalizeSpecShape } = require('./spec-shape.js');
const { resolveSurfaces, unresolvedSurfaceMessage } = require('./surface-resolver.js');

const CHOICE_OPTION_WARN = 12;
const SEQNUM_RE = /\{SEQNUM(:\d+)?\}/i;
Expand Down Expand Up @@ -390,6 +391,11 @@ function lintAppSpec(spec) {
W(`persona "${persona}" job "${job.name}" is not mapped to a surface (jobs[].surfaces[]) — nothing in this app demonstrably lets that persona do the job.`);
}
}
// A surface that names nothing this spec builds is the NEXT failure after "no surfaces at all":
// the job claims coverage that does not exist. Only a warning, because a surface may legitimately
// name an out-of-the-box artifact this spec never authors (the same reason app-spec.js validates
// surfaces as shape-only) — see lib/surface-resolver.js.
for (const u of resolveSurfaces(spec).unresolved) W(unresolvedSurfaceMessage(u));
if (!(Array.isArray(spec.pages) && spec.pages.length)) {
W('no pages[] — per the genpage-first policy, non-record surfaces (overview/landing, dashboard, analytics, guided or wizard flows) should be generative pages. If this app is genuinely record-CRUD only, ignore this.');
}
Expand Down
Loading
Loading