diff --git a/src/System Application/App/AI/src/Azure OpenAI/AOAIAccountVerificationLog.Table.al b/src/System Application/App/AI/src/Azure OpenAI/AOAIAccountVerificationLog.Table.al new file mode 100644 index 0000000000..e38e8380a4 --- /dev/null +++ b/src/System Application/App/AI/src/Azure OpenAI/AOAIAccountVerificationLog.Table.al @@ -0,0 +1,35 @@ +namespace System.AI; + +table 7767 "AOAI Account Verification Log" +{ + Access = Internal; + Caption = 'AOAI Account Verification Log'; + DataPerCompany = false; + Extensible = false; + InherentEntitlements = RIMDX; + InherentPermissions = X; + ReplicateData = false; + + fields + { + field(1; AccountName; Text[100]) + { + Caption = 'Account Name'; + DataClassification = CustomerContent; + } + + field(2; LastSuccessfulVerification; DateTime) + { + Caption = 'Time of last successful verification'; + DataClassification = SystemMetadata; + } + } + + keys + { + key(PrimaryKey; AccountName) + { + Clustered = false; + } + } +} \ No newline at end of file diff --git a/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al index 2e5ba3e42a..6e2950f938 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/AOAIAuthorization.Codeunit.al @@ -6,6 +6,7 @@ namespace System.AI; using System; +using System.Telemetry; /// /// Store the authorization information for the AOAI service. /// @@ -14,8 +15,10 @@ codeunit 7767 "AOAI Authorization" Access = Internal; InherentEntitlements = X; InherentPermissions = X; + Permissions = tabledata "AOAI Account Verification Log" = RIMD; var + CopilotCapabilityImpl: Codeunit "Copilot Capability Impl"; [NonDebuggable] Endpoint: Text; [NonDebuggable] @@ -24,7 +27,18 @@ codeunit 7767 "AOAI Authorization" ApiKey: SecretText; [NonDebuggable] ManagedResourceDeployment: Text; + [NonDebuggable] + AOAIAccountName: Text; ResourceUtilization: Enum "AOAI Resource Utilization"; + FirstPartyAuthorization: Boolean; + SelfManagedAuthorization: Boolean; + MicrosoftManagedAuthorizationWithDeployment: Boolean; + MicrosoftManagedAuthorizationWithAOAIAccount: Boolean; + TelemetryAOAIVerificationFailedTxt: Label 'Failed to authenticate account against Azure Open AI', Locked = true; + TelemetryAOAIVerificationSucceededTxt: Label 'Successfully authenticated account against Azure Open AI', Locked = true; + TelemetryAccessWithinCachePeriodTxt: Label 'Cached access to Azure Open AI was used', Locked = true; + TelemetryAccessTokenWithinGracePeriodTxt: Label 'Failed to authenticate against Azure Open AI but last successful authentication is within grace period. System still has access for %1', Locked = true; + TelemetryAccessTokenOutsideCachePeriodTxt: Label 'Failed to authenticate against Azure Open AI and last successful authentication is outside grace period. System no longer has access', Locked = true; [NonDebuggable] procedure IsConfigured(CallerModule: ModuleInfo): Boolean @@ -32,18 +46,24 @@ codeunit 7767 "AOAI Authorization" AzureOpenAiImpl: Codeunit "Azure OpenAI Impl"; CurrentModule: ModuleInfo; ALCopilotFunctions: DotNet ALCopilotFunctions; + AOAIAccountIsVerified: Boolean; begin NavApp.GetCurrentModuleInfo(CurrentModule); case ResourceUtilization of Enum::"AOAI Resource Utilization"::"First Party": - exit((ManagedResourceDeployment <> '') and ALCopilotFunctions.IsPlatformAuthorizationConfigured(CallerModule.Publisher(), CurrentModule.Publisher())); + exit(FirstPartyAuthorization and ALCopilotFunctions.IsPlatformAuthorizationConfigured(CallerModule.Publisher(), CurrentModule.Publisher())); Enum::"AOAI Resource Utilization"::"Self-Managed": - exit((Deployment <> '') and (Endpoint <> '') and (not ApiKey.IsEmpty())); + exit(SelfManagedAuthorization); Enum::"AOAI Resource Utilization"::"Microsoft Managed": - exit((Deployment <> '') and (Endpoint <> '') and (not ApiKey.IsEmpty()) and (ManagedResourceDeployment <> '') and AzureOpenAiImpl.IsTenantAllowlistedForFirstPartyCopilotCalls()); + if MicrosoftManagedAuthorizationWithAOAIAccount then begin + AOAIAccountIsVerified := VerifyAOAIAccount(AOAIAccountName, ApiKey); + exit(AOAIAccountIsVerified and AzureOpenAiImpl.IsTenantAllowlistedForFirstPartyCopilotCalls()); + end + else + if MicrosoftManagedAuthorizationWithDeployment then + exit(AzureOpenAiImpl.IsTenantAllowlistedForFirstPartyCopilotCalls()); end; - exit(false); end; @@ -57,6 +77,19 @@ codeunit 7767 "AOAI Authorization" Deployment := NewDeployment; ApiKey := NewApiKey; ManagedResourceDeployment := NewManagedResourceDeployment; + MicrosoftManagedAuthorizationWithDeployment := true; + end; + + [NonDebuggable] + procedure SetMicrosoftManagedAuthorization(NewAOAIAccountName: Text; NewApiKey: SecretText; NewManagedResourceDeployment: Text) + begin + ClearVariables(); + + ResourceUtilization := Enum::"AOAI Resource Utilization"::"Microsoft Managed"; + AOAIAccountName := NewAOAIAccountName; + ApiKey := NewApiKey; + ManagedResourceDeployment := NewManagedResourceDeployment; + MicrosoftManagedAuthorizationWithAOAIAccount := true; end; [NonDebuggable] @@ -68,6 +101,7 @@ codeunit 7767 "AOAI Authorization" Endpoint := NewEndpoint; Deployment := NewDeployment; ApiKey := NewApiKey; + SelfManagedAuthorization := true; end; [NonDebuggable] @@ -77,6 +111,7 @@ codeunit 7767 "AOAI Authorization" ResourceUtilization := Enum::"AOAI Resource Utilization"::"First Party"; ManagedResourceDeployment := NewDeployment; + FirstPartyAuthorization := true; end; [NonDebuggable] @@ -113,7 +148,173 @@ codeunit 7767 "AOAI Authorization" Clear(Endpoint); Clear(ApiKey); Clear(Deployment); + Clear(AOAIAccountName); Clear(ManagedResourceDeployment); Clear(ResourceUtilization); + Clear(FirstPartyAuthorization); + clear(SelfManagedAuthorization); + Clear(MicrosoftManagedAuthorizationWithDeployment); + Clear(MicrosoftManagedAuthorizationWithAOAIAccount); + end; + + [NonDebuggable] + local procedure PerformAOAIAccountVerification(AOAIAccountNameToVerify: Text; NewApiKey: SecretText): Boolean + var + HttpClient: HttpClient; + HttpRequestMessage: HttpRequestMessage; + HttpResponseMessage: HttpResponseMessage; + HttpContent: HttpContent; + ContentHeaders: HttpHeaders; + Url: Text; + IsSuccessful: Boolean; + UrlFormatTxt: Label 'https://%1.openai.azure.com/openai/models?api-version=2024-06-01', Locked = true; + begin + Url := StrSubstNo(UrlFormatTxt, AOAIAccountNameToVerify); + + HttpContent.GetHeaders(ContentHeaders); + if ContentHeaders.Contains('Content-Type') then + ContentHeaders.Remove('Content-Type'); + ContentHeaders.Add('Content-Type', 'application/json'); + ContentHeaders.Add('api-key', NewApiKey); + + HttpRequestMessage.Method := 'GET'; + HttpRequestMessage.SetRequestUri(Url); + HttpRequestMessage.Content(HttpContent); + + IsSuccessful := HttpClient.Send(HttpRequestMessage, HttpResponseMessage); + + if not IsSuccessful or not HttpResponseMessage.IsSuccessStatusCode() then begin + Session.LogMessage('0000OLQ', TelemetryAOAIVerificationFailedTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CopilotCapabilityImpl.GetAzureOpenAICategory()); + exit(false); + end; + + Session.LogMessage('0000OLR', TelemetryAOAIVerificationSucceededTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CopilotCapabilityImpl.GetAzureOpenAICategory()); + exit(true); + end; + + local procedure VerifyAOAIAccount(AccountName: Text; NewApiKey: SecretText): Boolean + var + VerificationLog: Record "AOAI Account Verification Log"; + AccountVerified: Boolean; + GracePeriod: Duration; + CachePeriod: Duration; + TruncatedAccountName: Text[100]; + IsWithinCachePeriod: Boolean; + RemainingGracePeriod: Duration; + AuthFailedWithinGracePeriodLogMessageLbl: Label 'Azure Open AI authorization failed for account %1 on %2 because it is not authorized to access AI services. The connection will be terminated within %3 if not rectified', Comment = 'Telemetry message where %1 is the name of the Azure Open AI account name, %2 is the date where verification has taken place, and %3 is the remaining time until the grace period expires'; + AuthFailedOutsideGracePeriodLogMessageLbl: Label 'Azure Open AI authorization failed for account %1 on %2 because it is not authorized to access AI services. The grace period has been exceeded and the connection has been terminated', Comment = 'Telemetry message where %1 is the name of the Azure Open AI account name and %2 is the date where verification has taken place'; + AuthFailedWithinGracePeriodUserNotificationLbl: Label 'Azure Open AI authorization failed. AI functionality will be disabled within %1. Please contact your system administrator or the extension developer for assistance.', Comment = 'User notification explaining that AI functionality will be disabled soon, where %1 is the remaining time until the grace period expires'; + AuthFailedOutsideGracePeriodUserNotificationLbl: Label 'Azure Open AI authorization failed and the AI functionality has been disabled. Please contact your system administrator or the extension developer for assistance.', Comment = 'User notification explaining that AI functionality has been disabled'; + + begin + GracePeriod := 14 * 24 * 60 * 60 * 1000; // 2 weeks in milliseconds + CachePeriod := 24 * 60 * 60 * 1000; // 1 day in milliseconds + TruncatedAccountName := CopyStr(DelChr(AccountName, '<>', ' '), 1, 100); + + IsWithinCachePeriod := IsAccountVerifiedWithinPeriod(TruncatedAccountName, CachePeriod); + // Within CACHE period + if IsWithinCachePeriod then begin + Session.LogMessage('0000OLS', TelemetryAccessWithinCachePeriodTxt, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CopilotCapabilityImpl.GetAzureOpenAICategory()); + exit(true); + end; + + // Outside CACHE period + AccountVerified := PerformAOAIAccountVerification(AccountName, NewApiKey); + + if not AccountVerified then begin + if VerificationLog.Get(TruncatedAccountName) then + RemainingGracePeriod := GracePeriod - (CurrentDateTime - VerificationLog.LastSuccessfulVerification) + else + RemainingGracePeriod := GracePeriod; + + // Within GRACE period + if IsAccountVerifiedWithinPeriod(TruncatedAccountName, GracePeriod) then begin + ShowUserNotification(StrSubstNo(AuthFailedWithinGracePeriodUserNotificationLbl, FormatDurationAsDays(RemainingGracePeriod))); + LogTelemetry(AccountName, Today, StrSubstNo(AuthFailedWithinGracePeriodLogMessageLbl, AccountName, Today, FormatDurationAsDays(RemainingGracePeriod))); + Session.LogMessage('0000OLT', StrSubstNo(TelemetryAccessTokenWithinGracePeriodTxt, FormatDurationAsDays(RemainingGracePeriod)), Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CopilotCapabilityImpl.GetAzureOpenAICategory()); + exit(true); + end + // Outside GRACE period + else begin + ShowUserNotification(AuthFailedOutsideGracePeriodUserNotificationLbl); + LogTelemetry(AccountName, Today, AuthFailedOutsideGracePeriodLogMessageLbl); + Session.LogMessage('0000OLU', TelemetryAccessTokenOutsideCachePeriodTxt, Verbosity::Warning, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CopilotCapabilityImpl.GetAzureOpenAICategory()); + exit(false); + end; + end; + + SaveVerificationTime(TruncatedAccountName); + exit(true); + end; + + local procedure IsAccountVerifiedWithinPeriod(AccountName: Text[100]; Period: Duration): Boolean + var + VerificationLog: Record "AOAI Account Verification Log"; + IsVerified: Boolean; + begin + if VerificationLog.Get(AccountName) then begin + IsVerified := CurrentDateTime - VerificationLog.LastSuccessfulVerification <= Period; + exit(IsVerified); + end; + + exit(false); + end; + + local procedure SaveVerificationTime(AccountName: Text[100]) + var + VerificationLog: Record "AOAI Account Verification Log"; + begin + if VerificationLog.Get(AccountName) then begin + VerificationLog.LastSuccessfulVerification := CurrentDateTime; + VerificationLog.Modify(); + end else begin + VerificationLog.Init(); + VerificationLog.AccountName := AccountName; + VerificationLog.LastSuccessfulVerification := CurrentDateTime; + VerificationLog.Insert() + end; + end; + + local procedure ShowUserNotification(Message: Text) + var + Notif: Notification; + begin + Notif.Message := Message; + Notif.Scope := NotificationScope::LocalScope; + Notif.Send(); + end; + + local procedure LogTelemetry(AccountName: Text; VerificationDate: Date; LogMessage: Text) + var + Telemetry: Codeunit Telemetry; + CustomDimensions: Dictionary of [Text, Text]; + begin + CustomDimensions.Add('AccountName', AccountName); + CustomDimensions.Add('VerificationDate', Format(VerificationDate)); + + Telemetry.LogMessage( + '0000AA1', // Event ID + StrSubstNo(LogMessage, AccountName, VerificationDate), + Verbosity::Warning, + DataClassification::SystemMetadata, + Enum::"AL Telemetry Scope"::All, + CustomDimensions + ); + end; + + local procedure FormatDurationAsDays(DurationValue: Duration): Text + var + Days: Decimal; + DaysLabelLbl: Label '%1 days', Comment = 'Days in plural. %1 is the number of days'; + DayLabelLbl: Label '1 day', Comment = 'A single day'; + begin + Days := DurationValue / (24 * 60 * 60 * 1000); + + if Days <= 1 then + exit(DayLabelLbl) + else + // Round up to the nearest whole day + Days := Round(Days, 1, '>'); + exit(StrSubstNo(DaysLabelLbl, Format(Days, 0, 0))); end; } \ No newline at end of file diff --git a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al index 7992af7d3f..6a8bc4f1bf 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAI.Codeunit.al @@ -100,11 +100,26 @@ codeunit 7771 "Azure OpenAI" /// Deployment would look like: gpt-35-turbo-16k /// [NonDebuggable] + [Obsolete('Using Managed AI resources now requires different input parameters. Use the other overload for SetManagedResourceAuthorization instead.', '26.0')] procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; Endpoint: Text; Deployment: Text; ApiKey: SecretText; ManagedResourceDeployment: Text) begin AzureOpenAIImpl.SetManagedResourceAuthorization(ModelType, Endpoint, Deployment, ApiKey, ManagedResourceDeployment); end; + /// + /// Sets the managed Azure OpenAI API authorization to use for a specific model type. + /// This will send the Azure OpenAI call to the deployment specified in , and will use the other parameters to verify that you have access to Azure OpenAI. + /// + /// The model type to set authorization for. + /// Name of the Azure Open AI resource like "MyAzureOpenAIResource" + /// The API key to access the Azure Open AI resource. This is used only for verification of access, not for actual Azure Open AI calls. + /// The managed deployment to use for the model type. + [NonDebuggable] + procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; AOAIAccountName: Text; ApiKey: SecretText; ManagedResourceDeployment: Text) + begin + AzureOpenAIImpl.SetManagedResourceAuthorization(ModelType, AOAIAccountName, ApiKey, ManagedResourceDeployment); + end; + /// /// Sets the Azure OpenAI API authorization to use for a specific model type and endpoint. /// diff --git a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al index 932a32220b..6d43985055 100644 --- a/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al +++ b/src/System Application/App/AI/src/Azure OpenAI/AzureOpenAIImpl.Codeunit.al @@ -207,6 +207,21 @@ codeunit 7772 "Azure OpenAI Impl" end; end; + [NonDebuggable] + procedure SetManagedResourceAuthorization(ModelType: Enum "AOAI Model Type"; AOAIAccountName: Text; ApiKey: SecretText; ManagedResourceDeployment: Text) + begin + case ModelType of + Enum::"AOAI Model Type"::"Text Completions": + TextCompletionsAOAIAuthorization.SetMicrosoftManagedAuthorization(AOAIAccountName, ApiKey, ManagedResourceDeployment); + Enum::"AOAI Model Type"::Embeddings: + EmbeddingsAOAIAuthorization.SetMicrosoftManagedAuthorization(AOAIAccountName, ApiKey, ManagedResourceDeployment); + Enum::"AOAI Model Type"::"Chat Completions": + ChatCompletionsAOAIAuthorization.SetMicrosoftManagedAuthorization(AOAIAccountName, ApiKey, ManagedResourceDeployment); + else + Error(InvalidModelTypeErr); + end; + end; + [NonDebuggable] procedure GenerateTextCompletion(Prompt: SecretText; var AOAIOperationResponse: Codeunit "AOAI Operation Response"; CallerModuleInfo: ModuleInfo): Text var @@ -757,5 +772,4 @@ codeunit 7772 "Azure OpenAI Impl" Session.LogMessage('0000MLE', TelemetryTenantAllowlistedMsg, Verbosity::Normal, DataClassification::SystemMetadata, TelemetryScope::ExtensionPublisher, 'Category', CopilotCapabilityImpl.GetAzureOpenAICategory()); exit(true); end; - -} \ No newline at end of file +}