Merge branch 'main' into feature/273_member_fields
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
commit
0c8a255476
13 changed files with 1659 additions and 131 deletions
|
|
@ -17,6 +17,10 @@ defmodule Mv.Accounts.User do
|
|||
# When a member is deleted, set the user's member_id to NULL
|
||||
# This allows users to continue existing even if their linked member is removed
|
||||
reference :member, on_delete: :nilify
|
||||
|
||||
# When a role is deleted, prevent deletion if users are assigned to it
|
||||
# This protects critical roles from accidental deletion
|
||||
reference :role, on_delete: :restrict
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -357,6 +361,12 @@ defmodule Mv.Accounts.User do
|
|||
# This automatically creates a `member_id` attribute in the User table
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
belongs_to :member, Mv.Membership.Member
|
||||
|
||||
# 1:1 relationship - User belongs to a Role
|
||||
# This automatically creates a `role_id` attribute in the User table
|
||||
# The relationship is optional (allow_nil? true by default)
|
||||
# Foreign key constraint: on_delete: :restrict (prevents deleting roles assigned to users)
|
||||
belongs_to :role, Mv.Authorization.Role
|
||||
end
|
||||
|
||||
identities do
|
||||
|
|
|
|||
31
lib/mv/authorization/authorization.ex
Normal file
31
lib/mv/authorization/authorization.ex
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule Mv.Authorization do
|
||||
@moduledoc """
|
||||
Ash Domain for authorization and role management.
|
||||
|
||||
## Resources
|
||||
- `Role` - User roles that reference permission sets
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- Role CRUD: `create_role/1`, `list_roles/0`, `update_role/2`, `destroy_role/1`
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
"""
|
||||
use Ash.Domain,
|
||||
extensions: [AshAdmin.Domain, AshPhoenix]
|
||||
|
||||
admin do
|
||||
show? true
|
||||
end
|
||||
|
||||
resources do
|
||||
resource Mv.Authorization.Role do
|
||||
define :create_role, action: :create_role
|
||||
define :list_roles, action: :read
|
||||
define :get_role, action: :read, get_by: [:id]
|
||||
define :update_role, action: :update_role
|
||||
define :destroy_role, action: :destroy
|
||||
end
|
||||
end
|
||||
end
|
||||
294
lib/mv/authorization/permission_sets.ex
Normal file
294
lib/mv/authorization/permission_sets.ex
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
defmodule Mv.Authorization.PermissionSets do
|
||||
@moduledoc """
|
||||
Defines the four hardcoded permission sets for the application.
|
||||
|
||||
Each permission set specifies:
|
||||
- Resource permissions (what CRUD operations on which resources)
|
||||
- Page permissions (which LiveView pages can be accessed)
|
||||
- Scopes (own, linked, all)
|
||||
|
||||
## Permission Sets
|
||||
|
||||
1. **own_data** - Default for "Mitglied" role
|
||||
- Can only access own user data and linked member/custom field values
|
||||
- Cannot create new members or manage system
|
||||
|
||||
2. **read_only** - For "Vorstand" and "Buchhaltung" roles
|
||||
- Can read all member data
|
||||
- Cannot create, update, or delete
|
||||
|
||||
3. **normal_user** - For "Kassenwart" role
|
||||
- Create/Read/Update members (no delete for safety), full CRUD on custom field values
|
||||
- Cannot manage custom fields or users
|
||||
|
||||
4. **admin** - For "Admin" role
|
||||
- Unrestricted access to all resources
|
||||
- Can manage users, roles, custom fields
|
||||
|
||||
## Usage
|
||||
|
||||
# Get permissions for a role's permission set
|
||||
permissions = PermissionSets.get_permissions(:admin)
|
||||
|
||||
# Check if a permission set name is valid
|
||||
PermissionSets.valid_permission_set?("read_only") # => true
|
||||
|
||||
# Convert string to atom safely
|
||||
{:ok, atom} = PermissionSets.permission_set_name_to_atom("own_data")
|
||||
|
||||
## Performance
|
||||
|
||||
All functions are pure and intended to be constant-time. Permission lookups
|
||||
are very fast (typically < 1 microsecond in practice) as they are simple
|
||||
pattern matches and map lookups with no database queries or external calls.
|
||||
"""
|
||||
|
||||
@type scope :: :own | :linked | :all
|
||||
@type action :: :read | :create | :update | :destroy
|
||||
|
||||
@type resource_permission :: %{
|
||||
resource: String.t(),
|
||||
action: action(),
|
||||
scope: scope(),
|
||||
granted: boolean()
|
||||
}
|
||||
|
||||
@type permission_set :: %{
|
||||
resources: [resource_permission()],
|
||||
pages: [String.t()]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Returns the list of all valid permission set names.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.all_permission_sets()
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec all_permission_sets() :: [atom()]
|
||||
def all_permission_sets do
|
||||
[:own_data, :read_only, :normal_user, :admin]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns permissions for the given permission set.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> permissions = PermissionSets.get_permissions(:admin)
|
||||
iex> Enum.any?(permissions.resources, fn p ->
|
||||
...> p.resource == "User" and p.action == :destroy
|
||||
...> end)
|
||||
true
|
||||
|
||||
iex> PermissionSets.get_permissions(:invalid)
|
||||
** (ArgumentError) invalid permission set: :invalid. Must be one of: [:own_data, :read_only, :normal_user, :admin]
|
||||
"""
|
||||
@spec get_permissions(atom()) :: permission_set()
|
||||
|
||||
def get_permissions(set) when set not in [:own_data, :read_only, :normal_user, :admin] do
|
||||
raise ArgumentError,
|
||||
"invalid permission set: #{inspect(set)}. Must be one of: #{inspect(all_permission_sets())}"
|
||||
end
|
||||
|
||||
def get_permissions(:own_data) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can always read/update own credentials
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read/update linked member
|
||||
%{resource: "Member", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :linked, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read/update custom field values of linked member
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :linked, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :linked, granted: true},
|
||||
|
||||
# CustomField: Can read all (needed for forms)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
# Home page
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Linked member detail (filtered by policy)
|
||||
"/members/:id"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:read_only) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Can read all members, no modifications
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Can read all custom field values
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Can read all
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
# Member list
|
||||
"/members",
|
||||
# Member detail
|
||||
"/members/:id",
|
||||
# Custom field values overview
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:normal_user) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Can read/update own credentials only
|
||||
%{resource: "User", action: :read, scope: :own, granted: true},
|
||||
%{resource: "User", action: :update, scope: :own, granted: true},
|
||||
|
||||
# Member: Full CRUD except destroy (safety)
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
# Note: destroy intentionally omitted for safety
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Read only (admin manages definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
"/",
|
||||
# Own profile
|
||||
"/profile",
|
||||
"/members",
|
||||
# Create member
|
||||
"/members/new",
|
||||
"/members/:id",
|
||||
# Edit member
|
||||
"/members/:id/edit",
|
||||
"/custom_field_values",
|
||||
# Custom field value detail
|
||||
"/custom_field_values/:id",
|
||||
"/custom_field_values/new",
|
||||
"/custom_field_values/:id/edit"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(:admin) do
|
||||
%{
|
||||
resources: [
|
||||
# User: Full management including other users
|
||||
%{resource: "User", action: :read, scope: :all, granted: true},
|
||||
%{resource: "User", action: :create, scope: :all, granted: true},
|
||||
%{resource: "User", action: :update, scope: :all, granted: true},
|
||||
%{resource: "User", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Member: Full CRUD
|
||||
%{resource: "Member", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Member", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomFieldValue: Full CRUD
|
||||
%{resource: "CustomFieldValue", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomFieldValue", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# CustomField: Full CRUD (admin manages custom field definitions)
|
||||
%{resource: "CustomField", action: :read, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :create, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :update, scope: :all, granted: true},
|
||||
%{resource: "CustomField", action: :destroy, scope: :all, granted: true},
|
||||
|
||||
# Role: Full CRUD (admin manages roles)
|
||||
%{resource: "Role", action: :read, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :create, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :update, scope: :all, granted: true},
|
||||
%{resource: "Role", action: :destroy, scope: :all, granted: true}
|
||||
],
|
||||
pages: [
|
||||
# Wildcard: Admin can access all pages
|
||||
"*"
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def get_permissions(invalid) do
|
||||
raise ArgumentError,
|
||||
"invalid permission set: #{inspect(invalid)}. Must be one of: #{inspect(all_permission_sets())}"
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a permission set name (string or atom) is valid.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.valid_permission_set?("admin")
|
||||
true
|
||||
|
||||
iex> PermissionSets.valid_permission_set?(:read_only)
|
||||
true
|
||||
|
||||
iex> PermissionSets.valid_permission_set?("invalid")
|
||||
false
|
||||
"""
|
||||
@spec valid_permission_set?(any()) :: boolean()
|
||||
def valid_permission_set?(name) when is_binary(name) do
|
||||
case permission_set_name_to_atom(name) do
|
||||
{:ok, _atom} -> true
|
||||
{:error, _} -> false
|
||||
end
|
||||
end
|
||||
|
||||
def valid_permission_set?(name) when is_atom(name) do
|
||||
name in all_permission_sets()
|
||||
end
|
||||
|
||||
def valid_permission_set?(_), do: false
|
||||
|
||||
@doc """
|
||||
Converts a permission set name string to atom safely.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> PermissionSets.permission_set_name_to_atom("admin")
|
||||
{:ok, :admin}
|
||||
|
||||
iex> PermissionSets.permission_set_name_to_atom("invalid")
|
||||
{:error, :invalid_permission_set}
|
||||
"""
|
||||
@spec permission_set_name_to_atom(String.t()) ::
|
||||
{:ok, atom()} | {:error, :invalid_permission_set}
|
||||
def permission_set_name_to_atom(name) when is_binary(name) do
|
||||
atom = String.to_existing_atom(name)
|
||||
|
||||
if valid_permission_set?(atom) do
|
||||
{:ok, atom}
|
||||
else
|
||||
{:error, :invalid_permission_set}
|
||||
end
|
||||
rescue
|
||||
ArgumentError -> {:error, :invalid_permission_set}
|
||||
end
|
||||
end
|
||||
142
lib/mv/authorization/role.ex
Normal file
142
lib/mv/authorization/role.ex
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
defmodule Mv.Authorization.Role do
|
||||
@moduledoc """
|
||||
Represents a user role that references a permission set.
|
||||
|
||||
Roles are stored in the database and link users to permission sets.
|
||||
Each role has a `permission_set_name` that references one of the four
|
||||
hardcoded permission sets defined in `Mv.Authorization.PermissionSets`.
|
||||
|
||||
## Fields
|
||||
|
||||
- `name` - Unique role name (e.g., "Vorstand", "Admin")
|
||||
- `description` - Human-readable description of the role
|
||||
- `permission_set_name` - Must be one of: "own_data", "read_only", "normal_user", "admin"
|
||||
- `is_system_role` - If true, role cannot be deleted (protects critical roles like "Mitglied")
|
||||
|
||||
## Relationships
|
||||
|
||||
- `has_many :users` - Users assigned to this role
|
||||
|
||||
## Validations
|
||||
|
||||
- `permission_set_name` must be a valid permission set (checked against PermissionSets.all_permission_sets/0)
|
||||
- `name` must be unique
|
||||
- System roles cannot be deleted (enforced via validation)
|
||||
|
||||
## Examples
|
||||
|
||||
# Create a new role
|
||||
{:ok, role} = Mv.Authorization.create_role(%{
|
||||
name: "Vorstand",
|
||||
description: "Board member with read access",
|
||||
permission_set_name: "read_only"
|
||||
})
|
||||
|
||||
# List all roles
|
||||
{:ok, roles} = Mv.Authorization.list_roles()
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Authorization,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "roles"
|
||||
repo Mv.Repo
|
||||
|
||||
references do
|
||||
# Prevent deletion of roles that are assigned to users
|
||||
reference :users, on_delete: :restrict
|
||||
end
|
||||
end
|
||||
|
||||
code_interface do
|
||||
define :create_role
|
||||
define :list_roles, action: :read
|
||||
define :update_role
|
||||
define :destroy_role, action: :destroy
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
|
||||
create :create_role do
|
||||
primary? true
|
||||
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
|
||||
accept [:name, :description, :permission_set_name]
|
||||
# Note: In Ash 3.0, require_atomic? is not available for create actions
|
||||
# Custom validations will still work
|
||||
end
|
||||
|
||||
update :update_role do
|
||||
primary? true
|
||||
# is_system_role is intentionally excluded - should only be set via seeds/internal actions
|
||||
accept [:name, :description, :permission_set_name]
|
||||
# Required because custom validation functions cannot be executed atomically
|
||||
require_atomic? false
|
||||
end
|
||||
|
||||
destroy :destroy do
|
||||
# Required because custom validation functions cannot be executed atomically
|
||||
require_atomic? false
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate one_of(
|
||||
:permission_set_name,
|
||||
Mv.Authorization.PermissionSets.all_permission_sets()
|
||||
|> Enum.map(&Atom.to_string/1)
|
||||
),
|
||||
message:
|
||||
"must be one of: #{Mv.Authorization.PermissionSets.all_permission_sets() |> Enum.map_join(", ", &Atom.to_string/1)}"
|
||||
|
||||
validate fn changeset, _context ->
|
||||
if changeset.data.is_system_role do
|
||||
{:error,
|
||||
field: :is_system_role,
|
||||
message:
|
||||
"Cannot delete system role. System roles are required for the application to function."}
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:destroy]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_v7_primary_key :id
|
||||
|
||||
attribute :name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :description, :string do
|
||||
allow_nil? true
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :permission_set_name, :string do
|
||||
allow_nil? false
|
||||
public? true
|
||||
end
|
||||
|
||||
attribute :is_system_role, :boolean do
|
||||
allow_nil? false
|
||||
default false
|
||||
public? true
|
||||
end
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
relationships do
|
||||
has_many :users, Mv.Accounts.User do
|
||||
destination_attribute :role_id
|
||||
end
|
||||
end
|
||||
|
||||
identities do
|
||||
identity :unique_name, [:name]
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue