Canonical architecture and workflow summary. For endpoints see API.md; for deploy see DEPLOYMENT.md.
A multi-tenant SaaS backend on .NET 10 with:
- Complete tenant isolation via
TenantId, EF global query filters, and middleware - JWT access tokens + refresh tokens (HttpOnly cookies)
- Hybrid RBAC:
SystemRoleceiling authority + custom permission-based roles - ASP.NET Core Identity for users and roles (
Guidkeys) - Email verification via OTP (6-digit, 15-minute lifetime); dev mode bypass available
- Versioned database seeds tracked in
SeedHistory(like EF migrations) - No public self-registration — tenants onboarded by platform SystemAdmin
| Area | Choice |
|---|---|
| Runtime | .NET 10 (net10.0) |
| API | ASP.NET Core Web API, /api/v1 |
| Data | EF Core 10, SQL Server |
| Auth | JWT Bearer + ASP.NET Core Identity |
| Validation | FluentValidation |
| Logging | Serilog + request middleware |
| API docs | Swagger (open in Development and Production) |
| Deploy | GitHub Actions → MonsterASP.NET FTPS (win-x86, InProcess) |
src/
Api/ HTTP pipeline, controllers, middleware, envelope, Swagger gate
Application/ DTOs, validators, interfaces, PermissionNames / RoleNames
Domain/ Entities, ITenantEntity, IAuditableEntity
Infrastructure/ DbContext, migrations, seeds, Identity, feature services
Shared/ Minimal shared utilities
deploy/ FTP deploy helpers (app_offline, smoke test, web.config)
Services live in Infrastructure and register via DI extensions. Data access uses ApplicationDbContext and feature services directly (no generic repository).
Every ApplicationUser has a SystemRole enum stored on the user record and embedded in the JWT as the system_role claim:
| Value | Name | Scope |
|---|---|---|
1 |
SystemAdmin |
Platform-wide. Manages all tenants. Never scoped to a single tenant. |
2 |
TenantAdmin |
Scoped to one tenant. Manages users, roles, and onboarding within that tenant. |
3 |
TenantUser |
Scoped to one tenant. Operational access only (products, files, reports, profile). |
SystemRole is a ceiling — it limits which permissions can ever be granted to a user. A role cannot grant a permission whose Scope exceeds the user's SystemRole.
SystemAdmin and TenantAdmin have their authority from SystemRole alone and do not need custom roles. Custom roles only exist to assign TenantUser-scoped business permissions to TenantUsers.
Custom roles live in the Roles table and are always scoped to a single TenantId. Each role has a set of Permissions (seeded catalog, PascalCase names). Role create/update caps permissions at the TenantUser scope for every caller, including SystemAdmin — enforced centrally in IdentityRoleService, which also covers custom roles created during tenant onboarding.
| Permission module | Minimum SystemRole |
Who can hold it |
|---|---|---|
Profile.* |
TenantUser | TenantUser, TenantAdmin, SystemAdmin |
Products.* |
TenantUser | TenantUser, TenantAdmin, SystemAdmin |
Reports.* |
TenantUser | TenantUser, TenantAdmin, SystemAdmin |
Files.View, Files.Upload |
TenantUser | TenantUser, TenantAdmin, SystemAdmin |
Users.*, Roles.* (includes .List and .View as separate granular permissions) |
TenantAdmin | TenantAdmin, SystemAdmin |
Onboarding.* |
TenantAdmin | TenantAdmin, SystemAdmin |
Files.Delete |
TenantAdmin | TenantAdmin, SystemAdmin |
Tenants.* |
SystemAdmin | SystemAdmin only |
Subscriptions.* |
SystemAdmin | SystemAdmin only |
Activity logs (GET /activity-logs) are accessible to SystemAdmin only via policy (SystemAdminOnly) — not a permission, no TenantAdmin access.
Permission names are defined in Application.Common.PermissionNames. The full catalog is available at GET /api/v1/permissions.
Request arrives → JWT validated → system_role extracted
→ if SystemAdmin: granted all permissions
→ else: load role_ids from JWT → fetch permissions from DB (cached)
→ check permission ceiling against system_role
Permissions are not stored in the JWT — they are computed per request from the database (with IMemoryCache).
GET /api/v1/auth/me returns the caller's effective permissions: string[] alongside identity data. Clients use this response (which [Authorize] already blocks until resolved) to gate UI elements without a separate permissions round-trip.
- SystemAdmin:
tenant_idin JWT isGuid.Empty. To perform any tenant-scoped operation, SystemAdmin must supply theX-Tenant-Idrequest header. Without it, tenant-scoped endpoints return HTTP 400. - TenantAdmin / TenantUser:
tenant_idis fixed in the JWT. TheX-Tenant-Idheader is accepted but ignored — the JWTtenant_idis always authoritative for non-SystemAdmin callers. This prevents header-spoofing attacks.
All entities implementing ITenantEntity + IAuditableEntity (i.e., all BaseEntity subclasses: Product, FileEntity, ActivityLog, etc.) have a global EF query filter:
(!_currentTenantService.TenantId.HasValue || e.TenantId == _currentTenantService.TenantId)
&& e.DeletedAt == nullThis filter is applied automatically to every LINQ query. Service methods also add explicit WHERE TenantId = @tenantId conditions for clarity and defense-in-depth.
Invitation does not extend BaseEntity and has no global filter; it is protected by explicit service-level checks.
All tenant-scoped services inherit TenantScopedService which exposes:
RequireTenantId()— returns the current tenant ID or throws HTTP 400 if it is missing (SystemAdmin without header)IsSystemAdmin()— true whensystem_role == 1RequireUserId()— returns the current user ID
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/auth/login |
Access token + refresh token (HttpOnly cookies) |
| POST | /api/v1/auth/refresh |
Rotate tokens using refresh_token cookie |
| POST | /api/v1/auth/logout |
Revoke refresh token; clear cookies |
| POST | /api/v1/auth/verify-email |
Verify email address with OTP |
| POST | /api/v1/auth/resend-verification |
Re-send email verification OTP |
| POST | /api/v1/auth/forgot-password |
Send password reset link |
| GET | /api/v1/auth/reset-password/validate |
Validate password reset token |
| POST | /api/v1/auth/reset-password |
Complete password reset |
| GET | /api/v1/auth/me |
Current user's identity + effective permissions[] computed server-side |
Tokens are set as HttpOnly cookies (access_token, refresh_token). The access token is also returned in the response body for clients that cannot use cookies.
| Claim | Description |
|---|---|
user_id |
User GUID |
tenant_id |
Tenant GUID (Guid.Empty for SystemAdmin) |
system_role |
1 = SystemAdmin, 2 = TenantAdmin, 3 = TenantUser |
full_name |
Display name |
role_ids |
GUIDs of custom roles assigned to the user |
email |
User email |
impersonated_by_id |
(impersonation sessions only) Admin's GUID |
impersonated_by_email |
(impersonation sessions only) Admin's email |
impersonated_by_name |
(impersonation sessions only) Admin's full name |
Permissions are not in the JWT — loaded from DB per request (cached).
SystemAdmin can impersonate any active TenantUser within a tenant via POST /api/v1/impersonation/start. This generates a short-lived impersonation JWT (normal access token lifetime) with impersonated_by_* claims. The admin's refresh_token is saved as an impersonation_restore_token HttpOnly cookie and POST /api/v1/impersonation/stop uses it to restore the admin's session. Token refresh is disabled during impersonation (no refresh_token cookie).
When a user changes their password via PUT /api/v1/users/me/password, all existing refresh tokens for that user are immediately revoked (RevokeAllForUserAsync). The current access token remains valid until it naturally expires (≤ 15 min dev / ≤ 60 min prod). This ensures that if an account is compromised and the password is changed, all attacker-held sessions become invalid on the next refresh attempt.
- Login finds the user by email globally. SystemAdmin users have
TenantId == Guid.Empty; tenant users have a tenant-scopedTenantId. - Inactive tenants are detected by
UserStatusMiddlewareafter login, not during credential lookup. - Login blocks users with
EmailConfirmed = false("Your email address has not been verified"). - Login blocks users with
IsActive = false("Your account has been deactivated. Please contact your administrator."). - Login detects soft-deleted users (
DeletedAt != null) viaIgnoreQueryFilters()after the normal query returns null, and returns the same deactivated message. The EF global filter hides deleted records, so this secondary check is required to distinguish "account deleted" from "no account". - Every authenticated request also runs active checks via
UserStatusMiddleware(runs afterUseAuthentication(), beforeTenantMiddleware):- User active: checks
IsActive && DeletedAt == null(cached 5 min per user; invalidated on deactivate/delete). - Tenant active: for non-SystemAdmin users, also checks tenant
IsActive && DeletedAt == null(cached 5 min per tenant; invalidated on tenant update/delete). - Failures return
401witherrors.code = "user_inactive"or"tenant_inactive"and a human-readablemessage. - Token refresh also checks user/tenant active state; inactive refresh returns
400with same messages.
- User active: checks
- Client behaviour:
baseQueryWithReauthdetectscode = "user_inactive" | "tenant_inactive", shows a toast with the server message, and dispatches logout immediately (no refresh attempt).
New user accounts are created with EmailConfirmed based on the Features:RequireEmailVerification setting:
| Environment | RequireEmailVerification |
EmailConfirmed on creation |
Login allowed immediately? |
|---|---|---|---|
| Development | false |
true |
Yes |
| Production | true |
false |
No — OTP required |
When RequireEmailVerification: true:
- User is created with
EmailConfirmed = false. - A 6-digit OTP is generated, hashed, stored in
EmailVerificationOtps, and emailed. - The user calls
POST /auth/verify-emailwith{ email, otp }. - On success,
EmailConfirmedis set totrue; the user can now log in. POST /auth/resend-verificationissues a fresh OTP (invalidates the previous one).
This applies to users created via:
POST /api/v1/tenants(tenant onboarding — TenantAdmin)POST /api/v1/users(direct user creation)
Users created via the account-setup flow (direct-create / invitation) go through a separate token-gated flow and their EmailConfirmed is set to true on account activation.
There are three ways to create users:
POST /api/v1/tenants creates a new tenant and its first TenantAdmin in one transaction.
POST /api/v1/users — creates a TenantUser immediately with the provided password. Email verification applies if enabled.
POST /api/v1/tenant-admins (SystemAdmin only) — creates a new TenantAdmin. Sends an account-setup email; the account is inactive until the user sets their password via the setup link.
POST /api/v1/users/direct-create — TenantAdmin direct-creates a TenantUser. Sends an account-setup email; user activates via the link.
POST /api/v1/tenant-admins/invite (SystemAdmin) — invites a prospective TenantAdmin by email.
POST /api/v1/users/invite (TenantAdmin) — invites a prospective TenantUser by email.
The invited user receives a tokenized link and registers via:
GET /api/v1/invitations/validate?token=...— validate before showing the formPOST /api/v1/invitations/accept/tenant-admin— accept TenantAdmin invitationPOST /api/v1/invitations/accept/user— accept TenantUser invitation
Account-setup links (direct-create flow):
GET /api/v1/account-setup/validate?token=...— validate setup tokenPOST /api/v1/account-setup/set-password— set password and activate account
- Stored in
Infrastructure/Persistence/Migrations/ - Baseline:
InitialCreate(consolidated schema) - History table:
__EFMigrationsHistory - On startup when
ApplyMigrationsOnStartup: true: apply pending migrations only
dotnet ef migrations add Name `
--project src/Infrastructure/Infrastructure.csproj `
--startup-project src/Api/Api.csproj `
--output-dir Persistence/Migrations- Interface:
IDataSeedwith stableSeedId(e.g.20260603000002_Permissions) - Runner:
SeedRunnercompares registered seeds vsSeedHistorytable - On startup when
ApplySeedsOnStartup: true: apply pending seeds only, inSeedIdorder
| SeedId | Purpose |
|---|---|
20260603000002_Permissions |
RBAC permission catalog |
20260603000003_SuperAdmin |
SystemAdmin role + admin@system.com (requires Seeding:AdminPassword) |
Add a seed: new class in Persistence/Seed/Seeds/, register in Persistence/DependencyInjection.cs.
| Environment | Approach |
|---|---|
| Local | dotnet ef database drop --force then dotnet run --project src/Api |
| Production | Delete/recreate database in MonsterASP control panel, or drop all tables in SSMS, then redeploy |
| Concept | Implementation |
|---|---|
| Users | ApplicationUser + TenantId, SystemRole, FullName, ProfileFileId, CreatedVia (Direct / Invitation), soft delete |
| Roles | ApplicationRole + TenantId, Description (custom roles only; no built-in role rows) |
| User ↔ role | Identity AspNetUserRoles |
| Role ↔ permission | RolePermissions |
| Permissions | Permissions table (seeded) |
| Email verification | EmailVerificationOtps (UserId, OtpHash, ExpiresAt, UsedAt) |
| Addresses | Addresses table; optional FK to user or tenant |
| Seed tracking | SeedHistory table |
SystemAdmin, TenantAdmin, and TenantUser exist only as the SystemRole enum on ApplicationUser. There are no corresponding rows in the Roles table. The Roles table contains only custom tenant-scoped roles.
- User / tenant profile image:
ProfileFileId→Files;profileUrl=/api/v1/files/{id}/download - Address: separate
Addressesrow linked to user or tenant; responses includeline1,city, … andfullAddress - Set at creation: optional
addressfield accepted byPOST /tenant-admins,POST /users,POST /users/direct-create,POST /invitations/accept/tenant-admin,POST /invitations/accept/user,POST /account-setup/set-password - Update after creation via
PUT /users,PUT /users/current, orPUT /tenantswithaddress/clearAddress - Admin avatar management:
POST /users/{id}/avatar/DELETE /users/{id}/avatar(Users.Edit) — admin upload/remove for any user's avatar without switching tenant context - Admin tenant logo management:
POST /tenants/{id}/logo/DELETE /tenants/{id}/logo(Tenants.Edit) — SystemAdmin upload/remove for any tenant's logo (separate fromPOST /tenant-settings/logowhich is TenantAdmin self-service)
{
"data": { },
"message": "Success message",
"errors": null,
"traceId": "..."
}Errors are mapped by ExceptionHandlingMiddleware to the same envelope.
GET /users, GET /tenants, GET /tenant-admins, GET /roles, GET /activity-logs: page (default 1), pageSize (default 20, max 100).
All five list endpoints also accept optional sortBy and sortOrder (asc|desc) query params. Supported sortBy keys: fullName/email/lastLoginAt for users, name for tenants and roles, fullName/email for tenant-admins, createdAt for activity logs. Each endpoint falls back to its own default order when sortBy is omitted.
| Resource | SystemAdmin (with X-Tenant-Id) | TenantAdmin / TenantUser |
|---|---|---|
| Users | Users of the specified tenant | Own tenant only |
| Tenants | All tenants (paginated) | Own tenant only |
| Roles | Roles of the specified tenant | Own tenant only |
| Products / files / reports | Filtered to specified tenant | Own tenant only |
| Permissions | Full catalog (incl. Tenants.*) |
Tenant-safe (no Tenants.*) |
| Area | Route |
|---|---|
| Auth | /api/v1/auth |
| Health | /api/v1/health (+ /health EF probe) |
| Users | /api/v1/users, /current |
| Tenant Admins | /api/v1/tenant-admins (SystemAdmin only) |
| Tenants | /api/v1/tenants, /current |
| Roles | /api/v1/roles, /current |
| Products | /api/v1/products |
| Permissions | /api/v1/permissions |
| Reports | /api/v1/reports (summary, export, platform-summary, platform-export) |
| Dashboard | /api/v1/dashboard (tenant + platform metrics) |
| Subscriptions | /api/v1/subscriptions (SystemAdmin) |
| Tenant Settings | /api/v1/tenant-settings (TenantAdmin self-service) |
| Impersonation | /api/v1/impersonation (start, stop; SystemAdmin) |
| Activity Logs | /api/v1/activity-logs (SystemAdmin only) |
| Files | /api/v1/files |
| Invitations | /api/v1/invitations (public, token-gated) |
| Account setup | /api/v1/account-setup (public, token-gated) |
- Soft delete —
DeletedAt/DeletedBy; global query filters. Unique indexes onUsers (Email, TenantId)andUsers (NormalizedUserName)include aWHERE DeletedAt IS NULLfilter so soft-deleted records don't block re-creation. Creating a user with the same email after deletion always inserts a fresh record with a new ID — deleted records are left untouched as audit history.OnboardingServiceandUserManagementServicecheck for existing active users only; a conflict exception is raised if an active user with that email already exists in the tenant. - CreatedVia —
CreatedViaenum (Direct= 1,Invitation= 2) on bothApplicationUserandTenanttracks whether the record was created directly by an admin or via an invitation link. Set at creation time across all paths:Directfor onboarding/direct-create flows,Invitationfor all threeInvitationService.Accept*flows (including the tenant created byAcceptTenantCreationInvitationAsync). Existing DB rows default toDirect. - Audit fields — stamped on
SaveChangesAsyncfrom JWTuser_id - Activity logging — auth and CRUD events to
ActivityLogs - Request logging — path, status, duration, tenant/user correlation
- Caching — permission catalog, roles, tenants, products, reports (
Cachingsection in appsettings) - File storage — local disk (
FileStorage:BasePath); uploaded images (JPEG/PNG/WebP/BMP) are auto-resized (max 2048×2048) and re-encoded as WebP 85% quality via SkiaSharp - Rate limiting — auth endpoints (
10/minute) - CORS —
AllowedOriginsin configuration
Defined in Application.Options.FeatureOptions (appsettings.json section Features):
| Flag | Development default | Production default | Purpose |
|---|---|---|---|
RequireEmailVerification |
false |
true |
Controls whether new users must verify their email via OTP before logging in |
| Environment | URL | Access |
|---|---|---|
| Development | /swagger |
Open |
| Production | /swagger |
Open (toggle via Swagger:EnabledInProduction) |
Use Authorize in Swagger UI with Bearer {accessToken} from POST /auth/login.
Production: GitHub Actions → MonsterASP.NET via FTPS. See DEPLOYMENT.md.
Startup on production:
ApplyMigrationsOnStartup: trueApplySeedsOnStartup: true- Secrets injected: connection string, JWT key, admin password, CORS origins
Post-deploy smoke test: GET /api/v1/health on SITE_URL.
dotnet ef database drop --force `
--project src/Infrastructure/Infrastructure.csproj `
--startup-project src/Api/Api.csproj # optional — fresh start
dotnet run --project src/Api- Swagger:
/swagger - SystemAdmin:
admin@system.com/Seeding:AdminPasswordfrom Development config or user secrets - Email verification is disabled in Development (
Features:RequireEmailVerification: false)
- Public user registration
- Permissions inside JWT
- Custom
UserRoletable /api/v2- Redis, Hangfire, cloud blob storage, centralized log stacks
- API.md — endpoints, profiles, addresses, login
- DEPLOYMENT.md — FTP deploy, secrets, migrations & seeds
src/Application/Common/PermissionNames.cs— permission constants and scope map