Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
9de0037
[Bug 640454] Scope Expense Agent Entra permissions by company
t-prda Sep 9, 2026
c45dda7
Address Expense Agent Entra lifecycle feedback
t-prda Sep 9, 2026
eb2c987
Test Expense Agent Entra lifecycle scenarios
t-prda Sep 9, 2026
a70355a
Refactor Expense Agent lifecycle tests
t-prda Sep 9, 2026
457683d
Address Expense Agent permission review findings
t-prda Sep 10, 2026
ce5d475
Harden Expense Agent Entra permission lifecycle
t-prda Sep 10, 2026
bba1520
Fail on missing Expense Agent prerequisites
t-prda Sep 10, 2026
deaffd7
Preflight Expense Agent security permissions
t-prda Sep 10, 2026
e503922
Preflight Expense Agent Entra authorization
t-prda Sep 10, 2026
728248e
Align Expense Agent preflight with platform security
t-prda Sep 10, 2026
86c9a40
Narrow Expense Agent fix to activation scope
t-prda Sep 11, 2026
756ca7d
Complete Expense Agent company lifecycle
t-prda Sep 14, 2026
2e4ae43
Reorder Expense Agent manager procedures
t-prda Sep 14, 2026
569b361
Simplify Expense Agent lifecycle flow
t-prda Sep 14, 2026
d868c82
Simplify company permission handling
t-prda Sep 14, 2026
4e7932b
Polish Expense Agent lifecycle tests
t-prda Sep 14, 2026
f0ab337
Preserve Expense Agent API compatibility
t-prda Sep 14, 2026
79d0ef8
Refine Expense Agent deactivation preflight
t-prda Sep 14, 2026
cbefdd5
Verify Expense Agent final deactivation
t-prda Sep 14, 2026
79d1377
Align Expense Agent obsolete tag
t-prda Sep 14, 2026
63f161a
Keep Expense Agent compatibility delegate
t-prda Sep 14, 2026
7343b04
Allow SUPER to manage Expense Agent Entra app
t-prda Sep 15, 2026
0ae5941
Run SUPER tests under SUPER permissions
t-prda Sep 16, 2026
bb2182e
Make Expense Agent lifecycle test permissions explicit
t-prda Sep 16, 2026
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 @@ -6,7 +6,6 @@ namespace Microsoft.ExpenseAgent;

using System.AI;
using System.Environment;
using System.Environment.Configuration;

codeunit 6993 "Expense Agent API Validation"
{
Expand All @@ -17,7 +16,6 @@ codeunit 6993 "Expense Agent API Validation"
var
AgentNotEnabledErr: Label 'Expense Agent is not enabled. Please contact your administrator.';
CapabilityNotEnabledErr: Label 'The "%1" capability is not enabled. Please contact your administrator to enable the capability.', Comment = '%1 = a capability name, such as Expense Agent';
ExpenseAgentAadAppIdTxt: Label 'ee1eb5fd-719b-44f2-97d0-0efd34bc4148', Locked = true;

procedure VerifyAgentAccess()
begin
Expand Down Expand Up @@ -46,32 +44,24 @@ codeunit 6993 "Expense Agent API Validation"
end;

procedure GetAadAppId(): Text
var
ExpenseAgentEntraApp: Codeunit "Expense Agent Entra App Mgt.";
begin
exit(ExpenseAgentAadAppIdTxt);
exit(ExpenseAgentEntraApp.GetAadAppId());
end;

procedure IsCurrentUserExpenseAgent(): Boolean
var
AADApplication: Record "AAD Application";
EnvironmentInfo: Codeunit "Environment Information";
ExpenseAgentEntraApp: Codeunit "Expense Agent Entra App Mgt.";
Comment thread
t-prda marked this conversation as resolved.
begin
if not EnvironmentInfo.IsSaaSInfrastructure() then
exit(true);

if not AADApplication.Get(ExpenseAgentAadAppIdTxt) then
exit(false);

exit(AADApplication."User ID" = UserSecurityId());
exit(ExpenseAgentEntraApp.IsCurrentUserExpenseAgent());
end;

[TryFunction]
internal procedure TryGetExpenseAgentUserId(var ExpenseAgentUserId: Guid)
var
AadApplication: Record "AAD Application";
ExpenseAgentEntraApp: Codeunit "Expense Agent Entra App Mgt.";
begin
AadApplication.Get(ExpenseAgentAadAppIdTxt);
AadApplication.TestField(State, AadApplication.State::Enabled);

ExpenseAgentUserId := AadApplication."User ID";
ExpenseAgentUserId := ExpenseAgentEntraApp.GetEnabledExpenseAgentUserId();
end;
}
Original file line number Diff line number Diff line change
Expand Up @@ -589,13 +589,13 @@ codeunit 6941 "EA Http Client"
var
ExpenseAgentSetup: Record "Expense Agent Setup";
AzureADMgt: Codeunit "Azure AD Mgt.";
ExpenseAgentAPIValidation: Codeunit "Expense Agent API Validation";
ExpenseAgentEntraApp: Codeunit "Expense Agent Entra App Mgt.";
OAuth2: Codeunit OAuth2;
Scopes: List of [Text];
OAuthScope: Text;
OAuthScopePatternLbl: Label 'api://%1/', Locked = true;
begin
OAuthScope := StrSubstNo(OAuthScopePatternLbl, ExpenseAgentAPIValidation.GetAadAppId());
OAuthScope := StrSubstNo(OAuthScopePatternLbl, ExpenseAgentEntraApp.GetAadAppId());
Scopes.Add(OAuthScope + 'Expenses.ReadWrite.All');
AccessToken := AzureADMgt.GetAccessTokenAsSecretText(OAuthScope, '', false);
if AccessToken.IsEmpty() then begin
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
// ------------------------------------------------------------------------------------------------
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License. See License.txt in the project root for license information.
// ------------------------------------------------------------------------------------------------
namespace Microsoft.ExpenseAgent;

