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¶
- User Ownership: Users should only access records they own (e.g., their own User profile)
- Role-Based Override: Higher roles (Admin, UserManager) should access any record
- Convention-Based: Minimize boilerplate with sensible defaults
- Explicit When Needed: Allow explicit configuration for complex cases
- Type Safety: Compile-time checks where possible
- 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:
- Simplest to implement - no breaking changes, minimal code
- Covers 90% of use cases - most ownership is user-based
- Easy to extend - can add Option B or C later if needed
- 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¶
- Timing Attacks: Return consistent error messages for "not found" and "access denied"
- Enumeration Prevention: Don't reveal which IDs exist vs. which are unauthorized
- Audit Logging: Log all ownership validation failures
- System Context: Respect
UserContext.RunAsSystem()bypass - Performance: Cache ownership validation results for multi-record operations
Future Enhancements¶
- Team/Organization Ownership: Extend to IOwnedByTeam, IOwnedByOrganization
- Custom Validators: Allow contracts to define custom ownership logic
- Ownership Cache: Cache ownership lookups for performance
- Field-Level Security: Hide specific fields based on ownership
- 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.