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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user