using System.Agents;
using System.Environment;
using System.Environment.Configuration;
using System.Security.AccessControl;
using System.Security.User;

codeunit 6913 "Expense Agent Entra App Mgt."
{
Access = Internal;
InherentEntitlements = X;
InherentPermissions = X;
Permissions =
tabledata "AAD Application" = rm,
tabledata "Access Control" = rimd;

internal procedure EnableAadApplicationForCurrentCompany()
Comment thread
t-prda marked this conversation as resolved.
begin
VerifyCurrentUserCanManageExpenseAgent();
EnableAadApplicationForCurrentCompanyWithoutAuthorization();
end;

internal procedure DisableAadApplicationForCurrentCompany()
begin
VerifyCanDisableAadApplicationForCurrentCompany();
DisableAadApplicationForCompany(GetCurrentCompanyName());
end;

internal procedure IsCurrentUserExpenseAgent(): Boolean
var
AadApplication: Record "AAD Application";
EnvironmentInfo: Codeunit "Environment Information";
begin
if not EnvironmentInfo.IsSaaSInfrastructure() then
exit(true);

if not AadApplication.Get(GetAadAppId()) then
exit(false);

exit(AadApplication."User ID" = UserSecurityId());
end;

internal procedure GetEnabledExpenseAgentUserId(): Guid
var
AadApplication: Record "AAD Application";
begin
AadApplication.Get(GetAadAppId());
AadApplication.TestField(State, AadApplication.State::Enabled);

exit(AadApplication."User ID");
end;

internal procedure GetAadAppId(): Text
begin
exit(ExpenseAgentAadAppIdTxt);
end;

internal procedure VerifyCanDisableAadApplicationForCurrentCompany()
begin
VerifyCurrentUserCanManageExpenseAgent();
end;

local procedure EnableAadApplicationForCurrentCompanyWithoutAuthorization()
Comment thread
t-prda marked this conversation as resolved.
var
AadApplication: Record "AAD Application";
begin
GetAadApplication(AadApplication);

// Enabling creates the application user. Disable it again before changing permissions.
if AadApplication.State <> AadApplication.State::Enabled then begin
AadApplication.Validate(State, AadApplication.State::Enabled);
AadApplication.Modify(true);
end;

if HasPermissionForCurrentCompany(AadApplication) then
exit;

AadApplication.Validate(State, AadApplication.State::Disabled);
AadApplication.Modify(true);

AddPermissionForCompany(AadApplication, GetCurrentCompanyName());

AadApplication.Validate(State, AadApplication.State::Enabled);
AadApplication.Modify(true);
end;

local procedure DisableAadApplicationForCompany(CompanyNameValue: Text[30])
var
AadApplication: Record "AAD Application";
OtherCompanyPermissionExists: Boolean;
begin
if not AadApplication.Get(GetAadAppId()) then
exit;

OtherCompanyPermissionExists := HasOtherCompanyPermission(AadApplication, CompanyNameValue);
RemovePermissionForCompany(AadApplication, CompanyNameValue, not OtherCompanyPermissionExists);
if OtherCompanyPermissionExists then
exit;
if AadApplication.State = AadApplication.State::Disabled then
exit;

AadApplication.Validate(State, AadApplication.State::Disabled);
AadApplication.Modify(true);
end;

local procedure VerifyCurrentUserCanManageExpenseAgent()
var
AggregatePermissionSet: Record "Aggregate Permission Set";
AgentSystemPermissions: Codeunit "Agent System Permissions";
UserPermissions: Codeunit "User Permissions";
begin
if UserPermissions.IsSuper(UserSecurityId()) then
exit;

GetAgentAdminPermissionSet(AggregatePermissionSet);
Comment thread
t-prda marked this conversation as resolved.
if not UserPermissions.HasUserPermissionSetAssigned(
UserSecurityId(),
GetCurrentCompanyName(),
AggregatePermissionSet."Role ID",
AggregatePermissionSet.Scope,
AggregatePermissionSet."App ID")
then
Error(PermissionSetRequiredErr, AggregatePermissionSet.Name);

if not AgentSystemPermissions.CurrentUserHasCanManageAllAgentsPermission() then
Error(PermissionSetRequiredErr, AggregatePermissionSet.Name);

GetExpenseManagementAdminPermissionSet(AggregatePermissionSet);
if not UserPermissions.HasUserPermissionSetAssigned(
UserSecurityId(),
GetCurrentCompanyName(),
AggregatePermissionSet."Role ID",
AggregatePermissionSet.Scope,
AggregatePermissionSet."App ID")
then
Error(PermissionSetRequiredErr, AggregatePermissionSet.Name);

if not HasSecurityPermission(UserPermissions) then
Comment thread
t-prda marked this conversation as resolved.
Error(SecurityPermissionRequiredErr);

GetExpenseAgentPermissionSet(AggregatePermissionSet);
if not UserPermissions.HasUserPermissionSetAssigned(
UserSecurityId(),
GetCurrentCompanyName(),
AggregatePermissionSet."Role ID",
AggregatePermissionSet.Scope,
AggregatePermissionSet."App ID")
then
Error(PermissionSetRequiredErr, AggregatePermissionSet.Name);
end;

local procedure HasSecurityPermission(UserPermissions: Codeunit "User Permissions"): Boolean
var
AccessControl: Record "Access Control";
NullGuid: Guid;
begin
exit(UserPermissions.HasUserPermissionSetAssigned(
UserSecurityId(),
GetCurrentCompanyName(),
SecurityPermissionSetLbl,
AccessControl.Scope::System,
NullGuid));
end;

local procedure HasPermissionForCurrentCompany(AadApplication: Record "AAD Application"): Boolean
begin
exit(HasPermissionForCompany(AadApplication, GetCurrentCompanyName()));
end;

local procedure HasPermissionForCompany(AadApplication: Record "AAD Application"; CompanyNameValue: Text[30]): Boolean
var
AccessControl: Record "Access Control";
AggregatePermissionSet: Record "Aggregate Permission Set";
begin
GetExpenseAgentPermissionSet(AggregatePermissionSet);

SetExpenseAgentPermissionFilters(AccessControl, AadApplication, AggregatePermissionSet);
AccessControl.SetRange("Company Name", CompanyNameValue);
exit(not AccessControl.IsEmpty());
end;

local procedure HasOtherCompanyPermission(AadApplication: Record "AAD Application"; CompanyNameValue: Text[30]): Boolean
var
AccessControl: Record "Access Control";
AggregatePermissionSet: Record "Aggregate Permission Set";
begin
GetExpenseAgentPermissionSet(AggregatePermissionSet);
SetExpenseAgentPermissionFilters(AccessControl, AadApplication, AggregatePermissionSet);
AccessControl.SecurityFiltering(SecurityFilter::Ignored);
AccessControl.SetFilter("Company Name", '<>''''&<>%1', CompanyNameValue);
exit(not AccessControl.IsEmpty());
end;

local procedure AddPermissionForCompany(AadApplication: Record "AAD Application"; CompanyNameValue: Text[30])
var
AccessControl: Record "Access Control";
AggregatePermissionSet: Record "Aggregate Permission Set";
begin
if HasPermissionForCompany(AadApplication, CompanyNameValue) then
exit;

GetExpenseAgentPermissionSet(AggregatePermissionSet);

AccessControl.Init();
AccessControl.Validate("User Security ID", AadApplication."User ID");
AccessControl.Validate("Role ID", AggregatePermissionSet."Role ID");
AccessControl.Validate("App ID", AggregatePermissionSet."App ID");
AccessControl.Validate(Scope, AggregatePermissionSet.Scope);
AccessControl.Validate("Company Name", CompanyNameValue);
AccessControl.Insert(true);
end;

local procedure RemovePermissionForCompany(AadApplication: Record "AAD Application"; CompanyNameValue: Text[30]; RunTrigger: Boolean)
var
AccessControl: Record "Access Control";
AggregatePermissionSet: Record "Aggregate Permission Set";
begin
GetExpenseAgentPermissionSet(AggregatePermissionSet);

SetExpenseAgentPermissionFilters(AccessControl, AadApplication, AggregatePermissionSet);
AccessControl.SetRange("Company Name", CompanyNameValue);
AccessControl.DeleteAll(RunTrigger);
end;

local procedure GetAadApplication(var AadApplication: Record "AAD Application")
begin
if not AadApplication.Get(GetAadAppId()) then
Error(AadApplicationMissingErr);
end;

local procedure GetCurrentCompanyName(): Text[30]
begin
exit(CopyStr(CompanyName(), 1, 30));
end;

local procedure GetExpenseAgentPermissionSet(var AggregatePermissionSet: Record "Aggregate Permission Set")
begin
GetPermissionSet(AggregatePermissionSet, ExpenseAgentPermissionSetLbl);
end;

local procedure GetAgentAdminPermissionSet(var AggregatePermissionSet: Record "Aggregate Permission Set")
var
BaseApplicationAppId: Guid;
begin
Evaluate(BaseApplicationAppId, BaseApplicationAppIdTxt);
AggregatePermissionSet.Reset();
AggregatePermissionSet.SetRange("App ID", BaseApplicationAppId);
AggregatePermissionSet.SetRange("Role ID", AgentAdminPermissionSetLbl);
if not AggregatePermissionSet.FindFirst() then
Comment thread
t-prda marked this conversation as resolved.
ErrorPermissionSetMissing(AgentAdminPermissionSetLbl);
end;

local procedure GetExpenseManagementAdminPermissionSet(var AggregatePermissionSet: Record "Aggregate Permission Set")
begin
GetPermissionSet(AggregatePermissionSet, ExpenseManagementAdminPermissionSetLbl);
end;

local procedure GetPermissionSet(var AggregatePermissionSet: Record "Aggregate Permission Set"; PermissionSetId: Code[20])
var
ExpenseAgentAppId: Guid;
begin
AggregatePermissionSet.Reset();
Evaluate(ExpenseAgentAppId, ExpenseAgentAppIdTxt);
AggregatePermissionSet.SetRange("App ID", ExpenseAgentAppId);
AggregatePermissionSet.SetRange("Role ID", PermissionSetId);
if not AggregatePermissionSet.FindFirst() then
ErrorPermissionSetMissing(PermissionSetId);
end;

local procedure ErrorPermissionSetMissing(PermissionSetId: Code[20])
var
MissingPermissionSetErrorInfo: ErrorInfo;
begin
MissingPermissionSetErrorInfo.ErrorType := ErrorType::Internal;
MissingPermissionSetErrorInfo.DataClassification := DataClassification::SystemMetadata;
MissingPermissionSetErrorInfo.Message := StrSubstNo(PermissionSetMissingErr, PermissionSetId);
Error(MissingPermissionSetErrorInfo);
end;

local procedure SetExpenseAgentPermissionFilters(var AccessControl: Record "Access Control"; AadApplication: Record "AAD Application"; AggregatePermissionSet: Record "Aggregate Permission Set")
begin
AccessControl.SetRange("User Security ID", AadApplication."User ID");
AccessControl.SetRange("Role ID", AggregatePermissionSet."Role ID");
AccessControl.SetRange(Scope, AggregatePermissionSet.Scope);
AccessControl.SetRange("App ID", AggregatePermissionSet."App ID");
end;

var
ExpenseAgentAadAppIdTxt: Label 'ee1eb5fd-719b-44f2-97d0-0efd34bc4148', Locked = true;
ExpenseAgentAppIdTxt: Label '66efe10c-8033-403b-a86d-77c0887178ba', Locked = true;
BaseApplicationAppIdTxt: Label '437dbf0e-84ff-417a-965d-ed2bb9650972', Locked = true;
AgentAdminPermissionSetLbl: Label 'Agent - Admin', Locked = true;
ExpenseAgentPermissionSetLbl: Label 'Expense Agent', Locked = true;
ExpenseManagementAdminPermissionSetLbl: Label 'Expense Mgmt. Admin', Locked = true;
SecurityPermissionSetLbl: Label 'SECURITY', Locked = true;
AadApplicationMissingErr: Label 'The Expense Agent Microsoft Entra application is not configured.';
PermissionSetRequiredErr: Label 'You must be assigned the %1 permission set to manage the Expense Agent Microsoft Entra application.', Comment = '%1 = permission set name';
PermissionSetMissingErr: Label 'The %1 permission set is not available.', Comment = '%1 = permission set ID';
SecurityPermissionRequiredErr: Label 'You must be assigned either the SUPER or SECURITY permission set to manage the Expense Agent Microsoft Entra application.';
}
Loading
Loading