Skip to content

Commit 6eb2fdb

Browse files
haflidifCopilot
andauthored
Add existing-project orchestrator for network-secured Standard Agent setup (microsoft-foundry#414)
Adds add-existing-project.bicep so the network-secured Standard Agent setup can be wired onto a Foundry project that already exists, instead of always creating a new project with a random suffix. This covers the common case of securing an already-deployed Foundry behind private endpoints. - New orchestrator add-existing-project.bicep mirrors add-project.bicep but references an existing project in place (no suffix appended). - New module ai-existing-project-connections.bicep declares the Cosmos DB, Storage, and AI Search connections on the existing project with AAD auth and outputs the names so the capability host binds to them. - Optional toggles for already-configured projects: assignRoles to skip role-assignment modules when the identity is already permissioned, explicit connection-name overrides to match a pre-existing capability host, and projectCapHost to target an existing host. - Example add-existing-project.bicepparam and a README section covering prerequisites, the optional parameters, a required-roles checklist for the assignRoles=false case, idempotency, and scope/caveats. Validated on a repro environment: idempotent re-deploy converges as a no-op, and assignRoles=false deploys cleanly against a project that already has the role assignments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 448ca84 commit 6eb2fdb

4 files changed

Lines changed: 538 additions & 0 deletions

File tree

infrastructure/infrastructure-setup-bicep/15-private-network-standard-agent-setup/README.md

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,3 +656,183 @@ Each new project deployment creates:
656656
- [Private Endpoint Documentation](https://learn.microsoft.com/en-us/azure/private-link/)
657657
- [RBAC Documentation](https://learn.microsoft.com/en-us/azure/role-based-access-control/)
658658
- [Network Security Best Practices](https://learn.microsoft.com/en-us/azure/security/fundamentals/network-best-practices)
659+
660+
# (Optional) Securing an Existing Project (Reuse In-Place)
661+
662+
`add-project.bicep` always creates a brand new project and appends a short random
663+
suffix to keep the name unique. That is the right behavior when you want a fresh
664+
project, but it does not help when you already have a Foundry project in use and
665+
simply want to wire the network-secured agent setup onto it.
666+
667+
`add-existing-project.bicep` covers that case. It reuses the project you name, in
668+
place, with no new project and no suffix. This is the common situation when you
669+
are securing an already-running Foundry deployment behind private endpoints and
670+
do not want to recreate projects or move agents.
671+
672+
## When to use which
673+
674+
| Goal | Template |
675+
|------|----------|
676+
| Add a new, separate project to an existing Foundry account | `add-project.bicep` |
677+
| Wire/secure the agent setup onto a project that already exists | `add-existing-project.bicep` |
678+
679+
## What it does
680+
681+
`add-existing-project.bicep` references the existing project with the `existing`
682+
keyword and then layers on the same building blocks the new-project flow uses:
683+
684+
-**Adds the three agent connections** (Cosmos DB, Storage, AI Search) to the
685+
existing project using AAD (Entra ID) auth, no keys.
686+
-**Assigns the required RBAC roles** to the project managed identity on the
687+
shared Storage, Cosmos DB, and AI Search resources.
688+
-**Creates (or updates) the project capability host** bound to those exact
689+
connection names.
690+
-**Assigns the container-scoped roles** for the project's storage and Cosmos
691+
containers after the capability host exists.
692+
-**Reuses every shared module** from the new-project flow, so behavior stays
693+
consistent between the two paths.
694+
695+
It does **not** create the project, change its display name, or touch its
696+
description. Those stay exactly as they are.
697+
698+
## Prerequisites
699+
700+
1.**The project already exists** under the target AI Services (Foundry)
701+
account, and the account is the network-secured account from your original
702+
deployment.
703+
2.**The project has a system-assigned managed identity.** The connections and
704+
role assignments target that identity.
705+
3.**Account-level network injection is already in place** (the agent subnet is
706+
configured on the account capability host from the original `main.bicep`
707+
deployment). Network security is account-scoped, so once the account is
708+
injected, every project under it inherits agent-subnet security. You do not
709+
re-run the full template per project.
710+
4.**AI Search allows AAD auth** (`authOptions` is not set to API-keys-only), so
711+
the AAD connection can be used.
712+
5.**Run the deployment in the account's resource group.** Like
713+
`add-project.bicep`, this template operates on the project and capability host
714+
at the deployment resource group, so deploy into the resource group that holds
715+
the Foundry account. The shared Storage, Cosmos DB, and AI Search resources can
716+
live in other resource groups or subscriptions (set their RG and subscription
717+
parameters accordingly).
718+
6.**Azure CLI** installed and logged in, with permission to create role
719+
assignments and capability hosts on the target resources.
720+
7.**Cross-subscription resources stay in the same tenant.** If your Storage,
721+
Cosmos DB, or AI Search resources live in other subscriptions, they must be
722+
reachable by the deployment identity and in the same Microsoft Entra tenant as
723+
the project managed identity, so the AAD connections and role assignments
724+
resolve.
725+
726+
## Step-by-step
727+
728+
### Step 1: Configure the parameters file
729+
730+
Edit `add-existing-project.bicepparam` and set `projectName` to the existing
731+
project, plus the names, resource groups, and subscription IDs of the shared
732+
resources from your original deployment. You can reuse `get-existing-resources.ps1`
733+
to discover the shared resource names.
734+
735+
```bicep
736+
// EXISTING project to reuse in place (no suffix is appended)
737+
param projectName = 'your-existing-project-name'
738+
param projectCapHost = 'caphostproj'
739+
740+
param existingAccountName = 'your-foundry-account'
741+
param accountResourceGroupName = 'your-resource-group'
742+
param accountSubscriptionId = 'your-subscription-id'
743+
// ... plus existingAiSearchName / existingStorageName / existingCosmosDBName
744+
// and their resource groups and subscription IDs
745+
```
746+
747+
### Step 2: Preview the change
748+
749+
Run a what-if first so you can see exactly what will be added before anything is
750+
deployed:
751+
752+
```powershell
753+
az deployment group what-if `
754+
--resource-group "your-resource-group" `
755+
--template-file "add-existing-project.bicep" `
756+
--parameters "add-existing-project.bicepparam"
757+
```
758+
759+
### Step 3: Deploy
760+
761+
```powershell
762+
az deployment group create `
763+
--resource-group "your-resource-group" `
764+
--template-file "add-existing-project.bicep" `
765+
--parameters "add-existing-project.bicepparam"
766+
```
767+
768+
## Handling already-configured projects
769+
770+
Production projects are rarely a blank slate. The template has a few optional
771+
parameters so you can point it at a project that already has some of this wiring
772+
in place. Review the project's current connections, capability host, and role
773+
assignments before you run, then set these as needed:
774+
775+
- **`assignRoles`** (default `true`). Set to `false` to skip every
776+
role-assignment module. Use this when the project identity is already
777+
permissioned on Storage, Cosmos DB, and AI Search. Manual or earlier
778+
assignments use different assignment names, and Azure rejects a duplicate on
779+
the same identity, role, and scope with `RoleAssignmentExists`. Skipping the
780+
modules avoids that conflict. Do not set `assignRoles = false` unless every
781+
role in the checklist below already exists for the project managed identity.
782+
When a required role is missing, the deployment still succeeds, but the agent
783+
fails at runtime rather than at deploy time, which is harder to diagnose.
784+
- **`cosmosDBConnectionName` / `azureStorageConnectionName` /
785+
`aiSearchConnectionName`** (default empty). Leave empty to use the
786+
deterministic default `<resourceName>-<project>`. Set them to the exact
787+
connection names a pre-existing capability host already binds to, so the
788+
capability host keeps pointing at the same connections. Note that if a
789+
connection with the supplied (or default) name already exists, this deployment
790+
updates that connection to target the Storage, Cosmos DB, and AI Search
791+
resources you pass in. Do not reuse a live connection name unless it already
792+
targets the resources you intend.
793+
- **`projectCapHost`** (default `caphostproj`). Point this at the capability host
794+
name the project already uses. If that host exists, the deployment updates its
795+
connection bindings, which affects live agents bound to it.
796+
797+
### Required roles when you skip role assignment
798+
799+
If you set `assignRoles = false`, confirm the project managed identity already
800+
holds these roles before you run, or the agent will fail at runtime:
801+
802+
| Resource | Role | Scope |
803+
|----------|------|-------|
804+
| Storage account | Storage Blob Data Contributor | Storage account |
805+
| Storage account | Storage Blob Data Owner (ABAC-conditioned to `*-azureml-agent` containers) | Storage account |
806+
| Cosmos DB account | Cosmos DB Operator | Cosmos DB account |
807+
| Cosmos DB data plane | Cosmos DB Built-in Data Contributor | `enterprise_memory` database |
808+
| AI Search | Search Index Data Contributor | AI Search service |
809+
| AI Search | Search Service Contributor | AI Search service |
810+
811+
## Idempotency
812+
813+
The template is safe to re-run. Connection names and role-assignment IDs are
814+
derived deterministically from the project name, so a second run converges on the
815+
same resources instead of creating duplicates.
816+
817+
## Scope and caveats
818+
819+
This template targets the retrofit case: wiring the agent setup onto a project
820+
that already exists. Keep these points in mind:
821+
822+
- 📝 A deployment that fails partway can leave the project partly wired (some
823+
connections or roles present, the capability host not yet created). Re-running
824+
after you fix the cause converges the rest, because every step is
825+
deterministic and idempotent.
826+
- 📝 The three connections are created (or updated) with names derived from the
827+
shared resource names plus the project name, unless you override them. If the
828+
project already has connections created another way (for example through the
829+
portal, which sanitizes and suffixes names), pass the override parameters so
830+
the capability host binds to the names that already exist instead of adding new
831+
ones.
832+
- 📝 If a capability host named `caphostproj` already exists on the project, the
833+
deployment updates its connection bindings. Use `projectCapHost` to point at a
834+
specific name if needed.
835+
- 📝 Network injection is account-scoped, not project-scoped. Once the account
836+
capability host has the agent subnet (from the original `main.bicep`
837+
deployment), every project under that account inherits agent-subnet security.
838+
You run this lighter template per project, not the full setup.
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Day-2 scenario: wire and secure the network-secured Standard Agent setup onto an
2+
// EXISTING AI Foundry project, reusing it in place. Unlike add-project.bicep this
3+
// does NOT create a new project and does NOT append a random suffix: the supplied
4+
// projectName must be an existing project under the supplied account. It layers the
5+
// three agent connections, role assignments, and the project capability host onto
6+
// that project, reusing every shared module. See the README section
7+
// "Securing an Existing Project (Reuse In-Place)".
8+
9+
@description('Name of the existing AI Services (Foundry) account')
10+
param existingAccountName string
11+
12+
@description('Resource group containing the AI Services account')
13+
param accountResourceGroupName string = resourceGroup().name
14+
15+
@description('Subscription ID containing the AI Services account')
16+
param accountSubscriptionId string = subscription().subscriptionId
17+
18+
@description('Name of the EXISTING project to reuse. No suffix is appended.')
19+
param projectName string
20+
21+
@description('Name for the project capability host')
22+
param projectCapHost string = 'caphostproj'
23+
24+
@description('Set false to skip all role-assignment modules. Use this when the existing project identity is ALREADY permissioned on the backing services (a pre-permissioned production project), to avoid RoleAssignmentExists on assignments that were created under different names.')
25+
param assignRoles bool = true
26+
27+
@description('Optional. Full name of the Cosmos DB connection on the project. Leave empty to use the default <cosmosName>-<project>. Set this to match a connection that already exists (for example one a portal-created capability host already binds to).')
28+
param cosmosDBConnectionName string = ''
29+
30+
@description('Optional. Full name of the Storage connection on the project. Leave empty to use the default <storageName>-<project>.')
31+
param azureStorageConnectionName string = ''
32+
33+
@description('Optional. Full name of the AI Search connection on the project. Leave empty to use the default <searchName>-<project>.')
34+
param aiSearchConnectionName string = ''
35+
36+
// Existing shared resources (from your original deployment)
37+
@description('Name of the existing AI Search service')
38+
param existingAiSearchName string
39+
40+
@description('Resource group containing the AI Search service')
41+
param aiSearchResourceGroupName string
42+
43+
@description('Subscription ID containing the AI Search service')
44+
param aiSearchSubscriptionId string
45+
46+
@description('Name of the existing Storage Account')
47+
param existingStorageName string
48+
49+
@description('Resource group containing the Storage Account')
50+
param storageResourceGroupName string
51+
52+
@description('Subscription ID containing the Storage Account')
53+
param storageSubscriptionId string
54+
55+
@description('Name of the existing Cosmos DB account')
56+
param existingCosmosDBName string
57+
58+
@description('Resource group containing the Cosmos DB account')
59+
param cosmosDBResourceGroupName string
60+
61+
@description('Subscription ID containing the Cosmos DB account')
62+
param cosmosDBSubscriptionId string
63+
64+
// Deterministic suffix from the project name. Keeps connection names and role
65+
// assignment GUIDs stable across re-runs so the deployment is idempotent.
66+
var projectNameLower = toLower(projectName)
67+
var connectionSuffix = '-${projectNameLower}'
68+
69+
// Effective connection names: explicit override if supplied, otherwise the
70+
// deterministic default. Lets a customer match connections a pre-existing
71+
// capability host already binds to.
72+
var cosmosDBConnectionNameEffective = empty(cosmosDBConnectionName) ? '${existingCosmosDBName}${connectionSuffix}' : cosmosDBConnectionName
73+
var azureStorageConnectionNameEffective = empty(azureStorageConnectionName) ? '${existingStorageName}${connectionSuffix}' : azureStorageConnectionName
74+
var aiSearchConnectionNameEffective = empty(aiSearchConnectionName) ? '${existingAiSearchName}${connectionSuffix}' : aiSearchConnectionName
75+
76+
// Reference existing AI Services account
77+
resource account 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = {
78+
name: existingAccountName
79+
scope: resourceGroup(accountSubscriptionId, accountResourceGroupName)
80+
}
81+
82+
// Reference existing shared resources
83+
resource aiSearch 'Microsoft.Search/searchServices@2023-11-01' existing = {
84+
name: existingAiSearchName
85+
scope: resourceGroup(aiSearchSubscriptionId, aiSearchResourceGroupName)
86+
}
87+
88+
resource storage 'Microsoft.Storage/storageAccounts@2022-05-01' existing = {
89+
name: existingStorageName
90+
scope: resourceGroup(storageSubscriptionId, storageResourceGroupName)
91+
}
92+
93+
resource cosmosDB 'Microsoft.DocumentDB/databaseAccounts@2024-11-15' existing = {
94+
name: existingCosmosDBName
95+
scope: resourceGroup(cosmosDBSubscriptionId, cosmosDBResourceGroupName)
96+
}
97+
98+
// Add the agent connections to the EXISTING project (no project is created)
99+
module aiProject 'modules-network-secured/ai-existing-project-connections.bicep' = {
100+
name: 'ai-existing-${projectNameLower}-deployment'
101+
params: {
102+
accountName: existingAccountName
103+
projectName: projectName
104+
105+
aiSearchName: existingAiSearchName
106+
aiSearchServiceResourceGroupName: aiSearchResourceGroupName
107+
aiSearchServiceSubscriptionId: aiSearchSubscriptionId
108+
109+
cosmosDBName: existingCosmosDBName
110+
cosmosDBSubscriptionId: cosmosDBSubscriptionId
111+
cosmosDBResourceGroupName: cosmosDBResourceGroupName
112+
113+
azureStorageName: existingStorageName
114+
azureStorageSubscriptionId: storageSubscriptionId
115+
azureStorageResourceGroupName: storageResourceGroupName
116+
117+
cosmosDBConnectionName: cosmosDBConnectionNameEffective
118+
azureStorageConnectionName: azureStorageConnectionNameEffective
119+
aiSearchConnectionName: aiSearchConnectionNameEffective
120+
}
121+
}
122+
123+
module formatProjectWorkspaceId 'modules-network-secured/format-project-workspace-id.bicep' = {
124+
name: 'format-workspace-id-${projectNameLower}-deployment'
125+
params: {
126+
projectWorkspaceId: aiProject.outputs.projectWorkspaceId
127+
}
128+
}
129+
130+
// Assign storage account role
131+
module storageAccountRoleAssignment 'modules-network-secured/azure-storage-account-role-assignment.bicep' = if (assignRoles) {
132+
name: 'storage-account-ra-${projectNameLower}-deployment'
133+
scope: resourceGroup(storageSubscriptionId, storageResourceGroupName)
134+
params: {
135+
azureStorageName: existingStorageName
136+
projectPrincipalId: aiProject.outputs.projectPrincipalId
137+
}
138+
}
139+
140+
// Assign Cosmos DB account role
141+
module cosmosAccountRoleAssignments 'modules-network-secured/cosmosdb-account-role-assignment.bicep' = if (assignRoles) {
142+
name: 'cosmos-account-ra-${projectNameLower}-deployment'
143+
scope: resourceGroup(cosmosDBSubscriptionId, cosmosDBResourceGroupName)
144+
params: {
145+
cosmosDBName: existingCosmosDBName
146+
projectPrincipalId: aiProject.outputs.projectPrincipalId
147+
}
148+
}
149+
150+
// Assign AI Search role
151+
module aiSearchRoleAssignments 'modules-network-secured/ai-search-role-assignments.bicep' = if (assignRoles) {
152+
name: 'ai-search-ra-${projectNameLower}-deployment'
153+
scope: resourceGroup(aiSearchSubscriptionId, aiSearchResourceGroupName)
154+
params: {
155+
aiSearchName: existingAiSearchName
156+
projectPrincipalId: aiProject.outputs.projectPrincipalId
157+
}
158+
}
159+
160+
// Create (or update) the capability host for the existing project
161+
module addProjectCapabilityHost 'modules-network-secured/add-project-capability-host.bicep' = {
162+
name: 'capabilityHost-${projectNameLower}-deployment'
163+
params: {
164+
accountName: existingAccountName
165+
projectName: aiProject.outputs.projectName
166+
cosmosDBConnection: aiProject.outputs.cosmosDBConnection
167+
azureStorageConnection: aiProject.outputs.azureStorageConnection
168+
aiSearchConnection: aiProject.outputs.aiSearchConnection
169+
projectCapHost: projectCapHost
170+
}
171+
dependsOn: [
172+
cosmosAccountRoleAssignments
173+
storageAccountRoleAssignment
174+
aiSearchRoleAssignments
175+
]
176+
}
177+
178+
// Assign storage container roles after capability host creation
179+
module storageContainersRoleAssignment 'modules-network-secured/blob-storage-container-role-assignments-unique.bicep' = if (assignRoles) {
180+
name: 'storage-containers-ra-${projectNameLower}-deployment'
181+
scope: resourceGroup(storageSubscriptionId, storageResourceGroupName)
182+
params: {
183+
aiProjectPrincipalId: aiProject.outputs.projectPrincipalId
184+
storageName: existingStorageName
185+
workspaceId: formatProjectWorkspaceId.outputs.projectWorkspaceIdGuid
186+
uniqueSuffix: projectNameLower
187+
}
188+
dependsOn: [
189+
addProjectCapabilityHost
190+
]
191+
}
192+
193+
// Assign Cosmos container roles after capability host creation
194+
module cosmosContainerRoleAssignments 'modules-network-secured/cosmos-container-role-assignments.bicep' = if (assignRoles) {
195+
name: 'cosmos-containers-ra-${projectNameLower}-deployment'
196+
scope: resourceGroup(cosmosDBSubscriptionId, cosmosDBResourceGroupName)
197+
params: {
198+
cosmosAccountName: existingCosmosDBName
199+
projectWorkspaceId: formatProjectWorkspaceId.outputs.projectWorkspaceIdGuid
200+
projectPrincipalId: aiProject.outputs.projectPrincipalId
201+
}
202+
dependsOn: [
203+
addProjectCapabilityHost
204+
storageContainersRoleAssignment
205+
]
206+
}
207+
208+
// Outputs
209+
output projectName string = aiProject.outputs.projectName
210+
output projectPrincipalId string = aiProject.outputs.projectPrincipalId
211+
output projectWorkspaceId string = aiProject.outputs.projectWorkspaceId
212+
output capabilityHostName string = addProjectCapabilityHost.outputs.projectCapHost

0 commit comments

Comments
 (0)