diff --git a/Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs b/Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs new file mode 100644 index 0000000..8e13964 --- /dev/null +++ b/Tharga.Platform.Mcp.Tests/PlatformTeamResourceProviderTests.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using Tharga.Mcp; +using Tharga.Platform.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp.Tests; + +public class PlatformTeamResourceProviderTests +{ + private readonly ITeamService _teamService = Substitute.For(); + private readonly IApiKeyAdministrationService _apiKeyService = Substitute.For(); + + private IMcpContext MakeContext(string teamId) + { + var ctx = Substitute.For(); + ctx.TeamId.Returns(teamId); + ctx.Scope.Returns(McpScope.Team); + return ctx; + } + + private static async IAsyncEnumerable ToAsyncEnumerable(params T[] items) + { + foreach (var item in items) yield return item; + await Task.CompletedTask; + } + + [Fact] + public async Task ListResourcesAsync_NoTeamId_ReturnsEmpty() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + var result = await sut.ListResourcesAsync(MakeContext(teamId: null), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task ListResourcesAsync_WithTeamId_Returns_Three_When_ApiKeyServiceRegistered() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + var result = await sut.ListResourcesAsync(MakeContext("T-1"), TestContext.Current.CancellationToken); + + Assert.Equal(3, result.Count); + Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.TeamUri); + Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.MembersUri); + Assert.Contains(result, r => r.Uri == PlatformTeamResourceProvider.ApiKeysUri); + } + + [Fact] + public async Task ListResourcesAsync_WithTeamId_OmitsApiKeys_When_ApiKeyServiceNotRegistered() + { + var sut = new PlatformTeamResourceProvider(_teamService, apiKeyAdministrationService: null); + + var result = await sut.ListResourcesAsync(MakeContext("T-1"), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Count); + Assert.DoesNotContain(result, r => r.Uri == PlatformTeamResourceProvider.ApiKeysUri); + } + + [Fact] + public async Task ReadResourceAsync_NoTeamId_Throws() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext(teamId: null), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_UnknownUri_Throws() + { + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync("platform://team/bogus", MakeContext("T-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_TeamUri_ReturnsTeamMetadata() + { + var team = Substitute.For(); + team.Key.Returns("T-1"); + team.Name.Returns("Acme"); + team.Icon.Returns((string)null); + team.ConsentedRoles.Returns(new[] { "viewer" }); + _teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(team)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext("T-1"), TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(content.Text); + var root = doc.RootElement; + Assert.Equal("T-1", root.GetProperty("key").GetString()); + Assert.Equal("Acme", root.GetProperty("name").GetString()); + Assert.Equal(1, root.GetProperty("consentedRoles").GetArrayLength()); + } + + [Fact] + public async Task ReadResourceAsync_TeamUri_NotInCallerTeams_Throws() + { + // The MCP caller's TeamKey claim says T-1 but GetTeamsAsync() returns a different team. + var otherTeam = Substitute.For(); + otherTeam.Key.Returns("T-OTHER"); + _teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(otherTeam)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformTeamResourceProvider.TeamUri, MakeContext("T-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_MembersUri_ReturnsMembers() + { + var m1 = Substitute.For(); + m1.Key.Returns("u-1"); + m1.Name.Returns("One"); + m1.AccessLevel.Returns(AccessLevel.Owner); + m1.State.Returns(MembershipState.Member); + var m2 = Substitute.For(); + m2.Key.Returns("u-2"); + m2.AccessLevel.Returns(AccessLevel.User); + m2.Invitation.Returns(new Invitation { InviteKey = "inv-abc", EMail = "two@example.com", InviteTime = DateTime.UtcNow }); + _teamService.GetMembersAsync("T-1").Returns(ToAsyncEnumerable(m1, m2)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.MembersUri, MakeContext("T-1"), TestContext.Current.CancellationToken); + + using var doc = JsonDocument.Parse(content.Text); + var root = doc.RootElement; + Assert.Equal("T-1", root.GetProperty("teamKey").GetString()); + var items = root.GetProperty("items"); + Assert.Equal(2, items.GetArrayLength()); + Assert.False(items[0].GetProperty("invited").GetBoolean()); + Assert.True(items[1].GetProperty("invited").GetBoolean()); + } + + [Fact] + public async Task ReadResourceAsync_ApiKeysUri_ReturnsRedactedKeys() + { + var key = Substitute.For(); + key.Key.Returns("k-1"); + key.Name.Returns("Default"); + key.ApiKey.Returns("RAW-SECRET-VALUE-SHOULD-BE-REDACTED"); + key.AccessLevel.Returns(AccessLevel.Administrator); + key.Roles.Returns(new[] { "Editor" }); + key.CreatedBy.Returns("alice"); + _apiKeyService.GetKeysAsync("T-1").Returns(ToAsyncEnumerable(key)); + + var sut = new PlatformTeamResourceProvider(_teamService, _apiKeyService); + var content = await sut.ReadResourceAsync(PlatformTeamResourceProvider.ApiKeysUri, MakeContext("T-1"), TestContext.Current.CancellationToken); + + Assert.DoesNotContain("RAW-SECRET-VALUE", content.Text); + + using var doc = JsonDocument.Parse(content.Text); + var item = doc.RootElement.GetProperty("items")[0]; + Assert.Equal("k-1", item.GetProperty("key").GetString()); + Assert.Equal("Default", item.GetProperty("name").GetString()); + Assert.Equal("alice", item.GetProperty("createdBy").GetString()); + Assert.False(item.TryGetProperty("apiKey", out _)); + } + + [Fact] + public async Task ReadResourceAsync_ApiKeysUri_NoApiKeyService_Throws() + { + var sut = new PlatformTeamResourceProvider(_teamService, apiKeyAdministrationService: null); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformTeamResourceProvider.ApiKeysUri, MakeContext("T-1"), TestContext.Current.CancellationToken)); + } +} diff --git a/Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs b/Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs new file mode 100644 index 0000000..ca882dc --- /dev/null +++ b/Tharga.Platform.Mcp.Tests/PlatformUserResourceProviderTests.cs @@ -0,0 +1,126 @@ +using System.Security.Claims; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Tharga.Mcp; +using Tharga.Platform.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp.Tests; + +public class PlatformUserResourceProviderTests +{ + private readonly IUserService _userService = Substitute.For(); + private readonly ITeamService _teamService = Substitute.For(); + private readonly IHttpContextAccessor _httpContextAccessor = Substitute.For(); + + private IMcpContext MakeContext(string userId) + { + var ctx = Substitute.For(); + ctx.UserId.Returns(userId); + ctx.Scope.Returns(McpScope.User); + return ctx; + } + + private static async IAsyncEnumerable ToAsyncEnumerable(params T[] items) + { + foreach (var item in items) yield return item; + await Task.CompletedTask; + } + + private PlatformUserResourceProvider CreateSut() + => new(_userService, _teamService, _httpContextAccessor); + + [Fact] + public async Task ListResourcesAsync_AnonymousContext_ReturnsEmpty() + { + var sut = CreateSut(); + + var result = await sut.ListResourcesAsync(MakeContext(userId: null), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task ListResourcesAsync_AuthenticatedContext_ReturnsMeResource() + { + var sut = CreateSut(); + + var result = await sut.ListResourcesAsync(MakeContext(userId: "u-1"), TestContext.Current.CancellationToken); + + var descriptor = Assert.Single(result); + Assert.Equal(PlatformUserResourceProvider.MeUri, descriptor.Uri); + Assert.Equal("application/json", descriptor.MimeType); + } + + [Fact] + public async Task ReadResourceAsync_UnknownUri_Throws() + { + var sut = CreateSut(); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync("platform://nope", MakeContext("u-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_NoCurrentUser_Throws() + { + _userService.GetCurrentUserAsync(Arg.Any()).Returns((IUser)null); + var sut = CreateSut(); + + await Assert.ThrowsAsync(() => + sut.ReadResourceAsync(PlatformUserResourceProvider.MeUri, MakeContext("u-1"), TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ReadResourceAsync_ReturnsUserAndMemberships() + { + var user = Substitute.For(); + user.Key.Returns("u-alice"); + user.Identity.Returns("alice@example.com"); + user.Name.Returns("Alice"); + user.EMail.Returns("alice@example.com"); + _userService.GetCurrentUserAsync(Arg.Any()).Returns(user); + + var team1 = Substitute.For(); + team1.Key.Returns("T-1"); + team1.Name.Returns("First"); + var team2 = Substitute.For(); + team2.Key.Returns("T-2"); + team2.Name.Returns("Second"); + _teamService.GetTeamsAsync().Returns(ToAsyncEnumerable(team1, team2)); + + var aliceInT1 = Substitute.For(); + aliceInT1.Key.Returns("u-alice"); + aliceInT1.AccessLevel.Returns(AccessLevel.Owner); + aliceInT1.State.Returns(MembershipState.Member); + var bobInT1 = Substitute.For(); + bobInT1.Key.Returns("u-bob"); + _teamService.GetMembersAsync("T-1").Returns(ToAsyncEnumerable(bobInT1, aliceInT1)); + + var aliceInT2 = Substitute.For(); + aliceInT2.Key.Returns("u-alice"); + aliceInT2.AccessLevel.Returns(AccessLevel.User); + aliceInT2.State.Returns(MembershipState.Member); + _teamService.GetMembersAsync("T-2").Returns(ToAsyncEnumerable(aliceInT2)); + + var sut = CreateSut(); + var content = await sut.ReadResourceAsync(PlatformUserResourceProvider.MeUri, MakeContext("u-alice"), TestContext.Current.CancellationToken); + + Assert.Equal(PlatformUserResourceProvider.MeUri, content.Uri); + Assert.Equal("application/json", content.MimeType); + + using var doc = JsonDocument.Parse(content.Text); + var root = doc.RootElement; + var userJson = root.GetProperty("user"); + Assert.Equal("u-alice", userJson.GetProperty("key").GetString()); + Assert.Equal("Alice", userJson.GetProperty("name").GetString()); + Assert.Equal("alice@example.com", userJson.GetProperty("email").GetString()); + + var memberships = root.GetProperty("memberships"); + Assert.Equal(2, memberships.GetArrayLength()); + var first = memberships[0]; + Assert.Equal("T-1", first.GetProperty("teamKey").GetString()); + Assert.Equal("First", first.GetProperty("teamName").GetString()); + Assert.Equal((int)AccessLevel.Owner, first.GetProperty("accessLevel").GetInt32()); + } +} diff --git a/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs b/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs index faa717f..913b7a1 100644 --- a/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs +++ b/Tharga.Platform.Mcp/McpPlatformBuilderExtensions.cs @@ -42,6 +42,11 @@ public static IThargaMcpBuilder AddPlatform(this IThargaMcpBuilder builder, Acti scopes.Register(McpScopes.Discover, AccessLevel.Viewer); }); + // Always-on user-scope and team-scope resource providers. They self-gate on the + // principal's UserId / TeamKey claim, so anonymous and system-only callers see nothing. + builder.AddResourceProvider(); + builder.AddResourceProvider(); + // Opt-in system-scope resource providers (diagnostic data for Developers). if (options.ExposeSystemResources) { diff --git a/Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs b/Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs new file mode 100644 index 0000000..afeac84 --- /dev/null +++ b/Tharga.Platform.Mcp/PlatformTeamResourceProvider.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using Tharga.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp; + +/// +/// MCP resource provider that surfaces the caller's *current* team under platform://team*. +/// Scope is ; the resource set is gated on a TeamKey claim being +/// present on the principal — anonymous or non-team callers see no resources and read attempts throw +/// . +/// +/// Cross-tenant enumeration (reading other teams the caller does not belong to) is intentionally +/// not supported here — that lives in a System-scope provider once ITeamService.GetAllTeamsAsync +/// is added. +/// +public sealed class PlatformTeamResourceProvider : IMcpResourceProvider +{ + private readonly ITeamService _teamService; + private readonly IApiKeyAdministrationService _apiKeyAdministrationService; + + public const string TeamUri = "platform://team"; + public const string MembersUri = "platform://team/members"; + public const string ApiKeysUri = "platform://team/apikeys"; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + }; + + public PlatformTeamResourceProvider( + ITeamService teamService, + IApiKeyAdministrationService apiKeyAdministrationService = null) + { + _teamService = teamService; + _apiKeyAdministrationService = apiKeyAdministrationService; + } + + public McpScope Scope => McpScope.Team; + + public Task> ListResourcesAsync(IMcpContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(context?.TeamId)) + return Task.FromResult>(Array.Empty()); + + var list = new List + { + new() + { + Uri = TeamUri, + Name = "Current Team", + Description = "Metadata for the caller's current team (Key, Name, Icon, ConsentedRoles).", + MimeType = "application/json", + }, + new() + { + Uri = MembersUri, + Name = "Current Team Members", + Description = "Members of the caller's current team.", + MimeType = "application/json", + }, + }; + + if (_apiKeyAdministrationService != null) + { + list.Add(new McpResourceDescriptor + { + Uri = ApiKeysUri, + Name = "Current Team API Keys", + Description = "API keys for the caller's current team. Raw key values are redacted.", + MimeType = "application/json", + }); + } + + return Task.FromResult>(list); + } + + public async Task ReadResourceAsync(string uri, IMcpContext context, CancellationToken cancellationToken) + { + var teamKey = context?.TeamId; + if (string.IsNullOrEmpty(teamKey)) + throw new UnauthorizedAccessException("No team selected."); + + return uri switch + { + TeamUri => await ReadTeamAsync(teamKey, cancellationToken), + MembersUri => await ReadMembersAsync(teamKey, cancellationToken), + ApiKeysUri => await ReadApiKeysAsync(teamKey, cancellationToken), + _ => throw new InvalidOperationException($"Unknown resource URI '{uri}'."), + }; + } + + private async Task ReadTeamAsync(string teamKey, CancellationToken cancellationToken) + { + ITeam team = null; + await foreach (var candidate in _teamService.GetTeamsAsync().WithCancellation(cancellationToken)) + { + if (candidate.Key == teamKey) { team = candidate; break; } + } + if (team == null) throw new InvalidOperationException($"Team '{teamKey}' not found for the caller."); + + var payload = new + { + key = team.Key, + name = team.Name, + icon = team.Icon, + consentedRoles = team.ConsentedRoles ?? Array.Empty(), + }; + + return new McpResourceContent + { + Uri = TeamUri, + Text = JsonSerializer.Serialize(payload, _jsonOptions), + MimeType = "application/json", + }; + } + + private async Task ReadMembersAsync(string teamKey, CancellationToken cancellationToken) + { + var items = new List(); + await foreach (var member in _teamService.GetMembersAsync(teamKey).WithCancellation(cancellationToken)) + { + items.Add(new + { + key = member.Key, + name = member.Name, + accessLevel = member.AccessLevel, + state = member.State, + tenantRoles = member.TenantRoles ?? Array.Empty(), + scopeOverrides = member.ScopeOverrides ?? Array.Empty(), + invited = member.Invitation != null, + }); + } + + return new McpResourceContent + { + Uri = MembersUri, + Text = JsonSerializer.Serialize(new { teamKey, items }, _jsonOptions), + MimeType = "application/json", + }; + } + + private async Task ReadApiKeysAsync(string teamKey, CancellationToken cancellationToken) + { + if (_apiKeyAdministrationService == null) + throw new InvalidOperationException("IApiKeyAdministrationService is not registered."); + + var items = new List(); + await foreach (var key in _apiKeyAdministrationService.GetKeysAsync(teamKey).WithCancellation(cancellationToken)) + { + items.Add(new + { + key = key.Key, + name = key.Name, + accessLevel = key.AccessLevel, + roles = key.Roles ?? Array.Empty(), + scopeOverrides = key.ScopeOverrides ?? Array.Empty(), + expiryDate = key.ExpiryDate, + createdAt = key.CreatedAt, + createdBy = key.CreatedBy, + // Raw ApiKey value intentionally omitted — redaction pattern. + }); + } + + return new McpResourceContent + { + Uri = ApiKeysUri, + Text = JsonSerializer.Serialize(new { teamKey, items }, _jsonOptions), + MimeType = "application/json", + }; + } +} diff --git a/Tharga.Platform.Mcp/PlatformUserResourceProvider.cs b/Tharga.Platform.Mcp/PlatformUserResourceProvider.cs new file mode 100644 index 0000000..079731a --- /dev/null +++ b/Tharga.Platform.Mcp/PlatformUserResourceProvider.cs @@ -0,0 +1,106 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Tharga.Mcp; +using Tharga.Team; + +namespace Tharga.Platform.Mcp; + +/// +/// MCP resource provider that surfaces the authenticated caller's own user identity and +/// team memberships under platform://me. Scope is — +/// the dispatcher's hierarchy filter lets Team and System callers see this too. +/// +public sealed class PlatformUserResourceProvider : IMcpResourceProvider +{ + private readonly IUserService _userService; + private readonly ITeamService _teamService; + private readonly IHttpContextAccessor _httpContextAccessor; + + public const string MeUri = "platform://me"; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + }; + + public PlatformUserResourceProvider( + IUserService userService, + ITeamService teamService, + IHttpContextAccessor httpContextAccessor) + { + _userService = userService; + _teamService = teamService; + _httpContextAccessor = httpContextAccessor; + } + + public McpScope Scope => McpScope.User; + + public Task> ListResourcesAsync(IMcpContext context, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(context?.UserId)) + return Task.FromResult>(Array.Empty()); + + return Task.FromResult>(new[] + { + new McpResourceDescriptor + { + Uri = MeUri, + Name = "Current User", + Description = "The authenticated caller's user identity and team memberships.", + MimeType = "application/json", + } + }); + } + + public async Task ReadResourceAsync(string uri, IMcpContext context, CancellationToken cancellationToken) + { + if (uri != MeUri) + throw new InvalidOperationException($"Unknown resource URI '{uri}'."); + + var principal = _httpContextAccessor.HttpContext?.User; + var user = await _userService.GetCurrentUserAsync(principal); + if (user == null) + throw new UnauthorizedAccessException("Authentication required."); + + var memberships = new List(); + await foreach (var team in _teamService.GetTeamsAsync().WithCancellation(cancellationToken)) + { + var memberRow = await FindMemberAsync(team.Key, user.Key, cancellationToken); + memberships.Add(new + { + teamKey = team.Key, + teamName = team.Name, + accessLevel = memberRow?.AccessLevel, + state = memberRow?.State, + }); + } + + var payload = new + { + user = new + { + key = user.Key, + identity = user.Identity, + name = user.Name, + email = user.EMail, + }, + memberships, + }; + + return new McpResourceContent + { + Uri = MeUri, + Text = JsonSerializer.Serialize(payload, _jsonOptions), + MimeType = "application/json", + }; + } + + private async Task FindMemberAsync(string teamKey, string userKey, CancellationToken cancellationToken) + { + await foreach (var member in _teamService.GetMembersAsync(teamKey).WithCancellation(cancellationToken)) + { + if (member.Key == userKey) return member; + } + return null; + } +} diff --git a/Tharga.Platform.Mcp/README.md b/Tharga.Platform.Mcp/README.md index 8de738e..e1e9647 100644 --- a/Tharga.Platform.Mcp/README.md +++ b/Tharga.Platform.Mcp/README.md @@ -25,6 +25,28 @@ builder.Services.AddThargaMcp(mcp => app.UseThargaMcp(); ``` +## User and team resources + +Always-on resource providers that surface the authenticated caller's own data. Both providers self-gate on the principal's claims, so anonymous and system-only callers see no resources. + +### User scope (`McpScope.User`) + +| URI | Contents | +|-----|----------| +| `platform://me` | The caller's `IUser` (`key`, `identity`, `name`, `email`) and a `memberships` array — for each team the caller is in, its `teamKey`, `teamName`, plus the caller's `accessLevel` and membership `state`. | + +Listed when the principal carries a `NameIdentifier` (or equivalent) claim. Read fails with `UnauthorizedAccessException` if `IUserService.GetCurrentUserAsync` returns null. + +### Team scope (`McpScope.Team`) + +| URI | Contents | +|-----|----------| +| `platform://team` | Metadata for the caller's *current* team (from the `TeamKey` claim): `key`, `name`, `icon`, `consentedRoles`. | +| `platform://team/members` | Members of the current team: `key`, `name`, `accessLevel`, `state`, `tenantRoles`, `scopeOverrides`, and an `invited` flag. | +| `platform://team/apikeys` | API keys for the current team. Raw key values are redacted (the `apiKey` property is omitted entirely). Listed only when `IApiKeyAdministrationService` is registered. | + +Listed only when the principal carries a `TeamKey` claim. Read fails with `UnauthorizedAccessException` if no team is selected. Cross-tenant team listing (reading other teams) is intentionally not supported here — that's a future system-scope provider once `ITeamService.GetAllTeamsAsync` is added. + ## System-scope diagnostic resources (opt-in) Expose read-only diagnostic data under `platform://system/*` for callers with the Developer role. Non-developers see no resources and get `UnauthorizedAccessException` on read. @@ -47,7 +69,7 @@ Available resources (listed only when the matching dependency is registered): | `platform://system/roles` | Tenant roles registered via `AddThargaTenantRoles` | | `platform://system/audit` | Most recent ~100 audit entries from the last 7 days | -Cross-tenant team listings and per-team API-key listings are deferred — they require a new `ITeamService` method and are tracked separately. +Per-team API-key listings now ship under `platform://team/apikeys` (see "Team scope" above). Cross-tenant team listings remain deferred — they require a new `ITeamService.GetAllTeamsAsync` method. ## Related packages diff --git a/Tharga.Team.Service.Tests/TeamServiceBaseGetMembersAsyncTests.cs b/Tharga.Team.Service.Tests/TeamServiceBaseGetMembersAsyncTests.cs new file mode 100644 index 0000000..6d15c00 --- /dev/null +++ b/Tharga.Team.Service.Tests/TeamServiceBaseGetMembersAsyncTests.cs @@ -0,0 +1,45 @@ +namespace Tharga.Team.Service.Tests; + +/// +/// Verifies the default implementation +/// — which uses reflection internally to read the typed team's Members array — +/// yields the underlying members as without forcing the caller +/// to know the consumer-specific TMember type. +/// +public class TeamServiceBaseGetMembersAsyncTests +{ + private readonly IUserService _userService = Substitute.For(); + + [Fact] + public async Task GetMembersAsync_ReturnsAllMembers_AsITeamMember() + { + var sut = new TestTeamService(_userService); + sut.AddTeam("team-1", "Test Team", + new TestMember { Key = "u-1", AccessLevel = AccessLevel.Owner, State = MembershipState.Member }, + new TestMember { Key = "u-2", AccessLevel = AccessLevel.User, State = MembershipState.Member }); + + var members = new List(); + await foreach (var member in sut.GetMembersAsync("team-1")) + { + members.Add(member); + } + + Assert.Equal(2, members.Count); + Assert.Equal("u-1", members[0].Key); + Assert.Equal("u-2", members[1].Key); + } + + [Fact] + public async Task GetMembersAsync_UnknownTeam_ReturnsEmpty() + { + var sut = new TestTeamService(_userService); + + var members = new List(); + await foreach (var member in sut.GetMembersAsync("does-not-exist")) + { + members.Add(member); + } + + Assert.Empty(members); + } +} diff --git a/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs b/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs index 491e3bd..29f088a 100644 --- a/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs +++ b/Tharga.Team.Service/Audit/AuditingTeamServiceDecorator.cs @@ -41,6 +41,7 @@ public event EventHandler SelectTeamEvent public IAsyncEnumerable> GetTeamsAsync() where TMember : ITeamMember => _inner.GetTeamsAsync(); public Task> GetTeamAsync(string teamKey) where TMember : ITeamMember => _inner.GetTeamAsync(teamKey); public Task GetTeamMemberAsync(string teamKey, string userKey) => _inner.GetTeamMemberAsync(teamKey, userKey); + public IAsyncEnumerable GetMembersAsync(string teamKey) => _inner.GetMembersAsync(teamKey); public IAsyncEnumerable GetConsentedTeamsAsync(string[] userRoles) => _inner.GetConsentedTeamsAsync(userRoles); public Task SetMemberLastSeenAsync(string teamKey) => _inner.SetMemberLastSeenAsync(teamKey); diff --git a/Tharga.Team/ITeamService.cs b/Tharga.Team/ITeamService.cs index 5099f50..04f7349 100644 --- a/Tharga.Team/ITeamService.cs +++ b/Tharga.Team/ITeamService.cs @@ -12,6 +12,7 @@ public interface ITeamService Task RenameTeamAsync(string teamKey, string name) where TMember : ITeamMember; Task DeleteTeamAsync(string teamKey) where TMember : ITeamMember; Task GetTeamMemberAsync(string teamKey, string userKey); + IAsyncEnumerable GetMembersAsync(string teamKey); Task AddMemberAsync(string teamKey, InviteUserModel model); Task RemoveMemberAsync(string teamKey, string userKey); Task SetMemberRoleAsync(string teamKey, string userKey, AccessLevel accessLevel); diff --git a/Tharga.Team/README.md b/Tharga.Team/README.md index 128623a..20145b6 100644 --- a/Tharga.Team/README.md +++ b/Tharga.Team/README.md @@ -14,7 +14,7 @@ Domain models, service abstractions, and authorization primitives for multi-tena - `Invitation`, `InviteUserModel`, `MembershipState`. ### Service interfaces -- `ITeamService` - Team CRUD, member management, invitations. +- `ITeamService` - Team CRUD, member management, invitations. Includes `GetMembersAsync(teamKey)` returning `IAsyncEnumerable` for consumers that need to enumerate members without knowing the per-consumer `TMember` type. - `ITeamManagementService` - Scope-enforced mutations (create, rename, delete, invite, etc.). - `IUserService` - Current user resolution. - `IApiKeyAdministrationService` / `IApiKeyManagementService` - API key management. diff --git a/Tharga.Team/TeamServiceBase.cs b/Tharga.Team/TeamServiceBase.cs index 94645c9..163f5e4 100644 --- a/Tharga.Team/TeamServiceBase.cs +++ b/Tharga.Team/TeamServiceBase.cs @@ -111,6 +111,17 @@ public async Task GetTeamMemberAsync(string teamKey, string userKey return teamMember; } + public virtual async IAsyncEnumerable GetMembersAsync(string teamKey) + { + var team = await GetTeamAsync(teamKey); + var members = GetMembersFromTeam(team); + if (members == null) yield break; + foreach (var member in members) + { + yield return member; + } + } + public async Task AddMemberAsync(string teamKey, InviteUserModel model) { await AddTeamMemberAsync(teamKey, model);