granity 0.1.0 → 0.1.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 165420efec85cf4fcec2feb3fd44a51da6efd978bc552c2301ac5d4cc7b0f4ee
4
- data.tar.gz: 5595acb02ddd28a9e0a6fde235d03c8a295262249c74c9246fc7fa901fbf51d7
3
+ metadata.gz: d187a969f4aa61ad031d5eff41882fba68796b1455f8ea11011663d51fea4960
4
+ data.tar.gz: b11f5bf0eb6fa7576d505f4f168a4b4bc4c7353f87b0d4120484347616228629
5
5
  SHA512:
6
- metadata.gz: d38148344bdab1966672fedc51ae902810b1ff2d0e4f3af534798b6f9c42c441995520f97a5edefdd6bfe049974153e0c73a792b29eb94bb25fb6658c4e1886e
7
- data.tar.gz: 2eaf91f2f46b48f32092e0651e91376da80efe5170e3de6132c4132a85365a5fb4cdf208332af15a923d017258ef69aaaec200102bc05dc672118a2823f31e14
6
+ metadata.gz: 1f2ee84f563fb463429658e3d55125db8cbe70bad1369d01d8c5f3514a929f2d2127fc58b4133691abc1d3bfabc9d5dff028e73c32a077ae0e944e6f96f22c0c
7
+ data.tar.gz: db09edf1bc7eb3df8d97240f6e030782cc5fe9f9b17f92c44935b11ef2a3a0864113674dfbf5f1f3702285bd3b4a14e8ee576fe1abcbd0aca57aba910ca2bec8
data/MIT-LICENSE CHANGED
@@ -1,4 +1,4 @@
1
- Copyright Yatish Mehta
1
+ Copyright TODO: Write your name
2
2
 
3
3
  Permission is hereby granted, free of charge, to any person obtaining
