Skip to content

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 tokeninfo endpoint 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:

  • AuthenticationProvider enum created in Petfolio.Service.Domain/Common/Enums/ with values: Google (1), Microsoft (2), Facebook (3)
  • User entity has Provider property (AuthenticationProvider, private set)
  • User entity has ProviderUserId property (string, private set)
  • User entity has AccountId property (Guid, private set)
  • User entity has Account navigation property
  • User.Create() method validates:
  • AccountId is not empty Guid
  • ProviderUserId is not null/empty/whitespace
  • Provider is a valid enum value
  • User.Create() throws ArgumentException with 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:

  • UserErrors class created in Petfolio.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 _users list field
  • Account entity has Users property (IReadOnlyList)
  • Account entity has AddUser(User user) method that:
  • Throws InvalidOperationException if user already exists in account
  • Adds user to the account's user list
  • Account entity has RemoveUser(Guid userId) method that:
  • Throws InvalidOperationException if user not found
  • Throws InvalidOperationException if 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()

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 AccountId property (Guid, private set)
  • Animal entity has Account navigation property
  • Animal.Create() method has accountId parameter
  • Animal.Create() validates AccountId is not empty Guid
  • Animal.Create() throws ArgumentException when AccountId is empty
  • Account entity has private _animals list field
  • Account entity has Animals property (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:

  • IUserRepository has GetByProviderAndProviderUserIdAsync() method signature:
  • Parameters: AuthenticationProvider provider, string providerUserId, CancellationToken cancellationToken = default
  • Returns: Task<User?>
  • Has XML documentation explaining the purpose (OAuth login lookup)
  • UserRepository implements GetByProviderAndProviderUserIdAsync():
  • 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:

  • UserConfiguration configures Provider property as required
  • UserConfiguration configures ProviderUserId property with max length 255, required
  • UserConfiguration configures AccountId property as required
  • UserConfiguration adds unique index on (Provider, ProviderUserId) named IX_Users_Provider_ProviderUserId
  • UserConfiguration adds unique index on Email named IX_Users_Email
  • UserConfiguration configures relationship to Account with DeleteBehavior.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:

  • AnimalConfiguration configures AccountId property as required
  • AnimalConfiguration configures relationship to Account:
  • One-to-many (Account has many Animals)
  • Foreign key on AccountId
  • DeleteBehavior.Restrict (prevents cascade delete)
  • AnimalConfiguration adds index on AccountId named IX_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:

  • IMultiTenant interface created in Petfolio.Service.Domain/Common/Interfaces/
  • IMultiTenant has AccountId property (Guid, get only)
  • IMultiTenant has SetAccountIdInternal(Guid accountId) method
  • Interface includes XML documentation explaining purpose and usage
  • Animal entity implements IMultiTenant
  • Animal implements SetAccountIdInternal() using explicit interface implementation:
  • Throws InvalidOperationException if 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:

  • ICurrentUserService interface created in Petfolio.Service.Application/Common/Interfaces/
  • Interface has UserId property (Guid?)
  • Interface has AccountId property (Guid?)
  • Interface has Email property (string?)
  • Interface has IsAuthenticated property (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:

  • PetfolioDbContext constructor accepts ICurrentUserService? (nullable for EF migrations)
  • Constructor stores ICurrentUserService in private field
  • OnModelCreating applies global query filter to Animal:
  • Filters by AccountId == _currentUserService!.AccountId!.Value
  • SaveChangesAsync overridden:
  • Iterates through ChangeTracker.Entries<IMultiTenant>() where state is Added
  • Throws InvalidOperationException if _currentUserService?.AccountId is 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.props includes Microsoft.AspNetCore.Authentication.JwtBearer version 9.0.0
  • Directory.Packages.props includes System.IdentityModel.Tokens.Jwt version 8.2.1
  • Petfolio.Service.Api.csproj references 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:

  • CurrentUserService class created in Petfolio.Service.Api/Services/ (or Infrastructure)
  • Implements ICurrentUserService
  • Injected with IHttpContextAccessor
  • UserId reads from ClaimTypes.NameIdentifier claim, parses as Guid
  • AccountId reads from custom "accountId" claim, parses as Guid
  • Email reads from ClaimTypes.Email claim
  • IsAuthenticated checks if HttpContext.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:

  • IJwtTokenService interface created with GenerateAccessToken(Guid userId, Guid accountId, string email) and GenerateRefreshToken(Guid userId) methods
  • JwtTokenService implementation created
  • Injected with IOptions<JwtSettings>
  • JwtSettings configuration 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:

  • IGoogleTokenValidator interface created with ValidateTokenAsync(string idToken) method
  • Returns GoogleTokenValidationResult with properties: IsValid, Email, GoogleUserId, Name
  • GoogleTokenValidator implementation 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, name from JWT claims
  • Returns IsValid = false on 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.json includes JwtSettings section (SecretKey, Issuer, Audience, ExpirationMinutes)
  • appsettings.Development.json includes Authentication:Google section (ClientId, ClientSecret)
  • User secrets configured for local development (instructions in code comments)
  • Program.cs configures JwtSettings from configuration
  • Program.cs validates JwtSettings are configured (throws if missing)
  • Program.cs adds JWT Bearer authentication with token validation parameters:
  • ValidateIssuer = true
  • ValidateAudience = true
  • ValidateLifetime = true
  • ValidateIssuerSigningKey = true
  • IssuerSigningKey from SecretKey
  • ClockSkew = 5 minutes
  • Program.cs adds authorization middleware
  • Program.cs registers services:
  • IHttpContextAccessor
  • ICurrentUserService -> CurrentUserService (scoped)
  • IJwtTokenService -> JwtTokenService (scoped)
  • IGoogleTokenValidator -> GoogleTokenValidator with 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:

  • AuthController created in Petfolio.Service.Api/Controllers/
  • POST /api/auth/google-login endpoint:
  • Accepts { idToken: string }
  • Validates Google ID token using IGoogleTokenValidator
  • Looks up user by (Provider=Google, ProviderUserId=sub) using IUserRepository
  • 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
  • LoginCommand and LoginCommandHandler created (CQRS pattern)
  • LoginCommandValidator validates 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

namespace Petfolio.Service.Domain.Common.Enums;

/// <summary>
/// OAuth authentication providers supported by the application.
/// </summary>
public enum AuthenticationProvider
{
    Google = 1,
    Microsoft = 2,
    Facebook = 3
}

1.2 Update User Entity to Include OAuth Information

File: Petfolio.Service.Domain/Users/User.cs

Add these properties to the User entity:

1
2
3
4
5
6
7
// OAuth provider information (stored directly on User)
public AuthenticationProvider Provider { get; private set; }
public string ProviderUserId { get; private set; } = string.Empty;

// Account relationship (user belongs to exactly ONE account)
public Guid AccountId { get; private set; }
public Account Account { get; private set; } = null!;

Update the Create method to include OAuth info:

public static User Create(
    EmailAddress email,
    Guid accountId,
    AuthenticationProvider provider,
    string providerUserId)
{
    // Validation
    if (accountId == Guid.Empty)
        throw new ArgumentException("AccountId is required", nameof(accountId));

    if (string.IsNullOrWhiteSpace(providerUserId))
        throw new ArgumentException("ProviderUserId is required", nameof(providerUserId));

    if (!Enum.IsDefined(typeof(AuthenticationProvider), provider))
        throw new ArgumentException($"Invalid provider: {provider}", nameof(provider));

    return new User
    {
        Email = email,
        AccountId = accountId,
        Provider = provider,
        ProviderUserId = providerUserId
    };
}

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:

private readonly List<User> _users = [];

/// <summary>
/// Users who have access to this account (supports family sharing)
/// </summary>
public IReadOnlyList<User> Users => _users.AsReadOnly();

/// <summary>
/// Adds a user to this account (for account invitations feature)
/// </summary>
public void AddUser(User user)
{
    if (_users.Any(u => u.Id == user.Id))
    {
        throw new InvalidOperationException($"User {user.Id} is already a member of this account");
    }

    _users.Add(user);
}

/// <summary>
/// Removes a user from this account
/// </summary>
public void RemoveUser(Guid userId)
{
    var user = _users.FirstOrDefault(u => u.Id == userId);
    if (user == null)
    {
        throw new InvalidOperationException($"User {userId} is not a member of this account");
    }

    // Business rule: Cannot remove the last user from an account
    if (_users.Count == 1)
    {
        throw new InvalidOperationException(
            "Cannot remove the last user from an account. At least one owner is required.");
    }

    _users.Remove(user);
}

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

Update File: Petfolio.Service.Domain/Animals/Animal.cs

Add AccountId property and update the Create method:

// Add to Animal class
public Guid AccountId { get; private set; }
public Account Account { get; private set; } = null!;

// Update the existing Create method signature to include accountId:
public static Animal Create(
    Name name,
    Species species,
    Breed breed,
    Sex sex,
    DateOnly dateOfBirth,
    Colour colour,
    Weight weight,
    Guid accountId)  // NEW PARAMETER
{
    // Validate AccountId
    if (accountId == Guid.Empty)
    {
        throw new ArgumentException("AccountId is required", nameof(accountId));
    }

    var animal = new Animal
    {
        Name = name,
        Species = species,
        Breed = breed,
        Sex = sex,
        DateOfBirth = dateOfBirth,
        Colour = colour,
        Weight = weight,
        AccountId = accountId  // Set the account
    };

    animal.RaiseDomainEvent(new AnimalCreatedDomainEvent(animal.Id));
    return animal;
}

Update File: Petfolio.Service.Domain/Accounts/Account.cs

Add Animals collection:

1
2
3
4
5
6
7
// Add to Account class
private readonly List<Animal> _animals = [];

/// <summary>
/// Animals belonging to this account
/// </summary>
public IReadOnlyList<Animal> Animals => _animals.AsReadOnly();

1.5 Create Error Definitions

File: Petfolio.Service.Domain/Users/Errors/UserErrors.cs

using Petfolio.Service.Domain.Common;

namespace Petfolio.Service.Domain.Users.Errors;

public static class UserErrors
{
    public static readonly PetfolioError NotFound = new(
        "User.NotFound",
        "The user with the specified identifier was not found");

    public static readonly PetfolioError EmailAlreadyExists = new(
        "User.EmailAlreadyExists",
        "A user with this email address already exists");

    public static readonly PetfolioError InvalidAccountId = new(
        "User.InvalidAccountId",
        "The specified account ID is invalid");

    public static readonly PetfolioError ProviderAlreadyLinked = new(
        "User.ProviderAlreadyLinked",
        "This OAuth provider account is already registered");
}

1.6 Update IUserRepository Interface

File: Petfolio.Service.Domain/Repositories/IUserRepository.cs

Add this method to the existing IUserRepository:

/// <summary>
/// Gets a User by OAuth provider and provider user ID.
/// Used during login to find if a Google/Microsoft account already exists.
/// </summary>
/// <param name="provider">The OAuth provider (Google, Microsoft, etc.)</param>
/// <param name="providerUserId">The user's ID from the OAuth provider (e.g., Google's 'sub' claim)</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The User if found, null otherwise</returns>
Task<User?> GetByProviderAndProviderUserIdAsync(
    AuthenticationProvider provider,
    string providerUserId,
    CancellationToken cancellationToken = default);

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 AuthenticationProvider enum
  • 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 IUserRepository with 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):

1
2
3
4
5
6
7
<ItemGroup>
  <!-- Existing packages... -->

  <!-- JWT Authentication -->
  <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.0" />
  <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
</ItemGroup>

Then reference in: Petfolio.Service.Api/Petfolio.Service.Api.csproj

1
2
3
4
<ItemGroup>
  <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
  <PackageReference Include="System.IdentityModel.Tokens.Jwt" />
</ItemGroup>

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

namespace Petfolio.Service.Domain.Common.Interfaces;

/// <summary>
/// Marker interface for entities that require tenant isolation.
/// Entities implementing this interface will automatically have AccountId set
/// and will be filtered by the current user's AccountId.
/// </summary>
public interface IMultiTenant
{
    Guid AccountId { get; }

    /// <summary>
    /// Sets the AccountId for multi-tenant entities.
    /// Should only be called by infrastructure layer (DbContext).
    /// </summary>
    void SetAccountIdInternal(Guid accountId);
}

Then update Animal entity:

File: Petfolio.Service.Domain/Animals/Animal.cs

public class Animal : Entity, IMultiTenant  // Add IMultiTenant
{
    public Guid AccountId { get; private set; }  // Already exists from Phase 1

    // ... rest of entity properties and methods ...

    /// <summary>
    /// Internal method for DbContext to set AccountId automatically.
    /// Should not be called from application code.
    /// Uses explicit interface implementation to hide from normal entity usage.
    /// </summary>
    void IMultiTenant.SetAccountIdInternal(Guid accountId)
    {
        if (AccountId != Guid.Empty)
        {
            throw new InvalidOperationException(
                "AccountId is already set and cannot be changed.");
        }

        AccountId = accountId;
    }
}

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 IMultiTenant if:
  • 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

namespace Petfolio.Service.Application.Common.Interfaces;

/// <summary>
/// Provides access to the current authenticated user's information.
/// </summary>
public interface ICurrentUserService
{
    /// <summary>
    /// Gets the current user's ID from the JWT claims.
    /// </summary>
    Guid? UserId { get; }

    /// <summary>
    /// Gets the current user's Account ID from the JWT claims.
    /// Used for multi-tenancy filtering.
    /// </summary>
    Guid? AccountId { get; }

    /// <summary>
    /// Gets the current user's email address from the JWT claims.
    /// </summary>
    string? Email { get; }

    /// <summary>
    /// Gets whether the current HTTP context has an authenticated user.
    /// </summary>
    bool IsAuthenticated { get; }
}

2.4 Update PetfolioDbContext

File: Petfolio.Service.Infrastructure/Persistence/PetfolioDbContext.cs

Replace entire file with:

using System.Reflection;
using Microsoft.EntityFrameworkCore;
using Petfolio.Service.Application.Common.Interfaces;
using Petfolio.Service.Domain.Accounts;
using Petfolio.Service.Domain.Animals;
using Petfolio.Service.Domain.Common.Interfaces;
using Petfolio.Service.Domain.Users;

namespace Petfolio.Service.Infrastructure.Persistence;

public class PetfolioDbContext : DbContext
{
    private readonly ICurrentUserService? _currentUserService;

    public PetfolioDbContext(
        DbContextOptions<PetfolioDbContext> options,
        ICurrentUserService? currentUserService = null) // Nullable for EF migrations
        : base(options)
    {
        _currentUserService = currentUserService;
    }

    public DbSet<Account> Accounts { get; set; } = null!;
    public DbSet<User> Users { get; set; } = null!;
    public DbSet<Animal> Animals { get; set; } = null!;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // ============ Global Query Filters for Multi-Tenancy ============
        // Automatically filter all queries for multi-tenant entities
        modelBuilder.Entity<Animal>()
            .HasQueryFilter(a => a.AccountId == _currentUserService!.AccountId!.Value);

        // Future: Add more multi-tenant entities here
        // modelBuilder.Entity<Photo>()
        //     .HasQueryFilter(p => p.AccountId == _currentUserService!.AccountId!.Value);

        // Apply all EF Core configurations from assembly
        modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
    }

    public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // ============ Automatic AccountId Assignment ============
        // For any new multi-tenant entity being inserted, automatically set the AccountId
        foreach (var entry in ChangeTracker.Entries<IMultiTenant>()
            .Where(e => e.State == EntityState.Added))
        {
            // Ensure we have an authenticated user context
            if (_currentUserService?.AccountId == null)
            {
                throw new InvalidOperationException(
                    "Cannot create multi-tenant entity without an authenticated user context. " +
                    "Ensure the user is authenticated and has a valid AccountId claim.");
            }

            // Only set if not already set (or is empty Guid)
            if (entry.Entity.AccountId == Guid.Empty)
            {
                // Use the interface method to set AccountId (no reflection needed!)
                entry.Entity.SetAccountIdInternal(_currentUserService.AccountId.Value);
            }
        }

        return await base.SaveChangesAsync(cancellationToken);
    }
}

