Skip to content

Commit 55159b7

Browse files
authored
fix: Simplify subject attribute matching (#1348)
## Description This simplifies the subject attribute mapping to only use a single attribute, as multiple subject attributes are explicitly disallowed by the PDP (eg. for system users). In fact, for external use, only a single subject attribute is ever required (eg. sending the authlvl attribute is not supported, as it is the PEPs responsibility to enforce any obligations returned from the PDP). In addition, support for allowing pure Maskinporten tokens (using only consumer-claims) has been removed, as this is not officially supported in the Altinn Authorization model; only userid/pid/systemuserid will cause the PDP to resolve roles/access packages in order to match policy rules, so the only way sending organization numbers as subject claims is if the policy itself contains hard coded organization numbers, which is discouraged (should access lists for that). Note that urn:altinn:org (ie serviceowner acronym claim types) are left out, as authenticated service owners should not use the end user APIs (this would potentially leak information that we only want to make available to the end users). ## Related Issue(s) See previous PR (#1340) and [slack thread](https://digdir.slack.com/archives/C079ZFUSFMW/p1729772275391209). ## 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) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced error handling with the introduction of an `UnreachableException` for invalid user types. - Streamlined attribute selection logic for improved performance. - **Bug Fixes** - Updated claims structure in tests to reflect recent changes, ensuring accurate validation. - **Tests** - Added a new test for exception handling. - Renamed and consolidated existing tests for clarity and maintainability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 169b043 commit 55159b7

File tree

2 files changed

+81
-82
lines changed

2 files changed

+81
-82
lines changed

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

+52-38
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
using Altinn.Authorization.ABAC.Xacml.JsonProfile;
1+
using System.Diagnostics;
2+
using Altinn.Authorization.ABAC.Xacml.JsonProfile;
23
using System.Security.Claims;
4+
35
using Digdir.Domain.Dialogporten.Application.Common.Extensions;
46
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
57
using Digdir.Domain.Dialogporten.Domain.Parties;
@@ -10,20 +12,29 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization;
1012
internal static class DecisionRequestHelper
1113
{
1214
private const string SubjectId = "s1";
13-
private const string AltinnUrnNsPrefix = "urn:altinn:";
15+
1416
private const string PidClaimType = "pid";
15-
private const string ConsumerClaimType = "consumer";
17+
private const string UserIdClaimType = "urn:altinn:userid";
18+
private const string RarAuthorizationDetailsClaimType = "authorization_details";
19+
1620
private const string AttributeIdAction = "urn:oasis:names:tc:xacml:1.0:action:action-id";
1721
private const string AttributeIdResource = "urn:altinn:resource";
1822
private const string AttributeIdResourceInstance = "urn:altinn:resourceinstance";
19-
private const string AltinnAutorizationDetailsClaim = "authorization_details";
23+
private const string AttributeIdSubResource = "urn:altinn:subresource";
24+
2025
private const string AttributeIdOrg = "urn:altinn:org";
2126
private const string AttributeIdApp = "urn:altinn:app";
22-
private const string AttributeIdSystemUser = "urn:altinn:systemuser:uuid";
27+
private const string AttributeIdAppInstance = "urn:altinn:instance-id";
28+
2329
private const string AttributeIdUserId = "urn:altinn:userid";
30+
private const string AttributeIdPerson = "urn:altinn:person:identifier-no";
31+
private const string AttributeIdSystemUser = "urn:altinn:systemuser:uuid";
32+
33+
// The order of these attribute types is important as we want to prioritize the most specific claim types.
34+
private static readonly List<string> PrioritizedClaimTypes = [AttributeIdUserId, AttributeIdPerson, AttributeIdSystemUser];
35+
2436
private const string ReservedResourcePrefixForApps = "app_";
25-
private const string AttributeIdAppInstance = "urn:altinn:instance-id";
26-
private const string AttributeIdSubResource = "urn:altinn:subresource";
37+
2738
private const string PermitResponse = "Permit";
2839

2940
public static XacmlJsonRequestRoot CreateDialogDetailsRequest(DialogDetailsAuthorizationRequest request)
@@ -71,39 +82,42 @@ public static DialogDetailsAuthorizationResult CreateDialogDetailsResponse(List<
7182
};
7283
}
7384

74-
private static List<XacmlJsonCategory> CreateAccessSubjectCategory(IEnumerable<Claim> claims)
75-
{
76-
var attributes = claims
77-
.Select(x => x switch
85+
private static List<XacmlJsonCategory> CreateAccessSubjectCategory(IEnumerable<Claim> claims) =>
86+
// The PDP expects for the most part only a single subject attribute, and will even fail the request
87+
// for some types (e.g. the urn:altinn:systemuser:uuid) if there are multiple subject attributes (for
88+
// security reasons). We therefore need to filter out the relevant attributes and only include those,
89+
// which in essence is the pid and the system user uuid. In addition, we also utilize urn:altinn:userid
90+
// if present instead of the pid as a simple optimization as this offloads the PDP from having to look up
91+
// the user id from the pid. See PrioritizedClaimTypes for the order of prioritization.
92+
claims.Select(claim => claim.Type switch
93+
{
94+
UserIdClaimType => new XacmlJsonCategory
7895
{
79-
{ Type: PidClaimType } => new XacmlJsonAttribute { AttributeId = NorwegianPersonIdentifier.Prefix, Value = x.Value },
80-
{ Type: var type } when type.StartsWith(AltinnUrnNsPrefix, StringComparison.Ordinal) => new() { AttributeId = type, Value = x.Value },
81-
{ Type: ConsumerClaimType } when x.TryGetOrganizationNumber(out var organizationNumber) => new() { AttributeId = NorwegianOrganizationIdentifier.Prefix, Value = organizationNumber },
82-
{ Type: AltinnAutorizationDetailsClaim } => new() { AttributeId = AttributeIdSystemUser, Value = GetSystemUserId(x) },
83-
_ => null
84-
})
85-
.Where(x => x is not null)
86-
.Cast<XacmlJsonAttribute>()
87-
.ToList();
88-
89-
// If we're authorizing a person (i.e. ID-porten token), we are not interested in the consumer-claim (organization number)
90-
// as that is not relevant for the authorization decision (it's just the organization owning the OAuth client).
91-
// The same goes if urn:altinn:userid is present, which might be present if using a legacy enterprise user token
92-
if (attributes.Any(x => x.AttributeId == NorwegianPersonIdentifier.Prefix) ||
93-
attributes.Any(x => x.AttributeId == AttributeIdUserId))
96+
Id = SubjectId,
97+
Attribute = [new() { AttributeId = AttributeIdUserId, Value = claim.Value }]
98+
},
99+
PidClaimType => new XacmlJsonCategory
100+
{
101+
Id = SubjectId,
102+
Attribute = [new() { AttributeId = AttributeIdPerson, Value = claim.Value }]
103+
},
104+
RarAuthorizationDetailsClaimType when new ClaimsPrincipal(new ClaimsIdentity(new[] { claim })).TryGetSystemUserId(out var systemUserId) => new XacmlJsonCategory
105+
{
106+
Id = SubjectId,
107+
Attribute =
108+
[
109+
new XacmlJsonAttribute { AttributeId = AttributeIdSystemUser, Value = systemUserId }
110+
]
111+
},
112+
_ => null
113+
})
114+
.Where(x => x != null)
115+
.MinBy(x => PrioritizedClaimTypes.IndexOf(x!.Attribute[0].AttributeId)) switch
94116
{
95-
attributes.RemoveAll(x => x.AttributeId == NorwegianOrganizationIdentifier.Prefix);
96-
}
97-
98-
return [new() { Id = SubjectId, Attribute = attributes }];
99-
}
100-
101-
private static string GetSystemUserId(Claim claim)
102-
{
103-
var claimsPrincipal = new ClaimsPrincipal(new ClaimsIdentity([claim]));
104-
claimsPrincipal.TryGetSystemUserId(out var systemUserId);
105-
return systemUserId!;
106-
}
117+
{ } validCategory => new List<XacmlJsonCategory> { validCategory },
118+
_ => throw new UnreachableException(
119+
"Unable to find a suitable subject attribute for the authorization request. Having a known user type should be enforced during authentication (see UserTypeValidationMiddleware)."),
120+
};
107121

