IMP-001: Authentication and Multi-Tenancy¶
Parent Decision Record
This is the implementation guide for ADR-001: Authentication and Multi-Tenancy. Read the parent ADR first for context, rationale, and alternatives considered. The feature backlog is tracked in PET-103 on Jira.
Last Updated: 2026-02-15
Approach: Manual EF Core Global Query Filters (No Finbuckle)
This guide provides the complete implementation details for all phases using manual Global Query Filters instead of Finbuckle.MultiTenant.
Updated Security Approach (2026-02-15)
The following changes were made to the original implementation approach based on security review. The complete feature backlog (21 BLIs across 7 phases) is tracked in PET-103 on Jira.
- Asymmetric JWT signing keys (RSA/ECDSA) replace symmetric HMAC-SHA256. The private key signs tokens; the public key validates. A leaked public key cannot forge tokens.
- Short-lived access tokens (15 min) + database-backed refresh tokens (7 days) replace the single 60-minute token. Refresh tokens are single-use, rotating, and revocable.
- JWKS-based Google token validation replaces the
tokeninfoendpoint call. Google's public keys are cached locally for offline validation. - Rate limiting on auth endpoints using ASP.NET Core's
AddRateLimiter()middleware. - GDPR compliance — Account deletion (Right to Erasure) and data export (Right to Portability) endpoints added.
- Frontend BLIs — Login page, signup flow, auth state management, and protected routes added.
Work Breakdown Structure¶
This section provides a high-level breakdown of implementation work items. Each item includes a description, acceptance criteria, and test requirements.
BLI-1: Add Authentication Provider Enum and User OAuth Properties¶
Description: Create the OAuth authentication provider enumeration and update the User entity to store OAuth provider information directly (Provider, ProviderUserId). Each user will be associated with exactly one OAuth provider.
Acceptance Criteria:
-
AuthenticationProviderenum created inPetfolio.Service.Domain/Common/Enums/with values: Google (1), Microsoft (2), Facebook (3) - User entity has
Providerproperty (AuthenticationProvider, private set) - User entity has
ProviderUserIdproperty (string, private set) - User entity has
AccountIdproperty (Guid, private set) - User entity has
Accountnavigation property -
User.Create()method validates: - AccountId is not empty Guid
- ProviderUserId is not null/empty/whitespace
- Provider is a valid enum value
-
User.Create()throwsArgumentExceptionwith descriptive message when validation fails
Tests:
- Domain Unit Test:
UserTests.Create_WithValidOAuthInfo_ShouldCreateUser() - Domain Unit Test:
UserTests.Create_WithEmptyAccountId_ShouldThrowArgumentException() - Domain Unit Test:
UserTests.Create_WithEmptyProviderUserId_ShouldThrowArgumentException() - Domain Unit Test:
UserTests.Create_WithInvalidProvider_ShouldThrowArgumentException()
BLI-2: Add User Error Definitions¶
Description: Create error definitions for User-related failures including duplicate emails, invalid accounts, and provider linking issues.
Acceptance Criteria:
-
UserErrorsclass created inPetfolio.Service.Domain/Users/Errors/ - Error defined:
NotFound- "User.NotFound" / "The user with the specified identifier was not found" - Error defined:
EmailAlreadyExists- "User.EmailAlreadyExists" / "A user with this email address already exists" - Error defined:
InvalidAccountId- "User.InvalidAccountId" / "The specified account ID is invalid" - Error defined:
ProviderAlreadyLinked- "User.ProviderAlreadyLinked" / "This OAuth provider account is already registered"
Tests:
- Domain Unit Test:
UserErrorsTests.AllErrors_ShouldHaveUniqueErrorCodes() - Domain Unit Test:
UserErrorsTests.AllErrors_ShouldHaveNonEmptyMessages()
BLI-3: Update Account Entity for Multi-User Support¶
Description: Update the Account entity to support multiple users per account (family sharing). Add methods to manage account members with business rule validation.
Acceptance Criteria:
- Account entity has private
_userslist field - Account entity has
Usersproperty (IReadOnlyList) - Account entity has
AddUser(User user)method that: - Throws
InvalidOperationExceptionif user already exists in account - Adds user to the account's user list
- Account entity has
RemoveUser(Guid userId)method that: - Throws
InvalidOperationExceptionif user not found - Throws
InvalidOperationExceptionif removing the last user (at least one owner required) - Removes user from the account's user list
Tests:
- Domain Unit Test:
AccountTests.AddUser_WithNewUser_ShouldAddToUsersList() - Domain Unit Test:
AccountTests.AddUser_WithExistingUser_ShouldThrowInvalidOperationException() - Domain Unit Test:
AccountTests.RemoveUser_WithMultipleUsers_ShouldRemoveUser() - Domain Unit Test:
AccountTests.RemoveUser_WithLastUser_ShouldThrowInvalidOperationException() - Domain Unit Test:
AccountTests.RemoveUser_WithNonExistentUser_ShouldThrowInvalidOperationException()
BLI-4: Link Animals to Accounts¶
Description: Add multi-tenancy support to the Animal entity by linking it to an Account. Each animal belongs to exactly one account.
Acceptance Criteria:
- Animal entity has
AccountIdproperty (Guid, private set) - Animal entity has
Accountnavigation property -
Animal.Create()method hasaccountIdparameter -
Animal.Create()validates AccountId is not empty Guid -
Animal.Create()throwsArgumentExceptionwhen AccountId is empty - Account entity has private
_animalslist field - Account entity has
Animalsproperty (IReadOnlyList)
Tests:
- Domain Unit Test:
AnimalTests.Create_WithValidAccountId_ShouldCreateAnimal() - Domain Unit Test:
AnimalTests.Create_WithEmptyAccountId_ShouldThrowArgumentException()
BLI-5: Update User Repository Interface and Implementation¶
Description: Add repository method to lookup users by OAuth provider and provider user ID. This is used during authentication to find existing users.
Acceptance Criteria:
-
IUserRepositoryhasGetByProviderAndProviderUserIdAsync()method signature: - Parameters:
AuthenticationProvider provider,string providerUserId,CancellationToken cancellationToken = default - Returns:
Task<User?> - Has XML documentation explaining the purpose (OAuth login lookup)
-
UserRepositoryimplementsGetByProviderAndProviderUserIdAsync(): - Queries Users table with Provider and ProviderUserId filter
- Includes Account navigation property
- Returns null if not found
Tests:
- Application Unit Test:
UserRepositoryTests.GetByProviderAndProviderUserIdAsync_WithExistingUser_ShouldReturnUser()(mocked) - Application Unit Test:
UserRepositoryTests.GetByProviderAndProviderUserIdAsync_WithNonExistentUser_ShouldReturnNull()(mocked) - Integration Test:
UserRepositoryIntegrationTests.GetByProviderAndProviderUserIdAsync_WithExistingUser_ShouldReturnUserWithAccount()
BLI-6: Update User EF Core Configuration and Create Migration¶
Description: Configure EF Core mappings for User OAuth properties, add unique constraints, and create database migration.
Acceptance Criteria:
-
UserConfigurationconfiguresProviderproperty as required -
UserConfigurationconfiguresProviderUserIdproperty with max length 255, required -
UserConfigurationconfiguresAccountIdproperty as required -
UserConfigurationadds unique index on(Provider, ProviderUserId)namedIX_Users_Provider_ProviderUserId -
UserConfigurationadds unique index onEmailnamedIX_Users_Email -
UserConfigurationconfigures relationship to Account withDeleteBehavior.Restrict - EF migration created and reviewed
- Migration adds Provider column (int)
- Migration adds ProviderUserId column (nvarchar(255))
- Migration adds AccountId foreign key
- Migration creates both unique indexes
Tests:
- Integration Test:
UserConfigurationTests.UserTable_ShouldHaveUniqueConstraintOnProviderAndProviderUserId() - Integration Test:
UserConfigurationTests.UserTable_ShouldHaveUniqueConstraintOnEmail() - Integration Test:
UserConfigurationTests.Insert_DuplicateProviderAndProviderUserId_ShouldThrowDbUpdateException()
BLI-7: Update Animal EF Core Configuration and Create Migration¶
Description: Configure EF Core mappings for Animal AccountId property, add relationship to Account, and create database migration.
Acceptance Criteria:
-
AnimalConfigurationconfiguresAccountIdproperty as required -
AnimalConfigurationconfigures relationship to Account: - One-to-many (Account has many Animals)
- Foreign key on
AccountId DeleteBehavior.Restrict(prevents cascade delete)-
AnimalConfigurationadds index onAccountIdnamedIX_Animals_AccountId - EF migration created and reviewed
- Migration adds AccountId column (uniqueidentifier/char(36))
- Migration adds foreign key constraint
- Migration creates index on AccountId
Tests:
- Integration Test:
AnimalConfigurationTests.AnimalTable_ShouldHaveForeignKeyToAccount() - Integration Test:
AnimalConfigurationTests.AnimalTable_ShouldHaveIndexOnAccountId() - Integration Test:
AnimalConfigurationTests.Delete_Account_WithAnimals_ShouldThrowDbUpdateException()(verifies restrict behaviour)
BLI-8: Create IMultiTenant Interface and Update Animal¶
Description: Create a marker interface for entities requiring tenant isolation. Implement interface on Animal entity with controlled AccountId setter.
Acceptance Criteria:
-
IMultiTenantinterface created inPetfolio.Service.Domain/Common/Interfaces/ -
IMultiTenanthasAccountIdproperty (Guid, get only) -
IMultiTenanthasSetAccountIdInternal(Guid accountId)method - Interface includes XML documentation explaining purpose and usage
- Animal entity implements
IMultiTenant - Animal implements
SetAccountIdInternal()using explicit interface implementation: - Throws
InvalidOperationExceptionif AccountId already set (not empty Guid) - Sets AccountId field directly
- Method is hidden from normal entity usage (explicit implementation)
Tests:
- Domain Unit Test:
AnimalTests.SetAccountIdInternal_WhenAccountIdIsEmpty_ShouldSetAccountId() - Domain Unit Test:
AnimalTests.SetAccountIdInternal_WhenAccountIdAlreadySet_ShouldThrowInvalidOperationException() - Domain Unit Test:
AnimalTests_AsIMultiTenant_ShouldNotExposeSetAccountIdInternalPublicly()(verify explicit implementation)
BLI-9: Create ICurrentUserService Interface¶
Description: Create an application-layer interface for accessing authenticated user information from JWT claims.
Acceptance Criteria:
-
ICurrentUserServiceinterface created inPetfolio.Service.Application/Common/Interfaces/ - Interface has
UserIdproperty (Guid?) - Interface has
AccountIdproperty (Guid?) - Interface has
Emailproperty (string?) - Interface has
IsAuthenticatedproperty (bool) - All properties include XML documentation explaining purpose
Tests:
- No direct tests (interface will be tested via implementation)
BLI-10: Update PetfolioDbContext for Multi-Tenancy¶
Description: Update DbContext to inject ICurrentUserService, apply global query filters for tenant isolation, and override SaveChangesAsync to automatically set AccountId.
Acceptance Criteria:
-
PetfolioDbContextconstructor acceptsICurrentUserService?(nullable for EF migrations) - Constructor stores
ICurrentUserServicein private field -
OnModelCreatingapplies global query filter to Animal: - Filters by
AccountId == _currentUserService!.AccountId!.Value -
SaveChangesAsyncoverridden: - Iterates through
ChangeTracker.Entries<IMultiTenant>()where state isAdded - Throws
InvalidOperationExceptionif_currentUserService?.AccountIdis null - Only processes entities where
AccountId == Guid.Empty - Calls
entry.Entity.SetAccountIdInternal()with AccountId from current user - Exception messages are clear and actionable
Tests:
- Integration Test:
MultiTenancyTests.Query_Animals_ShouldOnlyReturnCurrentUserAccountAnimals() - Integration Test:
MultiTenancyTests.Insert_Animal_ShouldAutomaticallySetAccountId() - Integration Test:
MultiTenancyTests.Insert_Animal_WithoutAuthenticatedUser_ShouldThrowInvalidOperationException() - Integration Test:
MultiTenancyTests.Insert_Animal_WithAccountIdAlreadySet_ShouldNotOverride()
BLI-11: Add NuGet Packages for Authentication¶
Description: Add required NuGet packages for JWT authentication and authorization.
Acceptance Criteria:
-
Directory.Packages.propsincludesMicrosoft.AspNetCore.Authentication.JwtBearerversion 9.0.0 -
Directory.Packages.propsincludesSystem.IdentityModel.Tokens.Jwtversion 8.2.1 -
Petfolio.Service.Api.csprojreferences both packages - Packages restore successfully (
dotnet restore)
Tests:
- Build Test: Project builds successfully with new packages
BLI-12: Implement CurrentUserService¶
Description: Create infrastructure implementation of ICurrentUserService that reads user information from HTTP context claims.
Acceptance Criteria:
-
CurrentUserServiceclass created inPetfolio.Service.Api/Services/(or Infrastructure) - Implements
ICurrentUserService - Injected with
IHttpContextAccessor -
UserIdreads fromClaimTypes.NameIdentifierclaim, parses as Guid -
AccountIdreads from custom "accountId" claim, parses as Guid -
Emailreads fromClaimTypes.Emailclaim -
IsAuthenticatedchecks ifHttpContext.User?.Identity?.IsAuthenticated == true - Returns null for properties when user not authenticated or claim missing
- Handles parse errors gracefully (returns null)
Tests:
- Application Unit Test:
CurrentUserServiceTests.UserId_WithValidClaim_ShouldReturnGuid() - Application Unit Test:
CurrentUserServiceTests.UserId_WithoutAuthentication_ShouldReturnNull() - Application Unit Test:
CurrentUserServiceTests.AccountId_WithValidClaim_ShouldReturnGuid() - Application Unit Test:
CurrentUserServiceTests.IsAuthenticated_WithAuthenticatedUser_ShouldReturnTrue()
BLI-13: Implement JWT Token Service¶
Updated Approach
Updated to use asymmetric keys (RSA/ECDSA) instead of symmetric HMAC-SHA256, and 15-minute access tokens + 7-day refresh tokens instead of a single 60-minute token. See PET-112 (JWT Token Service) and PET-113 (Refresh Token Support) for the updated acceptance criteria.
Description: Create service for generating JWT access tokens and refresh tokens with user claims (UserId, AccountId, Email).
Acceptance Criteria:
-
IJwtTokenServiceinterface created withGenerateAccessToken(Guid userId, Guid accountId, string email)andGenerateRefreshToken(Guid userId)methods -
JwtTokenServiceimplementation created - Injected with
IOptions<JwtSettings> -
JwtSettingsconfiguration class created with properties:PrivateKey,PublicKey,Issuer,Audience,AccessTokenExpirationMinutes,RefreshTokenExpirationDays -
GenerateAccessToken()creates JWT with claims: ClaimTypes.NameIdentifier= userId- Custom "accountId" claim = accountId
ClaimTypes.Email= email- Token signed with asymmetric key (RSA/ECDSA) using PrivateKey
- Access token expiration: 15 minutes (configurable)
-
GenerateRefreshToken()creates an opaque token, stores in database with 7-day expiry - Refresh tokens are single-use (invalidated after use) with rotation
Tests:
- Application Unit Test:
JwtTokenServiceTests.GenerateToken_ShouldReturnValidJwtToken() - Application Unit Test:
JwtTokenServiceTests.GenerateToken_ShouldIncludeAllClaims() - Application Unit Test:
JwtTokenServiceTests.GenerateToken_ShouldSetCorrectExpiration()
BLI-14: Implement Google Token Validator¶
Updated Approach
Updated to use local JWKS validation instead of calling Google's tokeninfo endpoint. See PET-114 for the updated acceptance criteria.
Description: Create service to validate Google OAuth ID tokens locally using Google's published JWKS (JSON Web Key Set) public keys, with caching for performance and resilience.
Acceptance Criteria:
-
IGoogleTokenValidatorinterface created withValidateTokenAsync(string idToken)method - Returns
GoogleTokenValidationResultwith properties:IsValid,Email,GoogleUserId,Name -
GoogleTokenValidatorimplementation created - Fetches and caches Google's JWKS from
https://www.googleapis.com/oauth2/v3/certs - Validates token signature locally using cached public keys
- Validates token audience matches configured ClientId
- Extracts
sub(Google user ID),email,namefrom JWT claims - Returns
IsValid = falseon any validation error - Falls back to cached keys if Google's JWKS endpoint is unavailable
Tests:
- Application Unit Test:
GoogleTokenValidatorTests.ValidateTokenAsync_WithValidToken_ShouldReturnValidResult()(mocked HTTP) - Application Unit Test:
GoogleTokenValidatorTests.ValidateTokenAsync_WithInvalidToken_ShouldReturnInvalidResult()(mocked HTTP) - Application Unit Test:
GoogleTokenValidatorTests.ValidateTokenAsync_WithWrongAudience_ShouldReturnInvalidResult()
BLI-15: Configure JWT Authentication in Program.cs¶
Description: Configure JWT Bearer authentication, authorization, and dependency injection for authentication services.
Acceptance Criteria:
-
appsettings.Development.jsonincludesJwtSettingssection (SecretKey, Issuer, Audience, ExpirationMinutes) -
appsettings.Development.jsonincludesAuthentication:Googlesection (ClientId, ClientSecret) - User secrets configured for local development (instructions in code comments)
-
Program.csconfiguresJwtSettingsfrom configuration -
Program.csvalidates JwtSettings are configured (throws if missing) -
Program.csadds JWT Bearer authentication with token validation parameters: - ValidateIssuer = true
- ValidateAudience = true
- ValidateLifetime = true
- ValidateIssuerSigningKey = true
- IssuerSigningKey from SecretKey
- ClockSkew = 5 minutes
-
Program.csadds authorization middleware -
Program.csregisters services: IHttpContextAccessorICurrentUserService->CurrentUserService(scoped)IJwtTokenService->JwtTokenService(scoped)IGoogleTokenValidator->GoogleTokenValidatorwith HttpClient- Middleware pipeline order: HTTPS -> CORS -> Authentication -> Authorization -> Exception Handling -> Controllers
- Swagger configured with JWT authorization support (Bearer token input)
Tests:
- Integration Test:
AuthenticationTests.Request_WithValidJwt_ShouldAuthenticate() - Integration Test:
AuthenticationTests.Request_WithoutJwt_ShouldReturn401() - Integration Test:
AuthenticationTests.Request_WithExpiredJwt_ShouldReturn401()
BLI-16: Create Authentication Endpoints (Login/Register)¶
Description: Create API endpoints for user registration and login using Google OAuth.
Acceptance Criteria:
-
AuthControllercreated inPetfolio.Service.Api/Controllers/ -
POST /api/auth/google-loginendpoint: - Accepts
{ idToken: string } - Validates Google ID token using
IGoogleTokenValidator - Looks up user by
(Provider=Google, ProviderUserId=sub)usingIUserRepository - If user exists: generates JWT and returns
{ token, user } - If user doesn't exist: creates new account, creates new user, saves, generates JWT, returns
{ token, user } - Returns 400 if token invalid
- Returns 500 on unexpected errors
-
LoginCommandandLoginCommandHandlercreated (CQRS pattern) -
LoginCommandValidatorvalidates idToken is not empty - DTOs created:
GoogleLoginRequest,AuthResponse(token, user)
Tests:
- Integration Test:
AuthControllerTests.GoogleLogin_WithValidToken_ExistingUser_ShouldReturnJwt() - Integration Test:
AuthControllerTests.GoogleLogin_WithValidToken_NewUser_ShouldCreateUserAndReturnJwt() - Integration Test:
AuthControllerTests.GoogleLogin_WithInvalidToken_ShouldReturn400() - Application Unit Test:
LoginCommandHandlerTests.Handle_WithExistingUser_ShouldReturnToken() - Application Unit Test:
LoginCommandHandlerTests.Handle_WithNewUser_ShouldCreateAccountAndUser()
BLI-17: End-to-End Multi-Tenancy Verification¶
Description: Comprehensive integration tests verifying complete multi-tenancy isolation and authentication flow.
Acceptance Criteria:
- Integration test: User A creates animal, User B cannot see it
- Integration test: User A queries animals, only sees their own
- Integration test: User A cannot update/delete User B's animal (404 due to query filter)
- Integration test: Animal created without AccountId set gets AccountId from current user automatically
- Integration test: Two users in same account can see same animals
- Integration test: User removed from account loses access to account's animals
Tests:
- Integration Test:
MultiTenancyEndToEndTests.UserA_CreatesAnimal_UserB_CannotSeeIt() - Integration Test:
MultiTenancyEndToEndTests.UserA_QueriesAnimals_OnlySeesTheirOwn() - Integration Test:
MultiTenancyEndToEndTests.UserA_CannotUpdateUserBAnimal() - Integration Test:
MultiTenancyEndToEndTests.Animal_CreatedWithoutAccountId_GetsAccountIdAutomatically() - Integration Test:
MultiTenancyEndToEndTests.TwoUsersInSameAccount_CanSeeSameAnimals()
Summary of Work Items¶
Full Feature Backlog
This implementation guide covers backend code examples and patterns. The complete feature backlog (21 BLIs across 7 phases) is tracked in PET-103 on Jira, covering backend, frontend, GDPR compliance, and E2E verification.
Backend BLIs (this document): 17
Estimated Effort (backend only):
- Domain Layer (BLI 1-4): 2-3 days
- Infrastructure Layer (BLI 5-11): 3-4 days
- Application/API Layer (BLI 12-16): 3-4 days
- Testing & Verification (BLI 17): 1 day
Backend Total: 9-12 days
Full Feature Total (incl. frontend, GDPR): 16-23 days
Dependencies:
- BLI 1-4 can be done in parallel (domain layer)
- BLI 5-7 depend on BLI 1-4 (infrastructure depends on domain)
- BLI 8-10 depend on BLI 4 (multi-tenancy depends on Animal updates)
- BLI 11-16 can be done after infrastructure is complete
- BLI 17 requires all previous items complete
Phase 1: Domain Layer - Authentication & Multi-Tenancy Foundation¶
This phase focuses on the domain layer - creating entities, value objects, enums, and repository interfaces. The domain layer has NO dependencies on infrastructure.
1.1 Create AuthenticationProvider Enum¶
File: Petfolio.Service.Domain/Common/Enums/AuthenticationProvider.cs
1.2 Update User Entity to Include OAuth Information¶
File: Petfolio.Service.Domain/Users/User.cs
Add these properties to the User entity:
Update the Create method to include OAuth info:
Key Points:
- OAuth info stored directly on User (no separate UserIdentity table)
- Each user has exactly ONE OAuth provider (cannot link Google + Microsoft)
- Provider and ProviderUserId are immutable after creation
- User belongs to exactly ONE account
1.3 Update Account Entity (Multi-User Support)¶
File: Petfolio.Service.Domain/Accounts/Account.cs
Add these properties and methods to support multiple users per account:
Important:
- Each User still belongs to only ONE Account (User.AccountId)
- Accounts can have multiple users (for family sharing)
- Email addresses are unique across the entire system
1.4 Link Animals to Accounts¶
Update File: Petfolio.Service.Domain/Animals/Animal.cs
Add AccountId property and update the Create method:
Update File: Petfolio.Service.Domain/Accounts/Account.cs
Add Animals collection:
1.5 Create Error Definitions¶
File: Petfolio.Service.Domain/Users/Errors/UserErrors.cs
1.6 Update IUserRepository Interface¶
File: Petfolio.Service.Domain/Repositories/IUserRepository.cs
Add this method to the existing IUserRepository:
Why this method?
During Google OAuth login, we receive a sub (subject/user ID) from Google. We need to check if a user with (Provider=Google, ProviderUserId=sub) already exists in our system.
Summary of Phase 1:
- Created
AuthenticationProviderenum - Added OAuth fields directly to User entity (Provider, ProviderUserId)
- User belongs to exactly one Account (AccountId on User)
- Accounts can have multiple users (family sharing support)
- Animals linked to accounts (multi-tenancy relationship)
- Created error definitions
- Updated
IUserRepositorywith OAuth lookup method
Domain layer is now complete and has ZERO infrastructure dependencies!
Phase 2: Manual Multi-Tenancy & Infrastructure Setup¶
2.1 Add NuGet Packages¶
File: Directory.Packages.props
Add these packages (NO Finbuckle packages):
Then reference in: Petfolio.Service.Api/Petfolio.Service.Api.csproj
2.2 Create IMultiTenant Interface (Recommended)¶
This makes it explicit which entities need tenant isolation and enables the SaveChanges override to automatically set AccountId.
File: Petfolio.Service.Domain/Common/Interfaces/IMultiTenant.cs
Then update Animal entity:
File: Petfolio.Service.Domain/Animals/Animal.cs
Key Points:
- Uses explicit interface implementation to hide the method from normal entity usage
- Prevents accidental calls from application code
- Validates that AccountId can only be set once
- No reflection needed - type-safe and performant
Which entities should implement IMultiTenant?
- An entity should implement
IMultiTenantif: - It represents account-owned data - The data belongs to a specific account/tenant
- It should be isolated - Users from Account A should NEVER see data from Account B
- It has a clear AccountId relationship - There's a natural foreign key to the Account
- The follow entities would not inherit
IMultiTenant Account- This is the tenant itself.User- Users belong to one account but are NOT filtered (they define the tenant context)
Note: Any future multi-tenant entities should also implement IMultiTenant.
2.3 Create ICurrentUserService Interface¶
File: Petfolio.Service.Application/Common/Interfaces/ICurrentUserService.cs
2.4 Update PetfolioDbContext¶
File: Petfolio.Service.Infrastructure/Persistence/PetfolioDbContext.cs
Replace entire file with:
Key Points:
ICurrentUserServiceis nullable to support EF migrations (which don't have an HTTP context)- Global Query Filters automatically filter ALL queries by AccountId
SaveChangesAsyncoverride automatically sets AccountId on new entities- Uses
SetAccountIdInternal()method - no reflection needed! - Type-safe, performant, and easy to debug
- Throws clear exceptions if AccountId cannot be determined
2.5 Update UserConfiguration (EF Core)¶
File: Petfolio.Service.Infrastructure/Persistence/Configurations/UserConfiguration.cs
Update the existing UserConfiguration to include OAuth fields:
Important Indexes:
IX_Users_Provider_ProviderUserId: Prevents duplicate OAuth logins (e.g., same Google account registered twice)IX_Users_Email: Enforces email uniqueness globally across all accounts
2.6 Update AnimalConfiguration¶
File: Petfolio.Service.Infrastructure/Persistence/Configurations/AnimalConfiguration.cs
Add these configurations to the existing file:
Why DeleteBehavior.Restrict?
- We don't want animals to be automatically deleted when an account is deleted
- This forces explicit handling of what happens to animals during account deletion
- You might want to transfer them, archive them, or require manual deletion
2.7 Update UserRepository Implementation¶
File: Petfolio.Service.Infrastructure/Repositories/UserRepository.cs
Add this method to the existing UserRepository:
2.8 Create Database Migration¶
This migration will create:
Users.Providercolumn (int, stores enum value)Users.ProviderUserIdcolumn (string, max 255)Users.AccountIdcolumn (foreign key to Accounts)Animals.AccountIdcolumn (foreign key to Accounts)- Indexes:
IX_Users_Provider_ProviderUserId,IX_Users_Email,IX_Animals_AccountId - NO UserIdentities table (OAuth info stored directly on User)
- NO AspNet* tables (we're not using ASP.NET Core Identity)
Review the migration before applying: Open the generated migration file and verify:
- Users table gets Provider and ProviderUserId columns
- Users table gets AccountId column
- Animal table gets AccountId column
- Foreign keys are correct
- Indexes are created
- NO UserIdentities table
- NO AspNetUsers, AspNetRoles, etc. tables
2.9 Apply Migration¶
2.10 Add Configuration to appsettings¶
File: Petfolio.Service.Api/appsettings.Development.json
Security Best Practices:
For development, use User Secrets instead of appsettings:
For production:
- Use Azure Key Vault, AWS Secrets Manager, or similar
- NEVER commit secrets to source control
- Rotate secrets regularly
- Use different secrets for each environment
Get Google OAuth credentials:
- Go to Google Cloud Console
- Create a new project or select existing
- Enable Google+ API
- Create OAuth 2.0 credentials
- Add authorized redirect URIs
Phase 4: API Layer - Updated Program.cs¶
This section replaces the Finbuckle-based Program.cs configuration with manual multi-tenancy.
4.6 Update Program.cs¶
File: Petfolio.Service.Api/Program.cs
Replace the authentication and multi-tenancy sections with:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 | |
UseHttpsRedirection()- Force HTTPSUseCors()- Handle cross-origin requestsUseAuthentication()- Validate JWT and populate User claimsUseAuthorization()- Check if user is authorized for the endpointUseMiddleware<ExceptionHandlingMiddleware>()- Handle exceptionsMapControllers()- Route to controllers
Testing the Implementation¶
Test Multi-Tenancy Isolation¶
Create an integration test to verify tenant isolation:
File: Tests/Petfolio.Service.IntegrationTests/MultiTenancy/TenantIsolationTests.cs
Test SaveChanges Override¶
Summary¶
What We Built:¶
Simplified Identity Model:
- OAuth info (Provider, ProviderUserId) stored directly on User entity
- NO UserIdentity table - simpler database schema
- Each user has exactly ONE OAuth provider (Google OR Microsoft, not both)
- Users belong to exactly ONE account
- Accounts can have multiple users (family sharing supported)
- Email addresses are globally unique
Manual Multi-Tenancy:
- Manual Global Query Filters for automatic tenant isolation (~50 lines of code)
- SaveChanges override for automatic AccountId assignment
- Zero external dependencies for multi-tenancy
- Full visibility and control
- Easy to test and debug
Database Schema:
Trade-offs Accepted:¶
Limitations:
- Users cannot link multiple OAuth providers (must choose Google OR Microsoft)
- Users cannot switch providers without data migration
- Email is globally unique (user cannot exist in multiple accounts)
- Users cannot be members of multiple accounts with same email
Benefits:
- Simpler implementation (~1 day vs ~2-3 days for UserIdentity approach)
- Fewer database tables (3 vs 4)
- Fewer repositories to implement
- Faster authentication (one less table join)
- Easier to understand and maintain
- Appropriate for small-medium scale
Migration Path to UserIdentity (If Needed Later):¶
If users frequently request multi-provider support, you can migrate:
Then update code to use UserIdentity entity. Domain logic doesn't change much.
Key Differences from Original ADR:¶
| Aspect | Original (UserIdentity) | This Implementation |
|---|---|---|
| Tables | Users, UserIdentities, Accounts, Animals | Users, Accounts, Animals |
| OAuth Storage | Separate UserIdentity table | Directly on User entity |
| Providers per User | Multiple (Google + Microsoft) | One only |
| Users per Account | Multiple | Multiple |
| Accounts per User | Potentially multiple (future) | One only |
| Email Uniqueness | Per account | Globally unique |
| Implementation Time | ~2-3 days | ~1 day |
| Flexibility | High | Medium |
| Complexity | Medium | Low |
Implementation Order¶
The full feature (PET-103) spans 7 phases with 21 BLIs. The dependency graph below shows the implementation order across all phases:
graph TD
subgraph "Phase 1: Domain Layer"
BLI1[BLI-1: User OAuth Props]
BLI2[BLI-2: User Errors]
BLI3[BLI-3: Account Multi-User]
BLI4[BLI-4: Animal + IMultiTenant]
end
subgraph "Phase 2: Infrastructure"
BLI5[BLI-5: EF Core Config + Migration]
BLI6[BLI-6: User Repository]
BLI7[BLI-7: DbContext Multi-Tenancy]
end
subgraph "Phase 3: Auth Services"
BLI8[BLI-8: CurrentUserService]
BLI9[BLI-9: JWT Token Service]
BLI10[BLI-10: Refresh Tokens]
BLI11[BLI-11: Google JWKS Validator]
BLI12[BLI-12: Auth Pipeline Config]
end
subgraph "Phase 4: API Endpoints"
BLI13[BLI-13: Google Login Endpoint]
BLI14[BLI-14: Token Refresh Endpoint]
end
subgraph "Phase 5: Frontend"
BLI15[BLI-15: Auth State + Routes]
BLI16[BLI-16: Login Page]
BLI17[BLI-17: Signup Flow]
BLI18[BLI-18: Logout]
end
subgraph "Phase 6: Compliance"
BLI19[BLI-19: Account Deletion]
BLI20[BLI-20: Data Export]
end
subgraph "Phase 7: Verification"
BLI21[BLI-21: E2E Tests]
end
BLI1 --> BLI5
BLI2 --> BLI5
BLI3 --> BLI5
BLI4 --> BLI5
BLI5 --> BLI6
BLI5 --> BLI7
BLI8 --> BLI7
BLI9 --> BLI10
BLI5 --> BLI10
BLI8 --> BLI12
BLI9 --> BLI12
BLI11 --> BLI12
BLI12 --> BLI13
BLI6 --> BLI13
BLI7 --> BLI13
BLI10 --> BLI13
BLI12 --> BLI14
BLI10 --> BLI14
BLI13 --> BLI15
BLI14 --> BLI15
BLI15 --> BLI16
BLI15 --> BLI17
BLI16 --> BLI17
BLI15 --> BLI18
BLI13 --> BLI19
BLI7 --> BLI19
BLI13 --> BLI20
BLI7 --> BLI20
BLI19 --> BLI21
BLI20 --> BLI21
BLI18 --> BLI21
BLI17 --> BLI21
Parallelism opportunities:
- Phase 1: BLIs 1-4 can all be done in parallel (domain layer, no dependencies)
- Phase 3: BLIs 9 and 11 can be done in parallel (independent services)
- Phase 5: BLIs 16, 17, and 18 can be partially parallelised once BLI-15 is complete
- Phase 6: BLIs 19 and 20 can be done in parallel
Next Steps:¶
- Implement Phase 1 (Domain Layer)
- Implement Phase 2 (Infrastructure)
- Implement Phase 4 (API/Program.cs)
- Create and apply database migration
- Test multi-tenancy isolation
- Implement authentication endpoints (Phase 3 - see original ADR)
The beauty of clean architecture: if you need to migrate to UserIdentity later, your domain layer doesn't change at all!