Key Points:

  • ICurrentUserService is nullable to support EF migrations (which don't have an HTTP context)
  • Global Query Filters automatically filter ALL queries by AccountId
  • SaveChangesAsync override 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:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Petfolio.Service.Domain.Common;
using Petfolio.Service.Domain.Users;
using Petfolio.Service.Domain.ValueObjects;

namespace Petfolio.Service.Infrastructure.Persistence.Configurations;

public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.ToTable("Users");

        builder.HasKey(u => u.Id);

        // Email (value object)
        builder.Property(u => u.Email)
            .HasConversion(
                email => email.Value,
                value => EmailAddress.Create(value))
            .HasMaxLength(Constants.EmailMaxLength)
            .IsRequired();

        // OAuth provider information
        builder.Property(u => u.Provider)
            .IsRequired();

        builder.Property(u => u.ProviderUserId)
            .HasMaxLength(255)
            .IsRequired();

        // AccountId (foreign key)
        builder.Property(u => u.AccountId)
            .IsRequired();

        // Unique constraint: (Provider, ProviderUserId) combination must be unique
        // This prevents same Google account from being registered twice
        builder.HasIndex(u => new { u.Provider, u.ProviderUserId })
            .IsUnique()
            .HasDatabaseName("IX_Users_Provider_ProviderUserId");

        // Email is globally unique across all users
        builder.HasIndex(u => u.Email)
            .IsUnique()
            .HasDatabaseName("IX_Users_Email");

        // Relationship to Account
        builder.HasOne(u => u.Account)
            .WithMany(a => a.Users)
            .HasForeignKey(u => u.AccountId)
            .OnDelete(DeleteBehavior.Restrict); // Don't cascade delete users
    }
}

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:

