cerberus-xacml 0.1.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +230 -0
  5. data/Rakefile +12 -0
  6. data/lib/cerberus/application/authorizer.rb +23 -0
  7. data/lib/cerberus/application/resolver.rb +23 -0
  8. data/lib/cerberus/configuration.rb +11 -0
  9. data/lib/cerberus/domain/condition.rb +22 -0
  10. data/lib/cerberus/domain/node.rb +20 -0
  11. data/lib/cerberus/domain/operand.rb +28 -0
  12. data/lib/cerberus/domain/policy.rb +30 -0
  13. data/lib/cerberus/domain/rule.rb +18 -0
  14. data/lib/cerberus/domain/strategies/deny_overrides.rb +24 -0
  15. data/lib/cerberus/domain/strategies/deny_unless_permit.rb +13 -0
  16. data/lib/cerberus/domain/strategies/permit_overrides.rb +24 -0
  17. data/lib/cerberus/domain/strategies/permit_unless_deny.rb +13 -0
  18. data/lib/cerberus/generators/migrations/active_record/000_cerberus_init.rb +46 -0
  19. data/lib/cerberus/generators/migrations.rb +33 -0
  20. data/lib/cerberus/infra/active_record/builder.rb +58 -0
  21. data/lib/cerberus/infra/active_record/mapper.rb +33 -0
  22. data/lib/cerberus/infra/active_record/models/application_record.rb +18 -0
  23. data/lib/cerberus/infra/active_record/models/expression.rb +14 -0
  24. data/lib/cerberus/infra/active_record/models/expressions/condition.rb +20 -0
  25. data/lib/cerberus/infra/active_record/models/expressions/node.rb +22 -0
  26. data/lib/cerberus/infra/active_record/models/operand.rb +42 -0
  27. data/lib/cerberus/infra/active_record/models/policy.rb +25 -0
  28. data/lib/cerberus/infra/active_record/models/policy_rule.rb +14 -0
  29. data/lib/cerberus/infra/active_record/models/rule.rb +21 -0
  30. data/lib/cerberus/infra/active_record/repository.rb +19 -0
  31. data/lib/cerberus/infra/types.rb +22 -0
  32. data/lib/cerberus/plugins/active_record.rb +43 -0
  33. data/lib/cerberus/plugins.rb +14 -0
  34. data/lib/cerberus/version.rb +5 -0
  35. data/lib/cerberus-xacml.rb +3 -0
  36. data/lib/cerberus.rb +44 -0
  37. data/sig/cerberus.rbs +4 -0
  38. metadata +86 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: aa2fcbe67119bdcbe8d407da3b16a6a9c1fc1524d09297757c7de2144d78c9a2
