vert-core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +14 -0
- data/README.md +126 -0
- data/lib/vert/authorization/controller_methods.rb +84 -0
- data/lib/vert/authorization/dynamic_policy.rb +156 -0
- data/lib/vert/authorization/permission_resolver.rb +253 -0
- data/lib/vert/authorization/policy_finder.rb +72 -0
- data/lib/vert/clients/document_service_client.rb +104 -0
- data/lib/vert/concerns/auditable.rb +24 -0
- data/lib/vert/concerns/company_scoped.rb +48 -0
- data/lib/vert/concerns/current_attributes.rb +53 -0
- data/lib/vert/concerns/document_storeable.rb +180 -0
- data/lib/vert/concerns/multi_tenant.rb +45 -0
- data/lib/vert/concerns/soft_deletable.rb +46 -0
- data/lib/vert/concerns/uuid_primary_key.rb +42 -0
- data/lib/vert/configuration.rb +65 -0
- data/lib/vert/generators/install_generator.rb +66 -0
- data/lib/vert/generators/rls_migration_generator.rb +57 -0
- data/lib/vert/generators/templates/application_record.rb.tt +8 -0
- data/lib/vert/generators/templates/create_outbox_events.rb.tt +24 -0
- data/lib/vert/generators/templates/create_rls_functions.rb.tt +27 -0
- data/lib/vert/generators/templates/current.rb.tt +10 -0
- data/lib/vert/generators/templates/enable_rls_on_tables.rb.tt +39 -0
- data/lib/vert/generators/templates/health_controller.rb.tt +5 -0
- data/lib/vert/generators/templates/initializer.rb.tt +39 -0
- data/lib/vert/generators/templates/outbox_event.rb.tt +11 -0
- data/lib/vert/health/checker.rb +119 -0
- data/lib/vert/health/routes.rb +44 -0
- data/lib/vert/outbox/event.rb +68 -0
- data/lib/vert/outbox/publisher.rb +105 -0
- data/lib/vert/outbox/publisher_job.rb +30 -0
- data/lib/vert/railtie.rb +54 -0
- data/lib/vert/rls/connection_handler.rb +56 -0
- data/lib/vert/rls/consumer_context.rb +31 -0
- data/lib/vert/rls/context_middleware.rb +37 -0
- data/lib/vert/rls/job_context.rb +56 -0
- data/lib/vert/version.rb +5 -0
- data/lib/vert.rb +58 -0
- data/vert.gemspec +43 -0
- metadata +223 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 802f4618d6763aeb190d05eb6bd29218d07ae0b3c229e968366a7c200bc08fb3
|
|
4
|
+
data.tar.gz: cf5140dd8bfb233db4163479ca714ac30049c69524a1139c36140b0046bc3091
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9e1afa0a09e98a12da919ab46ee34535eacc15d4e74e47edfe7da199b3b3f36895375abd360e7efb0ff87421bece811e0488ca7ddb10ed0dfffceb0f63387e57
|
|
7
|
+
data.tar.gz: ef379164049f6887b3748fdaa06b481e18c1836fa81172c5a63e28d8999d77c58bf08aaeda418f1ccf7be9cbcf94bf2f9b0c37cf3ad6a9d6df0b7bc43f9e3b88
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [1.0.0] - 2025-03-14
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- Initial release.
|
|
8
|
+
- Configuration via `Vert.configure` (optional RLS, Outbox, Health, Authorization, concerns).
|
|
9
|
+
- Concerns: Current, UuidPrimaryKey, MultiTenant, Auditable, SoftDeletable, CompanyScoped, DocumentStoreable.
|
|
10
|
+
- Outbox: Event, Publisher, PublisherJob.
|
|
11
|
+
- RLS: ConnectionHandler, ContextMiddleware, JobContext, BaseConsumer.
|
|
12
|
+
- Health: Checker, Routes, ControllerMixin.
|
|
13
|
+
- Authorization: PermissionResolver, DynamicPolicy, PolicyFinder, ControllerMethods.
|
|
14
|
+
- Generators: `vert:install`, `vert:rls_migration`.
|
data/README.md
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Vert
|
|
2
|
+
|
|
3
|
+
Gem genérica de padrões para aplicações Rails: contexto de request, RLS, Outbox, Health, autorização RBAC/ABAC e concerns opcionais (multi-tenant, auditável, soft delete, UUID, document store). Tudo é **opcional** e configurável via initializer.
|
|
4
|
+
|
|
5
|
+
## Instalação
|
|
6
|
+
|
|
7
|
+
Adicione ao `Gemfile`:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "vert"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Execute:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
rails generate vert:install
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Edite `config/initializers/vert.rb` e ative apenas os recursos que precisar.
|
|
21
|
+
|
|
22
|
+
## Configuração
|
|
23
|
+
|
|
24
|
+
Em `config/initializers/vert.rb`:
|
|
25
|
+
|
|
26
|
+
```ruby
|
|
27
|
+
Vert.configure do |config|
|
|
28
|
+
config.enable_rls = true
|
|
29
|
+
config.enable_outbox = true
|
|
30
|
+
config.enable_health = true
|
|
31
|
+
config.auto_mount_health_routes = false
|
|
32
|
+
config.rabbitmq_url = ENV.fetch("RABBITMQ_URL", "amqp://guest:guest@localhost:5672/")
|
|
33
|
+
config.exchange_name = "vert.events"
|
|
34
|
+
config.document_service_url = ENV["DOCUMENT_SERVICE_URL"]
|
|
35
|
+
config.enable_authorization = true
|
|
36
|
+
end
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Opção | Descrição | Padrão |
|
|
40
|
+
|-------|-----------|--------|
|
|
41
|
+
| `enable_rls` | Row Level Security (PostgreSQL) | `false` |
|
|
42
|
+
| `enable_outbox` | Publicação de eventos via Outbox | `false` |
|
|
43
|
+
| `enable_health` | Endpoints e checks de health | `true` |
|
|
44
|
+
| `auto_mount_health_routes` | Montar rotas de health automaticamente | `false` |
|
|
45
|
+
| `enable_authorization` | RBAC/ABAC com Pundit | `false` |
|
|
46
|
+
| `health_check_database` | Incluir check de DB no health | `true` |
|
|
47
|
+
| `health_check_redis` | Incluir check de Redis | `false` |
|
|
48
|
+
| `health_check_rabbitmq` | Incluir check de RabbitMQ | `false` |
|
|
49
|
+
| `health_check_sidekiq` | Incluir check de Sidekiq | `false` |
|
|
50
|
+
|
|
51
|
+
## Uso
|
|
52
|
+
|
|
53
|
+
### Contexto de request (Current)
|
|
54
|
+
|
|
55
|
+
Sempre disponível. Defina o contexto após autenticação:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
Vert::Current.set_context(tenant_id: "...", user_id: "...", company_id: "...")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Leitura: `Vert::Current.tenant_id`, `Vert::Current.user_id`, `Vert::Current.company_set?`, `Vert::Current.require_tenant!`.
|
|
62
|
+
|
|
63
|
+
Para herdar no app: `class Current < Vert::Current; end` (gerado pelo `vert:install`).
|
|
64
|
+
|
|
65
|
+
### RLS (Row Level Security)
|
|
66
|
+
|
|
67
|
+
1. `config.enable_rls = true`
|
|
68
|
+
2. Gere migrações: `rails generate vert:rls_migration --tables orders items`
|
|
69
|
+
3. No `ApplicationController`: `include Vert::Rls::ControllerContext`
|
|
70
|
+
4. Configure `Vert::Current` antes de cada request (ex.: no auth).
|
|
71
|
+
|
|
72
|
+
### Outbox
|
|
73
|
+
|
|
74
|
+
1. `config.enable_outbox = true`
|
|
75
|
+
2. Model `OutboxEvent` e migration criados pelo `vert:install`
|
|
76
|
+
3. Dentro de uma transação: `OutboxEvent.publish_for(record, event_type: "order.created", payload: { ... })`
|
|
77
|
+
4. Agende `Vert::Outbox::PublisherJob` (ex.: Sidekiq-Cron a cada 10s).
|
|
78
|
+
|
|
79
|
+
### Health
|
|
80
|
+
|
|
81
|
+
- `GET /health` → `Vert::Health.check_all`
|
|
82
|
+
- `GET /health/live` → liveness
|
|
83
|
+
- `GET /health/ready` → readiness (DB)
|
|
84
|
+
|
|
85
|
+
Rotas podem ser montadas pelo generator ou com `Vert.config.auto_mount_health_routes = true`. Checks customizados:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Vert::Health.add_check(:external_api) do
|
|
89
|
+
# return { status: "ok" } or { status: "error", message: "..." }
|
|
90
|
+
end
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Autorização (Pundit)
|
|
94
|
+
|
|
95
|
+
1. `config.enable_authorization = true`
|
|
96
|
+
2. No `ApplicationController`: `include Pundit::Authorization` e `include Vert::Authorization::ControllerMethods`
|
|
97
|
+
3. Políticas: herde de `Vert::Authorization::DynamicPolicy` e use `has_permission?("resource.action")`
|
|
98
|
+
4. No controller: `authorize_with_context(record)` ou `authorize_with_context(record, :approve?)`
|
|
99
|
+
|
|
100
|
+
### Concerns em models
|
|
101
|
+
|
|
102
|
+
Inclua conforme necessário (não dependem de flags, exceto document_storeable que usa `config.document_service_url`):
|
|
103
|
+
|
|
104
|
+
- `Vert::Concerns::UuidPrimaryKey`
|
|
105
|
+
- `Vert::Concerns::MultiTenant`
|
|
106
|
+
- `Vert::Concerns::Auditable`
|
|
107
|
+
- `Vert::Concerns::SoftDeletable`
|
|
108
|
+
- `Vert::Concerns::CompanyScoped`
|
|
109
|
+
- `Vert::Concerns::DocumentStoreable` (requer document service)
|
|
110
|
+
|
|
111
|
+
### Jobs e contexto
|
|
112
|
+
|
|
113
|
+
Para propagar tenant/user em jobs Sidekiq:
|
|
114
|
+
|
|
115
|
+
```ruby
|
|
116
|
+
class MyJob < ApplicationJob
|
|
117
|
+
include Vert::Rls::JobContext
|
|
118
|
+
def perform(id)
|
|
119
|
+
# Vert::Current.tenant_id disponível
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Licença
|
|
125
|
+
|
|
126
|
+
MIT.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Authorization
|
|
5
|
+
module ControllerMethods
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
included do
|
|
9
|
+
rescue_from Pundit::NotAuthorizedError, with: :render_forbidden if defined?(Pundit)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def authorize_with_context(record, query = nil, context = {})
|
|
13
|
+
return record unless Vert.config.enable_authorization && defined?(Pundit)
|
|
14
|
+
query ||= "#{action_name}?"
|
|
15
|
+
policy_context = authorization_context.merge(context)
|
|
16
|
+
policy = policy_with_context(record, policy_context)
|
|
17
|
+
unless policy.public_send(query)
|
|
18
|
+
raise Pundit::NotAuthorizedError, query: query, record: record, policy: policy
|
|
19
|
+
end
|
|
20
|
+
record
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def policy_with_context(record, context = {})
|
|
24
|
+
policy_class = PolicyFinder.new(record).policy
|
|
25
|
+
policy_class.new(current_user, record, context)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def has_permission?(permission_code, context = {})
|
|
29
|
+
return false unless Vert.config.enable_authorization
|
|
30
|
+
PermissionResolver.has_permission?(current_user, permission_code, authorization_context.merge(context))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def can_see_field?(resource, field)
|
|
34
|
+
allowed = PermissionResolver.get_allowed_fields(current_user, "#{resource}.read", authorization_context)
|
|
35
|
+
denied = PermissionResolver.get_denied_fields(current_user, "#{resource}.read", authorization_context)
|
|
36
|
+
return false if denied.include?(field.to_s)
|
|
37
|
+
return true if allowed.nil?
|
|
38
|
+
allowed.include?(field.to_s)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def allowed_fields_for(resource)
|
|
42
|
+
PermissionResolver.get_allowed_fields(current_user, "#{resource}.read", authorization_context)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def denied_fields_for(resource)
|
|
46
|
+
PermissionResolver.get_denied_fields(current_user, "#{resource}.read", authorization_context)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def current_user_permissions
|
|
50
|
+
PermissionResolver.user_permissions(current_user, authorization_context)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
protected
|
|
54
|
+
|
|
55
|
+
def authorization_context
|
|
56
|
+
{
|
|
57
|
+
tenant_id: current_tenant_id,
|
|
58
|
+
company_id: current_company_id,
|
|
59
|
+
user_id: current_user&.id,
|
|
60
|
+
action: action_name
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def current_tenant_id
|
|
65
|
+
Vert::Current.tenant_id
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def current_company_id
|
|
69
|
+
Vert::Current.company_id
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def render_forbidden(exception)
|
|
75
|
+
render json: {
|
|
76
|
+
error: "Access denied",
|
|
77
|
+
message: "You do not have permission to perform this action",
|
|
78
|
+
permission: exception.query&.to_s&.delete_suffix("?"),
|
|
79
|
+
resource: exception.record.is_a?(Class) ? exception.record.name : exception.record.class.name
|
|
80
|
+
}, status: :forbidden
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Authorization
|
|
5
|
+
class DynamicPolicy
|
|
6
|
+
attr_reader :user, :record, :context
|
|
7
|
+
|
|
8
|
+
def initialize(user, record, context = {})
|
|
9
|
+
@user = user
|
|
10
|
+
@record = record
|
|
11
|
+
@context = default_context.merge(context)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def index?
|
|
15
|
+
has_permission?("#{resource_name}.list")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def show?
|
|
19
|
+
has_permission?("#{resource_name}.read")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def create?
|
|
23
|
+
has_permission?("#{resource_name}.create")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def new?
|
|
27
|
+
create?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def update?
|
|
31
|
+
has_permission?("#{resource_name}.update")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edit?
|
|
35
|
+
update?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def destroy?
|
|
39
|
+
has_permission?("#{resource_name}.delete")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def export?
|
|
43
|
+
has_permission?("#{resource_name}.export")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def import?
|
|
47
|
+
has_permission?("#{resource_name}.import")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def approve?
|
|
51
|
+
has_permission?("#{resource_name}.approve")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reject?
|
|
55
|
+
has_permission?("#{resource_name}.reject")
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def cancel?
|
|
59
|
+
has_permission?("#{resource_name}.cancel")
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def print?
|
|
63
|
+
has_permission?("#{resource_name}.print")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class Scope
|
|
67
|
+
attr_reader :user, :scope, :context
|
|
68
|
+
|
|
69
|
+
def initialize(user, scope, context = {})
|
|
70
|
+
@user = user
|
|
71
|
+
@scope = scope
|
|
72
|
+
@context = context
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve
|
|
76
|
+
if PermissionResolver.has_permission?(user, "#{resource_name}.list", context)
|
|
77
|
+
own_records_only? ? scope.where(created_by: user.id) : scope.all
|
|
78
|
+
else
|
|
79
|
+
scope.none
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def resource_name
|
|
86
|
+
scope.model_name.plural
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def own_records_only?
|
|
90
|
+
PermissionResolver.get_condition(user, "#{resource_name}.list", "own_records_only", context) == true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
protected
|
|
95
|
+
|
|
96
|
+
def has_permission?(permission_code)
|
|
97
|
+
return true if user_super_admin?
|
|
98
|
+
PermissionResolver.has_permission?(user, permission_code, context)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def permission_condition(condition_key)
|
|
102
|
+
PermissionResolver.get_condition(user, "#{resource_name}.#{current_action}", condition_key, context)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def allowed_fields
|
|
106
|
+
PermissionResolver.get_allowed_fields(user, "#{resource_name}.read", context)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def denied_fields
|
|
110
|
+
PermissionResolver.get_denied_fields(user, "#{resource_name}.read", context)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def can_see_field?(field_name)
|
|
114
|
+
field = field_name.to_s
|
|
115
|
+
denied = denied_fields
|
|
116
|
+
return false if denied.include?(field)
|
|
117
|
+
allowed = allowed_fields
|
|
118
|
+
return true if allowed.nil?
|
|
119
|
+
allowed.include?(field)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def resource_name
|
|
123
|
+
@resource_name ||= begin
|
|
124
|
+
klass = record.is_a?(Class) ? record : record.class
|
|
125
|
+
klass.model_name.plural
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def service_name
|
|
130
|
+
@service_name ||= resource_name.split("/").first
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def default_context
|
|
136
|
+
{
|
|
137
|
+
tenant_id: Vert::Current.tenant_id,
|
|
138
|
+
company_id: Vert::Current.company_id,
|
|
139
|
+
user_id: user&.id
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def current_action
|
|
144
|
+
context[:action] || caller_action
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def caller_action
|
|
148
|
+
caller_locations(2, 1).first&.label&.delete_suffix("?")
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def user_super_admin?
|
|
152
|
+
user.respond_to?(:super_admin?) && user.super_admin?
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Vert
|
|
4
|
+
module Authorization
|
|
5
|
+
class PermissionResolver
|
|
6
|
+
CACHE_TTL = 5.minutes
|
|
7
|
+
CACHE_PREFIX = "vert:permissions"
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def has_permission?(user, permission_code, context = {})
|
|
11
|
+
return false unless user
|
|
12
|
+
return true if super_admin?(user)
|
|
13
|
+
|
|
14
|
+
cached = get_cached_permission(user, permission_code, context)
|
|
15
|
+
return cached unless cached.nil?
|
|
16
|
+
|
|
17
|
+
result = resolve_permission(user, permission_code, context)
|
|
18
|
+
cache_permission(user, permission_code, context, result)
|
|
19
|
+
result
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def get_condition(user, permission_code, condition_key, context = {})
|
|
23
|
+
return nil unless user
|
|
24
|
+
conditions = get_permission_conditions(user, permission_code, context)
|
|
25
|
+
conditions&.dig(condition_key.to_s)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def get_allowed_fields(user, permission_code, context = {})
|
|
29
|
+
return nil if super_admin?(user)
|
|
30
|
+
fields = get_field_restrictions(user, permission_code, context)
|
|
31
|
+
fields&.dig("granted_fields")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def get_denied_fields(user, permission_code, context = {})
|
|
35
|
+
return [] if super_admin?(user)
|
|
36
|
+
fields = get_field_restrictions(user, permission_code, context)
|
|
37
|
+
fields&.dig("denied_fields") || []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def user_permissions(user, context = {})
|
|
41
|
+
return [] unless user
|
|
42
|
+
return ["*"] if super_admin?(user)
|
|
43
|
+
|
|
44
|
+
cache_key = user_permissions_cache_key(user, context)
|
|
45
|
+
cached = redis_get(cache_key)
|
|
46
|
+
return cached if cached
|
|
47
|
+
|
|
48
|
+
permissions = collect_user_permissions(user, context)
|
|
49
|
+
redis_set(cache_key, permissions, CACHE_TTL)
|
|
50
|
+
permissions
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def invalidate_user_cache(user_id)
|
|
54
|
+
pattern = "#{CACHE_PREFIX}:#{user_id}:*"
|
|
55
|
+
redis_delete_pattern(pattern)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def invalidate_role_cache(role_id)
|
|
59
|
+
if defined?(UserRole)
|
|
60
|
+
UserRole.where(role_id: role_id).pluck(:user_id).each { |user_id| invalidate_user_cache(user_id) }
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def resolve_permission(user, permission_code, context)
|
|
67
|
+
company_id = context[:company_id]
|
|
68
|
+
return false if has_direct_deny?(user, permission_code, company_id)
|
|
69
|
+
return true if has_direct_grant?(user, permission_code, company_id)
|
|
70
|
+
|
|
71
|
+
effective_roles(user, company_id).each do |role|
|
|
72
|
+
return true if role_has_permission?(role, permission_code)
|
|
73
|
+
end
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def has_direct_deny?(user, permission_code, company_id)
|
|
78
|
+
return false unless defined?(UserPermission)
|
|
79
|
+
UserPermission
|
|
80
|
+
.joins(:permission)
|
|
81
|
+
.where(user_id: user.id, grant_type: "deny")
|
|
82
|
+
.where("permissions.code = ?", permission_code)
|
|
83
|
+
.where("company_id IS NULL OR company_id = ?", company_id)
|
|
84
|
+
.where("valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)", Time.current, Time.current)
|
|
85
|
+
.exists?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def has_direct_grant?(user, permission_code, company_id)
|
|
89
|
+
return false unless defined?(UserPermission)
|
|
90
|
+
UserPermission
|
|
91
|
+
.joins(:permission)
|
|
92
|
+
.where(user_id: user.id, grant_type: "grant")
|
|
93
|
+
.where("permissions.code = ?", permission_code)
|
|
94
|
+
.where("company_id IS NULL OR company_id = ?", company_id)
|
|
95
|
+
.where("valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)", Time.current, Time.current)
|
|
96
|
+
.exists?
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def effective_roles(user, company_id)
|
|
100
|
+
return [] unless defined?(UserRole)
|
|
101
|
+
role_ids = UserRole
|
|
102
|
+
.where(user_id: user.id)
|
|
103
|
+
.where("company_id IS NULL OR company_id = ?", company_id)
|
|
104
|
+
.where("valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)", Time.current, Time.current)
|
|
105
|
+
.pluck(:role_id)
|
|
106
|
+
return [] if role_ids.empty?
|
|
107
|
+
Role.where(id: role_ids).or(Role.where(id: inherited_role_ids(role_ids))).where(is_active: true).order(priority: :desc)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inherited_role_ids(role_ids, collected = [])
|
|
111
|
+
return collected if role_ids.empty?
|
|
112
|
+
parent_ids = Role.where(id: role_ids).where.not(parent_role_id: nil).pluck(:parent_role_id)
|
|
113
|
+
new_parents = parent_ids - collected
|
|
114
|
+
return collected if new_parents.empty?
|
|
115
|
+
inherited_role_ids(new_parents, collected + new_parents)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def role_has_permission?(role, permission_code)
|
|
119
|
+
return false unless defined?(RolePermission)
|
|
120
|
+
RolePermission.joins(:permission).where(role_id: role.id).where("permissions.code = ?", permission_code).exists?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def get_permission_conditions(user, permission_code, context)
|
|
124
|
+
company_id = context[:company_id]
|
|
125
|
+
conditions = {}
|
|
126
|
+
if defined?(UserPermission)
|
|
127
|
+
user_conditions = UserPermission
|
|
128
|
+
.joins(:permission)
|
|
129
|
+
.where(user_id: user.id, grant_type: "grant")
|
|
130
|
+
.where("permissions.code = ?", permission_code)
|
|
131
|
+
.where("company_id IS NULL OR company_id = ?", company_id)
|
|
132
|
+
.pluck(:conditions).compact
|
|
133
|
+
user_conditions.each { |c| conditions.merge!(c) if c.is_a?(Hash) }
|
|
134
|
+
end
|
|
135
|
+
effective_roles(user, company_id).each do |role|
|
|
136
|
+
if defined?(RolePermission)
|
|
137
|
+
role_conditions = RolePermission
|
|
138
|
+
.joins(:permission)
|
|
139
|
+
.where(role_id: role.id)
|
|
140
|
+
.where("permissions.code = ?", permission_code)
|
|
141
|
+
.pluck(:conditions).compact
|
|
142
|
+
role_conditions.each { |c| conditions.merge!(c) if c.is_a?(Hash) }
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
if defined?(Permission)
|
|
146
|
+
perm = Permission.find_by(code: permission_code)
|
|
147
|
+
conditions.merge!(perm.conditions) if perm&.conditions.is_a?(Hash)
|
|
148
|
+
end
|
|
149
|
+
conditions
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def get_field_restrictions(user, permission_code, context)
|
|
153
|
+
company_id = context[:company_id]
|
|
154
|
+
if defined?(UserPermission)
|
|
155
|
+
user_perm = UserPermission
|
|
156
|
+
.joins(:permission)
|
|
157
|
+
.where(user_id: user.id)
|
|
158
|
+
.where("permissions.code = ?", permission_code)
|
|
159
|
+
.where("company_id IS NULL OR company_id = ?", company_id)
|
|
160
|
+
.first
|
|
161
|
+
return user_perm.attributes.slice("granted_fields", "denied_fields") if user_perm
|
|
162
|
+
end
|
|
163
|
+
effective_roles(user, company_id).each do |role|
|
|
164
|
+
if defined?(RolePermission)
|
|
165
|
+
role_perm = RolePermission
|
|
166
|
+
.joins(:permission)
|
|
167
|
+
.where(role_id: role.id)
|
|
168
|
+
.where("permissions.code = ?", permission_code)
|
|
169
|
+
.first
|
|
170
|
+
return role_perm.attributes.slice("granted_fields", "denied_fields") if role_perm
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def collect_user_permissions(user, context)
|
|
177
|
+
permissions = Set.new
|
|
178
|
+
company_id = context[:company_id]
|
|
179
|
+
if defined?(UserPermission)
|
|
180
|
+
UserPermission
|
|
181
|
+
.joins(:permission)
|
|
182
|
+
.where(user_id: user.id, grant_type: "grant")
|
|
183
|
+
.where("company_id IS NULL OR company_id = ?", company_id)
|
|
184
|
+
.where("valid_from <= ? AND (valid_until IS NULL OR valid_until >= ?)", Time.current, Time.current)
|
|
185
|
+
.pluck("permissions.code")
|
|
186
|
+
.each { |code| permissions.add(code) }
|
|
187
|
+
end
|
|
188
|
+
effective_roles(user, company_id).each do |role|
|
|
189
|
+
if defined?(RolePermission)
|
|
190
|
+
RolePermission.joins(:permission).where(role_id: role.id).pluck("permissions.code").each { |code| permissions.add(code) }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
if defined?(UserPermission)
|
|
194
|
+
UserPermission
|
|
195
|
+
.joins(:permission)
|
|
196
|
+
.where(user_id: user.id, grant_type: "deny")
|
|
197
|
+
.where("company_id IS NULL OR company_id = ?", company_id)
|
|
198
|
+
.pluck("permissions.code")
|
|
199
|
+
.each { |code| permissions.delete(code) }
|
|
200
|
+
end
|
|
201
|
+
permissions.to_a
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def super_admin?(user)
|
|
205
|
+
user.respond_to?(:super_admin?) && user.super_admin?
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def cache_key(user, permission_code, context)
|
|
209
|
+
company_id = context[:company_id] || "all"
|
|
210
|
+
"#{CACHE_PREFIX}:#{user.id}:#{company_id}:#{permission_code}"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def user_permissions_cache_key(user, context)
|
|
214
|
+
company_id = context[:company_id] || "all"
|
|
215
|
+
"#{CACHE_PREFIX}:#{user.id}:#{company_id}:all"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def get_cached_permission(user, permission_code, context)
|
|
219
|
+
result = redis_get(cache_key(user, permission_code, context))
|
|
220
|
+
result.nil? ? nil : (result == "true")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def cache_permission(user, permission_code, context, result)
|
|
224
|
+
redis_set(cache_key(user, permission_code, context), result.to_s, CACHE_TTL)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def redis_get(key)
|
|
228
|
+
return nil unless redis_available?
|
|
229
|
+
value = redis.get(key)
|
|
230
|
+
value.nil? ? nil : (JSON.parse(value) rescue value)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def redis_set(key, value, ttl)
|
|
234
|
+
redis.setex(key, ttl.to_i, value.to_json) if redis_available?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def redis_delete_pattern(pattern)
|
|
238
|
+
return unless redis_available?
|
|
239
|
+
keys = redis.keys(pattern)
|
|
240
|
+
redis.del(*keys) if keys.any?
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def redis_available?
|
|
244
|
+
defined?(Redis) && redis.present?
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def redis
|
|
248
|
+
@redis ||= (Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0")) if defined?(Redis))
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|