Skip to content

Commit 7c66a0c

Browse files
authored
feat(sessions): expose session count in chain summaries (#131)
1 parent 45dcfcd commit 7c66a0c

9 files changed

Lines changed: 112 additions & 9 deletions

File tree

‎docs/content/client-sdk/session-management.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ Each summary provides information suitable for building a device management inte
101101
| `LastSeenAt` | `DateTimeOffset?` | Last recorded activity |
102102
| `RotationCount` | `int` | Recorded rotation count |
103103
| `TouchCount` | `int` | Recorded touch count |
104+
| `SessionCount` | `int` | Total number of session records in the chain, including historical and revoked sessions |
104105
| `IsRevoked` | `bool` | Whether the chain is revoked |
105106
| `RevokedAt` | `DateTimeOffset?` | Revocation timestamp |
106107
| `ActiveSessionId` | `AuthSessionId?` | Associated active session identifier |

‎docs/website/CodeBeam.UltimateAuth.Docs.Wasm/CodeBeam.UltimateAuth.Docs.Wasm.Client/wwwroot/docs/client-sdk/session-management.json‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎src/CodeBeam.UltimateAuth.Core/Abstractions/Stores/ISessionStore.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,5 @@ public interface ISessionStore
3838
Task<UAuthSessionChain?> GetChainByDeviceAsync(UserKey userKey, DeviceId deviceId, CancellationToken ct = default);
3939
Task<IReadOnlyList<UAuthSessionChain>> GetChainsByRootAsync(SessionRootId rootId, CancellationToken ct = default);
4040
Task<IReadOnlyList<UAuthSession>> GetSessionsByChainAsync(SessionChainId chainId, CancellationToken ct = default);
41+
Task<IReadOnlyDictionary<SessionChainId, int>> GetSessionCountsByChainsAsync(IReadOnlyCollection<SessionChainId> chainIds, CancellationToken ct = default);
4142
}

‎src/CodeBeam.UltimateAuth.Core/Contracts/Session/Dtos/SessionChainSummary.cs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ public sealed record SessionChainSummary
1313
public DateTimeOffset? LastSeenAt { get; init; }
1414
public int RotationCount { get; init; }
1515
public int TouchCount { get; init; }
16+
public int SessionCount { get; init; }
1617
public bool IsRevoked { get; init; }
1718
public DateTimeOffset? RevokedAt { get; init; }
1819
public AuthSessionId? ActiveSessionId { get; init; }

‎src/CodeBeam.UltimateAuth.Server/Services/SessionApplicationService.cs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,10 @@ public async Task<PagedResult<SessionChainSummary>> GetUserChainsAsync(AccessCon
8080

8181
var total = chains.Count;
8282

83-
var pageItems = chains
84-
.Skip((request.PageNumber - 1) * request.PageSize)
85-
.Take(request.PageSize)
83+
var pageChains = chains.Skip((request.PageNumber - 1) * request.PageSize).Take(request.PageSize).ToList();
84+
var sessionCounts = await store.GetSessionCountsByChainsAsync(pageChains.Select(c => c.ChainId).ToArray(), innerCt);
85+
86+
var pageItems = pageChains
8687
.Select(c => new SessionChainSummary
8788
{
8889
ChainId = c.ChainId,
@@ -94,11 +95,13 @@ public async Task<PagedResult<SessionChainSummary>> GetUserChainsAsync(AccessCon
9495
LastSeenAt = c.LastSeenAt,
9596
RotationCount = c.RotationCount,
9697
TouchCount = c.TouchCount,
98+
SessionCount = sessionCounts.GetValueOrDefault(c.ChainId),
9799
IsRevoked = c.IsRevoked,
98100
RevokedAt = c.RevokedAt,
99101
ActiveSessionId = c.ActiveSessionId,
100-
IsCurrentDevice = actorChainId.HasValue && c.ChainId == actorChainId.Value,
101-
State = c.State,
102+
IsCurrentDevice =
103+
actorChainId.HasValue && c.ChainId == actorChainId.Value,
104+
State = c.State
102105
})
103106
.ToList();
104107

‎src/sessions/CodeBeam.UltimateAuth.Sessions.EntityFrameworkCore/Stores/EfCoreSessionStore.cs‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,4 +867,21 @@ public async Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Can
867867
rootDomain.UpdateProjection(rootProjection);
868868
rootProjection.Version++;
869869
}
870+
871+
public async Task<IReadOnlyDictionary<SessionChainId, int>> GetSessionCountsByChainsAsync(IReadOnlyCollection<SessionChainId> chainIds, CancellationToken ct = default)
872+
{
873+
ct.ThrowIfCancellationRequested();
874+
875+
if (chainIds.Count == 0)
876+
return new Dictionary<SessionChainId, int>();
877+
878+
var ids = chainIds.Distinct().ToArray();
879+
880+
return await DbSetSession
881+
.AsNoTracking()
882+
.Where(s => s.Tenant == _tenant && ids.Contains(s.ChainId))
883+
.GroupBy(s => s.ChainId)
884+
.Select(g => new { ChainId = g.Key, Count = g.Count() })
885+
.ToDictionaryAsync(x => x.ChainId, x => x.Count, ct);
886+
}
870887
}

