Skip to content

GetByIdQuery Ownership Validation - Design Options

This document outlines potential approaches for implementing ownership validation in GetByIdQuery<TContract>. This is Phase 2 functionality that was deferred during the initial contract-level security implementation.

Current Implementation Status

Phase 1 (Completed): - Contract-level security via [RequiresRoles] attributes on IContract types - GetAllQuery validates users have required roles to access contract types - GetByIdQuery validates contract-level roles (same as GetAll) - Public role for unauthenticated access - Role hierarchy (Admin → Member → Public) - System context bypass

Phase 2 (To be implemented): - Ownership validation for GetByIdQuery (record-level security) - Allow users to access only their own records (e.g., User can only access their own UserContract) - Support role-based overrides (e.g., Admin can access any record)

Design Goals

  1. User Ownership: Users should only access records they own (e.g., their own User profile)
  2. Role-Based Override: Higher roles (Admin, UserManager) should access any record
  3. Convention-Based: Minimize boilerplate with sensible defaults
  4. Explicit When Needed: Allow explicit configuration for complex cases
  5. Type Safety: Compile-time checks where possible
  6. Backward Compatible: Don't break existing queries

Option A: Convention-Based (UserId Property)

Approach: Look for a UserId property on the contract by convention.

Implementation

public class GetByIdQueryHandler<TContract> : IQueryHandler<GetByIdQuery<TContract>, TContract>
    where TContract : IContract
{
    private bool ValidateOwnership(TContract record, ClaimsPrincipal currentUser)
    {
        // Check if contract has UserId property
        var userIdProperty = typeof(TContract).GetProperty("UserId");
        if (userIdProperty != null)
        {
            var recordUserId = userIdProperty.GetValue(record)?.ToString();
            var currentUserId = currentUser.GetUserId();

            return recordUserId == currentUserId;
        }

        // No UserId property = no ownership validation
        return true;
    }
}

Pros

  • Simple and intuitive
  • No additional attributes or interfaces needed
  • Works for 90% of use cases
  • No breaking changes to existing contracts

Cons

  • Only works for contracts with exactly "UserId" property name
  • Reflection-based (slight performance cost)
  • No compile-time safety
  • Can't handle multiple ownership patterns (e.g., TeamId, OrganizationId)

Example Contracts

// Ownership validation enabled (has UserId)
[RequiresRoles(RoleDefinition.Member)]
public class UserContract : IContract
{
    public Guid Id { get; init; }
    public Guid UserId { get; init; } // ← Convention: this enables ownership validation
    public string Email { get; init; }
}

// No ownership validation (no UserId)
[RequiresRoles(RoleDefinition.Public)]
public class TeamContract : IContract
{
    public Guid Id { get; init; }
    public string Name { get; init; }
}

Option B: Interface-Based (IOwnedByUser)

Approach: Contracts implement IOwnedByUser interface to explicitly enable ownership validation.

Implementation

public interface IOwnedByUser
{
    Guid UserId { get; }
}

public class GetByIdQueryHandler<TContract> : IQueryHandler<GetByIdQuery<TContract>, TContract>
    where TContract : IContract
{
    private bool ValidateOwnership(TContract record, ClaimsPrincipal currentUser)
    {
        if (record is IOwnedByUser ownedRecord)
        {
            var currentUserId = currentUser.GetUserId();
            return ownedRecord.UserId.ToString() == currentUserId;
        }

        // Not IOwnedByUser = no ownership validation
        return true;
    }
}

Pros

  • Explicit and type-safe
  • Easy to discover which contracts have ownership validation
  • Can extend to other ownership patterns (IOwnedByTeam, IOwnedByOrganization)
  • Compile-time enforcement

Cons

  • Requires changing all owned contracts to implement interface
  • More verbose than convention-based
  • Breaking change if retrofitting to existing contracts

Example Contracts

// Ownership validation enabled (implements IOwnedByUser)
[RequiresRoles(RoleDefinition.Member)]
public class UserContract : IContract, IOwnedByUser
{
    public Guid Id { get; init; }
    public Guid UserId { get; init; }
    public string Email { get; init; }
}

// No ownership validation (doesn't implement IOwnedByUser)
[RequiresRoles(RoleDefinition.Public)]
public class TeamContract : IContract
{
    public Guid Id { get; init; }
    public string Name { get; init; }
}

