Skip to content

Commit ae5347c

Browse files
authored
Refactor owner assignment for Agent Blueprints (Entra fix) (#164)
Set owners@odata.bind during blueprint creation to address Entra bug preventing post-creation owner assignment. Remove post-creation owner add logic and replace with read-only owner validation (IsApplicationOwnerAsync). Update logging, user guidance, and tests to reflect new behavior. Improve Azure CLI login messaging.
1 parent 69e4b9c commit ae5347c

File tree

4 files changed

+129
-253
lines changed

4 files changed

+129
-253
lines changed

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/BlueprintSubcommand.cs

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -706,13 +706,19 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
706706
["signInAudience"] = "AzureADMultipleOrgs" // Multi-tenant
707707
};
708708

709-
// Add sponsors field if we have the current user (PowerShell script includes this)
709+
// Add sponsors and owners fields if we have the current user
710+
// IMPORTANT: Setting owners during creation is required to avoid 2-call pattern that will fail due to Entra bug fix
711+
// See: https://learn.microsoft.com/en-us/entra/agent-id/identity-platform/create-blueprint?tabs=microsoft-graph-api#create-an-agent-identity-blueprint-1
710712
if (!string.IsNullOrEmpty(sponsorUserId))
711713
{
712714
appManifest["sponsors@odata.bind"] = new JsonArray
713715
{
714716
$"https://graph.microsoft.com/v1.0/users/{sponsorUserId}"
715717
};
718+
appManifest["owners@odata.bind"] = new JsonArray
719+
{
720+
$"https://graph.microsoft.com/v1.0/users/{sponsorUserId}"
721+
};
716722
}
717723

718724
var graphToken = await GetTokenFromGraphClient(logger, graphClient, tenantId, setupConfig.ClientAppId);
@@ -733,7 +739,7 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
733739
logger.LogInformation(" - Display Name: {DisplayName}", displayName);
734740
if (!string.IsNullOrEmpty(sponsorUserId))
735741
{
736-
logger.LogInformation(" - Sponsor: User ID {UserId}", sponsorUserId);
742+
logger.LogInformation(" - Sponsor and Owner: User ID {UserId}", sponsorUserId);
737743
}
738744

