Skip to content

Commit 1e674bd

Browse files
elsandoskogstad
andauthored
feat: Add support for external resource references in authorizationAttributes (#801)
## Description This adds support for supplying fully qualified resource references as `authorizationAttribute` values in dialog elements and actions. Before only simple strings were allowed, which was assumed to be a `urn:altinn:subresource` referring a rule in the same policy. Now it is possible to refer to other resources, ie. have one dialog element refer to `urn:altinn:resource:totally-other-resource`. This allows for greater flexibility, and makes it possible to eg. have notification jobs, which may refer other resource registry entry, to match the authorization rules for a dialog element. Implementation chances: - Added logic in `CreateResourceCategory` to let `authorizationAttribute` values referring to `urn:altinn:resource` namespaced override default resource/resourceinstance attributes - Moved (most) "elementread" vs "read" logic from application to infrastructure where it belongs - Handle other namespaced values by utilizing `SplitNsAndValue` and adding parameter for fallback namespace (if supplying simple string) - Changed from `HashSet<AltinnAction>` to List<AltinnAction>` as we are relying on ordering to match XACML responses to the request (needed fixing unrelated to functional change) - Added null cache option to disable caching locally. Timeouts still apply though, which is annoying during debugging/stepping - Added checks so that service owners can only refer to external resources they own (as with `serviceResource`) - Rewrote `CreateDialogDetailsResponse` for clarity Also added more unit tests, and made the existing tests more robust. Also added e2e tests for end users testing external resource references, as well as service owner checks ## Related Issue(s) N/A ## Verification - [x] **Your** code builds clean without any errors or warnings - [x] Manual testing done (required) - [x] Relevant automated test added (if you find this hard, leave it and we'll help out) --------- Co-authored-by: Ole Jørgen Skogstad <skogstad@softis.net>
1 parent d488241 commit 1e674bd

File tree

18 files changed

+392
-79
lines changed

18 files changed

+392
-79
lines changed

src/Digdir.Domain.Dialogporten.Application/Common/Extensions/ServiceCollectionExtensions.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ public static IServiceCollection ReplaceSingleton<TService, TImplementation>(
2424
bool predicate = true)
2525
where TService : class
2626
where TImplementation : class, TService =>
27-
services.Replace<TService, TImplementation>(ServiceLifetime.Scoped, predicate);
27+
services.Replace<TService, TImplementation>(ServiceLifetime.Singleton, predicate);
2828

2929
private static IServiceCollection Replace<TService, TImplementation>(
3030
this IServiceCollection services,
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
using Digdir.Domain.Dialogporten.Application.Common.Authorization;
2+
using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.DialogElements.Queries.Get;
3+
using Digdir.Domain.Dialogporten.Application.Features.V1.EndUser.Dialogs.Queries.Get;
24
using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Elements;
35

46
namespace Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
57

68
public sealed class DialogDetailsAuthorizationResult
79
{
8-
// Each action applies to a resource. This is the main resource, or another resource indicated by a authorization attribute
9-
// eg. "urn:altinn:subresource:some-sub-resource" or "urn:altinn:task:task_1"
10-
public HashSet<AltinnAction> AuthorizedAltinnActions { get; init; } = [];
10+
// Each action applies to a resource. This is the main resource, another subresource indicated by a authorization attribute
11+
// eg. "urn:altinn:subresource:some-sub-resource" or "urn:altinn:task:task_1", or another resource (ie. policy)
12+
// eg. urn:altinn:resource:some-other-resource
13+
public List<AltinnAction> AuthorizedAltinnActions { get; init; } = [];
1114

1215
public bool HasReadAccessToMainResource() =>
1316
AuthorizedAltinnActions.Contains(new(Constants.ReadAction, Constants.MainResource));
1417

15-
public bool HasReadAccessToDialogElement(DialogElement dialogElement)
18+
public bool HasReadAccessToDialogElement(DialogElement dialogElement) =>
19+
HasReadAccessToDialogElement(dialogElement.AuthorizationAttribute);
20+
21+
public bool HasReadAccessToDialogElement(GetDialogDialogElementDto dialogElement) =>
22+
HasReadAccessToDialogElement(dialogElement.AuthorizationAttribute);
23+
24+
private bool HasReadAccessToDialogElement(string? authorizationAttribute)
1625
{
17-
return dialogElement.AuthorizationAttribute is not null
18-
? AuthorizedAltinnActions.Contains(new(Constants.ElementReadAction, dialogElement.AuthorizationAttribute))
19-
: HasReadAccessToMainResource();
26+
return authorizationAttribute is not null
27+
? ( // Dialog elements are authorized by either the elementread or read action, depending on the authorization attribute type
28+
// The infrastructure will ensure that the correct action is used, so here we just check for either
29+
AuthorizedAltinnActions.Contains(new(Constants.ElementReadAction, authorizationAttribute))
30+
|| AuthorizedAltinnActions.Contains(new(Constants.ReadAction, authorizationAttribute))
31+
) : HasReadAccessToMainResource();
2032
}
2133
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/DialogElements/Queries/Get/GetDialogElementQuery.cs

+8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
using System.Linq.Expressions;
22
using AutoMapper;
3+
using Digdir.Domain.Dialogporten.Application.Common.Authorization;
34
using Digdir.Domain.Dialogporten.Application.Common.ReturnTypes;
45
using Digdir.Domain.Dialogporten.Application.Externals;
56
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
@@ -80,6 +81,13 @@ public async Task<GetDialogElementResult> Handle(GetDialogElementQuery request,
8081
var dto = _mapper.Map<GetDialogElementDto>(element);
8182
dto.IsAuthorized = authorizationResult.HasReadAccessToDialogElement(element);
8283

84+
if (dto.IsAuthorized) return dto;
85+
86+
foreach (var url in dto.Urls)
87+
{
88+
url.Url = Constants.UnauthorizedUri;
89+
}
90+
8391
return dto;
8492
}
8593
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/EndUser/Dialogs/Queries/Get/GetDialogQuery.cs

+5-8
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,12 @@ private static void DecorateWithAuthorization(GetDialogDto dto,
157157
}
158158
}
159159

160-
// Simple "read" on the main resource will give access to a dialog element, unless an authorization attribute is set,
161-
// in which case an "elementread" action is required
162-
var elements = dto.Elements.Where(dialogElement =>
163-
(dialogElement.AuthorizationAttribute is null && action == Constants.ReadAction) ||
164-
(dialogElement.AuthorizationAttribute is not null && action == Constants.ElementReadAction));
165-
166-
foreach (var dialogElement in elements)
160+
foreach (var element in dto.Elements)
167161
{
168-
dialogElement.IsAuthorized = true;
162+
if (authorizationResult.HasReadAccessToDialogElement(element))
163+
{
164+
element.IsAuthorized = true;
165+
}
169166
}
170167
}
171168
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Create/CreateDialogCommand.cs

+27-2
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,12 @@ public CreateDialogCommandHandler(
5050

5151
public async Task<CreateDialogResult> Handle(CreateDialogCommand request, CancellationToken cancellationToken)
5252
{
53-
if (!await _userResourceRegistry.CurrentUserIsOwner(request.ServiceResource, cancellationToken))
53+
foreach (var serviceResourceReference in GetServiceResourceReferences(request))
5454
{
55-
return new Forbidden($"Not owner of {request.ServiceResource}.");
55+
if (!await _userResourceRegistry.CurrentUserIsOwner(serviceResourceReference, cancellationToken))
56+
{
57+
return new Forbidden($"Not allowed to reference {serviceResourceReference}.");
58+
}
5659
}
5760

5861
var serviceResourceType = await _userResourceRegistry.GetResourceType(request.ServiceResource, cancellationToken);
@@ -105,4 +108,26 @@ public async Task<CreateDialogResult> Handle(CreateDialogCommand request, Cancel
105108
domainError => domainError,
106109
concurrencyError => throw new UnreachableException("Should never get a concurrency error when creating a new dialog"));
107110
}
111+
112+
private static List<string> GetServiceResourceReferences(CreateDialogDto request)
113+
{
114+
var serviceResourceReferences = new List<string> { request.ServiceResource };
115+
116+
static bool IsExternalResource(string? resource)
117+
{
118+
return resource is not null && resource.StartsWith(Constants.ServiceResourcePrefix, StringComparison.OrdinalIgnoreCase);
119+
}
120+
121+
serviceResourceReferences.AddRange(request.ApiActions
122+
.Where(action => IsExternalResource(action.AuthorizationAttribute))
123+
.Select(action => action.AuthorizationAttribute!));
124+
serviceResourceReferences.AddRange(request.GuiActions
125+
.Where(action => IsExternalResource(action.AuthorizationAttribute))
126+
.Select(action => action.AuthorizationAttribute!));
127+
serviceResourceReferences.AddRange(request.Elements
128+
.Where(element => IsExternalResource(element.AuthorizationAttribute))
129+
.Select(element => element.AuthorizationAttribute!));
130+
131+
return serviceResourceReferences.Distinct().ToList();
132+
}
108133
}

src/Digdir.Domain.Dialogporten.Application/Features/V1/ServiceOwner/Dialogs/Commands/Update/UpdateDialogCommand.cs

+30
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ public async Task<UpdateDialogResult> Handle(UpdateDialogCommand request, Cancel
7676
return new EntityNotFound<DialogEntity>(request.Id);
7777
}
7878

79+
foreach (var serviceResourceReference in GetServiceResourceReferences(request.Dto))
80+
{
81+
if (!await _userResourceRegistry.CurrentUserIsOwner(serviceResourceReference, cancellationToken))
82+
{
83+
return new Forbidden($"Not allowed to reference {serviceResourceReference}.");
84+
}
85+
}
86+
7987
if (!_userResourceRegistry.UserCanModifyResourceType(dialog.ServiceResourceType))
8088
{
8189
return new Forbidden($"User cannot modify resource type {dialog.ServiceResourceType}.");
@@ -150,6 +158,28 @@ await dialog.Elements
150158
concurrencyError => concurrencyError);
151159
}
152160

