Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added
- Re-enabled `a365 create-instance` command (previously deprecated) — creates agent identity, agent user, and assigns licenses in a single command. The custom client app now requires the `User.ReadWrite.All` delegated permission for user creation and license assignment; existing users may need to update admin consent on their client app.
- `Agent365.Observability.OtelWrite` scope now granted to all provisioned agent identities on the Observability API alongside `user_impersonation`, enabling agents to write OpenTelemetry data to the Agent 365 observability service
- `Agent365.Observability.OtelWrite` granted to all provisioned agent identities on the Observability API as both a **delegated** permission (OAuth2 grant) and an **application** permission (S2S app role assignment), enabling agents to write OpenTelemetry data to the Agent 365 observability service
- S2S app role assignment support in `a365 setup permissions` and `a365 setup admin` — the CLI now automatically grants application-type (`appRoleAssignments`) permissions on the blueprint service principal when a `ResourcePermissionSpec` defines `AppRoleScopes`. Global Administrator is required for S2S grants; non-admin users receive actionable PowerShell fallback instructions
- `ChannelMessage.Read.All` and `ChannelMessage.Send` added to default blueprint Microsoft Graph delegated scopes (`agentIdentityScopes`)
- `Files.ReadWrite.All`, `ChannelMessage.Read.All`, and `ChannelMessage.Send` added to default blueprint Microsoft Graph application scopes (`agentApplicationScopes`)
- Server-driven notice system: security advisories and critical upgrade prompts are displayed at startup when a maintainer updates `notices.json`. Notices are suppressed once the user upgrades past the specified `minimumVersion`. Results are cached locally for 4 hours to avoid network calls on every invocation.
Expand All @@ -22,6 +23,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- `A365CreateInstanceRunner` sponsor handling: sponsor is now required (Graph API rejects requests without one) — removed fallback that silently stripped the sponsor on retry, which caused `BadRequest` errors
- Intermittent `ConnectionResetError (10054)` failures on corporate networks with TLS inspection proxies (Zscaler, Netskope) — Graph and ARM API calls now use direct MSAL.NET token acquisition instead of `az account get-access-token` subprocesses, bypassing the Python HTTP stack that triggered proxy resets (#321)
- `a365 cleanup` blueprint deletion now succeeds for Global Administrators even when the blueprint was created by a different user
- Admin consent URL for the Observability API used the non-existent scope `Maven.ReadWrite.All` (AADSTS650053) — replaced with the correct delegated scope `Agent365.Observability.OtelWrite`
- `AppRoleAssignment.ReadWrite.All` (admin-only) was incorrectly included in `RequiredPermissionGrantScopes`, causing it to be requested on non-admin paths (`a365 deploy`, `setup permissions`) — moved to a dedicated `RequiredS2SGrantScopes` constant used only on Global Administrator paths
- `a365 setup all` no longer times out for non-admin users — the CLI immediately surfaces a consent URL to share with an administrator instead of waiting for a browser prompt
- `a365 setup all` requests admin consent once for all resources instead of prompting once per resource
- Browser and WAM authentication blocked by Conditional Access Policy (AADSTS53003, AADSTS53000) now automatically falls back to device code flow (#294)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public static Command CreateCommand(
logger, configService, executor, botConfigurator, authValidator, platformDetector, graphApiService, blueprintService, clientAppValidator, blueprintLookupService, federatedCredentialService, armApiService));

command.AddCommand(AdminSubcommand.CreateCommand(
logger, configService, authValidator, graphApiService, confirmationProvider));
logger, configService, authValidator, graphApiService, confirmationProvider, blueprintService));

