21 KiB
User Management Feature Implementation Guide
Goal
Add user management, authentication, role-based permissions, and hierarchy-aware data access to the existing Distribution API.
This plan is scoped to the current project and its implemented modules:
- Region
- Route
- Vehicle
- Store
- Warehouse
- Order
It follows the codebase's actual conventions (verified against Features/Region, Features/Order, Data/AppDBContext.cs, Models/Template.cs, Infra/UoW/), not generic ASP.NET Core defaults.
Hard Constraints
These rules must remain unchanged while implementing this feature (verified: zero violations exist in the current codebase):
- Never call
uow.SaveChangesAsync()in repositories or services. - Only services call
_uow.CommitAsync(ct). - Use
DateTime.Now, neverDateTime.UtcNow. - Do not add extra modules outside the requested user management scope.
- Follow the existing slice pattern exactly:
I{Feature}Repository.cs,{Feature}Repository.cs,{Feature}DTOs.cs,{Feature}Service.cs,{Feature}Controller.cs. There is no generic repository base (IGenericRepo<T>) in this codebase — every repository is hand-written per feature, and the newUser/Role/Permissionrepositories must follow the same pattern. - All new entities inherit
Models.Template(Id,CreatedAt,IsActive) exactly like every existing entity (Region,Route,Vehicle,Store,Warehouse,Order,OrderItem,DeliveryRun) — do not redeclareId/CreatedAt/IsActive. - Foreign keys follow the majority existing convention: a
FK_{Name}Idproperty plus a[ForeignKey("FK_{Name}Id")]-annotated navigation property (as inOrder.FK_StoreId,DeliveryRun.FK_VehicleId/FK_RouteId). DTOs expose the plain name without theFK_prefix (asOrderResponse.StoreIdalready does).
⚠️ Blocking Prerequisite: Territory Hierarchy Is Not Modeled Yet
This is the single biggest gap between this plan's authorization design and the current schema, and it must be resolved before Phase 1, not discovered mid-implementation.
Verified against Models/Store.cs, Models/Route.cs, Models/Region.cs, Models/SaleRoute.cs, Models/Order.cs:
Regionhas only aName. Nothing points to it.Routehas no link toRegion.Storehas no link toRouteorRegion.Orderlinks only toStore(FK_StoreId) — it has noRouteId.SaleRouteis an empty stub (public class SaleRoute : Template { }). It is already flagged inPLAN.mdas "shape is TBD — excluded until clarified" from the original module build.
Consequence: as written, "restrict a Supervisor to their assigned region/route" or "scope orders by route" cannot be implemented — there is no data path from a User's region/route scope down to a Store or Order. Only store-level scoping (UserStoreScope → Order.FK_StoreId) is directly wireable today.
Recommended resolution
Add two small FK fields to the existing Route and Store models (not new modules — minimal additions to unblock the hierarchy):
Route.FK_RegionId(Guid) — a Route belongs to one Region.Store.FK_RouteId(Guid) — a Store is served by one Route.
This gives the chain Region ← Route ← Store ← Order, so scope checks can resolve a Store or Order's region/route by joining up from Store.FK_RouteId and Route.FK_RegionId. It also naturally resolves what SaleRoute was probably meant to represent (a store's assigned route), so SaleRoute can either be repurposed as this Store↔Route link or left unused and removed later — do not silently redefine SaleRoute's shape without confirming with the user, since it was explicitly deferred in the prior plan.
This must be confirmed before Phase 1 (see Open Decisions, item 0). If declined, the first release should be scoped to store-level authorization only, and region/route scoping deferred until the hierarchy exists.
Recommended Authorization Model
Do not model access using role names alone.
Use three separate concerns:
- Authentication
- Permissions
- Data scope
That means:
Roledecides what actions a user can perform.Permissiondefines each action explicitly.Scopedefines which regions, routes, or stores a user can access.User.FK_ReportsToUserIddefines organizational hierarchy.
Business Hierarchy
Bottom-to-top hierarchy:
- SalesRep
- Supervisor
- AreaManager
- AreaSupervisor
- RegionalManager
- RegionalAgency
Store this in code and data using a stable level value.
Suggested role levels:
| Role | Level |
|---|---|
| SalesRep | 10 |
| Supervisor | 20 |
| AreaManager | 30 |
| AreaSupervisor | 40 |
| RegionalManager | 50 |
| RegionalAgency | 60 |
Why Role Alone Is Not Enough
Two supervisors may both have the same permissions, but one should only access stores and orders under their own territory.
Because of that, authorization must be:
- permission-based
- scope-aware
- hierarchy-aware where needed
Target Project Structure
Keep the existing slice pattern and add only the new feature slices required (matches the real layout, not the stale structure described in AGENTS.md):
Distribution/
├── Features/
│ ├── Region/ Route/ Vehicle/ Store/ Warehouse/ Order/ (existing)
│ ├── Auth/ (new)
│ ├── User/ (new)
│ └── Role/ (new)
├── Models/
├── Data/
│ └── AppDBContext.cs
├── Infra/
│ └── UoW/
└── Program.cs
New Domain Models
Add the following entities under Models/. All inherit Template (Id, CreatedAt, IsActive) — do not add separate Id/CreatedAt/IsActive fields.
1. User
Purpose: application user account.
public class User : Template
{
public string Username { get; set; }
public string PasswordHash { get; set; }
public string FullName { get; set; }
public Guid FK_RoleId { get; set; }
[ForeignKey("FK_RoleId")]
public Role Role { get; set; }
public Guid? FK_ReportsToUserId { get; set; }
[ForeignKey("FK_ReportsToUserId")]
public User ReportsTo { get; set; }
public DateTime? UpdatedAt { get; set; }
}
Notes:
FK_ReportsToUserIdis nullable — the top of the hierarchy (RegionalAgency) reports to no one.UpdatedAthas no precedent inTemplate(no existing entity tracks it) — this is a deliberate, scoped addition for audit purposes onUseronly, not a change toTemplateitself. Flagging it explicitly rather than adding it silently.- Configure
FK_ReportsToUserIdwithOnDelete(DeleteBehavior.Restrict)inOnModelCreating— a self-referencing FK with cascading delete will fail (or silently misbehave) once combined with the other FK relationships being added in this feature.
2. Role
public class Role : Template
{
public string Name { get; set; }
public int Level { get; set; }
}
(IsActive comes from Template; no separate field needed.)
3. Permission
public class Permission : Template
{
public string Code { get; set; }
public string Description { get; set; }
}
4. RolePermission
Join entity, same pattern as OrderItem (inherits Template, composite key configured via Fluent API):
public class RolePermission : Template
{
public Guid FK_RoleId { get; set; }
[ForeignKey("FK_RoleId")]
public Role Role { get; set; }
public Guid FK_PermissionId { get; set; }
[ForeignKey("FK_PermissionId")]
public Permission Permission { get; set; }
}
5. UserRegionScope / 6. UserRouteScope / 7. UserStoreScope
Same shape, one per scope type:
public class UserStoreScope : Template
{
public Guid FK_UserId { get; set; }
[ForeignKey("FK_UserId")]
public User User { get; set; }
public Guid FK_StoreId { get; set; }
[ForeignKey("FK_StoreId")]
public Store Store { get; set; }
}
(Mirror for UserRegionScope → Region, UserRouteScope → Route.)
DbContext Changes
Update Data/AppDBContext.cs with new DbSet<> declarations, following the existing style exactly:
public DbSet<User> Users { get; set; }
public DbSet<Role> Roles { get; set; }
public DbSet<Permission> Permissions { get; set; }
public DbSet<RolePermission> RolePermissions { get; set; }
public DbSet<UserRegionScope> UserRegionScopes { get; set; }
public DbSet<UserRouteScope> UserRouteScopes { get; set; }
public DbSet<UserStoreScope> UserStoreScopes { get; set; }
Extend the existing OnModelCreating (currently only configures OrderItem's composite key) with the same pattern for each join table, plus the self-referencing FK behavior:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<OrderItem>()
.HasKey(oi => new { oi.FK_OrderId, oi.FK_ItemId });
modelBuilder.Entity<RolePermission>()
.HasKey(rp => new { rp.FK_RoleId, rp.FK_PermissionId });
modelBuilder.Entity<UserRegionScope>()
.HasKey(s => new { s.FK_UserId, s.FK_RegionId });
modelBuilder.Entity<UserRouteScope>()
.HasKey(s => new { s.FK_UserId, s.FK_RouteId });
modelBuilder.Entity<UserStoreScope>()
.HasKey(s => new { s.FK_UserId, s.FK_StoreId });
modelBuilder.Entity<User>()
.HasOne(u => u.ReportsTo)
.WithMany()
.HasForeignKey(u => u.FK_ReportsToUserId)
.OnDelete(DeleteBehavior.Restrict);
}
If the territory hierarchy prerequisite above is accepted, also add Route.FK_RegionId and Store.FK_RouteId FK configuration here (or let EF's convention-based FK discovery handle them, consistent with how Order.FK_StoreId is currently handled with zero explicit Fluent config).
Authentication Strategy
Use JWT bearer authentication.
Package required: Microsoft.AspNetCore.Authentication.JwtBearer is not currently installed (Distribution.csproj only has Microsoft.AspNetCore.OpenApi, EF Core, and the MySQL provider) — add it as part of Phase 5.
Pipeline gap: Program.cs currently calls app.UseAuthorization() (line 47) with no app.UseAuthentication() before it and no AddAuthentication(...) registered in the service container — the authorization middleware is effectively a no-op today. Phase 5 must add both builder.Services.AddAuthentication(...).AddJwtBearer(...) and app.UseAuthentication() (before UseAuthorization()), not just policies.
Recommended token claims:
subor user idusernamerolerole_level
Do not put full permission lists or all scope data into the token initially. Keep permission and scope validation server-side.
Permission Model
Define permissions per feature action.
Suggested initial permission set:
Region
regions.viewregions.createregions.updateregions.delete
Route
routes.viewroutes.createroutes.updateroutes.delete
Vehicle
vehicles.viewvehicles.createvehicles.updatevehicles.delete
Store
stores.viewstores.createstores.updatestores.delete
Warehouse
warehouses.viewwarehouses.createwarehouses.updatewarehouses.delete
Order
orders.vieworders.createorders.updateorders.delete
User Management
users.viewusers.createusers.updateusers.deactivateusers.assign_roleusers.assign_scope
Suggested Role-to-Permission Mapping
This is a starting point and can be refined later.
SalesRep
orders.vieworders.createorders.updatestores.viewroutes.view
Supervisor
- all SalesRep permissions
orders.deletestores.createstores.update
AreaManager
- all Supervisor permissions
vehicles.viewwarehouses.view
AreaSupervisor
- all AreaManager permissions
routes.createroutes.update
RegionalManager
- full access to orders, stores, routes, vehicles, warehouses
regions.viewusers.view
RegionalAgency
- full access to all implemented business modules
- full user management permissions
Scope Rules
Permissions are not enough. Apply scope checks in services.
Recommended scope expectations:
SalesRep- limited to assigned stores (route-based scoping requires the territory hierarchy prerequisite above)
Supervisor- limited to assigned stores and subordinate users; route/region scoping requires the prerequisite
AreaManager/AreaSupervisor- limited to assigned route or area scope — requires the territory hierarchy prerequisite; not implementable against the current schema without it
RegionalManager- limited to assigned regions — requires the territory hierarchy prerequisite
RegionalAgency- global or region-wide based on business decision
If the prerequisite is deferred, ship UserStoreScope first (fully supported today via Order.FK_StoreId) and treat UserRegionScope/UserRouteScope as scaffolded-but-unenforced until Route.FK_RegionId/Store.FK_RouteId exist.
Authorization Behavior for Existing Features
The current API should eventually enforce both permission and scope.
Orders
GET /api/orders- return only orders inside the user's store scope (route/region scope needs the prerequisite)
GET /api/orders/{id}- allow only if the order's store is in scope
GET /api/orders/store/{storeId}- allow only if
storeIdis in scope
- allow only if
POST /api/orders- allow only if the target store is in scope
PUT /api/orders/{id}- allow only if the order's store is in scope
DELETE /api/orders/{id}- allow only if the order's store is in scope
Stores
- restrict reads and updates to assigned stores directly (
UserStoreScope); territory-based restriction (by route/region) needs the prerequisite
Routes
- restrict reads and updates to assigned route scope — needs the prerequisite (no user-facing entity currently references a Route except
DeliveryRun)
Regions
- restrict non-global roles to assigned regions only — needs the prerequisite
Warehouses and Vehicles
- restrict by linked route or regional business rules once those relationships are finalized (already correctly hedged in the original plan —
Warehouselinks only toVehicle,Vehiclehas no route/region link)
Implementation Sequence
Follow this order to reduce rework.
Phase 0: Territory Hierarchy Prerequisite (confirm first)
- Confirm with the user whether
Route.FK_RegionIdandStore.FK_RouteIdshould be added (see blocking prerequisite section above). - If confirmed, add the two FK fields to the existing
Route/Storemodels and a migration, before touching user-management code. - Resolve or explicitly defer
SaleRoute's undefined shape rather than leaving it ambiguous. - If declined, proceed with Phase 1 scoped to store-level authorization only.
Phase 1: Domain and Data Model
- Add user, role, permission, and scope models (all inheriting
Template). - Update
AppDBContext(DbSets +OnModelCreatingcomposite keys +ReportsToself-FKRestrictbehavior). - Create migration.
Phase 2: Auth Feature
Add Features/Auth/, following the same file split as every other feature:
AuthDTOs.csIAuthRepositoryonly if needed beyondIUserRepository(prefer reusingIUserRepositoryfor the username lookup rather than a new interface, to avoid an unnecessary extra abstraction)AuthService.csAuthController.cs
Recommended endpoints:
POST /api/auth/loginPOST /api/auth/refreshonly if refresh tokens are requested later
Phase 2 scope should at minimum support login with JWT issuance.
Phase 3: Role and Permission Seed Data
Seed:
- all six business roles
- the initial permission set
- role-permission mappings
Use EF model seeding (HasData in OnModelCreating) or a small startup seeding step — keep it simple, consistent with how the rest of this codebase avoids extra abstraction layers.
Phase 4: User Feature
Add Features/User/:
UserDTOs.csIUserRepository.csUserRepository.csUserService.csUserController.cs
Recommended initial endpoints:
GET /api/usersGET /api/users/{id}POST /api/usersPUT /api/users/{id}PUT /api/users/{id}/rolePUT /api/users/{id}/scopeDELETE /api/users/{id}as soft deactivate (setIsActive = false, matching how every other feature already soft-filters onIsActive— seeOrderService.GetAllAsync/GetByIdAsync)
Mutating endpoints follow the existing BeginAsync / CommitAsync / RollbackAsync try/catch pattern used in OrderService — not a bare CommitAsync call.
Phase 5: Authorization Infrastructure
Add:
Microsoft.AspNetCore.Authentication.JwtBearerpackage reference- JWT authentication configuration in
Program.cs(AddAuthentication+AddJwtBearer, plusapp.UseAuthentication()beforeapp.UseAuthorization()— both currently absent) - authorization policies
- a permission-checking service or handler
- a current-user accessor abstraction
Recommended abstractions:
ICurrentUserServiceIPermissionServiceIScopeAccessService
Register everything in Program.cs the same way existing features are registered — one AddScoped<IXRepository, XRepository>() per repository and one AddScoped<XService>() per service (services by concrete type, not behind an interface, matching current Program.cs).
Phase 6: Protect Existing Endpoints
Apply authorization incrementally:
- Auth endpoints
- User management endpoints
- Orders
- Stores
- Routes
- Warehouses and Vehicles
- Regions
Do not protect everything in one large change. Make authorization testable and reviewable per module.
Repository and Service Rules
Keep the existing architectural rule:
- Controllers remain thin (inject the service only, no logic — see
RegionController) - Services hold business logic
- Repositories perform data access only, no generic base — one hand-written interface + implementation per feature
- Only services call
_uow.CommitAsync(ct), wrapped inBeginAsync/try-catch/RollbackAsyncfor any multi-step write (seeOrderService.CreateAsync)
Permission and scope validation should happen in services, even if controller policies also exist.
DTO Guidance
Do not expose entities directly. DTOs are record types, matching every existing feature (see OrderDTOs.cs).
Auth DTOs
LoginRequestLoginResponse
User DTOs
CreateUserRequestUpdateUserRequestAssignUserRoleRequestAssignUserScopeRequestUserResponse
Role DTOs
RoleResponseRolePermissionResponse
Password Handling
Do not store plaintext passwords.
Use:
- password hash only
- a standard .NET password hasher implementation
Recommended:
Microsoft.AspNetCore.Identity.PasswordHasher<TUser>— this only requires theMicrosoft.AspNetCore.Identitypackage for the hasher utility, not full ASP.NET Core Identity (noIdentityDbContext, no Identity tables). Confirm this distinction explicitly since "Identity" is easy to over-adopt by accident.
There is no need to adopt full ASP.NET Core Identity unless that is explicitly requested later.
Recommended Minimal First Release
To avoid overbuilding, the first implementation should include only:
- user entity and role entity
- permission entity and role-permission map
- login endpoint with JWT
- create user and assign role
- store scope assignment (route/region scope assignment only if Phase 0 is accepted)
- permission checks on orders and stores first
This is enough to introduce secure access control without trying to solve every module at once.
Open Decisions To Confirm Before Coding
These should be confirmed before implementation starts:
- Blocking: Accept adding
Route.FK_RegionIdandStore.FK_RouteIdto unlock region/route-based scoping (see prerequisite section), or ship store-only scoping for the first release and defer the territory hierarchy? - Should
RegionalAgencyhave global access, or only access to assigned regions? - Should
SalesRepscope be store-based, route-based, or both? (Route-based depends on decision 0.) - Should supervisors see only direct reports, or all users below them in the hierarchy tree?
- Do you want user creation to include password setup immediately, or use a reset flow later?
- Do you want refresh tokens in the first version, or JWT login only?
Final Recommendation
For this project, the safest and most maintainable mapping is:
- hierarchy in
User.FK_ReportsToUserId - business power in
Role - action control in
Permission - data visibility in user scope tables
That model fits the current distribution modules and will scale better than hardcoding behavior by role name alone — but region/route-level scoping is only as real as the territory hierarchy data model backing it, which does not exist yet. Resolving Open Decision 0 first is what makes the rest of this plan actually buildable rather than aspirational.