// AccountId property (required for multi-tenancy)
builder.Property(a => a.AccountId)
    .IsRequired();

// Relationship to Account
builder.HasOne(a => a.Account)
    .WithMany(acc => acc.Animals)
    .HasForeignKey(a => a.AccountId)
    .OnDelete(DeleteBehavior.Restrict); // Don't cascade delete animals

// Index for efficient filtering by AccountId
builder.HasIndex(a => a.AccountId)
    .HasDatabaseName("IX_Animals_AccountId");

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:

public async Task<User?> GetByProviderAndProviderUserIdAsync(
    AuthenticationProvider provider,
    string providerUserId,
    CancellationToken cancellationToken = default)
{
    return await _context.Users
        .Include(u => u.Account)
        .FirstOrDefaultAsync(
            u => u.Provider == provider && u.ProviderUserId == providerUserId,
            cancellationToken);
}

2.8 Create Database Migration

1
2
3
dotnet ef migrations add AddAuthenticationAndMultiTenancy \
    --project Petfolio.Service.Infrastructure \
    --startup-project Petfolio.Service.Api

This migration will create:

  • Users.Provider column (int, stores enum value)
  • Users.ProviderUserId column (string, max 255)
  • Users.AccountId column (foreign key to Accounts)
  • Animals.AccountId column (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

1
2
3
dotnet ef database update \
    --project Petfolio.Service.Infrastructure \
    --startup-project Petfolio.Service.Api

2.10 Add Configuration to appsettings

File: Petfolio.Service.Api/appsettings.Development.json

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "ConnectionStrings": {
    "DefaultConnection": "Server=localhost;Port=3306;Database=petfolio;Uid=root;Pwd=root;"
  },
  "JwtSettings": {
    "SecretKey": "your-super-secret-key-must-be-at-least-32-characters-long-change-in-production",
    "Issuer": "PetfolioService",
    "Audience": "PetfolioClient",
    "ExpirationMinutes": 60
  },
  "Authentication": {
    "Google": {
      "ClientId": "your-google-client-id.apps.googleusercontent.com",
      "ClientSecret": "your-google-client-secret"
    }
  }
}