Extended Ownership Patterns

public interface IOwnedByTeam
{
    Guid TeamId { get; }
}

public interface IOwnedByOrganization
{
    Guid OrganizationId { get; }
}

// Example: Contract owned by both User and Team
[RequiresRoles(RoleDefinition.Member)]
public class ProjectContract : IContract, IOwnedByUser, IOwnedByTeam
{
    public Guid Id { get; init; }
    public Guid UserId { get; init; }
    public Guid TeamId { get; init; }

    // User can access if they own it OR if they're on the team
}

Option C: Attribute-Based (OwnershipPropertyAttribute)

Approach: Use attributes to specify which property determines ownership.

Implementation

[AttributeUsage(AttributeTargets.Property)]
public class OwnershipPropertyAttribute : Attribute
{
}

public class GetByIdQueryHandler<TContract> : IQueryHandler<GetByIdQuery<TContract>, TContract>
    where TContract : IContract
{
    private bool ValidateOwnership(TContract record, ClaimsPrincipal currentUser)
    {
        var properties = typeof(TContract).GetProperties()
            .Where(p => p.GetCustomAttribute<OwnershipPropertyAttribute>() != null)
            .ToList();

        if (!properties.Any())
        {
            // No ownership properties = no validation
            return true;
        }

        var currentUserId = currentUser.GetUserId();

        // Check if any ownership property matches current user
        foreach (var prop in properties)
        {
            var value = prop.GetValue(record)?.ToString();
            if (value == currentUserId)
            {
                return true;
            }
        }

        return false;
    }
}

Pros

  • Flexible - works with any property name
  • Explicit about which properties define ownership
  • Supports multiple ownership properties (OR logic)
  • No interface pollution

Cons

  • Reflection-based (performance cost)
  • Less discoverable than interfaces
  • No compile-time safety
  • Easy to forget adding the attribute

Example Contracts

// Ownership validation enabled (has [OwnershipProperty])
[RequiresRoles(RoleDefinition.Member)]
public class UserContract : IContract
{
    public Guid Id { get; init; }

    [OwnershipProperty] // ← User can access if this matches their user ID
    public Guid UserId { get; init; }

    public string Email { get; init; }
}

// Multiple ownership options (OR logic)
[RequiresRoles(RoleDefinition.Member)]
public class ProjectContract : IContract
{
    public Guid Id { get; init; }

    [OwnershipProperty] // ← User can access if they're the owner
    public Guid OwnerId { get; init; }

    [OwnershipProperty] // ← OR if they're a team member
    public Guid TeamId { get; init; }
}

Option D: Hybrid (Convention + Interface)

Approach: Combine convention-based (Option A) with interface-based (Option B) for flexibility.

Implementation

public interface IOwnedByUser
{
    Guid UserId { get; }
}

public class GetByIdQueryHandler<TContract> : IQueryHandler<GetByIdQuery<TContract>, TContract>
    where TContract : IContract
{
    private bool ValidateOwnership(TContract record, ClaimsPrincipal currentUser)
    {
        var currentUserId = currentUser.GetUserId();

        // Approach 1: Interface-based (explicit)
        if (record is IOwnedByUser ownedRecord)
        {
            return ownedRecord.UserId.ToString() == currentUserId;
        }

        // Approach 2: Convention-based (implicit)
        var userIdProperty = typeof(TContract).GetProperty("UserId");
        if (userIdProperty != null)
        {
            var recordUserId = userIdProperty.GetValue(record)?.ToString();
            return recordUserId == currentUserId;
        }

        // No ownership validation
        return true;
    }
}

Pros

  • Flexible - supports both explicit and implicit ownership
  • Works with legacy contracts (convention) and new contracts (interface)
  • Gradual migration path
  • Easy to retrofit

Cons

  • Two ways to do the same thing (confusion)
  • Reflection overhead for convention-based contracts
  • Harder to enforce consistency

Role-Based Override Logic

Regardless of which option is chosen, ownership validation should support role-based overrides:

