Skip to content

Commit 379047f

Browse files
feat: add Graph API group membership sync for Entra Agent ID bridge
- Add EntraGraphClient in entra_graph.py for Microsoft Graph API calls - get_group_memberships() with pagination and type filtering - get_app_role_assignments() for role-based access - Proper error handling (GraphAPIError with HTTP status codes) - Add sync_group_memberships() to EntraAgentRegistry - Maps Entra groups to AGT capabilities via configurable scope map - Preserves manually assigned capabilities (union merge) - Audit log integration - Add validate_bridge_configuration() to EntraAgentRegistry - Validates bridge mapping completeness for Agent365 compatibility - Add 26 tests covering Graph client, sync logic, and registry integration - Update Tutorial 31 with Steps 6-7 and refreshed Known Gaps table Closes microsoft#1173 Addresses microsoft#1174 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent f826f11 commit 379047f

4 files changed

Lines changed: 722 additions & 6 deletions

File tree

docs/tutorials/31-entra-agent-id-bridge.md

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,9 +242,9 @@ registry.suspend_agent(
242242
)
243243

244244
# When AGT kill switch fires → disable in Entra
245-
# (This requires Graph API — not yet built into AGT)
246-
# POST https://graph.microsoft.com/v1.0/applications/{entra_object_id}
247-
# { "disabledByMicrosoftStatus": "DisabledDueToViolationOfServicesAgreement" }
245+
# Use Graph API: PATCH /servicePrincipals/{entra_object_id}
246+
# with { "accountEnabled": false }
247+
# (Requires Directory.ReadWrite.All or Application.ReadWrite.All permission)
248248

