ADR-002: Role-Based Access Control¶
Status: Proposed
Last Updated: 2026-02-16
Target: Add account-scoped role-based access control (RBAC) to govern what actions users can perform within their account
Depends on: ADR-001: Authentication and Multi-Tenancy
Overview¶
ADR-001 establishes who users are (authentication via OAuth) and whose data they can see (multi-tenancy via EF Core Global Query Filters). This ADR adds the missing layer: what actions users can perform within their account.
The solution will:
- Define a fixed set of account-scoped roles (Owner, Admin, Member)
- Store the role directly on the User entity as an enum
- Include the role as a JWT claim for stateless authorization
- Enforce permissions via ASP.NET Core policy-based authorization
- Assign roles during account creation and user invitation
Relationship to ADR-001¶
| Concern | Handled by | Mechanism |
|---|---|---|
| Who is this user? | ADR-001 (Authentication) | OAuth + JWT Bearer tokens |
| Whose data can they see? | ADR-001 (Multi-Tenancy) | EF Core Global Query Filters on AccountId |
| What can they do? | ADR-002 (This ADR) | Role enum + ASP.NET Core authorization policies |
Multi-tenancy and RBAC are complementary. Global Query Filters guarantee a user in Account A can never see Account B's animals regardless of their role. RBAC then controls whether a Member in Account A can invite new users or only view animals.
Architecture Decisions¶
-
Role Model: Enum on User Entity
AccountRoleenum with values:Owner (1),Admin (2),Member (3)- Stored as a property on the User entity (
Role, private set) - Set during account creation (creator becomes Owner) and user invitation (inviter specifies role)
- Follows ADR-001's pattern of storing identity data directly on User rather than in separate tables
-
Authorization Enforcement: ASP.NET Core Policy-Based Authorization
- Role included as a
roleclaim in the JWT access token ICurrentUserServiceextended with aRoleproperty- Named authorization policies (e.g.,
RequireOwner,RequireAdmin,RequireMember) map to role checks - Endpoints decorated with
[Authorize(Policy = "RequireAdmin")] - No external authorization library needed
- Role included as a
-
Role Hierarchy: Implicit via Policy Definitions
- Owner can do everything Admin can do; Admin can do everything Member can do
- Enforced by policy definitions, not inheritance in the enum
- Example:
RequireAdminpolicy accepts bothOwnerandAdminroles
Justification¶
Consistency with ADR-001: ADR-001 chose to store OAuth provider info directly on the User entity to avoid extra tables. A Role enum follows the same principle: one column on an existing table, zero new tables.
Simplicity: Three roles cover the current requirements. ASP.NET Core's built-in authorization middleware supports claim-based policies natively, requiring no additional libraries. The [Authorize(Policy = "...")] attribute is well-understood by .NET developers.
Stateless enforcement: Including the role in the JWT means authorization checks require no database calls on every request. The 15-minute access token expiry (from ADR-001) limits the window where a stale role is honoured after a role change.
Separation of concerns: Multi-tenancy (data isolation) remains in the infrastructure layer via Global Query Filters. Authorization (action control) lives in the API layer via endpoint policies. Neither system depends on the other, making both easier to test independently.
Proposed Roles¶
| Role | Description | Typical Capabilities |
|---|---|---|
| Owner | Account creator. One per account (transferable). | Full control: manage animals, invite/remove users, assign roles, delete account, export data |
| Admin | Trusted account member. | Manage animals, invite users (as Member or Admin), remove Members |
| Member | Standard account member. | View and manage animals (CRUD). Cannot invite users or change account settings |
Permission Matrix¶
| Action | Owner | Admin | Member |
|---|---|---|---|
| View animals | Yes | Yes | Yes |
| Create/edit/delete animals | Yes | Yes | Yes |
| Invite users | Yes | Yes (Member/Admin only) | No |
| Remove users | Yes | Yes (Members only) | No |
| Change user roles | Yes | No | No |
| Transfer ownership | Yes | No | No |
| Delete account | Yes | No | No |
| Export account data | Yes | Yes | No |
Permission matrix is initial guidance
The exact permissions may evolve during implementation. The architecture supports any permission-to-role mapping without structural changes, as policies are defined in code, not in the database schema.
Alternatives Considered¶
Role Storage Strategy¶
-
Alternative 1: Separate Role and Permission Entities
- What: Create
Role,Permission, andRolePermissionmapping tables. Each role has a set of fine-grained permissions (e.g.,animal:create,user:invite). Users are assigned roles; policies check permissions, not roles. - Why not:
- Adds 3 tables and 2 repositories for a system with 3 roles and ~10 permissions
- Requires database lookups for authorization (or complex JWT embedding)
- Contradicts ADR-001's "simplicity now, complexity later" philosophy
- Overkill until permission requirements become genuinely dynamic (e.g., custom roles per account)
- When to reconsider:
- If accounts need to define custom roles with bespoke permission sets
- If the permission matrix grows beyond ~20 distinct permissions
- If regulatory requirements demand auditable, database-driven access control
- What: Create
-
Alternative 2: Claims-Based Granular Permissions (No Roles)
- What: Skip roles entirely. Assign individual permission claims (
animal:read,animal:write,user:invite) directly to users. JWT contains a list of permission claims. - Why not:
- JWT token size grows linearly with permissions (currently minimal impact, but problematic at scale)
- Managing per-user permissions is operationally complex for small teams
- No natural grouping for the invitation flow ("invite as what?")
- Harder for users to understand than named roles
- When to reconsider:
- If two users need the same role title but different permissions
- If a customer requires per-user permission overrides
- What: Skip roles entirely. Assign individual permission claims (
-
Alternative 3: External Authorization Service (e.g., OpenFGA, Ory Keto, Casbin)
- What: Delegate authorization decisions to a dedicated policy engine. Supports relationship-based access control (ReBAC), attribute-based access control (ABAC), and complex policy graphs.
- Why not:
- Adds infrastructure dependency (another service to deploy, monitor, and maintain)
- Network latency on every authorization check (or complex local caching)
- Designed for enterprise scenarios with thousands of users and complex org hierarchies
- A pet portfolio SaaS with 3 roles does not need a policy engine
- When to reconsider:
- If the product adds resource-level sharing (e.g., "share this specific animal with another account")
- If enterprise customers require hierarchical org structures with delegated admin
- If the permission model becomes too complex to express in ASP.NET Core policies
Role Hierarchy Strategy¶
- Alternative 1: Explicit Role Inheritance in Code
- What: Define a formal hierarchy (
Owner > Admin > Member) with inheritance logic in a base class or utility. - Why not:
- Over-engineering for 3 roles
- ASP.NET Core policies already support listing multiple accepted roles
- A simple
RequireAdminpolicy that acceptsOwnerandAdminachieves the same result
- When to reconsider: If 5+ roles are added and maintaining policy definitions becomes unwieldy
- What: Define a formal hierarchy (
Considerations¶
Design Decisions¶
-
One Owner per account: The account creator becomes the Owner. Ownership can be transferred but not shared. This simplifies the "who can delete the account?" question. If the Owner leaves, they must transfer ownership first or the account becomes orphaned (requiring support intervention).
-
Role assignment during invitation: When an Admin or Owner invites a user, they specify the role. This aligns with the invitation flow from ADR-001 (BLI-3: Account multi-user support). The
Account.AddUser()method will need updating to accept a role parameter. -
No self-role-escalation: Users cannot change their own role. Only Owners can change roles (except their own). This is enforced at the endpoint level.
-
Owner cannot be removed: The
Account.RemoveUser()method (ADR-001, BLI-3) already prevents removing the last user. This ADR adds: Owners cannot be removed at all, only transferred via a dedicated ownership transfer flow.
Impact on ADR-001 Components¶
| ADR-001 Component | Required Change |
|---|---|
| User entity | Add Role property (AccountRole enum, private set) |
| User.Create() | Add role parameter (defaults to Owner for account creators) |
| Account.AddUser() | Accept role parameter for invited users |
| ICurrentUserService | Add Role property (reads from JWT role claim) |
| JWT Token Service | Include role claim in generated access tokens |
| EF Core UserConfiguration | Configure Role column (int, required) |
| Database migration | Add Role column to Users table |
| Program.cs | Register authorization policies |
Risks¶
-
Stale role in JWT after role change
- Risk: Owner changes a user's role from Admin to Member, but the user's current JWT still contains the
Adminrole claim until it expires - Mitigation: 15-minute access token expiry (ADR-001) limits the window. For immediate revocation, invalidate the user's refresh token, forcing re-authentication on next token refresh
- Alternative: Add a
RoleVersionclaim and check against the database, but this adds a database call per request and defeats stateless JWT benefits
- Risk: Owner changes a user's role from Admin to Member, but the user's current JWT still contains the
-
Owner account becomes orphaned
- Risk: Owner deletes their Google account (ADR-001 risk) or is removed, leaving an account with no Owner
- Mitigation: Prevent Owner removal without ownership transfer. Add an admin support tool for ownership reassignment. Consider a "last resort" flow where the remaining Admin can claim ownership after a waiting period
- Future consideration: Auto-promote the longest-tenured Admin if the Owner is inactive for X days
-
Role escalation via API manipulation
- Risk: User crafts a request to assign themselves a higher role
- Mitigation: Role changes go through a dedicated endpoint protected by
[Authorize(Policy = "RequireOwner")]. The endpoint reads the current user's role from the JWT, not from the request body. Role changes are validated server-side - Testing: Integration tests must verify that non-Owners receive 403 when attempting role changes
-
Invitation role abuse
- Risk: Admin invites a user as Admin, bypassing the Owner's intent
- Mitigation: Admins can only invite as Member or Admin (not Owner). Only Owners can promote to Admin. Log all role assignments for audit
- Alternative: Restrict Admins to inviting Members only, requiring Owner approval for Admin invitations
-
Migration complexity
- Risk: Existing users in the database have no Role value after migration
- Mitigation: Database migration sets all existing users to
Owner(safe default for existing single-user accounts). Validate with a data migration script that checks for accounts with multiple users
Security¶
Authorization Enforcement Points¶
| Layer | Mechanism | Purpose |
|---|---|---|
| API endpoints | [Authorize(Policy = "...")] |
Primary enforcement, rejects unauthorized requests with 403 |
| Application services | ICurrentUserService.Role check |
Secondary enforcement for complex business rules (e.g., "Admins can only invite Members") |
| Domain layer | None | Domain entities are role-agnostic; authorization is an application concern |
| Infrastructure layer | Global Query Filters (ADR-001) | Data isolation is role-independent; even Owners see only their account's data |
Best Practices¶
- Never trust role information from client requests; always read from the JWT
roleclaim server-side - Log all role changes with actor, target user, old role, new role, and timestamp
- Integration tests must verify that each role-restricted endpoint returns 403 for insufficient roles
- The
roleclaim in the JWT is a string ("Owner","Admin","Member"), not an integer, for readability in debugging and logging - Consider adding a
RoleChangedAttimestamp on the User entity for audit purposes
References¶
- ADR-001: Authentication and Multi-Tenancy - Parent authentication and multi-tenancy decisions
- ASP.NET Core Policy-Based Authorization
- ASP.NET Core Claims-Based Authorization
- OWASP Access Control Cheat Sheet