108122
private static List<XacmlJsonCategory> CreateActionCategories(
109123
List<AltinnAction> altinnActions, out Dictionary<string, string> actionIdByName)

tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/DecisionRequestHelperTests.cs

+29-44
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Security.Claims;
1+
using System.Diagnostics;
2+
using System.Security.Claims;
23
using Altinn.Authorization.ABAC.Xacml.JsonProfile;
34
using Digdir.Domain.Dialogporten.Application.Common.Authorization;
45
using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization;
@@ -10,8 +11,6 @@ namespace Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests;
1011

1112
public class DecisionRequestHelperTests
1213
{
13-
private const string ConsumerClaimValue = /*lang=json,strict*/ "{\"authority\":\"iso6523-actorid-upis\",\"ID\":\"0192:991825827\"}";
14-
1514
private const string AuthorizationDetailsClaimValue = /*lang=json,strict*/"[{\"type\":\"urn:altinn:systemuser\",\"systemuser_id\":[\"unique_systemuser_id\"]}]";
1615

1716
[Fact]
@@ -20,10 +19,9 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequest()
2019
// Arrange
2120
var request = CreateDialogDetailsAuthorizationRequest(
2221
GetAsClaims(
23-
("pid", "12345678901"),
24-
2522
// This should not be copied as subject claim since there's a "pid"-claim
26-
("consumer", ConsumerClaimValue)
23+
("authorization_details", AuthorizationDetailsClaimValue),
24+
("pid", "12345678901")
2725
),
2826
$"{NorwegianOrganizationIdentifier.PrefixWithSeparator}713330310");
2927
var dialogId = request.DialogId;
@@ -42,9 +40,8 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequest()
4240
// Check AccessSubject attributes
4341
var accessSubject = result.Request.AccessSubject.First();
4442
Assert.Equal("s1", accessSubject.Id);
45-
Assert.Contains(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:foo" && a.Value == "bar");
4643
Assert.Contains(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:person:identifier-no" && a.Value == "12345678901");
47-
Assert.DoesNotContain(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:organization:identifier-no");
44+
Assert.Single(accessSubject.Attribute);
4845

4946
// Check Action attributes.
5047
var actionIdsByName = new Dictionary<string, string>();
@@ -79,15 +76,14 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequest()
7976
}
8077

8178
[Fact]
82-
public void CreateDialogDetailsRequestShouldReturnCorrectRequestForLegacyEnterpriseUsers()
79+
public void CreateDialogDetailsRequestShouldReturnCorrectRequestForExchangedTokens()
8380
{
8481
// Arrange
8582
var request = CreateDialogDetailsAuthorizationRequest(
8683
GetAsClaims(
87-
("urn:altinn:userid", "5678901"),
88-
8984
// This should not be copied as subject claim since there's a "urn:altinn:user-id"-claim
90-
("consumer", ConsumerClaimValue)
85+
("pid", "12345678901"),
86+
("urn:altinn:userid", "5678901")
9187
),
9288
$"{NorwegianOrganizationIdentifier.PrefixWithSeparator}713330310");
9389

@@ -98,7 +94,7 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequestForLegacyEnterpr
9894
var accessSubject = result.Request.AccessSubject.First();
9995
Assert.Equal("s1", accessSubject.Id);
10096
Assert.Contains(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:userid" && a.Value == "5678901");
101-
Assert.DoesNotContain(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:organization:identifier-no");
97+
Assert.Single(accessSubject.Attribute);
10298
}
10399

104100
[Fact]
@@ -151,33 +147,8 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequestForSystemUser()
151147

152148
var accessSubject = result.Request.AccessSubject.First();
153149
Assert.Equal("s1", accessSubject.Id);
154-
Assert.Contains(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:foo" && a.Value == "bar");
155150
Assert.Contains(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:systemuser:uuid" && a.Value == "unique_systemuser_id");
156-
}
157-
158-
[Fact]
159-
public void CreateDialogDetailsRequestShouldReturnCorrectRequestForConsumerOrgAndPersonParty()
160-
{
161-
// Arrange
162-
var request = CreateDialogDetailsAuthorizationRequest(
163-
GetAsClaims(
164-
// Should be copied as subject claim since there's not a "pid"-claim
165-
("consumer", ConsumerClaimValue)
166-
),
167-
$"{NorwegianPersonIdentifier.PrefixWithSeparator}16073422888");
168-
169-
// Act
170-
var result = DecisionRequestHelper.CreateDialogDetailsRequest(request);
171-
172-
// Assert
173-
// Check that we have the organizationnumber
174-
var accessSubject = result.Request.AccessSubject.First();
175-
Assert.Contains(accessSubject.Attribute, a => a.AttributeId == "urn:altinn:organization:identifier-no" && a.Value == "991825827");
176-
177-
// Check that we have the ssn attribute as resource owner
178-
var resource1 = result.Request.Resource.FirstOrDefault(r => r.Id == "r1");
179-
Assert.NotNull(resource1);
180-
Assert.Contains(resource1.Attribute, a => a.AttributeId == "urn:altinn:person:identifier-no" && a.Value == "16073422888");
151+
Assert.Single(accessSubject.Attribute);
181152
}
182153

183154
[Fact]
@@ -186,7 +157,7 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequestForOverriddenRes
186157
// Arrange
187158
var request = CreateDialogDetailsAuthorizationRequest(
188159
GetAsClaims(
189-
("consumer", ConsumerClaimValue)
160+
("pid", "12345678901")
190161
),
191162
$"{NorwegianPersonIdentifier.PrefixWithSeparator}16073422888");
192163

@@ -211,7 +182,7 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequestForOverriddenRes
211182
// Arrange
212183
var request = CreateDialogDetailsAuthorizationRequest(
213184
GetAsClaims(
214-
("consumer", ConsumerClaimValue)
185+
("pid", "12345678901")
215186
),
216187
$"{NorwegianPersonIdentifier.PrefixWithSeparator}16073422888");
217188

@@ -238,7 +209,7 @@ public void CreateDialogDetailsRequestShouldReturnCorrectRequestForFullyQualifie
238209
// Arrange
239210
var request = CreateDialogDetailsAuthorizationRequest(
240211
GetAsClaims(
241-
("consumer", ConsumerClaimValue)
212+
("pid", "12345678901")
242213
),
243214
$"{NorwegianPersonIdentifier.PrefixWithSeparator}16073422888");
244215

@@ -262,8 +233,7 @@ public void CreateDialogDetailsResponseShouldReturnCorrectResponse()
262233
// Arrange
263234
var request = CreateDialogDetailsAuthorizationRequest(
264235
GetAsClaims(
265-
// Should be copied as subject claim since there's not a "pid"-claim
266-
("consumer", ConsumerClaimValue)
236+
("pid", "12345678901")
267237
),
268238
$"{NorwegianPersonIdentifier.PrefixWithSeparator}12345678901");
269239

@@ -287,6 +257,21 @@ public void CreateDialogDetailsResponseShouldReturnCorrectResponse()
287257
Assert.DoesNotContain(new AltinnAction("failaction", Constants.MainResource), response.AuthorizedAltinnActions);
288258
}
289259

260+
[Fact]
261+
public void CreateDetailsRequestShouldThrowUnreachableExceptionIfNoValidUserType()
262+
{
263+
// Arrange
264+
var request = CreateDialogDetailsAuthorizationRequest(
265+
GetAsClaims(
266+
("consumer", "somevalue")
267+
),
268+
$"{NorwegianOrganizationIdentifier.PrefixWithSeparator}713330310");
269+
270+
// Act / assert
271+
Assert.Throws<UnreachableException>(() => DecisionRequestHelper.CreateDialogDetailsRequest(request));
272+
}
273+
274+
290275
private static DialogDetailsAuthorizationRequest CreateDialogDetailsAuthorizationRequest(List<Claim> principalClaims, string party, bool isApp = false)
291276
{
292277
var allClaims = new List<Claim>

0 commit comments

Comments
 (0)