Skip to content

Commit 1aebec6

Browse files
authored
Ensure blueprint creator is app owner; add tests (#163)
* Ensure blueprint creator is app owner; add tests Adds logic to make the current user an owner of the Agent Blueprint application during configuration, using a new AddApplicationOwnerAsync method in GraphApiService. This method checks for existing ownership, adds the user if needed via the Graph beta API, and handles errors with clear logging and remediation steps. Includes a comprehensive test suite covering all major scenarios. This improves usability by ensuring the creator can manage the app in Azure and the Developer Portal. No breaking changes. * Ensure proper disposal in tests; update Graph API payload - Update testing standards to require disposing IDisposable objects (e.g., HttpResponseMessage) in tests. - Refactor GraphApiService to use LINQ for owner checks and use JsonObject with "@odata.id" for payloads. - Use using statements for StringContent and test handlers to ensure disposal. - Update tests to check for "@odata.id" and dispose handlers properly. - Add Dispose overrides to test handlers to clean up HttpResponseMessage instances. * Improve app owner assignment via Graph API, add constants Refactored owner assignment to use new GraphApiConstants for URLs, versions, and scopes. Now explicitly requests Application.ReadWrite.All permissions and uses the beta endpoint with a fully qualified @odata.id payload. Enhanced error logging with actionable guidance. Updated tests for new payload format. Improves standards compliance and user experience.
1 parent 270b084 commit 1aebec6

7 files changed

Lines changed: 647 additions & 348 deletions

File tree

.github/copilot-instructions.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,11 @@
3636
- Focus on quality over quantity of tests
3737
- Add regression tests for bug fixes
3838
- Tests should verify CLI reliability
39+
- **Dispose IDisposable objects properly**:
40+
- `HttpResponseMessage` objects created in tests must be disposed
41+
- Even in mock/test handlers, follow proper disposal patterns
42+
- Consider using `using` statements or ensure test handlers dispose responses
43+
- This applies to all `IDisposable` test objects to avoid analyzer warnings
3944

4045
### Output and Logging
4146
- No emojis or special characters in logs, output, or comments

docs/guides/custom-client-app-registration.md

Lines changed: 0 additions & 336 deletions
This file was deleted.

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

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -892,8 +892,8 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
892892
setupConfig.AgentBlueprintObjectId = objectId;
893893
setupConfig.AgentBlueprintServicePrincipalObjectId = servicePrincipalId;
894894
setupConfig.AgentBlueprintId = appId;
895-
896-
logger.LogDebug("Blueprint identifiers staged for persistence: ObjectId={ObjectId}, SPObjectId={SPObjectId}, AppId={AppId}",
895+
896+
logger.LogDebug("Blueprint identifiers staged for persistence: ObjectId={ObjectId}, SPObjectId={SPObjectId}, AppId={AppId}",
897897
objectId, servicePrincipalId, appId);
898898

899899
// Complete configuration (FIC validation + admin consent)
@@ -946,6 +946,31 @@ public static async Task<bool> EnsureDelegatedConsentWithRetriesAsync(
946946
bool alreadyExisted,
947947
CancellationToken ct)
948948
{
949+
// ========================================================================
950+
// Application Owner Assignment
951+
// ========================================================================
952+
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)
965+
{
966+
logger.LogInformation("Current user is an owner of the application");
967+
}
968+
else
969+
{
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");
972+
}
973+
949974
// ========================================================================
950975
// Federated Identity Credential Validation/Creation
951976
// ========================================================================
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
namespace Microsoft.Agents.A365.DevTools.Cli.Constants;
5+
6+
/// <summary>
7+
/// Constants for Microsoft Graph API endpoints and resources
8+
/// </summary>
9+
public static class GraphApiConstants
10+
{
11+
/// <summary>
12+
/// Base URL for Microsoft Graph API
13+
/// </summary>
14+
public const string BaseUrl = "https://graph.microsoft.com";
15+
16+
/// <summary>
17+
/// Resource identifier for Microsoft Graph API (used in Azure CLI token acquisition)
18+
/// </summary>
19+
public const string Resource = "https://graph.microsoft.com/";
20+
21+
/// <summary>
22+
/// Endpoint versions
23+
/// </summary>
24+
public static class Versions
25+
{
26+
/// <summary>
27+
/// Stable v1.0 endpoint for production workloads
28+
/// </summary>
29+
public const string V1 = "v1.0";
30+
31+
/// <summary>
32+
/// Beta endpoint for preview features
33+
/// </summary>
34+
public const string Beta = "beta";
35+
}
36+
37+
/// <summary>
38+
/// Common Microsoft Graph permission scopes
39+
/// </summary>
40+
public static class Scopes
41+
{
42+
/// <summary>
43+
/// Application.ReadWrite.All permission scope - required for managing application registrations
44+
/// </summary>
45+
public const string ApplicationReadWriteAll = "https://graph.microsoft.com/Application.ReadWrite.All";
46+
47+
/// <summary>
48+
/// Directory.ReadWrite.All permission scope - required for directory-level operations
49+
/// </summary>
50+
public const string DirectoryReadWriteAll = "https://graph.microsoft.com/Directory.ReadWrite.All";
51+
}
52+
}

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

Lines changed: 167 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
using Microsoft.Agents.A365.DevTools.Cli.Services.Internal;
77
using Microsoft.Extensions.Logging;
88
using Microsoft.Extensions.Logging.Abstractions;
9+
using System.Net;
910
using System.Net.Http.Headers;
1011
using System.Text;
1112
using System.Text.Json;
13+
using System.Text.Json.Nodes;
1214

1315
namespace Microsoft.Agents.A365.DevTools.Cli.Services;
1416

@@ -413,21 +415,21 @@ public async Task<bool> CreateOrUpdateOauth2PermissionGrantAsync(
413415
/// <param name="ct">Cancellation token</param>
414416
/// <returns>True if user has required roles, false otherwise</returns>
415417
public virtual async Task<(bool hasPrivileges, List<string> roles)> CheckServicePrincipalCreationPrivilegesAsync(
416-
string tenantId,
418+
string tenantId,
417419
CancellationToken ct = default)
418420
{
419421
try
420422
{
421423
_logger.LogDebug("Checking user's directory roles for service principal creation privileges");
422-
424+
423425
var token = await GetGraphAccessTokenAsync(tenantId, ct);
424426
if (token == null)
425427
{
426428
_logger.LogWarning("Could not acquire Graph token to check privileges");
427429
return (false, new List<string>());
428430
}
429431

430-
using var request = new HttpRequestMessage(HttpMethod.Get,
432+
using var request = new HttpRequestMessage(HttpMethod.Get,
431433
"https://graph.microsoft.com/v1.0/me/memberOf/microsoft.graph.directoryRole");
432434
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
433435

@@ -454,22 +456,22 @@ public async Task<bool> CreateOrUpdateOauth2PermissionGrantAsync(
454456
_logger.LogDebug("User has {Count} directory roles", roles.Count);
455457

456458
// Check for required roles
457-
var requiredRoles = new[]
458-
{
459-
"Application Administrator",
460-
"Cloud Application Administrator",
461-
"Global Administrator"
459+
var requiredRoles = new[]
460+
{
461+
"Application Administrator",
462+
"Cloud Application Administrator",
463+
"Global Administrator"
462464
};
463465

464466
var hasRequiredRole = roles.Any(r => requiredRoles.Contains(r, StringComparer.OrdinalIgnoreCase));
465-
467+
466468
if (hasRequiredRole)
467469
{
468470
_logger.LogDebug("User has sufficient privileges for service principal creation");
469471
}
470472
else
471473
{
472-
_logger.LogDebug("User does not have required roles for service principal creation. Roles: {Roles}",
474+
_logger.LogDebug("User does not have required roles for service principal creation. Roles: {Roles}",
473475
string.Join(", ", roles));
474476
}
475477

@@ -481,4 +483,159 @@ public async Task<bool> CreateOrUpdateOauth2PermissionGrantAsync(
481483
return (false, new List<string>());
482484
}
483485
}
486+
487+
/// <summary>
488+
/// Ensures the current user is an owner of an application (idempotent operation).
489+
/// First checks if the user is already an owner, and only adds if not present.
490+
/// This ensures the creator has ownership permissions for setting callback URLs and bot IDs via the Developer Portal.
491+
/// Requires Application.ReadWrite.All or Directory.ReadWrite.All permissions.
492+
/// See: https://learn.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-beta
493+
/// </summary>
494+
/// <param name="tenantId">The tenant ID</param>
495+
/// <param name="applicationObjectId">The application object ID (not the client/app ID)</param>
496+
/// <param name="userObjectId">The user's object ID to add as owner. If null, uses the current authenticated user.</param>
497+
/// <param name="ct">Cancellation token</param>
498+
/// <param name="scopes">OAuth2 scopes for elevated permissions (e.g., Application.ReadWrite.All, Directory.ReadWrite.All)</param>
499+
/// <returns>True if the user is an owner (either already was or was successfully added), false otherwise</returns>
500+
public virtual async Task<bool> AddApplicationOwnerAsync(
501+
string tenantId,
502+
string applicationObjectId,
503+
string? userObjectId = null,
504+
CancellationToken ct = default,
505+
IEnumerable<string>? scopes = null)
506+
{
507+
try
508+
{
509+
// Get current user's object ID if not provided
510+
if (string.IsNullOrWhiteSpace(userObjectId))
511+
{
512+
if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes))
513+
{
514+
_logger.LogWarning("Could not acquire Graph token to add application owner");
515+
return false;
516+
}
517+
518+
using var meRequest = new HttpRequestMessage(HttpMethod.Get,
519+
"https://graph.microsoft.com/v1.0/me?$select=id");
520+
meRequest.Headers.Authorization = _httpClient.DefaultRequestHeaders.Authorization;
521+
522+
using var meResponse = await _httpClient.SendAsync(meRequest, ct);
523+
if (!meResponse.IsSuccessStatusCode)
524+
{
525+
_logger.LogWarning("Could not retrieve current user's ID: {Status}", meResponse.StatusCode);
526+
return false;
527+
}
528+
529+
var meJson = await meResponse.Content.ReadAsStringAsync(ct);
530+
using var meDoc = JsonDocument.Parse(meJson);
531+
532+
if (!meDoc.RootElement.TryGetProperty("id", out var idElement))
533+
{
534+
_logger.LogWarning("Could not extract user ID from Graph response");
535+
return false;
536+
}
537+
538+
userObjectId = idElement.GetString();
539+
_logger.LogDebug("Retrieved current user's object ID: {UserId}", userObjectId);
540+
}
541+
542+
if (string.IsNullOrWhiteSpace(userObjectId))
543+
{
544+
_logger.LogWarning("User object ID is empty, cannot add as owner");
545+
return false;
546+
}
547+
548+
// Check if user is already an owner (idempotency check)
549+
_logger.LogDebug("Checking if user {UserId} is already an owner of application {AppObjectId}", userObjectId, applicationObjectId);
550+
551+
var ownersDoc = await GraphGetAsync(tenantId, $"/v1.0/applications/{applicationObjectId}/owners?$select=id", ct, scopes);
552+
if (ownersDoc != null && ownersDoc.RootElement.TryGetProperty("value", out var ownersArray))
553+
{
554+
var isAlreadyOwner = ownersArray.EnumerateArray()
555+
.Where(owner => owner.TryGetProperty("id", out var ownerId))
556+
.Any(owner => string.Equals(owner.GetProperty("id").GetString(), userObjectId, StringComparison.OrdinalIgnoreCase));
557+
558+
if (isAlreadyOwner)
559+
{
560+
_logger.LogDebug("User is already an owner of the application");
561+
return true;
562+
}
563+
}
564+
565+
// User is not an owner, add them
566+
// https://learn.microsoft.com/en-us/graph/api/application-post-owners?view=graph-rest-beta
567+
_logger.LogDebug("Adding user {UserId} as owner to application {AppObjectId}", userObjectId, applicationObjectId);
568+
569+
var payload = new JsonObject
570+
{
571+
["@odata.id"] = $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}"
572+
};
573+
574+
// Use beta endpoint as recommended in the documentation
575+
var relativePath = $"/beta/applications/{applicationObjectId}/owners/$ref";
576+
577+
if (!await EnsureGraphHeadersAsync(tenantId, ct, scopes))
578+
{
579+
_logger.LogWarning("Could not authenticate to Graph API to add application owner");
580+
return false;
581+
}
582+
583+
var url = $"{GraphApiConstants.BaseUrl}{relativePath}";
584+
using var content = new StringContent(
585+
payload.ToJsonString(),
586+
Encoding.UTF8,
587+
"application/json");
588+
589+
using var response = await _httpClient.PostAsync(url, content, ct);
590+
591+
if (response.IsSuccessStatusCode)
592+
{
593+
_logger.LogInformation("Successfully added user as owner to application");
594+
return true;
595+
}
596+
597+
var errorBody = await response.Content.ReadAsStringAsync(ct);
598+
599+
// Check if the user is already an owner (409 Conflict or specific error message)
600+
// This handles race conditions where the user was added between our check and the POST
601+
if ((int)response.StatusCode == 409 ||
602+
errorBody.Contains("already exist", StringComparison.OrdinalIgnoreCase) ||
603+
errorBody.Contains("One or more added object references already exist", StringComparison.OrdinalIgnoreCase))
604+
{
605+
_logger.LogDebug("User is already an owner of the application (detected during add)");
606+
return true;
607+
}
608+
609+
// Log specific error guidance based on status code
610+
_logger.LogWarning("Failed to add user as owner to application. Status: {Status}, URL: {Url}",
611+
response.StatusCode, url);
612+
613+
if (response.StatusCode == HttpStatusCode.Forbidden)
614+
{
615+
_logger.LogWarning("Access denied. Ensure the authenticated user has Application.ReadWrite.All or Directory.ReadWrite.All permissions");
616+
_logger.LogWarning("To manually add yourself as an owner, make this Graph API call:");
617+
_logger.LogWarning(" POST {Url}", url);
618+
_logger.LogWarning(" Content-Type: application/json");
619+
_logger.LogWarning(" Body: {{\"@odata.id\": \"{ODataId}\"}}", $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}");
620+
}
621+
else if (response.StatusCode == HttpStatusCode.NotFound)
622+
{
623+
_logger.LogWarning("Application or user not found. Verify ObjectId: {AppObjectId}, UserId: {UserId}",
624+
applicationObjectId, userObjectId);
625+
}
626+
else if (response.StatusCode == HttpStatusCode.BadRequest)
627+
{
628+
_logger.LogWarning("Bad request. Verify the payload format and user object ID");
629+
_logger.LogWarning("Attempted payload: {{\"@odata.id\": \"{ODataId}\"}}", $"{GraphApiConstants.BaseUrl}/{GraphApiConstants.Versions.Beta}/directoryObjects/{userObjectId}");
630+
}
631+
632+
_logger.LogDebug("Graph API error response: {Error}", errorBody);
633+
return false;
634+
}
635+
catch (Exception ex)
636+
{
637+
_logger.LogWarning(ex, "Error adding user as owner to application: {Message}", ex.Message);
638+
return false;
639+
}
640+
}
484641
}

0 commit comments

Comments
 (0)