4
+ data.tar.gz: d6a73081d99d03083e6d74f478b51d8df4db0fa2cf48902e455527780af22c4f
5
+ SHA512:
6
+ metadata.gz: 26e19a1f5fa1d0d0984b57a32dc90a9df2fba2d3bff8e711f49dcda8ccb27c982c3ce322e3da9383ca161a33f0b43baf8133870a21b5bcc8b0f038aed542e87a
7
+ data.tar.gz: 117eb9ff293e721104ff8bbf8efce9416a6ebc1b0d179610723b355fd14db48ba6f3a56298f8c8aff6b5b66b6764535c44ad266f87639e6eb4d7c29fa42a87fe
data/CHANGELOG.md ADDED
@@ -0,0 +1,3 @@
1
+ ## [0.1.0] - 2026-04-04
2
+
3
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Nasibullin Danil
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # Cerberus
2
+
3
+ Cerberus is a rule engine for Ruby applications inspired by the XACML (eXtensible Access Control Markup Language) standard.
4
+
5
+ 👉 Learn more about XACML: https://en.wikipedia.org/wiki/XACML
6
+
7
+ It provides a structured way to define, manage, and evaluate rules and policies in a consistent and scalable manner. It provides a structured way to define, manage, and evaluate rules and policies in a consistent and scalable manner.
8
+
9
+ While internally it follows a modular architecture with pluggable adapters and dependency injection, its primary goal is to offer a familiar policy-based approach similar to XACML, adapted for Ruby applications.
10
+
11
+ ---
12
+
13
+ ## 📋 Requirements
14
+
15
+ - Ruby >= 3.1
16
+ - Rails >= 7.0 (only for ActiveRecord adapter)
17
+ - PostgreSQL (recommended, due to JSONB support)
18
+
19
+ ---
20
+
21
+ ## ✨ Features
22
+
23
+ * Decoupled domain and infrastructure layers
24
+ * Pluggable persistence adapters (ActiveRecord, future: Sequel, etc.)
25
+ * Dependency injection via container
26
+ * Composable operations (use-cases)
27
+ * Flexible rule and expression system
28
+
29
+ ---
30
+
31
+ ## 🚀 Installation
32
+
33
+ Add this line to your application's Gemfile:
34
+
35
+ ```ruby
36
+ gem 'cerberus-xacml'
37
+ ```
38
+
39
+ And then execute:
40
+
41
+ ```bash
42
+ bundle install
43
+ ```
44
+
45
+ ---
46
+
47
+ ## ⚙️ Configuration
48
+
49
+ Create an initializer:
50
+
51
+ ```ruby
52
+ # config/initializers/cerberus.rb
53
+
54
+ Cerberus::Base.plugin :active_record
55
+ ```
56
+
57
+ ---
58
+
59
+ ## 🧱 Database Setup
60
+
61
+ ### Install migrations
62
+
63
+ #### Create task
64
+
65
+ ```ruby
66
+ Cerberus::Generators::Migrations.install!(:active_record, 'path/to/migrations/dir')
67
+ ```
68
+
69
+ ```bash
70
+ bin/rails db:migrate
71
+ ```
72
+
73
+ ---
74
+
75
+ ### Manual migration example
76
+
77
+ If you prefer to manage schema manually:
78
+
79
+ ```ruby
80
+ class InitCerberusMigration < ActiveRecord::Migration[7.0]
81
+ create_table :cerberus_policies do |t|
82
+ t.string :action, null: false
83
+ t.string :resource_type, null: false
84
+ t.string :strategy, null: false
85
+
86
+ t.timestamp
87
+ end
88
+
89
+ add_index :cerberus_policies, %i[action resource_type], unique: true, name: 'index_cerberus_policies_uniqueness'
90
+
91
+ create_table :cerberus_rules do |t|
92
+ t.string :effect, null: false
93
+
94
+ t.timestamps
95
+ end
96
+
97
+ create_table :cerberus_policy_rules do |t|
98
+ t.belongs_to :policy, foreign_key: { to_table: :cerberus_policies }
99
+ t.belongs_to :rule, foreign_key: { to_table: :cerberus_rules }
100
+
101
+ t.timestamps
102
+ end
103
+
104
+ create_table :cerberus_operands do |t|
105
+ t.string :kind, null: false
106
+ t.string :value
107
+ t.string :name
108
+ t.string :value_type
109
+
110
+ t.timestamps
111
+ end
112
+
113
+ create_table :cerberus_expressions do |t|
114
+ t.string :type, null: false
115
+ t.string :operator, null: false
116
+ t.belongs_to :rule, foreign_key: { to_table: :cerberus_rules }
117
+ t.belongs_to :parent, foreign_key: { to_table: :cerberus_expressions }
118
+ t.belongs_to :left_operand, foreign_key: { to_table: :cerberus_operands }
119
+ t.belongs_to :right_operand, foreign_key: { to_table: :cerberus_operands }
120
+
121
+ t.timestamps
122
+ end
123
+ end
124
+ ```
125
+
126
+ ---
127
+
128
+ ## 🧩 Usage
129
+
130
+ ### Create a rule
131
+
132
+ ```ruby
133
+ rule = Cerberus::Domain::Rule.create(name: "Test rule")
134
+ ```
135
+
136
+ ### Add expressions
137
+
138
+ ```ruby
139
+ rule.expressions.create!(kind: "equals", payload: { field: "status", value: "active" })
140
+ ```
141
+
142
+ ---
143
+
144
+ ### Using operations
145
+
146
+ ```ruby
147
+ result = Cerberus::Operations::Rules::Create.call(name: "Test")
148
+
149
+ if result.success?
150
+ rule = result.value!
151
+ else
152
+ puts result.failure
153
+ end
154
+ ```
155
+
156
+ ⚠️ Do not use infrastructure models directly in your application. Always go through domain or operations layer.
157
+
158
+ ---
159
+
160
+ ## 🧪 Testing
161
+
162
+ Example with RSpec:
163
+
164
+ ```ruby
165
+ RSpec.describe Cerberus::Domain::Rule do
166
+ it 'creates a rule' do
167
+ expect { described_class.create(name: 'Test') }
168
+ .to change(described_class, :count).by(1)
169
+ end
170
+ end
171
+ ```
172
+
173
+ ---
174
+
175
+ ## 🏗 Architecture
176
+
177
+ ```
178
+ cerberus/
179
+ domain/ # Pure business logic
180
+ infra/ # Adapters (ActiveRecord, etc.)
181
+ operations/ # Use cases
182
+ plugins/ # Set up for plugins
183
+ ```
184
+
185
+ ---
186
+
187
+ ## ⚠️ Important Notes
188
+
189
+ * Do not depend on `infra` layer directly
190
+ * Always use public API (operations, domain interfaces)
191
+ * Database schema may evolve — check migrations on upgrade
192
+
193
+ ---
194
+
195
+ ## 🛠 Roadmap
196
+
197
+ * [ ] Sequel adapter
198
+ * [ ] ROM adapter
199
+ * [ ] YAML adapter
200
+ * [ ] Async evaluation
201
+ * [ ] Rule builder DSL
202
+
203
+ ---
204
+
205
+ ## 💡 Example Workflow
206
+
207
+ ```ruby
208
+ # seeds
209
+ op1 = operand.create!(kind: :resource, name: 'nested.role')
210
+ op2 = operand.create!(kind: :subject, name: 'role')
211
+
212
+ r1 = rule.create!(effect: :permit)
213
+ c1 = condition.create!(rule: r1, operator: :eq, left_operand: op1, right_operand: op2)
214
+ p = policy.create!(action: :update, resource_type: :vehicle, strategy: :permit_overrides)
215
+ p.rules << r1
216
+
217
+ # usage
218
+ Cerberus::Base.authorize!(
219
+ action: :update,
220
+ resource_type: :vehicle,
221
+ subject: Struct.new(:id, :role).new(1, 'admin'),
222
+ resource: Struct.new(:nested).new(Struct.new(:role).new('admin'))
223
+ )
224
+ ```
225
+
226
+ ---
227
+
228
+ ## 📄 License
229
+
230
+ MIT
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require 'rubocop/rake_task'
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Application
5
+ class Authorizer
6
+ attr_reader :resolver
7
+
8
+ def initialize(resolver:)
9
+ @resolver = resolver
10
+ end
11
+
12
+ def authorized?(action:, resource_type:, subject: nil, resource: nil, env: {})
13
+ policy = resolver.resolve(action:, resource_type:)
14
+ policy&.evaluate(subject:, resource:, env:) == :permit
15
+ end
16
+
17
+ def authorize!(action:, resource_type:, **args)
18
+ authorized?(action:, resource_type:, **args) ||
19
+ (raise NotAuthorized, "Not authorized #{resource_type} to #{action}")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Application
5
+ class Resolver
6
+ def initialize(repository:, mapper:)
7
+ @repository = repository
8
+ @mapper = mapper
9
+ end
10
+
11
+ def resolve(action:, resource_type:)
12
+ record = repository.find(action:, resource_type:)
13
+ return unless record
14
+
15
+ mapper.call(record)
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :repository, :mapper
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ class Configuration
5
+ attr_accessor :authorizer
6
+
7
+ def plugins
8
+ @plugins ||= []
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ class Condition
6
+ attr_reader :left, :right, :operator
7
+
8
+ def initialize(left:, right:, operator:)
9
+ @left = left
10
+ @right = right
11
+ @operator = operator
12
+ end
13
+
14
+ def evaluate(context)
15
+ left.resolve(context).public_send(
16
+ operator,
17
+ right.resolve(context)
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ class Node
6
+ attr_reader :operator, :children
7
+
8
+ def initialize(operator:, children:)
9
+ @operator = operator
10
+ @children = children
11
+ end
12
+
13
+ def evaluate(context)
14
+ return children.all? { |child| child.evaluate(context) } if operator == :and
15
+
16
+ children.any? { |child| child.evaluate(context) }
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ class Operand
6
+ attr_reader :kind, :name, :value, :value_type, :types
7
+
8
+ def initialize(kind:, name: nil, value: nil, value_type: nil, types: Infra::Types)
9
+ @kind = kind
10
+ @name = name
11
+ @value = value
12
+ @value_type = value_type
13
+ @types = types
14
+ end
15
+
16
+ def resolve(context)
17
+ return types.cast(value, value_type) if kind == :constant
18
+
19
+ keys = name.is_a?(String) ? name.split('.') : Array(name)
20
+ object = context.fetch(kind)
21
+
22
+ keys.reduce(object) do |memo, key|
23
+ memo.is_a?(Hash) ? memo.fetch(key.to_sym) : memo.public_send(key)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cerberus/domain/strategies/deny_overrides'
4
+ require 'cerberus/domain/strategies/deny_unless_permit'
5
+ require 'cerberus/domain/strategies/permit_overrides'
6
+ require 'cerberus/domain/strategies/permit_unless_deny'
7
+
8
+ module Cerberus
9
+ module Domain
10
+ class Policy
11
+ attr_reader :rules, :strategy
12
+
13
+ STRATEGIES = {
14
+ permit_overrides: Strategies::PermitOverrides,
15
+ permit_unless_deny: Strategies::PermitUnlessDeny,
16
+ deny_overrides: Strategies::DenyOverrides,
17
+ deny_unless_permit: Strategies::DenyUnlessPermit
18
+ }.freeze
19
+
20
+ def initialize(rules:, strategy: :permit_overrides)
21
+ @rules = rules
22
+ @strategy = strategy.to_sym
23
+ end
24
+
25
+ def evaluate(context)
26
+ STRATEGIES.fetch(strategy).combine(rules, context)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ class Rule
6
+ attr_reader :effect, :expression
7
+
8
+ def initialize(effect:, expression:)
9
+ @effect = effect
10
+ @expression = expression
11
+ end
12
+
13
+ def evaluate(context)
14
+ effect.to_sym if expression.evaluate(context)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ module Strategies
6
+ class DenyOverrides
7
+ def self.combine(rules, context)
8
+ permit = nil
9
+
10
+ rules.each do |rule|
11
+ case rule.evaluate(context)
12
+ when :deny
13
+ return :deny
14
+ when :permit
15
+ permit = :permit
16
+ end
17
+ end
18
+
19
+ permit
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ module Strategies
6
+ class DenyUnlessPermit
7
+ def self.combine(rules, context)
8
+ rules.any? { |rule| rule.evaluate(context) == :permit } ? :permit : :deny
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ module Strategies
6
+ class PermitOverrides
7
+ def self.combine(rules, context)
8
+ deny = nil
9
+
10
+ rules.each do |rule|
11
+ case rule.evaluate(context)
12
+ when :permit
13
+ return :permit
14
+ when :deny
15
+ deny = :deny
16
+ end
17
+ end
18
+
19
+ deny
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Domain
5
+ module Strategies
6
+ class PermitUnlessDeny
7
+ def self.combine(rules, context)
8
+ rules.any? { |rule| rule.evaluate(context) == :deny } ? :deny : :permit
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ class InitCerberusMigration < ActiveRecord::Migration
4
+ create_table :cerberus_policies do |t|
5
+ t.string :action, null: false
6
+ t.string :resource_type, null: false
7
+ t.string :strategy, null: false
8
+
9
+ t.timestamp
10
+ end
11
+
12
+ add_index :cerberus_policies, %i[action resource_type], unique: true, name: 'index_cerberus_policies_uniqueness'
13
+
14
+ create_table :cerberus_rules do |t|
15
+ t.string :effect, null: false
16
+
17
+ t.timestamps
18
+ end
19
+
20
+ create_table :cerberus_policy_rules do |t|
21
+ t.belongs_to :policy, foreign_key: { to_table: :cerberus_policies }
22
+ t.belongs_to :rule, foreign_key: { to_table: :cerberus_rules }
23
+
24
+ t.timestamps
25
+ end
26
+
27
+ create_table :cerberus_operands do |t|
28
+ t.string :kind, null: false
29
+ t.string :value
30
+ t.string :name
31
+ t.string :value_type
32
+
33
+ t.timestamps
34
+ end
35
+
36
+ create_table :cerberus_expressions do |t|
37
+ t.string :type, null: false
38
+ t.string :operator, null: false
39
+ t.belongs_to :rule, foreign_key: { to_table: :cerberus_rules }
40
+ t.belongs_to :parent, foreign_key: { to_table: :cerberus_expressions }
41
+ t.belongs_to :left_operand, foreign_key: { to_table: :cerberus_operands }
42
+ t.belongs_to :right_operand, foreign_key: { to_table: :cerberus_operands }
43
+
44
+ t.timestamps
45
+ end
46
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Generators
5
+ class Migrations
6
+ def install!(plugin, to)
7
+ Dir[File.join(migrations_path(plugin), '*.rb')].each { |file| copy_migration(file, to) }
8
+ end
9
+
10
+ private
11
+
12
+ def migrations_path(plugin)
13
+ File.expand_path("migrations/#{plugin}", __dir__)
14
+ end
15
+
16
+ def copy_migration(file, target_dir)
17
+ filename = File.basename(file).sub(/^\d{3}_/, '')
18
+ return if migration_exists?(filename, target_dir)
19
+
20
+ target = File.join(target_dir, timestamped(filename))
21
+ FileUtils.cp(file, target)
22
+ end
23
+
24
+ def migration_exists?(filename, target_dir)
25
+ Dir[File.join(target_dir, "*_#{filename}")].any?
26
+ end
27
+
28
+ def timestamped(filename)
29
+ "#{Time.now.utc.strftime('%Y%m%d%H%M%S')}_#{filename}"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ class Builder
7
+ def initialize(**args)
8
+ @node_model = args.fetch(:node_model, Models::Expressions::Node)
9
+ @condition_model = args.fetch(:condition_model, Models::Expressions::Condition)
10
+ @domain_node = args.fetch(:domain_node, Domain::Node)
11
+ @domain_condition = args.fetch(:domain_condition, Domain::Condition)
12
+ @domain_operand = args.fetch(:domain_operand, Domain::Operand)
13
+ end
14
+
15
+ def build(record)
16
+ build_record(record)
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :node_model, :condition_model, :domain_node, :domain_condition, :domain_operand
22
+
23
+ def build_record(record)
24
+ case record
25
+ when node_model
26
+ build_node(record)
27
+ when condition_model
28
+ build_condition(record)
29
+ end
30
+ end
31
+
32
+ def build_node(node)
33
+ domain_node.new(
34
+ operator: node.operator.to_sym,
35
+ children: node.children.map { |child| build_record(child) }
36
+ )
37
+ end
38
+
39
+ def build_condition(condition)
40
+ domain_condition.new(
41
+ left: build_operand(condition.left_operand),
42
+ operator: condition.operator_before_type_cast,
43
+ right: build_operand(condition.right_operand)
44
+ )
45
+ end
46
+
47
+ def build_operand(operand)
48
+ domain_operand.new(
49
+ kind: operand.kind.to_sym,
50
+ name: operand.name,
51
+ value: operand.value,
52
+ value_type: operand.value_type
53
+ )
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ class Mapper
7
+ attr_reader :domain_policy, :domain_rule, :builder
8
+
9
+ def initialize(domain_policy:, domain_rule:, builder:)
10
+ @domain_policy = domain_policy
11
+ @domain_rule = domain_rule
12
+ @builder = builder
13
+ end
14
+
15
+ def call(record)
16
+ domain_policy.new(
17
+ strategy: record.strategy.to_sym,
18
+ rules: record.rules.map { |rule| build_rule(rule) }
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def build_rule(rule)
25
+ domain_rule.new(
26
+ effect: rule.effect.to_sym,
27
+ expression: builder.build(rule.expression)
28
+ )
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ class ApplicationRecord < ::ActiveRecord::Base
8
+ self.abstract_class = true
9
+ self.table_name_prefix = 'cerberus_'
10
+
11
+ def self.namespace
12
+ 'Cerberus::Infra::ActiveRecord::Models::'
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ class Expression < ApplicationRecord
8
+ belongs_to :rule, class_name: "#{namespace}Rule", optional: true
9
+ belongs_to :parent, class_name: "#{namespace}Expression", optional: true
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ module Expressions
8
+ class Condition < Expression
9
+ OPERATORS = { eq: '==', neq: '!=', gt: '>', gteq: '>=', lt: '<', lteq: '<=' }.freeze
10
+
11
+ belongs_to :left_operand, class_name: "#{namespace}Operand"
12
+ belongs_to :right_operand, class_name: "#{namespace}Operand"
13
+
14
+ enum :operator, OPERATORS
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ module Expressions
8
+ class Node < Expression
9
+ OPERATORS = { or: 'or', and: 'and' }.freeze
10
+
11
+ has_many :children,
12
+ class_name: "#{namespace}Expression",
13
+ foreign_key: :parent_id,
14
+ dependent: :destroy
15
+
16
+ enum :operator, OPERATORS, prefix: true
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ class Operand < ApplicationRecord
8
+ KINDS = {
9
+ subject: 'subject',
10
+ resource: 'resource',
11
+ env: 'env',
12
+ constant: 'constant'
13
+ }.freeze
14
+
15
+ VALUE_TYPES = {
16
+ string: 'string',
17
+ integer: 'integer',
18
+ float: 'float',
19
+ boolean: 'boolean',
20
+ time: 'time',
21
+ date: 'date',
22
+ datetime: 'datetime',
23
+ json: 'json',
24
+ nil: 'nil'
25
+ }.freeze
26
+
27
+ has_many :left_conditions,
28
+ class_name: "#{namespace}Expressions::Condition",
29
+ inverse_of: :left_operand,
30
+ dependent: :restrict_with_exception
31
+ has_many :right_conditions,
32
+ class_name: "#{namespace}Expressions::Condition",
33
+ inverse_of: :right_operand,
34
+ dependent: :restrict_with_exception
35
+
36
+ enum :kind, KINDS, prefix: true
37
+ enum :value_type, VALUE_TYPES, prefix: true
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ class Policy < ApplicationRecord
8
+ STRATEGIES = {
9
+ permit_overrides: 'permit_overrides',
10
+ permit_unless_deny: 'permit_unless_deny',
11
+ deny_overrides: 'deny_overrides',
12
+ deny_unless_permit: 'deny_unless_permit'
13
+ }.freeze
14
+
15
+ has_many :policy_rules, class_name: "#{namespace}PolicyRule", dependent: :destroy
16
+ has_many :rules, class_name: "#{namespace}Rule", through: :policy_rules
17
+
18
+ enum :strategy, STRATEGIES
19
+
20
+ validates :action, :strategy, presence: true
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ class PolicyRule < ApplicationRecord
8
+ belongs_to :policy, class_name: "#{namespace}Policy"
9
+ belongs_to :rule, class_name: "#{namespace}Rule"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ module Models
7
+ class Rule < ApplicationRecord
8
+ EFFECTS = { permit: 'permit', deny: 'deny' }.freeze
9
+
10
+ has_many :policy_rules, class_name: "#{namespace}PolicyRule", dependent: :destroy
11
+ has_many :policies, class_name: "#{namespace}Policy", through: :policy_rules
12
+ has_one :expression, class_name: "#{namespace}Expression", dependent: :destroy
13
+
14
+ enum :effect, EFFECTS
15
+
16
+ validates :effect, presence: true
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ module ActiveRecord
6
+ class Repository
7
+ attr_reader :model
8
+
9
+ def initialize(model:)
10
+ @model = model
11
+ end
12
+
13
+ def find(action:, resource_type:)
14
+ model.find_by(action:, resource_type:)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ module Infra
5
+ class Types
6
+ MAPPER = {
7
+ string: ->(value) { value },
8
+ integer: ->(value) { Integer(value) },
9
+ float: ->(value) { Float(value) },
10
+ boolean: ->(value) { value == 'true' },
11
+ time: ->(value) { Time.parse(value) },
12
+ date: ->(value) { Date.parse(value) },
13
+ datetime: ->(value) { DateTime.parse(value) },
14
+ json: ->(value) { JSON.parse(value) }
15
+ }.freeze
16
+
17
+ def self.cast(value, type)
18
+ MAPPER.fetch(type.to_sym, ->(_) {}).call(value)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ class Plugins
5
+ class ActiveRecord
6
+ class << self
7
+ def apply(config, **)
8
+ load_dependencies
9
+
10
+ config.authorizer = Application::Authorizer.new(
11
+ resolver: Application::Resolver.new(
12
+ repository: Infra::ActiveRecord::Repository.new(
13
+ model: Infra::ActiveRecord::Models::Policy
14
+ ),
15
+ mapper: Infra::ActiveRecord::Mapper.new(
16
+ domain_policy: Domain::Policy,
17
+ domain_rule: Domain::Rule,
18
+ builder: Infra::ActiveRecord::Builder.new
19
+ )
20
+ )
21
+ )
22
+ end
23
+
24
+ private
25
+
26
+ def load_dependencies
27
+ require 'active_record'
28
+ require_relative '../infra/active_record/models/application_record'
29
+ require_relative '../infra/active_record/repository'
30
+ require_relative '../infra/active_record/builder'
31
+ require_relative '../infra/active_record/mapper'
32
+ require_relative '../infra/active_record/models/policy'
33
+ require_relative '../infra/active_record/models/rule'
34
+ require_relative '../infra/active_record/models/policy_rule'
35
+ require_relative '../infra/active_record/models/operand'
36
+ require_relative '../infra/active_record/models/expression'
37
+ require_relative '../infra/active_record/models/expressions/node'
38
+ require_relative '../infra/active_record/models/expressions/condition'
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ class Plugins
5
+ def self.load(config, name, **opts)
6
+ require "cerberus/plugins/#{name}"
7
+
8
+ config.plugins << name
9
+
10
+ const_get(name.to_s.split('_').map(&:capitalize).join)
11
+ .apply(config, **opts)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cerberus
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cerberus'
data/lib/cerberus.rb ADDED
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cerberus/version'
4
+
5
+ require 'time'
6
+ require 'date'
7
+ require 'json'
8
+
9
+ require 'cerberus/infra/types'
10
+ require 'cerberus/domain/operand'
11
+ require 'cerberus/domain/condition'
12
+ require 'cerberus/domain/node'
13
+ require 'cerberus/domain/rule'
14
+ require 'cerberus/domain/policy'
15
+
16
+ require 'cerberus/application/authorizer'
17
+ require 'cerberus/application/resolver'
18
+ require 'cerberus/generators/migrations'
19
+ require 'cerberus/plugins'
20
+ require 'cerberus/configuration'
21
+
22
+ module Cerberus
23
+ class NotAuthorized < StandardError; end
24
+
25
+ class Base
26
+ class << self
27
+ def configuration
28
+ @configuration ||= Cerberus::Configuration.new
29
+ end
30
+
31
+ def plugin(name, **opts)
32
+ Cerberus::Plugins.load(configuration, name, **opts)
33
+ end
34
+
35
+ def authorized?(**args)
36
+ configuration.authorizer.authorized?(**args)
37
+ end
38
+
39
+ def authorize!(**args)
40
+ configuration.authorizer.authorize!(**args)
41
+ end
42
+ end
43
+ end
44
+ end
data/sig/cerberus.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Cerberus
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,86 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cerberus-xacml
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Danil Nasibullin
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-04-05 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Cerberus is a policy evaluation engine implementing ABAC authorization
14
+ model.
15
+ email:
16
+ - dan.nasibullin@gmail.com
17
+ executables: []
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE.txt
23
+ - README.md
24
+ - Rakefile
25
+ - lib/cerberus-xacml.rb
26
+ - lib/cerberus.rb
27
+ - lib/cerberus/application/authorizer.rb
28
+ - lib/cerberus/application/resolver.rb
29
+ - lib/cerberus/configuration.rb
30
+ - lib/cerberus/domain/condition.rb
31
+ - lib/cerberus/domain/node.rb
32
+ - lib/cerberus/domain/operand.rb
33
+ - lib/cerberus/domain/policy.rb
34
+ - lib/cerberus/domain/rule.rb
35
+ - lib/cerberus/domain/strategies/deny_overrides.rb
36
+ - lib/cerberus/domain/strategies/deny_unless_permit.rb
37
+ - lib/cerberus/domain/strategies/permit_overrides.rb
38
+ - lib/cerberus/domain/strategies/permit_unless_deny.rb
39
+ - lib/cerberus/generators/migrations.rb
40
+ - lib/cerberus/generators/migrations/active_record/000_cerberus_init.rb
41
+ - lib/cerberus/infra/active_record/builder.rb
42
+ - lib/cerberus/infra/active_record/mapper.rb
43
+ - lib/cerberus/infra/active_record/models/application_record.rb
44
+ - lib/cerberus/infra/active_record/models/expression.rb
45
+ - lib/cerberus/infra/active_record/models/expressions/condition.rb
46
+ - lib/cerberus/infra/active_record/models/expressions/node.rb
47
+ - lib/cerberus/infra/active_record/models/operand.rb
48
+ - lib/cerberus/infra/active_record/models/policy.rb
49
+ - lib/cerberus/infra/active_record/models/policy_rule.rb
50
+ - lib/cerberus/infra/active_record/models/rule.rb
51
+ - lib/cerberus/infra/active_record/repository.rb
52
+ - lib/cerberus/infra/types.rb
53
+ - lib/cerberus/plugins.rb
54
+ - lib/cerberus/plugins/active_record.rb
55
+ - lib/cerberus/version.rb
56
+ - sig/cerberus.rbs
57
+ homepage: https://github.com/D-Black-N/cerberus
58
+ licenses:
59
+ - MIT
60
+ metadata:
61
+ homepage_uri: https://github.com/D-Black-N/cerberus
62
+ source_code_uri: https://github.com/D-Black-N/cerberus
63
+ changelog_uri: https://github.com/D-Black-N/cerberus/blob/main/CHANGELOG.md
64
+ bug_tracker_uri: https://github.com/D-Black-N/cerberus/issues
65
+ allowed_push_host: https://rubygems.org
66
+ rubygems_mfa_required: 'true'
67
+ post_install_message:
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '3.1'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 3.3.27
83
+ signing_key:
84
+ specification_version: 4
85
+ summary: High-performance ABAC (Attribute-Based Access Control) engine for Ruby
86
+ test_files: []