From 8afa495a873dd38b224c5beb20a02efaab6f3574 Mon Sep 17 00:00:00 2001 From: itsnotaboutthecell <44716363+itsnotaboutthecell@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:42:41 -0500 Subject: [PATCH 1/2] docs: specify frontend design skill Define the scope, workflow, acceptance criteria, and repository integration for a SQL Apps frontend-design skill. --- ...2026-10-09-frontend-design-skill-design.md | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md diff --git a/docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md b/docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md new file mode 100644 index 0000000..3e2212f --- /dev/null +++ b/docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md @@ -0,0 +1,91 @@ +# SQL Apps Frontend Design Skill + +## Problem + +SQL Apps helps assistants build custom browser applications, but its application guidance focuses on domain scope, data boundaries, runtime behavior, and validation. It gives little practical direction for visual identity, hierarchy, responsive layouts, or visual iteration. As a result, assistants can deliver functionally complete interfaces that look generic or unfinished. + +The SQL Apps foundation browser is intentionally a functional example, not the design target for every app. A new skill should help users and assistants create distinctive, usable interfaces for the domain app they are building without turning SQL Apps into a theme or template system. + +## Goals + +- Improve visual craft and usability of custom applications built with SQL Apps. +- Fit the existing SQL Apps skill/plugin conventions and application workflow. +- Preserve freedom to use an app-appropriate visual identity and the existing project stack. +- Make browser-based visual review an explicit acceptance activity when feasible. +- Keep functional, data, access, and deployment decisions within the existing SQL Apps application workflow. + +## Non-goals + +- Redesigning `public/` or the shipped foundation browser. +- Creating a universal SQL Apps theme, component library, or domain-specific starter template. +- Mandating a frontend framework, CSS library, typography, palette, or design trend. +- Replacing the application skill's SQL, authorization, safety, or runtime guidance. +- Treating a successful build, source inspection, or generated screenshot as proof of end-to-end app behavior. + +## Proposed approach + +Add a focused `sql-apps-frontend-design` skill to the SQL Apps plugin. Route custom UI creation and visual-polish tasks to it from the existing `sql-apps-application` skill, and add a short discovery cue to `docs/guides/build-your-app.md`. Update the repository's explicit plugin skill inventory, packaging check, and targeted tests so the skill is included and validated with the existing bundle. + +This keeps the app workflow authoritative for SQL Apps-specific boundaries and gives visual design a focused reusable workflow. It is preferred over expanding the app skill into a catch-all and over a shared visual system that could make unrelated custom apps look alike. + +## Skill responsibilities and workflow + +### 1. Understand the interface being designed + +- Inspect the existing application and its established visual patterns before proposing changes. +- Understand the intended users, primary task, important information, and device context from the app brief. Ask only for missing decisions that materially affect the interface. +- For substantial new interfaces, present two or three app-appropriate visual directions with concise trade-offs and recommend one. Obtain user approval of the direction before implementation. +- For small extensions or refinements, follow the existing app's visual language rather than adding an approval ceremony. +- Do not make users choose a framework or cloud architecture as a prerequisite to visual design. + +### 2. Establish an app-specific design direction + +Translate the approved direction into practical choices for page composition, information hierarchy, typography, color, spacing, density, and imagery or illustration when useful. Avoid generic dashboard patterns or decorative effects without a purpose. Respect an existing product brand when present; do not impose a SQL Apps theme on the app. + +Identify the key populated, empty, loading, error, disabled, and success states applicable to the requested workflow. Do not add states or controls for capabilities the app does not have. + +### 3. Implement within existing project constraints + +- Use the existing frontend stack, assets, and dependencies where suitable. Do not add packages or fetch remote fonts/images just for appearance without user approval. +- Keep the implementation semantic, responsive, and consistent with the established app structure. +- Preserve existing workflow, data, identity, authorization, and capability boundaries; route SQL Apps-specific questions to the application skill rather than inventing behavior. +- Prefer clear focus states, sufficient contrast, keyboard-operable controls, meaningful labels, and respect for reduced-motion preferences. Do not trade usability or accessibility for visual novelty. + +### 4. Review the rendered interface and iterate + +When a local browser preview is available, run the app using its documented project workflow and inspect the actual rendered UI at a desktop viewport and a narrow mobile viewport. Review the primary screen and important populated/empty/loading/error states that are feasible to reach. Fix meaningful issues in hierarchy, spacing, text wrapping, responsive behavior, affordance clarity, and visual consistency, then inspect the changed render again. + +Use visual browser tools when available. Otherwise use the project's supported preview and screenshot mechanism. Do not start unrelated services or use production data just to obtain a preview. If browser review cannot be performed safely or feasibly, state that limitation explicitly and report what was checked instead; do not claim visual acceptance based only on source code or a successful build. + +### 5. Report evidence and remaining gaps + +Summarize the design direction and significant interface decisions, list the viewports and states actually inspected, describe any iterations made, and clearly identify visual or accessibility checks that remain unverified. Preserve the application skill's separate requirements for functional workflow, persistence, access, and SQL validation; visual inspection does not replace them. + +## Quality bar + +A completed UI change should: + +- Communicate an intentional, app-appropriate visual identity rather than an unexamined default. +- Make the primary task and information hierarchy apparent. +- Present relevant interaction states and content at the right level of visual emphasis. +- Remain usable at desktop and narrow mobile sizes without clipping or awkward overflow. +- Provide keyboard access, visible focus, understandable labels, sufficient contrast, and reduced-motion handling where motion is present. +- Be visually inspected in a running browser when feasible, with limitations reported honestly. + +## Integration and validation + +Expected implementation touchpoints: + +- Add `plugins/sql-apps/skills/sql-apps-frontend-design/SKILL.md` using the plugin's existing skill frontmatter, portable-project rules, and focused length limit. +- Add a narrowly scoped route to the design skill in `plugins/sql-apps/skills/sql-apps-application/SKILL.md`. +- Add a short note to `docs/guides/build-your-app.md` so users discover the design guidance when asking an assistant to implement screens. +- Update `scripts/check-plugin.mjs` and `tests/plugin.test.ts` for the explicit skill inventory and bundle contents. +- Update relevant assertions in `tests/guide.test.mjs` and `tests/plugin.test.ts` to protect the skill metadata, routing, visual review criteria, and packaging behavior. + +Run the focused plugin and guide tests, followed by the repository's relevant validation if needed. Do not modify the foundation's runtime UI, change runtime dependencies, or alter unrelated worktree changes as part of this skill addition. + +## Assumptions and boundaries + +- The skill is part of the SQL Apps plugin source, not a separately installed global skill and not an exported skill copy inside an application checkout. +- Skill routing is additive: non-UI SQL Apps work continues to use the existing skills, and the application skill remains authoritative for SQL Apps-specific implementation and safety. +- Browser inspection is required when feasible but must not trigger unsafe service startup, unapproved network access, or a false claim of end-to-end validation. From a0c0d6eb75c3b2809bc32e296f9bf0ba11f5553c Mon Sep 17 00:00:00 2001 From: itsnotaboutthecell <44716363+itsnotaboutthecell@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:28:52 -0500 Subject: [PATCH 2/2] feat: add role-based data profile and frontend design Add the role-based data profile, deployment examples, and frontend design guidance. Update local workflows, plugin packaging, docs, and tests; ignore development-only superpowers artifacts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 1 + README.md | 4 +- azure-role-based-cost.example.json | 6 + azure-role-based.example.json | 17 ++ docs/README.md | 1 + docs/guides/build-your-app.md | 6 +- docs/guides/costs.md | 2 +- docs/guides/getting-started.md | 2 +- docs/guides/run-locally.md | 30 +++- docs/guides/sharing.md | 13 +- docs/reference/copilot-plugin.md | 4 +- docs/reference/deployment.md | 10 ++ docs/reference/guide.md | 8 +- docs/reference/local-development.md | 10 +- docs/reference/role-based-data.md | 112 ++++++++++++ ...2026-10-09-frontend-design-skill-design.md | 91 ---------- infra/main.bicep | 77 +++++---- plugins/sql-apps/scripts/sql-apps.mjs | 16 +- .../skills/sql-apps-application/SKILL.md | 24 ++- .../skills/sql-apps-cloud-preview/SKILL.md | 22 ++- .../skills/sql-apps-frontend-design/SKILL.md | 22 +++ .../sql-apps/skills/sql-apps-local/SKILL.md | 61 ++++--- role-based-data.example.json | 5 + scripts/check-plugin.mjs | 13 +- scripts/install-plugin.mjs | 2 +- scripts/setup-check.mjs | 10 +- sql/database.sqlproj | 4 + sql/grant-runtime.sql | 10 +- src/artifacts.ts | 19 +- src/auth.ts | 6 +- src/cli.ts | 39 ++++- src/config.ts | 34 +++- src/deployment.ts | 108 ++++++++++-- src/gateway.ts | 15 +- src/local-app.ts | 45 ++++- src/local-cli.ts | 23 ++- src/local-ownership.ts | 8 +- src/local-startup.ts | 6 +- src/local.ts | 32 ++-- src/maintenance.ts | 2 + src/role-based-cost.ts | 40 +++++ src/role-based-identity.ts | 47 +++++ src/role-based-profile.ts | 81 +++++++++ src/role-based-smoke.ts | 19 ++ src/server.ts | 6 +- tests/guide.test.mjs | 2 +- tests/plugin.test.ts | 63 ++++++- tests/role-based-data.test.ts | 139 +++++++++++++++ tests/role-based-deployment.test.ts | 162 ++++++++++++++++++ tests/role-based-infra.test.mjs | 55 ++++++ tests/setup.test.mjs | 12 ++ 51 files changed, 1292 insertions(+), 254 deletions(-) create mode 100644 azure-role-based-cost.example.json create mode 100644 azure-role-based.example.json create mode 100644 docs/reference/role-based-data.md delete mode 100644 docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md create mode 100644 plugins/sql-apps/skills/sql-apps-frontend-design/SKILL.md create mode 100644 role-based-data.example.json create mode 100644 src/role-based-cost.ts create mode 100644 src/role-based-identity.ts create mode 100644 src/role-based-profile.ts create mode 100644 src/role-based-smoke.ts create mode 100644 tests/role-based-data.test.ts create mode 100644 tests/role-based-deployment.test.ts create mode 100644 tests/role-based-infra.test.mjs diff --git a/.gitignore b/.gitignore index 563f771..13a6812 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ sql/bin/ sql/obj/ examples/**/obj/ examples/**/bin/ +/docs/superpowers/ diff --git a/README.md b/README.md index f1b8a9b..bc063c9 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,9 @@ The check explains missing prerequisites without installing anything. The guides **Your application.** Work with your AI assistant to implement and test its screens and SQL behavior using the existing foundation. This is not a one-command generator. The default foundation has no Todo screen or sample data; examples are selected explicitly. -**A useful first result.** Run the app, create a record, reload it, and make a change such as adding a field or filter. [Run locally](docs/guides/run-locally.md) explains the available startup paths and how to keep your data between sessions. +**A useful first result.** Run the app, create a record, reload it, and make a change such as adding a field or filter. Implemented, built, running and workflow-verified are separate milestones; a launch URL is usable only after the intended server responds. [Run locally](docs/guides/run-locally.md) explains startup paths, scoped approvals and how to keep your data between sessions. -**Sharing is optional.** Azure is the cloud target. Free tier means recurring allowances, not trial credits or unlimited usage. The current templates also include paid resources, and the minimal public-demo deployment workflow is not yet complete. Read [Sharing your app](docs/guides/sharing.md) and [Costs and growth](docs/guides/costs.md) before choosing a cloud path. +**Sharing is optional.** Azure is the cloud target. Free tier means recurring allowances, not trial credits or unlimited usage. The explicit [role-based-data profile](docs/reference/role-based-data.md) starts SQL/DAB/browser locally and selects matching authenticated Azure resources without file/job services. Its private-network SQL deployment is paid and still needs live acceptance; the full foundation retains its existing services. The minimal public-demo workflow is incomplete, and its cost review is demo-only. Read [Sharing your app](docs/guides/sharing.md) and [Costs and growth](docs/guides/costs.md) before choosing a cloud path. Existing Azure resources are collision information, not permission to replace your new app with an older deployment. ## Find your next step diff --git a/azure-role-based-cost.example.json b/azure-role-based-cost.example.json new file mode 100644 index 0000000..fd2a6b2 --- /dev/null +++ b/azure-role-based-cost.example.json @@ -0,0 +1,6 @@ +{ + "version": 1, + "profile": "role-based-data", + "intent": "zero-azure-spend", + "acknowledgeFixedCharges": false +} diff --git a/azure-role-based.example.json b/azure-role-based.example.json new file mode 100644 index 0000000..0d47c25 --- /dev/null +++ b/azure-role-based.example.json @@ -0,0 +1,17 @@ +{ + "profile": "role-based-data", + "requiredRole": "AppUser", + "readinessPath": "/api/AppReady", + "subscriptionId": "00000000-0000-0000-0000-000000000000", + "tenantId": "00000000-0000-0000-0000-000000000000", + "apiClientId": "00000000-0000-0000-0000-000000000000", + "sqlAdminObjectId": "00000000-0000-0000-0000-000000000000", + "sqlAdminName": "deployment-operator", + "resourceGroup": "data-app-dev", + "location": "eastus", + "environment": "dev", + "name": "data-app", + "gatewayImage": "yourregistry.azurecr.io/app-gateway:0.1.0", + "dabImage": "yourregistry.azurecr.io/app-data:0.1.0", + "registryServer": "yourregistry.azurecr.io" +} diff --git a/docs/README.md b/docs/README.md index 7670b6e..c49e9dc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ To try something already implemented, use the [Todo reference app](../examples/t ## Command and configuration reference - [Local runtime](reference/local-development.md): services, workspace settings, diagnostics and recovery. +- [Role-based-data profile](reference/role-based-data.md): SQL/DAB/browser startup, application-role authorization, matching costs and private SQL Azure deployment. - [Copilot plugin](reference/copilot-plugin.md): installation, binding and troubleshooting. - [Guide command](reference/guide.md): saved brief and checkpoint format. - [Demo cost review](reference/demo-cost.md): offline cost command and SQL billing settings. diff --git a/docs/guides/build-your-app.md b/docs/guides/build-your-app.md index db92048..563bdc6 100644 --- a/docs/guides/build-your-app.md +++ b/docs/guides/build-your-app.md @@ -33,6 +33,10 @@ Complete [Get started](getting-started.md) before downloading dependencies or la Your AI assistant can help write your frontend, API and schema; SQL Apps does not generate a complete domain app automatically. Review its changes and use the startup path for your application in [Run locally](run-locally.md). Trying the Todo reference is optional, not a prerequisite. +For substantial new screens or a visual redesign, ask your assistant to use the `sql-apps-frontend-design` skill for an app-appropriate visual direction and browser review of the rendered interface. + +Check capability/access support early: the [role-based-data profile](../reference/role-based-data.md) provides SQL/DAB/browser startup with trusted application-role forwarding and a matching paid private-SQL Azure path. Domain screens, procedures and actual workflow acceptance still need implementation. The full foundation retains files/jobs; do not add excluded services or switch to anonymous access just to fit a template. + In the browser, create a test record and reload the page. Check that it is still there. Try a missing required field and, if the app has different users, check who can see or edit the record. Use synthetic data while developing. ## 3. Make one useful change @@ -51,7 +55,7 @@ Keep a short description of the agreed app and next change. You can ask your AI npm run guide ``` -This shows saved decisions and a suggested next step. Saved check results are historical; it does not start the app or check that running services are healthy. After a code change, run the affected workflow again. See the [guide command reference](../reference/guide.md) if you want to manage the saved brief yourself. +This shows saved decisions and a suggested next step. Saved check results are historical; it does not start the app or check that running services are healthy. Record launch evidence under `run-locally`, not `describe`, and update an already completed `nextChange` rather than repeating it. Distinguish completed setup, running services and outstanding browser acceptance. After a code change, run the affected workflow again. See the [guide command reference](../reference/guide.md). ## 4. Share when it is useful diff --git a/docs/guides/costs.md b/docs/guides/costs.md index e87ff63..4f218cd 100644 --- a/docs/guides/costs.md +++ b/docs/guides/costs.md @@ -16,7 +16,7 @@ Azure SQL and Container Apps have free allowances, but the current demo template If zero Azure spending is a requirement, stay local until you have reviewed a hosting design that meets it. SQL Apps does not currently provide a guaranteed zero-cost deployment. -Before creating resources, review the target region, subscription eligibility, fixed charges, expected usage and cleanup plan. The [offline demo cost command](../reference/demo-cost.md) identifies known charges without contacting Azure; it is not a price quote or a spending cap. +Before creating resources, select the approved capability/access profile, then review the target region, subscription eligibility, fixed charges, expected usage and cleanup plan. The [offline demo cost command](../reference/demo-cost.md) identifies known demo charges without contacting Azure; it is demo-only, not an authenticated app cost model, price quote or spending cap. Role-authorized data-only apps use the matching [role-based-cost review](../reference/role-based-data.md#profile-specific-azure-preparation); do not price an older stack or substitute the full foundation as though it were the agreed app. ## What happens when an allowance runs out? diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 8d1a239..45a1166 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -77,7 +77,7 @@ The Azure SQL Database container is in **private preview**. [Request access](htt If a usable image is cached, you do not need to download it again just to refresh it. Startup verifies the actual SQL engine. If preview access is not available yet, keep your completed setup and resume when it is; do not substitute a different database. -SQL startup accepts the container EULA using `ACCEPT_EULA=Y`. Review the preview's terms before starting it. +Startup passes `ACCEPT_EULA=Y`; there is no chat dialog. Review the [container documentation/access instructions](https://aka.ms/azuresqldb-container) and applicable terms supplied with your preview registry access before approving SQL startup under those terms. If those terms are unavailable, pause rather than assume acceptance. This approval does not cover other licenses or later operations. ## 5. Choose your next step diff --git a/docs/guides/run-locally.md b/docs/guides/run-locally.md index 5a98012..479b284 100644 --- a/docs/guides/run-locally.md +++ b/docs/guides/run-locally.md @@ -6,12 +6,36 @@ Run a browser app backed by SQL on your own computer. No Azure subscription is r | What you are working on | Startup path | | --- | --- | -| Your own app | Follow [Build your app](build-your-app.md). Use the foundation startup below once its screens and schema are implemented. | +| Your own data-only app | Follow [Build your app](build-your-app.md) and the data-only steps below; do not start excluded file/job services. | +| Your own app with files/jobs | Use the foundation startup below after implementing its screens, schema and authorization. | | The included Todo reference | Follow the [Todo guide](../../examples/todo/README.md). It starts only SQL, DAB and the browser/API. | | The foundation's file-processing demonstration | Use the startup below. It also starts local storage and Functions. | The foundation is not a finished inventory or registration app. Its default browser demonstrates files and jobs; your domain screens need to be implemented. +## Approvals and readiness + +Before the first approval, explain the remaining stages: needed tool installations, dependency restore, build, workspace initialization, container downloads/builds, SQL terms/schema/startup, then synthetic acceptance writes. Approve only the named operations and target; approving `npm ci` does not approve build, and workspace initialization does not approve launch. Previously approved operations need not be approved again unless their scope changes. + +If the approval control first returns "user unavailable" or is invisible, no approval was captured. Stop retrying that control. The assistant should provide one precise statement with the actual checkout, operations, effects and exclusions that you can send in ordinary chat. Wait for explicit consent before continuing. + +Startup passes `ACCEPT_EULA=Y`; there is no chat dialog. Review the [container documentation/access instructions](https://aka.ms/azuresqldb-container) and applicable preview terms supplied with your registry access. Explicitly approving SQL startup under those terms permits the launcher to pass that value; it is not approval for other licenses. + +Report **implemented**, **built**, **running** and **workflow verified** separately. A proposed port/URL is unavailable until the intended server responds and readiness checks pass. Only then open/publish the launch URL. Verify the agreed browser save/reload action and SQL persistence separately; tests or a successful build do not replace it. If launch is blocked, the task is blocked, not complete. + +## Run a data-only domain app + +For a role-authorized domain app, use the [role-based-data profile](../reference/role-based-data.md): configure its dedicated application role and read-only readiness procedure, then use `npm run local -- role-based-app` after scoped build/workspace/startup approval. It starts SQL/DAB/browser only and explicitly labels authorized/unauthorized user simulations; `role-based-serve` resumes it. Real Entra sign-in is used in Azure, not local simulation. + +For advanced SQL-only work without this role-based profile, the existing individual steps remain available, with the scoped approvals above: + +1. Restore/build as needed and select the workspace using `workspace-plan` and approved `workspace-init`. +2. Use `npm run local -- start-sql`, or `verify ` for explicitly approved reuse. +3. Use `npm run local -- init` and `npm run local -- data`. +4. Start the gateway with `npm run local -- serve-sql`. + +Pass the same optional SQL container name throughout when selecting a non-default container. `serve-sql` omits file/job adapters but does not generate domain screens, custom application roles or app-specific readiness checks. Remove excluded controls and verify the application's actual browser action, authorization and SQL save/reload. Do not run `app`, `services` or file-processing acceptance for this scope. The selected synthetic reference is not a role-authorized substitute. + ## Start the foundation From the project folder: @@ -31,7 +55,7 @@ npm run local -- app Workspace selection keeps this checkout's service names, ports and saved state separate. Startup downloads missing images, accepts the SQL container EULA, publishes the project schema and launches services. Read the [local runtime reference](../reference/local-development.md) before reusing an existing stack or database. -Open the browser URL printed in the terminal. For the foundation demonstration, choose Development Alice, upload a small text file and select **Process file**. The completed job shows byte count and SHA-256. Development Bob has a separate view of files and jobs. +After the intended server responds and `/health/ready` passes, open its reported browser URL. For the approved foundation file/job demonstration, choose Development Alice, upload a small text file and select **Process file**. The completed job shows byte count and SHA-256. Development Bob has a separate view of files and jobs. These local identities are simulations, not production sign-in. Keep the local app and DAB on loopback; do not expose them to other computers. @@ -53,7 +77,7 @@ To stop the foundation worker and storage without deleting their data: npm run local -- stop-services ``` -After stopping services, use `app` to start them again. Selected-reference commands have their own stop/resume steps in the Todo guide. Do not delete volumes or saved credentials to restart an app. +After stopping foundation services, use `app` to start them again. Data-only apps resume with `serve-sql` against running SQL/DAB, not `serve` or `app`. Selected-reference commands have their own stop/resume steps in the Todo guide. Do not delete volumes or saved credentials to restart an app. ## When something fails diff --git a/docs/guides/sharing.md b/docs/guides/sharing.md index d0d81ce..e9aa8fe 100644 --- a/docs/guides/sharing.md +++ b/docs/guides/sharing.md @@ -8,13 +8,14 @@ Before moving a local app online, decide **who should use it** and **what inform | --- | --- | | Keep using the app on your computer | Stay local. No cloud deployment is needed. | | Let people try a public app with synthetic data | The minimal public-demo workflow is still in development. Local reference testing, image assembly, diagnostics and templates exist, but there is no completed guided deployment command. | -| Share an authenticated app with real users | The full Azure deployment commands are available. They require Entra setup, paid infrastructure and a privately connected SQL deployment runner. | +| Share an authenticated app with the full foundation capabilities | Full Azure commands require Entra setup, paid Functions/storage/Key Vault/private-network infrastructure and a privately connected SQL deployment runner. | +| Share a role-authorized data-only domain app | Use the [role-based-data profile](../reference/role-based-data.md): matching cost review, two-image artifacts, authorized identity/assignment and private SQL deployment. Paid infrastructure and live authorization/browser acceptance are still required. | Do not put real or sensitive information in the anonymous synthetic-data reference. ## Review costs first -Read [Costs and growth](costs.md). The goal is free-tier-first, but the current templates include recurring charges. A cost review is a decision step, not permission to spend or deploy. +Select the agreed capability/access profile before choosing a cost command. Read [Costs and growth](costs.md). The goal is free-tier-first, but current templates include recurring charges. A cost review is a decision step, not permission to spend or deploy. For a public-demo cost review after building: @@ -22,12 +23,16 @@ For a public-demo cost review after building: npm run azure -- demo-cost azure-demo-cost.example.json ``` -The zero-spend example reports known fixed-charge blockers and exits 2. This is expected; it does not create resources. See the [cost command reference](../reference/demo-cost.md) to interpret its output. +The zero-spend example reports known fixed-charge blockers and exits 2. This is expected; it does not create resources. Its output is demo-only, not a price estimate for an authenticated app. Review the authenticated application's actual resources/prices separately; missing estimates are unknown, not zero. See the [cost command reference](../reference/demo-cost.md). ## Prepare an authenticated deployment If this is the path you need, follow the [deployment reference](../reference/deployment.md). You will review your Azure target, identities, images, networking, schema and costs before creating resources. -After deployment, test sign-in, each user's access, saved data, file behavior and recovery in that environment. Local tests and template compilation do not prove those cloud behaviors. +If the approved app excludes files/jobs, select `role-based-data` rather than expanding to the full template or changing authorized access to an anonymous demo. Its `identity` command creates the configured human role and `role-based-assign` manages explicitly approved assignments; the foundation profile still creates `Function.Invoke`. Verify token claims and trusted DAB forwarding in the real environment. + +Existing-resource discovery must not change the deployment subject. Older matching Azure resources are potential collisions and remain untouched. Keep the new checkout and its artifacts as the source unless reuse is explicitly requested and separately reviewed. Preparation is not permission to create, modify or delete resources. + +After an approved deployment, test sign-in, each user's access, saved data, selected capabilities and recovery in that environment. Local tests and template compilation do not prove those cloud behaviors. For the evolving minimal public-demo path, use [public-demo preparation](../reference/demo-deployment.md). It is a technical reference, not a shortcut around the incomplete deployment workflow. diff --git a/docs/reference/copilot-plugin.md b/docs/reference/copilot-plugin.md index 86e4802..5f44834 100644 --- a/docs/reference/copilot-plugin.md +++ b/docs/reference/copilot-plugin.md @@ -51,6 +51,8 @@ For building an app: **"Help me describe, run and change a useful SQL Apps app l The local marketplace loads from disk (`source: live` in CLI output). Edit the bundled skills or launcher, run `npm run plugin:check`, then start a fresh session/restart to load changes. No Git push or plugin update is needed for this directory-backed source. After TypeScript runtime changes, separately build/restart the application as documented. +Keep three paths distinct: **loaded skill source** (the actual marketplace/skill location), **installed launcher** (resolved relative to that skill), and **application checkout** (selected by `home`/`SQL_APPS_HOME`). Exported skill copies in another application are not automatically active. Editing them does not change the installed plugin; confirm the loader source with discovery output before editing and reload a fresh session afterward. Changing the runtime home does not change loaded instructions. + If the App does not show the plugin, check the profile used to launch it. `COPILOT_HOME` can select a different configuration directory. Register the local checkout through the App's marketplace controls if available, or launch the App from the same configured CLI/profile (`copilot app`). Do not copy or overwrite the App's settings. Verify discovery with: ```powershell @@ -83,7 +85,7 @@ node "C:\git\sql-apps\plugins\sql-apps\scripts\sql-apps.mjs" setup-check `guide` is also dependency-free and available before the runtime is built. It returns JSON with the four stages, saved brief, suggested next action and explicitly historical progress; it does not probe live services. `guide-save ""` validates and saves the agreed brief in `.sql-apps/guide.json` in the bound checkout. Both enforce the same runtime-home mismatch checks. Source changes flag saved progress for local re-verification. See [the brief contract](guide.md); do not include credentials or actual data. -The guide also introduces cost awareness progressively; optional saved `costPreference` is not spending approval. The cloud-preview skill uses the offline `demo-cost` review before Azure requests. A zero-spend intent stays blocked while the minimal template has registry/network fixed charges; budgets are alerts, not spending caps. See [costs and paid growth](../guides/costs.md). +The guide also introduces cost awareness progressively; optional saved `costPreference` is not spending approval. The cloud-preview skill selects the approved application profile first. Offline `demo-cost` is synthetic-demo-only; `role-based-cost` matches the [role-based-data profile](role-based-data.md), whose `role-based-app`/`role-based-serve` commands are also exposed by the launcher. A zero-spend intent stays blocked while the minimal template has registry/network fixed charges; budgets are alerts, not spending caps. See [costs and paid growth](../guides/costs.md). If the active checkout differs from the installed binding, runtime execution is refused until you explicitly select the intended home. This also applies when the active worktree lacks the uncommitted foundation. Review the report, then select one checkout for this terminal/session: diff --git a/docs/reference/deployment.md b/docs/reference/deployment.md index 3de5796..790fab2 100644 --- a/docs/reference/deployment.md +++ b/docs/reference/deployment.md @@ -2,6 +2,12 @@ Use this reference when deploying an authenticated application with the full Azure infrastructure template. Start with [sharing your app](../guides/sharing.md) to choose the appropriate path. For an existing pre-rebrand deployment, review [migration notes](../maintainers/naming-transition.md) first. +## Profile and deployment subject + +The default authenticated foundation path includes Functions, storage, Key Vault and private networking. The explicit [role-based-data profile](role-based-data.md) uses the same orchestration with two images, human application roles and private SQL, excluding file/job services. Do not add excluded services or use an anonymous demo without an agreed scope change. `demo-cost` remains demo-only; use `role-based-cost` for role-based-data and review actual regional/account prices separately. + +Keep the confirmed new application checkout/configuration/artifacts as the deployment subject. With read-only Azure authorization, discovered older resources are potential collisions, not permission to reuse or modify them. Reuse requires an explicit request and a separate compatibility/migration/cost review; existing data remains untouched. + ## Supported tools The project uses Azure CLI/Bicep for infrastructure and Microsoft.Build.Sql/SqlPackage for schema publishing. No custom Azure control-plane client or SQL migration engine is implemented. @@ -59,6 +65,10 @@ npm run azure -- identity sql-apps.json This is an explicit create operation, not an idempotent lookup by display name. It creates one single-tenant SPA/API registration, a delegated `access_as_user` scope, a `Function.Invoke` application role, and a service principal. Replace `apiClientId` in your real configuration with the printed ID. +For a `role-based-data` configuration, `identity` instead creates the configured human role such as `AppUser` with allowed member type `User`; `role-based-assign` explicitly assigns approved users/groups. Verify API access-token role claims, trusted gateway-selected DAB role, entity/procedure permissions and SQL grants end to end with `role-based-smoke` and browser acceptance. Local simulation and app-side role enforcement alone do not prove Entra/cloud authorization. + +An account email address is not a tenant ID. Confirm the intended directory's tenant ID, or retrieve it with an authorized read-only account query, before tenant-specific sign-in. Do not change subscription defaults or repeat generic login instructions to resolve that mismatch. + The registration requests v2 access tokens. The same registration is used by the browser and API. Initial local redirect: `http://localhost:8080`. Deployment adds its HTTPS origin without removing existing redirects. An Entra administrator may need to grant consent under your tenant's policy. Alternatively configure an existing dedicated registration with the same scope and application role: diff --git a/docs/reference/guide.md b/docs/reference/guide.md index 57687ee..a36d289 100644 --- a/docs/reference/guide.md +++ b/docs/reference/guide.md @@ -35,7 +35,7 @@ Example input: Required text fields are nonblank, at most 500 characters, without control characters. `records` and `actions` contain 1-12 unique nonblank strings. `capabilities` must include `data`; optional values are `files` and `background-jobs`. The existing file processor requires `files` with `background-jobs`. -These are scope decisions, not switches that dynamically compose services. The foundation `app` command still starts its full file/job stack; the explicitly selected Todo reference has a separate data-only startup. +These are scope decisions, not switches that dynamically compose services. The foundation `app` command still starts its full file/job stack. Role-authorized data apps use the configured [role-based-data profile](role-based-data.md), `role-based-app` and `role-based-serve`; `serve-sql` remains an advanced SQL-only gateway. The selected Todo reference has a separate synthetic-only startup. See [Run locally](../guides/run-locally.md). Optional `costPreference` is `zero-azure-spend` or `review-paid-costs`. It is never spending consent. @@ -55,6 +55,12 @@ Add it to the complete brief object. Stage is `describe`, `run-locally` or `make Saved evidence is **historical**, not current readiness. Source changes require rebuilding and verifying the actual app locally. A completed local checkpoint does not authorize installations, downloads or cloud deployment. +Use the actual stage: scope agreement is `describe`, browser launch/save/reload is `run-locally`, and verified changed behavior is `make-it-yours`. Include exact commands/results and separate agent-observed evidence from user-reported testing. Do not promote "I tested it" into a detailed agent-verified acceptance record. + +Before saving, replace a completed `nextChange` with the next agreed outstanding change or explicit text such as "No further change agreed"; the required text field cannot be omitted. Saving replaces the current brief/checkpoint, not an append-only history. Keep earlier evidence explicitly historical if retained; do not invent unsupported fields. + +`guide` does not model live launch state or detect whether `nextChange` is implemented. Its setup suggestion is not a demand to redo completed setup: reconcile it with verified setup, responding services and outstanding acceptance. Report implemented, built, running and workflow-verified separately. + ## Storage and failure handling The command saves version-1 state in `.sql-apps/guide.json`, including the canonical checkout path, source fingerprint, timestamp and brief. It uses a lock and atomic replacement. diff --git a/docs/reference/local-development.md b/docs/reference/local-development.md index b418243..37f1221 100644 --- a/docs/reference/local-development.md +++ b/docs/reference/local-development.md @@ -4,6 +4,14 @@ The local inner loop uses the Azure SQL Database container (Private Preview, **E For the shortest startup path, use [Run locally](../guides/run-locally.md). This reference covers individual services, workspace ownership, file processing and recovery. If you have a pre-rebrand checkout, read [migration notes](../maintainers/naming-transition.md). +## Capability-scoped startup + +The foundation `app` command starts SQL/DAB/storage/Functions/browser; saved brief capabilities do not turn services off. A role-authorized data application uses the [role-based-data profile](role-based-data.md), `role-based-app` and `role-based-serve` with the same explicit workspace/container selection. This stages SQL/schema/DAB/browser startup without storage/Functions and checks the configured procedure before listening. + +`serve-sql` remains an advanced SQL-only gateway, not the configured role-based-profile launcher. Implement domain UI/schema/procedures, configure authorized DAB permissions, remove excluded controls, and verify the real SQL procedure/browser save-reload flow. The selected reference path below is anonymous synthetic-only, not a role-based-app substitute. The [role-based-data Azure path](role-based-data.md#identity-artifacts-and-deployment) selects matching resources while preserving private SQL. + +Follow [scoped consent and readiness](../guides/run-locally.md#approvals-and-readiness). A proposed origin or successful build is not a running app. Publish the intended origin only after response/readiness probes, then report workflow acceptance separately. + ## Prerequisites First time installing development tools? Use [guided setup](../guides/getting-started.md) rather than treating this list as instructions. Copilot can offer approved installations, explain access/license conditions, guide restarts and verify each step on Windows, macOS or Linux. `node scripts/setup-check.mjs` runs before npm restore/build and never installs or mutates resources. @@ -43,7 +51,7 @@ After selection and startup approval: npm run local -- app ``` -Open the URL printed by startup (**http://127.0.0.1:18080** in legacy mode). Choose Development Alice or Development Bob and use file upload/list/download/delete, Function invocation and queued file processing. Switching users revokes the previous local session; each user sees only their own data, files and jobs. Reloading preserves the selected session within the browser tab; SQL rows and Blob files persist independently of browser sessions. +After the intended server responds and `/health/ready` passes, open the URL printed by startup (**http://127.0.0.1:18080** in legacy mode). For approved foundation file/job scope, choose Development Alice or Development Bob and use file upload/list/download/delete, Function invocation and queued file processing. Switching users revokes the previous local session; each user sees only their own data, files and jobs. Reloading preserves the selected session within the browser tab; SQL rows and Blob files persist independently of browser sessions. `app` starts/reuses the project-owned SQL container, publishes the schema, runs SQL security checks, recreates local DAB, starts persistent Azurite, builds/starts the local Functions image, and serves the existing frontend and gateway on a single loopback origin. It stays in the foreground. Ctrl+C stops the browser gateway; the service containers continue running. After restarting the browser server, select a user again because local sessions are held only in server memory. diff --git a/docs/reference/role-based-data.md b/docs/reference/role-based-data.md new file mode 100644 index 0000000..38fae4a --- /dev/null +++ b/docs/reference/role-based-data.md @@ -0,0 +1,112 @@ +# Role-based data-only applications + +Use the explicit `role-based-data` profile for an implemented domain application that needs SQL/DAB/browser access restricted to users assigned the required role, without files or background jobs. It is a runtime/deployment path, not an application generator. Keep the original foundation and unrelated databases unchanged; use the intended application checkout. + +This is application role-based access control: Entra authenticates users, the gateway requires the configured application role, and DAB enforces procedure permissions. It is not Azure resource-management RBAC and does not automatically provide row-level security (RLS). Implement row/owner restrictions separately where the application requires them. `AppUser` is an example role; choose a role appropriate to your application. + +## Application contract + +Keep `application.json` named for your app with `selectedExamples: []`. Implement its domain screens and SQL procedures. Copy `role-based-data.example.json` to `role-based-data.json` in that checkout and choose: + +- `requiredRole`: a dedicated human role such as `AppUser`; reserved foundation/anonymous roles are rejected. +- `readinessPath`: one `/api/` REST endpoint backed by an approved read-only procedure with no required request parameters. It must check the domain schema/access needed to operate, not substitute a success-shaped response for real readiness. + +Configure `dab/dab-config.json` with explicit stored-procedure entities and role-authorized `execute` permissions. Autoentities, foundation FileJob entities and anonymous/processor permissions are rejected. Source objects use `schema.procedure` identifiers. A readiness entity looks like: + +```json +"AppReady": { + "source": { "object": "dbo.AppReady", "type": "stored-procedure" }, + "rest": { "enabled": true, "methods": ["get"] }, + "graphql": false, + "permissions": [{ "role": "AppUser", "actions": ["execute"] }] +} +``` + +Implement the procedure in the app's SQL project before startup. Other authorized procedures can expose approved GET/POST operations. DAB defaults procedures to POST, so explicitly enable GET on the read-only readiness procedure. Permission grants are generated only for the validated procedures (`EXECUTE` and object-scoped `VIEW DEFINITION` for DAB metadata); no schema-wide execute or database-owner grant is added. + +For cloud delivery, preserve production `AzureAD` DAB authentication, `/api` REST routing, and JWT settings `@env('API_CLIENT_ID')` / `@env('ENTRA_ISSUER')`. The local launcher generates a separately saved AppService simulation configuration; never deploy that configuration. Hide excluded UI controls; the authorized gateway does not register file/job/function routes. + +## Local startup and acceptance + +Follow [scoped consent](../guides/run-locally.md#approvals-and-readiness). Dependency restore, build, workspace selection, SQL terms/downloads/schema/startup and synthetic acceptance are distinct approval scopes. + +Before build, `node scripts/setup-check.mjs --profile role-based-data` checks prerequisites and only selected gateway/DAB ports; the installed launcher exposes the same diagnostic as `role-based-setup-check`. It does not install tools or start services. + +After an approved build and explicit workspace selection: + +```powershell +npm run local -- role-based-app +``` + +An optional second argument selects an explicitly approved existing SQL container. Startup runs SQL, schema publishing and DAB, then checks the actual readiness procedure before listening on the loopback gateway. It does not start Azurite or Functions or run foundation file/job acceptance. Port ownership checks include only selected services. It preserves data and reports the failed stage rather than resetting volumes. + +Development Alice is clearly labeled a simulated user with the required role; Development Bob is a simulated user without it. Sessions are server-issued; caller role/principal headers cannot grant access. The gateway forwards the configured DAB role only for verified authorized sessions. This proves local routing, not real Entra assignment. + +Open only the responding workspace origin after readiness passes. Verify an agreed browser action, SQL persistence/reload, invalid input and applicable owner rules, plus Bob's denial. These are separate from startup readiness. Ctrl+C stops the gateway; SQL/DAB data remain. Resume with: + +```powershell +npm run local -- role-based-serve +``` + +Use the existing approved `stop` command to stop only the owned DAB container when needed; never use `stop-services` for this profile or delete SQL volumes to resume. + +## Profile-specific Azure preparation + +Copy `azure-role-based.example.json` to an application-specific configuration, fill the exact tenant/subscription/target and immutable version image choices, and retain `profile`, `requiredRole` and `readinessPath` consistent with the local app. No Functions image is accepted. Placeholder identifiers in the example are not valid deployment targets. + +Review costs offline: + +```powershell +npm run azure -- role-based-cost azure-role-based-cost.example.json +``` + +The zero-spend example exits **2** because this path uses paid provisioned S0 SQL and private infrastructure. To review paid costs, explicitly choose `review-paid-costs` and acknowledge fixed charges in a separate cost configuration. The report is not a quote, eligibility check, spending cap or deployment approval. It describes the actual profile: two Consumption apps with minimum one replica each, S0 SQL, the configured registry, SQL private endpoint/DNS, managed networking and Log Analytics. Registry tier and regional prices require review. `demo-cost` is not this profile's cost model. + +With separate target/read-only authorization, validate the app/configuration and review what-if. No command silently selects discovered older resources; treat them as collisions unless reuse is explicitly requested. + +## Identity, artifacts and deployment + +After explicit approval for each relevant cloud-write scope: + +```powershell +npm run azure -- identity azure-role-based.json +``` + +This creates a new single-tenant registration/service principal with delegated `access_as_user` and the configured human application role (allowed member type `User`), not `Function.Invoke`. Set `apiClientId` to its printed identifier. Existing registrations need an enabled equivalent application role; creation does not patch unrelated registrations. + +Assign only approved authorized users/groups by object ID: + +```powershell +npm run azure -- role-based-assign azure-role-based.json +``` + +This verifies the configured tenant, role and assignment, and avoids duplicating an existing assignment. Directory permissions/consent are separate from Azure RBAC. Acquire a fresh delegated API access token after assignment. + +The existing orchestration now selects resources by profile: + +```powershell +npm run azure -- validate azure-role-based.json +npm run azure -- artifacts azure-role-based.json +npm run azure -- plan azure-role-based.json +npm run azure -- provision azure-role-based.json +npm run azure -- deploy azure-role-based.json +npm run azure -- status azure-role-based.json +``` + +`validate` is offline. `artifacts` builds/publishes exactly gateway and DAB images from this checkout and saves digests only after both succeed. `plan` uses Azure/Graph reads and ARM what-if; obtain separate authorization. `provision`/`deploy` create paid resources. ACR must already exist at the explicitly selected target; creating it is a separate approved operation. + +SQL remains Entra-only with its public endpoint disabled. The deployment operator needs private runner connectivity and database publishing authorization. The template retains SQL private endpoint/DNS but excludes Functions, storage, Key Vault, their identities/grants/endpoints and the Functions subnet. Schema publishing is non-destructive and grants the DAB managed identity only approved procedure permissions. + +Role-based deployment persists infrastructure/schema/runtime/readiness stages and resumes an unchanged configuration from the saved stage. State is bound to the canonical application checkout and SQL/DAB source fingerprint. Changed configuration/source, copied or malformed state fails rather than resetting state or transferring deployment to an older app. Runtime HTTP readiness is not authorization acceptance or a production-release claim. + +## Live cloud acceptance + +In a trusted process, set `SQL_APPS_USER_TOKEN` to an authorized delegated API token and `SQL_APPS_SECOND_USER_TOKEN` to a valid delegated token without the required role for the same tenant/API. Never paste tokens into chat or commit them. + +```powershell +npm run azure -- role-based-smoke azure-role-based.json +``` + +This checks real authorized procedure execution, denial for valid users without the required role and anonymous denial, including caller-forged role headers. It does not create domain records. Then verify real browser sign-in, the agreed order/record save and reload, owner rules, validation and persistence in Azure. Report implemented, built, running and workflow-verified separately; retain actual commands/results and outstanding acceptance gaps. + +Compiled templates, mocked Azure calls and local simulation do not prove live Entra, private-network SQL, image startup or domain workflows. No live deployment is implicit in implementation or documentation updates. diff --git a/docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md b/docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md deleted file mode 100644 index 3e2212f..0000000 --- a/docs/superpowers/specs/2026-10-09-frontend-design-skill-design.md +++ /dev/null @@ -1,91 +0,0 @@ -# SQL Apps Frontend Design Skill - -## Problem - -SQL Apps helps assistants build custom browser applications, but its application guidance focuses on domain scope, data boundaries, runtime behavior, and validation. It gives little practical direction for visual identity, hierarchy, responsive layouts, or visual iteration. As a result, assistants can deliver functionally complete interfaces that look generic or unfinished. - -The SQL Apps foundation browser is intentionally a functional example, not the design target for every app. A new skill should help users and assistants create distinctive, usable interfaces for the domain app they are building without turning SQL Apps into a theme or template system. - -## Goals - -- Improve visual craft and usability of custom applications built with SQL Apps. -- Fit the existing SQL Apps skill/plugin conventions and application workflow. -- Preserve freedom to use an app-appropriate visual identity and the existing project stack. -- Make browser-based visual review an explicit acceptance activity when feasible. -- Keep functional, data, access, and deployment decisions within the existing SQL Apps application workflow. - -## Non-goals - -- Redesigning `public/` or the shipped foundation browser. -- Creating a universal SQL Apps theme, component library, or domain-specific starter template. -- Mandating a frontend framework, CSS library, typography, palette, or design trend. -- Replacing the application skill's SQL, authorization, safety, or runtime guidance. -- Treating a successful build, source inspection, or generated screenshot as proof of end-to-end app behavior. - -## Proposed approach - -Add a focused `sql-apps-frontend-design` skill to the SQL Apps plugin. Route custom UI creation and visual-polish tasks to it from the existing `sql-apps-application` skill, and add a short discovery cue to `docs/guides/build-your-app.md`. Update the repository's explicit plugin skill inventory, packaging check, and targeted tests so the skill is included and validated with the existing bundle. - -This keeps the app workflow authoritative for SQL Apps-specific boundaries and gives visual design a focused reusable workflow. It is preferred over expanding the app skill into a catch-all and over a shared visual system that could make unrelated custom apps look alike. - -## Skill responsibilities and workflow - -### 1. Understand the interface being designed - -- Inspect the existing application and its established visual patterns before proposing changes. -- Understand the intended users, primary task, important information, and device context from the app brief. Ask only for missing decisions that materially affect the interface. -- For substantial new interfaces, present two or three app-appropriate visual directions with concise trade-offs and recommend one. Obtain user approval of the direction before implementation. -- For small extensions or refinements, follow the existing app's visual language rather than adding an approval ceremony. -- Do not make users choose a framework or cloud architecture as a prerequisite to visual design. - -### 2. Establish an app-specific design direction - -Translate the approved direction into practical choices for page composition, information hierarchy, typography, color, spacing, density, and imagery or illustration when useful. Avoid generic dashboard patterns or decorative effects without a purpose. Respect an existing product brand when present; do not impose a SQL Apps theme on the app. - -Identify the key populated, empty, loading, error, disabled, and success states applicable to the requested workflow. Do not add states or controls for capabilities the app does not have. - -### 3. Implement within existing project constraints - -- Use the existing frontend stack, assets, and dependencies where suitable. Do not add packages or fetch remote fonts/images just for appearance without user approval. -- Keep the implementation semantic, responsive, and consistent with the established app structure. -- Preserve existing workflow, data, identity, authorization, and capability boundaries; route SQL Apps-specific questions to the application skill rather than inventing behavior. -- Prefer clear focus states, sufficient contrast, keyboard-operable controls, meaningful labels, and respect for reduced-motion preferences. Do not trade usability or accessibility for visual novelty. - -### 4. Review the rendered interface and iterate - -When a local browser preview is available, run the app using its documented project workflow and inspect the actual rendered UI at a desktop viewport and a narrow mobile viewport. Review the primary screen and important populated/empty/loading/error states that are feasible to reach. Fix meaningful issues in hierarchy, spacing, text wrapping, responsive behavior, affordance clarity, and visual consistency, then inspect the changed render again. - -Use visual browser tools when available. Otherwise use the project's supported preview and screenshot mechanism. Do not start unrelated services or use production data just to obtain a preview. If browser review cannot be performed safely or feasibly, state that limitation explicitly and report what was checked instead; do not claim visual acceptance based only on source code or a successful build. - -### 5. Report evidence and remaining gaps - -Summarize the design direction and significant interface decisions, list the viewports and states actually inspected, describe any iterations made, and clearly identify visual or accessibility checks that remain unverified. Preserve the application skill's separate requirements for functional workflow, persistence, access, and SQL validation; visual inspection does not replace them. - -## Quality bar - -A completed UI change should: - -- Communicate an intentional, app-appropriate visual identity rather than an unexamined default. -- Make the primary task and information hierarchy apparent. -- Present relevant interaction states and content at the right level of visual emphasis. -- Remain usable at desktop and narrow mobile sizes without clipping or awkward overflow. -- Provide keyboard access, visible focus, understandable labels, sufficient contrast, and reduced-motion handling where motion is present. -- Be visually inspected in a running browser when feasible, with limitations reported honestly. - -## Integration and validation - -Expected implementation touchpoints: - -- Add `plugins/sql-apps/skills/sql-apps-frontend-design/SKILL.md` using the plugin's existing skill frontmatter, portable-project rules, and focused length limit. -- Add a narrowly scoped route to the design skill in `plugins/sql-apps/skills/sql-apps-application/SKILL.md`. -- Add a short note to `docs/guides/build-your-app.md` so users discover the design guidance when asking an assistant to implement screens. -- Update `scripts/check-plugin.mjs` and `tests/plugin.test.ts` for the explicit skill inventory and bundle contents. -- Update relevant assertions in `tests/guide.test.mjs` and `tests/plugin.test.ts` to protect the skill metadata, routing, visual review criteria, and packaging behavior. - -Run the focused plugin and guide tests, followed by the repository's relevant validation if needed. Do not modify the foundation's runtime UI, change runtime dependencies, or alter unrelated worktree changes as part of this skill addition. - -## Assumptions and boundaries - -- The skill is part of the SQL Apps plugin source, not a separately installed global skill and not an exported skill copy inside an application checkout. -- Skill routing is additive: non-UI SQL Apps work continues to use the existing skills, and the application skill remains authoritative for SQL Apps-specific implementation and safety. -- Browser inspection is required when feasible but must not trigger unsafe service startup, unapproved network access, or a false claim of end-to-end validation. diff --git a/infra/main.bicep b/infra/main.bicep index fed1c58..42329cb 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -9,15 +9,20 @@ param environment string param location string = resourceGroup().location param tenantId string param apiClientId string +@allowed(['foundation', 'role-based-data']) +param profile string = 'foundation' +param requiredRole string = '' +param readinessPath string = '' param sqlAdminObjectId string param sqlAdminName string param gatewayImage string param dabImage string -param functionsImage string +param functionsImage string = '' param registryServer string param deployGateway bool = true var prefix = '${name}-${environment}' +var fullFoundation = profile == 'foundation' var suffix = uniqueString(resourceGroup().id, prefix) var sqlName = '${prefix}-${suffix}' var storageName = 'sqlapps${suffix}' @@ -40,7 +45,7 @@ resource dabIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-3 location: location tags: tags } -resource functionIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = { +resource functionIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-01-31' = if (fullFoundation) { name: '${prefix}-functions' location: location tags: tags @@ -48,12 +53,12 @@ resource functionIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023 resource registry 'Microsoft.ContainerRegistry/registries@2023-07-01' existing = { name: split(registryServer, '.')[0] } -resource pullRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for index in range(0, 3): { +resource pullRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for index in range(0, fullFoundation ? 3 : 2): { name: guid(registry.id, ['gateway', 'data', 'functions'][index], prefix, acrPull) scope: registry properties: { roleDefinitionId: acrPull - principalId: [gatewayIdentity.properties.principalId, dabIdentity.properties.principalId, functionIdentity.properties.principalId][index] + principalId: index == 0 ? gatewayIdentity.properties.principalId : index == 1 ? dabIdentity.properties.principalId : functionIdentity!.properties.principalId principalType: 'ServicePrincipal' } }] @@ -72,13 +77,13 @@ resource network 'Microsoft.Network/virtualNetworks@2024-05-01' = { delegations: [{ name: 'containers', properties: { serviceName: 'Microsoft.App/environments' } }] } } - { + ...(fullFoundation ? [{ name: 'functions' properties: { addressPrefix: '10.42.2.0/24' delegations: [{ name: 'functions', properties: { serviceName: 'Microsoft.Web/serverFarms' } }] } - } + }] : []) { name: 'endpoints', properties: { addressPrefix: '10.42.3.0/24', privateEndpointNetworkPolicies: 'Disabled' } } ] } @@ -89,7 +94,7 @@ resource logs 'Microsoft.OperationalInsights/workspaces@2023-09-01' = { tags: tags properties: { sku: { name: 'PerGB2018' }, retentionInDays: 30 } } -resource insights 'Microsoft.Insights/components@2020-02-02' = { +resource insights 'Microsoft.Insights/components@2020-02-02' = if (fullFoundation) { name: '${prefix}-insights' location: location kind: 'web' @@ -134,7 +139,7 @@ resource database 'Microsoft.Sql/servers/databases@2023-08-01' = { sku: { name: 'S0', tier: 'Standard', capacity: 10 } properties: { collation: 'SQL_Latin1_General_CP1_CI_AS', zoneRedundant: false } } -resource files 'Microsoft.Storage/storageAccounts@2023-05-01' = { +resource files 'Microsoft.Storage/storageAccounts@2023-05-01' = if (fullFoundation) { name: storageName location: location tags: tags @@ -149,32 +154,32 @@ resource files 'Microsoft.Storage/storageAccounts@2023-05-01' = { networkAcls: { defaultAction: 'Deny', bypass: 'None' } } } -resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = { +resource blobService 'Microsoft.Storage/storageAccounts/blobServices@2023-05-01' = if (fullFoundation) { parent: files name: 'default' properties: { deleteRetentionPolicy: { enabled: true, days: 7 }, containerDeleteRetentionPolicy: { enabled: true, days: 7 } } } -resource fileContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { +resource fileContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = if (fullFoundation) { parent: blobService name: 'files' properties: { publicAccess: 'None' } } -resource hostContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = { +resource hostContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-05-01' = if (fullFoundation) { parent: blobService name: 'azure-webjobs-hosts' properties: { publicAccess: 'None' } } -resource gatewayStorageRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +resource gatewayStorageRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (fullFoundation) { name: guid(fileContainer.id, gatewayIdentity.id, blobContributor) scope: fileContainer properties: { roleDefinitionId: blobContributor, principalId: gatewayIdentity.properties.principalId, principalType: 'ServicePrincipal' } } -resource hostStorageRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in [blobOwner, queueContributor, tableContributor]: { +resource hostStorageRoles 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for role in (fullFoundation ? [blobOwner, queueContributor, tableContributor] : []): { name: guid(files.id, functionIdentity.id, role) scope: files - properties: { roleDefinitionId: role, principalId: functionIdentity.properties.principalId, principalType: 'ServicePrincipal' } + properties: { roleDefinitionId: role, principalId: functionIdentity!.properties.principalId, principalType: 'ServicePrincipal' } }] -resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { +resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = if (fullFoundation) { name: vaultName location: location tags: tags @@ -189,16 +194,16 @@ resource vault 'Microsoft.KeyVault/vaults@2023-07-01' = { networkAcls: { defaultAction: 'Deny', bypass: 'None' } } } -resource functionSecretsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = { +resource functionSecretsRole 'Microsoft.Authorization/roleAssignments@2022-04-01' = if (fullFoundation) { name: guid(vault.id, functionIdentity.id, 'secrets-user') scope: vault properties: { roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '4633458b-17de-408a-b874-0445c86b69e6') - principalId: functionIdentity.properties.principalId + principalId: functionIdentity!.properties.principalId principalType: 'ServicePrincipal' } } -resource functionPlan 'Microsoft.Web/serverfarms@2023-12-01' = { +resource functionPlan 'Microsoft.Web/serverfarms@2023-12-01' = if (fullFoundation) { name: '${prefix}-functions-plan' location: location tags: tags @@ -206,7 +211,7 @@ resource functionPlan 'Microsoft.Web/serverfarms@2023-12-01' = { sku: { name: 'EP1', tier: 'ElasticPremium', capacity: 1 } properties: { reserved: true } } -resource functions 'Microsoft.Web/sites@2023-12-01' = { +resource functions 'Microsoft.Web/sites@2023-12-01' = if (fullFoundation) { name: functionName location: location tags: tags @@ -222,7 +227,7 @@ resource functions 'Microsoft.Web/sites@2023-12-01' = { siteConfig: { linuxFxVersion: 'DOCKER|${functionsImage}' acrUseManagedIdentityCreds: true - acrUserManagedIdentityID: functionIdentity.properties.clientId + acrUserManagedIdentityID: functionIdentity!.properties.clientId ftpsState: 'Disabled' minTlsVersion: '1.2' appSettings: [ @@ -232,12 +237,12 @@ resource functions 'Microsoft.Web/sites@2023-12-01' = { { name: 'AZURE_TENANT_ID', value: tenantId } { name: 'API_CLIENT_ID', value: apiClientId } { name: 'GATEWAY_PRINCIPAL_ID', value: gatewayIdentity.properties.principalId } - { name: 'FUNCTIONS_IDENTITY_CLIENT_ID', value: functionIdentity.properties.clientId } - { name: 'KEY_VAULT_URL', value: vault.properties.vaultUri } + { name: 'FUNCTIONS_IDENTITY_CLIENT_ID', value: functionIdentity!.properties.clientId } + { name: 'KEY_VAULT_URL', value: vault!.properties.vaultUri } { name: 'AzureWebJobsStorage__accountName', value: files.name } { name: 'AzureWebJobsStorage__credential', value: 'managedidentity' } - { name: 'AzureWebJobsStorage__clientId', value: functionIdentity.properties.clientId } - { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: insights.properties.ConnectionString } + { name: 'AzureWebJobsStorage__clientId', value: functionIdentity!.properties.clientId } + { name: 'APPLICATIONINSIGHTS_CONNECTION_STRING', value: insights!.properties.ConnectionString } ] } } @@ -248,15 +253,15 @@ module sqlEndpoint 'private-endpoint.bicep' = { name: 'sql-endpoint' params: { name: '${prefix}-sql', location: location, networkId: network.id, targetId: sql.id, groupId: 'sqlServer', dnsZone: 'privatelink${az.environment().suffixes.sqlServerHostname}' } } -module storageEndpoints 'private-endpoint.bicep' = [for kind in ['blob', 'queue', 'table']: { +module storageEndpoints 'private-endpoint.bicep' = [for kind in (fullFoundation ? ['blob', 'queue', 'table'] : []): { name: '${kind}-endpoint' params: { name: '${prefix}-${kind}', location: location, networkId: network.id, targetId: files.id, groupId: kind, dnsZone: 'privatelink.${kind}.core.windows.net' } }] -module vaultEndpoint 'private-endpoint.bicep' = { +module vaultEndpoint 'private-endpoint.bicep' = if (fullFoundation) { name: 'vault-endpoint' params: { name: '${prefix}-vault', location: location, networkId: network.id, targetId: vault.id, groupId: 'vault', dnsZone: 'privatelink.vaultcore.azure.net' } } -module functionEndpoint 'private-endpoint.bicep' = { +module functionEndpoint 'private-endpoint.bicep' = if (fullFoundation) { name: 'function-endpoint' params: { name: '${prefix}-function', location: location, networkId: network.id, targetId: functions.id, groupId: 'sites', dnsZone: 'privatelink.azurewebsites.net' } } @@ -318,9 +323,15 @@ resource gateway 'Microsoft.App/containerApps@2024-03-01' = if (deployGateway) { { name: 'AZURE_TENANT_ID', value: tenantId } { name: 'API_CLIENT_ID', value: apiClientId } { name: 'DAB_URL', value: 'https://${dataApp!.properties.configuration.ingress.fqdn}' } - { name: 'BLOB_ACCOUNT_URL', value: files.properties.primaryEndpoints.blob } - { name: 'BLOB_CONTAINER', value: fileContainer.name } - { name: 'FUNCTIONS_URL', value: 'https://${functions.properties.defaultHostName}' } + ...(fullFoundation ? [ + { name: 'BLOB_ACCOUNT_URL', value: files!.properties.primaryEndpoints.blob } + { name: 'BLOB_CONTAINER', value: fileContainer!.name } + { name: 'FUNCTIONS_URL', value: 'https://${functions!.properties.defaultHostName}' } + ] : [ + { name: 'SQL_APPS_PROFILE', value: profile } + { name: 'SQL_APPS_REQUIRED_ROLE', value: requiredRole } + { name: 'SQL_APPS_READINESS_PATH', value: readinessPath } + ]) ] probes: [ { type: 'Liveness', httpGet: { path: '/health/live', port: 8080 }, initialDelaySeconds: 10, periodSeconds: 30 } @@ -338,7 +349,7 @@ output databaseName string = database.name output dabPrincipalId string = dabIdentity.properties.principalId output gatewayPrincipalId string = gatewayIdentity.properties.principalId output gatewayName string = '${prefix}-gateway' -output functionsName string = functions.name +output functionsName string = fullFoundation ? functions!.name : '' output networkId string = network.id -output storageAccount string = files.name -output vaultName string = vault.name +output storageAccount string = fullFoundation ? files!.name : '' +output vaultName string = fullFoundation ? vault!.name : '' diff --git a/plugins/sql-apps/scripts/sql-apps.mjs b/plugins/sql-apps/scripts/sql-apps.mjs index f0ebbb8..aa6b810 100644 --- a/plugins/sql-apps/scripts/sql-apps.mjs +++ b/plugins/sql-apps/scripts/sql-apps.mjs @@ -78,7 +78,7 @@ export async function status(fetcher = fetch, origins = { app: 'http://127.0.0.1 scope: 'HTTP liveness/data readiness only; not worker, storage, SQL authorization or cloud acceptance' }; } -const commands = new Set(['app', 'serve', 'serve-sql', 'services', 'stop-services', 'maintain', +const commands = new Set(['app', 'serve', 'serve-sql', 'role-based-app', 'role-based-serve', 'services', 'stop-services', 'maintain', 'start-sql', 'verify', 'init', 'test', 'data', 'api-test', 'app-test', 'app-check', 'services-test', 'stop', 'workspace-plan', 'workspace-init', 'recover-sql', 'selected-app', 'selected-serve', 'selected-stop', 'selected-test']); @@ -87,10 +87,10 @@ export async function main(args = process.argv.slice(2)) { if (args.length > 2) throw new Error('Expected a command and its optional selection'); if (command === 'help') { console.log('SQL Apps local plugin: home | workspace-check | guide | guide-save | status | setup-check [existing-sql-container] | ' + [...commands].join(' | ')); - console.log('Local commands use the configured checkout; no cloud deployment command is exposed.'); + console.log('role-based-setup-check [existing-sql-container] checks only role-based-data service ports before build. Local commands use the configured checkout; no cloud deployment command is exposed.'); return; } - if (!['home', 'workspace-check', 'guide', 'guide-save', 'status', 'setup-check'].includes(command) && !commands.has(command)) throw new Error(`Unsupported plugin command: ${command}`); + if (!['home', 'workspace-check', 'guide', 'guide-save', 'status', 'setup-check', 'role-based-setup-check'].includes(command) && !commands.has(command)) throw new Error(`Unsupported plugin command: ${command}`); if (command === 'guide-save') { if (args.length !== 2 || !isAbsolute(container)) throw new Error('Provide an absolute project brief JSON path'); } else if (command === 'guide') { @@ -120,19 +120,21 @@ export async function main(args = process.argv.slice(2)) { return; } const guided = command === 'guide' || command === 'guide-save'; + const setup = command === 'setup-check' || command === 'role-based-setup-check'; const cli = guided ? join(home, 'scripts', 'guide.mjs') : - command === 'setup-check' ? join(home, 'scripts', 'setup-check.mjs') : join(home, 'dist', 'src', 'local-cli.js'); + setup ? join(home, 'scripts', 'setup-check.mjs') : join(home, 'dist', 'src', 'local-cli.js'); try { await access(cli); } catch (error) { throw new Error(guided ? 'Guide missing from the bound checkout; obtain the updated project.' : - command === 'setup-check' ? 'Setup diagnostic missing from the bound checkout; obtain the updated project.' : + setup ? 'Setup diagnostic missing from the bound checkout; obtain the updated project.' : 'Local CLI not built. Run npm run build in the configured checkout', { cause: error }); } - if (!guided && command !== 'setup-check' && !workspace.bound.build.ready) { + if (!guided && !setup && !workspace.bound.build.ready) { throw new Error(`Runtime build is not verified: ${workspace.bound.build.code}. ${workspace.bound.build.nextAction}`); } const childArgs = guided ? [cli, ...(command === 'guide-save' ? ['--save', container] : []), '--json'] : - command === 'setup-check' ? [cli, '--json', ...(args.length === 2 ? ['--container', container] : [])] : + setup ? [cli, '--json', ...(command === 'role-based-setup-check' ? ['--profile', 'role-based-data'] : []), + ...(args.length === 2 ? ['--container', container] : [])] : [cli, command, ...(args.length === 2 ? [container] : [])]; await new Promise((done, reject) => { const child = spawn(process.execPath, childArgs, { cwd: home, shell: false, stdio: 'inherit' }); diff --git a/plugins/sql-apps/skills/sql-apps-application/SKILL.md b/plugins/sql-apps/skills/sql-apps-application/SKILL.md index 03576ea..617c239 100644 --- a/plugins/sql-apps/skills/sql-apps-application/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-application/SKILL.md @@ -10,6 +10,8 @@ Confirm the intended SQL Apps application directory before edits. Do not infer a Resolve `../../scripts/sql-apps.mjs` relative to this installed skill; run its absolute path with `home` to locate the foundation. The session application/worktree may be a different checkout. Confirm the intended application directory before edits. Do not overwrite the foundation or silently use its runtime binding for application-specific commands; set `SQL_APPS_HOME` to the validated application checkout when invoking its launcher. +Identify the loaded skill source, installed launcher and application checkout separately. Editing exported skill copies in an application does not change the active plugin. Verify the marketplace source and reload a fresh session after editing that source; `SQL_APPS_HOME` selects runtime code, not instructions. + Read `docs/maintainers/application-boundary.md` in the foundation. The reusable foundation defaults to no selected examples. Keep auth, domain-neutral `dbo.OwnerPredicate`, deployment tools and only the foundation capabilities the application uses. Do not copy `examples/` into a generated application or include it in TypeScript runtime inputs, browser bundles, DAB configuration, SQL projects or container images. ## Describe -> Run locally -> Make it yours -> Share optionally @@ -20,7 +22,23 @@ Ask one outcome-focused question at a time about the intended users, information Present a small scope summary before edits: useful screens/actions, stored information, access/validation rules, selected capabilities, a first useful change and explicit exclusions. No cloud deployment is planned by default. Agree on the summary and save it via `guide-save ""` using `docs/reference/guide.md` only with authorization; no credentials or actual records. Preserve root `selectedExamples: []`. -Use existing supported startup paths. The explicitly chosen synthetic reference starts SQL/DAB/browser only; it is not a prerequisite for a new app. The full foundation starts file/job services too, and a saved capability list does not dynamically turn them off. Do not invent launcher flags or claim this skill is an automatic general-purpose application generator. +State local and cloud support for the agreed capability/access profile before implementation. The explicit `role-based-data` profile provides SQL/DAB/browser startup and matching paid private-SQL Azure orchestration; read `docs/reference/role-based-data.md`. Domain screens/procedures and live acceptance remain app-specific. The full foundation retains its services; do not silently add excluded capabilities or switch authorized access to an anonymous demo. + +### Data-only application path + +For role-authorized data scope, configure `role-based-data.json` and explicit role-authorized DAB procedures with a read-only GET readiness endpoint. After scoped restore/build/workspace/EULA/schema approvals, run `role-based-app` in the validated checkout, then `role-based-serve` to resume. It omits storage/Functions and probes the procedure before listening; Alice is labeled simulated user with required role, Bob a simulated user without the required role. `serve-sql` remains an advanced SQL-only path, not this configured launcher. Implement domain screens/procedures and hide excluded controls; the profile is not an app generator. + +Do not run full `app`/`services` or require a file-processing acceptance roundtrip for data-only scope. If a needed command is absent from the installed launcher, inspect project-owned CLI help and use its documented checkout command; never invent a launcher flag. The explicitly chosen synthetic reference starts SQL/DAB/browser only but is not a prerequisite or a role-authorized substitute. A saved capability list does not dynamically compose services. + +### End-to-end application authorization + +For a custom application role (for example `AppUser`), check role definition and allowed member types, application role assignment, token claims, gateway-selected DAB role, entity/procedure permissions and SQL grants. With `role-based-data`, `identity` creates the configured human role and `role-based-assign` assigns explicitly approved users/groups; the foundation still creates `Function.Invoke`. These require separate authorized Entra work; an app-side role check alone is insufficient. + +Explicitly test custom-role forwarding against live local DAB: an authorized authorized request succeeds, missing/unassigned roles fail, caller-supplied role headers cannot escalate access, and procedure permissions match the selected role. The gateway must choose the trusted DAB role from validated claims (or explicitly gated local simulation), not arbitrary client headers. A local authorized simulation does not prove Entra assignment or cloud token behavior. Preserve owner isolation where required by the agreed app. + +### Custom UI design + +For substantial new screens, visual redesigns, or UI-focused polish, use `sql-apps-frontend-design` for app-specific visual direction and rendered-browser review. Keep SQL, data, authentication, authorization, capability, and runtime decisions in this application workflow. 1. Confirm domain behavior and selected capabilities with the user. Use the existing infrastructure and CLI, not upstream packages or another deployment implementation. 2. Implement domain-specific frontend/client/API/schema and tests. Never preserve the Todo screen in a hidden section, Todo client methods, DAB entity, sample migration, grants or default fixtures. @@ -31,10 +49,10 @@ Use existing supported startup paths. The explicitly chosen synthetic reference Report exact commands/results and any remaining migration or live-acceptance gap. A clean browser alone does not prove a clean application. Cloud deployment remains separately authorized and billable. -For the user's first local success, verify an actual agreed browser action and SQL persistence/reload with approved synthetic fixtures, plus relevant invalid-input and ownership behavior. Then implement the brief's first useful change and repeat the changed action, validation and existing workflow tests. Explain what changed and how to change it again. Save actual commands/results and browser evidence as a historical `run-locally` or `make-it-yours` checkpoint, never as current acceptance or blanket consent. +For the user's first local success, verify an actual agreed browser action and SQL persistence/reload with approved synthetic fixtures, plus relevant invalid-input and ownership behavior. Then implement the brief's first useful change and repeat the changed action, validation and existing workflow tests. Explain what changed and how to change it again. Save evidence under the actual stage (`run-locally` or `make-it-yours`, not `describe`) and update completed `nextChange` with the agreed outstanding change or explicit no-change status. Record exact commands/results and distinguish agent-verified acceptance from user-reported testing. Guide setup suggestions do not override completed setup or prove live readiness; report outstanding acceptance separately. A useful local app is success. Offer sharing only as an optional next step through the cloud-preview skill; review synthetic versus real data and access before choosing a profile. Never infer an anonymous public app from a request to share real data. -Follow `docs/reference/demo-cost.md`: lead with local-first/free-tier-first guidance and run the offline cost review before sharing. Saved preferences are not spending consent; do not promise a free deployment or automatically move to paid capacity. +Select the approved application profile before a cost command through the cloud-preview skill. `demo-cost` is synthetic-demo-only; `role-based-cost` reviews the matching paid role-based-data template. Unsupported capability combinations remain explicit gaps, not permission to substitute a foundation/demo review. Saved preferences are not spending consent; do not promise free deployment or automatically move to paid capacity. An explicit reference-app selection is separate from replacing it with a real application: use `app:build -- ` and the selected artifact checker without changing the clean root selection. Selected local commands accept the exact artifact directory, verify workspace/source/checksums, and provision a separate application database/login/DAB namespace. Follow the local skill for consent, `selected-app`, `selected-serve`, `selected-test` and `selected-stop`. Never copy the complete examples tree into application delivery or treat selected-build success as cloud readiness. diff --git a/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md b/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md index 0bd076a..1e35676 100644 --- a/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-cloud-preview/SKILL.md @@ -10,7 +10,17 @@ Confirm the intended SQL Apps application directory before edits. Do not infer a Sharing is the optional final stage of `docs/guides/build-your-app.md`, not a prerequisite for local success. Do not initiate cloud preparation during beginner onboarding unless the user requests it. Recover the agreed brief with the bound launcher's `guide`; local checkpoints are historical, not deployment consent or cloud readiness. -Read `docs/reference/demo-cost.md`. Lead with free-tier-first: recurring monthly allowances, not time-bound trial credits or a guarantee of zero charges. Use "free offer" only to identify Microsoft's official enrollment/documentation terminology. Before Azure requests, run the offline `npm run azure -- demo-cost ` after the approved build. The zero-spend example intentionally exits 2 because ACR Basic and managed-network infrastructure have fixed charges. Stop at that blocker for a free-only intent; do not provision, weaken SQL access or silently switch to paid resources. A saved `costPreference` is not spending approval. +Select the approved application profile before choosing a cost command: capabilities, synthetic/real data and anonymous/role-authorized access. Keep preparation tied to the same new application checkout and agreed scope. Read `docs/guides/sharing.md` and state support before proposing a template: + +| Approved profile | Current support and gap | +| --- | --- | +| Anonymous synthetic public demo | Demo-only cost/preflight and minimal templates exist; guided publication/deployment is incomplete. | +| Authenticated full foundation | Existing deployment commands include Functions, storage, Key Vault and private networking; review that full stack's paid resources and identity requirements. | +| Role-authorized data-only domain app | Explicit `role-based-data` supports matching cost review, two-image publication, authorized roles/assignments and resumable private SQL deployment. Domain workflows and live acceptance remain required. | + +Do not substitute the full foundation or anonymous demo for the last profile. Read `docs/reference/role-based-data.md`; run offline `role-based-cost ` after the approved build. Zero-spend exits 2 for paid S0/private infrastructure; unknown regional prices are not zero. Use a role-based-data configuration for existing `validate`, `artifacts`, `identity`, `plan`, `provision`, `deploy`, `status` and `schema` commands. `role-based-assign` is a separately authorized directory write; `role-based-smoke` verifies real authorized/unauthorized user procedure access, not browser order-save acceptance. No command or cost acknowledgement grants deployment consent. + +Lead with free-tier-first: recurring allowances, not trial credits or guaranteed zero charges. For the approved synthetic public-demo profile only, read `docs/reference/demo-cost.md` and run offline `npm run azure -- demo-cost ` after the approved build. Its output is demo-only, never the cost model for a role-authorized app. The zero-spend example exits 2 for ACR Basic/managed-network fixed charges. Stop for free-only intent; never weaken SQL access or switch to paid resources silently. Authenticated profiles need a review of their actual resources/prices; unavailable estimates remain unknown. A saved `costPreference` is not spending approval. For a new demo SQL database, require explicit `free-paused` or `paid-reviewed` policy. Free-paused sets `useFreeLimit: true` with `AutoPause`; it must fail on unavailable eligibility/region/slots, never retry as paid. Ordinary paid-reviewed SQL is distinct from irreversible free-tier paid continuation. Never enable `BillOverUsage` or change an existing database from this review. @@ -18,14 +28,20 @@ Explain in everyday terms who can open the app, whether its information is synth The review does not query actual usage or configure alerts. Review SQL remaining free amount, shared subscription compute grants and actual/forecast costs in both app and managed infrastructure groups after approved deployment. Budgets are delayed alerts, not hard spending caps. Introduce paid capacity for measured exhaustion, latency/availability or data/recovery requirements, not a user-count threshold. Cleanup/export is separately reviewed; do not delete resources/data automatically. -Resolve `../../scripts/sql-apps.mjs` relative to this installed skill and run `node "" home`. Work in the resolved application checkout. Read `docs/reference/deployment.md`, `docs/maintainers/operations.md`, and `README.md` before selecting commands. +Identify the loaded skill source, installed launcher and application checkout separately. Resolve `../../scripts/sql-apps.mjs` relative to this installed skill and run `node "" home`. Confirm this is the requested new application, not merely the foundation binding; select explicit `SQL_APPS_HOME` when needed. Exported skill-copy edits do not update installed instructions. Read `docs/reference/deployment.md`, `docs/maintainers/operations.md`, and `README.md` in the confirmed checkout before selecting commands. Use `npm run azure -- help` and the existing Azure CLI/Bicep orchestration. Do not invent CLI verbs or implement another login/deployment engine. Validate the selected configuration using documented commands. Treat profile/subscription/resource group/image/identity selections as user decisions. Run the launcher's read-only `workspace-check` before preparing cloud commands. If active and bound checkouts/worktrees differ, require an explicit `SQL_APPS_HOME` selection; do not transfer uncommitted source or silently update the binding. Confirm the selected application and current source/build provenance before building or publishing its artifacts. +**Existing-resource discovery must not change the deployment subject.** With approved read-only Azure access, classify older matching resources as potential collisions and leave them untouched. Keep the new checkout/configuration/artifacts as the source unless reuse is explicitly requested, then separately review compatibility, migration, cost and writes. Discovery is not permission to reuse, price the old stack as the new app, rename/delete resources or pivot deployment away from the agreed application. + +Distinguish account email from tenant ID: an email address is not a tenant identifier. If one is supplied instead, explain that difference and the smallest authorized next step (confirm the intended directory's tenant ID or retrieve it with an approved read-only account query). Do not loop through generic login steps or change subscription defaults. + +For application-role authorization, follow the application skill's end-to-end checklist: role definition, application role assignment, token claims, trusted gateway-selected DAB role and procedure permissions. `identity` creates the configured human role for `role-based-data`; the foundation retains `Function.Invoke`. Require explicit registration/assignment approval and verify real cloud sign-in/custom-role forwarding separately from local simulation. + **Preparation is not deployment approval.** This skill must not create resources, register Entra apps, assign roles, publish SQL, build/push cloud artifacts, or alter subscription selection without explicit user authorization. A what-if may need Azure access; confirm the target and permission before running it. Keep secrets out of chat, files committed to Git, and client configuration. -The current cloud template includes billed EP1/S0/private-endpoint/always-on resources; it is not a free-tier-only template. Subscription-free local operation does not make Azure deployment free. Report costs and live-acceptance gaps; compiled Bicep, schema validation and mocks do not prove cloud readiness. +The default full template includes billed EP1/S0/private-endpoint/always-on resources. Explicit `role-based-data` excludes Functions/storage/Key Vault but retains paid S0, private SQL/networking, registry and minimum-one-replica gateway/DAB. Neither is guaranteed free. Report the same application's preparation, reviewed costs and remaining live acceptance separately; compiled Bicep, schema validation and mocks do not prove cloud readiness or deployment completion. For a selected public synthetic-data demo, read `docs/reference/demo-deployment.md` first. Do not require the full-foundation Entra registration, Functions, storage or private-endpoint configuration for its preparation. With explicit target/read-only Azure consent, use `npm run azure -- demo-preflight ""` in the bound checkout. Its existing CLI tenant-token acquisition and in-memory ARM reads deliberately avoid dependence on default-tenant/cache discovery; it never requests Graph, changes global defaults, refreshes login, registers providers or creates resources. Explain cache, actual ARM, candidate authorization, not-yet-evaluated policy and not-evaluated costs separately. An eligible result is not deployment success. Separate minimal foundation/runtime templates compile and have resource-contract tests (`npm run infra:test-demo`); their new-database SQL billing policy is explicit and never authorizes cloud writes. Image publication, SQL identity probing and the resumable guide are still in development; never substitute the full template or report template compilation as deployment success. diff --git a/plugins/sql-apps/skills/sql-apps-frontend-design/SKILL.md b/plugins/sql-apps/skills/sql-apps-frontend-design/SKILL.md new file mode 100644 index 0000000..8f634eb --- /dev/null +++ b/plugins/sql-apps/skills/sql-apps-frontend-design/SKILL.md @@ -0,0 +1,22 @@ +--- +name: sql-apps-frontend-design +description: "Use when designing or refining custom screens in a standalone SQL Apps application, including visual direction, responsive behavior, accessibility, or browser-based visual review." +--- + +# Design custom SQL Apps interfaces + +SQL Apps is a standalone project with its own runtime and workflow. Work in the confirmed absolute application directory from the SQL Apps application workflow. Use only SQL Apps skills and project-owned commands for SQL Apps work. Do not disable or modify other installed plugins. Do not infer a directory from another plugin, session title or conversation history. + +Use this skill for the visual design and rendered-interface review of a custom application. Preserve existing app workflows and agreed capability boundaries; do not invent SQL, authentication, authorization, or data behavior for visual design. The `sql-apps-application` skill is authoritative for SQL Apps setup, project boundaries, data and identity behavior, authorization, local startup, and functional acceptance; route those questions there. Do not redesign the SQL Apps foundation browser or create a shared SQL Apps theme, framework, or component system. + +## Design workflow + +1. Inspect the intended application and its existing visual patterns before proposing changes. Understand the users, primary task, important information, and device context; ask only about missing decisions that materially affect the interface. +2. For a substantial new interface or visual redesign, present two or three app-appropriate visual directions with concise trade-offs and a recommendation. Get approval of the direction before implementation. For a small extension or refinement, follow the existing application's visual language without adding an approval step. +3. Translate the selected direction into the page composition, hierarchy, typography, color, spacing, density, and imagery appropriate to this app. Respect an existing product identity; avoid generic dashboard layouts and decoration without a purpose. Include only interaction states and controls supported by the agreed application behavior. +4. Implement within the existing project structure. Prefer existing dependencies and assets; do not add an unapproved package or fetch remote fonts or images for appearance. Keep the interface semantic, responsive, and consistent with existing patterns. +5. Preserve usability and accessibility: use clear labels, keyboard-operable controls, visible focus, sufficient contrast, and respect reduced-motion preferences when motion is present. Do not trade these for visual novelty. +6. When safe and feasible, run the project's documented preview and inspect the actual rendered interface in a browser at desktop and narrow-mobile viewports. Inspect the primary screen and relevant states reachable in the preview, such as populated, empty, loading, error, disabled, or success states. Fix meaningful layout, hierarchy, wrapping, responsive, affordance, and consistency issues, then inspect the changed render again. +7. If browser preview or a relevant state is unavailable or unsafe to reach, state the limitation and report what you did inspect. Do not claim visual or end-to-end validation based only on source inspection, a successful build, or a generated screenshot. + +Report the chosen direction, significant UI decisions, viewports and states actually inspected, iterations made, and remaining visual or accessibility checks. Visual review does not replace the application skill's functional, SQL, access, or runtime validation. diff --git a/plugins/sql-apps/skills/sql-apps-local/SKILL.md b/plugins/sql-apps/skills/sql-apps-local/SKILL.md index cdd1e86..ce50b22 100644 --- a/plugins/sql-apps/skills/sql-apps-local/SKILL.md +++ b/plugins/sql-apps/skills/sql-apps-local/SKILL.md @@ -10,67 +10,78 @@ Confirm the intended SQL Apps application directory before edits. Do not infer a ## A useful local app is the goal -Follow **Describe -> Run locally -> Make it yours -> Share optionally** from `docs/guides/build-your-app.md` in the intended checkout. SQL knowledge is welcome, not required. Ask about users, information and actions one question at a time, not frameworks or Azure services. Check setup/private-preview access early and summarize a small agreed scope before application edits. +Follow **Describe -> Run locally -> Make it yours -> Share optionally** from `docs/guides/build-your-app.md`. Ask about users, information and actions, not frameworks or Azure services. Check preview access and agree on scope before edits. -After resolving the home and checking its binding below, run `node "" guide` to recover decisions before choosing a startup path. It works before restore/build and does not start/probe services. Treat saved checkpoints as historical reports; recheck the intended runtime and real app action. With agreement, use `guide-save ""` to save scope, next change and exact verification evidence; never include secrets or actual records. +Run the resolved launcher's `guide` before selecting startup; it works before build and does not probe services. Checkpoints are historical. With agreement, use `guide-save ""` for scope, next change and evidence; exclude secrets/actual records. -Do not start the full file/job demonstration for every request. Explicit reference selection uses the smaller selected-app path below. A new domain app uses the application skill and clean boundary, not an implicit example copy. Capability choices in the brief describe scope, not runtime switches; the full foundation command still starts its documented services. +Select startup from approved capabilities, not the foundation demonstration. Role-authorized data apps use `docs/reference/role-based-data.md`: configure the role/procedure contract, then approved `role-based-app` and `role-based-serve`. They start SQL/DAB/browser only, probe actual procedure readiness and label Alice with/Bob without the required role simulations. `serve-sql` remains an advanced SQL-only path. Never run `app`/`services` or demonstrate excluded files/jobs. The brief is not a runtime switch; the selected reference is synthetic-only. -After actual local verification, help the user make one useful change and verify browser validation, SQL save/reload and existing behavior. A working local app is success; Azure sharing is a separate, optional decision. +After local acceptance, make one agreed useful change and repeat browser validation, SQL save/reload and regression checks. Azure sharing is separately approved and optional. -Introduce only brief cost awareness during local setup: no Azure usage bill, but tool/access/licensing conditions remain. When adding capabilities, explain relevant future costs from `docs/reference/demo-cost.md` without a cloud pricing questionnaire. The guide defaults to zero-spend guidance; saved cost preferences are not spending consent. Lead with free tier for recurring monthly allowances, not trial credits. Detailed free-tier/fixed-charge review belongs to optional sharing, not local startup. +Keep cost awareness brief locally: no Azure usage bill, but tool/access/licensing conditions remain. Saved cost preferences are not spending consent. Profile-specific cost review belongs to optional sharing, not startup. ## Beginner onboarding and consent -Do not leave a beginner with a prerequisite list. Guide one step at a time, explain the expected result and stay at the failed step until resolved. Follow `docs/guides/getting-started.md` in the intended checkout for Windows, macOS and Linux. Use the exact distribution/version's official installation page for Linux, not guessed package commands. +Guide one step at a time; stay at a failed step until resolved. Follow `docs/guides/getting-started.md` for Windows, macOS and Linux; use official OS/version-specific installers. -If Node is missing, the launcher cannot run. Confirm OS/CPU and the intended checkout using host tools; guide the official Node 22/24 installer first, preserving existing version managers. Do not require Git: the guide includes ZIP download and VS Code terminal instructions. Plugin installation is optional and Copilot still needs its own account/access. +If Node is missing, confirm OS/CPU and checkout; guide the official Node 22/24 installer, preserving version managers. Git is optional (ZIP works); Copilot has separate access requirements. -Before EACH tool installation, ask explicit approval with its name, official source, network/disk implications and privileges. Prefer existing package managers/dedicated installation tools. Never replace an existing compatible installation, change global version-manager selection, run unreviewed remote scripts, automatically accept licenses, elevate, reboot, change Docker groups/socket permissions or disable TLS/firewalls. +Before EACH tool installation, ask explicit approval with its name, official source, downloads/disk effects and privileges. Prefer native installers. Never replace compatible tools, change global version managers, run unreviewed scripts, accept licenses automatically, elevate, reboot, change Docker permissions or disable TLS/firewalls. Pause for human-only GUI/license/restart/credential actions; tell the user how to resume. After installs, reopen the terminal/host as needed and recheck actual versions/PATH rather than repeating installations. -State access/cost facts BEFORE downloads: no Azure subscription or Azure usage charges, but SQL is private preview requiring registry access unless cached or an existing usable container is selected. Docker Desktop license eligibility varies. Never call acquisition universally account-free or free for every organization. Direct the user to official preview signup; credentials are entered in their own terminal, never pasted into chat or repository files. +State access/cost facts BEFORE downloads: SQL is private preview requiring registry access unless cached or an existing usable container is selected. Docker Desktop licensing varies; acquisition is not universally account-free. Use official preview signup; credentials are entered in their own terminal, never in chat or repository files. + +Explain stages before the first approval prompt: needed installs, restore, build, workspace initialization, downloads/builds and SQL terms/schema/startup, then acceptance writes. Name each scope, target/effects and remaining stages. Track approved operations; repeat only if scope changes. `npm ci` approval excludes build; workspace approval excludes launch. Bare "I approve" covers only the preceding explicit scope. + +After the first approval-tool response "user unavailable" (or invisible controls), stop retrying. Explain that no approval was captured. Offer one precise ordinary chat statement with actual values: "I approve in , including ; this excludes ." Wait for explicit consent; tool failure is not approval. Ask one scoped question at a time. + +Before SQL acceptance, say: "Startup passes `ACCEPT_EULA=Y`; there is no chat dialog." Link the [container documentation and preview access instructions](https://aka.ms/azuresqldb-container) and the applicable terms supplied with the user's preview access; if unavailable, pause for them. Explain that explicit approval to start SQL under those terms authorizes the launcher to pass that value, not blanket acceptance of other licenses. ## Locate the application Resolve `../../scripts/sql-apps.mjs` relative to **this installed SKILL.md**, not the session working directory. Use the absolute script path in every invocation and quote paths with spaces. -Run `node "" home`. It selects `SQL_APPS_HOME`, then the installer-created binding in `COPILOT_HOME` (default `~/.copilot`), then an application checkout in the current directory or its ancestors. The binding contains only a checkout path, not credentials. +Record three separate paths: loaded skill source, installed launcher resolved from it, and application checkout selected by `home`. `SQL_APPS_HOME` changes the runtime checkout, not the loaded skills. Exported skill-copy edits do not update the installed plugin; follow `docs/reference/copilot-plugin.md` and reload a fresh session from the verified source. -Run `node "" workspace-check` before execution. Compare the active checkout/worktree with the bound home, source fingerprint, runtime contract and build provenance. If they differ, require the user's explicit runtime home via `SQL_APPS_HOME`; never change the global binding or copy uncommitted foundation files implicitly. Equal package versions or commits do not certify equal source. A missing/stale build needs an approved build in that selected checkout. +Run `node "" home`: `SQL_APPS_HOME`, then `COPILOT_HOME` binding (default `~/.copilot`), then cwd/ancestors. The binding stores only a path. -Do not run the application from a plugin cache or assume the user's active project is SQL Apps. If resolution fails, report the error and ask for the checkout path; instruct the user to run `npm run plugin:install` in that checkout or set `SQL_APPS_HOME`. Never silently select another project. +Run `node "" workspace-check` before execution. Compare checkout, binding, fingerprint, runtime contract and build provenance. Differences require explicit `SQL_APPS_HOME`; never silently rebind or copy uncommitted source. Equal commits/versions do not certify source. Missing/stale builds need approval in that checkout. + +Do not run from a plugin cache. On resolution failure, report it and ask for the checkout path; use approved `plugin:install` or `SQL_APPS_HOME`, never another project silently. ## Start or reuse 1. Read `README.md`, `docs/guides/getting-started.md` and `docs/reference/local-development.md` **in the resolved checkout**. -2. Run `node "" setup-check` (or `setup-check `) before restore/build. It prints structured prerequisite/action reports using Node alone. Nonzero means action needed, including existing ports whose ownership needs confirmation, not permission to kill anything. Run `node "" status` to assess existing HTTP services; it does not certify worker/storage correctness. -3. If healthy and confirmed to be the intended application, reuse `http://127.0.0.1:18080/`. Do not launch another gateway on the same port. - Use the app/DAB origins reported by workspace-check for isolated workspaces; the URL above is legacy-only. Confirm `/local/workspace` when the running version exposes it, without treating HTTP health alone as ownership. -4. If startup is needed, resolve missing prerequisites using the guide and approved native installers. Ask separately before `npm ci`/build and explain internet use for first npm/NuGet/image downloads. Install dependencies only if missing or validation identifies them; build in the resolved checkout when compiled code is missing/source changed. A cached SQL image alone is not complete offline readiness. +2. Run `setup-check [selected-existing-sql-container]` before restore/build. Nonzero means action needed, not permission to kill occupied ports. `status` checks HTTP services, not worker/storage correctness. +3. Reuse only a responding service confirmed as the intended application. Use its workspace-reported origin; port 18080 is legacy-only. Confirm `/local/workspace` when available; HTTP health alone is not ownership. Proposed ports/URLs are unavailable until startup and probes succeed. +4. Resolve prerequisites with approved installers. Ask separately before `npm ci`/build; explain first npm/NuGet/image downloads. Restore only for missing dependencies; build missing/stale code in the selected checkout. Cached SQL alone is not offline readiness. 5. Before `app`, get informed approval for container downloads/builds, SQL EULA acceptance (`ACCEPT_EULA=Y`) and application schema initialization. Explain the local resources/data effects; choose a new owned SQL container unless the user explicitly selected reuse. Run `node "" app` as an attached task using host tools. It publishes a non-destructive schema and starts actual services. Report named startup stages; on failure preserve completed work/data and follow targeted resume guidance. Never silently reset volumes/credentials or substitute another SQL engine. Before first startup, run the built launcher's `workspace-plan`; ask explicitly for a new isolated port block or `workspace-init legacy` to preserve an existing stack. Explain that initialization records only the descriptor, does not migrate data, and does not authorize subsequent downloads/schema changes. A copied descriptor is an error, not permission to overwrite it. -6. Open the browser and explain Development Alice/Bob, files, processing, retries and traces. Local identities are simulations, never production authentication. The Todo reference example is isolated outside application delivery; do not copy it into a user's application. +6. Open only the verified origin and explain the approved app's users/actions. Demonstrate files/jobs only when selected. Local identities are simulations, never production authentication. Do not copy the Todo reference into application delivery. -For an already running alternate SQL container, validate the user-selected name and run `verify` before approved `init`, `data`, `services`, then `serve` with that name. These commands execute in the bound checkout. Do not replace, delete or stop unrelated/user-owned containers. +For an existing alternate SQL container, validate its selected name and run `verify` before approved `init`/`data` with that name. Use `serve-sql` for data-only scope; `services`/`serve` are for approved file/job scope. Do not replace, delete or stop unrelated/user-owned containers. `serve` reuses running services. `stop-services` stops the project worker/storage and preserves data. Stopping the attached gateway process does not erase SQL/Blob volumes. -No Azure subscription or Entra registration is required for local operation. First-time downloads need internet; do not claim everything is offline before prerequisites are cached. Do not deploy cloud resources, install unverified platform packages, or scaffold another runtime. +No Azure subscription or Entra registration is required locally. First downloads need internet. Do not deploy cloud resources, install unverified platform packages, or scaffold another runtime. ## Explicitly selected synthetic application -Only when the user explicitly chooses the reference application, read its `examples/todo/README.md` in the bound checkout. Keep the root `selectedExamples: []` and other applications sample-free. Ask for isolated workspace selection and the same download/EULA/schema consent as above; never reuse a legacy descriptor or change the global binding implicitly. +Only with explicit reference selection, read `examples/todo/README.md`. Keep root `selectedExamples: []`. Require isolated workspace selection and download/EULA/schema consent; never overwrite legacy state or rebind implicitly. -Run `npm run app:build -- todo local-simulation` in that bound home, and retain the exact absolute artifact directory printed by the builder. Use `node "" selected-app ""` as an attached task. This starts only the selected SQL database/login and DAB plus browser/API, not storage or Functions. The gateway checks actual selected procedure readiness before reporting its origin. +Run approved `npm run app:build -- todo local-simulation`; retain its exact absolute artifact directory. Run `selected-app ""` attached via the resolved launcher. It starts SQL/login, DAB and browser/API only, probes procedure readiness, and excludes storage/Functions. -Explain anonymous synthetic sessions and the conspicuous data warning; there are no Alice/Bob or signed-in users in this adapter. With separate approval for synthetic writes, `selected-test ""` runs the reference's real SQL/HTTP limits/ownership suite and removes its own synthetic fixtures. It does not prove browser or restart behavior; check those separately when authorized. +Explain anonymous synthetic sessions/data warning, not Alice/Bob sign-in. With synthetic-write approval, `selected-test ""` checks SQL/HTTP limits/ownership and cleans its fixtures; browser/restart acceptance remains separate. -Ctrl+C stops only the attached gateway. `selected-serve ""` resumes using verified services and SQL-backed sessions. `selected-stop todo` removes only the selected owned DAB container and preserves SQL data. Do not use foundation `stop`/`stop-services` to clean a selected app or silently replace a mismatched DAB configuration. Public-demo assembly is not authorization or evidence of Azure deployment. +Ctrl+C stops the gateway. `selected-serve ""` resumes verified services/SQL sessions. `selected-stop todo` removes only owned selected DAB, preserving SQL. Never use foundation stop commands or replace mismatched DAB silently. Assembly is not Azure deployment. ## Verify and explain completion -Verify `/health/ready` and open the browser. With permission, perform a small test-file processing roundtrip and observe completion/results; HTTP liveness alone is not end-to-end acceptance. Offer cleanup of only that fixture. Full `services-test` briefly restarts owned services and needs separate approval when others may be processing. Report exactly what passed and any unverified capability/platform; do not claim fresh-machine macOS/Linux/ARM acceptance from simulated tests. +Report four separate states: **implemented** (source), **built** (successful commands), **running** (intended server responds), and **workflow verified** (agreed browser action plus SQL persistence/reload). Publish a launch URL only after the intended server responds at that origin and `/health/ready` passes. A proposed URL is unavailable; a build/test count is not a launch. If launch or requested acceptance is blocked, report blocked, not complete; completion tooling cannot bypass these gates. + +Verify only selected capabilities with approved synthetic fixtures. HTTP liveness alone is not end-to-end acceptance. For file/job scope, perform a small processing roundtrip and offer cleanup of only that fixture. `services-test` restarts owned services and needs separate approval when others may be processing. Report exact commands/results, agent-verified browser actions and remaining gaps; "I tested it" is user-reported evidence, not agent verification. Do not claim fresh-machine macOS/Linux/ARM acceptance from simulations. + +Save launch evidence under the actual `run-locally` stage, and changed-workflow evidence under `make-it-yours`, not `describe`. Update completed `nextChange` with the agreed outstanding change (or explicit no-change status). Guide suggestions are historical, not live readiness: distinguish completed setup, running services and outstanding acceptance. -Show the browser URL, how to choose the local user, Ctrl+C to stop the gateway, `serve` to resume and safe service-stop commands. Explain that SQL/Blob data persist and local sessions must be reselected after gateway restart. Leave the user with a usable app or an explicit next human action, never a success claim while waiting on access/restart/downloads. +Show the verified URL, approved local-user flow, Ctrl+C and matching resume/stop commands (`role-based-serve` for role-based-data, `serve-sql` for advanced SQL-only, selected commands for the reference). SQL/Blob data persist; foundation sessions must be reselected after restart. Leave a usable app or a precise next action, never a success claim while startup is blocked. diff --git a/role-based-data.example.json b/role-based-data.example.json new file mode 100644 index 0000000..12aa9f6 --- /dev/null +++ b/role-based-data.example.json @@ -0,0 +1,5 @@ +{ + "profile": "role-based-data", + "requiredRole": "AppUser", + "readinessPath": "/api/AppReady" +} diff --git a/scripts/check-plugin.mjs b/scripts/check-plugin.mjs index ba3c617..14fe968 100644 --- a/scripts/check-plugin.mjs +++ b/scripts/check-plugin.mjs @@ -5,7 +5,8 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; export const checkout = resolve(dirname(fileURLToPath(import.meta.url)), '..'); export const plugin = join(checkout, 'plugins', 'sql-apps'); -export const skillNames = ['sql-apps-local', 'sql-apps-diagnostics', 'sql-apps-validation', 'sql-apps-cloud-preview', 'sql-apps-application']; +export const skillNames = ['sql-apps-local', 'sql-apps-diagnostics', 'sql-apps-validation', 'sql-apps-cloud-preview', 'sql-apps-application', 'sql-apps-frontend-design']; +const runtimeSkillNames = ['sql-apps-local', 'sql-apps-diagnostics', 'sql-apps-validation', 'sql-apps-cloud-preview', 'sql-apps-application']; export async function checkPlugin() { const manifest = JSON.parse(await readFile(join(plugin, 'plugin.json'), 'utf8')); @@ -29,7 +30,11 @@ export async function checkPlugin() { for (const name of skillNames) { const content = await readFile(join(plugin, 'skills', name, 'SKILL.md'), 'utf8'); assert.match(content, new RegExp(`^---\\r?\\nname: ${name}\\r?\\ndescription: "[^\\r\\n]+"\\r?\\n---`)); - assert.match(content, /\.\.\/\.\.\/scripts\/sql-apps\.mjs/); + if (runtimeSkillNames.includes(name)) { + assert.match(content, /\.\.\/\.\.\/scripts\/sql-apps\.mjs/); + } else { + assert.match(content, /sql-apps-application.*authoritative|authoritative.*sql-apps-application/i, 'Design guidance defers SQL Apps setup and project authority to the application skill'); + } assert.match(content, /absolute/i); assert.ok(content.length < 12000, 'Keep skills focused and discoverable'); } @@ -48,8 +53,8 @@ export async function checkPlugin() { } assert.deepEqual((await readdir(plugin)).sort(), ['plugin.json', 'scripts', 'skills']); await inspect(plugin); - assert.equal(files.length, 7, 'Bundle contains only manifest, launcher and five skills'); - console.log('Local plugin packaging passed: five skills, portable manifest, clean isolated bundle and matching marketplaces.'); + assert.equal(files.length, 8, 'Bundle contains only manifest, launcher and six skills'); + console.log('Local plugin packaging passed: six skills, portable manifest, clean isolated bundle and matching marketplaces.'); } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { diff --git a/scripts/install-plugin.mjs b/scripts/install-plugin.mjs index f878043..bc0f4ca 100644 --- a/scripts/install-plugin.mjs +++ b/scripts/install-plugin.mjs @@ -61,7 +61,7 @@ async function install() { } console.log(`SQL Apps installed locally and bound to ${home}.`); console.log(`Runtime contract ${source.contract.version}; source fingerprint ${source.fingerprint}. Other worktrees require explicit home selection.`); - console.log('All five skills are discovered and enabled by the actual Copilot plugin loader.'); + console.log(`All ${skillNames.length} skills are discovered and enabled by the actual Copilot plugin loader.`); console.log('Restart the Copilot App or create a fresh session. No upstream plugins were changed and no cloud resources were deployed.'); } diff --git a/scripts/setup-check.mjs b/scripts/setup-check.mjs index e9a0a56..1913830 100644 --- a/scripts/setup-check.mjs +++ b/scripts/setup-check.mjs @@ -49,7 +49,8 @@ export function parseArguments(args) { for (let i = 0; i < args.length; i++) { if (args[i] === '--json' && !options.json) options.json = true; else if (args[i] === '--container' && !options.container && containerPattern.test(args[i + 1] ?? '')) options.container = args[++i]; - else throw new Error('Usage: node scripts/setup-check.mjs [--json] [--container ]'); + else if (args[i] === '--profile' && !options.profile && ['foundation', 'role-based-data'].includes(args[i + 1])) options.profile = args[++i]; + else throw new Error('Usage: node scripts/setup-check.mjs [--json] [--container ] [--profile ]'); } return options; } @@ -58,6 +59,7 @@ export async function checkSetup(options = {}, injected = {}) { const root = resolve(options.root ?? defaultRoot); const runtime = runtimeFor(undefined, root); if (options.container && !containerPattern.test(options.container)) throw new Error('Invalid SQL container name'); + if (options.profile && !['foundation', 'role-based-data'].includes(options.profile)) throw new Error('Invalid setup profile'); const deps = { platform: process.platform, architecture: process.arch, nodeVersion: process.versions.node, memoryBytes: totalmem(), read: path => readFile(path, 'utf8'), exists, port: portState, fetch, @@ -173,8 +175,10 @@ export async function checkSetup(options = {}, injected = {}) { 'Inspect only safe project ownership labels before reusing services.', false); } } - for (const [port, owner] of [[runtime.ports.gateway, null], [runtime.ports.data, 'data'], - [runtime.ports.blob, 'storage'], [runtime.ports.queue, 'storage'], [runtime.ports.functions, 'functions']]) { + const ports = [[runtime.ports.gateway, null], [runtime.ports.data, 'data'], + ...(options.profile === 'role-based-data' ? [] : + [[runtime.ports.blob, 'storage'], [runtime.ports.queue, 'storage'], [runtime.ports.functions, 'functions']])]; + for (const [port, owner] of ports) { const state = await deps.port(port); if (state === 'free') add(`port-${port}`, 'ready', 'PORT_FREE', `Local port ${port} is free.`, 'Available for application startup.'); else if (state === 'occupied' && owner && containers.some(value => value.owner === owner && diff --git a/sql/database.sqlproj b/sql/database.sqlproj index 5a0e5f7..6ddf98c 100644 --- a/sql/database.sqlproj +++ b/sql/database.sqlproj @@ -14,6 +14,10 @@ 00000000-0000-0000-0000-000000000000 $(SqlCmdVar__1) + + + $(SqlCmdVar__2) + diff --git a/sql/grant-runtime.sql b/sql/grant-runtime.sql index 4bc5942..dc63ce6 100644 --- a/sql/grant-runtime.sql +++ b/sql/grant-runtime.sql @@ -13,5 +13,11 @@ END ELSE IF (SELECT sid FROM sys.database_principals WHERE name = N'sql_apps_dab') <> @sid THROW 50002, 'Existing DAB user belongs to a different identity; review before changing permissions.', 1; -GRANT SELECT ON dbo.FileJobs TO [sql_apps_dab]; -GRANT VIEW DEFINITION ON dbo.FileJobs TO [sql_apps_dab]; +DECLARE @roleBasedGrants nvarchar(max) = N'$(RoleBasedProcedureGrants)'; +IF LEN(@roleBasedGrants) > 0 + EXEC sys.sp_executesql @roleBasedGrants; +ELSE +BEGIN + GRANT SELECT ON dbo.FileJobs TO [sql_apps_dab]; + GRANT VIEW DEFINITION ON dbo.FileJobs TO [sql_apps_dab]; +END diff --git a/src/artifacts.ts b/src/artifacts.ts index 4ad0143..6ac3ed5 100644 --- a/src/artifacts.ts +++ b/src/artifacts.ts @@ -1,7 +1,11 @@ import { deploymentSchema, type DeploymentConfig } from './config.js'; import type { Run } from './process.js'; +import { roleBasedSchemaFingerprint } from './role-based-profile.js'; export const imageFields = ['gatewayImage', 'dabImage', 'functionsImage'] as const; +export function deploymentImageFields(config: DeploymentConfig) { + return config.profile === 'role-based-data' ? imageFields.slice(0, 2) : [...imageFields]; +} export const pinnedDabImage = 'mcr.microsoft.com/azure-databases/data-api-builder:2.0.12@sha256:85db5c7f1af9d0bc93af824a0602285880a07dba210854bc68394e08d9d338ac'; export async function imageDigest(image: string, config: DeploymentConfig, run: Run): Promise { @@ -16,21 +20,26 @@ export async function imageDigest(image: string, config: DeploymentConfig, run: } export async function buildArtifacts(config: DeploymentConfig, run: Run): Promise { - if (imageFields.some(field => config[field].includes('@'))) { - throw new Error('Artifact builds require new version tags for all three configured images, not digests'); + const fields = deploymentImageFields(config); + const source = config.profile === 'role-based-data' ? await roleBasedSchemaFingerprint() : undefined; + if (fields.some(field => config[field].includes('@'))) { + throw new Error('Artifact builds require new version tags for every selected image, not digests'); } - if (new Set(imageFields.map(field => config[field])).size !== imageFields.length) { - throw new Error('Artifact builds require distinct gateway, DAB and Functions image references'); + if (new Set(fields.map(field => config[field])).size !== fields.length) { + throw new Error('Artifact builds require distinct references for every selected service image'); } await run('az', ['acr', 'show', '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, '--name', config.registryServer.split('.')[0]!, '-o', 'none']); const dockerfiles = ['Dockerfile', 'dab/Dockerfile', 'functions/Dockerfile']; const pinned = { ...config }; - for (const [index, field] of imageFields.entries()) { + for (const [index, field] of fields.entries()) { await run('az', ['acr', 'build', '--subscription', config.subscriptionId, '--registry', config.registryServer.split('.')[0]!, '--image', config[field].slice(config.registryServer.length + 1), '--file', dockerfiles[index]!, '.']); pinned[field] = await imageDigest(config[field], config, run); } + if (source !== undefined && source !== await roleBasedSchemaFingerprint()) { + throw new Error('Role-based SQL/DAB source changed during artifact publication; published images may exist but configuration was not updated'); + } return deploymentSchema.parse(pinned); } diff --git a/src/auth.ts b/src/auth.ts index bc98c6a..06bb853 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,6 +3,7 @@ import { createRemoteJWKSet, jwtVerify, type JWTVerifyGetKey, type JWTPayload } export interface UserIdentity { oid: string; tenantId: string; + roles?: string[]; } export type VerifyUser = (token: string) => Promise; @@ -31,7 +32,10 @@ export function userFromClaims(payload: JWTPayload, tenantId: string): UserIdent typeof payload.scp !== 'string' || !payload.scp.split(' ').includes('access_as_user')) { throw new Error('A delegated access_as_user token from the configured tenant is required'); } - return { oid: payload.oid, tenantId }; + if (payload.roles !== undefined && (!Array.isArray(payload.roles) || !payload.roles.every(role => typeof role === 'string'))) { + throw new Error('Token roles must be an array of strings'); + } + return { oid: payload.oid, tenantId, ...(payload.roles === undefined ? {} : { roles: payload.roles }) }; } export function bearerToken(header: string | undefined): string | undefined { diff --git a/src/cli.ts b/src/cli.ts index 63b12e6..a793827 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,10 @@ import { deployStatic, setSecret, updateImage } from './maintenance.js'; import { buildArtifacts } from './artifacts.js'; import { preflightDemo, DemoPreflightError } from './azure-preflight.js'; import { reviewDemoCost } from './demo-cost.js'; +import { reviewRoleBasedCost } from './role-based-cost.js'; +import { assignApplicationRole, applicationRegistrationRole } from './role-based-identity.js'; +import { roleBasedSmoke } from './role-based-smoke.js'; +import { validateRoleBasedCloudDab } from './role-based-profile.js'; import { configureRedirect, delegatedScopeId, deploy, functionRoleId, parametersFile, preflight, publishSchema, readConfig, readState, saveJson, saveState, statePath, withDeploymentLock, provision, @@ -18,12 +22,13 @@ async function main() { console.log('Usage: npm run azure -- [config.json] [secret-name]\nRun npm run build first. Azure CLI, Bicep, .NET and SqlPackage are used directly.'); console.log('Public-demo diagnostics: demo-preflight . Read-only cache/tenant/ARM/provider/region/permission evidence; no Graph, app registration, login reset or resource writes. Not deployment readiness.'); console.log('Public-demo costs: demo-cost . Offline free-allowance/fixed-charge review; no Azure login, network requests or resource writes. Exit 2 means cost intent is blocked or acknowledgement is missing; exit 1 means invalid input.'); + console.log('Role-based-data: role-based-cost is offline and profile-specific. Use validate/artifacts/identity/plan/provision/deploy/status/schema with a role-based-data deployment configuration. role-based-assign explicitly assigns its application role; role-based-smoke needs delegated tokens with and without the required role in SQL_APPS_USER_TOKEN / SQL_APPS_SECOND_USER_TOKEN. All cloud writes need operator approval; local simulations never deploy.'); return; } - if (command === 'demo-cost') { - if (process.argv.length !== 4) throw new Error('Provide an explicit public-demo cost configuration; no billing choice or spending consent is inferred.'); + if (command === 'demo-cost' || command === 'role-based-cost') { + if (process.argv.length !== 4) throw new Error('Provide an explicit matching-profile cost configuration; no billing choice or spending consent is inferred.'); const input: unknown = JSON.parse(await readFile(resolve(configPath), 'utf8')); - const report = reviewDemoCost(input); + const report = command === 'role-based-cost' ? reviewRoleBasedCost(input) : reviewDemoCost(input); console.log(JSON.stringify(report, null, 2)); if (!report.review.canProceedToWhatIf) process.exitCode = 2; return; @@ -34,10 +39,19 @@ async function main() { console.log(JSON.stringify(await preflightDemo(target, run), null, 2)); return; } - if (!['validate', 'artifacts', 'identity', 'plan', 'provision', 'deploy', 'status', 'schema', 'static', 'redirect', 'smoke', 'gateway', 'functions', 'secret-set'].includes(command)) { + if (!['validate', 'artifacts', 'identity', 'plan', 'provision', 'deploy', 'status', 'schema', 'static', 'redirect', 'smoke', 'gateway', 'functions', 'secret-set', 'role-based-assign', 'role-based-smoke'].includes(command)) { throw new Error(`Unknown command: ${command}`); } const config = await readConfig(resolve(configPath)); + if (config.profile === 'role-based-data') validateRoleBasedCloudDab(JSON.parse(await readFile('dab/dab-config.json', 'utf8')), { + profile: 'role-based-data', requiredRole: config.requiredRole!, readinessPath: config.readinessPath!, + }); + if (command === 'role-based-assign') { + if (!argument || process.argv.length !== 5) throw new Error('Provide exactly one authorized user/group object ID'); + await assignApplicationRole(config, argument, run); + console.log('Application role assignment verified or created; acquire a fresh delegated access token and run role-based-smoke.'); + return; + } if (command === 'validate') { console.log('Configuration valid'); return; } if (command === 'artifacts') { await withDeploymentLock(statePath(config), async () => { @@ -47,7 +61,7 @@ async function main() { } await saveJson(resolve(configPath), pinned); }); - console.log('Three images published to ACR; configuration atomically updated to immutable digests.'); + console.log(`${config.profile === 'role-based-data' ? 'Two' : 'Three'} images published to ACR; configuration atomically updated to immutable digests.`); return; } if (command === 'identity') { @@ -66,7 +80,7 @@ async function main() { userConsentDisplayName: 'Access SQL Apps', userConsentDescription: 'Access your application data.', }] }, spa: { redirectUris: ['http://localhost:8080'] }, - appRoles: [{ + appRoles: config.profile === 'role-based-data' ? [applicationRegistrationRole(config)] : [{ id: functionRoleId, value: 'Function.Invoke', displayName: 'Invoke functions', description: 'Allow the gateway to invoke application functions.', allowedMemberTypes: ['Application'], isEnabled: true, }], @@ -94,11 +108,22 @@ async function main() { await withDeploymentLock(statePath(config), async () => { const state = await provision(config, run); await saveState(statePath(config), state); - console.log(`Infrastructure provisioned. Configure private runner connectivity to ${state.outputs.networkId}, then run deploy.`); + console.log(`Infrastructure available (saved stage: ${state.stage}). Configure private runner connectivity to ${state.outputs.networkId}, then run deploy. Saved stages are historical, not live acceptance.`); }); return; } const state = await readState(config); + if (command === 'role-based-smoke') { + const first = process.env.SQL_APPS_USER_TOKEN; + const second = process.env.SQL_APPS_SECOND_USER_TOKEN; + if (!first || !second) throw new Error('Set delegated authorized and valid tokens without the required role in the trusted process environment; do not paste tokens into chat'); + await roleBasedSmoke(config, state.outputs.gatewayUrl, first, second); + console.log('Live authorized procedure succeeded; requests from valid users without the required role and anonymous callers were denied, including forged role headers. Browser save/reload remains separate acceptance.'); + return; + } + if (command === 'smoke' && config.profile === 'role-based-data') { + throw new Error('Use role-based-smoke for role-based-data, not the foundation file/job smoke suite'); + } if (command === 'static') { await preflight(config, run, false); await withDeploymentLock(statePath(config), async () => { diff --git a/src/config.ts b/src/config.ts index 2aa1dfc..559bd2b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { requiredRoleSchema, readinessPathSchema } from './role-based-profile.js'; const uuid = z.string().uuid(); const image = z.string().regex( @@ -7,6 +8,9 @@ const image = z.string().regex( ).refine(value => !value.endsWith(':latest'), 'Use an explicit version, not latest'); export const deploymentSchema = z.strictObject({ + profile: z.enum(['foundation', 'role-based-data']).optional(), + requiredRole: requiredRoleSchema.optional(), + readinessPath: readinessPathSchema.optional(), subscriptionId: uuid, tenantId: uuid, apiClientId: uuid, @@ -18,11 +22,18 @@ export const deploymentSchema = z.strictObject({ name: z.string().regex(/^[a-z][a-z0-9-]{2,19}$/), gatewayImage: image, dabImage: image, - functionsImage: image, + functionsImage: z.union([image, z.literal('')]).default(''), registryServer: z.string().regex(/^[a-z0-9]+\.azurecr\.io$/), }).superRefine((config, context) => { + if (config.profile === 'role-based-data') { + if (!config.requiredRole || !config.readinessPath || config.functionsImage) { + context.addIssue({ code: 'custom', message: 'Role-based-data requires requiredRole/readinessPath and excludes functionsImage' }); + } + } else if (!config.functionsImage || config.requiredRole || config.readinessPath) { + context.addIssue({ code: 'custom', message: 'Foundation requires functionsImage and does not select role-based-data settings' }); + } for (const field of ['gatewayImage', 'dabImage', 'functionsImage'] as const) { - if (!config[field].startsWith(`${config.registryServer}/`)) { + if (config[field] && !config[field].startsWith(`${config.registryServer}/`)) { context.addIssue({ code: 'custom', path: [field], message: 'Image must belong to the configured ACR' }); } } @@ -31,6 +42,9 @@ export const deploymentSchema = z.strictObject({ export type DeploymentConfig = z.infer; export const runtimeSchema = z.strictObject({ + profile: z.enum(['foundation', 'role-based-data']).optional(), + requiredRole: requiredRoleSchema.optional(), + readinessPath: readinessPathSchema.optional(), tenantId: uuid, apiClientId: uuid, dabUrl: z.url(), @@ -38,18 +52,28 @@ export const runtimeSchema = z.strictObject({ blobContainer: z.string().regex(/^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$/), functionsUrl: z.url(), publicDirectory: z.string().min(1), +}).superRefine((config, context) => { + if (config.profile === 'role-based-data' && (!config.requiredRole || !config.readinessPath)) { + context.addIssue({ code: 'custom', message: 'Role-based-data runtime requires requiredRole and readinessPath' }); + } + if (config.profile !== 'role-based-data' && (config.requiredRole || config.readinessPath)) { + context.addIssue({ code: 'custom', message: 'Role-based settings require the role-based-data profile' }); + } }); export type RuntimeConfig = z.infer; export function loadRuntimeConfig(env: NodeJS.ProcessEnv): RuntimeConfig { return runtimeSchema.parse({ + profile: env.SQL_APPS_PROFILE, + requiredRole: env.SQL_APPS_REQUIRED_ROLE, + readinessPath: env.SQL_APPS_READINESS_PATH, tenantId: env.AZURE_TENANT_ID, apiClientId: env.API_CLIENT_ID, dabUrl: env.DAB_URL, - blobAccountUrl: env.BLOB_ACCOUNT_URL, - blobContainer: env.BLOB_CONTAINER, - functionsUrl: env.FUNCTIONS_URL, + blobAccountUrl: env.BLOB_ACCOUNT_URL ?? (env.SQL_APPS_PROFILE === 'role-based-data' ? 'https://unconfigured.invalid' : undefined), + blobContainer: env.BLOB_CONTAINER ?? (env.SQL_APPS_PROFILE === 'role-based-data' ? 'files' : undefined), + functionsUrl: env.FUNCTIONS_URL ?? (env.SQL_APPS_PROFILE === 'role-based-data' ? 'https://unconfigured.invalid' : undefined), publicDirectory: env.PUBLIC_DIRECTORY ?? 'public', }); } diff --git a/src/deployment.ts b/src/deployment.ts index adf5f40..69676df 100644 --- a/src/deployment.ts +++ b/src/deployment.ts @@ -1,10 +1,11 @@ -import { mkdir, readFile, writeFile, rename, open, unlink } from 'node:fs/promises'; +import { mkdir, readFile, writeFile, rename, open, unlink, realpath } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; import { createHash, randomUUID } from 'node:crypto'; import { z } from 'zod'; import { deploymentSchema, environmentKey, type DeploymentConfig } from './config.js'; import { type Run } from './process.js'; -import { imageDigest, imageFields } from './artifacts.js'; +import { imageDigest, deploymentImageFields } from './artifacts.js'; +import { validateRoleBasedCloudDab, roleBasedProcedureGrants, roleBasedSchemaFingerprint } from './role-based-profile.js'; const outputSchema = z.object({ sqlServer: z.string().regex(/^[a-z0-9-]+\.database\.windows\.net$/), @@ -21,12 +22,14 @@ const outputSchema = z.object({ export type DeploymentOutputs = z.infer; export const functionRoleId = '15d91b1c-83c7-4cb6-b349-d7b8fd4b1425'; export const delegatedScopeId = '6bb296dd-f397-4dcc-a69b-d37dfce3c555'; +export const applicationRoleId = '530d74b7-cf30-41f4-a44c-9f594cba7c63'; export interface DeploymentState { environmentKey: string; configHash: string; stage: 'infrastructure' | 'schema' | 'runtime' | 'ready'; outputs: DeploymentOutputs; + applicationHome?: string | undefined; } export const deploymentStateSchema = z.strictObject({ @@ -34,17 +37,29 @@ export const deploymentStateSchema = z.strictObject({ configHash: z.string(), stage: z.enum(['infrastructure', 'schema', 'runtime', 'ready']), outputs: outputSchema, + applicationHome: z.string().optional(), }); export async function readState(config: DeploymentConfig): Promise { const state = deploymentStateSchema.parse(JSON.parse(await readFile(statePath(config), 'utf8'))); if (state.environmentKey !== environmentKey(config)) throw new Error('Deployment state belongs to a different environment'); + if (config.profile === 'role-based-data' && state.applicationHome !== await realpath('.')) { + throw new Error('Role-based deployment state belongs to a different application checkout; discovery or copied state is not reuse approval'); + } return state; } +export async function deploymentConfigHash(config: DeploymentConfig): Promise { + const hash = createHash('sha256').update(JSON.stringify(config)); + if (config.profile === 'role-based-data') hash.update(await realpath('.')).update(await roleBasedSchemaFingerprint()); + return hash.digest('hex'); +} + export function armParameters(config: DeploymentConfig, deployGateway: boolean) { - const { subscriptionId: _subscription, resourceGroup: _group, ...parameters } = config; - return { parameters: Object.fromEntries(Object.entries({ ...parameters, deployGateway }).map(([key, value]) => [key, { value }])) }; + const { subscriptionId: _subscription, resourceGroup: _group, readinessPath: _readiness, ...parameters } = config; + return { parameters: Object.fromEntries(Object.entries({ ...parameters, + ...(config.profile === 'role-based-data' ? { readinessPath: config.readinessPath } : {}), deployGateway }) + .filter(([, value]) => value !== undefined).map(([key, value]) => [key, { value }])) }; } export function parseOutputs(text: string): DeploymentOutputs { @@ -100,24 +115,47 @@ export async function preflight(config: DeploymentConfig, run: Run, checkImages requestedAccessTokenVersion: z.literal(2), oauth2PermissionScopes: z.array(z.object({ value: z.string(), isEnabled: z.boolean() })), }), - appRoles: z.array(z.object({ id: z.string(), value: z.string(), isEnabled: z.boolean() })), + appRoles: z.array(z.object({ id: z.string(), value: z.string(), isEnabled: z.boolean(), + allowedMemberTypes: z.array(z.string()).optional() })), }).parse(JSON.parse(await run('az', ['ad', 'app', 'show', '--id', config.apiClientId, '-o', 'json']))); if (!identity.api.oauth2PermissionScopes.some(scope => scope.value === 'access_as_user' && scope.isEnabled) || - !identity.appRoles.some(role => role.id === functionRoleId && role.value === 'Function.Invoke' && role.isEnabled)) { - throw new Error('App registration must expose access_as_user and the documented Function.Invoke role'); + !identity.appRoles.some(role => config.profile === 'role-based-data' ? + role.value === config.requiredRole && role.isEnabled && role.allowedMemberTypes?.includes('User') : + role.id === functionRoleId && role.value === 'Function.Invoke' && role.isEnabled)) { + throw new Error(config.profile === 'role-based-data' ? + 'App registration must expose access_as_user and the enabled human application role' : + 'App registration must expose access_as_user and the documented Function.Invoke role'); } await run('az', ['acr', 'show', '--name', config.registryServer.split('.')[0]!, '--resource-group', config.resourceGroup, '--subscription', config.subscriptionId, '-o', 'none']); if (checkImages) { - for (const field of imageFields) await imageDigest(config[field], config, run); + for (const field of deploymentImageFields(config)) await imageDigest(config[field], config, run); } - for (const namespace of ['Microsoft.App', 'Microsoft.Sql', 'Microsoft.Storage', 'Microsoft.Web', 'Microsoft.Network', 'Microsoft.KeyVault', 'Microsoft.ManagedIdentity', 'Microsoft.OperationalInsights', 'Microsoft.Insights', 'Microsoft.Authorization']) { + if (config.profile === 'role-based-data') validateRoleBasedCloudDab(JSON.parse(await readFile('dab/dab-config.json', 'utf8')), { + profile: 'role-based-data', requiredRole: config.requiredRole!, readinessPath: config.readinessPath!, + }); + const providers = ['Microsoft.App', 'Microsoft.Sql', 'Microsoft.Network', 'Microsoft.ManagedIdentity', + 'Microsoft.OperationalInsights', 'Microsoft.Authorization']; + if (config.profile !== 'role-based-data') providers.push('Microsoft.Storage', 'Microsoft.Web', 'Microsoft.KeyVault', 'Microsoft.Insights'); + for (const namespace of providers) { const status = (await run('az', ['provider', 'show', '--namespace', namespace, '--subscription', config.subscriptionId, '--query', 'registrationState', '-o', 'tsv'])).trim(); if (status !== 'Registered') throw new Error(`Register resource provider ${namespace} before deploying`); } } export async function provision(config: DeploymentConfig, run: Run): Promise { + const configHash = await deploymentConfigHash(config); await preflight(config, run); + if (config.profile === 'role-based-data') { + let previous: DeploymentState | undefined; + try { previous = await readState(config); } + catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; } + if (previous) { + if (previous.configHash !== configHash) { + throw new Error('Role-based configuration or SQL/DAB source changed; provisioning cannot overwrite the saved deployment subject'); + } + return previous; + } + } const outputs = parseOutputs(await run('az', [ 'deployment', 'group', 'create', '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, '--name', `${config.name}-${config.environment}-infrastructure`, '--template-file', 'infra/main.bicep', @@ -125,9 +163,10 @@ export async function provision(config: DeploymentConfig, run: Run): Promise Promise = state => saveState(statePath(config), state), ): Promise { await preflight(config, run); - const configHash = createHash('sha256').update(JSON.stringify(config)).digest('hex'); + const configHash = await deploymentConfigHash(config); const deployResources = async (enabled: boolean) => parseOutputs(await run('az', [ 'deployment', 'group', 'create', '--subscription', config.subscriptionId, '--resource-group', config.resourceGroup, '--name', `${config.name}-${config.environment}-${enabled ? 'runtime' : 'infrastructure'}`, '--template-file', 'infra/main.bicep', '--parameters', `@${await parametersFile(config, enabled)}`, '--query', 'properties.outputs', '-o', 'json', ])); - let state: DeploymentState = { environmentKey: environmentKey(config), configHash, stage: 'infrastructure', outputs: await deployResources(false) }; + let previous: DeploymentState | undefined; + if (config.profile === 'role-based-data') { + try { previous = await readState(config); } + catch (error) { if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error; } + if (previous && previous.configHash !== configHash) throw new Error('Role-based deployment configuration or SQL/DAB source changed; review the prior deployment before resuming'); + } + let state: DeploymentState = previous ?? { environmentKey: environmentKey(config), configHash, stage: 'infrastructure', + outputs: await deployResources(false), ...(config.profile === 'role-based-data' ? { applicationHome: await realpath('.') } : {}) }; await persist(state); - await publishSchema(config, state.outputs, run); + if (!previous || previous.stage === 'infrastructure') await publishSchema(config, state.outputs, run); + if (config.profile === 'role-based-data') { + if (!previous || ['infrastructure', 'schema'].includes(previous.stage)) { + state = { ...state, stage: 'schema' }; + await persist(state); + state = { ...state, stage: 'runtime', outputs: await deployResources(true) }; + await persist(state); + } + await configureRedirect(config, state.outputs.gatewayUrl, run); + return await waitForReadiness(config, state, fetcher, persist); + } state = { ...state, stage: 'schema' }; await persist(state); const apiServicePrincipal = z.string().uuid().parse( @@ -216,6 +277,27 @@ export async function deploy( throw new Error('Runtime deployed but readiness failed. Deployment state remains at runtime; inspect Container Apps logs.'); } +export async function waitForReadiness( + config: DeploymentConfig, state: DeploymentState, fetcher: typeof fetch, + persist: (state: DeploymentState) => Promise, +): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + try { + const response = await fetcher(new URL('/health/ready', state.outputs.gatewayUrl), + { signal: AbortSignal.timeout(5_000), redirect: 'error' }); + if (response.ok) { + const ready = { ...state, stage: 'ready' as const }; + await persist(ready); + return ready; + } + } catch (error) { + console.error(`Role-based-data readiness attempt ${attempt + 1} failed: ${error instanceof Error ? error.name : 'UnknownError'}`); + } + if (attempt < 29) await new Promise(done => setTimeout(done, 10_000)); + } + throw new Error(`Role-based-data runtime deployed but not ready for ${environmentKey(config)}; state retained, authenticated procedure acceptance still required`); +} + export async function configureRedirect(config: DeploymentConfig, origin: string, run: Run) { const parsed = new URL(origin); if (parsed.protocol !== 'https:' || parsed.origin !== origin) throw new Error('HTTPS origin required'); diff --git a/src/gateway.ts b/src/gateway.ts index 55876cd..c61549f 100644 --- a/src/gateway.ts +++ b/src/gateway.ts @@ -27,6 +27,7 @@ export interface GatewayDependencies { requestGuard?: (request: FastifyRequest, reply: FastifyReply) => Promise; processing?: FileProcessing; telemetry?: LocalTracing; + ready?: () => Promise; } interface AuthenticatedRequest extends FastifyRequest { @@ -70,6 +71,10 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway app.get('/health/live', async () => ({ status: 'live' })); app.get('/health/ready', async (_request, reply) => { try { + if (dependencies.ready) { + await dependencies.ready(); + return { status: 'ready' }; + } const response = await dependencies.fetch(new URL('/health', config.dabUrl), { signal: AbortSignal.timeout(5_000) }); if (!response.ok) return reply.code(503).send({ status: 'not-ready' }); return { status: 'ready' }; @@ -85,7 +90,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway tenantId: config.tenantId, clientId: config.apiClientId, scope: `api://${config.apiClientId}/access_as_user`, - capabilities: { files: true, functions: true }, + capabilities: { files: config.profile !== 'role-based-data', functions: config.profile !== 'role-based-data' }, }; }); await app.register(async protectedRoutes => { @@ -94,6 +99,9 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway if (!token) return reply.code(401).send({ error: 'Bearer access token required' }); try { const identity = await dependencies.verifyUser(token); + if (config.profile === 'role-based-data' && !identity.roles?.includes(config.requiredRole!)) { + return reply.code(403).send({ error: 'Authorized role required' }); + } const authenticated = request as AuthenticatedRequest; authenticated.identity = identity; authenticated.accessToken = token; @@ -118,6 +126,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway handler: proxyData, }); protectedRoutes.post('/graphql', proxyData); + if (config.profile !== 'role-based-data') { protectedRoutes.get('/storage', async (request, reply) => { if (!dependencies.files.list) return reply.code(503).send({ error: 'File listing unavailable' }); const identity = (request as AuthenticatedRequest).identity!; @@ -155,6 +164,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway if (!params.success) return reply.code(400).send({ error: 'Invalid job id' }); return reply.code(await dependencies.processing.delete((request as AuthenticatedRequest).identity!, params.data.id) ? 204 : 404).send(); }); + } async function proxyData(request: FastifyRequest, reply: import('fastify').FastifyReply) { const url = new URL(config.dabUrl); @@ -163,6 +173,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway url.search = incoming.search; const headers = new Headers({ authorization: `Bearer ${(request as AuthenticatedRequest).accessToken}` }); if (request.headers['if-match'] === '*') headers.set('if-match', '*'); + if (config.profile === 'role-based-data') headers.set('x-ms-api-role', config.requiredRole!); if (request.body !== undefined) headers.set('content-type', 'application/json'); const response = await dependencies.fetch(url, { method: request.method, @@ -176,6 +187,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway return reply.send(Buffer.from(await response.arrayBuffer())); } + if (config.profile !== 'role-based-data') { protectedRoutes.route({ method: ['PUT', 'GET', 'DELETE'], url: '/storage/:name', @@ -214,6 +226,7 @@ export async function createGateway(config: RuntimeConfig, dependencies: Gateway }); return reply.code(response.status).type('application/json').send(Buffer.from(await response.arrayBuffer())); }); + } }); await app.register(fastifyStatic, { root: resolve(config.publicDirectory), index: ['index.html'] }); return app; diff --git a/src/local-app.ts b/src/local-app.ts index 53f6a82..5feb17c 100644 --- a/src/local-app.ts +++ b/src/local-app.ts @@ -5,6 +5,7 @@ import { createGateway } from './gateway.js'; import { localOrigin, localPrincipal } from './local.js'; import { localFunctionsOrigin, localServiceAdapters } from './local-services.js'; import { runtimeFor } from './workspace.mjs'; +import type { RoleBasedProfile } from './role-based-profile.js'; type LocalAdapters = Awaited>; @@ -15,8 +16,9 @@ export const developmentUsers = [ { id: '22222222-2222-4222-8222-222222222222', name: 'Development Bob' }, ]; -export async function createLocalApp(fetcher: typeof fetch = fetch, services?: LocalAdapters) { +export async function createLocalApp(fetcher: typeof fetch = fetch, services?: LocalAdapters, authorized?: RoleBasedProfile) { if (process.env.NODE_ENV === 'production') throw new Error('Local development server is disabled in production'); + if (authorized && services) throw new Error('Role-based-data excludes storage and Functions adapters'); const sessions = new Map(); const identity = async (token: string) => { const session = sessions.get(token); @@ -24,12 +26,15 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L sessions.delete(token); throw new Error('Local session expired; select a development user again'); } - return { oid: session.oid, tenantId }; + return { oid: session.oid, tenantId, ...(authorized ? { + roles: session.oid === developmentUsers[0]!.id ? [authorized.requiredRole] : [], + } : {}) }; }; const unavailable = async (): Promise => { throw Object.assign(new Error('This service is not configured in the local SQL development stack'), { statusCode: 503 }); }; const app = await createGateway({ + ...authorized, tenantId, apiClientId: tenantId, dabUrl: localOrigin, blobAccountUrl: 'https://unconfigured.invalid', blobContainer: 'files', functionsUrl: services ? localFunctionsOrigin : 'https://unconfigured.invalid', publicDirectory: 'public', @@ -38,7 +43,17 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L files: services?.files ?? { put: unavailable, get: unavailable, delete: unavailable }, functionToken: services?.functionToken ?? unavailable, ...(services ? { processing: services.processing, ...(services.telemetry ? { telemetry: services.telemetry } : {}) } : {}), - browserConfig: { mode: 'local', users: developmentUsers, capabilities: { + ...(authorized ? { ready: async () => { + const response = await fetcher(new URL(authorized.readinessPath, localOrigin), { + headers: { 'x-ms-client-principal': localPrincipal(developmentUsers[0]!.id, 'access_as_user', authorized.requiredRole), + 'x-ms-api-role': authorized.requiredRole }, + signal: AbortSignal.timeout(5_000), redirect: 'error', + }); + if (!response.ok) throw new Error(`Authorized procedure readiness failed: HTTP ${response.status}`); + } } : {}), + browserConfig: { mode: 'local', users: authorized ? developmentUsers.map((user, index) => ({ + ...user, name: `${user.name} (${index === 0 ? 'simulated user with required role' : 'simulated user without required role'})`, + })) : developmentUsers, capabilities: { files: Boolean(services), functions: Boolean(services), ...(services ? { processing: true, jobSubmission: services.settings.processing, jobRetry: services.settings.processing && services.settings.jobRetry, @@ -65,7 +80,8 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L const user = await identity(token); headers.delete('authorization'); headers.delete('x-ms-api-role'); - headers.set('x-ms-client-principal', localPrincipal(user.oid)); + headers.set('x-ms-client-principal', localPrincipal(user.oid, 'access_as_user', authorized?.requiredRole ?? 'authenticated')); + if (authorized) headers.set('x-ms-api-role', authorized.requiredRole); return fetcher(target, { ...options, headers }); }, }); @@ -95,9 +111,26 @@ export async function createLocalApp(fetcher: typeof fetch = fetch, services?: L return app; } -export async function serveLocalApp(container = runtimeFor().defaultSql, sqlOnly = false): Promise { - const app = await createLocalApp(fetch, sqlOnly ? undefined : await localServiceAdapters(container)); +export async function serveLocalApp(container = runtimeFor().defaultSql, sqlOnly = false, authorized?: RoleBasedProfile): Promise { + const app = await createLocalApp(fetch, sqlOnly ? undefined : await localServiceAdapters(container), authorized); + if (authorized) { + const ready = await app.inject({ url: '/health/ready', headers: { host: new URL(localAppOrigin).host } }); + if (ready.statusCode !== 200) { + await app.close(); + throw new Error('Role-based-data SQL procedure readiness failed; gateway was not launched'); + } + } await app.listen({ host: '127.0.0.1', port: runtimeFor(container).ports.gateway }); + if (authorized) { + try { + const response = await fetch(new URL('/health/ready', localAppOrigin), + { signal: AbortSignal.timeout(5_000), redirect: 'error' }); + if (!response.ok) throw new Error(`Running role-based-data readiness returned HTTP ${response.status}`); + } catch (error) { + await app.close(); + throw new Error('Role-based-data gateway did not respond as ready; no launch URL is published', { cause: error }); + } + } console.log(`Local browser app ready at ${localAppOrigin}. Development identities are simulated; do not expose remotely.`); for (const signal of ['SIGINT', 'SIGTERM'] as const) { process.once(signal, () => { void app.close(); }); diff --git a/src/local-cli.ts b/src/local-cli.ts index 2418865..278255f 100644 --- a/src/local-cli.ts +++ b/src/local-cli.ts @@ -1,4 +1,6 @@ -import { localCommand, localOrigin } from './local.js'; +import { localCommand, localOrigin, sqlImage, dabImage } from './local.js'; +import { readRoleBasedProfile, validateRoleBasedDab } from './role-based-profile.js'; +import { readFile } from 'node:fs/promises'; import { run } from './process.js'; import { startLocalServices, stopLocalServices, runLocalMaintenance, localServiceImages } from './local-services.js'; import { startLocalApplication } from './local-startup.js'; @@ -11,6 +13,7 @@ const container = selection ?? runtimeFor().defaultSql; if (command === 'help') { console.log('Usage: npm run local -- [sql-container-name]\nWorkspace: workspace-plan | workspace-init . Select isolation or explicit legacy compatibility before app startup. workspace-plan is read-only; initialization records selection, never deletes existing resources. app starts SQL/DAB/Azurite/Functions/browser; serve reuses running services. app-check inspects legacy sample objects without dropping data. services-test restarts only verified owned services and needs separate approval. stop-services preserves data. No Azure subscription or Entra registration required. Loopback-only claim simulation is not production authentication.'); console.log('SQL recovery: recover-sql [workspace-sql-container] explicitly recreates only a stopped isolated SQL container, preserving its image version, credentials, ports and labeled volume. Obtain approval first; no volume reset or external/legacy recovery.'); + console.log('Role-based-data: role-based-app [sql-container] starts only SQL/DAB/browser using role-based-data.json and authorized procedure permissions in dab/dab-config.json. role-based-serve resumes that profile. Alice is simulated user with required role; Bob is simulated user without required role. Neither is production sign-in.'); console.log('Selected application: selected-app provisions only its isolated SQL database/login and DAB, then serves the browser. selected-serve reuses verified running services. selected-stop removes only that owned DAB container and preserves SQL data. Build with npm run app:build -- local-simulation first. These commands never select an example in the default application.'); console.log('Selected acceptance: selected-test runs the opted-in example acceptance suite against its already-running local app; obtain approval for synthetic writes and scoped cleanup first.'); } else { @@ -35,21 +38,23 @@ if (command === 'help') { } else if (command === 'workspace-init') { if (!selection) throw new Error('Explicitly select workspace-init or workspace-init legacy after reviewing workspace-plan.'); console.log(JSON.stringify(await initializeWorkspace(undefined, selection === 'legacy' ? 'legacy' : Number(selection)), null, 2)); - } else if (command === 'app' || command === 'serve') { + } else if (command === 'app' || command === 'serve' || command === 'role-based-app' || command === 'role-based-serve') { if (process.env.NODE_ENV === 'production') throw new Error('Local development server is disabled in production'); + const profile = command.startsWith('role-based-') ? await readRoleBasedProfile() : undefined; + if (profile) validateRoleBasedDab(JSON.parse(await readFile('dab/dab-config.json', 'utf8')), profile); const { serveLocalApp } = await import('./local-app.js'); - if (command === 'app') { + if (command === 'app' || command === 'role-based-app') { await startLocalApplication(container, { command: async (step, name) => { if (step === 'start-sql') { if (runtimeFor(name).mode === 'legacy-unselected') throw new Error('Explicit workspace selection required: run workspace-plan, then approve workspace-init or workspace-init legacy.'); - await checkWorkspacePorts(name, run, localServiceImages(name)); + await checkWorkspacePorts(name, run, profile ? { sql: sqlImage, data: dabImage } : localServiceImages(name)); } - await localCommand(step, name, run); + await localCommand(profile && step === 'start-sql' && !runtimeFor(name).ownsSqlName ? 'verify' : step, name, run, profile); }, - services: startLocalServices, serve: serveLocalApp, - }); - } else await serveLocalApp(container); + services: startLocalServices, serve: name => serveLocalApp(name, Boolean(profile), profile), + }, console.log, Boolean(profile)); + } else await serveLocalApp(container, Boolean(profile), profile); } else if (command === 'serve-sql') { const { serveLocalApp } = await import('./local-app.js'); await serveLocalApp(container, true); @@ -66,7 +71,7 @@ if (command === 'help') { const { testLocalApp } = await import('./local-app-smoke.js'); await testLocalApp(); } else await localCommand(command, container, run); - if (!['app', 'serve', 'serve-sql', 'selected-app', 'selected-serve', 'workspace-plan', 'workspace-init'].includes(command)) { + if (!['app', 'serve', 'serve-sql', 'role-based-app', 'role-based-serve', 'selected-app', 'selected-serve', 'workspace-plan', 'workspace-init'].includes(command)) { console.log(command === 'data' ? `Local DAB ready at ${localOrigin}; use npm run local -- api-test` : `Local ${command} passed`); } } catch (error) { diff --git a/src/local-ownership.ts b/src/local-ownership.ts index bc66f0a..fb87166 100644 --- a/src/local-ownership.ts +++ b/src/local-ownership.ts @@ -30,9 +30,11 @@ export async function checkWorkspacePorts(container: string, runner: Run, images const components: [number | null, string | null, string | null][] = [ [runtime.ports.gateway, null, null], [runtime.ports.data, runtime.names.data, 'data'], - [runtime.ports.functions, runtime.names.functions, 'functions'], - [runtime.ports.blob, runtime.names.storage, 'storage'], - [runtime.ports.queue, runtime.names.storage, 'storage'], + ...(images.functions ? [[runtime.ports.functions, runtime.names.functions, 'functions'] as [number, string, string]] : []), + ...(images.storage ? [ + [runtime.ports.blob, runtime.names.storage, 'storage'] as [number, string, string], + [runtime.ports.queue, runtime.names.storage, 'storage'] as [number, string, string], + ] : []), ]; if (runtime.ownsSqlName) components.push([runtime.ports.sql, container, 'sql']); for (const [port, name, role] of components) { diff --git a/src/local-startup.ts b/src/local-startup.ts index 3ba4cea..41e0c33 100644 --- a/src/local-startup.ts +++ b/src/local-startup.ts @@ -5,7 +5,7 @@ export interface LocalStartupActions { } export async function startLocalApplication( - container: string, actions: LocalStartupActions, report: (message: string) => void = console.log, + container: string, actions: LocalStartupActions, report: (message: string) => void = console.log, dataOnly = false, ): Promise { const stages = [ { @@ -20,10 +20,10 @@ export async function startLocalApplication( name: 'Data API', run: () => actions.command('data', container), recovery: 'Check the workspace data container and selected DAB port reported by workspace-check. Retry data for this SQL container after resolving the conflict; never stop unrelated services.', }, - { + ...(!dataOnly ? [{ name: 'Storage and Functions', run: () => actions.services(container), recovery: 'Preserve volumes. Check downloads/builds and workspace Blob/Queue/Functions ports reported by workspace-check; retry services for the same SQL container.', - }, + }] : []), { name: 'Browser gateway', run: () => actions.serve(container), recovery: 'Check who owns the workspace gateway port and reuse the intended app. If free, retry serve for this SQL container; choose your development user after restart.', diff --git a/src/local.ts b/src/local.ts index 3b79c58..84787e2 100644 --- a/src/local.ts +++ b/src/local.ts @@ -9,6 +9,7 @@ import { localJobData } from './local-job-data.js'; import { runtimeFor } from './workspace.mjs'; import { verifyContainerWorkspace } from './local-ownership.js'; import { pinnedDabImage } from './artifacts.js'; +import { type RoleBasedProfile, validateRoleBasedDab, roleBasedProcedureGrants } from './role-based-profile.js'; const database = runtimeFor().database; const login = runtimeFor().login; @@ -259,7 +260,7 @@ export async function publishLocalSchema(container: string, target: { { redact: [adminPassword, adminPassword.replaceAll('"', '""')] }); } -export async function initLocal(container: string, run: Run): Promise { +export async function initLocal(container: string, run: Run, authorized?: RoleBasedProfile): Promise { const { stateFile } = localPaths(container); await withDeploymentLock(stateFile, async () => { await verifyLocal(container, run); @@ -275,16 +276,19 @@ IF DB_ID(N'${database}') IS NULL CREATE DATABASE [${database}]; IF SUSER_ID(N'${login}') IS NULL CREATE LOGIN [${login}] WITH PASSWORD = N'${state.password}'; `, run, [state.password]); await publishLocalSchema(container, { database, project: join('sql', 'database.sqlproj'), output: join('dist', 'sql') }, run); + const grants = authorized ? roleBasedProcedureGrants( + JSON.parse(await readFile('dab/dab-config.json', 'utf8')), authorized, login, + ) : `GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.FileJobs TO [${login}]; +GRANT VIEW DEFINITION ON dbo.FileJobs TO [${login}];`; try { await databaseQuery(state, ` IF DATABASE_PRINCIPAL_ID(N'${login}') IS NULL CREATE USER [${login}] FOR LOGIN [${login}]; -GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.FileJobs TO [${login}]; -GRANT VIEW DEFINITION ON dbo.FileJobs TO [${login}]; +${grants} `, run, true); } catch (error) { throw new Error('Local app-user setup failed. Older preview images may reject login mapping; use start-sql with a new container name and the current azure-sql/db-dev image. Runtime will not fall back to sa.', { cause: error }); } - await testLocalSql(state, run); + if (!authorized) await testLocalSql(state, run); }); } @@ -311,9 +315,11 @@ export function localPrincipal(oid: string, scope = 'access_as_user', role = 'au })).toString('base64'); } -export async function localDabConfig(): Promise { +export async function localDabConfig(authorized?: RoleBasedProfile): Promise { const config = JSON.parse(await readFile('dab/dab-config.json', 'utf8')); + if (authorized) validateRoleBasedDab(config, authorized); config.runtime.host = { mode: 'development', authentication: { provider: 'AppService' } }; + if (authorized) return config; config.entities.FileJob = { source: { object: 'dbo.FileJobs', type: 'table' }, rest: { enabled: true }, graphql: { enabled: true }, permissions: [ @@ -325,12 +331,16 @@ export async function localDabConfig(): Promise { return config; } -export async function startLocalData(container: string, run: Run): Promise { +export async function startLocalData(container: string, run: Run, authorized?: RoleBasedProfile): Promise { const runtime = runtimeFor(container); const state = await readLocal(container); const { dataContainer, configFile: path } = localPaths(container); - await testLocalSql(state, run); - await saveJson(path, await localDabConfig()); + const config = await localDabConfig(authorized); + if (authorized) { + await verifyLocal(container, run); + await databaseQuery(state, roleBasedProcedureGrants(config, authorized, login), run, true); + } else await testLocalSql(state, run); + await saveJson(path, config); const networks = z.record(z.string(), z.object({ IPAddress: z.ipv4() })) .parse(JSON.parse(await run('docker', ['inspect', container, '--format', '{{json .NetworkSettings.Networks}}']))); const network = Object.entries(networks)[0]; @@ -459,17 +469,17 @@ async function assertLocalDenied(action: () => Promise): Promise throw new Error('Forbidden local DAB mutation succeeded'); } -export async function localCommand(command: string, container: string, run: Run): Promise { +export async function localCommand(command: string, container: string, run: Run, authorized?: RoleBasedProfile): Promise { if (command === 'start-sql') await startLocalSql(container, run); else if (command === 'recover-sql') await recoverLocalSql(container, run); else if (command === 'verify') await verifyLocal(container, run); - else if (command === 'init') await initLocal(container, run); + else if (command === 'init') await initLocal(container, run, authorized); else if (command === 'test') await testLocalSql(await readLocal(container), run); else if (command === 'app-check') { const result = await databaseQuery(await readLocal(container), await readFile('sql/local/application-acceptance.sql', 'utf8'), run, true); if (!result.includes('CLEAN APPLICATION DATABASE PASSED')) throw new Error('Clean application database acceptance did not report success'); } - else if (command === 'data') await startLocalData(container, run); + else if (command === 'data') await startLocalData(container, run, authorized); else if (command === 'api-test') await testLocalData(); else if (command === 'stop') { const data = localPaths(container).dataContainer; diff --git a/src/maintenance.ts b/src/maintenance.ts index 8fae3ea..d122201 100644 --- a/src/maintenance.ts +++ b/src/maintenance.ts @@ -31,6 +31,7 @@ export async function deployStatic( export async function updateImage( target: 'gateway' | 'functions', config: DeploymentConfig, state: DeploymentState, run: Run, ): Promise { + if (target === 'functions' && config.profile === 'role-based-data') throw new Error('Functions excluded by role-based-data profile'); if (!state.outputs.gatewayUrl) throw new Error('Complete a full deployment before image-only updates'); if (target === 'gateway') { await run('az', ['containerapp', 'update', '--name', state.outputs.gatewayName, '--resource-group', config.resourceGroup, @@ -45,6 +46,7 @@ export async function updateImage( export async function setSecret( name: string, value: string, config: DeploymentConfig, state: DeploymentState, run: Run, ): Promise { + if (config.profile === 'role-based-data') throw new Error('Key Vault excluded by role-based-data profile'); if (!/^[a-zA-Z0-9-]{1,127}$/.test(name)) throw new Error('Invalid Key Vault secret name'); if (!value) throw new Error('Secret value must not be empty'); await run('az', ['keyvault', 'secret', 'set', '--vault-name', state.outputs.vaultName, diff --git a/src/role-based-cost.ts b/src/role-based-cost.ts new file mode 100644 index 0000000..d175da7 --- /dev/null +++ b/src/role-based-cost.ts @@ -0,0 +1,40 @@ +import { z } from 'zod'; + +const schema = z.strictObject({ + version: z.literal(1), profile: z.literal('role-based-data'), + intent: z.enum(['zero-azure-spend', 'review-paid-costs']), + acknowledgeFixedCharges: z.boolean(), +}).refine(value => value.intent !== 'zero-azure-spend' || !value.acknowledgeFixedCharges, + 'Zero-spend intent cannot acknowledge paid charges'); + +export function reviewRoleBasedCost(input: unknown) { + const config = schema.parse(input); + const permitted = config.intent === 'review-paid-costs' && config.acknowledgeFixedCharges; + return { + version: 1, profile: config.profile, intent: config.intent, + review: { canProceedToWhatIf: permitted, + status: permitted ? 'ready-for-target-cost-review' : 'blocked-by-paid-infrastructure', + nextAction: permitted ? 'Review current regional/account prices and obtain separate target/what-if approval.' : + 'Stay local or separately review paid SQL, registry and private-network charges; no paid fallback is authorized.' }, + estimatedMonthlyCost: null, pricingStatus: 'not-quoted-region-currency-and-account-review-required', + deployment: { authorized: false, ready: false }, + resources: [ + { name: 'Azure SQL', sku: 'S0', minCapacity: 10, billing: 'paid provisioned database, not free-offer SQL' }, + { name: 'ACR', sku: 'not-verified', billing: 'configured dedicated registry; review its actual tier, storage and build charges' }, + { name: 'Gateway and DAB', sku: 'Container Apps Consumption', minReplicas: 1, maxReplicas: 2, + each: { vCpu: 0.5, memoryGiB: 1 }, billing: 'both services have always-on allocation; shared grants are not a cap' }, + { name: 'Private networking', billing: 'SQL private endpoint, DNS and managed VNet infrastructure charges' }, + { name: 'Log Analytics', billing: 'ingestion and retention charges' }, + ], + excluded: ['Functions', 'Blob/Queue/Table storage', 'Key Vault', 'Functions/storage/vault private endpoints'], + cleanup: 'Review/export application data and remove only explicitly owned resources with separate approval; discovery is not reuse consent.', + monitoring: 'Review actual/forecast costs in app and managed infrastructure groups. Budgets are delayed alerts, not hard caps.', + references: { + sql: 'https://azure.microsoft.com/pricing/details/azure-sql-database/single/', + containers: 'https://azure.microsoft.com/pricing/details/container-apps/', + registry: 'https://azure.microsoft.com/pricing/details/container-registry/', + network: 'https://azure.microsoft.com/pricing/details/private-link/', + logs: 'https://azure.microsoft.com/pricing/details/monitor/', + }, + }; +} diff --git a/src/role-based-identity.ts b/src/role-based-identity.ts new file mode 100644 index 0000000..5606890 --- /dev/null +++ b/src/role-based-identity.ts @@ -0,0 +1,47 @@ +import { mkdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { z } from 'zod'; +import type { DeploymentConfig } from './config.js'; +import type { Run } from './process.js'; +import { saveJson, applicationRoleId } from './deployment.js'; + +export async function assignApplicationRole(config: DeploymentConfig, principal: string, run: Run): Promise { + if (config.profile !== 'role-based-data') throw new Error('Application role assignment requires the role-based-data profile'); + const principalId = z.string().uuid().parse(principal); + const tenant = z.object({ tenantId: z.string() }).parse(JSON.parse(await run('az', ['account', 'show', '-o', 'json']))); + if (tenant.tenantId !== config.tenantId) throw new Error('Sign into the configured tenant before application role assignment'); + const service = z.object({ id: z.string().uuid(), appRoles: z.array(z.object({ + id: z.string().uuid(), value: z.string(), isEnabled: z.boolean(), allowedMemberTypes: z.array(z.string()), + })) }).parse(JSON.parse(await run('az', ['ad', 'sp', 'show', '--id', config.apiClientId, '-o', 'json']))); + const role = service.appRoles.find(value => value.value === config.requiredRole && value.isEnabled && + value.allowedMemberTypes.includes('User')); + if (!role) throw new Error('The enabled human application role is missing from this service principal'); + const assignmentsUrl = `https://graph.microsoft.com/v1.0/servicePrincipals/${service.id}/appRoleAssignedTo`; + let url: string | undefined = assignmentsUrl; + const visited = new Set(); + while (url) { + if ((url !== assignmentsUrl && !url.startsWith(`${assignmentsUrl}?`)) || visited.has(url)) { + throw new Error('Invalid or repeated application role assignment continuation URL'); + } + visited.add(url); + const page = z.object({ value: z.array(z.object({ + principalId: z.string(), appRoleId: z.string(), resourceId: z.string(), + })), '@odata.nextLink': z.string().optional() }).parse(JSON.parse( + await run('az', ['rest', '--method', 'GET', '--url', url, '-o', 'json']), + )); + if (page.value.some(item => item.principalId === principalId && item.appRoleId === role.id && item.resourceId === service.id)) return; + url = page['@odata.nextLink']; + } + await mkdir('.sql-apps', { recursive: true }); + const path = resolve('.sql-apps', `role-assignment-${principalId}.json`); + await saveJson(path, { principalId, resourceId: service.id, appRoleId: role.id }); + await run('az', ['rest', '--method', 'POST', '--url', assignmentsUrl, '--body', `@${path}`, '-o', 'none']); +} + +export function applicationRegistrationRole(config: DeploymentConfig) { + return { + id: applicationRoleId, value: config.requiredRole, displayName: 'Application user', + description: 'Allow users assigned this role to use this data application.', + allowedMemberTypes: ['User'], isEnabled: true, + }; +} diff --git a/src/role-based-profile.ts b/src/role-based-profile.ts new file mode 100644 index 0000000..c9b7953 --- /dev/null +++ b/src/role-based-profile.ts @@ -0,0 +1,81 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { z } from 'zod'; + +export const requiredRoleSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._-]{0,63}$/) + .refine(value => !['anonymous', 'authenticated', 'Function.Invoke', 'processor'].includes(value), + 'Choose a dedicated human application role'); +export const readinessPathSchema = z.string().regex(/^\/api\/[A-Za-z][A-Za-z0-9_-]{0,63}$/); +export const roleBasedProfileSchema = z.strictObject({ + profile: z.literal('role-based-data'), + requiredRole: requiredRoleSchema, + readinessPath: readinessPathSchema, +}); +export type RoleBasedProfile = z.infer; + +export async function readRoleBasedProfile(path = 'role-based-data.json'): Promise { + return roleBasedProfileSchema.parse(JSON.parse(await readFile(path, 'utf8'))); +} + +export function validateRoleBasedDab(input: unknown, profile: RoleBasedProfile): void { + const config = z.object({ + autoentities: z.never().optional(), + entities: z.record(z.string(), z.object({ + source: z.object({ type: z.literal('stored-procedure'), object: z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*\.[A-Za-z_][A-Za-z0-9_]*$/) }), + rest: z.union([z.boolean(), z.object({ enabled: z.boolean(), path: z.string().optional(), + methods: z.array(z.enum(['get', 'post'])).optional() })]), + permissions: z.array(z.object({ role: z.string(), actions: z.array(z.literal('execute')).min(1) })).min(1), + })).refine(entities => Object.keys(entities).length > 0, 'Define authorized procedures'), + }).parse(input); + for (const [name, entity] of Object.entries(config.entities)) { + if (name === 'FileJob' || entity.permissions.some(permission => permission.role !== profile.requiredRole)) { + throw new Error('Role-based-data DAB entities must expose only approved authorized procedures, not foundation or anonymous roles'); + } + } + const ready = Object.entries(config.entities).find(([name, entity]) => + `/api/${typeof entity.rest === 'object' && entity.rest.path ? entity.rest.path : name}` === profile.readinessPath && + typeof entity.rest === 'object' && entity.rest.enabled && entity.rest.methods?.includes('get')); + if (!ready) throw new Error('Role-based-data readinessPath must identify an enabled read-only REST readiness procedure'); +} + +export function roleBasedProcedureGrants(input: unknown, profile: RoleBasedProfile, user: string): string { + validateRoleBasedDab(input, profile); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(user)) throw new Error('Invalid authorized SQL principal'); + const entities = z.object({ entities: z.record(z.string(), z.object({ + source: z.object({ object: z.string() }), + })) }).parse(input).entities; + return [...new Set(Object.values(entities).map(entity => entity.source.object))].map(object => { + const [schema, name] = object.split('.'); + return `GRANT EXECUTE ON OBJECT::[${schema}].[${name}] TO [${user}];\nGRANT VIEW DEFINITION ON OBJECT::[${schema}].[${name}] TO [${user}];`; + }).join('\n'); +} + +export function validateRoleBasedCloudDab(input: unknown, profile: RoleBasedProfile): void { + validateRoleBasedDab(input, profile); + z.object({ runtime: z.object({ + rest: z.object({ enabled: z.literal(true), path: z.literal('/api') }), + host: z.object({ mode: z.literal('production'), authentication: z.object({ + provider: z.literal('AzureAD'), + jwt: z.object({ audience: z.literal("@env('API_CLIENT_ID')"), issuer: z.literal("@env('ENTRA_ISSUER')") }), + }) }), + }) }).parse(input); +} + +export async function roleBasedSchemaFingerprint(): Promise { + const hash = createHash('sha256').update(await readFile(join('dab', 'dab-config.json'))); + async function add(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (entry.isSymbolicLink()) throw new Error('Role-based schema inputs must not be symlinks'); + const path = join(directory, entry.name); + if (entry.isDirectory()) { + if (!['obj', 'bin'].includes(entry.name)) await add(path); + } else if (/\.(sql|sqlproj)$/.test(entry.name)) { + hash.update(path).update('\0').update(await readFile(path)).update('\0'); + } + } + } + await add('sql'); + return hash.digest('hex'); +} diff --git a/src/role-based-smoke.ts b/src/role-based-smoke.ts new file mode 100644 index 0000000..ccc437b --- /dev/null +++ b/src/role-based-smoke.ts @@ -0,0 +1,19 @@ +import type { DeploymentConfig } from './config.js'; + +export async function roleBasedSmoke( + config: DeploymentConfig, origin: string, authorizedToken: string, unauthorizedToken: string, fetcher: typeof fetch = fetch, +): Promise { + if (config.profile !== 'role-based-data' || !config.readinessPath) throw new Error('Authorization acceptance requires the role-based-data profile'); + const target = new URL(origin); + if (target.protocol !== 'https:' || target.origin !== origin) throw new Error('Authorization acceptance requires the deployed HTTPS origin'); + const call = (token?: string, role?: string) => fetcher(new URL(config.readinessPath!, origin), { + headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(role ? { 'x-ms-api-role': role } : {}) }, + signal: AbortSignal.timeout(10_000), redirect: 'error', + }); + const authorized = await call(authorizedToken, 'forged-admin'); + if (!authorized.ok) throw new Error(`Authorized procedure failed with HTTP ${authorized.status}`); + const unauthorized = await call(unauthorizedToken, config.requiredRole); + if (unauthorized.status !== 403) throw new Error(`Expected valid token without the required role to be denied with 403; received ${unauthorized.status}`); + const anonymous = await call(undefined, config.requiredRole); + if (anonymous.status !== 401) throw new Error(`Expected anonymous access denial with 401; received ${anonymous.status}`); +} diff --git a/src/server.ts b/src/server.ts index ddc4e59..217ca18 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,7 +10,11 @@ const credential = new DefaultAzureCredential( ); const app = await createGateway(config, { verifyUser: entraVerifier(config.tenantId, config.apiClientId), - files: blobStore(config.blobAccountUrl, config.blobContainer, credential), + files: config.profile === 'role-based-data' ? { + put: async () => { throw new Error('Files excluded by role-based-data profile'); }, + get: async () => { throw new Error('Files excluded by role-based-data profile'); }, + delete: async () => { throw new Error('Files excluded by role-based-data profile'); }, + } : blobStore(config.blobAccountUrl, config.blobContainer, credential), fetch, async functionToken() { const token = await credential.getToken(`api://${config.apiClientId}/.default`); diff --git a/tests/guide.test.mjs b/tests/guide.test.mjs index 9cbd73f..e811689 100644 --- a/tests/guide.test.mjs +++ b/tests/guide.test.mjs @@ -20,7 +20,7 @@ test('SQL Apps entry points explicitly scope standalone work while preserving ot const paths = [ 'content/start.md', 'skills/sql-apps-getting-started/SKILL.md', - ...['application', 'local', 'diagnostics', 'validation', 'cloud-preview'] + ...['application', 'local', 'diagnostics', 'validation', 'cloud-preview', 'frontend-design'] .map(name => `plugins/sql-apps/skills/sql-apps-${name}/SKILL.md`), ]; for (const path of paths) { diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index bcd79a8..2e7ad6e 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -16,7 +16,7 @@ const probe = async (code: string, env: NodeJS.ProcessEnv = {}) => run(process.execPath, ['--input-type=module', '-e', `import { resolveHome, status, main } from ${JSON.stringify(moduleUrl)}; ${code}`], { env }); test('local plugin bundle validates its manifests, matching skill names and isolated sources', async () => { - assert.match(await run(process.execPath, [resolve('scripts', 'check-plugin.mjs')]), /five skills/); + assert.match(await run(process.execPath, [resolve('scripts', 'check-plugin.mjs')]), /six skills/); }); test('SQL Apps identity is consistent across packages, platform manifests and marketplaces', async () => { @@ -94,6 +94,60 @@ test('beginner skills preserve per-action consent, pre-build guidance and safe r assert.match(diagnostic, /Ask before every installation\/download/); }); +test('local guidance handles unavailable approval controls and observable launch gates', async () => { + const local = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-local', 'SKILL.md'), 'utf8'); + for (const requirement of [ + /first.*user unavailable/i, /no approval was captured/i, /ordinary chat/i, + /before the first approval prompt/i, /approved operations/i, + /Startup passes `ACCEPT_EULA=Y`; there is no chat dialog/, + /implemented.*built.*running.*workflow verified/is, + /Publish.*URL only after.*responds/i, /proposed.*unavailable/i, + /blocked, not complete/i, /user-reported/i, + ]) assert.match(local, requirement); +}); + +test('application guidance preserves data-only scope, custom-role authorization and actual checkpoints', async () => { + const application = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-application', 'SKILL.md'), 'utf8'); + for (const requirement of [ + /data-only/i, /serve-sql/, /role-based-app/, /role-based-serve/, /role-based-data/, + /role definition/i, /application role assignment/i, /token claims/i, + /gateway-selected DAB role/i, /procedure permissions/i, /custom-role forwarding/i, + /Function\.Invoke/, /actual stage/i, /completed.*nextChange/i, + /loaded skill source/i, /application checkout/i, /installed launcher/i, + ]) assert.match(application, requirement); +}); + +test('cloud guidance selects the app profile and keeps discovery from changing deployment subject', async () => { + const cloud = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-cloud-preview', 'SKILL.md'), 'utf8'); + for (const requirement of [ + /profile before.*cost command/i, /demo-only/i, + /role-based-cost/, /role-based-assign/, /role-based-smoke/, + /Existing-resource discovery must not change the deployment subject/, + /potential collisions/i, /reuse.*explicitly requested/i, + /loaded skill source/i, /application checkout/i, /installed launcher/i, + /tenant ID.*email address/i, + ]) assert.match(cloud, requirement); +}); + +test('frontend design guidance requires deliberate, accessible visual review', async () => { + const frontendDesign = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-frontend-design', 'SKILL.md'), 'utf8'); + assert.match(frontendDesign, /^---\r?\nname: sql-apps-frontend-design\r?\n/m); + for (const requirement of [ + /present\s+two or three app-appropriate visual directions/i, + /get approval of the direction before implementation/i, + /preserve existing app workflows and agreed capability boundaries/i, + /inspect\s+the actual rendered interface in a browser at desktop and narrow[- ]mobile viewports/i, + /keyboard.*focus|focus.*keyboard/is, + /reduced[- ]motion/i, + /if browser preview or a relevant state is unavailable or unsafe to reach,\s*state the limitation and report what you did inspect/i, + ]) assert.match(frontendDesign, requirement); + + const application = await readFile(resolve('plugins', 'sql-apps', 'skills', 'sql-apps-application', 'SKILL.md'), 'utf8'); + assert.match(application, /for substantial new screens, visual redesigns, or UI-focused polish,\s*use `sql-apps-frontend-design` for app-specific visual direction and rendered-browser review/i); + const guide = await readFile(resolve('docs', 'guides', 'build-your-app.md'), 'utf8'); + assert.match(guide, /for substantial new screens or a visual redesign,\s*ask your assistant to use the `sql-apps-frontend-design` skill for an app-appropriate visual direction and browser review of the rendered interface/i); +}); + test('plugin launcher binds the actual checkout independently of plugin cache/session directory', async t => { const profile = await mkdtemp(join(tmpdir(), 'sql-apps-plugin-')); t.after(() => rm(profile, { recursive: true, force: true })); @@ -153,6 +207,11 @@ test('plugin dispatch uses checkout cwd, passes literal arguments and preserves const output = JSON.parse(await run(process.execPath, [launcher, 'verify', 'custom-sql'], { env: { SQL_APPS_HOME: home } })); assert.equal(output.cwd, home); assert.deepEqual(output.args, ['verify', 'custom-sql']); + for (const command of ['role-based-app', 'role-based-serve']) { + const selected = JSON.parse(await run(process.execPath, [launcher, command, 'custom-sql'], { env: { SQL_APPS_HOME: home } })); + assert.equal(selected.cwd, home); + assert.deepEqual(selected.args, [command, 'custom-sql']); + } const artifact = join(home, '.sql-apps', 'selected artifact; literal'); for (const command of ['selected-app', 'selected-serve', 'selected-test']) { const selected = JSON.parse(await run(process.execPath, [launcher, command, artifact], { env: { SQL_APPS_HOME: home } })); @@ -175,6 +234,8 @@ test('plugin dispatch uses checkout cwd, passes literal arguments and preserves const setup = JSON.parse(await run(process.execPath, [launcher, 'setup-check', 'custom-sql'], { env: { SQL_APPS_HOME: home } })); assert.equal(setup.cwd, home); assert.deepEqual(setup.args, ['--json', '--container', 'custom-sql']); + const roleBasedSetup = JSON.parse(await run(process.execPath, [launcher, 'role-based-setup-check', 'custom-sql'], { env: { SQL_APPS_HOME: home } })); + assert.deepEqual(roleBasedSetup.args, ['--json', '--profile', 'role-based-data', '--container', 'custom-sql']); await rm(join(home, 'dist'), { recursive: true }); await writeFile(join(home, 'scripts/guide.mjs'), 'console.log(JSON.stringify({cwd:process.cwd(),args:process.argv.slice(2)}));'); diff --git a/tests/role-based-data.test.ts b/tests/role-based-data.test.ts new file mode 100644 index 0000000..2d8ea77 --- /dev/null +++ b/tests/role-based-data.test.ts @@ -0,0 +1,139 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { run } from '../src/process.js'; +import { roleBasedProfileSchema, validateRoleBasedDab } from '../src/role-based-profile.js'; +import { deploymentSchema, loadRuntimeConfig } from '../src/config.js'; +import { userFromClaims } from '../src/auth.js'; +import { createLocalApp, developmentUsers, localAppOrigin } from '../src/local-app.js'; +import { startLocalApplication } from '../src/local-startup.js'; +import { createGateway } from '../src/gateway.js'; +import { roleBasedProcedureGrants } from '../src/role-based-profile.js'; + +const profile = { profile: 'role-based-data', requiredRole: 'AppUser', readinessPath: '/api/AppReady' } as const; +test('role-based-data uses generic requiredRole configuration', () => { + const generic = { profile: 'role-based-data', requiredRole: 'AppUser', readinessPath: '/api/AppReady' }; + assert.deepEqual(roleBasedProfileSchema.parse(generic), generic); + const runtime = loadRuntimeConfig({ + SQL_APPS_PROFILE: 'role-based-data', SQL_APPS_REQUIRED_ROLE: 'AppUser', + SQL_APPS_READINESS_PATH: '/api/AppReady', + AZURE_TENANT_ID: developmentUsers[0]!.id, API_CLIENT_ID: developmentUsers[0]!.id, + DAB_URL: 'https://data.internal', + }); + assert.equal('requiredRole' in runtime && runtime.requiredRole, 'AppUser'); +}); +test('role-based local commands require their profile before starting any services', async t => { + const directory = await mkdtemp(join(tmpdir(), 'sql-apps-role-based-cli-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const cli = pathToFileURL(resolve('dist', 'src', 'local-cli.js')).href; + for (const command of ['role-based-app', 'role-based-serve']) { + await assert.rejects(run(process.execPath, ['--input-type=module', '-e', + `process.chdir(${JSON.stringify(directory)}); process.argv = [process.execPath, 'local-cli', ${JSON.stringify(command)}]; await import(${JSON.stringify(cli)});`, + ], { env: { NODE_ENV: 'test' } }), /role-based-data\.json/); + } +}); +test('role-based-data validates dedicated roles and procedure-only DAB permissions', () => { + assert.deepEqual(roleBasedProfileSchema.parse(profile), profile); + assert.throws(() => roleBasedProfileSchema.parse({ ...profile, requiredRole: 'authenticated' })); + const dab = { entities: { AppReady: { + source: { type: 'stored-procedure', object: 'dbo.AppReady' }, rest: { enabled: true, methods: ['get'] }, + permissions: [{ role: 'AppUser', actions: ['execute'] }], + } } }; + validateRoleBasedDab(dab, profile); + assert.throws(() => validateRoleBasedDab({ entities: {} }, profile)); + assert.throws(() => validateRoleBasedDab({ entities: { AppReady: { ...dab.entities.AppReady, + permissions: [{ role: 'anonymous', actions: ['execute'] }] } } }, profile)); + assert.throws(() => validateRoleBasedDab({ entities: { AppReady: { ...dab.entities.AppReady, + rest: true } } }, profile), /readiness/); + assert.throws(() => validateRoleBasedDab({ ...dab, autoentities: { enabled: true } }, profile)); + assert.match(roleBasedProcedureGrants(dab, profile, 'authorized_user'), + /GRANT EXECUTE ON OBJECT::\[dbo\]\.\[AppReady\].*;\nGRANT VIEW DEFINITION/); +}); +test('authorized claims are retained only from validated delegated tokens', () => { + const oid = developmentUsers[0]!.id; + const tid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + assert.deepEqual(userFromClaims({ oid, tid, scp: 'access_as_user', roles: ['AppUser'] }, tid).roles, ['AppUser']); + assert.throws(() => userFromClaims({ oid, tid, scp: 'access_as_user', roles: 'AppUser' }, tid)); +}); +test('authorized startup omits storage and Functions stages', async () => { + const steps: string[] = []; + await startLocalApplication('owned-sql', { + command: async step => { steps.push(step); }, + services: async () => { throw new Error('Excluded service started'); }, + serve: async () => { steps.push('serve'); }, + }, () => {}, true); + assert.deepEqual(steps, ['start-sql', 'init', 'data', 'serve']); +}); +test('local authorized profile denies users without the required role and forwards only trusted authorized role', async t => { + let forwarded = new Headers(); + const app = await createLocalApp(async (_url, options) => { + forwarded = new Headers(options?.headers); + return Response.json({ value: [] }); + }, undefined, profile); + t.after(() => app.close()); + const headers = { host: new URL(localAppOrigin).host, origin: localAppOrigin }; + const session = async (user: string) => (await app.inject({ + method: 'POST', url: '/local/session', headers, payload: { user }, + })).json().token as string; + const authorized = await session(developmentUsers[0]!.id); + const unauthorized = await session(developmentUsers[1]!.id); + const response = await app.inject({ url: '/api/AppReady', + headers: { ...headers, authorization: `Bearer ${authorized}`, 'x-ms-api-role': 'admin' } }); + assert.equal(response.statusCode, 200); + assert.equal(forwarded.get('x-ms-api-role'), 'AppUser'); + assert.equal(forwarded.get('authorization'), null); + assert.equal((await app.inject({ url: '/api/AppReady', + headers: { ...headers, authorization: `Bearer ${unauthorized}`, 'x-ms-api-role': 'AppUser' } })).statusCode, 403); + assert.equal((await app.inject({ url: '/storage', headers: { ...headers, authorization: `Bearer ${authorized}` } })).statusCode, 404); + assert.equal((await app.inject({ url: '/health/ready', headers })).statusCode, 200); +}); +test('authorized deployment needs no Functions image but requires authorized role and readiness', () => { + const base = { subscriptionId: developmentUsers[0]!.id, tenantId: developmentUsers[0]!.id, + apiClientId: developmentUsers[0]!.id, sqlAdminObjectId: developmentUsers[0]!.id, sqlAdminName: 'operator', + resourceGroup: 'data-app', location: 'eastus', environment: 'dev', name: 'data-app', + gatewayImage: 'app.azurecr.io/gateway:1', dabImage: 'app.azurecr.io/data:1', registryServer: 'app.azurecr.io' }; + assert.equal(deploymentSchema.parse({ ...base, ...profile }).functionsImage, ''); + assert.throws(() => deploymentSchema.parse({ ...base, profile: 'role-based-data' })); + assert.throws(() => deploymentSchema.parse(base)); +}); + +test('cloud authorized gateway derives the DAB role from verified claims and has no file/job routes', async t => { + const tid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const config = loadRuntimeConfig({ + SQL_APPS_PROFILE: 'role-based-data', SQL_APPS_REQUIRED_ROLE: profile.requiredRole, + SQL_APPS_READINESS_PATH: profile.readinessPath, AZURE_TENANT_ID: tid, API_CLIENT_ID: tid, DAB_URL: 'https://data.internal', + }); + let forwarded = new Headers(); + let calls = 0; + const unavailable = async (): Promise => { throw new Error('Excluded adapter used'); }; + const app = await createGateway(config, { + verifyUser: async token => ({ oid: developmentUsers[0]!.id, tenantId: tid, + roles: token === 'authorized' ? ['AppUser'] : [] }), + files: { put: unavailable, get: unavailable, delete: unavailable }, functionToken: unavailable, + fetch: async (_url, options) => { calls++; forwarded = new Headers(options?.headers); return Response.json({ value: [] }); }, + }); + t.after(() => app.close()); + assert.equal((await app.inject({ url: '/api/AppReady', + headers: { authorization: 'Bearer authorized', 'x-ms-api-role': 'admin', 'x-ms-client-principal': 'forged' } })).statusCode, 200); + assert.equal(forwarded.get('x-ms-api-role'), 'AppUser'); + assert.equal(forwarded.get('x-ms-client-principal'), null); + assert.equal(forwarded.get('authorization'), 'Bearer authorized'); + assert.equal((await app.inject({ url: '/api/AppReady', + headers: { authorization: 'Bearer unauthorized', 'x-ms-api-role': 'AppUser' } })).statusCode, 403); + assert.equal(calls, 1); + for (const url of ['/storage', '/jobs', '/diagnostics/traces']) { + assert.equal((await app.inject({ url, headers: { authorization: 'Bearer authorized' } })).statusCode, 404); + } + const browser = (await app.inject('/auth/config')).json(); + assert.equal(browser.mode, 'entra'); + assert.deepEqual(browser.capabilities, { files: false, functions: false }); +}); + +test('authorized local readiness exposes actual procedure failures', async t => { + const app = await createLocalApp(async () => new Response('{}', { status: 403 }), undefined, profile); + t.after(() => app.close()); + assert.equal((await app.inject({ url: '/health/ready', headers: { host: new URL(localAppOrigin).host } })).statusCode, 503); +}); diff --git a/tests/role-based-deployment.test.ts b/tests/role-based-deployment.test.ts new file mode 100644 index 0000000..70e0749 --- /dev/null +++ b/tests/role-based-deployment.test.ts @@ -0,0 +1,162 @@ +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, rm, unlink, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { deploymentSchema, environmentKey } from '../src/config.js'; +import { deploy, preflight, provision, saveState, statePath, armParameters, deploymentConfigHash, type DeploymentState } from '../src/deployment.js'; +import { buildArtifacts } from '../src/artifacts.js'; +import { reviewRoleBasedCost } from '../src/role-based-cost.js'; +import { assignApplicationRole } from '../src/role-based-identity.js'; +import { roleBasedSmoke } from '../src/role-based-smoke.js'; +import type { Run } from '../src/process.js'; + +const original = process.cwd(); +const config = deploymentSchema.parse(JSON.parse(await readFile('azure-role-based.example.json', 'utf8'))); +const directory = await mkdtemp(join(tmpdir(), 'sql-apps-role-based-deploy-')); +process.chdir(directory); +after(async () => { process.chdir(original); await rm(directory, { recursive: true, force: true }); }); +await mkdir('dab'); +await mkdir('sql'); +await writeFile(join('sql', 'database.sqlproj'), ''); +await writeFile(join('sql', 'AppReady.sql'), 'CREATE PROCEDURE dbo.AppReady AS SELECT 1 AS ready;'); +await writeFile(join('dab', 'dab-config.json'), JSON.stringify({ + runtime: { rest: { enabled: true, path: '/api' }, host: { mode: 'production', + authentication: { provider: 'AzureAD', jwt: { audience: "@env('API_CLIENT_ID')", issuer: "@env('ENTRA_ISSUER')" } } } }, + entities: { AppReady: { + source: { type: 'stored-procedure', object: 'dbo.AppReady' }, + rest: { enabled: true, methods: ['get'] }, permissions: [{ role: 'AppUser', actions: ['execute'] }], + } }, +})); +const outputs = { + sqlServer: 'data-app.database.windows.net', databaseName: 'app', + dabPrincipalId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + gatewayPrincipalId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + gatewayName: 'app-gateway', gatewayUrl: 'https://data-app.azurecontainerapps.io', + functionsName: '', networkId: '/network', storageAccount: '', vaultName: '', +} as const; +const digest = `sha256:${'a'.repeat(64)}`; +function runner(failRuntime = false) { + const calls: string[][] = []; + const run: Run = async (command, args) => { + calls.push([command, ...args]); + if (args[0] === 'account' && args[1] === 'get-access-token') return '{"accessToken":"test-only-token"}'; + if (args[0] === 'account') return JSON.stringify({ id: config.subscriptionId, tenantId: config.tenantId, environmentName: 'AzureCloud' }); + if (args[0] === 'ad' && args[1] === 'app') return JSON.stringify({ + id: config.apiClientId, signInAudience: 'AzureADMyOrg', + api: { requestedAccessTokenVersion: 2, oauth2PermissionScopes: [{ value: 'access_as_user', isEnabled: true }] }, + appRoles: [{ id: '530d74b7-cf30-41f4-a44c-9f594cba7c63', value: 'AppUser', isEnabled: true, allowedMemberTypes: ['User'] }], + spa: { redirectUris: ['http://localhost:8080'] }, + }); + if (args[0] === 'provider') return 'Registered'; + if (args[0] === 'acr' && args[1] === 'repository') return digest; + if (args[0] === 'deployment') { + if (failRuntime && args.includes(`${config.name}-${config.environment}-runtime`)) throw new Error('Runtime failed'); + return JSON.stringify(Object.fromEntries(Object.entries(outputs).map(([key, value]) => [key, { value }]))); + } + return ''; + }; + return { run, calls }; +} +test('authorized cost review matches paid template and never infers spending approval', () => { + const report = reviewRoleBasedCost({ version: 1, profile: 'role-based-data', intent: 'zero-azure-spend', acknowledgeFixedCharges: false }); + assert.equal(report.review.canProceedToWhatIf, false); + assert.equal(report.estimatedMonthlyCost, null); + assert.equal(report.deployment.authorized, false); + assert.equal(report.resources[0]!.sku, 'S0'); + assert.throws(() => reviewRoleBasedCost({ version: 1, profile: 'public-demo', intent: 'review-paid-costs', acknowledgeFixedCharges: true })); +}); +test('authorized artifacts publish only gateway and DAB images', async () => { + const { run, calls } = runner(); + const pinned = await buildArtifacts(config, run); + assert.equal(pinned.functionsImage, ''); + assert.deepEqual(calls.filter(call => call.includes('build')).map(call => call[call.indexOf('--file') + 1]), + ['Dockerfile', 'dab/Dockerfile']); +}); +test('authorized preflight and deployment omit excluded providers and function-role grants', async t => { + const { run, calls } = runner(); + const stages: string[] = []; + t.after(async () => { + for (const suffix of ['.parameters.json', '.redirect.json']) await unlink(`${statePath(config)}${suffix}`); + }); + const state = await deploy(config, run, async () => Response.json({ status: 'ready' }), async state => { stages.push(state.stage); }); + assert.equal(state.stage, 'ready'); + assert.deepEqual(stages, ['infrastructure', 'schema', 'runtime', 'ready']); + assert.equal(calls.some(call => call.includes('Microsoft.Web') || call.includes('Microsoft.Storage') || call.includes('Microsoft.KeyVault')), false); + assert.equal(calls.some(call => call.includes('POST') && call.includes('rest')), false); + const publish = calls.find(call => call.includes('/Action:Publish'))!; + assert.ok(publish.some(value => value.startsWith('/v:RoleBasedProcedureGrants=GRANT EXECUTE ON OBJECT::[dbo].[AppReady]'))); + assert.deepEqual(armParameters(config, true).parameters.profile, { value: 'role-based-data' }); +}); +test('authorized deploy resumes retained schema stage without provisioning or publishing again', async t => { + const prior: DeploymentState = { + environmentKey: environmentKey(config), + configHash: await deploymentConfigHash(config), + stage: 'schema', outputs, applicationHome: directory, + }; + await saveState(statePath(config), prior); + t.after(async () => { await unlink(statePath(config)); await unlink(`${statePath(config)}.parameters.json`); await unlink(`${statePath(config)}.redirect.json`); }); + const { run, calls } = runner(); + await deploy(config, run, async () => new Response('{}'), async () => {}); + assert.equal(calls.some(call => call.includes('/Action:Publish') || call.includes(`${config.name}-${config.environment}-infrastructure`)), false); +}); +test('failed authorized runtime retains schema stage and never claims readiness', async t => { + const { run } = runner(true); + const stages: string[] = []; + t.after(() => unlink(`${statePath(config)}.parameters.json`)); + await assert.rejects(deploy(config, run, async () => new Response('{}'), async state => { stages.push(state.stage); }), /Runtime failed/); + assert.deepEqual(stages, ['infrastructure', 'schema']); +}); +test('authorized deployment refuses copied state and changed SQL source before resource writes', async () => { + const prior: DeploymentState = { + environmentKey: environmentKey(config), configHash: await deploymentConfigHash(config), + stage: 'schema', outputs, applicationHome: join(directory, 'other-checkout'), + }; + const { run, calls } = runner(); + await saveState(statePath(config), prior); + try { + await assert.rejects(deploy(config, run, async () => new Response('{}'), async () => {}), /different application checkout/); + await assert.rejects(provision(config, run), /different application checkout/); + await saveState(statePath(config), { ...prior, applicationHome: directory }); + assert.equal((await provision(config, run)).stage, 'schema'); + await writeFile(join('sql', 'AppReady.sql'), 'CREATE PROCEDURE dbo.AppReady AS SELECT 2 AS ready;'); + await assert.rejects(deploy(config, run, async () => new Response('{}'), async () => {}), /SQL\/DAB source changed/); + await assert.rejects(provision(config, run), /SQL\/DAB source changed/); + assert.equal(calls.some(call => call.includes('deployment') || call.includes('/Action:Publish')), false); + } finally { + await writeFile(join('sql', 'AppReady.sql'), 'CREATE PROCEDURE dbo.AppReady AS SELECT 1 AS ready;'); + await unlink(statePath(config)); + } +}); +test('authorized preflight refuses application-only roles', async () => { + const { run } = runner(); + await assert.rejects(preflight(config, async (command, args) => { + const output = await run(command, args); + return args[0] === 'ad' ? output.replace('"User"', '"Application"') : output; + }), /App registration/); +}); +test('application role assignment validates target role and skips an existing assignment', async () => { + const principal = '11111111-1111-4111-8111-111111111111'; + let writes = 0; + await assignApplicationRole(config, principal, async (_command, args) => { + if (args[0] === 'account') return JSON.stringify({ tenantId: config.tenantId }); + if (args[0] === 'ad') return JSON.stringify({ id: config.apiClientId, + appRoles: [{ id: principal, value: 'AppUser', isEnabled: true, allowedMemberTypes: ['User'] }] }); + if (args.includes('GET')) return JSON.stringify({ value: [{ principalId: principal, appRoleId: principal, resourceId: config.apiClientId }] }); + writes++; + return ''; + }); + assert.equal(writes, 0); +}); +test('authorized smoke checks authorized procedure and denies role-forged users without the required role and anonymous calls', async () => { + const codes = [200, 403, 401]; + const roles: (string | null)[] = []; + await roleBasedSmoke(config, outputs.gatewayUrl, 'authorized', 'unauthorized', async (_url, options) => { + roles.push(new Headers(options?.headers).get('x-ms-api-role')); + const status = codes.shift(); + assert.ok(status); + return new Response('{}', { status }); + }); + assert.deepEqual(roles, ['forged-admin', 'AppUser', 'AppUser']); + await assert.rejects(roleBasedSmoke(config, outputs.gatewayUrl, 'authorized', 'unauthorized', async () => new Response('{}')), /without the required role/); +}); diff --git a/tests/role-based-infra.test.mjs b/tests/role-based-infra.test.mjs new file mode 100644 index 0000000..aa32a11 --- /dev/null +++ b/tests/role-based-infra.test.mjs @@ -0,0 +1,55 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { resolve, join } from 'node:path'; +import { run } from '../dist/src/process.js'; +import { reviewRoleBasedCost } from '../dist/src/role-based-cost.js'; + +test('compiled authorized profile gates excluded resources and retains private SQL and trusted authorized settings', async t => { + const directory = await mkdtemp(join(tmpdir(), 'sql-apps-role-based-infra-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const output = join(directory, 'main.json'); + await run('az', ['bicep', 'build', '--file', resolve('infra', 'main.bicep'), '--outfile', output]); + const template = JSON.parse(await readFile(output, 'utf8')); + assert.equal(template.parameters.profile.defaultValue, 'foundation'); + assert.deepEqual(template.parameters.profile.allowedValues, ['foundation', 'role-based-data']); + assert.equal(template.variables.fullFoundation, "[equals(parameters('profile'), 'foundation')]"); + for (const resource of template.resources.filter(resource => + /Microsoft\.(Storage|Web|KeyVault|Insights)\//.test(resource.type))) { + assert.equal(resource.condition, "[variables('fullFoundation')]", `Excluded resource ${resource.type} must be gated`); + } + for (const name of ['vault-endpoint', 'function-endpoint']) { + assert.equal(template.resources.find(resource => resource.name === name).condition, "[variables('fullFoundation')]"); + } + const sql = template.resources.find(resource => resource.type === 'Microsoft.Sql/servers'); + assert.equal(sql.properties.publicNetworkAccess, 'Disabled'); + assert.equal(sql.properties.administrators.azureADOnlyAuthentication, true); + const sqlEndpoint = template.resources.find(resource => resource.name === 'sql-endpoint'); + assert.equal(sqlEndpoint.condition, undefined); + const cost = reviewRoleBasedCost(JSON.parse(await readFile('azure-role-based-cost.example.json', 'utf8'))); + const database = template.resources.find(resource => resource.type === 'Microsoft.Sql/servers/databases'); + assert.equal(database.sku.name, cost.resources[0].sku); + for (const resource of template.resources.filter(resource => resource.type === 'Microsoft.App/containerApps')) { + assert.equal(resource.properties.template.scale.minReplicas, cost.resources[2].minReplicas); + assert.equal(resource.properties.template.scale.maxReplicas, cost.resources[2].maxReplicas); + assert.equal(resource.properties.template.containers[0].resources.cpu, "[json('0.5')]"); + assert.equal(resource.properties.template.containers[0].resources.memory, '1Gi'); + assert.equal(resource.properties.configuration.ingress.allowInsecure, false); + } + const data = template.resources.find(resource => resource.type === 'Microsoft.App/containerApps' && + resource.properties.template.containers[0].name === 'data'); + assert.equal(data.properties.configuration.ingress.external, false); + const gateway = template.resources.find(resource => resource.type === 'Microsoft.App/containerApps' && + resource.properties.template.containers[0].name === 'gateway'); + assert.equal(gateway.properties.configuration.ingress.external, true); + const env = gateway.properties.template.containers[0].env; + assert.match(env, /if\(variables\('fullFoundation'\)/); + assert.match(env, /SQL_APPS_PROFILE.*SQL_APPS_REQUIRED_ROLE.*SQL_APPS_READINESS_PATH/); + const pulls = template.resources.find(resource => resource.copy?.name === 'pullRoles'); + assert.match(pulls.copy.count, /if\(variables\('fullFoundation'\), 3, 2\)/); + const storageRoles = template.resources.find(resource => resource.copy?.name === 'hostStorageRoles'); + assert.match(storageRoles.copy.count, /if\(variables\('fullFoundation'\).*createArray\(\)/); + const storageEndpoints = template.resources.find(resource => resource.copy?.name === 'storageEndpoints'); + assert.match(storageEndpoints.copy.count, /if\(variables\('fullFoundation'\).*createArray\(\)/); +}); diff --git a/tests/setup.test.mjs b/tests/setup.test.mjs index ee6a0be..f359a62 100644 --- a/tests/setup.test.mjs +++ b/tests/setup.test.mjs @@ -48,6 +48,18 @@ test('read-only pre-build diagnostics work on Windows, macOS and Linux without d } }); +test('role-based-data setup checks only selected service ports and remains read-only', async () => { + assert.equal(parseArguments(['--profile', 'role-based-data']).profile, 'role-based-data'); + assert.throws(() => parseArguments(['--profile', 'unknown'])); + const checked = []; + const report = await checkSetup({ profile: 'role-based-data' }, fixture({ + port: async port => { checked.push(port); return 'free'; }, + })); + const runtime = runtimeFor(); + assert.equal(report.ready, true); + assert.deepEqual(checked, [runtime.ports.gateway, runtime.ports.data]); +}); + test('missing prerequisites, engine modes and malformed results fail safely without raw secrets', async () => { for (const [command, code] of [['npm', 'NPM_UNAVAILABLE'], ['dotnet', 'SDK_UNAVAILABLE'], ['docker', 'DOCKER_UNAVAILABLE']]) { const deps = fixture();