Module: Rhino::HasPermissions

Extended by:
ActiveSupport::Concern
Defined in:
lib/rhino/concerns/has_permissions.rb

Overview

Permission checking concern for the User model. Mirrors the Laravel HasPermissions trait.

Usage:

class User < ApplicationRecord
  include Rhino::HasPermissions

  has_many :user_roles
end

Permission format: ‘slug.action’ (e.g., ‘posts.index’, ‘blogs.store’) Wildcard support on every layer:

- '*' grants/denies everything
- 'posts.*' grants/denies all actions on posts

── Layered resolution (organization context) ────────────────────────────The effective decision for an org-scoped check is:

  effective = (role ∪ granted) − denied        (deny always wins)

- role    → org_role_permissions[(organization, role)].permissions
            The shared "role layer" an org manages once per role.
- granted → user_roles.granted_permissions  (per-user additive delta)
- denied  → user_roles.denied_permissions   (per-user subtractive delta)
- legacy  → user_roles.permissions          (kept in the allow set)

Deny is checked first and overrides everything — even a role ‘*’. This is intentionally deny-overrides (not most-specific-wins).

Backward compatibility:

- The global roles.permissions column is preserved as a FALLBACK: it is
  consulted only when the primary union (legacy ∪ granted ∪ org role layer)
  is empty — exactly the pre-layer "fall back to role.permissions when
  user_role.permissions is empty" behavior.
- When org_role_permissions has no row and the granted/denied columns are
  absent, resolution reduces to the previous behavior byte-for-byte.

Sources:

1. organization provided (tenant route group) → user_roles layers above.
2. no organization (non-tenant route group)   → users.permissions (with
   optional user-level granted/denied if those columns exist; deny wins).

Instance Method Summary collapse

Instance Method Details

#explain_permission(permission, organization = nil, route_group: nil) ⇒ Hash

Explain a permission decision — returns the deciding layer.

Returns:

  • (Hash)

    { granted: Boolean, reason: String } reason ∈ { ‘denied’, ‘role’, ‘granted’, ‘legacy’, ‘user’, ‘default-deny’ }



83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/rhino/concerns/has_permissions.rb', line 83

def explain_permission(permission, organization = nil, route_group: nil)
  return { granted: false, reason: "default-deny" } if permission.blank?

  if group_membership_enforced?
    membership = Rhino::GroupMembership.matching_membership(self, route_group, organization)
    return decide_for_record(permission, membership, organization, explain: true)
  end

  if organization
    return decide_for_record(permission, find_user_role(organization), organization, explain: true)
  end

  deny = parse_permissions(safe_attr(self, :denied_permissions))
  return { granted: false, reason: "denied" } if matches_permission?(permission, deny)

  user = parse_permissions(safe_attr(self, :permissions))
  granted = parse_permissions(safe_attr(self, :granted_permissions))
  return { granted: true, reason: "granted" } if matches_permission?(permission, granted)
  return { granted: true, reason: "user" } if matches_permission?(permission, user)

  { granted: false, reason: "default-deny" }
end

#has_permission?(permission, organization = nil, route_group: nil) ⇒ Boolean

Check if the user has a specific permission.

Parameters:

  • permission (String)

    Permission string like ‘posts.index’

  • organization (Object, nil) (defaults to: nil)

    Organization to check permissions for

  • route_group (String, nil) (defaults to: nil)

    Resolved route group (group enforcement only)

Returns:

  • (Boolean)


54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/rhino/concerns/has_permissions.rb', line 54

def has_permission?(permission, organization = nil, route_group: nil)
  return false if permission.blank?

  # Group-aware permission resolution (GROUP_AUTH_DESIGN.md §6). Only active
  # when enforce_group_membership is on. Permissions then resolve from the
  # membership row matching (route_group, organization).
  if group_membership_enforced?
    membership = Rhino::GroupMembership.matching_membership(self, route_group, organization)
    return decide_for_record(permission, membership, organization)
  end

  if organization
    # Tenant route group: layered resolution from the user_role for this org.
    return decide_for_record(permission, find_user_role(organization), organization)
  end

  # Non-tenant route group: users.permissions (+ optional user-level deltas).
  deny = parse_permissions(safe_attr(self, :denied_permissions))
  return false if matches_permission?(permission, deny)

  allow = parse_permissions(safe_attr(self, :permissions)) +
          parse_permissions(safe_attr(self, :granted_permissions))
  matches_permission?(permission, allow)
end

#role_slug_for_validation(organization = nil) ⇒ String?

Get the role slug for validation purposes.

Parameters:

  • organization (Object, nil) (defaults to: nil)

    Organization context

Returns:

  • (String, nil)

    Role slug or nil



110
111
112
113
114
115
116
117
118
# File 'lib/rhino/concerns/has_permissions.rb', line 110

def role_slug_for_validation(organization = nil)
  user_role = find_user_role(organization)
  return nil unless user_role

  role = user_role.respond_to?(:role) ? user_role.role : nil
  return nil unless role

  role.respond_to?(:slug) ? role.slug : nil
end