161+
private static List<string> GetServiceResourceReferences(UpdateDialogDto request)
162+
{
163+
var serviceResourceReferences = new List<string>();
164+
165+
static bool IsExternalResource(string? resource)
166+
{
167+
return resource is not null && resource.StartsWith(Domain.Common.Constants.ServiceResourcePrefix, StringComparison.OrdinalIgnoreCase);
168+
}
169+
170+
serviceResourceReferences.AddRange(request.ApiActions
171+
.Where(action => IsExternalResource(action.AuthorizationAttribute))
172+
.Select(action => action.AuthorizationAttribute!));
173+
serviceResourceReferences.AddRange(request.GuiActions
174+
.Where(action => IsExternalResource(action.AuthorizationAttribute))
175+
.Select(action => action.AuthorizationAttribute!));
176+
serviceResourceReferences.AddRange(request.Elements
177+
.Where(element => IsExternalResource(element.AuthorizationAttribute))
178+
.Select(element => element.AuthorizationAttribute!));
179+
180+
return serviceResourceReferences.Distinct().ToList();
181+
}
182+
153183
private void ValidateTimeFields(DialogEntity dialog)
154184
{
155185
const string errorMessage = "Must be in future or current value.";

src/Digdir.Domain.Dialogporten.Application/LocalDevelopmentSettings.cs

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public sealed class LocalDevelopmentSettings
1010
public bool UseLocalDevelopmentAltinnAuthorization { get; init; } = true;
1111
public bool UseLocalDevelopmentCloudEventBus { get; init; } = true;
1212
public bool DisableShortCircuitOutboxDispatcher { get; init; } = true;
13+
public bool DisableCache { get; init; } = true;
1314
public bool DisableAuth { get; init; } = true;
1415
public bool UseLocalDevelopmentNameRegister { get; set; } = true;
1516
public bool UseLocalDevelopmentOrganizationRegister { get; set; } = true;

src/Digdir.Domain.Dialogporten.GraphQL/appsettings.Development.json

+1
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"UseLocalDevelopmentAltinnAuthorization": true,
6868
"UseLocalDevelopmentCloudEventBus": true,
6969
"DisableShortCircuitOutboxDispatcher": true,
70+
"DisableCache": false,
7071
"DisableAuth": true
7172
}
7273
}

