diff --git a/Directory.Packages.props b/Directory.Packages.props index 9bca2dd..3fb601f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -19,6 +19,13 @@ + + + + + + + diff --git a/src/Hosts/TeamUp.Web/Program.cs b/src/Hosts/TeamUp.Web/Program.cs index 780e904..77714eb 100644 --- a/src/Hosts/TeamUp.Web/Program.cs +++ b/src/Hosts/TeamUp.Web/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using OpenTelemetry.Trace; using Serilog; using TeamUp.Bootstrap; @@ -12,6 +13,10 @@ builder.Host.UseSerilog((context, services, configuration) => configuration builder.Services.AddOpenApi(); +// Bind/serialize enums as strings across the API (e.g. ScopeType "Organization", RoleType "Member"). +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + builder.Services.AddTeamUpObservability( builder.Configuration, serviceName: "teamup-web", @@ -42,6 +47,9 @@ app.UseSerilogRequestLogging(); app.UseDefaultFiles(); app.UseStaticFiles(); +app.UseAuthentication(); +app.UseAuthorization(); + app.MapHealthChecks("/health"); app.MapTeamUpModules(); diff --git a/src/Hosts/TeamUp.Web/appsettings.json b/src/Hosts/TeamUp.Web/appsettings.json index db21550..f23be41 100644 --- a/src/Hosts/TeamUp.Web/appsettings.json +++ b/src/Hosts/TeamUp.Web/appsettings.json @@ -5,6 +5,12 @@ "Database": { "ApplyMigrationsOnStartup": false }, + "Jwt": { + "Secret": "dev-only-teamup-jwt-signing-secret-change-in-production-0123456789", + "Issuer": "teamup", + "Audience": "teamup", + "ExpiryMinutes": 480 + }, "OpenTelemetry": { "OtlpEndpoint": "" }, diff --git a/src/Hosts/TeamUp.Worker/appsettings.json b/src/Hosts/TeamUp.Worker/appsettings.json index f0d96a0..c17bfab 100644 --- a/src/Hosts/TeamUp.Worker/appsettings.json +++ b/src/Hosts/TeamUp.Worker/appsettings.json @@ -5,6 +5,12 @@ "Database": { "ApplyMigrationsOnStartup": false }, + "Jwt": { + "Secret": "dev-only-teamup-jwt-signing-secret-change-in-production-0123456789", + "Issuer": "teamup", + "Audience": "teamup", + "ExpiryMinutes": 480 + }, "OpenTelemetry": { "OtlpEndpoint": "" }, diff --git a/src/Modules/TeamUp.Modules.Identity/Access/CurrentUser.cs b/src/Modules/TeamUp.Modules.Identity/Access/CurrentUser.cs new file mode 100644 index 0000000..ab6be96 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Access/CurrentUser.cs @@ -0,0 +1,52 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.IdentityModel.JsonWebTokens; +using TeamUp.Modules.Identity.Auth; +using TeamUp.SharedKernel.Access; + +namespace TeamUp.Modules.Identity.Access; + +/// +/// Resolves from the request's JWT claims. JWT bearer is configured with +/// MapInboundClaims=false, so claim names stay raw ("sub", "email", "membership"). In the worker +/// (no HttpContext) this reports unauthenticated. +/// +internal sealed class CurrentUser(IHttpContextAccessor accessor) : ICurrentUser +{ + private ClaimsPrincipal? Principal => accessor.HttpContext?.User; + + public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true; + + public Guid MemberId => + Guid.TryParse(Principal?.FindFirstValue(JwtRegisteredClaimNames.Sub), out var id) + ? id + : throw new InvalidOperationException("No authenticated member on the current request."); + + public string Email => Principal?.FindFirstValue(JwtRegisteredClaimNames.Email) ?? string.Empty; + + public IReadOnlyList Memberships + { + get + { + if (Principal is null) + { + return []; + } + + var memberships = new List(); + foreach (var claim in Principal.FindAll(JwtTokenService.MembershipClaim)) + { + var parts = claim.Value.Split(':'); + if (parts.Length == 3 + && Enum.TryParse(parts[0], out var scopeType) + && Guid.TryParse(parts[1], out var scopeId) + && Enum.TryParse(parts[2], out var role)) + { + memberships.Add(new ScopedRole(new ScopeRef(scopeType, scopeId), role)); + } + } + + return memberships; + } + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Access/PermissionService.cs b/src/Modules/TeamUp.Modules.Identity/Access/PermissionService.cs new file mode 100644 index 0000000..c8b7a2a --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Access/PermissionService.cs @@ -0,0 +1,29 @@ +using TeamUp.SharedKernel.Access; + +namespace TeamUp.Modules.Identity.Access; + +/// +/// Default : the current user has a capability if any of their +/// memberships sits on a scope in the supplied chain and that role permits the capability. +/// +internal sealed class PermissionService(ICurrentUser currentUser) : IPermissionService +{ + public bool Has(Capability capability, params ScopeRef[] scopeChain) + { + if (!currentUser.IsAuthenticated || scopeChain.Length == 0) + { + return false; + } + + foreach (var membership in currentUser.Memberships) + { + if (Array.IndexOf(scopeChain, membership.Scope) >= 0 + && AccessPolicy.Permits(membership.Role, capability)) + { + return true; + } + } + + return false; + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Auth/JwtOptions.cs b/src/Modules/TeamUp.Modules.Identity/Auth/JwtOptions.cs new file mode 100644 index 0000000..549c272 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Auth/JwtOptions.cs @@ -0,0 +1,11 @@ +namespace TeamUp.Modules.Identity.Auth; + +internal sealed class JwtOptions +{ + public const string SectionName = "Jwt"; + + public string Secret { get; set; } = string.Empty; + public string Issuer { get; set; } = "teamup"; + public string Audience { get; set; } = "teamup"; + public int ExpiryMinutes { get; set; } = 480; +} diff --git a/src/Modules/TeamUp.Modules.Identity/Auth/JwtTokenService.cs b/src/Modules/TeamUp.Modules.Identity/Auth/JwtTokenService.cs new file mode 100644 index 0000000..05c5df4 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Auth/JwtTokenService.cs @@ -0,0 +1,47 @@ +using System.Security.Claims; +using System.Text; +using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; +using TeamUp.Modules.Identity.Domain; + +namespace TeamUp.Modules.Identity.Auth; + +/// Issues signed JWTs carrying the member id, email, and one claim per membership. +internal sealed class JwtTokenService(IOptions options, TimeProvider timeProvider) +{ + public const string MembershipClaim = "membership"; + + private readonly JwtOptions _options = options.Value; + + public string Issue(Member member, IReadOnlyList memberships) + { + var now = timeProvider.GetUtcNow(); + + var claims = new List + { + new(JwtRegisteredClaimNames.Sub, member.Id.ToString()), + new(JwtRegisteredClaimNames.Email, member.Email), + new("name", member.DisplayName), + }; + + foreach (var membership in memberships) + { + claims.Add(new Claim(MembershipClaim, $"{membership.ScopeType}:{membership.ScopeId}:{membership.Role}")); + } + + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Secret)); + var descriptor = new SecurityTokenDescriptor + { + Issuer = _options.Issuer, + Audience = _options.Audience, + Subject = new ClaimsIdentity(claims), + IssuedAt = now.UtcDateTime, + NotBefore = now.UtcDateTime, + Expires = now.AddMinutes(_options.ExpiryMinutes).UtcDateTime, + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256), + }; + + return new JsonWebTokenHandler().CreateToken(descriptor); + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Contracts/IMemberDirectory.cs b/src/Modules/TeamUp.Modules.Identity/Contracts/IMemberDirectory.cs new file mode 100644 index 0000000..0d2f3c6 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Contracts/IMemberDirectory.cs @@ -0,0 +1,17 @@ +namespace TeamUp.Modules.Identity.Contracts; + +/// Public, non-sensitive member info other modules may display (e.g. board assignees). +public sealed record MemberSummary(Guid Id, string Email, string DisplayName); + +/// +/// The Identity module's public surface for resolving member display info by id. Other modules +/// depend on this interface — never on Identity's entities or DbContext. +/// +public interface IMemberDirectory +{ + Task FindByIdAsync(Guid memberId, CancellationToken cancellationToken = default); + + Task> GetByIdsAsync( + IReadOnlyCollection memberIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Modules/TeamUp.Modules.Identity/Domain/Invitation.cs b/src/Modules/TeamUp.Modules.Identity/Domain/Invitation.cs new file mode 100644 index 0000000..d97fa1b --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Domain/Invitation.cs @@ -0,0 +1,55 @@ +using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.Identity.Domain; + +internal enum InvitationStatus +{ + Pending, + Accepted, + Revoked, +} + +/// An invitation to join at a scope+role. Accepting it creates the member + membership. +internal sealed class Invitation : Entity +{ + public string Email { get; private set; } = null!; + public ScopeType ScopeType { get; private set; } + public Guid ScopeId { get; private set; } + public RoleType Role { get; private set; } + public string Token { get; private set; } = null!; + public InvitationStatus Status { get; private set; } + public Guid InvitedByMemberId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset? AcceptedAtUtc { get; private set; } + + private Invitation() + { + } + + public Invitation( + string email, + ScopeRef scope, + RoleType role, + string token, + Guid invitedByMemberId, + DateTimeOffset createdAtUtc) + { + Email = email; + ScopeType = scope.Type; + ScopeId = scope.Id; + Role = role; + Token = token; + InvitedByMemberId = invitedByMemberId; + Status = InvitationStatus.Pending; + CreatedAtUtc = createdAtUtc; + } + + public ScopeRef Scope => new(ScopeType, ScopeId); + + public void Accept(DateTimeOffset whenUtc) + { + Status = InvitationStatus.Accepted; + AcceptedAtUtc = whenUtc; + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Domain/Member.cs b/src/Modules/TeamUp.Modules.Identity/Domain/Member.cs new file mode 100644 index 0000000..fb98992 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Domain/Member.cs @@ -0,0 +1,43 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.Identity.Domain; + +internal enum MemberStatus +{ + Invited, + Active, + Disabled, +} + +/// An invited/active human in the system. Identity owns the credential; other modules +/// reference a member only by id (via the public member directory). +internal sealed class Member : Entity +{ + public string Email { get; private set; } = null!; + public string DisplayName { get; private set; } = null!; + public string PasswordHash { get; private set; } = null!; + public MemberStatus Status { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + + private Member() + { + } + + public Member( + string email, + string displayName, + string passwordHash, + DateTimeOffset createdAtUtc, + MemberStatus status = MemberStatus.Active) + { + Email = email; + DisplayName = displayName; + PasswordHash = passwordHash; + CreatedAtUtc = createdAtUtc; + Status = status; + } + + public void SetPasswordHash(string passwordHash) => PasswordHash = passwordHash; + + public void Activate() => Status = MemberStatus.Active; +} diff --git a/src/Modules/TeamUp.Modules.Identity/Domain/Membership.cs b/src/Modules/TeamUp.Modules.Identity/Domain/Membership.cs new file mode 100644 index 0000000..c70f3de --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Domain/Membership.cs @@ -0,0 +1,29 @@ +using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.Identity.Domain; + +/// A member's role at a scope. Additive — a member may hold several. +internal sealed class Membership : Entity +{ + public Guid MemberId { get; private set; } + public ScopeType ScopeType { get; private set; } + public Guid ScopeId { get; private set; } + public RoleType Role { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + + private Membership() + { + } + + public Membership(Guid memberId, ScopeRef scope, RoleType role, DateTimeOffset createdAtUtc) + { + MemberId = memberId; + ScopeType = scope.Type; + ScopeId = scope.Id; + Role = role; + CreatedAtUtc = createdAtUtc; + } + + public ScopeRef Scope => new(ScopeType, ScopeId); +} diff --git a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityDtos.cs b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityDtos.cs new file mode 100644 index 0000000..a4075ff --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityDtos.cs @@ -0,0 +1,34 @@ +using TeamUp.SharedKernel.Access; + +namespace TeamUp.Modules.Identity.Endpoints; + +internal sealed record BootstrapRequest( + string OrganizationName, + string OwnerEmail, + string OwnerDisplayName, + string OwnerPassword); + +internal sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); + +internal sealed record LoginRequest(string Email, string Password); + +internal sealed record AuthResponse(string Token, Guid MemberId); + +internal sealed record MembershipDto(string ScopeType, Guid ScopeId, string Role); + +internal sealed record MeResponse( + Guid MemberId, + string Email, + string DisplayName, + IReadOnlyList Memberships); + +internal sealed record InviteRequest( + string Email, + ScopeType ScopeType, + Guid ScopeId, + RoleType Role, + Guid OrganizationId); + +internal sealed record InviteResponse(Guid InvitationId, string Token); + +internal sealed record AcceptInviteRequest(string Token, string DisplayName, string Password); diff --git a/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs new file mode 100644 index 0000000..d11d887 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Endpoints/IdentityEndpoints.cs @@ -0,0 +1,165 @@ +using System.Security.Cryptography; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using TeamUp.Modules.Identity.Auth; +using TeamUp.Modules.Identity.Domain; +using TeamUp.Modules.Identity.Persistence; +using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Modularity; + +namespace TeamUp.Modules.Identity.Endpoints; + +internal static class IdentityEndpoints +{ + public static void Map(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/identity").WithTags("Identity"); + + group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("identity"))); + group.MapPost("/bootstrap", Bootstrap).AllowAnonymous(); + group.MapPost("/auth/login", Login).AllowAnonymous(); + group.MapGet("/me", Me).RequireAuthorization(); + group.MapPost("/invitations", CreateInvitation).RequireAuthorization(); + group.MapPost("/invitations/accept", AcceptInvitation).AllowAnonymous(); + } + + private static async Task Bootstrap( + BootstrapRequest request, + IdentityDbContext db, + IPasswordHasher hasher, + JwtTokenService tokens, + TimeProvider clock, + CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.OwnerEmail) || string.IsNullOrWhiteSpace(request.OwnerPassword)) + { + return Results.BadRequest("Owner email and password are required."); + } + + if (await db.Members.AnyAsync(ct)) + { + return Results.Conflict("The system is already bootstrapped."); + } + + var now = clock.GetUtcNow(); + var organizationId = Guid.CreateVersion7(); + var owner = new Member(request.OwnerEmail.Trim(), request.OwnerDisplayName.Trim(), string.Empty, now); + owner.SetPasswordHash(hasher.HashPassword(owner, request.OwnerPassword)); + + var membership = new Membership(owner.Id, ScopeRef.Org(organizationId), RoleType.Owner, now); + + db.Members.Add(owner); + db.Memberships.Add(membership); + await db.SaveChangesAsync(ct); + + var token = tokens.Issue(owner, [membership]); + return Results.Ok(new BootstrapResponse(token, owner.Id, organizationId)); + } + + private static async Task Login( + LoginRequest request, + IdentityDbContext db, + IPasswordHasher hasher, + JwtTokenService tokens, + CancellationToken ct) + { + var member = await db.Members.FirstOrDefaultAsync(m => m.Email == request.Email, ct); + if (member is null || member.Status == MemberStatus.Disabled) + { + return Results.Unauthorized(); + } + + var result = hasher.VerifyHashedPassword(member, member.PasswordHash, request.Password); + if (result == PasswordVerificationResult.Failed) + { + return Results.Unauthorized(); + } + + var memberships = await db.Memberships.Where(m => m.MemberId == member.Id).ToListAsync(ct); + return Results.Ok(new AuthResponse(tokens.Issue(member, memberships), member.Id)); + } + + private static async Task Me(ICurrentUser currentUser, IdentityDbContext db, CancellationToken ct) + { + var member = await db.Members.FirstOrDefaultAsync(m => m.Id == currentUser.MemberId, ct); + if (member is null) + { + return Results.NotFound(); + } + + var memberships = currentUser.Memberships + .Select(m => new MembershipDto(m.Scope.Type.ToString(), m.Scope.Id, m.Role.ToString())) + .ToList(); + + return Results.Ok(new MeResponse(member.Id, member.Email, member.DisplayName, memberships)); + } + + private static async Task CreateInvitation( + InviteRequest request, + ICurrentUser currentUser, + IPermissionService permissions, + IdentityDbContext db, + TimeProvider clock, + CancellationToken ct) + { + var targetScope = new ScopeRef(request.ScopeType, request.ScopeId); + var orgScope = ScopeRef.Org(request.OrganizationId); + var chain = targetScope == orgScope + ? new[] { targetScope } + : new[] { targetScope, orgScope }; + + if (!permissions.Has(Capability.InvitePeople, chain)) + { + return Results.Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.Email)) + { + return Results.BadRequest("Email is required."); + } + + var token = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)); + var invitation = new Invitation( + request.Email.Trim(), targetScope, request.Role, token, currentUser.MemberId, clock.GetUtcNow()); + + db.Invitations.Add(invitation); + await db.SaveChangesAsync(ct); + + return Results.Ok(new InviteResponse(invitation.Id, token)); + } + + private static async Task AcceptInvitation( + AcceptInviteRequest request, + IdentityDbContext db, + IPasswordHasher hasher, + JwtTokenService tokens, + TimeProvider clock, + CancellationToken ct) + { + var invitation = await db.Invitations.FirstOrDefaultAsync(i => i.Token == request.Token, ct); + if (invitation is null || invitation.Status != InvitationStatus.Pending) + { + return Results.BadRequest("Invitation not found or already used."); + } + + if (string.IsNullOrWhiteSpace(request.Password) || string.IsNullOrWhiteSpace(request.DisplayName)) + { + return Results.BadRequest("Display name and password are required."); + } + + var now = clock.GetUtcNow(); + var member = new Member(invitation.Email, request.DisplayName.Trim(), string.Empty, now); + member.SetPasswordHash(hasher.HashPassword(member, request.Password)); + var membership = new Membership(member.Id, invitation.Scope, invitation.Role, now); + invitation.Accept(now); + + db.Members.Add(member); + db.Memberships.Add(membership); + await db.SaveChangesAsync(ct); + + return Results.Ok(new AuthResponse(tokens.Issue(member, [membership]), member.Id)); + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs b/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs index 3c7c08c..3f098b0 100644 --- a/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs +++ b/src/Modules/TeamUp.Modules.Identity/IdentityModule.cs @@ -1,27 +1,65 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; +using System.Text; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.IdentityModel.Tokens; +using TeamUp.Modules.Identity.Access; +using TeamUp.Modules.Identity.Auth; +using TeamUp.Modules.Identity.Contracts; +using TeamUp.Modules.Identity.Domain; +using TeamUp.Modules.Identity.Endpoints; +using TeamUp.Modules.Identity.Persistence; +using TeamUp.SharedKernel.Access; using TeamUp.SharedKernel.Modularity; +using TeamUp.SharedKernel.Persistence; namespace TeamUp.Modules.Identity; -/// Identity & access: members, memberships, roles, permission enforcement (M1). +/// Identity & access: members, memberships, invitations, JWT auth, permission enforcement (M1). public sealed class IdentityModule : IModule { public string Name => "identity"; public void Register(IServiceCollection services, IConfiguration configuration) { - // Skeleton: no services yet. M1 introduces this module's (internal) DbContext, - // FluentValidation validators, and domain services here. + var connectionString = configuration.GetConnectionString("Postgres") + ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'."); + + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + + services.TryAddSingleton(TimeProvider.System); + services.AddHttpContextAccessor(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton, PasswordHasher>(); + + services.Configure(configuration.GetSection(JwtOptions.SectionName)); + var jwt = configuration.GetSection(JwtOptions.SectionName).Get() ?? new JwtOptions(); + + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.MapInboundClaims = false; + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = jwt.Issuer, + ValidateAudience = true, + ValidAudience = jwt.Audience, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt.Secret)), + ValidateLifetime = true, + }; + }); + services.AddAuthorization(); } - public void MapEndpoints(IEndpointRouteBuilder endpoints) - { - endpoints.MapGroup($"/api/{Name}") - .WithTags("Identity") - .MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name))); - } + public void MapEndpoints(IEndpointRouteBuilder endpoints) => IdentityEndpoints.Map(endpoints); } diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContext.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContext.cs new file mode 100644 index 0000000..53664fc --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContext.cs @@ -0,0 +1,54 @@ +using Microsoft.EntityFrameworkCore; +using TeamUp.Modules.Identity.Domain; +using TeamUp.SharedKernel.Persistence; + +namespace TeamUp.Modules.Identity.Persistence; + +internal sealed class IdentityDbContext(DbContextOptions options) + : DbContext(options), IModuleDbContext +{ + public DbSet Members => Set(); + public DbSet Memberships => Set(); + public DbSet Invitations => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("identity"); + + modelBuilder.Entity(member => + { + member.ToTable("members"); + member.HasKey(m => m.Id); + member.Property(m => m.Email).HasMaxLength(256).IsRequired(); + member.HasIndex(m => m.Email).IsUnique(); + member.Property(m => m.DisplayName).HasMaxLength(128).IsRequired(); + member.Property(m => m.PasswordHash).HasMaxLength(512).IsRequired(); + member.Property(m => m.Status).HasConversion().HasMaxLength(32); + }); + + modelBuilder.Entity(membership => + { + membership.ToTable("memberships"); + membership.HasKey(m => m.Id); + membership.Property(m => m.ScopeType).HasConversion().HasMaxLength(32); + membership.Property(m => m.Role).HasConversion().HasMaxLength(32); + membership.Ignore(m => m.Scope); + membership.HasIndex(m => m.MemberId); + membership.HasIndex(m => new { m.MemberId, m.ScopeType, m.ScopeId, m.Role }).IsUnique(); + }); + + modelBuilder.Entity(invitation => + { + invitation.ToTable("invitations"); + invitation.HasKey(i => i.Id); + invitation.Property(i => i.Email).HasMaxLength(256).IsRequired(); + invitation.Property(i => i.Token).HasMaxLength(128).IsRequired(); + invitation.HasIndex(i => i.Token).IsUnique(); + invitation.HasIndex(i => i.Email); + invitation.Property(i => i.ScopeType).HasConversion().HasMaxLength(32); + invitation.Property(i => i.Role).HasConversion().HasMaxLength(32); + invitation.Property(i => i.Status).HasConversion().HasMaxLength(32); + invitation.Ignore(i => i.Scope); + }); + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContextFactory.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContextFactory.cs new file mode 100644 index 0000000..f3367b5 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Persistence/IdentityDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TeamUp.Modules.Identity.Persistence; + +/// Design-time factory so `dotnet ef` can build the internal context without a host. +internal sealed class IdentityDbContextFactory : IDesignTimeDbContextFactory +{ + public IdentityDbContext CreateDbContext(string[] args) + { + var connectionString = + Environment.GetEnvironmentVariable("ConnectionStrings__Postgres") + ?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + + return new IdentityDbContext(options); + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/MemberDirectory.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/MemberDirectory.cs new file mode 100644 index 0000000..7a588a7 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Persistence/MemberDirectory.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using TeamUp.Modules.Identity.Contracts; + +namespace TeamUp.Modules.Identity.Persistence; + +internal sealed class MemberDirectory(IdentityDbContext db) : IMemberDirectory +{ + public async Task FindByIdAsync(Guid memberId, CancellationToken cancellationToken = default) => + await db.Members + .Where(m => m.Id == memberId) + .Select(m => new MemberSummary(m.Id, m.Email, m.DisplayName)) + .FirstOrDefaultAsync(cancellationToken); + + public async Task> GetByIdsAsync( + IReadOnlyCollection memberIds, + CancellationToken cancellationToken = default) + { + var ids = memberIds.ToHashSet(); + return await db.Members + .Where(m => ids.Contains(m.Id)) + .Select(m => new MemberSummary(m.Id, m.Email, m.DisplayName)) + .ToDictionaryAsync(m => m.Id, cancellationToken); + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.Designer.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.Designer.cs new file mode 100644 index 0000000..a3d3666 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.Designer.cs @@ -0,0 +1,156 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +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))] + [Migration("20260609042521_InitialIdentity")] + partial class InitialIdentity + { + /// + protected override void BuildTargetModel(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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByMemberId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.cs new file mode 100644 index 0000000..f35c495 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/20260609042521_InitialIdentity.cs @@ -0,0 +1,122 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.Identity.Persistence.Migrations +{ + /// + public partial class InitialIdentity : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "identity"); + + migrationBuilder.CreateTable( + name: "invitations", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + ScopeType = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + ScopeId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + Token = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + InvitedByMemberId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + AcceptedAtUtc = table.Column(type: "timestamp with time zone", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_invitations", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "members", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: false), + DisplayName = table.Column(type: "character varying(128)", maxLength: 128, nullable: false), + PasswordHash = table.Column(type: "character varying(512)", maxLength: 512, nullable: false), + Status = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_members", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "memberships", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + MemberId = table.Column(type: "uuid", nullable: false), + ScopeType = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + ScopeId = table.Column(type: "uuid", nullable: false), + Role = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_memberships", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_invitations_Email", + schema: "identity", + table: "invitations", + column: "Email"); + + migrationBuilder.CreateIndex( + name: "IX_invitations_Token", + schema: "identity", + table: "invitations", + column: "Token", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_members_Email", + schema: "identity", + table: "members", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_memberships_MemberId", + schema: "identity", + table: "memberships", + column: "MemberId"); + + migrationBuilder.CreateIndex( + name: "IX_memberships_MemberId_ScopeType_ScopeId_Role", + schema: "identity", + table: "memberships", + columns: new[] { "MemberId", "ScopeType", "ScopeId", "Role" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "invitations", + schema: "identity"); + + migrationBuilder.DropTable( + name: "members", + schema: "identity"); + + migrationBuilder.DropTable( + name: "memberships", + schema: "identity"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/IdentityDbContextModelSnapshot.cs new file mode 100644 index 0000000..3afa602 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Identity/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -0,0 +1,153 @@ +// +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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AcceptedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("InvitedByMemberId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("ScopeType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ScopeId") + .HasColumnType("uuid"); + + b.Property("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 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj b/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj index 65f5856..d33fd0d 100644 --- a/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj +++ b/src/Modules/TeamUp.Modules.Identity/TeamUp.Modules.Identity.csproj @@ -1,10 +1,20 @@ - + + + + + + + + + + + + diff --git a/src/Shared/TeamUp.SharedKernel/Access/AccessPolicy.cs b/src/Shared/TeamUp.SharedKernel/Access/AccessPolicy.cs new file mode 100644 index 0000000..9fb59eb --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/AccessPolicy.cs @@ -0,0 +1,26 @@ +namespace TeamUp.SharedKernel.Access; + +/// +/// The role × capability matrix (docs/PRODUCT.md "Access / RBAC"). Pure policy — no scope logic +/// here; scope coverage is applied by . Owner is org-wide and can +/// do everything; TeamOwner is scoped to its own team; Member works tasks; Viewer reads only. +/// +public static class AccessPolicy +{ + public static bool Permits(RoleType role, Capability capability) => role switch + { + RoleType.Owner => true, + RoleType.TeamOwner => capability is + Capability.InvitePeople + or Capability.CreateProductsAndTeams + or Capability.ConfigureAgents + or Capability.SetAutonomy + or Capability.ApproveHeldActions + or Capability.WorkTasks + or Capability.ViewBoard + or Capability.ViewAuditLog, + RoleType.Member => capability is Capability.WorkTasks or Capability.ViewBoard, + RoleType.Viewer => capability is Capability.ViewBoard, + _ => false, + }; +} diff --git a/src/Shared/TeamUp.SharedKernel/Access/Capability.cs b/src/Shared/TeamUp.SharedKernel/Access/Capability.cs new file mode 100644 index 0000000..3ee62e3 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/Capability.cs @@ -0,0 +1,19 @@ +namespace TeamUp.SharedKernel.Access; + +/// +/// A permission-checked action, evaluated against a scope. Mirrors the capability rows of the +/// access matrix in docs/PRODUCT.md ("Access / RBAC"). +/// +public enum Capability +{ + ManageBilling, + ManageApiKeys, + InvitePeople, + CreateProductsAndTeams, + ConfigureAgents, + SetAutonomy, + ApproveHeldActions, + WorkTasks, + ViewBoard, + ViewAuditLog, +} diff --git a/src/Shared/TeamUp.SharedKernel/Access/ICurrentUser.cs b/src/Shared/TeamUp.SharedKernel/Access/ICurrentUser.cs new file mode 100644 index 0000000..47b56a5 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/ICurrentUser.cs @@ -0,0 +1,20 @@ +namespace TeamUp.SharedKernel.Access; + +/// A role held at a scope. +public readonly record struct ScopedRole(ScopeRef Scope, RoleType Role); + +/// +/// The authenticated principal for the current request — or unauthenticated. Resolved from the +/// JWT by the Identity module; available to every module via DI. +/// +public interface ICurrentUser +{ + bool IsAuthenticated { get; } + + /// The member id. Throws if not authenticated — guard with . + Guid MemberId { get; } + + string Email { get; } + + IReadOnlyList Memberships { get; } +} diff --git a/src/Shared/TeamUp.SharedKernel/Access/IPermissionService.cs b/src/Shared/TeamUp.SharedKernel/Access/IPermissionService.cs new file mode 100644 index 0000000..a156305 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/IPermissionService.cs @@ -0,0 +1,12 @@ +namespace TeamUp.SharedKernel.Access; + +/// +/// Evaluates whether the current user may perform a capability at a scope. The caller passes the +/// scope chain from most-specific to broadest (e.g. a team, then its org); a covering role at any +/// link grants the capability. This keeps Identity free of OrgBoard's scope hierarchy — the module +/// performing the action supplies the chain it already knows. +/// +public interface IPermissionService +{ + bool Has(Capability capability, params ScopeRef[] scopeChain); +} diff --git a/src/Shared/TeamUp.SharedKernel/Access/RoleType.cs b/src/Shared/TeamUp.SharedKernel/Access/RoleType.cs new file mode 100644 index 0000000..634d1c6 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/RoleType.cs @@ -0,0 +1,10 @@ +namespace TeamUp.SharedKernel.Access; + +/// A role granted to a member at a scope. Memberships are additive. +public enum RoleType +{ + Owner, + TeamOwner, + Member, + Viewer, +} diff --git a/src/Shared/TeamUp.SharedKernel/Access/Scope.cs b/src/Shared/TeamUp.SharedKernel/Access/Scope.cs new file mode 100644 index 0000000..46e4111 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/Scope.cs @@ -0,0 +1,18 @@ +namespace TeamUp.SharedKernel.Access; + +/// The kind of scope a role is granted at. V1 uses Organization and Team. +public enum ScopeType +{ + Organization, + Division, + Product, + Team, +} + +/// A reference to the scope a role applies to (additive across memberships). +public readonly record struct ScopeRef(ScopeType Type, Guid Id) +{ + public static ScopeRef Org(Guid id) => new(ScopeType.Organization, id); + + public static ScopeRef Team(Guid id) => new(ScopeType.Team, id); +} diff --git a/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs b/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs new file mode 100644 index 0000000..513d1bc --- /dev/null +++ b/tests/TeamUp.IntegrationTests/IdentityFlowTests.cs @@ -0,0 +1,127 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// 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. +/// +[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 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(); + 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(); + 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(); + 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 GetMe(TeamUpWebFactory factory, string token) + { + using var client = Authed(factory, token); + var me = await client.GetFromJsonAsync("/api/identity/me"); + Assert.NotNull(me); + return me!; + } +}