Setting the file. One moment.
Check RBAC Preflight · Foundry Agentic Loop · github/awesome-copilot · Skills Docs
ContentsBack to the top of the page 180.10
Foundry Toolbox
def find_missing
— line 380
This file
Number 180.3
Position 3 of 54
Type Python
Size 19 KB
Lines 476 references/ check_rbac_preflight.py
Python · 476 lines · 19 KB
15
and group-derived grants count. Roles are matched by their built-in role
16 definition ID, so renames (Azure AI * -> Foundry *) do not break the check, and
17 a requirement is satisfied by an equivalent-or-broader role (Owner satisfies
18 Contributor), which is why a fully-permissioned run reports nothing. Custom
19 roles are not evaluated.
20
21 Verdicts:
22 PASS every required assignment is satisfied. Prints one summary line
23 (nothing at all with --quiet).
24 BLOCKED at least one is missing. Prints the full list. Do not start provisioning.
25 ERROR the check could not be evaluated -- resolve it, don't skip it.
26
27 Usage:
28 python check_rbac_preflight.py --subscription <sub-id>
29 python check_rbac_preflight.py --subscription <sub-id> --resource-group rg-app
30 python check_rbac_preflight.py --from-file observed.json --subscription <sub-id>
31
32 Environment (each has a matching flag):
33 AZURE_SUBSCRIPTION_ID target subscription (required)
34 AZURE_RESOURCE_GROUP pre-existing resource group, if the run targets one
35 AZURE_AI_ACCOUNT_ID existing Foundry AI account resource id, if brownfield
36 AZURE_AI_PROJECT_ID existing Foundry project resource id, if brownfield
37
38 Exit codes: 0 on PASS, 2 on BLOCKED, 1 on ERROR.
39 """
40
41 from __future__ import annotations
42
43 import argparse
44 import json
45 import os
46 import shutil
47 import subprocess
48 import sys
49
50 REFERENCE = (
51 "skills/foundry-agentic-loop/references/rbac-contract.md#deployer-prerequisites-pre-flight"
52 )
53
54 # Scope kinds, in the order they are reported.
55 SUBSCRIPTION = "subscription"
56 RESOURCE_GROUP = "resource_group"
57 AI_ACCOUNT = "ai_account"
58 AI_PROJECT = "ai_project"
59
60 # Built-in role definition IDs are stable; display names are not. Microsoft
61 # renamed the Azure AI roles to Foundry roles, so match on the ID and keep the
62 # current name for the report. Legacy names still resolve for --from-file input.
63 ROLES : dict[ str , tuple[ str , str ]] = {
64 "owner" : ( "8e3af657-a8ff-443c-a75c-2fe8c4bcb635" , "Owner" ),
65 "contributor" : ( "b24988ac-6180-42a0-ab88-20f7382dd24c" , "Contributor" ),
66 "user_access_admin" : ( "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9" , "User Access Administrator" ),
67 "rbac_admin" : ( "f58310d9-a9f6-439a-9e8d-f62e7b41a168" , "Role Based Access Control Administrator" ),
68 "cs_contributor" : ( "25fbc0a9-bd7c-42a3-aa1a-3b75d497ee68" , "Cognitive Services Contributor" ),
69 "foundry_owner" : ( "c883944f-8b7b-4483-af10-35834be79c4a" , "Foundry Owner" ),
70 "foundry_account_owner" : ( "e47c6f54-e4a2-4754-9501-8e0985b135e1" , "Foundry Account Owner" ),
71 "foundry_project_manager" : ( "eadc314b-1a2d-4efa-be10-5d325db5065e" , "Foundry Project Manager" ),
72 "foundry_user" : ( "53ca6127-db72-4b80-b1b0-d745d6d5456d" , "Foundry User" ),
73 "cs_user" : ( "a97b65f3-24c7-4388-baec-2e87135dc908" , "Cognitive Services User" ),
74 }
75 LEGACY_NAMES = {
76 "Azure AI Owner" : "foundry_owner" ,
77 "Azure AI Account Owner" : "foundry_account_owner" ,
78 "Azure AI Project Manager" : "foundry_project_manager" ,
79 "Azure AI User" : "foundry_user" ,
80 }
81 _BY_ID = {guid: key for key, (guid, _) in ROLES .items()}
82 _BY_NAME = {name.lower(): key for key, (_, name) in ROLES .items()} | {
83 name.lower(): key for name, key in LEGACY_NAMES .items()
84 }
85
86 # Foundry control plane (Microsoft.CognitiveServices/* actions) versus data plane
87 # (Microsoft.CognitiveServices/* dataActions). Owner, Contributor, Foundry Account
88 # Owner and Cognitive Services Contributor carry no dataActions, so none of them
89 # can create agents alone; Foundry User and Cognitive Services User carry only
90 # dataActions, so neither can create project connections alone.
91 ACCOUNT_CONTROL = [ "foundry_account_owner" , "foundry_owner" , "owner" , "contributor" , "cs_contributor" ]
92 DATA_PLANE = [ "foundry_user" , "cs_user" ]
93 DEPLOY = [{ "contributor" }, { "owner" }]
94 ASSIGN = [{ "rbac_admin" }, { "owner" }, { "user_access_admin" }]
95
96 # Required assignments per scope kind -- the deployer prerequisites table.
97 # A requirement is met when every role of any one alternative is effective at the
98 # scope. Remedies are tried in order and the first that completes an alternative
99 # is reported, so the remediation is the smallest grant that works.
100 REQUIREMENTS : list[ dict ] = [
101 {
102 "kind" : SUBSCRIPTION ,
103 "why" : "Create the resource group and every resource in the reference architecture" ,
104 "alternatives" : DEPLOY ,
105 "accepts" : "Contributor | Owner" ,
106 "remedies" : [{ "contributor" }],
107 },
108 {
109 "kind" : SUBSCRIPTION ,
110 "why" : "Microsoft.Authorization/roleAssignments/write - the Bicep creates every row "
111 "of the runtime matrix" ,
112 "alternatives" : ASSIGN ,
113 "accepts" : "Role Based Access Control Administrator | Owner | User Access Administrator" ,
114 "remedies" : [{ "rbac_admin" }],
115 },
116 {
117 "kind" : RESOURCE_GROUP ,
118 "why" : "Create every resource of the solution in the existing resource group" ,
119 "alternatives" : DEPLOY ,
120 "accepts" : "Contributor | Owner" ,
121 "remedies" : [{ "contributor" }],
122 },
123 {
124 "kind" : RESOURCE_GROUP ,
125 "why" : "Microsoft.Authorization/roleAssignments/write - create the runtime matrix rows "
126 "in the resource group" ,
127 "alternatives" : ASSIGN ,
128 "accepts" : "Role Based Access Control Administrator | Owner | User Access Administrator" ,
129 "remedies" : [{ "rbac_admin" }],
130 },
131 {
132 "kind" : AI_ACCOUNT ,
133 "why" : "Create projects, model deployments and account resources (control plane)" ,
134 "alternatives" : [{role} for role in ACCOUNT_CONTROL ],
135 "accepts" : "Foundry Account Owner | Foundry Owner | Owner | Contributor | "
136 "Cognitive Services Contributor" ,
137 "remedies" : [{ "foundry_account_owner" }],
138 },
139 {
140 "kind" : AI_PROJECT ,
141 "why" : "Create and publish hosted agents, skills, toolboxes (data plane) and project "
142 "connections (control plane)" ,
143 "alternatives" : [{ "foundry_project_manager" }, { "foundry_owner" }]
144 + [{data, control} for data in DATA_PLANE for control in ACCOUNT_CONTROL ],
145 "accepts" : "Foundry Project Manager | Foundry Owner | Foundry User (or Cognitive "
146 "Services User) plus Foundry Account Owner, Owner, Contributor or Cognitive "
147 "Services Contributor" ,
148 "remedies" : [{ "foundry_user" }, { "foundry_project_manager" }],
149 },
150 ]
151
152
153 class PreflightError ( Exception ):
154 """The check could not be evaluated (verdict ERROR)."""
155
156
157 def _parse_args (argv: list[ str ] | None = None ) -> argparse.Namespace:
158 parser = argparse.ArgumentParser( description = __doc__ .splitlines()[ 0 ])
159 parser.add_argument(
160 "--subscription" ,
161 default = os.environ.get( "AZURE_SUBSCRIPTION_ID" ),
162 help = "Target subscription id (default: $AZURE_SUBSCRIPTION_ID)." ,
163 )
164 parser.add_argument(
165 "--resource-group" ,
166 default = os.environ.get( "AZURE_RESOURCE_GROUP" ),
167 help = (
168 "Pre-existing resource group name (default: $AZURE_RESOURCE_GROUP). "
169 "Omit on a greenfield run, where the deployment creates it."
170 ),
171 )
172 parser.add_argument(
173 "--ai-account" ,
174 default = os.environ.get( "AZURE_AI_ACCOUNT_ID" ),
175 help = "Existing Foundry AI account resource id (default: $AZURE_AI_ACCOUNT_ID)." ,
176 )
177 parser.add_argument(
178 "--ai-project" ,
179 default = os.environ.get( "AZURE_AI_PROJECT_ID" ),
180 help = "Existing Foundry project resource id (default: $AZURE_AI_PROJECT_ID)." ,
181 )
182 parser.add_argument(
183 "--assignee" ,
184 help = (
185 "Principal to check (object id or UPN). Defaults to the signed-in user, "
186 "or to 'principal' in the --from-file payload."
187 ),
188 )
189 parser.add_argument(
190 "--from-file" ,
191 help = (
192 "Read observed assignments from a JSON file instead of calling Azure. "
193 "Shape: {'principal': {'id': ..., 'display': ...}, "
194 "'assignments': [{'scope': ..., 'role': <name or definition id>}, ...]}. "
195 "Raw 'az role assignment list' output is accepted too."
196 ),
197 )
198 parser.add_argument(
199 "--quiet" ,
200 action = "store_true" ,
201 help = "Print nothing on PASS. The BLOCKED report is always printed." ,
202 )
203 return parser.parse_args(argv)
204
205
206 def _az (args: list[ str ]) -> object :
207 """Run an `az` command and return its parsed JSON output."""
208 if shutil.which( "az" ) is None :
209 raise PreflightError(
210 "Azure CLI ('az') not found on PATH -- required unless --from-file is used."
211 )
212 completed = subprocess.run(
213 [ "az" , * args, "-o" , "json" ],
214 capture_output = True ,
215 text = True ,
216 check = False ,
217 )
218 if completed.returncode != 0 :
219 detail = (completed.stderr or completed.stdout).strip().splitlines()
220 raise PreflightError(
221 f "az { ' ' .join(args) } failed: { detail[ - 1 ] if detail else 'unknown error' } "
222 )
223 try :
224 return json.loads(completed.stdout or "null" )
225 except json.JSONDecodeError as exc:
226 raise PreflightError( f "az { ' ' .join(args) } returned non-JSON output: { exc } " ) from exc
227
228
229 def build_scopes (args: argparse.Namespace) -> list[tuple[ str , str ]]:
230 """Return the (scope kind, scope id) pairs in play, in report order.
231
232 An existing resource group carries the deployment, so its rows replace the
233 subscription rows (subscription grants still satisfy them by inheritance).
234 Without one, the subscription rows are checked because the run creates the
235 group. Foundry scopes are checked when the run targets existing ones.
236 """
237 if not args.subscription:
238 raise PreflightError(
239 "No subscription: set AZURE_SUBSCRIPTION_ID or pass --subscription."
240 )
241
242 subscription_scope = f "/subscriptions/ { args.subscription } "
243 if args.resource_group:
244 group = args.resource_group
245 scopes = [
246 (
247 RESOURCE_GROUP ,
248 group
249 if group.startswith( "/subscriptions/" )
250 else f " { subscription_scope } /resourceGroups/ { group } " ,
251 )
252 ]
253 else :
254 scopes = [( SUBSCRIPTION , subscription_scope)]
255 if args.ai_account:
256 scopes.append(( AI_ACCOUNT , args.ai_account))
257 if args.ai_project:
258 scopes.append(( AI_PROJECT , args.ai_project))
259 return scopes
260
261
262 def drop_missing_group (scopes: list[tuple[ str , str ]], subscription: str ) -> list[tuple[ str , str ]]:
263 """Check the subscription rows instead when the resource group does not exist yet."""
264 for index, (kind, scope) in enumerate (scopes):
265 if kind != RESOURCE_GROUP :
266 continue
267 name = scope.rstrip( "/" ).rsplit( "/" , 1 )[ - 1 ]
268 sub = scope.split( "/" )[ 2 ] if scope.startswith( "/subscriptions/" ) else subscription
269 if _az([ "group" , "exists" , "--name" , name, "--subscription" , sub]) is False :
270 scopes[index] = ( SUBSCRIPTION , f "/subscriptions/ { sub } " )
271 return scopes
272
273
274 def _role_key (item: dict ) -> str :
275 """Map an assignment to a ROLES key by definition ID, falling back to its name."""
276 for field in ( "roleDefinitionId" , "role" , "roleDefinitionName" ):
277 value = str (item.get(field) or "" ).strip()
278 if not value:
279 continue
280 guid = value.rstrip( "/" ).rsplit( "/" , 1 )[ - 1 ].lower()
281 if guid in _BY_ID :
282 return _BY_ID [guid]
283 if value.lower() in _BY_NAME :
284 return _BY_NAME [value.lower()]
285 return ""
286
287
288 def _normalize (assignments: object ) -> list[dict[ str , str ]]:
289 """Keep the known roles only; custom and unrelated roles satisfy nothing here."""
290 records = []
291 for item in assignments or []:
292 if not isinstance (item, dict ):
293 continue
294 scope = item.get( "scope" )
295 role = _role_key(item)
296 if scope and role:
297 records.append({ "scope" : str (scope), "role" : role})
298 return records
299
300
301 def load_from_file (path: str , assignee: str | None ) -> tuple[dict[ str , str ], list[dict[ str , str ]]]:
302 """Load a recorded snapshot: {"principal": {"id", "display"}, "assignments": [...]}.
303
304 A bare list of assignments carries no principal, so it needs --assignee; the
305 report never prints a remediation command for an unknown principal.
306 """
307 try :
308 with open (path, encoding = "utf-8" ) as handle:
309 payload = json.load(handle)
310 except json.JSONDecodeError as exc:
311 raise PreflightError( f " { path } is not valid JSON: { exc } " ) from exc
312 if isinstance (payload, list ):
313 payload = { "assignments" : payload}
314 if not isinstance (payload, dict ):
315 raise PreflightError( f " { path } must hold an object or a list of assignments." )
316 principal = payload.get( "principal" ) or {}
317 assignments = payload.get( "assignments" )
318 if not isinstance (principal, dict ) or not isinstance (assignments, list ):
319 raise PreflightError(
320 f " { path } must hold 'principal' as an object and 'assignments' as a list."
321 )
322 principal_id = str (assignee or principal.get( "id" ) or "" ).strip()
323 if not principal_id:
324 raise PreflightError( f " { path } names no principal -- pass --assignee." )
325 resolved = {
326 "id" : principal_id,
327 "display" : str (principal.get( "display" ) or principal_id),
328 }
329 return resolved, _normalize(assignments)
330
331
332 def resolve_principal (assignee: str | None ) -> dict[ str , str ]:
333 if assignee:
334 return { "id" : assignee, "display" : assignee}
335 user = _az([ "ad" , "signed-in-user" , "show" ])
336 if not isinstance (user, dict ) or not user.get( "id" ):
337 raise PreflightError(
338 "Could not resolve the signed-in user -- run 'az login' or pass --assignee."
339 )
340 return {
341 "id" : str (user[ "id" ]),
342 "display" : str (user.get( "userPrincipalName" ) or user.get( "displayName" ) or user[ "id" ]),
343 }
344
345
346 def resolve_assignments (principal_id: str , scopes: list[tuple[ str , str ]]) -> list[dict[ str , str ]]:
347 """Read effective assignments -- inherited and group-derived included."""
348 records: list[dict[ str , str ]] = []
349 for _, scope in scopes:
350 listed = _az(
351 [
352 "role" ,
353 "assignment" ,
354 "list" ,
355 "--assignee" ,
356 principal_id,
357 "--scope" ,
358 scope,
359 "--include-inherited" ,
360 "--include-groups" ,
361 ]
362 )
363 if not isinstance (listed, list ):
364 raise PreflightError( f "az role assignment list returned no list for { scope } ." )
365 records.extend(_normalize(listed))
366 return records
367
368
369 def _covers (assignment_scope: str , scope: str ) -> bool :
370 """True when an assignment at `assignment_scope` applies to `scope`.
371
372 An assignment inherits downwards, so a subscription-scoped grant satisfies a
373 requirement on a resource group or Foundry project beneath it.
374 """
375 a = assignment_scope.rstrip( "/" ).lower()
376 s = scope.rstrip( "/" ).lower()
377 return s == a or s.startswith( f " { a } /" )
378
379
380 def find_missing (
381 scopes: list[tuple[ str , str ]], assignments: list[dict[ str , str ]]
382 ) -> list[dict[ str , object ]]:
383 """Diff required against effective, applying role and scope equivalence."""
384 missing = []
385 for kind, scope in scopes:
386 effective = {
387 record[ "role" ] for record in assignments if _covers(record[ "scope" ], scope)
388 }
389 for req in REQUIREMENTS :
390 alternatives = req[ "alternatives" ]
391 if req[ "kind" ] != kind or any (alt <= effective for alt in alternatives):
392 continue
393 remedy = next (
394 (
395 grant
396 for grant in req[ "remedies" ]
397 if any (alt <= effective | grant for alt in alternatives)
398 ),
399 req[ "remedies" ][ - 1 ],
400 )
401 missing.append(
402 {
403 "scope" : scope,
404 "grant" : sorted (remedy - effective),
405 "accepts" : req[ "accepts" ],
406 "why" : req[ "why" ],
407 }
408 )
409 return missing
410
411
412 def report_blocked (principal: dict[ str , str ], missing: list[dict[ str , object ]]) -> None :
413 count = len (missing)
414 plural = "" if count == 1 else "s"
415 print (
416 f "RBAC pre-flight: BLOCKED -- { count } required role assignment { plural } "
417 f " { 'is' if count == 1 else 'are' } missing."
418 )
419 print ()
420 print ( f " principal: { principal[ 'display' ] } ( { principal[ 'id' ] } )" )
421 print ()
422 for gap in missing:
423 grant = ", " .join( f " { ROLES [key][ 1 ] } ( { ROLES [key][ 0 ] } )" for key in gap[ "grant" ])
424 print ( f " MISSING { principal[ 'display' ] } -> { gap[ 'scope' ] } " )
425 print ( f " grant: { grant } " )
426 print ( f " accepts: { gap[ 'accepts' ] } " )
427 print ( f " why: { gap[ 'why' ] } " )
428 print ()
429 print ( "Ask a subscription owner to run (role definition IDs are stable across renames):" )
430 print ()
431 for gap in missing:
432 for key in gap[ "grant" ]:
433 print (
434 f ' az role assignment create --assignee " { principal[ "id" ] } " '
435 f '--role " { ROLES [key][ 0 ] } " --scope " { gap[ "scope" ] } "'
436 )
437 print ()
438 print ( f "reference: { REFERENCE } " )
439 print ( "No resources have been created. Re-run this check once the assignments are in place." )
440
441
442 def main (argv: list[ str ] | None = None ) -> int :
443 args = _parse_args(argv)
444
445 try :
446 scopes = build_scopes(args)
447 if args.from_file:
448 principal, assignments = load_from_file(args.from_file, args.assignee)
449 else :
450 scopes = drop_missing_group(scopes, args.subscription)
451 principal = resolve_principal(args.assignee)
452 assignments = resolve_assignments(principal[ "id" ], scopes)
453 except (PreflightError, OSError ) as exc:
454 print ( f "RBAC pre-flight: ERROR -- { exc } " , file = sys.stderr)
455 return 1
456 except ( TypeError , ValueError , KeyError , AttributeError ) as exc:
457 print ( f "RBAC pre-flight: ERROR -- unexpected input shape: { exc } " , file = sys.stderr)
458 return 1
459
460 missing = find_missing(scopes, assignments)
461 if missing:
462 report_blocked(principal, missing)
463 return 2
464
465 if not args.quiet:
466 required = sum ( 1 for kind, _ in scopes for req in REQUIREMENTS if req[ "kind" ] == kind)
467 checked = ", " .join(kind.replace( "_" , " " ) for kind, _ in scopes)
468 print (
469 f "RBAC pre-flight: PASS -- { required } required role assignments satisfied "
470 f "( { checked } )."
471 )
472 return 0
473
474
475 if __name__ == "__main__" :
476 raise SystemExit (main())