249249
# When sponsor re-approves → reactivate
250250
registry.reactivate_agent(agent_did="did:agentmesh:a7f3b2c1...")
@@ -299,13 +299,58 @@ def verify_tool_call(agent_did: str, tool_name: str, params: dict) -> bool:
299299
return True
300300
```
301301

302+
## Step 6 — Group Membership Sync via Graph API
303+
304+
Automatically map Entra group memberships to AGT capabilities:
305+
306+
```python
307+
from agentmesh.identity.entra_graph import EntraGraphClient, build_group_scope_map
308+
309+
# 1. Get a Graph API token (via managed identity, workload identity, etc.)
310+
token = entra_agent.get_agent_token(scope="https://graph.microsoft.com/.default")
311+
312+
# 2. Create the Graph client
313+
graph_client = EntraGraphClient(access_token=token)
314+
315+
# 3. Define group-to-capability mapping (keyed by Entra group object ID)
316+
group_scope_map = build_group_scope_map({
317+
"aaaaaaaa-1111-2222-3333-444444444444": ["read:customer-data", "read:reports"],
318+
"bbbbbbbb-1111-2222-3333-444444444444": ["write:reports", "export:csv"],
319+
})
320+
321+
# 4. Sync — fetches groups from Graph, maps to capabilities, preserves manual caps
322+
capabilities = registry.sync_group_memberships(
323+
agent_did=identity.did,
324+
graph_client=graph_client,
325+
group_scope_map=group_scope_map,
326+
)
327+
print(f"Updated capabilities: {capabilities}")
328+
# ['export:csv', 'manual:cap', 'read:customer-data', 'read:reports', 'write:reports']
329+
```
330+
331+
> **Note:** The Graph API call requires `GroupMember.Read.All` or `Directory.Read.All` permission on the Entra application. Group sync only updates AGT **capabilities** — Entra API **scopes** remain unchanged.
332+
333+
## Step 7 — Validate Bridge Configuration
334+
335+
Before going to production, validate the bridge mapping is complete:
336+
337+
```python
338+
valid, issues = registry.validate_bridge_configuration(agent_did=identity.did)
339+
if not valid:
340+
print(f"Bridge configuration issues: {issues}")
341+
# e.g., ['Missing entra_app_id — Agent365 may not resolve the agent']
342+
else:
343+
print("Bridge configuration OK — ready for enterprise deployment")
344+
```
345+
302346
## Known Gaps and Limitations
303347

304348
| Gap | Status | Workaround |
305349
|-----|--------|------------|
306-
| **Graph API provisioning** | Not in AGT | Create Entra Agent ID via Azure Portal or Graph API, then register mapping in AGT |
307-
| **Agent365 native integration** | Not yet tested | Agent365 sees Entra Agent ID — AGT bridge maps the DID; should work but needs validation |
350+
| **Graph API group membership sync** | ✅ Built | `EntraGraphClient.get_group_memberships()` + `EntraAgentRegistry.sync_group_memberships()` — maps Entra groups to AGT capabilities |
351+
| **Agent365 native integration** | Configuration validated | `EntraAgentRegistry.validate_bridge_configuration()` checks bridge mapping completeness; end-to-end Agent365 testing pending |
308352
| **Bidirectional lifecycle sync** | One-way (manual) | Use Azure Event Grid or Logic Apps to sync Entra state changes → AGT kill switch |
353+
| **Graph API service principal disable** | Not in AGT | Use Graph API directly: `PATCH /servicePrincipals/{id}` with `accountEnabled: false` |
309354
| **Entra bridge in non-Python SDKs** | Python-only | TS, .NET, Rust, Go SDKs need `EntraAgentRegistry` and `EntraAgentID` ported |
310355
| **DID format inconsistency** | `did:agentmesh:*` (Python, .NET) vs `did:agentmesh:*` (TS, Rust, Go) | Both formats work; standardization planned for v4.0 |
311356
| **Cryptographic token verification** | Claim-level only | Add `azure-identity` for JWKS-based signature verification |

packages/agent-mesh/src/agentmesh/identity/entra.py

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,18 @@
1212

1313
from __future__ import annotations
1414

15+
import logging
1516
from datetime import datetime, timezone
1617
from enum import Enum
17-
from typing import Any, Optional
18+
from typing import TYPE_CHECKING, Any, Optional
1819

1920
from pydantic import BaseModel, Field
2021

22+
if TYPE_CHECKING:
23+
from agentmesh.identity.entra_graph import EntraGraphClient
24+
25+
logger = logging.getLogger(__name__)
26+
2127

2228
class EntraAgentStatus(str, Enum):
2329
"""Entra Agent ID lifecycle states."""
@@ -280,6 +286,95 @@ def get_audit_log(self) -> list[dict[str, Any]]:
280286
"""Return the full audit log."""
281287
return list(self._audit_log)
282288

289+
# -- Graph API integration (Issue #1173) -----------------------------------
290+
291+
def sync_group_memberships(
292+
self,
293+
agent_did: str,
294+
graph_client: "EntraGraphClient",
295+
group_scope_map: dict[str, list[str]],
296+
) -> list[str]:
297+
"""
298+
Sync Entra group memberships to AGT capabilities for an agent.
299+
300+
Fetches the agent's group memberships from Microsoft Graph API,
301+
maps them to AGT capabilities using ``group_scope_map``, and
302+
updates the agent's capabilities (preserving manually assigned ones).
303+
304+
Args:
305+
agent_did: The agent's AgentMesh DID.
306+
graph_client: An authenticated ``EntraGraphClient`` instance.
307+
group_scope_map: Mapping of Entra group object IDs to lists
308+
of AGT capability strings.
309+
310+
Returns:
311+
The updated list of capabilities.
312+
313+
Raises:
314+
KeyError: If the agent is not registered.
315+
GraphAPIError: If the Graph API call fails.
316+
"""
317+
from agentmesh.identity.entra_graph import sync_memberships_to_capabilities
318+
319+
identity = self._agents.get(agent_did)
320+
if not identity:
321+
raise KeyError(f"Agent {agent_did!r} not found in registry")
322+
323+
groups = graph_client.get_group_memberships(identity.entra_object_id)
324+
325+
new_caps = sync_memberships_to_capabilities(
326+
groups=groups,
327+
group_scope_map=group_scope_map,
328+
preserve_existing=identity.capabilities,
329+
)
330+
331+
identity.capabilities = new_caps
332+
self._log_event("sync_group_memberships", identity, {
333+
"groups_found": len(groups),
334+
"capabilities_after": new_caps,
335+
})
336+
return new_caps
337+
338+
# -- Bridge validation (Issue #1174) ---------------------------------------
339+
340+
def validate_bridge_configuration(
341+
self, agent_did: str
342+
) -> tuple[bool, list[str]]:
343+
"""
344+
Validate that an agent's Entra bridge configuration is complete.
345+
346+
Checks that all required fields for enterprise identity bridging
347+
are populated. This validates the **configuration** (not live
348+
connectivity to Entra or Agent365).
349+
350+
Returns:
351+
Tuple of (valid, issues) where issues is a list of problems found.
352+
"""
353+
identity = self._agents.get(agent_did)
354+
if not identity:
355+
return False, [f"Agent {agent_did!r} not found in registry"]
356+
357+
issues: list[str] = []
358+
359+
if not identity.entra_object_id:
360+
issues.append("Missing entra_object_id")
361+
if not identity.tenant_id:
362+
issues.append("Missing tenant_id")
363+
if not identity.sponsor_email:
364+
issues.append("Missing sponsor_email (required for Agent365)")
365+
if not identity.agent_did:
366+
issues.append("Missing agent_did")
367+
if identity.status != EntraAgentStatus.ACTIVE:
368+
issues.append(
369+
f"Agent status is {identity.status.value}, expected active"
370+
)
371+
if not identity.entra_app_id:
372+
issues.append(
373+
"Missing entra_app_id — Agent365 may not resolve the agent"
374+
)
375+
376+
return (len(issues) == 0, issues)
377+
283378
def _log_event(
284379
self,
285380
event_type: str,

0 commit comments

Comments
 (0)