src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DecisionRequestHelper.cs

+62-20
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@ internal static class DecisionRequestHelper
3030

3131
public static XacmlJsonRequestRoot CreateDialogDetailsRequest(DialogDetailsAuthorizationRequest request)
3232
{
33+
var sortedActions = request.AltinnActions.SortForXacml();
34+
3335
var accessSubject = CreateAccessSubjectCategory(request.Claims);
34-
var actions = CreateActionCategories(request.AltinnActions, out var actionIdByName);
35-
var resources = CreateResourceCategories(request.ServiceResource, request.DialogId, request.Party, request.AltinnActions, out var resourceIdByName);
36+
var actions = CreateActionCategories(sortedActions, out var actionIdByName);
37+
var resources = CreateResourceCategories(request.ServiceResource, request.DialogId, request.Party, sortedActions, out var resourceIdByName);
3638

37-
var multiRequests = CreateMultiRequests(request.AltinnActions, actionIdByName, resourceIdByName);
39+
var multiRequests = CreateMultiRequests(sortedActions, actionIdByName, resourceIdByName);
3840

3941
var xacmlJsonRequest = new XacmlJsonRequest
4042
{
@@ -47,15 +49,29 @@ public static XacmlJsonRequestRoot CreateDialogDetailsRequest(DialogDetailsAutho
4749
return new XacmlJsonRequestRoot { Request = xacmlJsonRequest };
4850
}
4951

50-
public static DialogDetailsAuthorizationResult CreateDialogDetailsResponse(HashSet<AltinnAction> altinnActions, XacmlJsonResponse? xamlJsonResponse) =>
51-
new()
52+
public static DialogDetailsAuthorizationResult CreateDialogDetailsResponse(List<AltinnAction> altinnActions, XacmlJsonResponse? xamlJsonResponse)
53+
{
54+
var authorizedAltinnActions = new List<AltinnAction>();
55+
56+
var sortedAltinnActions = altinnActions.SortForXacml();
57+
var xacmlJsonResults = xamlJsonResponse?.Response ?? new List<XacmlJsonResult>();
58+
59+
int count = Math.Min(sortedAltinnActions.Count, xacmlJsonResults.Count);
60+
for (int i = 0; i < count; i++)
5261
{
53-
AuthorizedAltinnActions = altinnActions
54-
.Zip(xamlJsonResponse?.Response ?? Enumerable.Empty<XacmlJsonResult>(), (action, response) => (action, response))
55-
.Where(x => x.response.Decision == PermitResponse)
56-
.Select(x => x.action)
57-
.ToHashSet()
62+
var action = sortedAltinnActions[i];
63+
var response = xacmlJsonResults[i];
64+
if (response.Decision == PermitResponse)
65+
{
66+
authorizedAltinnActions.Add(action);
67+
}
68+
}
69+
70+
return new DialogDetailsAuthorizationResult
71+
{
72+
AuthorizedAltinnActions = authorizedAltinnActions
5873
};
74+
}
5975

6076
private static List<XacmlJsonCategory> CreateAccessSubjectCategory(IEnumerable<Claim> claims)
6177
{
@@ -92,7 +108,7 @@ private static string GetSystemUserId(Claim claim)
92108
}
93109

94110
private static List<XacmlJsonCategory> CreateActionCategories(
95-
HashSet<AltinnAction> altinnActions, out Dictionary<string, string> actionIdByName)
111+
List<AltinnAction> altinnActions, out Dictionary<string, string> actionIdByName)
96112
{
97113
actionIdByName = altinnActions
98114
.Select(x => x.Name)
@@ -115,7 +131,7 @@ private static List<XacmlJsonCategory> CreateResourceCategories(
115131
string serviceResource,
116132
Guid dialogId,
117133
string party,
118-
HashSet<AltinnAction> altinnActions, out Dictionary<string, string> resourceIdByName)
134+
List<AltinnAction> altinnActions, out Dictionary<string, string> resourceIdByName)
119135
{
120136
resourceIdByName = altinnActions
121137
.Select(x => x.AuthorizationAttribute)
@@ -133,9 +149,9 @@ private static List<XacmlJsonCategory> CreateResourceCategories(
133149
.ToList();
134150
}
135151

136-
private static XacmlJsonCategory CreateResourceCategory(string id, string serviceResource, Guid? dialogId, XacmlJsonAttribute? partyAttribute, string? subResource = null)
152+
private static XacmlJsonCategory CreateResourceCategory(string id, string serviceResource, Guid? dialogId, XacmlJsonAttribute? partyAttribute, string? authorizationAttribute = null)
137153
{
138-
var (ns, value, org) = SplitNsAndValue(serviceResource);
154+
var (ns, value, org) = SplitNamespaceAndValue(serviceResource);
139155
var attributes = new List<XacmlJsonAttribute>
140156
{
141157
new() { AttributeId = ns, Value = value }
@@ -178,9 +194,19 @@ private static XacmlJsonCategory CreateResourceCategory(string id, string servic
178194
}
179195
}
180196

181-
if (subResource is not null)
197+
if (authorizationAttribute is not null)
182198
{
183-
attributes.Add(new XacmlJsonAttribute { AttributeId = AttributeIdSubResource, Value = subResource });
199+
var resourceAttributesFromAuthorizationAttribute = GetResourceAttributesForAuthorizationAttribute(authorizationAttribute);
200+
201+
// If we get either urn:altinn:app/urn:altinn:org or urn:altinn:resource attributes, this should
202+
// be considered overrides that should be used instead of the default resource attributes.
203+
if (resourceAttributesFromAuthorizationAttribute.Any(x => x.AttributeId is AttributeIdApp or AttributeIdOrg or AttributeIdResource))
204+
{
205+
attributes.RemoveAll(x =>
206+
x.AttributeId is AttributeIdResource or AttributeIdResourceInstance or AttributeIdApp or AttributeIdOrg or AttributeIdAppInstance);
207+
}
208+
209+
attributes.AddRange(resourceAttributesFromAuthorizationAttribute);
184210
}
185211

186212
return new XacmlJsonCategory
@@ -190,14 +216,27 @@ private static XacmlJsonCategory CreateResourceCategory(string id, string servic
190216
};
191217
}
192218

193-
private static (string, string, string?) SplitNsAndValue(string serviceResource)
219+
private static List<XacmlJsonAttribute> GetResourceAttributesForAuthorizationAttribute(string subResource)
220+
{
221+
var result = new List<XacmlJsonAttribute>();
222+
var (ns, value, org) = SplitNamespaceAndValue(subResource, AttributeIdSubResource);
223+
result.Add(new XacmlJsonAttribute { AttributeId = ns, Value = value });
224+
if (org is not null)
225+
{
226+
result.Add(new XacmlJsonAttribute { AttributeId = AttributeIdOrg, Value = org });
227+
}
228+
229+
return result;
230+
}
231+
232+
private static (string, string, string?) SplitNamespaceAndValue(string serviceResource, string defaultNamespace = AttributeIdResource)
194233
{
195234
var lastColonIndex = serviceResource.LastIndexOf(':');
196235
if (lastColonIndex == -1 || lastColonIndex == serviceResource.Length - 1)
197236
{
198237
// If we don't recognize the format, we just return the whole string as the value and assume
199238
// that the caller wants to refer a resource in the Resource Registry namespace.
200-
return (AttributeIdResource, serviceResource, null);
239+
return (defaultNamespace, serviceResource, null);
201240
}
202241

203242
var ns = serviceResource[..lastColonIndex];
@@ -232,7 +271,7 @@ private static (string, string, string?) SplitNsAndValue(string serviceResource)
232271
}
233272

234273
private static XacmlJsonMultiRequests CreateMultiRequests(
235-
HashSet<AltinnAction> altinnActions,
274+
List<AltinnAction> altinnActions,
236275
Dictionary<string, string> actionIdByName,
237276
Dictionary<string, string> resourceIdByName)
238277
{
@@ -256,6 +295,9 @@ private static XacmlJsonMultiRequests CreateMultiRequests(
256295
return multiRequests;
257296
}
258297

298+
private static List<AltinnAction> SortForXacml(this List<AltinnAction> altinnActions) =>
299+
altinnActions.OrderBy(x => x.Name).ThenBy(x => x.AuthorizationAttribute).ToList();
300+
259301
public static class NonScalable
260302
{
261303
// This contains the helpers for the preliminary implementation which doesn't scale, and should only be used in very low volume situations
@@ -265,7 +307,7 @@ public static class NonScalable
265307

266308
public static XacmlJsonRequestRoot CreateDialogSearchRequest(DialogSearchAuthorizationRequest request)
267309
{
268-
var requestActions = new HashSet<AltinnAction>
310+
var requestActions = new List<AltinnAction>
269311
{
270312
new (Constants.ReadAction, Constants.MainResource)
271313
};

src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/DialogDetailsAuthorizationRequest.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public sealed class DialogDetailsAuthorizationRequest
1515

1616
// Each action applies to a resource. This is the main resource, or another resource indicated by a authorization attribute
1717
// eg. "urn:altinn:subresource:some-sub-resource" or "urn:altinn:task:task_1"
18-
public required HashSet<AltinnAction> AltinnActions { get; init; }
18+
public required List<AltinnAction> AltinnActions { get; init; }
1919
}
2020

2121
public static class DialogDetailsAuthorizationRequestExtensions

0 commit comments

Comments
 (0)