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!;
+ }
+}