Security Best Practices:

For development, use User Secrets instead of appsettings:

1
2
3
4
5
6
7
8
9
# Initialize user secrets
dotnet user-secrets init --project Petfolio.Service.Api

# Set JWT secret
dotnet user-secrets set "JwtSettings:SecretKey" "your-super-secret-key-32-chars" --project Petfolio.Service.Api

# Set Google credentials
dotnet user-secrets set "Authentication:Google:ClientId" "your-client-id" --project Petfolio.Service.Api
dotnet user-secrets set "Authentication:Google:ClientSecret" "your-client-secret" --project Petfolio.Service.Api

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:

  1. Go to Google Cloud Console
  2. Create a new project or select existing
  3. Enable Google+ API
  4. Create OAuth 2.0 credentials
  5. 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:

using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Petfolio.Service.Api.Middleware;
using Petfolio.Service.Api.Services;
using Petfolio.Service.Application.Common.Interfaces;
using Petfolio.Service.Infrastructure.Persistence;
using Petfolio.Service.Infrastructure.Repositories;
using Petfolio.Service.Infrastructure.Services;

var builder = WebApplication.CreateBuilder(args);

// ============ Database ============
builder.Services.AddDbContext<PetfolioDbContext>(options =>
    options.UseMySql(
        builder.Configuration.GetConnectionString("DefaultConnection"),
        ServerVersion.AutoDetect(builder.Configuration.GetConnectionString("DefaultConnection"))));