return command;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ public static Command CreateCommand(
IConfigService configService,
AzureAuthValidator authValidator,
GraphApiService graphApiService,
IConfirmationProvider confirmationProvider)
IConfirmationProvider confirmationProvider,
AgentBlueprintService? blueprintService = null)
{
var command = new Command(
"admin",
Expand Down Expand Up @@ -215,7 +216,8 @@ await BatchPermissionsOrchestrator.GrantAdminPermissionsAsync(
graphApiService, setupConfig,
setupConfig.AgentBlueprintId!, setupConfig.TenantId,
specs, logger, setupResults, ct,
knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId);
knownBlueprintSpObjectId: setupConfig.AgentBlueprintServicePrincipalObjectId,
blueprintService: blueprintService);

setupResults.AdminConsentGranted = grantsConfigured;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ internal static class BatchPermissionsOrchestrator
var grantsOk = await ConfigureOauth2GrantsAsync(
graph, blueprintAppId, tenantId, specs, phase1Result, permScopes, logger, ct);

// S2S runs after delegated grants, regardless of their success, using elevated scopes.
var s2sScopes = permScopes.Concat(AuthenticationConstants.RequiredS2SGrantScopes).ToArray();
if (!string.IsNullOrWhiteSpace(phase1Result?.BlueprintSpObjectId))
await PerformS2SGrantsAsync(blueprintService, tenantId, phase1Result.BlueprintSpObjectId, specs, s2sScopes, logger, setupResults, ct);
else if (setupResults is not null)
setupResults.S2SAppRoleGranted = true;
Comment thread
sellakumaran marked this conversation as resolved.
Outdated

logger.LogInformation("");
if (grantsOk)
{
Expand Down Expand Up @@ -389,6 +396,7 @@ private static async Task<BlueprintPermissionsResult> UpdateBlueprintPermissions
/// Phase 2b: Creates AllPrincipals (tenant-wide) OAuth2 permission grants for all specs.
/// Requires Global Administrator. Only called when the current user is confirmed GA.
/// Returns true if all grants succeeded, false if any grant failed.
/// S2S app role assignments are handled separately by <see cref="PerformS2SGrantsAsync"/>.
/// </summary>
private static async Task<bool> ConfigureOauth2GrantsAsync(
GraphApiService graph,
Expand Down Expand Up @@ -443,6 +451,60 @@ private static async Task<bool> ConfigureOauth2GrantsAsync(
return allGrantsOk;
}

/// <summary>
/// Grants S2S app role assignments for all specs that carry <see cref="ResourcePermissionSpec.AppRoleScopes"/>.
/// Idempotent: skips roles already assigned. Sets <see cref="SetupResults.S2SAppRoleGranted"/> on completion.
/// Requires Global Administrator and <see cref="AuthenticationConstants.RequiredS2SGrantScopes"/>.
/// </summary>
private static async Task PerformS2SGrantsAsync(
AgentBlueprintService blueprintService,
string tenantId,
string blueprintSpObjectId,
IEnumerable<ResourcePermissionSpec> specs,
string[] s2sScopes,
ILogger logger,
SetupResults? setupResults,
CancellationToken ct)
{
var s2sSpecs = specs.Where(s => s.AppRoleScopes is { Length: > 0 }).ToList();
if (s2sSpecs.Count == 0)
{
if (setupResults is not null) setupResults.S2SAppRoleGranted = true;
return;
}

logger.LogInformation("");
logger.LogInformation("Configuring S2S app role assignments...");

var allS2SOk = true;
foreach (var spec in s2sSpecs)
{
logger.LogDebug(
" - App role assignment: blueprint -> {ResourceName} [{AppRoles}]",
spec.ResourceName, string.Join(' ', spec.AppRoleScopes!));

var s2sOk = await blueprintService.GrantAppRoleAssignmentAsync(
tenantId,
blueprintSpObjectId,
spec.ResourceAppId,
spec.AppRoleScopes!,
requiredScopes: s2sScopes,
ct: ct);

if (s2sOk)
logger.LogInformation(" - S2S app role assigned for {ResourceName}", spec.ResourceName);
else
{
logger.LogWarning(" - Failed to assign S2S app role for {ResourceName}.", spec.ResourceName);
setupResults?.Warnings.Add($"S2S app role assignment failed for {spec.ResourceName}. Re-run 'a365 setup admin' to retry.");
allS2SOk = false;
}
}

if (setupResults is not null)
setupResults.S2SAppRoleGranted = allS2SOk;
}

/// <summary>
/// Phase 3: Checks for existing consent (skips browser if found), then either opens the
/// browser for admins or returns a consolidated consent URL for non-admins.
Expand Down Expand Up @@ -535,6 +597,9 @@ private static async Task<bool> ConfigureOauth2GrantsAsync(
// we performed a role check and found the user lacks the GA role.
if (adminCheck == Models.RoleCheckResult.DoesNotHaveRole)
{
var s2sSpecs = specs.Where(s => s.AppRoleScopes is { Length: > 0 }).ToList();
if (s2sSpecs.Count > 0 && setupResults is not null)
setupResults.S2SAppRoleGranted = false;
return (false, consentUrl);
}

Expand Down Expand Up @@ -678,7 +743,8 @@ private record BlueprintPermissionsResult(
ILogger logger,
SetupResults setupResults,
CancellationToken ct,
string? knownBlueprintSpObjectId = null)
string? knownBlueprintSpObjectId = null,
AgentBlueprintService? blueprintService = null)
{
if (specs.Count == 0)
{
Expand All @@ -689,8 +755,14 @@ private record BlueprintPermissionsResult(
var effectiveSpecs = specs.Where(s => s.Scopes.Length > 0).ToList();
if (effectiveSpecs.Count == 0)
{
logger.LogInformation("All permission specs have empty scope lists — nothing to grant.");
return (true, null);
var hasS2SSpecs = specs.Any(s => s.AppRoleScopes is { Length: > 0 });
if (!hasS2SSpecs)
{
logger.LogInformation("All permission specs have empty scope and app role lists — nothing to grant.");
setupResults.S2SAppRoleGranted = true;
return (true, null);
}
logger.LogDebug("No delegated scopes to grant — proceeding with S2S app role assignments.");
}

var permScopes = AuthenticationConstants.RequiredPermissionGrantScopes;
Expand All @@ -699,11 +771,17 @@ private record BlueprintPermissionsResult(
logger.LogInformation("");
logger.LogInformation("Resolving service principals...");

// When delegated specs are empty but S2S specs exist, resolve SPs from S2S specs instead
// so the blueprint SP object ID is available for app role assignment.
var specsForPhase1 = effectiveSpecs.Count > 0
? (IReadOnlyList<ResourcePermissionSpec>)effectiveSpecs
: specs.Where(s => s.AppRoleScopes is { Length: > 0 }).ToList();

BlueprintPermissionsResult? phase1Result = null;
try
{
phase1Result = await UpdateBlueprintPermissionsAsync(
graph, blueprintAppId, tenantId, effectiveSpecs, permScopes, logger, ct,
graph, blueprintAppId, tenantId, specsForPhase1, permScopes, logger, ct,
knownBlueprintSpObjectId);
}
catch (Exception ex)
Expand Down Expand Up @@ -758,6 +836,13 @@ private record BlueprintPermissionsResult(
}
}

// S2S: Grant app role assignments for specs that carry AppRoleScopes.
var s2sScopes = permScopes.Concat(AuthenticationConstants.RequiredS2SGrantScopes).ToArray();
if (blueprintService is not null && !string.IsNullOrWhiteSpace(phase1Result.BlueprintSpObjectId))
await PerformS2SGrantsAsync(blueprintService, tenantId, phase1Result.BlueprintSpObjectId, specs, s2sScopes, logger, setupResults, ct);
else
setupResults.S2SAppRoleGranted = true; // M-003: no service or unresolved SP — mark not applicable
Comment thread
sellakumaran marked this conversation as resolved.
Outdated

return (allGrantsOk, phase1Result.BlueprintSpObjectId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ internal record ResourcePermissionSpec(
string ResourceAppId,
string ResourceName,
string[] Scopes,
bool SetInheritable);
bool SetInheritable,
string[]? AppRoleScopes = null);
Loading
Loading