4
4
  a copy of this software and associated documentation files (the
data/README.md CHANGED
@@ -1,28 +1,224 @@
1
1
  # Granity
2
- Short description and motivation.
3
2
 
4
- ## Usage
5
- How to use my plugin.
3
+ Granity is a fine-grained authorization engine for Ruby on Rails applications. It provides a flexible DSL for defining authorization rules and efficient permission checking.
6
4
 
7
5
  ## Installation
8
- Add this line to your application's Gemfile:
6
+
7
+ Add this gem to your application's Gemfile:
9
8
 
10
9
  ```ruby
11
- gem "granity"
10
+ gem 'granity'
12
11
  ```
13
12
 
14
- And then execute:
13
+ Then execute:
14
+
15
15
  ```bash
16
- $ bundle
16
+ $ bundle install
17
17
  ```
18
18
 
19
- Or install it yourself as:
19
+ Run the migrations:
20
+
20
21
  ```bash
21
- $ gem install granity
22
+ $ rails granity:install:migrations
23
+ $ rails db:migrate
24
+ ```
25
+
26
+ ## Configuration
27
+
28
+ Create an initializer for Granity:
29
+
30
+ ```ruby
31
+ # config/initializers/granity.rb
32
+ Granity.configure do |config|
33
+ config.cache_provider = Rails.cache # Optional: Uses Rails.cache if provided
34
+ config.cache_ttl = 10.minutes
35
+ config.max_cache_size = 10_000
36
+ config.enable_tracing = !Rails.env.production?
37
+ config.max_traversal_depth = 10
38
+ end
39
+ ```
40
+
41
+ ## Defining Authorization Schema
42
+
43
+ Use the Granity DSL to define your authorization schema:
44
+
45
+ ```ruby
46
+ # config/initializers/granity.rb
47
+ Granity.define do
48
+ resource_type :user do
49
+ # User schema
50
+ end
51
+
52
+ resource_type :document do
53
+ relation :owner, type: :user
54
+ relation :viewer, type: :user
55
+ relation :team, type: :team
56
+
57
+ permission :view do
58
+ include_any do
59
+ include_relation :owner
60
+ include_relation :viewer
61
+ include_relation :admin from :team
62
+ end
63
+ end
64
+
65
+ permission :edit do
66
+ include_relation :owner
67
+ end
68
+ end
69
+
70
+ resource_type :team do
71
+ relation :member, type: :user
72
+ relation :admin, type: :user
73
+ end
74
+ end
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ ### Checking Permissions
80
+
81
+ ```ruby
82
+ # Check if a user has permission on a resource
83
+ if Granity.check_permission(
84
+ subject_type: 'user',
85
+ subject_id: current_user.id,
86
+ permission: 'view',
87
+ resource_type: 'document',
88
+ resource_id: document.id
89
+ )
90
+ # User can view the document
91
+ end
92
+ ```
93
+
94
+ ### Creating Relations
95
+
96
+ ```ruby
97
+ # Grant a user owner access to a document
98
+ Granity.create_relation(
99
+ object_type: 'document',
100
+ object_id: document.id,
101
+ relation: 'owner',
102
+ subject_type: 'user',
103
+ subject_id: user.id
104
+ )
105
+ ```
106
+
107
+ ### Finding Subjects
108
+
109
+ ```ruby
110
+ # Find all users who can view a document
111
+ viewers = Granity.find_subjects(
112
+ resource_type: 'document',
113
+ resource_id: document.id,
114
+ permission: 'view'
115
+ )
116
+ ```
117
+
118
+ ### Integration with Rails Controllers
119
+
120
+ ```ruby
121
+ class ApplicationController < ActionController::Base
122
+ def authorize!(resource, permission)
123
+ unless Granity.check_permission(
124
+ subject_type: 'user',
125
+ subject_id: current_user.id,
126
+ permission: permission,
127
+ resource_type: resource.model_name.singular,
128
+ resource_id: resource.id
129
+ )
130
+ raise Unauthorized, "Not authorized to #{permission} this #{resource.model_name.human}"
131
+ end
132
+ end
133
+ end
134
+
135
+ class DocumentsController < ApplicationController
136
+ def show
137
+ @document = Document.find(params[:id])
138
+ authorize!(@document, :view)
139
+ # ...
140
+ end
141
+
142
+ def update
143
+ @document = Document.find(params[:id])
144
+ authorize!(@document, :edit)
145
+ # ...
146
+ end
147
+ end
148
+ ```
149
+
150
+ ## DSL Reference
151
+
152
+ ### Resource Types
153
+
154
+ Define the entities in your authorization model:
155
+
156
+ ```ruby
157
+ resource_type :document do
158
+ # Resource definition
159
+ end
22
160
  ```
23
161
 
24
- ## Contributing
25
- Contribution directions go here.
162
+ ### Relations
163
+
164
+ Define relationships between resources:
165
+
166
+ ```ruby
167
+ relation :owner, type: :user
168
+ relation :parent_folder, type: :folder
169
+ ```
170
+
171
+ ### Permissions
172
+
173
+ Define access rules with Boolean logic:
174
+
175
+ ```ruby
176
+ permission :view do
177
+ include_any do
178
+ include_relation :owner
179
+ include_relation :viewer
180
+ include_relation :editor
181
+ end
182
+ end
183
+ ```
184
+
185
+ ### Boolean Logic
186
+
187
+ Combine relations with `include_any` (OR) and `include_all` (AND):
188
+
189
+ ```ruby
190
+ permission :publish do
191
+ include_all do
192
+ include_relation :editor
193
+
194
+ include_any do
195
+ include_relation :approved
196
+ include_relation :admin from :team
197
+ end
198
+ end
199
+ end
200
+ ```
201
+
202
+ ### Permission Composition
203
+
204
+ Reuse and compose permissions:
205
+
206
+ ```ruby
207
+ permission :manage do
208
+ include_permission :view
209
+ include_permission :edit
210
+ include_relation :owner
211
+ end
212
+ ```
213
+
214
+ ### Relation Traversal
215
+
216
+ Follow paths through related resources:
217
+
218
+ ```ruby
219
+ include_relation :member from :team
220
+ ```
26
221
 
27
222
  ## License
223
+
28
224
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
@@ -0,0 +1,12 @@
1
+ module Granity
2
+ class RelationTuple < ActiveRecord::Base
3
+ self.table_name = "granity_relation_tuples"
4
+
5
+ validates :object_type, :object_id, :relation, :subject_type, :subject_id, presence: true
6
+
7
+ # Useful scopes for querying
8
+ scope :for_object, ->(type, id) { where(object_type: type, object_id: id) }
9
+ scope :for_subject, ->(type, id) { where(subject_type: type, subject_id: id) }
10
+ scope :with_relation, ->(relation) { where(relation: relation) }
11
+ end
12
+ end
@@ -0,0 +1,20 @@
1
+ class CreateGranityRelationTuples < ActiveRecord::Migration[7.0]
2
+ def change
3
+ create_table :granity_relation_tuples do |t|
4
+ t.string :object_type, null: false
5
+ t.string :object_id, null: false
6
+ t.string :relation, null: false
7
+ t.string :subject_type, null: false
8
+ t.string :subject_id, null: false
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :granity_relation_tuples, [:object_type, :object_id, :relation],
14
+ name: "index_granity_tuples_on_object"
15
+ add_index :granity_relation_tuples, [:subject_type, :subject_id],
16
+ name: "index_granity_tuples_on_subject"
17
+ add_index :granity_relation_tuples, [:object_type, :object_id, :relation, :subject_type, :subject_id],
18
+ unique: true, name: "index_granity_tuples_unique"
19
+ end
20
+ end
@@ -0,0 +1,16 @@
1
+ module Granity
2
+ module Generators
3
+ class InstallGenerator < Rails::Generators::Base
4
+ source_root File.expand_path("templates", __dir__)
5
+
6
+ def create_migration
7
+ timestamp = Time.now.utc.strftime("%Y%m%d%H%M%S")
8
+ copy_file "create_granity_tables.rb", "db/migrate/#{timestamp}_create_granity_tables.rb"
9
+ end
10
+
11
+ def create_initializer
12
+ copy_file "initializer.rb", "config/initializers/granity.rb"
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,155 @@
1
+ module Granity
2
+ class AuthorizationEngine
3
+ class << self
4
+ def check_permission(subject_type:, subject_id:, permission:, resource_type:, resource_id:)
5
+ cache_key = "granity:permission:#{subject_type}:#{subject_id}:#{permission}:#{resource_type}:#{resource_id}"
6
+
7
+ # Try fetching from cache first
8
+ cached_result = cache.read(cache_key)
9
+ if cached_result
10
+ trace("CACHE HIT: #{cache_key} -> #{cached_result}")
11
+ return cached_result
12
+ end
13
+
14
+ trace("CACHE MISS: #{cache_key}")
15
+
16
+ # Generate dependencies for this permission check
17
+ dependencies = DependencyAnalyzer.analyze_permission_check(
18
+ subject_type: subject_type,
19
+ subject_id: subject_id,
20
+ permission: permission,
21
+ resource_type: resource_type,
22
+ resource_id: resource_id
23
+ )
24
+
25
+ # Check the permission
26
+ result = PermissionEvaluator.evaluate(
27
+ subject_type: subject_type,
28
+ subject_id: subject_id,
29
+ permission: permission,
30
+ resource_type: resource_type,
31
+ resource_id: resource_id
32
+ )
33
+
34
+ # Store in cache with all dependency keys
35
+ cache.write(cache_key, result, dependencies: dependencies)
36
+ trace("CACHE WRITE: #{cache_key} -> #{result} with dependencies: #{dependencies}")
37
+
38
+ result
39
+ end
40
+
41
+ def find_subjects(resource_type:, resource_id:, permission:)
42
+ cache_key = "granity:subjects:#{permission}:#{resource_type}:#{resource_id}"
43
+
44
+ # Try fetching from cache first
45
+ cached_result = cache.read(cache_key)
46
+ if cached_result
47
+ trace("CACHE HIT: #{cache_key}")
48
+ return cached_result
49
+ end
50
+
51
+ trace("CACHE MISS: #{cache_key}")
52
+
53
+ # Generate dependencies for this subjects query
54
+ dependencies = DependencyAnalyzer.analyze_find_subjects(
55
+ resource_type: resource_type,
56
+ resource_id: resource_id,
57
+ permission: permission
58
+ )
59
+
60
+ # Get the subjects
61
+ subjects = PermissionEvaluator.find_subjects(
62
+ resource_type: resource_type,
63
+ resource_id: resource_id,
64
+ permission: permission
65
+ )
66
+
67
+ # Store in cache with all dependency keys
68
+ cache.write(cache_key, subjects, dependencies: dependencies)
69
+ trace("CACHE WRITE: #{cache_key} with dependencies: #{dependencies}")
70
+
71
+ subjects
72
+ end
73
+
74
+ def create_relation(object_type:, object_id:, relation:, subject_type:, subject_id:)
75
+ # Create the relation tuple in the database
76
+ tuple = Granity::RelationTuple.create!(
77
+ object_type: object_type,
78
+ object_id: object_id,
79
+ relation: relation,
80
+ subject_type: subject_type,
81
+ subject_id: subject_id
82
+ )
83
+
84
+ # Invalidate cache entries that depend on this relation
85
+ invalidate_cache_for_relation(object_type, object_id, relation)
86
+
87
+ tuple
88
+ rescue ActiveRecord::RecordNotUnique
89
+ # If the relation already exists, just return it
90
+ Granity::RelationTuple.find_by(
91
+ object_type: object_type,
92
+ object_id: object_id,
93
+ relation: relation,
94
+ subject_type: subject_type,
95
+ subject_id: subject_id
96
+ )
97
+ end
98
+
99
+ def delete_relation(object_type:, object_id:, relation:, subject_type:, subject_id:)
100
+ tuple = Granity::RelationTuple.find_by(
101
+ object_type: object_type,
102
+ object_id: object_id,
103
+ relation: relation,
104
+ subject_type: subject_type,
105
+ subject_id: subject_id
106
+ )
107
+
108
+ return false unless tuple
109
+
110
+ tuple.destroy
111
+
112
+ # Invalidate cache entries that depend on this relation
113
+ invalidate_cache_for_relation(object_type, object_id, relation)
114
+
115
+ true
116
+ end
117
+
118
+ def reset_cache
119
+ cache.clear
120
+ end
121
+
122
+ private
123
+
124
+ def cache
125
+ @cache ||= begin
126
+ config = Granity.configuration
127
+ config.cache_provider || Granity::InMemoryCache.new(
128
+ max_size: config.max_cache_size,
129
+ ttl: config.cache_ttl
130
+ )
131
+ end
132
+ end
133
+
134
+ def invalidate_cache_for_relation(object_type, object_id, relation)
135
+ # This is a simplified approach. In a real implementation, we would use
136
+ # a more sophisticated approach to track which cache keys depend on which
137
+ # relations, possibly using Redis sets or a similar mechanism.
138
+
139
+ # For now, we take a conservative approach and invalidate any cache entry
140
+ # that might depend on this relation being changed
141
+ dependency_key = "granity:relation:#{object_type}:#{object_id}:#{relation}"
142
+ cache.invalidate_dependencies([dependency_key])
143
+
144
+ trace("CACHE INVALIDATE for dependency: #{dependency_key}")
145
+ end
146
+
147
+ def trace(message)
148
+ return unless Granity.configuration.enable_tracing
149
+
150
+ # In a real implementation, this would use a proper logging system
151
+ puts "[Granity] #{message}"
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,18 @@
1
+ module Granity
2
+ # Configuration class to hold Granity settings
3
+ class Configuration
4
+ attr_accessor :cache_provider
5
+ attr_accessor :cache_ttl
6
+ attr_accessor :max_cache_size
7
+ attr_accessor :enable_tracing
8
+ attr_accessor :max_traversal_depth
9
+
10
+ def initialize
11
+ @cache_provider = nil
12
+ @cache_ttl = 10.minutes
13
+ @max_cache_size = 10_000
14
+ @enable_tracing = !defined?(Rails) || !Rails.env.production?
15
+ @max_traversal_depth = 10
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,127 @@
1
+ module Granity
2
+ # Analyzes dependencies between relations and permissions for proper cache invalidation
3
+ class DependencyAnalyzer
4
+ class << self
5
+ # Analyze dependencies for a permission check
6
+ # Returns an array of dependencies for cache invalidation
7
+ def analyze_permission_check(subject_type:, subject_id:, permission:, resource_type:, resource_id:)
8
+ deps = []
9
+
10
+ # Basic direct dependencies
11
+ deps << "granity:subject:#{subject_type}:#{subject_id}"
12
+ deps << "granity:resource:#{resource_type}:#{resource_id}"
13
+
14
+ # Get schema dependencies for this permission
15
+ schema_dependencies = analyze_permission_schema(resource_type, permission)
16
+ deps.concat(schema_dependencies)
17
+
18
+ deps
19
+ end
20
+
21
+ # Analyze dependencies for finding subjects with a permission
22
+ def analyze_find_subjects(resource_type:, resource_id:, permission:)
23
+ deps = []
24
+
25
+ # Basic resource dependency
26
+ deps << "granity:resource:#{resource_type}:#{resource_id}"
27
+
28
+ # Get schema dependencies for this permission
29
+ schema_dependencies = analyze_permission_schema(resource_type, permission)
30
+ deps.concat(schema_dependencies)
31
+
32
+ deps
33
+ end
34
+
35
+ private
36
+
37
+ # Analyze schema dependencies for a permission
38
+ def analyze_permission_schema(resource_type, permission)
39
+ deps = []
40
+ schema = Granity::Schema.current
41
+
42
+ # Add dependency on the permission definition itself
43
+ deps << "granity:schema:#{resource_type}:permission:#{permission}"
44
+
45
+ # Get the resource type from schema
46
+ resource_type_def = schema.resource_types[resource_type.to_sym]
47
+ return deps unless resource_type_def
48
+
49
+ # Get the permission definition
50
+ permission_def = resource_type_def.permissions[permission.to_sym]
51
+ return deps unless permission_def
52
+
53
+ # Track visited permissions to prevent cycles
54
+ visited = Set.new(["#{resource_type}:#{permission}"])
55
+
56
+ # Add all relations that this permission depends on
57
+ relations = extract_relations_from_permission(permission_def, [], visited)
58
+ relations.each do |relation|
59
+ deps << "granity:relation:#{resource_type}:#{relation}"
60
+ end
61
+
62
+ deps
63
+ end
64
+
65
+ # Extract all relations used in a permission definition
66
+ def extract_relations_from_permission(rule_or_permission, relations = [], visited = Set.new)
67
+ # Handle different types of input
68
+ if rule_or_permission.is_a?(Granity::Permission)
69
+ # It's a Permission object with rules
70
+ rule_or_permission.rules.each do |rule|
71
+ extract_relations_from_rule(rule, relations, visited, rule_or_permission.resource_type)
72
+ end
73
+ else
74
+ # It's a rule object directly
75
+ extract_relations_from_rule(rule_or_permission, relations, visited, nil)
76
+ end
77
+
78
+ relations.uniq
79
+ end
80
+
81
+ # Process a single rule to extract relations
82
+ def extract_relations_from_rule(rule, relations = [], visited = Set.new, resource_type = nil)
83
+ if rule.is_a?(Granity::Rules::Relation)
84
+ # Direct relation - add to results
85
+ relations << rule.relation
86
+ elsif rule.is_a?(Granity::Rules::Any) || rule.is_a?(Granity::Rules::All)
87
+ # Container rule - process each subrule
88
+ rule.rules.each do |subrule|
89
+ extract_relations_from_rule(subrule, relations, visited, resource_type)
90
+ end
91
+ elsif rule.is_a?(Granity::Rules::Permission)
92
+ # Referenced permission - get from schema and process
93
+ # Skip if we've already visited this permission to prevent cycles
94
+ permission_key = "#{resource_type}:#{rule.permission}"
95
+ return relations if visited.include?(permission_key)
96
+
97
+ # Mark as visited to prevent cycles
98
+ visited.add(permission_key)
99
+
100
+ # Get referenced permission from schema (if possible)
101
+ schema = Granity::Schema.current
102
+
103
+ if resource_type && schema.resource_types[resource_type.to_sym]
104
+ # If we know the resource type, look for the permission there
105
+ resource_type_def = schema.resource_types[resource_type.to_sym]
106
+ if resource_type_def.permissions.has_key?(rule.permission)
107
+ referenced_permission = resource_type_def.permissions[rule.permission]
108
+ extract_relations_from_permission(referenced_permission, relations, visited)
109
+ end
110
+ else
111
+ # If resource type is unknown, look in all resource types
112
+ resource_types = schema.resource_types.values
113
+ resource_types.each do |rt|
114
+ if rt.permissions.has_key?(rule.permission)
115
+ referenced_permission = rt.permissions[rule.permission]
116
+ extract_relations_from_permission(referenced_permission, relations, visited)
117
+ break
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ relations
124
+ end
125
+ end
126
+ end
127
+ end
@@ -1,5 +1,13 @@
1
1
  module Granity
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace Granity
4
+
5
+ initializer "granity.load_migrations" do |app|
6
+ unless app.root.to_s.match root.to_s
7
+ config.paths["db/migrate"].expanded.each do |expanded_path|
8
+ app.config.paths["db/migrate"] << expanded_path
9
+ end
10
+ end
11
+ end
4
12
  end
5
13
  end