// ============ JWT Settings Configuration ============
builder.Services.Configure<JwtSettings>(builder.Configuration.GetSection("JwtSettings"));
var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get<JwtSettings>();

if (jwtSettings == null || string.IsNullOrEmpty(jwtSettings.SecretKey))
{
    throw new InvalidOperationException(
        "JWT settings are not configured. Please configure JwtSettings in appsettings.json or user secrets.");
}

// ============ Authentication (JWT Bearer) ============
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidateAudience = true,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            ValidIssuer = jwtSettings.Issuer,
            ValidAudience = jwtSettings.Audience,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),
            ClockSkew = TimeSpan.FromMinutes(5) // Allow 5 minutes clock skew
        };

        // Optional: Add events for debugging
        options.Events = new JwtBearerEvents
        {
            OnAuthenticationFailed = context =>
            {
                if (context.Exception.GetType() == typeof(SecurityTokenExpiredException))
                {
                    context.Response.Headers.Add("Token-Expired", "true");
                }
                return Task.CompletedTask;
            }
        };
    });

// ============ Authorisation ============
builder.Services.AddAuthorisation();

// ============ Infrastructure Services ============
// Current user service (scoped - per HTTP request)
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();

// JWT token service
builder.Services.AddScoped<IJwtTokenService, JwtTokenService>();

