diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 52da04c..5808cdc 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -18,11 +18,10 @@ jobs: timeout-minutes: 15 steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 with: persist-credentials: false - name: Validate shell: pwsh run: ./scripts/validate.ps1 - diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 8a1b352..aef8287 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -35,6 +35,10 @@ ## v2 Requirements +- [x] **INT-01**: Platform injects the reference product non-secret configuration contract. +- [x] **INT-02**: Platform configures reference product liveness and readiness probes. +- [x] **INT-03**: Foundry RBAC is optional and requires explicit project scope and role definition inputs. +- [x] **INT-04**: Contract tests prove workload identity, configuration, probes, ingress, and RBAC boundaries. - **NET-01**: Operator can enable private networking and controlled egress. - **GOV-01**: Operator can apply Azure Policy and compliance reporting. - **DEL-01**: Authorized workload identity can promote reviewed infrastructure. @@ -57,9 +61,10 @@ | SEC-01, SEC-02, SEC-03, SEC-04 | Phase 1 | Complete | | OPS-01, OPS-02, OPS-03, OPS-04 | Phase 1 | Complete | | QUAL-01, QUAL-02, QUAL-03, QUAL-04 | Phase 1 | Complete | +| INT-01, INT-02, INT-03, INT-04 | Phase 2 | Complete | +| NET-01, GOV-01 | Phase 3 | Pending landing-zone contract | -**Coverage:** 16 v1 requirements, 16 mapped, 0 unmapped. +**Coverage:** 16 v1 requirements and 4 integration requirements complete. --- -*Last updated: 2026-06-11 after v0.1 foundation* - +*Last updated: 2026-06-12 after reference product integration* diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 87e3e5f..1f139df 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -13,18 +13,23 @@ through OPS-04, QUAL-01 through QUAL-04. 3. Architecture and threat model accurately describe residual risk. 4. Repository can be created publicly and CI can validate the foundation. -## Phase 2: Network and Policy Guardrails +## Phase 2: Reference Product Integration + +Implement the public reference product deployment contract: non-secret +configuration, health probes, system-assigned identity boundaries, and +optional project-scoped Foundry RBAC. + +## Phase 3: Network and Policy Guardrails Add private networking, controlled egress, Azure Policy, and compliance -reporting after target subscription architecture is agreed. +reporting only after target subscription architecture is agreed. -## Phase 3: Federated Delivery +## Phase 4: Federated Delivery Add OIDC/managed-identity deployment workflows, approvals, evidence retention, and safe promotion between environments. -## Phase 4: Production Operations +## Phase 5: Production Operations Add alerts, SLOs, dashboards, incident routing, release attestations, and recovery exercises. - diff --git a/.planning/STATE.md b/.planning/STATE.md index 25f92c7..e6fddda 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,10 +5,12 @@ See `.planning/PROJECT.md`. **Core value:** An operator can confidently understand and validate a secure CAS Azure environment before deploying it. -**Current focus:** Phase 2 - Network and Policy Guardrails +**Current focus:** Phase 3 - Network and Policy Guardrails ## Status - Phase 1 v0.1 foundation implemented and locally verified. +- Phase 2 reference product integration implemented and locally verified. - No Azure resources deployed. +- Private networking and policy deferred until a landing-zone contract exists. - Next planned phase: Network and Policy Guardrails. diff --git a/.planning/phases/02-reference-product-integration/02-01-PLAN.md b/.planning/phases/02-reference-product-integration/02-01-PLAN.md new file mode 100644 index 0000000..5d0774b --- /dev/null +++ b/.planning/phases/02-reference-product-integration/02-01-PLAN.md @@ -0,0 +1,24 @@ +# Phase 2 Plan: Reference Product Integration + +## Goal + +Implement the public `cas-reference-product` deployment interface without +deploying Azure resources or assuming a landing-zone topology. + +## Tasks + +1. Inject the required non-secret workload configuration from Bicep. +2. Configure port 8080, internal ingress, system-assigned identity, and health + probes matching the public workload interface. +3. Add optional Foundry RBAC gated by explicit project resource and role + definition resource IDs, scoped only to that project. +4. Add contract tests and documentation covering the integration boundary. +5. Build all Bicep and parameter files, run Pester, and retain non-deploying + what-if behavior. + +## Guardrails + +- Do not deploy Azure resources. +- Do not add private networking or Azure Policy without a target landing-zone + contract. +- Do not select a broad built-in role on the operator's behalf. diff --git a/.planning/phases/02-reference-product-integration/02-01-SUMMARY.md b/.planning/phases/02-reference-product-integration/02-01-SUMMARY.md new file mode 100644 index 0000000..7e44f0f --- /dev/null +++ b/.planning/phases/02-reference-product-integration/02-01-SUMMARY.md @@ -0,0 +1,12 @@ +# Phase 2 Summary: Reference Product Integration + +Implemented the reference product configuration and health contract in the +Container App module. Application Insights configuration flows directly from +the observability module. Foundry role assignment is disabled by default and +can only be enabled with both an explicit Foundry project resource ID and an +explicit approved role definition resource ID; its scope is the project. + +Private networking and Azure Policy were reviewed but not added because no +landing-zone topology, DNS, egress, or policy ownership contract exists. + +No Azure resources were deployed. diff --git a/.planning/phases/02-reference-product-integration/02-VERIFICATION.md b/.planning/phases/02-reference-product-integration/02-VERIFICATION.md new file mode 100644 index 0000000..c0f9db1 --- /dev/null +++ b/.planning/phases/02-reference-product-integration/02-VERIFICATION.md @@ -0,0 +1,18 @@ +# Phase 2 Verification + +**Status:** Passed locally + +## Evidence + +- `az bicep build --file infra/main.bicep --stdout`: passed. +- All dev, test, and prod Bicep parameter builds: passed. +- `scripts/validate.ps1`: passed. +- Pester infrastructure contract tests: 10 passed, 0 failed. +- `scripts/what-if.ps1 -Environment dev -Location northeurope`: succeeded + with only expected creates and no deployment. The preview proved internal + ingress, port 8080, system-assigned identity, required environment settings, + both health probes, and no default Foundry role assignment. +- Tests prove required environment injection, probes, port 8080, internal + ingress default, system-assigned identity, principal output, optional RBAC + gating, project scope, and non-deploying what-if commands. +- No Azure resources deployed. diff --git a/README.md b/README.md index 1ef0bd5..7c45493 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,8 @@ Production-oriented Azure infrastructure foundation for the Coding Autopilot System (CAS). It provides environment-isolated Container Apps hosting, workspace-based observability, system-assigned managed identity, budgets, and -safe validation tooling without storing secrets. +safe validation tooling without storing secrets. The workload module implements +the public `cas-reference-product` deployment interface. ## v0.1 foundation @@ -13,6 +14,8 @@ safe validation tooling without storing secrets. - Log Analytics, Application Insights, diagnostic settings, tags, and budgets. - Dev, test, and production parameter sets. - Local and CI validation plus a non-deploying Azure `what-if` script. +- Reference-product configuration injection and liveness/readiness probes. +- Optional Foundry project RBAC requiring an explicit project scope and role. ## Validate locally @@ -40,6 +43,18 @@ az login The script only invokes `az deployment sub what-if`. It never invokes a create or deploy command. +## Reference product configuration + +The Container App injects `ENVIRONMENT`, `WORKFLOW_BACKEND`, +`FOUNDRY_PROJECT_ENDPOINT`, `FOUNDRY_AGENT_NAME`, and +`APPLICATIONINSIGHTS_CONNECTION_STRING`. Local mode is the safe default. +Foundry mode requires a project endpoint and Next Gen agent name. + +Foundry RBAC is disabled unless both `foundryProjectResourceId` and +`foundryRoleDefinitionResourceId` are explicitly supplied. The assignment is +created only at that Foundry project resource. Select and approve the minimum +role externally; the template does not assume a broad built-in role. + ## Architecture See [architecture](docs/architecture.md), [threat model](docs/threat-model.md), @@ -48,6 +63,7 @@ kept under `.planning/`. ## Security -Public ingress is disabled by default. No secrets, credentials, connection -strings, or access keys are accepted by the templates. Runtime access to future -dependencies must use managed identity with narrowly scoped RBAC. +Public ingress is disabled by default. No secrets, credentials, or access keys +are accepted by the templates. Runtime access to dependencies uses managed +identity with narrowly scoped RBAC. Private networking and Azure Policy remain +deferred until a target landing-zone contract defines topology and ownership. diff --git a/docs/architecture.md b/docs/architecture.md index 119a0da..baf5efe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,14 +29,24 @@ environment, identity boundary, and budget. ## Identity and networking -The Container App receives a system-assigned managed identity. v0.1 has no -dependent data plane resources, so it deliberately creates no role -assignments. Future modules must grant the identity only the minimum data-plane -roles at the narrowest resource scope. +The Container App receives a system-assigned managed identity. Foundry access +is optional and creates no role assignment by default. When both an explicit +Foundry project resource ID and an approved role definition resource ID are +provided, the platform assigns that role only at the project resource scope. +Subscription-wide workload roles are not supported. External ingress defaults to disabled. Enabling it is an explicit parameter decision and requires a threat-model review. The v0.1 baseline does not claim -private networking; that is planned as a later hardened topology. +private networking. That topology remains deferred until a target landing-zone +contract identifies required subnets, DNS, firewall, and egress ownership. + +## Reference product contract + +The workload module follows the public `cas-reference-product` deployment +interface: Linux container image, port 8080, internal ingress by default, +system-assigned identity, `/health/live` and `/health/ready` probes, and +non-secret workload configuration. Application Insights configuration is +injected directly from the observability module. ## Observability @@ -50,4 +60,3 @@ are output. Normal changes flow through build, lint, contract tests, and subscription `what-if`. Resource naming is deterministic. Renaming workload, environment, or location can replace resource boundaries and must be treated as a migration. - diff --git a/docs/operations.md b/docs/operations.md index 6bc14d1..bc520d4 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -15,6 +15,17 @@ sets. Production deployment is intentionally not implemented in v0.1. Add it only after workload identity, approvals, policy, and rollback ownership are defined. +## Reference product modes + +Parameter files default to `WORKFLOW_BACKEND=local`. To validate Foundry mode, +provide the non-secret project endpoint and Next Gen agent name. RBAC remains +disabled unless the operator also supplies both the exact Foundry project +resource ID and an approved role definition resource ID. Review the resulting +project-scoped role assignment in what-if before any deployment authorization. + +Health probes call `/health/live` and `/health/ready` on port 8080. Readiness +checks configuration only; it does not invoke Foundry. + ## Rollback Bicep redeployment can restore configuration but does not restore deleted data @@ -26,4 +37,3 @@ Never rely on changing a resource name as a rollback mechanism. Query the environment Log Analytics workspace for platform and application logs. Application Insights is available for workload instrumentation when the application is configured without exposing credentials. - diff --git a/docs/threat-model.md b/docs/threat-model.md index d438cab..0fc26a1 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -21,7 +21,7 @@ | Threat | v0.1 control | Residual risk | |---|---|---| | Credential disclosure | Templates accept no secrets; managed identity only | Operator and CI Azure identities remain external controls | -| Excessive runtime privilege | No role assignments until a dependency requires one | Future modules could grant overly broad roles | +| Excessive runtime privilege | Optional Foundry assignment requires explicit role and project scope inputs | Operator could still select an overly broad role definition | | Accidental production change | Environment isolation and what-if-only validation script | A separate deployment workflow could bypass review | | Unreviewed public exposure | External ingress disabled by default | Enabling ingress does not yet add WAF/private networking | | Telemetry loss | Diagnostic settings route logs and metrics to Log Analytics | Alert rules and SLOs are deferred | @@ -30,9 +30,8 @@ ## Security decisions required before production -- Adopt private networking and controlled egress where workload needs justify it. +- Adopt private networking and controlled egress after landing-zone topology and ownership are defined. - Define a federated, least-privilege deployment identity and approval workflow. - Add Azure Policy assignments for allowed regions, required tags, and exposure. - Add workload-specific RBAC only after data-plane dependencies are known. - Define alerts, incident routing, retention, and evidence access controls. - diff --git a/infra/main.bicep b/infra/main.bicep index 1f3cf27..7e61962 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -33,6 +33,25 @@ param dataClassification string = 'internal' @description('Container image for the foundation workload.') param containerImage string +@description('Workflow backend selected by the reference product.') +@allowed([ + 'local' + 'foundry' +]) +param workflowBackend string = 'local' + +@description('Foundry project endpoint. Required by the application when workflowBackend is foundry.') +param foundryProjectEndpoint string = '' + +@description('Foundry Next Gen agent name. Required by the application when workflowBackend is foundry.') +param foundryAgentName string = '' + +@description('Optional explicit Foundry project resource identifier used as the narrow RBAC scope.') +param foundryProjectResourceId string = '' + +@description('Optional explicit role definition resource identifier approved for the workload at the Foundry project scope.') +param foundryRoleDefinitionResourceId string = '' + @description('Whether the Container App accepts public network ingress.') param enableExternalIngress bool = false @@ -48,6 +67,7 @@ param monthlyBudget int = 0 param budgetContactEmails array = [] var suffix = take(uniqueString(subscription().subscriptionId, workloadName, environment, location), 6) +var enableFoundryRbac = !empty(foundryProjectResourceId) && !empty(foundryRoleDefinitionResourceId) var resourceGroupName = 'rg-${workloadName}-${environment}-${suffix}' var tags = { application: workloadName @@ -87,12 +107,26 @@ module compute './modules/container-apps.bicep' = { location: location suffix: suffix containerImage: containerImage + workflowBackend: workflowBackend + foundryProjectEndpoint: foundryProjectEndpoint + foundryAgentName: foundryAgentName + applicationInsightsConnectionString: observability.outputs.applicationInsightsConnectionString enableExternalIngress: enableExternalIngress logAnalyticsWorkspaceId: observability.outputs.logAnalyticsWorkspaceId tags: tags } } +module foundryRbac './modules/foundry-rbac.bicep' = if (enableFoundryRbac) { + name: 'foundry-rbac-${environment}' + scope: resourceGroup(split(foundryProjectResourceId, '/')[2], split(foundryProjectResourceId, '/')[4]) + params: { + workloadPrincipalId: compute.outputs.workloadPrincipalId + foundryProjectResourceId: foundryProjectResourceId + foundryRoleDefinitionResourceId: foundryRoleDefinitionResourceId + } +} + module budget './modules/budget.bicep' = if (monthlyBudget > 0 && length(budgetContactEmails) > 0) { name: 'budget-${environment}' scope: environmentResourceGroup @@ -117,4 +151,3 @@ output logAnalyticsWorkspaceId string = observability.outputs.logAnalyticsWorksp @description('Application Insights resource identifier.') output applicationInsightsId string = observability.outputs.applicationInsightsId - diff --git a/infra/modules/container-apps.bicep b/infra/modules/container-apps.bicep index 500faad..e01d73e 100644 --- a/infra/modules/container-apps.bicep +++ b/infra/modules/container-apps.bicep @@ -15,6 +15,22 @@ param suffix string @description('Container image.') param containerImage string +@description('Workflow backend selected by the workload.') +@allowed([ + 'local' + 'foundry' +]) +param workflowBackend string + +@description('Foundry project endpoint used by the workload. Required by the application when workflowBackend is foundry.') +param foundryProjectEndpoint string + +@description('Foundry Next Gen agent name used by the workload. Required by the application when workflowBackend is foundry.') +param foundryAgentName string + +@description('Non-secret Application Insights connection string injected by the platform.') +param applicationInsightsConnectionString string + @description('Whether public ingress is enabled.') param enableExternalIngress bool @@ -57,8 +73,56 @@ resource containerApp 'Microsoft.App/containerApps@2025-01-01' = { template: { containers: [ { - name: 'platform' + name: 'cas-reference-product' image: containerImage + env: [ + { + name: 'ENVIRONMENT' + value: environment + } + { + name: 'WORKFLOW_BACKEND' + value: workflowBackend + } + { + name: 'FOUNDRY_PROJECT_ENDPOINT' + value: foundryProjectEndpoint + } + { + name: 'FOUNDRY_AGENT_NAME' + value: foundryAgentName + } + { + name: 'APPLICATIONINSIGHTS_CONNECTION_STRING' + value: applicationInsightsConnectionString + } + ] + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health/live' + port: 8080 + scheme: 'HTTP' + } + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + } + { + type: 'Readiness' + httpGet: { + path: '/health/ready' + port: 8080 + scheme: 'HTTP' + } + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 2 + failureThreshold: 3 + } + ] resources: { cpu: json('0.25') memory: '0.5Gi' @@ -99,4 +163,3 @@ output containerAppId string = containerApp.id @description('System-assigned managed identity principal identifier.') output workloadPrincipalId string = containerApp.identity.principalId - diff --git a/infra/modules/foundry-rbac.bicep b/infra/modules/foundry-rbac.bicep new file mode 100644 index 0000000..1503ca4 --- /dev/null +++ b/infra/modules/foundry-rbac.bicep @@ -0,0 +1,36 @@ +targetScope = 'resourceGroup' + +@description('Container App system-assigned identity principal identifier.') +param workloadPrincipalId string + +@description('Explicit Foundry project resource identifier used as the role assignment scope.') +param foundryProjectResourceId string + +@description('Explicit role definition resource identifier approved for the workload.') +param foundryRoleDefinitionResourceId string + +var projectResourceIdParts = split(foundryProjectResourceId, '/') +var foundryAccountName = projectResourceIdParts[8] +var foundryProjectName = projectResourceIdParts[10] + +resource foundryAccount 'Microsoft.CognitiveServices/accounts@2025-06-01' existing = { + name: foundryAccountName +} + +resource foundryProject 'Microsoft.CognitiveServices/accounts/projects@2025-06-01' existing = { + parent: foundryAccount + name: foundryProjectName +} + +resource foundryProjectRoleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = { + name: guid(foundryProject.id, workloadPrincipalId, foundryRoleDefinitionResourceId) + scope: foundryProject + properties: { + principalId: workloadPrincipalId + principalType: 'ServicePrincipal' + roleDefinitionId: foundryRoleDefinitionResourceId + } +} + +@description('Foundry project role assignment identifier.') +output roleAssignmentId string = foundryProjectRoleAssignment.id diff --git a/infra/modules/observability.bicep b/infra/modules/observability.bicep index b781a8b..cbf229c 100644 --- a/infra/modules/observability.bicep +++ b/infra/modules/observability.bicep @@ -53,3 +53,5 @@ output logAnalyticsWorkspaceId string = workspace.id @description('Application Insights resource identifier.') output applicationInsightsId string = applicationInsights.id +@description('Non-secret Application Insights connection string injected into the workload.') +output applicationInsightsConnectionString string = applicationInsights.properties.ConnectionString diff --git a/infra/parameters/dev.bicepparam b/infra/parameters/dev.bicepparam index 8ec00ca..393d9f9 100644 --- a/infra/parameters/dev.bicepparam +++ b/infra/parameters/dev.bicepparam @@ -5,9 +5,13 @@ param location = 'northeurope' param owner = 'Coding-Autopilot-System' param costCenter = 'portfolio' param dataClassification = 'internal' -param containerImage = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' +param containerImage = 'ghcr.io/coding-autopilot-system/cas-reference-product:0.1.0' +param workflowBackend = 'local' +param foundryProjectEndpoint = '' +param foundryAgentName = '' +param foundryProjectResourceId = '' +param foundryRoleDefinitionResourceId = '' param enableExternalIngress = false param logRetentionDays = 30 param monthlyBudget = 0 param budgetContactEmails = [] - diff --git a/infra/parameters/prod.bicepparam b/infra/parameters/prod.bicepparam index ec96887..57484ad 100644 --- a/infra/parameters/prod.bicepparam +++ b/infra/parameters/prod.bicepparam @@ -5,9 +5,13 @@ param location = 'northeurope' param owner = 'Coding-Autopilot-System' param costCenter = 'portfolio' param dataClassification = 'internal' -param containerImage = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' +param containerImage = 'ghcr.io/coding-autopilot-system/cas-reference-product:0.1.0' +param workflowBackend = 'local' +param foundryProjectEndpoint = '' +param foundryAgentName = '' +param foundryProjectResourceId = '' +param foundryRoleDefinitionResourceId = '' param enableExternalIngress = false param logRetentionDays = 90 param monthlyBudget = 0 param budgetContactEmails = [] - diff --git a/infra/parameters/test.bicepparam b/infra/parameters/test.bicepparam index ac4f8b4..702648b 100644 --- a/infra/parameters/test.bicepparam +++ b/infra/parameters/test.bicepparam @@ -5,9 +5,13 @@ param location = 'northeurope' param owner = 'Coding-Autopilot-System' param costCenter = 'portfolio' param dataClassification = 'internal' -param containerImage = 'mcr.microsoft.com/azuredocs/containerapps-helloworld:latest' +param containerImage = 'ghcr.io/coding-autopilot-system/cas-reference-product:0.1.0' +param workflowBackend = 'local' +param foundryProjectEndpoint = '' +param foundryAgentName = '' +param foundryProjectResourceId = '' +param foundryRoleDefinitionResourceId = '' param enableExternalIngress = false param logRetentionDays = 30 param monthlyBudget = 0 param budgetContactEmails = [] - diff --git a/tests/Infrastructure.Tests.ps1 b/tests/Infrastructure.Tests.ps1 index b424fc0..40d4635 100644 --- a/tests/Infrastructure.Tests.ps1 +++ b/tests/Infrastructure.Tests.ps1 @@ -2,6 +2,8 @@ BeforeAll { $repoRoot = Split-Path -Parent $PSScriptRoot $main = Get-Content -Raw (Join-Path $repoRoot 'infra/main.bicep') $compute = Get-Content -Raw (Join-Path $repoRoot 'infra/modules/container-apps.bicep') + $observability = Get-Content -Raw (Join-Path $repoRoot 'infra/modules/observability.bicep') + $foundryRbac = Get-Content -Raw (Join-Path $repoRoot 'infra/modules/foundry-rbac.bicep') $whatIf = Get-Content -Raw (Join-Path $repoRoot 'scripts/what-if.ps1') } @@ -16,17 +18,53 @@ Describe 'CAS platform infrastructure contracts' { It 'disables external ingress by default' { $main | Should -Match 'param enableExternalIngress bool = false' + $compute | Should -Match 'allowInsecure: false' + $compute | Should -Match 'targetPort: 8080' } - It 'does not grant RBAC before dependencies exist' { - $allBicep = Get-ChildItem (Join-Path $repoRoot 'infra') -Recurse -Filter '*.bicep' | - Get-Content -Raw - ($allBicep -join "`n") | Should -Not -Match 'roleAssignments' + It 'injects the complete reference product configuration contract' { + @( + 'ENVIRONMENT', + 'WORKFLOW_BACKEND', + 'FOUNDRY_PROJECT_ENDPOINT', + 'FOUNDRY_AGENT_NAME', + 'APPLICATIONINSIGHTS_CONNECTION_STRING' + ) | ForEach-Object { + $compute | Should -Match "name: '$_'" + } + $observability | Should -Match 'output applicationInsightsConnectionString string' + } + + It 'configures the reference product health probes' { + $compute | Should -Match "type: 'Liveness'" + $compute | Should -Match "path: '/health/live'" + $compute | Should -Match "type: 'Readiness'" + $compute | Should -Match "path: '/health/ready'" + } + + It 'exposes the workload principal identifier' { + $compute | Should -Match 'output workloadPrincipalId string' + $main | Should -Match 'output workloadPrincipalId string' + } + + It 'makes Foundry RBAC optional and requires explicit paired inputs' { + $main | Should -Match "param foundryProjectResourceId string = ''" + $main | Should -Match "param foundryRoleDefinitionResourceId string = ''" + $main | Should -Match 'var enableFoundryRbac = !empty\(foundryProjectResourceId\) && !empty\(foundryRoleDefinitionResourceId\)' + $main | Should -Match 'if \(enableFoundryRbac\)' + } + + It 'scopes optional Foundry RBAC to the explicit project resource' { + $foundryRbac | Should -Match "resource foundryProject 'Microsoft.CognitiveServices/accounts/projects@" + $foundryRbac | Should -Match 'scope: foundryProject' + $foundryRbac | Should -Match 'roleDefinitionId: foundryRoleDefinitionResourceId' + $foundryRbac | Should -Not -Match "scope: subscription\(\)" } It 'keeps the what-if script non-deploying' { $whatIf | Should -Match 'az deployment sub what-if' $whatIf | Should -Not -Match 'az deployment sub create' + $whatIf | Should -Not -Match 'az deployment group create' } It 'defines all supported environments' { @@ -35,4 +73,3 @@ Describe 'CAS platform infrastructure contracts' { } } } -