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

Adds the access foundation everything else enforces against.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-09 07:59:57 +03:30
parent 265861b89b
commit 61991bf6cd
29 changed files with 1333 additions and 14 deletions
@@ -0,0 +1,26 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>
/// The role × capability matrix (docs/PRODUCT.md "Access / RBAC"). Pure policy — no scope logic
/// here; scope coverage is applied by <see cref="IPermissionService"/>. Owner is org-wide and can
/// do everything; TeamOwner is scoped to its own team; Member works tasks; Viewer reads only.
/// </summary>
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,
};
}
@@ -0,0 +1,19 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>
/// A permission-checked action, evaluated against a scope. Mirrors the capability rows of the
/// access matrix in docs/PRODUCT.md ("Access / RBAC").
/// </summary>
public enum Capability
{
ManageBilling,
ManageApiKeys,
InvitePeople,
CreateProductsAndTeams,
ConfigureAgents,
SetAutonomy,
ApproveHeldActions,
WorkTasks,
ViewBoard,
ViewAuditLog,
}
@@ -0,0 +1,20 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>A role held at a scope.</summary>
public readonly record struct ScopedRole(ScopeRef Scope, RoleType Role);
/// <summary>
/// The authenticated principal for the current request — or unauthenticated. Resolved from the
/// JWT by the Identity module; available to every module via DI.
/// </summary>
public interface ICurrentUser
{
bool IsAuthenticated { get; }
/// <summary>The member id. Throws if not authenticated — guard with <see cref="IsAuthenticated"/>.</summary>
Guid MemberId { get; }
string Email { get; }
IReadOnlyList<ScopedRole> Memberships { get; }
}
@@ -0,0 +1,12 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>
/// 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.
/// </summary>
public interface IPermissionService
{
bool Has(Capability capability, params ScopeRef[] scopeChain);
}
@@ -0,0 +1,10 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>A role granted to a member at a scope. Memberships are additive.</summary>
public enum RoleType
{
Owner,
TeamOwner,
Member,
Viewer,
}
@@ -0,0 +1,18 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>The kind of scope a role is granted at. V1 uses Organization and Team.</summary>
public enum ScopeType
{
Organization,
Division,
Product,
Team,
}
/// <summary>A reference to the scope a role applies to (additive across memberships).</summary>
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);
}