// Google token validator (with HttpClient)
builder.Services.AddHttpClient<IGoogleTokenValidator, GoogleTokenValidator>();

// Repositories
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAccountRepository, AccountRepository>();
builder.Services.AddScoped<IAnimalRepository, AnimalRepository>();
// ... other repositories

// ============ Application Services (MediatR, FluentValidation, AutoMapper) ============
// Your existing MediatR, FluentValidation, and AutoMapper setup
builder.Services.AddMediatR(cfg =>
    cfg.RegisterServicesFromAssembly(typeof(Application.AssemblyReference).Assembly));
// ... rest of your application setup

// ============ API Controllers ============
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();

// ============ Swagger Configuration (with JWT support) ============
builder.Services.AddSwaggerGen(options =>
{
    options.SwaggerDoc("v1", new() { Title = "Petfolio API", Version = "v1" });

    // Add JWT authentication to Swagger
    options.AddSecurityDefinition("Bearer", new Microsoft.OpenApi.Models.OpenApiSecurityScheme
    {
        Description = "JWT Authorization header using the Bearer scheme. " +
                      "Enter 'Bearer' [space] and then your token in the text input below. " +
                      "Example: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'",
        Name = "Authorization",
        In = Microsoft.OpenApi.Models.ParameterLocation.Header,
        Type = Microsoft.OpenApi.Models.SecuritySchemeType.ApiKey,
        Scheme = "Bearer"
    });

    options.AddSecurityRequirement(new Microsoft.OpenApi.Models.OpenApiSecurityRequirement
    {
        {
            new Microsoft.OpenApi.Models.OpenApiSecurityScheme
            {
                Reference = new Microsoft.OpenApi.Models.OpenApiReference
                {
                    Type = Microsoft.OpenApi.Models.ReferenceType.SecurityScheme,
                    Id = "Bearer"
                }
            },
            Array.Empty<string>()
        }
    });
});

// ============ CORS (configure for your frontend) ============
builder.Services.AddCors(options =>
{
    options.AddPolicy("AllowFrontend", policy =>
    {
        policy.WithOrigins("http://localhost:3000", "http://localhost:5173") // React, Vite
            .AllowAnyHeader()
            .AllowAnyMethod()
            .AllowCredentials();
    });
});

var app = builder.Build();

// ============ Middleware Pipeline ============
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

app.UseCors("AllowFrontend");

// ORDER MATTERS: Authentication -> Authorisation
app.UseAuthentication();  // Must come before UseAuthorization
app.UseAuthorization();

// Custom exception handling middleware
app.UseMiddleware<ExceptionHandlingMiddleware>();

app.MapControllers();

