diff --git a/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs b/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs index 01a4857442..9dd62e9547 100644 --- a/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs +++ b/src/Altinn.App.Core/Features/Signing/Models/SigneeContext.cs @@ -23,6 +23,12 @@ internal sealed class SigneeContext [JsonPropertyName("CommunicationConfig")] public CommunicationConfig? CommunicationConfig { get; init; } + /// + /// Additional actions to delegate to this signee beyond the default read and sign. + /// + [JsonPropertyName("additionalActionsToDelegate")] + public List? AdditionalActionsToDelegate { get; init; } + /// /// The state of the signee. /// diff --git a/src/Altinn.App.Core/Features/Signing/ProvidedSignee.cs b/src/Altinn.App.Core/Features/Signing/ProvidedSignee.cs index ee7f1a2c2f..8b34a68eb4 100644 --- a/src/Altinn.App.Core/Features/Signing/ProvidedSignee.cs +++ b/src/Altinn.App.Core/Features/Signing/ProvidedSignee.cs @@ -12,6 +12,13 @@ public abstract class ProvidedSignee /// [JsonPropertyName("communicationConfig")] public CommunicationConfig? CommunicationConfig { get; init; } + + /// + /// Additional actions to delegate to this signee. Read and sign are always delegated by default. + /// Use this to delegate additional actions, e.g. "reject". Custom action strings are also supported. + /// + [JsonPropertyName("additionalActionsToDelegate")] + public List? AdditionalActionsToDelegate { get; init; } } /// diff --git a/src/Altinn.App.Core/Features/Signing/Services/SigneeContextsManager.cs b/src/Altinn.App.Core/Features/Signing/Services/SigneeContextsManager.cs index 1b646ca9a4..fc236752fc 100644 --- a/src/Altinn.App.Core/Features/Signing/Services/SigneeContextsManager.cs +++ b/src/Altinn.App.Core/Features/Signing/Services/SigneeContextsManager.cs @@ -162,6 +162,7 @@ CancellationToken ct TaskId = taskId, SigneeState = new SigneeContextState(), CommunicationConfig = providedSignee.CommunicationConfig, + AdditionalActionsToDelegate = providedSignee.AdditionalActionsToDelegate, Signee = signee, }; } diff --git a/src/Altinn.App.Core/Features/Signing/Services/SigningDelegationService.cs b/src/Altinn.App.Core/Features/Signing/Services/SigningDelegationService.cs index e4d9f3919b..fd369854a8 100644 --- a/src/Altinn.App.Core/Features/Signing/Services/SigningDelegationService.cs +++ b/src/Altinn.App.Core/Features/Signing/Services/SigningDelegationService.cs @@ -68,7 +68,7 @@ CancellationToken ct partyUuid.ToString() ?? throw new InvalidOperationException("Delegatee: PartyUuid is null"), }, - Rights = CreateRights(appIdentifier, taskId), + Rights = CreateRights(appIdentifier, taskId, signeeContext.AdditionalActionsToDelegate), }; await accessManagementClient.DelegateRights(delegationRequest, ct); state.IsAccessDelegated = true; @@ -125,7 +125,7 @@ CancellationToken ct partyUuid.ToString() ?? throw new InvalidOperationException("Delegatee: PartyUuid is null"), }, - Rights = CreateRights(appIdentifier, taskId), + Rights = CreateRights(appIdentifier, taskId, signeeContext.AdditionalActionsToDelegate), }; await accessManagementClient.RevokeRights(delegationRequest, ct); signeeContext.SigneeState.IsAccessDelegated = false; @@ -155,7 +155,11 @@ private static Guid ParseInstanceGuid(string instanceIdCombo) } } - private static List CreateRights(AppIdentifier appIdentifier, string taskId) + private static List CreateRights( + AppIdentifier appIdentifier, + string taskId, + List? additionalActions + ) { var resources = new List { @@ -164,7 +168,7 @@ private static List CreateRights(AppIdentifier appIdentifier, stri new TaskResource { Value = taskId }, }; - return + List rights = [ new RightRequest { @@ -177,5 +181,21 @@ private static List CreateRights(AppIdentifier appIdentifier, stri Action = new AltinnAction { Value = ActionType.Sign }, }, ]; + + if (additionalActions is not null) + { + foreach (string action in additionalActions) + { + rights.Add( + new RightRequest + { + Resource = resources, + Action = new AltinnAction { Value = action }, + } + ); + } + } + + return rights; } } diff --git a/test/Altinn.App.Core.Tests/Features/Signing/SigneeContextsManagerTests.cs b/test/Altinn.App.Core.Tests/Features/Signing/SigneeContextsManagerTests.cs index 469695512f..f25fa6baf3 100644 --- a/test/Altinn.App.Core.Tests/Features/Signing/SigneeContextsManagerTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Signing/SigneeContextsManagerTests.cs @@ -530,4 +530,49 @@ public async Task GetSigneeContexts_WithMissingSigneeStatesDataTypeId_ThrowsAppl Assert.Empty(result); } + + [Fact] + public async Task GenerateSigneeContexts_WithAdditionalActionsToDelegate_ThreadsToSigneeContext() + { + // Arrange + var signatureConfiguration = new AltinnSignatureConfiguration + { + SigneeProviderId = "testProvider", + SigneeStatesDataTypeId = SigneeStatesDataTypeId, + }; + + var instance = new Instance + { + Process = new ProcessState { CurrentTask = new ProcessElementInfo { ElementId = "Task_1" } }, + }; + + var cachedInstanceMutator = new Mock(); + cachedInstanceMutator.Setup(x => x.Instance).Returns(instance); + + var personSignee = new ProvidedPerson + { + SocialSecurityNumber = "12345678901", + FullName = "Person One", + AdditionalActionsToDelegate = ["reject"], + }; + + var signeesResult = new SigneeProviderResult { Signees = [personSignee] }; + + _signeeProvider.Setup(x => x.Id).Returns("testProvider"); + _signeeProvider.Setup(x => x.GetSignees(It.IsAny())).ReturnsAsync(signeesResult); + + // Act + var result = await _signeeContextsManager.GenerateSigneeContexts( + cachedInstanceMutator.Object, + signatureConfiguration, + CancellationToken.None + ); + + // Assert + Assert.NotNull(result); + Assert.Single(result); + var additionalActions = result[0].AdditionalActionsToDelegate; + Assert.NotNull(additionalActions); + Assert.Equal("reject", Assert.Single(additionalActions)); + } } diff --git a/test/Altinn.App.Core.Tests/Features/Signing/SigningDelegationServiceTests.cs b/test/Altinn.App.Core.Tests/Features/Signing/SigningDelegationServiceTests.cs index dcf1b1e750..4b9b14d89c 100644 --- a/test/Altinn.App.Core.Tests/Features/Signing/SigningDelegationServiceTests.cs +++ b/test/Altinn.App.Core.Tests/Features/Signing/SigningDelegationServiceTests.cs @@ -540,4 +540,149 @@ public async Task RevokeSigneeRights_RecordsFailedRevocationOnError() // Assert Assert.False(success); } + + [Fact] + public async Task DelegateSigneeRights_WithAdditionalActions_DelegatesAllRights() + { + // Arrange + DelegationRequest? capturedRequest = null; + var accessManagementClient = new Mock(); + accessManagementClient + .Setup(x => x.DelegateRights(It.IsAny(), It.IsAny())) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new DelegationResponse()); + var logger = new Mock>(); + var service = new SigningDelegationService(accessManagementClient.Object, logger.Object); + var taskId = "taskId"; + Guid instanceGuid = Guid.NewGuid(); + var instanceId = "instanceOwnerPartyId" + "/" + instanceGuid; + Guid instanceOwnerPartyUuid = Guid.NewGuid(); + var appIdentifier = new AppIdentifier("testOrg", "testApp"); + var signeeContexts = new List() + { + new() + { + TaskId = taskId, + SigneeState = new SigneeState() { IsAccessDelegated = false }, + Signee = _signee, + AdditionalActionsToDelegate = ["reject"], + }, + }; + var ct = new CancellationToken(); + + // Act + (signeeContexts, var success) = await service.DelegateSigneeRights( + taskId, + instanceId, + instanceOwnerPartyUuid, + appIdentifier, + signeeContexts, + ct + ); + + // Assert + Assert.True(success); + Assert.True(signeeContexts[0].SigneeState.IsAccessDelegated); + Assert.NotNull(capturedRequest); + Assert.Equal(3, capturedRequest!.Rights.Count); + Assert.Equal("read", capturedRequest.Rights[0].Action!.Value); + Assert.Equal("sign", capturedRequest.Rights[1].Action!.Value); + Assert.Equal("reject", capturedRequest.Rights[2].Action!.Value); + } + + [Fact] + public async Task DelegateSigneeRights_WithNullAdditionalActions_DelegatesOnlyReadAndSign() + { + // Arrange + DelegationRequest? capturedRequest = null; + var accessManagementClient = new Mock(); + accessManagementClient + .Setup(x => x.DelegateRights(It.IsAny(), It.IsAny())) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new DelegationResponse()); + var logger = new Mock>(); + var service = new SigningDelegationService(accessManagementClient.Object, logger.Object); + var taskId = "taskId"; + Guid instanceGuid = Guid.NewGuid(); + var instanceId = "instanceOwnerPartyId" + "/" + instanceGuid; + Guid instanceOwnerPartyUuid = Guid.NewGuid(); + var appIdentifier = new AppIdentifier("testOrg", "testApp"); + var signeeContexts = new List() + { + new() + { + TaskId = taskId, + SigneeState = new SigneeState() { IsAccessDelegated = false }, + Signee = _signee, + AdditionalActionsToDelegate = null, + }, + }; + var ct = new CancellationToken(); + + // Act + (_, bool success) = await service.DelegateSigneeRights( + taskId, + instanceId, + instanceOwnerPartyUuid, + appIdentifier, + signeeContexts, + ct + ); + + // Assert + Assert.True(success); + Assert.NotNull(capturedRequest); + Assert.Equal(2, capturedRequest!.Rights.Count); + Assert.Equal("read", capturedRequest.Rights[0].Action!.Value); + Assert.Equal("sign", capturedRequest.Rights[1].Action!.Value); + } + + [Fact] + public async Task RevokeSigneeRights_WithAdditionalActions_RevokesAllRights() + { + // Arrange + DelegationRequest? capturedRequest = null; + var accessManagementClient = new Mock(); + accessManagementClient + .Setup(x => x.RevokeRights(It.IsAny(), It.IsAny())) + .Callback((req, _) => capturedRequest = req) + .ReturnsAsync(new DelegationResponse()); + var logger = new Mock>(); + var service = new SigningDelegationService(accessManagementClient.Object, logger.Object); + var taskId = "taskId"; + Guid instanceGuid = Guid.NewGuid(); + var instanceId = "instanceOwnerPartyId" + "/" + instanceGuid; + Guid instanceOwnerPartyUuid = Guid.NewGuid(); + var appIdentifier = new AppIdentifier("testOrg", "testApp"); + var signeeContexts = new List() + { + new() + { + TaskId = taskId, + SigneeState = new SigneeState() { IsAccessDelegated = true }, + Signee = _signee, + AdditionalActionsToDelegate = ["reject"], + }, + }; + var ct = new CancellationToken(); + + // Act + (signeeContexts, var success) = await service.RevokeSigneeRights( + taskId, + instanceId, + instanceOwnerPartyUuid, + appIdentifier, + signeeContexts, + ct + ); + + // Assert + Assert.True(success); + Assert.False(signeeContexts[0].SigneeState.IsAccessDelegated); + Assert.NotNull(capturedRequest); + Assert.Equal(3, capturedRequest!.Rights.Count); + Assert.Equal("read", capturedRequest.Rights[0].Action!.Value); + Assert.Equal("sign", capturedRequest.Rights[1].Action!.Value); + Assert.Equal("reject", capturedRequest.Rights[2].Action!.Value); + } } diff --git a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt index ec33a03324..9298d85727 100644 --- a/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt +++ b/test/Altinn.App.Core.Tests/PublicApiTests.PublicApi_ShouldNotChange_Unintentionally.verified.txt @@ -2020,6 +2020,8 @@ namespace Altinn.App.Core.Features.Signing public abstract class ProvidedSignee { protected ProvidedSignee() { } + [System.Text.Json.Serialization.JsonPropertyName("additionalActionsToDelegate")] + public System.Collections.Generic.List? AdditionalActionsToDelegate { get; init; } [System.Text.Json.Serialization.JsonPropertyName("communicationConfig")] public Altinn.App.Core.Features.Signing.CommunicationConfig? CommunicationConfig { get; init; } }