M1: Identity & access — members, RBAC, JWT auth, invitations

Adds the access foundation everything else enforces against.

SharedKernel (shared access contracts, no Identity dependency for consumers):
- ScopeRef/ScopeType, RoleType, Capability, AccessPolicy (role x capability matrix),
  ICurrentUser, IPermissionService (scope-chain evaluation).

Identity module:
- Member, Membership, Invitation entities; internal IdentityDbContext (schema
  "identity") + InitialIdentity migration; design-time factory.
- JWT auth (HS256) issuing membership claims; PasswordHasher<Member>; CurrentUser
  (claims -> ICurrentUser) and PermissionService implementations.
- Public IMemberDirectory contract for other modules to resolve member display info.
- Endpoints: POST /bootstrap (first owner), /auth/login, GET /me, POST /invitations,
  POST /invitations/accept. Owner-only actions enforced via IPermissionService.
- Web host wires UseAuthentication/UseAuthorization and string-enum JSON.

Verified: build green; ArchitectureTests 8/8 (Identity references only SharedKernel);
IntegrationTests 11/11 incl. a new end-to-end flow — bootstrap -> login -> /me ->
invite -> accept -> login as invitee, and a Member is 403'd from inviting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 07:59:57 +03:30
parent 265861b89b
commit 61991bf6cd
29 changed files with 1333 additions and 14 deletions
@@ -0,0 +1,127 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// M1 Identity/access acceptance at the API level: bootstrap the first owner, log in, read /me,
/// invite a member, accept the invite, and confirm a Member cannot perform an owner-only action.
/// </summary>
[Collection(PostgresCollection.Name)]
public sealed class IdentityFlowTests(PostgresFixture postgres)
{
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record AuthResponse(string Token, Guid MemberId);
private sealed record InviteResponse(Guid InvitationId, string Token);
private sealed record MembershipDto(string ScopeType, Guid ScopeId, string Role);
private sealed record MeResponse(Guid MemberId, string Email, string DisplayName, List<MembershipDto> Memberships);
[Fact]
public async Task Bootstrap_login_invite_accept_and_rbac_enforcement()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var client = factory.CreateClient();
// First owner is created by bootstrap.
var bootstrap = await client.PostAsJsonAsync("/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.OK, bootstrap.StatusCode);
var owner = await bootstrap.Content.ReadFromJsonAsync<BootstrapResponse>();
Assert.NotNull(owner);
// Bootstrapping again is rejected.
var second = await client.PostAsJsonAsync("/api/identity/bootstrap", new
{
organizationName = "x",
ownerEmail = "x@x.test",
ownerDisplayName = "X",
ownerPassword = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
// /me requires auth.
Assert.Equal(HttpStatusCode.Unauthorized, (await client.GetAsync("/api/identity/me")).StatusCode);
// Owner reads /me and holds an Owner membership.
var me = await GetMe(factory, owner!.Token);
Assert.Equal("owner@alia.test", me.Email);
Assert.Contains(me.Memberships, m => m.Role == "Owner" && m.ScopeId == owner.OrganizationId);
// Owner invites a Member at the org scope.
var invite = await Authed(factory, owner.Token).PostAsJsonAsync("/api/identity/invitations", new
{
email = "dev@alia.test",
scopeType = "Organization",
scopeId = owner.OrganizationId,
role = "Member",
organizationId = owner.OrganizationId,
});
Assert.Equal(HttpStatusCode.OK, invite.StatusCode);
var inviteResponse = await invite.Content.ReadFromJsonAsync<InviteResponse>();
Assert.NotNull(inviteResponse);
// The invitee accepts and gets a token.
var accept = await client.PostAsJsonAsync("/api/identity/invitations/accept", new
{
token = inviteResponse!.Token,
displayName = "Dev",
password = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.OK, accept.StatusCode);
var member = await accept.Content.ReadFromJsonAsync<AuthResponse>();
Assert.NotNull(member);
// The new member can log in with their credentials.
var login = await client.PostAsJsonAsync("/api/identity/auth/login", new
{
email = "dev@alia.test",
password = "Passw0rd!",
});
Assert.Equal(HttpStatusCode.OK, login.StatusCode);
// A Member cannot invite (owner-only capability) → 403.
var memberInvite = await Authed(factory, member!.Token).PostAsJsonAsync("/api/identity/invitations", new
{
email = "nope@alia.test",
scopeType = "Organization",
scopeId = owner.OrganizationId,
role = "Member",
organizationId = owner.OrganizationId,
});
Assert.Equal(HttpStatusCode.Forbidden, memberInvite.StatusCode);
// Bad credentials are rejected.
var badLogin = await client.PostAsJsonAsync("/api/identity/auth/login", new
{
email = "owner@alia.test",
password = "wrong",
});
Assert.Equal(HttpStatusCode.Unauthorized, badLogin.StatusCode);
}
private static HttpClient Authed(TeamUpWebFactory factory, string token)
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
return client;
}
private static async Task<MeResponse> GetMe(TeamUpWebFactory factory, string token)
{
using var client = Authed(factory, token);
var me = await client.GetFromJsonAsync<MeResponse>("/api/identity/me");
Assert.NotNull(me);
return me!;
}
}