app.Run();
  1. UseHttpsRedirection() - Force HTTPS
  2. UseCors() - Handle cross-origin requests
  3. UseAuthentication() - Validate JWT and populate User claims
  4. UseAuthorization() - Check if user is authorized for the endpoint
  5. UseMiddleware<ExceptionHandlingMiddleware>() - Handle exceptions
  6. MapControllers() - 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

using Shouldly;
using Xunit;

namespace Petfolio.Service.IntegrationTests.MultiTenancy;

[Collection(nameof(IntegrationTestCollection))]
public class TenantIsolationTests : BaseIntegrationTest
{
    public TenantIsolationTests(IntegrationTestWebApplicationFactory factory)
        : base(factory)
    {
    }

    [Fact]
    public async Task CreateAnimal_WithUserA_ShouldNotBeVisibleToUserB()
    {
        // Arrange - Create two separate accounts with users
        var (accountA, userA) = await CreateTestAccountAndUser("User A");
        var (accountB, userB) = await CreateTestAccountAndUser("User B");

        // Act - User A creates an animal
        var tokenA = GenerateJwtToken(userA.Id, accountA.Id, userA.Email.Value);
        Client.DefaultRequestHeaders.Authorization = new("Bearer", tokenA);

        var animalRequest = new { name = "Fluffy", species = "Cat" };
        var createResponse = await Client.PostAsJsonAsync("/api/animals", animalRequest);
        createResponse.EnsureSuccessStatusCode();

        // Act - User B tries to see all animals
        var tokenB = GenerateJwtToken(userB.Id, accountB.Id, userB.Email.Value);
        Client.DefaultRequestHeaders.Authorization = new("Bearer", tokenB);

        var listResponse = await Client.GetAsync("/api/animals");
        var animals = await listResponse.Content.ReadFromJsonAsync<List<AnimalDto>>();

        // Assert - User B should NOT see User A's animal
        animals.ShouldNotBeNull();
        animals.ShouldBeEmpty(); // User B has no animals
    }
}

Test SaveChanges Override

[Fact]
public async Task CreateAnimal_ShouldAutomaticallySetAccountId()
{
    // Arrange
    var (account, user) = await CreateTestAccountAndUser("Test User");
    var token = GenerateJwtToken(user.Id, account.Id, user.Email.Value);
    Client.DefaultRequestHeaders.Authorization = new("Bearer", token);

    // Act
    var animalRequest = new { name = "Buddy", species = "Dog" };
    var response = await Client.PostAsJsonAsync("/api/animals", animalRequest);

    // Assert
    response.EnsureSuccessStatusCode();
    var animal = await response.Content.ReadFromJsonAsync<AnimalDto>();
    animal.ShouldNotBeNull();

    // Verify AccountId was set automatically
    var dbAnimal = await DbContext.Animals.FindAsync(animal.Id);
    dbAnimal!.AccountId.ShouldBe(account.Id);
}

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:

Users Table:
+-- Id (Guid)
+-- Email (string, unique)
+-- Provider (int)
+-- ProviderUserId (string)
+-- AccountId (Guid)
+-- Indexes: IX_Users_Provider_ProviderUserId, IX_Users_Email

Animals Table:
+-- Id (Guid)
+-- Name, Species, Breed, etc.
+-- AccountId (Guid)
+-- Index: IX_Animals_AccountId

NO UserIdentities table
NO AspNetUsers/AspNetRoles tables

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:

-- Step 1: Create UserIdentities table
CREATE TABLE UserIdentities (
    Id CHAR(36) PRIMARY KEY,
    UserId CHAR(36) NOT NULL,
    Provider INT NOT NULL,
    ProviderUserId VARCHAR(255) NOT NULL,
    ProviderEmail VARCHAR(320) NOT NULL,
    FOREIGN KEY (UserId) REFERENCES Users(Id)
);

-- Step 2: Migrate existing data
INSERT INTO UserIdentities (Id, UserId, Provider, ProviderUserId, ProviderEmail)
SELECT UUID(), Id, Provider, ProviderUserId, Email
FROM Users;

-- Step 3: Drop columns from Users
ALTER TABLE Users
    DROP COLUMN Provider,
    DROP COLUMN ProviderUserId;

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:

  1. Implement Phase 1 (Domain Layer)
  2. Implement Phase 2 (Infrastructure)
  3. Implement Phase 4 (API/Program.cs)
  4. Create and apply database migration
  5. Test multi-tenancy isolation
  6. 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!