Skip to content

Commit 9b485ef

Browse files
authored
bugfix: ensure to mask SSN numbers (#1822)
bugfix: ensure to mask SSN
1 parent 42ad5bd commit 9b485ef

8 files changed

Lines changed: 351 additions & 20 deletions

File tree

‎src/Altinn.App.Api/Controllers/AuthorizationController.cs‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Globalization;
2+
using Altinn.App.Api.Helpers;
23
using Altinn.App.Core.Configuration;
34
using Altinn.App.Core.Features.Auth;
45
using Altinn.App.Core.Internal.Auth;
@@ -58,7 +59,7 @@ public async Task GetCurrentParty(bool returnPartyObject = false)
5859
{
5960
if (returnPartyObject)
6061
{
61-
return Ok(details.SelectedParty);
62+
return Ok(PartySsnMasking.MaskParty(details.SelectedParty));
6263
}
6364

6465
return Ok(details.SelectedParty.PartyId);
@@ -79,7 +80,7 @@ public async Task GetCurrentParty(bool returnPartyObject = false)
7980

8081
if (returnPartyObject)
8182
{
82-
return Ok(reportee);
83+
return Ok(PartySsnMasking.MaskParty(reportee));
8384
}
8485
return Ok(reportee.PartyId);
8586
}
@@ -88,7 +89,7 @@ public async Task GetCurrentParty(bool returnPartyObject = false)
8889
var details = await org.LoadDetails();
8990
if (returnPartyObject)
9091
{
91-
return Ok(details.Party);
92+
return Ok(PartySsnMasking.MaskParty(details.Party));
9293
}
9394

9495
return Ok(details.Party.PartyId);
@@ -98,7 +99,7 @@ public async Task GetCurrentParty(bool returnPartyObject = false)
9899
var details = await so.LoadDetails();
99100
if (returnPartyObject)
100101
{
101-
return Ok(details.Party);
102+
return Ok(PartySsnMasking.MaskParty(details.Party));
102103
}
103104

104105
return Ok(details.Party.PartyId);
@@ -108,7 +109,7 @@ public async Task GetCurrentParty(bool returnPartyObject = false)
108109
var details = await su.LoadDetails();
109110
if (returnPartyObject)
110111
{
111-
return Ok(details.Party);
112+
return Ok(PartySsnMasking.MaskParty(details.Party));
112113
}
113114

114115
return Ok(details.Party.PartyId);

‎src/Altinn.App.Api/Controllers/PartiesController.cs‎

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Globalization;
2+
using Altinn.App.Api.Helpers;
23
using Altinn.App.Core.Configuration;
34
using Altinn.App.Core.Features.Auth;
45
using Altinn.App.Core.Models.Validation;
@@ -49,25 +50,23 @@ public async Task Get(string org, string app, bool allowedToInsta
4950
case Authenticated.User user:
5051
{
5152
var details = await user.LoadDetails(validateSelectedParty: false);
52-
return allowedToInstantiateFilter ? Ok(details.PartiesAllowedToInstantiate) : Ok(details.Parties);
53+
var parties = allowedToInstantiateFilter ? details.PartiesAllowedToInstantiate : details.Parties;
54+
return Ok(PartySsnMasking.MaskParties(parties));
5355
}
5456
case Authenticated.Org orgInfo:
5557
{
5658
var details = await orgInfo.LoadDetails();
57-
IReadOnlyList<Party> parties = [details.Party];
58-
return Ok(parties);
59+
return Ok(PartySsnMasking.MaskParties([details.Party]));
5960
}
6061
case Authenticated.ServiceOwner serviceOwner:
6162
{
6263
var details = await serviceOwner.LoadDetails();
63-
IReadOnlyList<Party> parties = [details.Party];
64-
return Ok(parties);
64+
return Ok(PartySsnMasking.MaskParties([details.Party]));
6565
}
6666
case Authenticated.SystemUser su:
6767
{
6868
var details = await su.LoadDetails();
69-
IReadOnlyList<Party> parties = [details.Party];
70-
return Ok(parties);
69+
return Ok(PartySsnMasking.MaskParties([details.Party]));
7170
}
7271
default:
7372
throw new Exception($"Unexpected authentication context: {context.GetType().Name}");
@@ -102,7 +101,7 @@ public async Task ValidateInstantiation(string org, string app, [
102101
{
103102
Valid = false,
104103
Message = "The user does not represent the supplied party",
105-
ValidParties = details.PartiesAllowedToInstantiate.ToList(),
104+
ValidParties = PartySsnMasking.MaskParties(details.PartiesAllowedToInstantiate),
106105
}
107106
);
108107
}
@@ -114,7 +113,7 @@ public async Task ValidateInstantiation(string org, string app, [
114113
{
115114
Valid = false,
116115
Message = "The supplied party is not allowed to instantiate the application",
117-
ValidParties = details.PartiesAllowedToInstantiate.ToList(),
116+
ValidParties = PartySsnMasking.MaskParties(details.PartiesAllowedToInstantiate),
118117
}
119118
);
120119
}
@@ -131,7 +130,7 @@ public async Task ValidateInstantiation(string org, string app, [
131130
{
132131
Valid = false,
133132
Message = "The user does not represent the supplied party",
134-
ValidParties = new List<Party> { details.Party },
133+
ValidParties = PartySsnMasking.MaskParties([details.Party]),
135134
}
136135
);
137136
}
@@ -142,7 +141,7 @@ public async Task ValidateInstantiation(string org, string app, [
142141
{
143142
Valid = false,
144143
Message = "The supplied party is not allowed to instantiate the application",
145-
ValidParties = new List<Party> { details.Party },
144+
ValidParties = PartySsnMasking.MaskParties([details.Party]),
146145
}
147146
);
148147
}
@@ -163,7 +162,7 @@ public async Task ValidateInstantiation(string org, string app, [
163162
{
164163
Valid = false,
165164
Message = "The user does not represent the supplied party",
166-
ValidParties = new List<Party> { details.Party },
165+
ValidParties = PartySsnMasking.MaskParties([details.Party]),
167166
}
168167
);
169168
}
@@ -174,7 +173,7 @@ public async Task ValidateInstantiation(string org, string app, [
174173
{
175174
Valid = false,
176175
Message = "The supplied party is not allowed to instantiate the application",
177-
ValidParties = new List<Party> { details.Party },
176+
ValidParties = PartySsnMasking.MaskParties([details.Party]),
178177
}
179178
);
180179
}

‎src/Altinn.App.Api/Controllers/ProfileController.cs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using Altinn.App.Api.Helpers;
12
using Altinn.App.Core.Features.Auth;
23
using Altinn.Platform.Profile.Models;
34
using Microsoft.AspNetCore.Authorization;
@@ -38,7 +39,7 @@ public async Task GetUser()
3839
case Authenticated.User user:
3940
{
4041
var details = await user.LoadDetails(validateSelectedParty: false);
41-
return Ok(details.Profile);
42+
return Ok(PartySsnMasking.MaskUserProfile(details.Profile));
4243
}
4344
default:
4445
return BadRequest($"Unknown authentication context: {context.GetType().Name}");
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
using System.Reflection;
3+
using Altinn.App.Core.Extensions;
4+
using Altinn.Platform.Profile.Models;
5+
using Altinn.Platform.Register.Models;
6+
7+
namespace Altinn.App.Api.Helpers;
8+
9+
///
10+
/// Produces copies of objects with their social security numbers (SSNs) masked,
11+
/// so the full SSN is never leaked in HTTP responses (e.g. "12345678901" becomes "123456*****").
12+
/// The masking rule itself lives in ; this type only
13+
/// applies it across the party graph. The original objects (which may be cached and used server-side)
14+
/// are never modified.
15+
///
16+
internal static class PartySsnMasking
17+
{
18+
// The properties we copy when cloning. Cached once per type so we don't reflect on every call.
19+
private static readonly PropertyInfo[] _partyProperties = CopyableProperties(typeof(Party));
20+
private static readonly PropertyInfo[] _personProperties = CopyableProperties(typeof(Person));
21+
private static readonly PropertyInfo[] _userProfileProperties = CopyableProperties(typeof(UserProfile));
22+
23+
///
24+
/// Returns a copy of with the SSN masked on its nested
25+
/// (including that party's and
26+
/// ). Returns null if is null.
27+
///
28+
[return: NotNullIfNotNull(nameof(profile))]
29+
public static UserProfile? MaskUserProfile(UserProfile? profile)
30+
{
31+
if (profile is null)
32+
{
33+
return null;
34+
}
35+
36+
UserProfile clone = new UserProfile();
37+
CopyProperties(_userProfileProperties, profile, clone);
38+
39+
clone.Party = MaskParty(profile.Party);
40+
41+
return clone;
42+
}
43+
44+
///
45+
/// Returns a new list where every party is a masked copy of the corresponding input party.
46+
///
47+
public static List<Party> MaskParties(IEnumerable<Party> parties)
48+
{
49+
List<Party> maskedParties = new List<Party>();
50+
foreach (Party party in parties)
51+
{
52+
maskedParties.Add(MaskParty(party));
53+
}
54+
55+
return maskedParties;
56+
}
57+
58+
///
59+
/// Returns a copy of with the SSN masked, including the nested
60+
/// and any . Returns null if
61+
/// is null.
62+
///
63+
[return: NotNullIfNotNull(nameof(party))]
64+
public static Party? MaskParty(Party? party)
65+
{
66+
if (party is null)
67+
{
68+
return null;
69+
}
70+
71+
// Copy every field as-is, then transform only the parts that carry an SSN.
72+
Party clone = new Party();
73+
CopyProperties(_partyProperties, party, clone);
74+
75+
clone.SSN = NationalIdentityNumberExtensions.Mask(party.SSN);
76+
clone.Person = MaskPerson(party.Person);
77+
clone.ChildParties = MaskChildParties(party.ChildParties);
78+
79+
return clone;
80+
}
81+
82+
private static List<Party>? MaskChildParties(List<Party>? childParties)
83+
{
84+
if (childParties is null)
85+
{
86+
return null;
87+
}
88+
89+
return MaskParties(childParties);
90+
}
91+
92+
///
93+
/// Returns a copy of with only the SSN masked; all other fields are
94+
/// copied as-is. Returns null if is null.
95+
///
96+
private static Person? MaskPerson(Person? person)
97+
{
98+
if (person is null)
99+
{
100+
return null;
101+
}
102+
103+
Person clone = new Person();
104+
CopyProperties(_personProperties, person, clone);
105+
106+
clone.SSN = NationalIdentityNumberExtensions.Mask(person.SSN);
107+
108+
return clone;
109+
}
110+
111+
///
112+
/// Returns the readable and writable, non-indexer properties of ;
113+
/// these are the ones we can copy when cloning.
114+
///
115+
private static PropertyInfo[] CopyableProperties(Type type)
116+
{
117+
return type.GetProperties()
118+
.Where(property => property.CanRead && property.CanWrite && property.GetIndexParameters().Length == 0)
119+
.ToArray();
120+
}
121+
122+
private static void CopyProperties(PropertyInfo[] properties, object source, object destination)
123+
{
124+
foreach (PropertyInfo property in properties)
125+
{
126+
property.SetValue(destination, property.GetValue(source));
127+
}
128+
}
129+
}

‎src/Altinn.App.Core/Extensions/NationalIdentityNumberExtensions.cs‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ namespace Altinn.App.Core.Extensions;
55

66
internal static class NationalIdentityNumberExtensions
77
{
8+
///
9+
/// Number of leading characters (the DDMMYY birth date) kept visible when masking; the rest is replaced with '*'.
10+
///
11+
private const int VisibleDigits = 6;
12+
813
///
914
/// Returns a string representation of the , prefixed with the URN value
1015
///
@@ -20,4 +25,38 @@ public static string ToUrnFormattedString(this NationalIdentityNumber identityNu
2025
{
2126
return identityNumber is null ? null : $"{AltinnUrns.PersonId}:{identityNumber}";
2227
}
28+
29+
///
30+
/// Returns the national identity number with all but the first characters
31+
/// (the DDMMYY birth date) replaced with '*', e.g. "12345678901" becomes "123456*****".
32+
///
33+
public static string ToMaskedString(this NationalIdentityNumber identityNumber)
34+
{
35+
return Mask(identityNumber.Value) ?? string.Empty;
36+
}
37+
38+
///
39+
///

Masks a national identity number string: keeps the first characters

40+
/// (the DDMMYY birth date) visible and replaces the rest with '*', e.g. "12345678901" becomes "123456*****".

41+
///

This is a defensive helper for not leaking SSNs: it deliberately does NOT validate the input, so a

42+
/// partial or malformed value is still masked rather than returned in the clear. Validating first would
43+
/// risk leaking any value that fails the check. null and empty values are returned unchanged.

44+
///
45+
public static string? Mask(string? nationalIdentityNumber)
46+
{
47+
if (string.IsNullOrEmpty(nationalIdentityNumber))
48+
{
49+
return nationalIdentityNumber;
50+
}
51+
52+
if (nationalIdentityNumber.Length <= VisibleDigits)
53+
{
54+
// Too short to keep any part visible, so mask the whole thing.
55+
return new string('*', nationalIdentityNumber.Length);
56+
}
57+
58+
string visiblePart = nationalIdentityNumber.Substring(0, VisibleDigits);
59+
string maskedPart = new string('*', nationalIdentityNumber.Length - VisibleDigits);
60+
return visiblePart + maskedPart;
61+
}
2362
}

‎test/Altinn.App.Api.Tests/Controllers/ProfileControllerTests.User.verified.txt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@
8686
partyUuid: null,
8787
partyTypeName: 0,
8888
orgNumber: null,
89-
ssn: 01039012345,
89+
ssn: 010390*****,
9090
unitType: null,
9191
name: null,
9292
isDeleted: false,

0 commit comments

Comments
 (0)