‎src/sessions/CodeBeam.UltimateAuth.Sessions.InMemory/InMemorySessionStore.cs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,4 +574,23 @@ public Task RevokeRootCascadeAsync(UserKey userKey, DateTimeOffset at, Cancellat
574574

575575
return Task.CompletedTask;
576576
}
577+
578+
public Task<IReadOnlyDictionary<SessionChainId, int>> GetSessionCountsByChainsAsync(IReadOnlyCollection<SessionChainId> chainIds, CancellationToken ct = default)
579+
{
580+
ct.ThrowIfCancellationRequested();
581+
582+
if (chainIds.Count == 0)
583+
{
584+
return Task.FromResult<IReadOnlyDictionary<SessionChainId, int>>(new Dictionary<SessionChainId, int>());
585+
}
586+
587+
var ids = chainIds.ToHashSet();
588+
589+
IReadOnlyDictionary<SessionChainId, int> counts = _sessions.Values
590+
.Where(s => ids.Contains(s.ChainId))
591+
.GroupBy(s => s.ChainId)
592+
.ToDictionary(g => g.Key, g => g.Count());
593+
594+
return Task.FromResult(counts);
595+
}
577596
}

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Server/SessionApplicationServiceTests.cs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,68 @@ public async Task RevokeRootAsync_RevokesRootAndItsChains()
737737
(await store.GetChainAsync(secondChain.ChainId))!.IsRevoked.Should().BeTrue();
738738
}
739739

740+
[Fact]
741+
public async Task GetUserChainsAsync_ReturnsTotalSessionCountPerChain()
742+
{
743+
var (sut, store) = CreateSut();
744+
var user = UserKey.New();
745+
var root = await CreateRootAsync(store, user);
746+
747+
var firstChain = CreateChain(root, Now.AddMinutes(-30));
748+
var secondChain = CreateChain(root, Now.AddMinutes(-20));
749+
750+
await store.CreateChainAsync(firstChain);
751+
await store.CreateChainAsync(secondChain);
752+
753+
var firstSession = CreateSession(
754+
user, firstChain.ChainId, Now.AddMinutes(-25), "first-1");
755+
756+
var rotatedSession = CreateSession(
757+
user, firstChain.ChainId, Now.AddMinutes(-15), "first-2");
758+
759+
var secondSession = CreateSession(
760+
user, secondChain.ChainId, Now.AddMinutes(-10), "second-1");
761+
762+
await store.CreateSessionAsync(firstSession);
763+
await store.CreateSessionAsync(rotatedSession);
764+
await store.CreateSessionAsync(secondSession);
765+
766+
var result = await sut.GetUserChainsAsync(
767+
Context(user),
768+
user,
769+
new PageRequest { PageNumber = 1, PageSize = 10 });
770+
771+
result.Items.Single(x => x.ChainId == firstChain.ChainId)
772+
.SessionCount.Should().Be(2);
773+
774+
result.Items.Single(x => x.ChainId == secondChain.ChainId)
775+
.SessionCount.Should().Be(1);
776+
}
777+
778+
[Fact]
779+
public async Task GetUserChainsAsync_RevokedSessionsRemainCounted()
780+
{
781+
var (sut, store) = CreateSut();
782+
var user = UserKey.New();
783+
var root = await CreateRootAsync(store, user);
784+
785+
var chain = CreateChain(root, Now.AddMinutes(-30));
786+
await store.CreateChainAsync(chain);
787+
788+
var first = CreateSession(user, chain.ChainId, Now.AddMinutes(-20), "revoked-1");
789+
790+
var second = CreateSession(user, chain.ChainId, Now.AddMinutes(-10), "revoked-2");
791+
792+
await store.CreateSessionAsync(first);
793+
await store.CreateSessionAsync(second);
794+
795+
await store.RevokeChainCascadeAsync(chain.ChainId, Now);
796+
797+
var result = await sut.GetUserChainsAsync(Context(user), user, new PageRequest { PageNumber = 1, PageSize = 10 });
798+
799+
result.Items.Single(x => x.ChainId == chain.ChainId).SessionCount.Should().Be(2);
800+
}
801+
740802
// ---------------------------------------------------------------------
741803
// Infrastructure / Helpers
742804
// ---------------------------------------------------------------------

‎tests/CodeBeam.UltimateAuth.Tests.Unit/Sessions/Store/InMemorySessionStoreChainContractTests.cs‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@
44

55
namespace CodeBeam.UltimateAuth.Tests.Unit.Sessions.Contracts;
66

7-
public sealed class InMemorySessionStoreChainContractTests
8-
: SessionStoreChainContractTests
7+
public sealed class InMemorySessionStoreChainContractTests : SessionStoreChainContractTests
98
{
109
protected override Task<ISessionStoreTestDatabase>
1110
CreateDatabaseAsync()

0 commit comments

Comments
 (0)