Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -706,13 +706,19 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
["signInAudience"] = "AzureADMultipleOrgs" // Multi-tenant
};

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

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

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

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

// Remove sponsors field and retry
// Remove sponsors and owners fields and retry
appManifest.Remove("sponsors@odata.bind");
appManifest.Remove("owners@odata.bind");

appResponse = await httpClient.PostAsync(
createAppUrl,
Expand Down Expand Up @@ -947,28 +954,42 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
CancellationToken ct)
{
// ========================================================================
// Application Owner Assignment
// Application Owner Validation
// ========================================================================

// Add current user as owner to the application (for both new and existing blueprints)
// This ensures the creator can set callback URLs and bot IDs via the Developer Portal
// Requires Application.ReadWrite.All or Directory.ReadWrite.All permissions
logger.LogInformation("Ensuring current user is owner of application...");
var ownerScopes = new[] { GraphApiConstants.Scopes.ApplicationReadWriteAll };
var ownerAdded = await graphApiService.AddApplicationOwnerAsync(
tenantId,
objectId,
userObjectId: null,
ct,
scopes: ownerScopes);
if (ownerAdded)
// Owner assignment is handled during blueprint creation via owners@odata.bind
// NOTE: The 2-call pattern (POST blueprint, then POST owner) will fail due to Entra bug fix
// For existing blueprints, owners must be manually managed via Azure Portal or Graph API
// We cannot add owners after blueprint creation

if (!alreadyExisted)
{
logger.LogInformation("Current user is an owner of the application");
// For new blueprints, verify that the owner was set during creation
logger.LogInformation("Validating blueprint owner assignment...");
var isOwner = await graphApiService.IsApplicationOwnerAsync(
tenantId,
objectId,
userObjectId: null,
ct);

if (isOwner)
{
logger.LogInformation("Current user is confirmed as blueprint owner");
}
else
{
logger.LogWarning("WARNING: Current user is NOT set as blueprint owner");
logger.LogWarning("This may have occurred if the owners@odata.bind field was rejected during creation");
logger.LogWarning("You may need to manually add yourself as owner via Azure Portal:");
logger.LogWarning(" 1. Go to Azure Portal -> Entra ID -> App registrations");
logger.LogWarning(" 2. Find application: {DisplayName}", displayName);
logger.LogWarning(" 3. Navigate to Owners blade and add yourself");
logger.LogWarning("Without owner permissions, you cannot configure callback URLs or bot IDs in Developer Portal");
}
}
else
{
logger.LogWarning("Could not verify or add current user as application owner");
logger.LogWarning("See detailed error above or refer to: https://learn.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-beta");
logger.LogInformation("Skipping owner validation for existing blueprint (owners@odata.bind not applied to existing blueprints)");
}

// ========================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,8 +333,8 @@ public static async Task<bool> ValidateAzureCliAuthenticationAsync(
if (!accountCheck.Success)
{
logger.LogInformation("Azure CLI not authenticated. Initiating login with management scope...");
logger.LogInformation("A browser window will open for authentication.");
logger.LogInformation("A browser window will open for authentication. Please check your taskbar or browser if you don't see it.");

var loginResult = await executor.ExecuteAsync("az", $"login --tenant {tenantId}", cancellationToken: cancellationToken);

if (!loginResult.Success)
Expand Down
107 changes: 16 additions & 91 deletions src/Microsoft.Agents.A365.DevTools.Cli/Services/GraphApiService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,12 @@ public GraphApiService(ILogger<GraphApiService> logger, CommandExecutor executor
if (!accountCheck.Success)
{
_logger.LogInformation("Azure CLI not authenticated. Initiating login...");
_logger.LogInformation("A browser window will open for authentication. Please check your taskbar or browser if you don't see it.");
var loginResult = await _executor.ExecuteAsync(
"az",
$"login --tenant {tenantId}",
"az",
$"login --tenant {tenantId}",
cancellationToken: ct);

if (!loginResult.Success)
{
_logger.LogError("Azure CLI login failed");
Expand Down Expand Up @@ -485,19 +486,16 @@ public async Task<bool> CreateOrUpdateOauth2PermissionGrantAsync(
}

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

Expand All @@ -536,105 +534,32 @@ public virtual async Task<bool> AddApplicationOwnerAsync(
}

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

if (string.IsNullOrWhiteSpace(userObjectId))
{
_logger.LogWarning("User object ID is empty, cannot add as owner");
_logger.LogWarning("User object ID is empty, cannot check owner");
return false;
}

// Check if user is already an owner (idempotency check)
_logger.LogDebug("Checking if user {UserId} is already an owner of application {AppObjectId}", userObjectId, applicationObjectId);
// Check if user is an owner
_logger.LogDebug("Checking if user {UserId} is an owner of application {AppObjectId}", userObjectId, applicationObjectId);

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

if (isAlreadyOwner)
{
_logger.LogDebug("User is already an owner of the application");
return true;
}
}

// User is not an owner, add them
// https://learn.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-beta
_logger.LogDebug("Adding user {UserId} as owner to application {AppObjectId}", userObjectId, applicationObjectId);

var payload = new JsonObject
{
["@odata.id"] = $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}"
};

// Use beta endpoint as recommended in the documentation
var relativePath = $"/beta/applications/{applicationObjectId}/owners/$ref";

if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes))
{
_logger.LogWarning("Could not authenticate to Graph API to add application owner");
return false;
}

var url = $"{GraphApiConstants.BaseUrl}{relativePath}";
using var content = new StringContent(
payload.ToJsonString(),
Encoding.UTF8,
"application/json");

using var response = await _httpClient.PostAsync(url, content, ct);

if (response.IsSuccessStatusCode)
{
_logger.LogInformation("Successfully added user as owner to application");
return true;
}

var errorBody = await response.Content.ReadAsStringAsync(ct);

// Check if the user is already an owner (409 Conflict or specific error message)
// This handles race conditions where the user was added between our check and the POST
if ((int)response.StatusCode == 409 ||
errorBody.Contains("already exist", StringComparison.OrdinalIgnoreCase) ||
errorBody.Contains("One or more added object references already exist", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("User is already an owner of the application (detected during add)");
return true;
}

// Log specific error guidance based on status code
_logger.LogWarning("Failed to add user as owner to application. Status: {Status}, URL: {Url}",
response.StatusCode, url);

if (response.StatusCode == HttpStatusCode.Forbidden)
{
_logger.LogWarning("Access denied. Ensure the authenticated user has Application.ReadWrite.All or Directory.ReadWrite.All permissions");
_logger.LogWarning("To manually add yourself as an owner, make this Graph API call:");
_logger.LogWarning(" POST {Url}", url);
_logger.LogWarning(" Content-Type: application/json");
_logger.LogWarning(" Body: {{\"@odata.id\": \"{ODataId}\"}}", $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}");
}
else if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogWarning("Application or user not found. Verify ObjectId: {AppObjectId}, UserId: {UserId}",
applicationObjectId, userObjectId);
}
else if (response.StatusCode == HttpStatusCode.BadRequest)
{
_logger.LogWarning("Bad request. Verify the payload format and user object ID");
_logger.LogWarning("Attempted payload: {{\"@odata.id\": \"{ODataId}\"}}", $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}");
return isOwner;
}

_logger.LogDebug("Graph API error response: {Error}", errorBody);
return false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error adding user as owner to application: {Message}", ex.Message);
_logger.LogWarning(ex, "Error checking if user is owner of application: {Message}", ex.Message);
return false;
}
}
Expand Down
Loading
Loading