private bool ValidateOwnership(TContract record, ClaimsPrincipal currentUser)
{
    // Role-based override: Admins can access any record
    var userRoles = currentUser.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray();
    if (RoleHierarchy.HasRole(userRoles, RoleDefinition.Admin))
    {
        return true; // Admins bypass ownership validation
    }

    // Contract-specific role overrides
    var requiresRolesAttr = typeof(TContract).GetCustomAttribute<RequiresRolesAttribute>();
    if (requiresRolesAttr != null)
    {
        // If contract allows UserManager, they can access any record of this type
        if (requiresRolesAttr.Roles.Contains(RoleDefinition.UserManager) &&
            RoleHierarchy.HasRole(userRoles, RoleDefinition.UserManager))
        {
            return true;
        }
    }

    // ... check ownership property ...
}

Recommendation

Start with Option A (Convention-Based) for Phase 2:

  1. Simplest to implement - no breaking changes, minimal code
  2. Covers 90% of use cases - most ownership is user-based
  3. Easy to extend - can add Option B or C later if needed
  4. Performance acceptable - reflection only on GetById, not GetAll

Migration path: - Phase 2.1: Implement Option A (convention-based UserId) - Phase 2.2: Add Option B interfaces for complex ownership patterns - Phase 2.3: Consider Option C attributes if convention doesn't scale


Example: Complete Flow with Option A

// 1. User makes request
GET /api/readmodel?queryType=GetById_User&id=abc-123

// 2. Contract definition
[RequiresRoles(RoleDefinition.Member)]
public class UserContract : IContract
{
    public Guid Id { get; init; }
    public Guid UserId { get; init; } // ← Convention: enables ownership validation
    public string Email { get; init; }
}

// 3. GetByIdQueryHandler logic
public async Task<TContract> HandleAsync(GetByIdQuery<TContract> query, ...)
{
    // A. Validate contract-level roles (Member required)
    ValidateContractAccess(typeof(TContract), currentUser);

    // B. Load record from database
    var record = await session.LoadAsync<TContract>(query.Id);

    if (record == null)
    {
        return null;
    }

    // C. Validate ownership (UserId must match current user, unless Admin)
    if (!ValidateOwnership(record, currentUser))
    {
        throw new QueryAuthorizationException($"User does not own record {query.Id}");
    }

    return record;
}

// 4. Ownership validation logic
private bool ValidateOwnership(TContract record, ClaimsPrincipal currentUser)
{
    // Admin bypass
    var userRoles = currentUser.FindAll(ClaimTypes.Role).Select(c => c.Value).ToArray();
    if (RoleHierarchy.HasRole(userRoles, RoleDefinition.Admin))
    {
        return true;
    }

    // Check UserId property (convention)
    var userIdProperty = typeof(TContract).GetProperty("UserId");
    if (userIdProperty != null)
    {
        var recordUserId = userIdProperty.GetValue(record)?.ToString();
        var currentUserId = currentUser.GetUserId();

        return recordUserId == currentUserId;
    }

    // No ownership property = no validation required
    return true;
}

Security Considerations

  1. Timing Attacks: Return consistent error messages for "not found" and "access denied"
  2. Enumeration Prevention: Don't reveal which IDs exist vs. which are unauthorized
  3. Audit Logging: Log all ownership validation failures
  4. System Context: Respect UserContext.RunAsSystem() bypass
  5. Performance: Cache ownership validation results for multi-record operations

Future Enhancements

  1. Team/Organization Ownership: Extend to IOwnedByTeam, IOwnedByOrganization
  2. Custom Validators: Allow contracts to define custom ownership logic
  3. Ownership Cache: Cache ownership lookups for performance
  4. Field-Level Security: Hide specific fields based on ownership
  5. Audit Trail: Track who accessed which records

Decision Record

Date: 2025-10-20

Decision: Defer ownership validation to Phase 2, start with Option A (convention-based UserId)

Rationale: - Phase 1 focuses on contract-level security (type-based access control) - Option A is the simplest approach that solves 90% of use cases - Can be extended to Option B or C if needed - Maintains backward compatibility with existing contracts

Next Steps: 1. Implement Phase 1 (contract-level security) Complete 2. Document ownership validation options Complete 3. Implement Option A (convention-based) in Phase 2 Pending 4. Add unit tests for ownership validation Pending 5. Evaluate Option B or C if convention doesn't scale Pending


This document is a living specification and should be updated as the ownership validation system evolves.