docs(roles): condense roles/permissions/auth docs and align with the code

This commit is contained in:
Moritz 2026-06-15 21:53:36 +02:00
parent 07503fc6fe
commit 8d783276d0
8 changed files with 348 additions and 3836 deletions

View file

@ -63,20 +63,7 @@ During the design phase, we evaluated multiple implementation approaches to find
### Approach 1: JSONB in Roles Table
Store all permissions as a single JSONB column directly in the roles table.
**Advantages:**
- Simplest database schema (single table)
- Very flexible structure
- No additional tables needed
- Fast to implement
**Disadvantages:**
- Poor queryability (can't efficiently filter by specific permissions)
- No referential integrity
- Difficult to validate structure
- Hard to audit permission changes
- Can't leverage database indexes effectively
Store all permissions as a single JSONB column directly in the roles table. Simplest schema (single table), flexible, fast to implement — but poor queryability (can't filter by specific permissions), no referential integrity, hard to validate/audit, can't use indexes.
**Verdict:** Rejected - Poor queryability makes it unsuitable for complex permission logic.
@ -84,22 +71,7 @@ Store all permissions as a single JSONB column directly in the roles table.
### Approach 2: Normalized Database Tables
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization.
**Advantages:**
- Fully queryable with SQL
- Runtime configurable permissions
- Strong referential integrity
- Easy to audit changes
- Can index for performance
**Disadvantages:**
- Complex database schema (4+ tables)
- DB queries required for every permission check
- Requires ETS cache for performance
- Needs admin UI for permission management
- Longer implementation time (4-5 weeks)
- Overkill for fixed set of 4 permission sets
Separate tables for `permission_sets`, `permission_set_resources`, `permission_set_pages` with full normalization. Fully queryable, runtime-configurable, strong referential integrity, auditable, indexable — but complex schema (4+ tables), a DB query per check, needs ETS cache + admin UI, 4-5 weeks, overkill for 4 fixed sets.
**Verdict:** Deferred to Phase 3 - Excellent for runtime configuration but too complex for MVP.
@ -107,20 +79,7 @@ Separate tables for `permission_sets`, `permission_set_resources`, `permission_s
### Approach 3: Custom Authorizer
Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
**Advantages:**
- Complete control over authorization logic
- Can implement any custom behavior
- Not constrained by Ash Policy DSL
**Disadvantages:**
- Significantly more code to write and maintain
- Loses benefits of Ash's declarative policies
- Harder to test than built-in policy system
- Mixes declarative and imperative approaches
- Must reimplement filter generation for queries
- Higher bug risk
Implement a custom Ash Authorizer from scratch instead of using Ash Policies. Full control over logic — but significantly more code, loses Ash's declarative policies (must reimplement query filter generation), harder to test, mixes declarative/imperative, higher bug risk.
**Verdict:** Rejected - Too much custom code, reduces maintainability and loses Ash ecosystem benefits.
@ -128,21 +87,7 @@ Implement a custom Ash Authorizer from scratch instead of using Ash Policies.
### Approach 4: Simple Role Enum
Add a simple `:role` enum field directly on User resource with hardcoded checks in each policy.
**Advantages:**
- Very simple to implement (< 1 week)
- No extra tables needed
- Fast performance
- Easy to understand
**Disadvantages:**
- No separation between roles and permissions
- Can't add new roles without code changes
- No dynamic permission configuration
- Not extensible to field-level permissions
- Violates separation of concerns (role = job function, not permission set)
- Difficult to maintain as requirements grow
Add a `:role` enum field directly on User with hardcoded checks in each policy. Very simple (< 1 week), no extra tables, fast but no separation of role (job function) from permission set, can't add roles without code changes, no dynamic config, not extensible to field-level, hard to maintain as requirements grow.
**Verdict:** Rejected - Too inflexible, doesn't meet requirement for configurable permissions and role separation.
@ -150,33 +95,11 @@ Add a simple `:role` enum field directly on User resource with hardcoded checks
### Approach 5: Hardcoded Permissions with Migration Path (SELECTED for MVP)
Permission Sets hardcoded in Elixir module, only Roles table in database.
Permission Sets hardcoded in Elixir module, only Roles table in database. Fast (2-3 weeks vs 4-5), maximum performance (zero DB queries, < 1μs), pure-function testing, Git-reviewable permissions, no data migration, keeps role/permission-set separation, clear Phase 3 upgrade path. Trade-offs: permissions not editable at runtime (only role assignment), new permissions need a code deploy, unsuitable if permissions change > 1x/week, limited to the 4 predefined sets.
**Advantages:**
- Fast implementation (2-3 weeks vs 4-5 weeks)
- Maximum performance (zero DB queries, < 1 microsecond)
- Simple to test (pure functions)
- Code-reviewable permissions (visible in Git)
- No migration needed for existing data
- Clearly defined 4 permission sets as required
- Clear migration path to database-backed solution (Phase 3)
- Maintains separation of roles and permission sets
**Why Selected:** MVP requires 4 fixed sets (not custom ones), no stated need for runtime permission editing, performance is critical, fast time-to-market, and a clear upgrade path exists when runtime config becomes necessary.
**Disadvantages:**
- Permissions not editable at runtime (only role assignment possible)
- New permissions require code deployment
- Not suitable if permissions change frequently (> 1x/week)
- Limited to the 4 predefined permission sets
**Why Selected:**
- MVP requirement is for 4 fixed permission sets (not custom ones)
- No stated requirement for runtime permission editing
- Performance is critical for authorization checks
- Fast time-to-market (2-3 weeks)
- Clear upgrade path when runtime configuration becomes necessary
**Migration Path:**
When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
**Migration Path:** When runtime permission editing becomes a business requirement, migrate to Approach 2 (normalized DB tables) without changing the public API of the PermissionSets module.
---
@ -201,7 +124,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro
**Resource Level (MVP):**
- Controls create, read, update, destroy actions on resources
- Resources: Member, User, CustomFieldValue, CustomField, Role
- Resources: Member, User, CustomFieldValue, CustomField, Role, Group, MemberGroup, MembershipFeeType, MembershipFeeCycle, JoinRequest
**Page Level (MVP):**
- Controls access to LiveView pages
@ -214,7 +137,7 @@ When runtime permission editing becomes a business requirement, migrate to Appro
### Special Cases
1. **Own Credentials:** Users can always edit their own email and password
2. **Linked Member Email:** Only admins can edit email of members linked to users
2. **Linked Member Email:** Only administrators or the linked user themselves can change the email of a member linked to a user
3. **User-Member Linking:** Only admins can link/unlink users to members (except self-service creation)
---
@ -331,46 +254,39 @@ Users need to create member profiles for themselves (self-service), but only adm
- Unlink members from users
- Create members pre-linked to arbitrary users
### Selected Approach: Separate Ash Actions
### Selected Approach: Admin-Only `:user` Argument
Instead of complex field-level validation, we use action-based authorization.
Linking is **not** modelled as separate per-operation actions. The Member resource has a single
`create_member` and a single `update_member` action; linking and unlinking happen through an
optional **`:user` argument** on those actions. `user_id` is deliberately not accepted, so the
foreign key cannot be set directly.
### Actions on Member Resource
### How Linking Works on the Member Resource
**1. create_member_for_self** (All authenticated users)
- Automatically sets user_id = actor.id
- User cannot specify different user_id
- UI: "Create My Profile" button
**`create_member` / `update_member`** (the only Member write actions)
- The optional `:user` argument drives the relationship via `manage_relationship`.
- On update, `on_missing: :ignore` means omitting `:user` leaves the link unchanged
(no "unlink by omission"); unlink is explicit (`user: nil`).
- The policy check `ForbidMemberUserLinkUnlessAdmin` forbids the action for non-admins whenever the
`:user` argument is present (any value), so only admins may set or change the link.
- Non-admins can still create/update members as long as they do not pass `:user`.
**2. create_member** (Admin only)
- Can set user_id to any user or leave unlinked
- Full flexibility for admin
- UI: Admin member management form
**Self-service** ("a user creates a member linked to themselves") is handled on the **User** side:
the admin-only `update_user` action takes a `:member` argument for link/unlink, and the UI exposes
the linking controls only to admins.
**3. link_member_to_user** (Admin only)
- Updates existing member to set user_id
- Connects unlinked member to user account
### Why This Design?
**4. unlink_member_from_user** (Admin only)
- Sets user_id to nil
- Disconnects member from user account
**Single write path:** one create and one update action to reason about, instead of a fan-out of
`link_*`/`unlink_*` actions.
**5. update** (Permission-based)
- Normal updates (name, address, etc.)
- user_id NOT in accept list (prevents manipulation)
- Available to users with Member.update permission
**Centralized rule:** the admin-only constraint lives in one reusable policy check
(`ForbidMemberUserLinkUnlessAdmin`).
### Why Separate Actions?
**Server-Side Security:** `user_id` is never accepted directly, so it cannot be mass-assigned —
only argument-driven relationship management can change it.
**Explicit Semantics:** Each action has clear, single purpose
**Server-Side Security:** user_id set by server, not client input
**Better UX:** Different UI flows for different use cases
**Simple Policies:** Authorization at action level, not field level
**Easy Testing:** Each action independently testable
**Better UX:** distinct UI flows for self-service vs. admin linking.
---
@ -486,23 +402,7 @@ Use Custom Validations
**[roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md):** Complete technical specification with code examples
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Detailed implementation plan with TDD approach
**[roles-and-permissions-implementation-plan.md](./roles-and-permissions-implementation-plan.md):** Historical record of how the MVP was built (PR #346/#345)
**[CODE_GUIDELINES.md](../CODE_GUIDELINES.md):** Project coding standards
---
## Summary
The selected architecture uses **hardcoded Permission Sets in Elixir** for the MVP, providing:
- **Speed:** 2-3 weeks implementation vs 4-5 weeks
- **Performance:** Zero database queries for authorization
- **Clarity:** Permissions in Git, reviewable and testable
- **Flexibility:** Clear migration path to database-backed system
**User-Member linking** uses **separate Ash Actions** for clarity and security.
**Field-level permissions** have a **defined strategy** (Calculations + Validations) for Phase 2 implementation.
The approach balances pragmatism for MVP delivery with extensibility for future requirements.