Skip to content

Commit ead92c3

Browse files
authored
feat(users): Add batch user retrieval with target-aware authorization (#113)
* Add Users GetByKeys Endpoint * Add TargetUserKeys to AccessContext and Add Tests * Add Tests and Docs Content
1 parent 13e22f3 commit ead92c3

30 files changed

Lines changed: 1621 additions & 108 deletions

File tree

‎apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Policy/SandboxUAuthAccessPolicy.cs‎

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,7 @@ public sealed class SandboxUAuthAccessPolicy(IDbContextFactory<LabDbContext> dbF
1414
public bool AppliesTo(AccessContext context)
1515
=> IsProtectedAction(context.Action);
1616

17-
public async ValueTask<AccessDecision> DecideAsync(
18-
AccessContext context,
19-
CancellationToken cancellationToken = default)
17+
public async ValueTask<AccessDecision> DecideAsync(AccessContext context, CancellationToken cancellationToken = default)
2018
{
2119
cancellationToken.ThrowIfCancellationRequested();
2220

@@ -45,8 +43,7 @@ public async ValueTask<AccessDecision> DecideAsync(
4543
if (rootResolver.IsLadmin(context))
4644
return AccessDecision.Allow();
4745

48-
await using var db = await dbFactory.CreateDbContextAsync(
49-
cancellationToken);
46+
await using var db = await dbFactory.CreateDbContextAsync(cancellationToken);
5047

5148
var actor = await db.SandboxMembers
5249
.AsNoTracking()
@@ -70,18 +67,35 @@ public async ValueTask<AccessDecision> DecideAsync(
7067
if (operation == SandboxOperation.Denied)
7168
return AccessDecision.Deny("sandbox_operation_restricted");
7269

73-
if (actor.Status == SandboxStatus.Passive &&
74-
operation != SandboxOperation.Read)
70+
if (actor.Status == SandboxStatus.Passive && operation != SandboxOperation.Read)
7571
{
7672
return AccessDecision.Deny("sandbox_read_only");
7773
}
7874

79-
if (operation == SandboxOperation.Manage &&
80-
actor.Role != SandboxMemberRole.Owner)
75+
if (operation == SandboxOperation.Manage && actor.Role != SandboxMemberRole.Owner)
8176
{
8277
return AccessDecision.Deny("sandbox_owner_required");
8378
}
8479

80+
if (action == UAuthActions.Users.GetByKeysAdmin)
81+
{
82+
var requestedKeys = context.TargetUserKeys;
83+
84+
if (requestedKeys.Count == 0)
85+
return AccessDecision.Deny("sandbox_targets_required");
86+
87+
var keys = requestedKeys.Distinct().ToArray();
88+
89+
var allowedCount = await db.SandboxMembers
90+
.AsNoTracking()
91+
.CountAsync(x => x.SandboxId == actor.SandboxId && keys.Contains(x.UserKey), cancellationToken);
92+
93+
if (allowedCount != keys.Length)
94+
{
95+
return AccessDecision.Deny("sandbox_target_out_of_scope");
96+
}
97+
}
98+
8599
// All target-based admin operations must remain
86100
// inside the actor's sandbox.
87101
if (RequiresTarget(action))
@@ -91,10 +105,7 @@ public async ValueTask<AccessDecision> DecideAsync(
91105

92106
var belongsToSandbox = await db.SandboxMembers
93107
.AsNoTracking()
94-
.AnyAsync(
95-
x => x.UserKey == targetKey &&
96-
x.SandboxId == actor.SandboxId,
97-
cancellationToken);
108+
.AnyAsync(x => x.UserKey == targetKey && x.SandboxId == actor.SandboxId, cancellationToken);
98109

99110
if (!belongsToSandbox)
100111
return AccessDecision.Deny("sandbox_target_out_of_scope");
@@ -138,18 +149,14 @@ private static SandboxOperation ClassifyAction(string action)
138149
{
139150
// User creation requires a controlled sandbox
140151
// creation workflow, including membership and capacity.
141-
if (action is
142-
UAuthActions.Users.CreateAdmin or
143-
UAuthActions.Users.QueryAdmin)
152+
if (action is UAuthActions.Users.CreateAdmin or UAuthActions.Users.QueryAdmin)
144153
{
145154
return SandboxOperation.Denied;
146155
}
147156

148157
// Global role definitions and assignments are
149158
// handled separately by LabApplicationPolicy.
150-
if (action.StartsWith(
151-
"authorization.roles.",
152-
StringComparison.Ordinal))
159+
if (action.StartsWith("authorization.roles.", StringComparison.Ordinal))
153160
{
154161
return action is
155162
UAuthActions.Authorization.Roles.GetSelf or
@@ -173,6 +180,7 @@ UAuthActions.Authorization.Roles.GetAdmin or
173180

174181
private static bool IsReadAction(string action)
175182
=> action is
183+
UAuthActions.Users.GetByKeysAdmin or
176184
UAuthActions.UserProfiles.GetSelf or
177185
UAuthActions.UserProfiles.GetAdmin or
178186
UAuthActions.UserIdentifiers.GetSelf or

‎apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/LabAdminService.cs‎

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@
55
using CodeBeam.UltimateAuth.IdentityLab.Contracts;
66
using CodeBeam.UltimateAuth.IdentityLab.Domain;
77
using CodeBeam.UltimateAuth.IdentityLab.Persistence;
8+
using CodeBeam.UltimateAuth.Users.Contracts;
89
using Microsoft.EntityFrameworkCore;
910

1011
namespace CodeBeam.UltimateAuth.IdentityLab.Services;
1112

1213
public sealed class LabAdminService(IDbContextFactory<LabDbContext> dbFactory, IRoleStoreFactory roleStoreFactory,
13-
IUserRoleStoreFactory userRoleStoreFactory, IUAuthClient uAuthClient)
14+
IUserRoleStoreFactory userRoleStoreFactory, IUserSummaryQueryStoreFactory userSummaryQueryStoreFactory)
1415
{
1516
public async Task<bool> IsAdminAsync(UserKey actorUserKey, CancellationToken ct = default)
1617
{
@@ -30,11 +31,7 @@ public async Task<bool> IsAdminAsync(UserKey actorUserKey, CancellationToken ct
3031
.Create(tenant)
3132
.GetByIdsAsync(roleIds, ct);
3233

33-
return roles.Any(x =>
34-
string.Equals(
35-
x.Name,
36-
"Ladmin",
37-
StringComparison.OrdinalIgnoreCase));
34+
return roles.Any(x => string.Equals(x.Name, "Ladmin", StringComparison.OrdinalIgnoreCase));
3835
}
3936

4037
private async Task RequireAdminAsync(UserKey actorUserKey, CancellationToken ct)
@@ -142,21 +139,26 @@ public async Task<IReadOnlyList<AdminSandboxMemberItem>> GetMemberDetailsAsync(U
142139
{
143140
var members = await GetMembersAsync(actorUserKey, sandboxId, ct);
144141

145-
var result = new List<AdminSandboxMemberItem>();
142+
if (members.Count == 0)
143+
return Array.Empty<AdminSandboxMemberItem>();
144+
145+
var keys = members.Select(x => x.UserKey).Distinct().ToArray();
146+
147+
var store = userSummaryQueryStoreFactory.Create(TenantKeys.Single);
148+
149+
var summaries = await store.GetByKeysAsync(keys, ct);
150+
151+
var userMap = summaries.ToDictionary(x => x.UserKey);
152+
153+
var result = new List<AdminSandboxMemberItem>(members.Count);
146154

147155
foreach (var member in members)
148156
{
149-
var userResult = await uAuthClient.Users.GetUserAsync(
150-
member.UserKey);
151-
152-
if (!userResult.IsSuccess || userResult.Value is null)
157+
if (!userMap.TryGetValue(member.UserKey, out var user))
153158
{
154-
throw new InvalidOperationException(
155-
$"Unable to load member {member.UserKey.Value}.");
159+
throw new InvalidOperationException($"Unable to load member {member.UserKey.Value}.");
156160
}
157161

158-
var user = userResult.Value;
159-
160162
result.Add(new AdminSandboxMemberItem(
161163
member.UserKey,
162164
user.DisplayName ?? user.UserName ?? "Unknown",

‎apps/identity-lab/CodeBeam.UltimateAuth.IdentityLab/Services/SandboxUserService.cs‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
namespace CodeBeam.UltimateAuth.IdentityLab.Services;
1111

1212
public sealed class SandboxUserService(SandboxService sandboxService, SandboxAccessService accessService, IUAuthClient uAuthClient,
13-
IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, ActivityLogService activityLogService)
13+
IRoleStoreFactory roleStoreFactory, IUserRoleStoreFactory userRoleStoreFactory, IUserSummaryQueryStoreFactory userSummaryQueryStoreFactory,
14+
ActivityLogService activityLogService)
1415
{
1516
public async Task<SandboxUsersSnapshot?> GetUsersAsync(UserKey actorUserKey, CancellationToken ct = default)
1617
{
@@ -25,16 +26,28 @@ public sealed class SandboxUserService(SandboxService sandboxService, SandboxAcc
2526
throw new UnauthorizedAccessException();
2627

2728
var members = await sandboxService.GetMembersAsync(sandbox.Id, ct);
28-
var users = new List<SandboxUserItem>();
2929

30-
foreach (var member in members)
30+
if (members.Count == 0)
3131
{
32-
var result = await uAuthClient.Users.GetUserAsync(member.UserKey);
32+
return new SandboxUsersSnapshot(sandbox.Id, sandbox.Settings.MaxUsers, Array.Empty<SandboxUserItem>());
33+
}
3334

34-
if (!result.IsSuccess || result.Value is null)
35-
throw new InvalidOperationException($"Unable to load user {member.UserKey.Value}.");
35+
var keys = members.Select(x => x.UserKey).Distinct().ToArray();
36+
37+
var store = userSummaryQueryStoreFactory.Create(TenantKeys.Single);
38+
39+
var summaries = await store.GetByKeysAsync(keys, ct);
3640

37-
var user = result.Value;
41+
var userMap = summaries.ToDictionary(x => x.UserKey);
42+
43+
var users = new List<SandboxUserItem>(members.Count);
44+
45+
foreach (var member in members)
46+
{
47+
if (!userMap.TryGetValue(member.UserKey, out var user))
48+
{
49+
throw new InvalidOperationException($"Unable to load user {member.UserKey.Value}.");
50+
}
3851

3952
users.Add(new SandboxUserItem(
4053
UserKey: member.UserKey,
@@ -45,7 +58,7 @@ public sealed class SandboxUserService(SandboxService sandboxService, SandboxAcc
4558
Email: user.PrimaryEmail ?? "",
4659
IsOwner: member.Role == SandboxMemberRole.Owner,
4760
Status: user.Status,
48-
CreatedAt: user.CreatedAt ?? member.JoinedAt
61+
CreatedAt: user.CreatedAt
4962
));
5063
}
5164

0 commit comments

Comments
 (0)