66using Microsoft . Agents . A365 . DevTools . Cli . Services . Internal ;
77using Microsoft . Extensions . Logging ;
88using Microsoft . Extensions . Logging . Abstractions ;
9+ using System . Net ;
910using System . Net . Http . Headers ;
1011using System . Text ;
1112using System . Text . Json ;
13+ using System . Text . Json . Nodes ;
1214
1315namespace 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