739745
var appResponse = await httpClient.PostAsync(
@@ -745,14 +751,15 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
745751
{
746752
var errorContent = await appResponse.Content.ReadAsStringAsync(ct);
747753

748-
// If sponsors field causes error (Bad Request 400), retry without it
754+
// If sponsors/owners fields cause error (Bad Request 400), retry without them
749755
if (appResponse.StatusCode == System.Net.HttpStatusCode.BadRequest &&
750756
!string.IsNullOrEmpty(sponsorUserId))
751757
{
752-
logger.LogWarning("Agent Blueprint creation with sponsors failed (Bad Request). Retrying without sponsors...");
758+
logger.LogWarning("Agent Blueprint creation with sponsors and owners failed (Bad Request). Retrying without sponsors/owners...");
753759

754-
// Remove sponsors field and retry
760+
// Remove sponsors and owners fields and retry
755761
appManifest.Remove("sponsors@odata.bind");
762+
appManifest.Remove("owners@odata.bind");
756763

757764
appResponse = await httpClient.PostAsync(
758765
createAppUrl,
@@ -947,28 +954,42 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
947954
CancellationToken ct)
948955
{
949956
// ========================================================================
950-
// Application Owner Assignment
957+
// Application Owner Validation
951958
// ========================================================================
952959

953-
// Add current user as owner to the application (for both new and existing blueprints)
954-
// This ensures the creator can set callback URLs and bot IDs via the Developer Portal
955-
// Requires Application.ReadWrite.All or Directory.ReadWrite.All permissions
956-
logger.LogInformation("Ensuring current user is owner of application...");
957-
var ownerScopes = new[] { GraphApiConstants.Scopes.ApplicationReadWriteAll };
958-
var ownerAdded = await graphApiService.AddApplicationOwnerAsync(
959-
tenantId,
960-
objectId,
961-
userObjectId: null,
962-
ct,
963-
scopes: ownerScopes);
964-
if (ownerAdded)
960+
// Owner assignment is handled during blueprint creation via owners@odata.bind
961+
// NOTE: The 2-call pattern (POST blueprint, then POST owner) will fail due to Entra bug fix
962+
// For existing blueprints, owners must be manually managed via Azure Portal or Graph API
963+
// We cannot add owners after blueprint creation
964+
965+
if (!alreadyExisted)
965966
{
966-
logger.LogInformation("Current user is an owner of the application");
967+
// For new blueprints, verify that the owner was set during creation
968+
logger.LogInformation("Validating blueprint owner assignment...");
969+
var isOwner = await graphApiService.IsApplicationOwnerAsync(
970+
tenantId,
971+
objectId,
972+
userObjectId: null,
973+
ct);
974+
975+
if (isOwner)
976+
{
977+
logger.LogInformation("Current user is confirmed as blueprint owner");
978+
}
979+
else
980+
{
981+
logger.LogWarning("WARNING: Current user is NOT set as blueprint owner");
982+
logger.LogWarning("This may have occurred if the owners@odata.bind field was rejected during creation");
983+
logger.LogWarning("You may need to manually add yourself as owner via Azure Portal:");
984+
logger.LogWarning(" 1. Go to Azure Portal -> Entra ID -> App registrations");
985+
logger.LogWarning(" 2. Find application: {DisplayName}", displayName);
986+
logger.LogWarning(" 3. Navigate to Owners blade and add yourself");
987+
logger.LogWarning("Without owner permissions, you cannot configure callback URLs or bot IDs in Developer Portal");
988+
}
967989
}
968990
else
969991
{
970-
logger.LogWarning("Could not verify or add current user as application owner");
971-
logger.LogWarning("See detailed error above or refer to: https://learn.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-beta");
992+
logger.LogInformation("Skipping owner validation for existing blueprint (owners@odata.bind not applied to existing blueprints)");
972993
}
973994

974995
// ========================================================================

src/Microsoft.Agents.A365.DevTools.Cli/Commands/SetupSubcommands/InfrastructureSubcommand.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,8 @@ public static async Task<bool> ValidateAzureCliAuthenticationAsync(
333333
if (!accountCheck.Success)
334334
{
335335
logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope...");
336-
logger.LogInformation("A browser window will open for authentication.");
337-
336+
logger.LogInformation("A browser window will open for authentication. Please check your taskbar or browser if you don't see it.");
337+
338338
var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);
339339

340340
if (!loginResult.Success)

src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs

Lines changed: 16 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,12 @@ public GraphApiService(ILogger<GraphApiService> logger, CommandExecutor executor
8383
if (!accountCheck.Success)
8484
{
8585
_logger.LogInformation("Azure CLI not authenticated. Initiating login...");
86+
_logger.LogInformation("A browser window will open for authentication. Please check your taskbar or browser if you don't see it.");
8687
var loginResult = await _executor.ExecuteAsync(
87-
"az",
88-
$"login --tenant {tenantId}",
88+
"az",
89+
$"login --tenant {tenantId}",
8990
cancellationToken: ct);
90-
91+
9192
if (!loginResult.Success)
9293
{
9394
_logger.LogError("Azure CLI login failed");
@@ -491,19 +492,16 @@ public async Task<bool> CreateOrUpdateOauth2PermissionGrantAsync(
491492
}
492493

493494
/// <summary>
494-
/// Ensures the current user is an owner of an application (idempotent operation).
495-
/// First checks if the user is already an owner, and only adds if not present.
496-
/// This ensures the creator has ownership permissions for setting callback URLs and bot IDs via the Developer Portal.
497-
/// Requires Application.ReadWrite.All or Directory.ReadWrite.All permissions.
498-
/// See: https://learn.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-beta
495+
/// Checks if a user is an owner of an application (read-only validation).
496+
/// Does not attempt to add the user as owner, only verifies ownership.
499497
/// </summary>
500498
/// <param name="tenantId">The tenant ID</param>
501499
/// <param name="applicationObjectId">The application object ID (not the client/app ID)</param>
502-
/// <param name="userObjectId">The user's object ID to add as owner. If null, uses the current authenticated user.</param>
500+
/// <param name="userObjectId">The user's object ID to check. If null, uses the current authenticated user.</param>
503501
/// <param name="ct">Cancellation token</param>
504502
/// <param name="scopes">OAuth2 scopes for elevated permissions (e.g., Application.ReadWrite.All, Directory.ReadWrite.All)</param>
505-
/// <returns>True if the user is an owner (either already was or was successfully added), false otherwise</returns>
506-
public virtual async Task<bool> AddApplicationOwnerAsync(
503+
/// <returns>True if the user is an owner, false otherwise</returns>
504+
public virtual async Task<bool> IsApplicationOwnerAsync(
507505
string tenantId,
508506
string applicationObjectId,
509507
string? userObjectId = null,
@@ -517,7 +515,7 @@ public virtual async Task<bool> AddApplicationOwnerAsync(
517515
{
518516
if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes))
519517
{
520-
_logger.LogWarning("Could not acquire Graph token to add application owner");
518+
_logger.LogWarning("Could not acquire Graph token to check application owner");
521519
return false;
522520
}
523521

@@ -542,105 +540,32 @@ public virtual async Task<bool> AddApplicationOwnerAsync(
542540
}
543541

544542
userObjectId = idElement.GetString();
545-
_logger.LogDebug("Retrieved current user's object ID: {UserId}", userObjectId);
546543
}
547544

548545
if (string.IsNullOrWhiteSpace(userObjectId))
549546
{
550-
_logger.LogWarning("User object ID is empty, cannot add as owner");
547+
_logger.LogWarning("User object ID is empty, cannot check owner");
551548
return false;
552549
}
553550

554-
// Check if user is already an owner (idempotency check)
555-
_logger.LogDebug("Checking if user {UserId} is already an owner of application {AppObjectId}", userObjectId, applicationObjectId);
551+
// Check if user is an owner
552+
_logger.LogDebug("Checking if user {UserId} is an owner of application {AppObjectId}", userObjectId, applicationObjectId);
556553

557554
var ownersDoc = await GraphGetAsync(tenantId, $"/v1.0/applications/{applicationObjectId}/owners?$select=id", ct, scopes);
558555
if (ownersDoc != null && ownersDoc.RootElement.TryGetProperty("value", out var ownersArray))
559556
{
560-
var isAlreadyOwner = ownersArray.EnumerateArray()
557+
var isOwner = ownersArray.EnumerateArray()
561558
.Where(owner => owner.TryGetProperty("id", out var ownerId))
562559
.Any(owner => string.Equals(owner.GetProperty("id").GetString(), userObjectId, StringComparison.OrdinalIgnoreCase));
563560

564-
if (isAlreadyOwner)
565-
{
566-
_logger.LogDebug("User is already an owner of the application");
567-
return true;
568-
}
569-
}
570-
571-
// User is not an owner, add them
572-
// https://learn.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-beta
573-
_logger.LogDebug("Adding user {UserId} as owner to application {AppObjectId}", userObjectId, applicationObjectId);
574-
575-
var payload = new JsonObject
576-
{
577-
["@odata.id"] = $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}"
578-
};
579-
580-
// Use beta endpoint as recommended in the documentation
581-
var relativePath = $"/beta/applications/{applicationObjectId}/owners/$ref";
582-
583-
if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes))
584-
{
585-
_logger.LogWarning("Could not authenticate to Graph API to add application owner");
586-
return false;
587-
}
588-
589-
var url = $"{GraphApiConstants.BaseUrl}{relativePath}";
590-
using var content = new StringContent(
591-
payload.ToJsonString(),
592-
Encoding.UTF8,
593-
"application/json");
594-
595-
using var response = await _httpClient.PostAsync(url, content, ct);
596-
597-
if (response.IsSuccessStatusCode)
598-
{
599-
_logger.LogInformation("Successfully added user as owner to application");
600-
return true;
601-
}
602-
603-
var errorBody = await response.Content.ReadAsStringAsync(ct);
604-
605-
// Check if the user is already an owner (409 Conflict or specific error message)
606-
// This handles race conditions where the user was added between our check and the POST
607-
if ((int)response.StatusCode == 409 ||
608-
errorBody.Contains("already exist", StringComparison.OrdinalIgnoreCase) ||
609-
errorBody.Contains("One or more added object references already exist", StringComparison.OrdinalIgnoreCase))
610-
{
611-
_logger.LogDebug("User is already an owner of the application (detected during add)");
612-
return true;
613-
}
614-
615-
// Log specific error guidance based on status code
616-
_logger.LogWarning("Failed to add user as owner to application. Status: {Status}, URL: {Url}",
617-
response.StatusCode, url);
618-
619-
if (response.StatusCode == HttpStatusCode.Forbidden)
620-
{
621-
_logger.LogWarning("Access denied. Ensure the authenticated user has Application.ReadWrite.All or Directory.ReadWrite.All permissions");
622-
_logger.LogWarning("To manually add yourself as an owner, make this Graph API call:");
623-
_logger.LogWarning(" POST {Url}", url);
624-
_logger.LogWarning(" Content-Type: application/json");
625-
_logger.LogWarning(" Body: {{\"@odata.id\": \"{ODataId}\"}}", $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}");
626-
}
627-
else if (response.StatusCode == HttpStatusCode.NotFound)
628-
{
629-
_logger.LogWarning("Application or user not found. Verify ObjectId: {AppObjectId}, UserId: {UserId}",
630-
applicationObjectId, userObjectId);
631-
}
632-
else if (response.StatusCode == HttpStatusCode.BadRequest)
633-
{
634-
_logger.LogWarning("Bad request. Verify the payload format and user object ID");
635-
_logger.LogWarning("Attempted payload: {{\"@odata.id\": \"{ODataId}\"}}", $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}");
561+
return isOwner;
636562
}
637563

638-
_logger.LogDebug("Graph API error response: {Error}", errorBody);
639564
return false;
640565
}
641566
catch (Exception ex)
642567
{
643-
_logger.LogWarning(ex, "Error adding user as owner to application: {Message}", ex.Message);
568+
_logger.LogWarning(ex, "Error checking if user is owner of application: {Message}", ex.Message);
644569
return false;
645570
}
646571
}

0 commit comments

Comments
 (0)