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
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,12 @@ 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: blueprint SP was not resolved — leave S2SAppRoleGranted = null (not attempted)

logger.LogInformation("");
if (grantsOk)
{
Expand Down Expand Up @@ -389,6 +395,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 +450,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 +596,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 +742,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 +754,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 +770,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 +835,12 @@ 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: blueprint service unavailable or SP not resolved — leave S2SAppRoleGranted = null (not attempted)

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