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,153 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.Identity.Persistence;
#nullable disable
namespace TeamUp.Modules.Identity.Persistence.Migrations
{
[DbContext(typeof(IdentityDbContext))]
partial class IdentityDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Invitation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("AcceptedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<Guid>("InvitedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("ScopeId")
.HasColumnType("uuid");
b.Property<string>("ScopeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.HasKey("Id");
b.HasIndex("Email");
b.HasIndex("Token")
.IsUnique();
b.ToTable("invitations", "identity");
});
modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Member", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("character varying(512)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("Email")
.IsUnique();
b.ToTable("members", "identity");
});
modelBuilder.Entity("TeamUp.Modules.Identity.Domain.Membership", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("MemberId")
.HasColumnType("uuid");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("ScopeId")
.HasColumnType("uuid");
b.Property<string>("ScopeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.HasKey("Id");
b.HasIndex("MemberId");
b.HasIndex("MemberId", "ScopeType", "ScopeId", "Role")
.IsUnique();
b.ToTable("memberships", "identity");
});
#pragma warning restore 612, 618
}
}
}