phalanx 0.2.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/MIT-LICENSE +20 -0
- data/README.md +28 -0
- data/Rakefile +9 -0
- data/app/controllers/phalanx/application_controller.rb +7 -0
- data/app/jobs/phalanx/application_job.rb +7 -0
- data/app/models/concerns/phalanx/permission_assignable.rb +79 -0
- data/app/models/phalanx/application_record.rb +8 -0
- data/app/models/phalanx/permission_assignment.rb +65 -0
- data/config/routes.rb +6 -0
- data/db/migrate/20260216223657_create_phalanx_permission_assignments.rb +21 -0
- data/lib/generators/phalanx/install/USAGE +8 -0
- data/lib/generators/phalanx/install/install_generator.rb +12 -0
- data/lib/generators/phalanx/install/templates/initializer.rb +25 -0
- data/lib/phalanx/config.rb +28 -0
- data/lib/phalanx/engine.rb +17 -0
- data/lib/phalanx/error.rb +7 -0
- data/lib/phalanx/generator/dsl.rb +93 -0
- data/lib/phalanx/generator.rb +91 -0
- data/lib/phalanx/migration_extensions.rb +21 -0
- data/lib/phalanx/parser/cycle_validator.rb +128 -0
- data/lib/phalanx/parser/permission.rb +21 -0
- data/lib/phalanx/parser/permission_group.rb +12 -0
- data/lib/phalanx/parser/schema_validator.rb +23 -0
- data/lib/phalanx/parser.rb +52 -0
- data/lib/phalanx/permission.rb +132 -0
- data/lib/phalanx/permission_not_found.rb +18 -0
- data/lib/phalanx/version.rb +6 -0
- data/lib/phalanx.rb +70 -0
- data/lib/tasks/phalanx_tasks.rake +19 -0
- metadata +115 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e3cae5ca06701db7bcbae49089f451412001f4c9f87f178d13b4ae9750a44a35
|
|
4
|
+
data.tar.gz: 738418d35debd75d1f09a5707b237bb1bd946454c0dac2f5094b8228fd3bcc22
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 46825966362b53eb137632b340291404f2a4a960476c4d9a202869a8b621a990820107fd307be08b029841b1b48405054b6794b3aa4f9d1cb79e6671ed2acdc8
|
|
7
|
+
data.tar.gz: d4e567ef908b83eac76aaf19aa1ea2fe1079eb52240faefceef4308f4c5876b761b9fa5896858cdac288ceddb237ba6d7efd60d6e2a66be4b1cc74b28e192f01
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Minty Fresh
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Phalanx
|
|
2
|
+
Short description and motivation.
|
|
3
|
+
|
|
4
|
+
## Usage
|
|
5
|
+
How to use my plugin.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
Add this line to your application's Gemfile:
|
|
9
|
+
|
|
10
|
+
```ruby
|
|
11
|
+
gem "phalanx"
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
And then execute:
|
|
15
|
+
```bash
|
|
16
|
+
$ bundle
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or install it yourself as:
|
|
20
|
+
```bash
|
|
21
|
+
$ gem install phalanx
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Contributing
|
|
25
|
+
Contribution directions go here.
|
|
26
|
+
|
|
27
|
+
## License
|
|
28
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
module PermissionAssignable
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend T::Helpers
|
|
8
|
+
extend ActiveSupport::Concern
|
|
9
|
+
|
|
10
|
+
requires_ancestor { ActiveRecord::Base }
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
T.bind(self, T.class_of(ActiveRecord::Base))
|
|
14
|
+
|
|
15
|
+
has_many :permission_assignments, as: :assignable, autosave: true, class_name: 'Phalanx::PermissionAssignment',
|
|
16
|
+
dependent: :destroy, inverse_of: :assignable
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
module ClassMethods
|
|
20
|
+
extend T::Sig
|
|
21
|
+
|
|
22
|
+
sig { params(scopes: String).void }
|
|
23
|
+
def permitted_permission_scopes(*scopes)
|
|
24
|
+
T.bind(self, T.class_of(ActiveRecord::Base))
|
|
25
|
+
define_method(:permitted_permission_scopes) { scopes }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
mixes_in_class_methods ClassMethods
|
|
30
|
+
|
|
31
|
+
sig { returns(T::Set[Phalanx::Permission]) }
|
|
32
|
+
def permissions
|
|
33
|
+
T.let(T.unsafe(self).permission_assignments, T::Enumerable[Phalanx::PermissionAssignment])
|
|
34
|
+
.reject { |assignment| assignment.marked_for_destruction? || !assignment.permission_scope_supported? }
|
|
35
|
+
.filter_map(&:permission)
|
|
36
|
+
.flat_map(&:with_implied_permissions)
|
|
37
|
+
.to_set
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(permissions: T::Enumerable[Phalanx::Permission]).void }
|
|
41
|
+
def permissions=(permissions)
|
|
42
|
+
permission_ids = permissions.flat_map(&:with_implied_permissions).to_set(&:id)
|
|
43
|
+
|
|
44
|
+
T.unsafe(self).permission_assignments.each do |permission_assignment|
|
|
45
|
+
next if permission_assignment.marked_for_destruction?
|
|
46
|
+
|
|
47
|
+
if permission_ids.include?(permission_assignment.permission_id)
|
|
48
|
+
permission_ids.delete(permission_assignment.permission_id)
|
|
49
|
+
else
|
|
50
|
+
permission_assignment.mark_for_destruction
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
permission_ids.each do |permission_id|
|
|
55
|
+
T.unsafe(self).permission_assignments.build(permission_id:)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { returns(T::Boolean) }
|
|
60
|
+
def permission_assignments_changed?
|
|
61
|
+
T.unsafe(self).permission_assignments.target.any?(&:changed_for_autosave?)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { overridable.returns(T.nilable(T.any(String, T::Enumerable[String]))) }
|
|
65
|
+
def permitted_permission_scopes
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
sig { params(scope: String).returns(T::Boolean) }
|
|
70
|
+
def permission_scope_supported?(scope)
|
|
71
|
+
case (scopes = permitted_permission_scopes)
|
|
72
|
+
when Enumerable then scopes.include?(scope)
|
|
73
|
+
when String then scopes == scope
|
|
74
|
+
when nil then true
|
|
75
|
+
else T.absurd(scopes)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# == Schema Information
|
|
5
|
+
#
|
|
6
|
+
# Table name: phalanx_permission_assignments
|
|
7
|
+
#
|
|
8
|
+
# id :integer not null, primary key
|
|
9
|
+
# assignable_type :string not null
|
|
10
|
+
# created_at :datetime not null
|
|
11
|
+
# updated_at :datetime not null
|
|
12
|
+
# assignable_id :bigint not null
|
|
13
|
+
# permission_id :string not null
|
|
14
|
+
#
|
|
15
|
+
# Indexes
|
|
16
|
+
#
|
|
17
|
+
# idx_on_assignable_type_assignable_id_permission_id_db35db88c3 (assignable_type,assignable_id,permission_id) UNIQUE
|
|
18
|
+
# index_phalanx_permission_assignments_on_assignable (assignable_type,assignable_id)
|
|
19
|
+
# index_phalanx_permission_assignments_on_permission_id (permission_id)
|
|
20
|
+
#
|
|
21
|
+
module Phalanx
|
|
22
|
+
class PermissionAssignment < ApplicationRecord
|
|
23
|
+
extend T::Sig
|
|
24
|
+
|
|
25
|
+
belongs_to :assignable, polymorphic: true
|
|
26
|
+
|
|
27
|
+
validates :permission_id, presence: true
|
|
28
|
+
|
|
29
|
+
validate :permission_must_have_supported_permission_scope
|
|
30
|
+
|
|
31
|
+
scope :stale, -> { where.not(permission_id: Phalanx.permission_class.values.map(&:id)) }
|
|
32
|
+
|
|
33
|
+
sig { returns(T::Boolean) }
|
|
34
|
+
def stale?
|
|
35
|
+
permission_id.present? && permission.nil?
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { returns(T.nilable(Phalanx::Permission)) }
|
|
39
|
+
def permission
|
|
40
|
+
permission_id.present? ? Phalanx.permission_class[permission_id] : nil
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
sig { params(permission: Phalanx::Permission).void }
|
|
44
|
+
def permission=(permission)
|
|
45
|
+
self.permission_id = permission.id
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
sig { returns(T::Boolean) }
|
|
49
|
+
def permission_scope_supported?
|
|
50
|
+
return false if (assignable = self.assignable).nil? || (permission = self.permission).nil?
|
|
51
|
+
|
|
52
|
+
assignable.permission_scope_supported?(permission.scope)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
sig { void }
|
|
58
|
+
def permission_must_have_supported_permission_scope
|
|
59
|
+
return if (assignable = self.assignable).nil? || (permission = self.permission).nil?
|
|
60
|
+
return if permission_scope_supported?
|
|
61
|
+
|
|
62
|
+
errors.add(:permission, :unsupported_permission_scope, scope: permission.scope)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
class CreatePhalanxPermissionAssignments < ActiveRecord::Migration[8.1]
|
|
5
|
+
extend T::Sig
|
|
6
|
+
|
|
7
|
+
include Phalanx::MigrationExtensions
|
|
8
|
+
|
|
9
|
+
sig { void }
|
|
10
|
+
def change
|
|
11
|
+
primary_key_type, foreign_key_type = primary_and_foreign_key_types
|
|
12
|
+
|
|
13
|
+
create_table :phalanx_permission_assignments, id: primary_key_type do |t|
|
|
14
|
+
t.belongs_to :assignable, polymorphic: true, null: false, type: foreign_key_type
|
|
15
|
+
t.string :permission_id, null: false, index: true
|
|
16
|
+
t.timestamps default: -> { 'CURRENT_TIMESTAMP' }
|
|
17
|
+
|
|
18
|
+
t.index %i[assignable_type assignable_id permission_id], unique: true
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
class InstallGenerator < Rails::Generators::Base
|
|
6
|
+
source_root File.expand_path('templates', __dir__)
|
|
7
|
+
|
|
8
|
+
def create_initializer
|
|
9
|
+
template 'initializer.rb', 'config/initializers/phalanx.rb'
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
Phalanx.configure do |config|
|
|
5
|
+
# To change the name of the permission class, you can specify a custom class name.
|
|
6
|
+
#
|
|
7
|
+
# config.permission_class_name = 'Permission'
|
|
8
|
+
|
|
9
|
+
# To change the location of the permission class file, you can specify a custom file path.
|
|
10
|
+
# This should match the class name specified in `config.permission_class_name`
|
|
11
|
+
# for the class loader to be able to find the class.
|
|
12
|
+
#
|
|
13
|
+
# config.permission_file_path = Rails.root.join('lib/permission.rb')
|
|
14
|
+
|
|
15
|
+
# To change the location from which permission files are loaded, you can specify a custom glob pattern.
|
|
16
|
+
# Multiple patterns can be specified if needed.
|
|
17
|
+
#
|
|
18
|
+
# config.permission_file_paths = [
|
|
19
|
+
# Rails.root.join('config/permissions/**/*.{yml,yaml}'),
|
|
20
|
+
# ]
|
|
21
|
+
|
|
22
|
+
# If extending the permissions DSL, you can specify your own custom JSON schema file.
|
|
23
|
+
#
|
|
24
|
+
# config.schema_file_path = Rails.root.join('config/permissions-schema.json')
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
class Config
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { returns(Pathname) }
|
|
9
|
+
attr_accessor :schema_file_path
|
|
10
|
+
|
|
11
|
+
sig { returns(T::Array[Pathname]) }
|
|
12
|
+
attr_accessor :permission_file_paths
|
|
13
|
+
|
|
14
|
+
sig { returns(Pathname) }
|
|
15
|
+
attr_accessor :permission_file_path
|
|
16
|
+
|
|
17
|
+
sig { returns(String) }
|
|
18
|
+
attr_accessor :permission_class_name
|
|
19
|
+
|
|
20
|
+
sig { void }
|
|
21
|
+
def initialize
|
|
22
|
+
@schema_file_path = T.let(T.unsafe(Phalanx::Engine).root.join('schema.json'), Pathname)
|
|
23
|
+
@permission_file_paths = T.let([Rails.root.join('config/permissions/**/*.{yml,yaml}')], T::Array[Pathname])
|
|
24
|
+
@permission_file_path = T.let(Rails.root.join('lib/permission.rb'), Pathname)
|
|
25
|
+
@permission_class_name = T.let('Permission', String)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'rails'
|
|
5
|
+
|
|
6
|
+
module Phalanx
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Phalanx
|
|
9
|
+
|
|
10
|
+
config.generators.api_only = true
|
|
11
|
+
|
|
12
|
+
config.generators do |g|
|
|
13
|
+
g.test_framework :rspec
|
|
14
|
+
g.fixture_replacement :factory_bot
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
class Generator
|
|
6
|
+
class DSL
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
SPACES_PER_INDENT = 2
|
|
10
|
+
|
|
11
|
+
sig { void }
|
|
12
|
+
def initialize
|
|
13
|
+
@buffer = T.let([], T::Array[String])
|
|
14
|
+
@indent_level = T.let(0, Integer)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { returns(String) }
|
|
18
|
+
def generate
|
|
19
|
+
@buffer.join
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { params(text: String).returns(DSL) }
|
|
23
|
+
def comment(text)
|
|
24
|
+
text.lines.each do |line|
|
|
25
|
+
@buffer << indented("# #{line.strip}\n")
|
|
26
|
+
end
|
|
27
|
+
self
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { returns(DSL) }
|
|
31
|
+
def newline
|
|
32
|
+
@buffer << "\n"
|
|
33
|
+
self
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
sig do
|
|
37
|
+
params(name: String, superclass: T.nilable(String), block: T.nilable(T.proc.params(dsl: DSL).void))
|
|
38
|
+
.returns(DSL)
|
|
39
|
+
end
|
|
40
|
+
def class(name, superclass: nil, &block)
|
|
41
|
+
@buffer << indented("class #{name}")
|
|
42
|
+
@buffer << " < #{superclass}" if superclass
|
|
43
|
+
@buffer << "\n"
|
|
44
|
+
with_indent(&block)
|
|
45
|
+
@buffer << indented('end')
|
|
46
|
+
@buffer << "\n"
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
sig { params(names: String).returns(DSL) }
|
|
51
|
+
def include(*names)
|
|
52
|
+
@buffer << indented("include #{names.join(', ')}\n")
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
sig { params(block: T.nilable(T.proc.params(dsl: DSL).void)).returns(DSL) }
|
|
57
|
+
def enums(&block)
|
|
58
|
+
@buffer << indented("enums do\n")
|
|
59
|
+
with_indent(&block)
|
|
60
|
+
@buffer << indented("end\n")
|
|
61
|
+
self
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
sig { params(name: String, attributes: T::Hash[String, T.untyped]).returns(DSL) }
|
|
65
|
+
def enum(name, attributes)
|
|
66
|
+
@buffer << indented("#{name} = new(\n")
|
|
67
|
+
with_indent do
|
|
68
|
+
attributes.each do |key, value|
|
|
69
|
+
@buffer << indented("#{key.inspect} => #{value.inspect},\n")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
@buffer << indented(")\n")
|
|
73
|
+
self
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
sig { params(step: Integer, block: T.nilable(T.proc.params(dsl: DSL).void)).returns(DSL) }
|
|
79
|
+
def with_indent(step = 1, &block) # rubocop:disable Metrics/UnusedMethodArgument
|
|
80
|
+
@indent_level += step
|
|
81
|
+
yield self if block_given?
|
|
82
|
+
self
|
|
83
|
+
ensure
|
|
84
|
+
@indent_level -= step
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
sig { params(text: String).returns(String) }
|
|
88
|
+
def indented(text)
|
|
89
|
+
(' ' * (@indent_level * SPACES_PER_INDENT)) + text
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'phalanx/generator/dsl'
|
|
5
|
+
|
|
6
|
+
module Phalanx
|
|
7
|
+
class Generator
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
MAGIC_COMMENT = <<~MAGIC_COMMENT
|
|
11
|
+
typed: strict
|
|
12
|
+
frozen_string_literal: true
|
|
13
|
+
MAGIC_COMMENT
|
|
14
|
+
|
|
15
|
+
HEADER = <<~HEADER
|
|
16
|
+
GENERATED FILE - DO NOT MODIFY
|
|
17
|
+
|
|
18
|
+
This file was generated by Phalanx.
|
|
19
|
+
Use `rails phalanx:generate` to regenerate this file.
|
|
20
|
+
HEADER
|
|
21
|
+
|
|
22
|
+
sig { params(permission_groups: T::Array[Parser::PermissionGroup], output_class_name: String, output_path: Pathname).void }
|
|
23
|
+
def initialize(permission_groups, output_class_name:, output_path:)
|
|
24
|
+
@permission_groups = permission_groups
|
|
25
|
+
@output_class_name = output_class_name
|
|
26
|
+
@output_path = output_path
|
|
27
|
+
@dsl = T.let(DSL.new, DSL)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
sig { returns(T::Boolean) }
|
|
31
|
+
def stale?
|
|
32
|
+
# Early check if file is missing
|
|
33
|
+
return true unless File.exist?(@output_path)
|
|
34
|
+
|
|
35
|
+
file = Tempfile.new(['permission', '.rb'])
|
|
36
|
+
generate_permissions_class
|
|
37
|
+
file.write(@dsl.generate)
|
|
38
|
+
file.flush
|
|
39
|
+
|
|
40
|
+
!FileUtils.identical?(@output_path, T.must(file.path))
|
|
41
|
+
ensure
|
|
42
|
+
file&.close
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
sig { void }
|
|
46
|
+
def generate
|
|
47
|
+
file = File.open(@output_path, 'w')
|
|
48
|
+
generate_permissions_class
|
|
49
|
+
file.write(@dsl.generate)
|
|
50
|
+
ensure
|
|
51
|
+
file&.close
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
sig { void }
|
|
57
|
+
def generate_permissions_class
|
|
58
|
+
@dsl.comment(MAGIC_COMMENT)
|
|
59
|
+
@dsl.newline
|
|
60
|
+
@dsl.comment(HEADER)
|
|
61
|
+
@dsl.class(@output_class_name, superclass: 'T::Enum') do |dsl|
|
|
62
|
+
dsl.include('Phalanx::Permission')
|
|
63
|
+
dsl.enums do
|
|
64
|
+
@permission_groups.each do |permission_group|
|
|
65
|
+
generate_enums_values_for_permission_group(dsl, permission_group)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
sig { params(dsl: DSL, permission_group: Parser::PermissionGroup).returns(DSL) }
|
|
72
|
+
def generate_enums_values_for_permission_group(dsl, permission_group)
|
|
73
|
+
dsl.comment("Permissions for #{permission_group.subject}")
|
|
74
|
+
dsl.newline
|
|
75
|
+
permission_group.permissions.each do |permission|
|
|
76
|
+
dsl.comment(T.must(permission.description).squish) if permission.description.present?
|
|
77
|
+
dsl.enum(
|
|
78
|
+
permission.constant_name, {
|
|
79
|
+
id: permission.id,
|
|
80
|
+
subject: permission_group.subject,
|
|
81
|
+
name: permission.name,
|
|
82
|
+
scope: permission.scope,
|
|
83
|
+
description: permission.description,
|
|
84
|
+
implies: permission.implies,
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
dsl.newline
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
module MigrationExtensions
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
sig { returns([T.any(Symbol, String), T.any(Symbol, String)]) }
|
|
11
|
+
def primary_and_foreign_key_types
|
|
12
|
+
config = Rails.configuration.generators
|
|
13
|
+
setting = config.options[config.orm][:primary_key_type]
|
|
14
|
+
|
|
15
|
+
primary_key_type = setting || :primary_key
|
|
16
|
+
foreign_key_type = setting || :bigint
|
|
17
|
+
|
|
18
|
+
[primary_key_type, foreign_key_type]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
class Parser
|
|
6
|
+
# Validates that permissions do not form a cycle by implication.
|
|
7
|
+
class CycleValidator
|
|
8
|
+
extend T::Sig
|
|
9
|
+
|
|
10
|
+
class CycleError < Phalanx::Error
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { returns(T::Array[T::Array[String]]) }
|
|
14
|
+
attr_reader :cycles
|
|
15
|
+
|
|
16
|
+
sig { params(cycles: T::Array[T::Array[String]]).void }
|
|
17
|
+
def initialize(cycles)
|
|
18
|
+
super(build_message(cycles))
|
|
19
|
+
|
|
20
|
+
@cycles = cycles
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
sig { params(cycles: T::Array[T::Array[String]]).returns(String) }
|
|
26
|
+
def build_message(cycles)
|
|
27
|
+
cycle_descriptions = cycles.map.with_index(1) do |cycle, index|
|
|
28
|
+
" #{index}. #{cycle.join(' -> ')}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
[
|
|
32
|
+
'Permission implication cycles detected:',
|
|
33
|
+
*cycle_descriptions,
|
|
34
|
+
].join("\n")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
sig { params(permission_groups: T::Array[Parser::PermissionGroup]).void }
|
|
39
|
+
def validate(permission_groups)
|
|
40
|
+
# Build a graph: permission_id -> array of implied permission_ids
|
|
41
|
+
graph = T.let({}, T::Hash[String, T::Array[String]])
|
|
42
|
+
|
|
43
|
+
permission_groups.each do |permission_group|
|
|
44
|
+
permission_group.permissions.each do |permission|
|
|
45
|
+
graph[permission.id] = permission.implies
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
cycles = find_all_cycles(graph)
|
|
50
|
+
|
|
51
|
+
raise CycleError, cycles if cycles.any?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
sig { params(graph: T::Hash[String, T::Array[String]]).returns(T::Array[T::Array[String]]) }
|
|
57
|
+
def find_all_cycles(graph)
|
|
58
|
+
# Track global visited state and current path
|
|
59
|
+
visited = T.let(Set.new, T::Set[String])
|
|
60
|
+
path = T.let([], T::Array[String])
|
|
61
|
+
path_set = T.let(Set.new, T::Set[String])
|
|
62
|
+
cycles = T.let([], T::Array[T::Array[String]])
|
|
63
|
+
|
|
64
|
+
graph.each_key do |node|
|
|
65
|
+
next if visited.include?(node)
|
|
66
|
+
|
|
67
|
+
dfs(node, graph, visited, path, path_set, cycles)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Normalize cycles to remove duplicates (same cycle starting from different points)
|
|
71
|
+
normalize_cycles(cycles)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
sig do
|
|
75
|
+
params(
|
|
76
|
+
node: String,
|
|
77
|
+
graph: T::Hash[String, T::Array[String]],
|
|
78
|
+
visited: T::Set[String],
|
|
79
|
+
path: T::Array[String],
|
|
80
|
+
path_set: T::Set[String],
|
|
81
|
+
cycles: T::Array[T::Array[String]]
|
|
82
|
+
).void
|
|
83
|
+
end
|
|
84
|
+
def dfs(node, graph, visited, path, path_set, cycles) # rubocop:disable Metrics/ParameterLists
|
|
85
|
+
visited.add(node)
|
|
86
|
+
path.push(node)
|
|
87
|
+
path_set.add(node)
|
|
88
|
+
|
|
89
|
+
neighbors = graph.fetch(node, [])
|
|
90
|
+
neighbors.each do |neighbor|
|
|
91
|
+
if path_set.include?(neighbor)
|
|
92
|
+
# Found a cycle - extract it from the path
|
|
93
|
+
cycle_start_index = T.must(path.index(neighbor))
|
|
94
|
+
cycle = [*path[cycle_start_index..], neighbor]
|
|
95
|
+
cycles << cycle
|
|
96
|
+
elsif visited.exclude?(neighbor)
|
|
97
|
+
dfs(neighbor, graph, visited, path, path_set, cycles)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
path.pop
|
|
102
|
+
path_set.delete(node)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
sig { params(cycles: T::Array[T::Array[String]]).returns(T::Array[T::Array[String]]) }
|
|
106
|
+
def normalize_cycles(cycles)
|
|
107
|
+
seen = T.let(Set.new, T::Set[String])
|
|
108
|
+
|
|
109
|
+
cycles.select do |cycle|
|
|
110
|
+
# Remove the duplicate last element for normalization
|
|
111
|
+
cycle_nodes = cycle[0...-1] || []
|
|
112
|
+
|
|
113
|
+
# Rotate to start from the smallest element for consistent comparison
|
|
114
|
+
min_index = cycle_nodes.each_with_index.min_by { |node, _| node }&.last || 0
|
|
115
|
+
rotated = cycle_nodes.rotate(min_index)
|
|
116
|
+
normalized = rotated.join(' -> ')
|
|
117
|
+
|
|
118
|
+
if seen.include?(normalized)
|
|
119
|
+
false
|
|
120
|
+
else
|
|
121
|
+
seen.add(normalized)
|
|
122
|
+
true
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
class Parser
|
|
6
|
+
class Permission < T::Struct
|
|
7
|
+
extend T::Sig
|
|
8
|
+
|
|
9
|
+
const :id, String
|
|
10
|
+
const :name, String
|
|
11
|
+
const :scope, T.nilable(String)
|
|
12
|
+
const :description, T.nilable(String)
|
|
13
|
+
const :implies, T::Array[String]
|
|
14
|
+
|
|
15
|
+
sig { returns(String) }
|
|
16
|
+
def constant_name
|
|
17
|
+
id.tr('.', '_').upcase
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json-schema'
|
|
5
|
+
|
|
6
|
+
module Phalanx
|
|
7
|
+
class Parser
|
|
8
|
+
class SchemaValidator
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
sig { params(schema_file_path: T.any(String, Pathname)).void }
|
|
12
|
+
def initialize(schema_file_path)
|
|
13
|
+
@schema_file_path = schema_file_path
|
|
14
|
+
@schema = T.let(JSON.load_file(schema_file_path), T.untyped)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
sig { params(permissions_data: T.untyped).void }
|
|
18
|
+
def validate(permissions_data)
|
|
19
|
+
JSON::Validator.validate!(@schema, permissions_data)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'phalanx/parser/cycle_validator'
|
|
5
|
+
require 'phalanx/parser/permission'
|
|
6
|
+
require 'phalanx/parser/permission_group'
|
|
7
|
+
require 'phalanx/parser/schema_validator'
|
|
8
|
+
|
|
9
|
+
module Phalanx
|
|
10
|
+
class Parser
|
|
11
|
+
extend T::Sig
|
|
12
|
+
|
|
13
|
+
sig { params(schema_file_path: T.any(String, Pathname)).void }
|
|
14
|
+
def initialize(schema_file_path:)
|
|
15
|
+
@schema_validator = T.let(SchemaValidator.new(schema_file_path), SchemaValidator)
|
|
16
|
+
@cycle_validator = T.let(CycleValidator.new, CycleValidator)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
sig { params(file_paths: T::Array[T.any(String, Pathname)]).returns(T::Array[PermissionGroup]) }
|
|
20
|
+
def parse(file_paths)
|
|
21
|
+
permission_groups = file_paths.map do |file_path|
|
|
22
|
+
permission_data = YAML.load_file(file_path)
|
|
23
|
+
@schema_validator.validate(permission_data)
|
|
24
|
+
|
|
25
|
+
build_permission_group(permission_data)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@cycle_validator.validate(permission_groups)
|
|
29
|
+
|
|
30
|
+
permission_groups
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
sig { params(group_data: T::Hash[String, T.untyped]).returns(PermissionGroup) }
|
|
36
|
+
def build_permission_group(group_data)
|
|
37
|
+
PermissionGroup.new(
|
|
38
|
+
subject: group_data['subject'],
|
|
39
|
+
scope: group_data['scope'],
|
|
40
|
+
permissions: group_data['permissions'].map do |permission_id, permission_data|
|
|
41
|
+
Permission.new(
|
|
42
|
+
id: permission_id,
|
|
43
|
+
name: permission_data['name'],
|
|
44
|
+
scope: permission_data['scope'] || group_data['scope'],
|
|
45
|
+
description: permission_data['description'],
|
|
46
|
+
implies: [*permission_data['implies']]
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
module Permission
|
|
6
|
+
extend T::Sig
|
|
7
|
+
extend T::Helpers
|
|
8
|
+
|
|
9
|
+
requires_ancestor { T::Enum }
|
|
10
|
+
|
|
11
|
+
module ClassMethods
|
|
12
|
+
extend T::Sig
|
|
13
|
+
extend T::Helpers
|
|
14
|
+
extend T::Generic
|
|
15
|
+
|
|
16
|
+
has_attached_class!(:out) { { upper: Phalanx::Permission } }
|
|
17
|
+
|
|
18
|
+
# An index of all permissions mapped by their ID.
|
|
19
|
+
sig { returns(T::Hash[String, T.attached_class]) }
|
|
20
|
+
def permissions_by_id
|
|
21
|
+
@permissions_by_id ||= T.let(
|
|
22
|
+
T.unsafe(self).values.index_by(&:id).freeze,
|
|
23
|
+
T.nilable(T::Hash[String, T.attached_class])
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# An index of all permissions mapped by their subject.
|
|
28
|
+
sig { returns(T::Hash[String, T::Array[T.attached_class]]) }
|
|
29
|
+
def permissions_by_subject
|
|
30
|
+
@permissions_by_subject ||= T.let(
|
|
31
|
+
T.unsafe(self).values.group_by(&:subject).freeze,
|
|
32
|
+
T.nilable(T::Hash[String, T::Array[T.attached_class]])
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# An index of all permissions mapped by the IDs of the permissions that imply them.
|
|
37
|
+
# NOTE: This is the inverse of the `implies` property in the permissions configuration file.
|
|
38
|
+
sig { returns(T::Hash[String, T::Array[T.attached_class]]) }
|
|
39
|
+
def implying_permissions_by_id
|
|
40
|
+
@implying_permissions_by_id ||= T.let(
|
|
41
|
+
T.unsafe(self).values
|
|
42
|
+
.flat_map { |permission| permission.implied_permission_ids.map { |id| [id, permission] } }
|
|
43
|
+
.group_by { |id, _| id }
|
|
44
|
+
.transform_values { |permissions| permissions.map(&:last) }
|
|
45
|
+
.freeze,
|
|
46
|
+
T.nilable(T::Hash[String, T::Array[T.attached_class]])
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Finds a permission by its ID.
|
|
51
|
+
# @raise [Phalanx::PermissionNotFound] if the permission is not found.
|
|
52
|
+
sig { params(id: String).returns(T.attached_class) }
|
|
53
|
+
def find(id)
|
|
54
|
+
permissions_by_id[id] or Kernel.raise Phalanx::PermissionNotFound, id
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Finds a permission by its ID.
|
|
58
|
+
# Returns `nil` if the permission is not found.
|
|
59
|
+
sig { params(id: String).returns(T.nilable(T.attached_class)) }
|
|
60
|
+
def [](id) # rubocop:disable Rails/Delegate
|
|
61
|
+
permissions_by_id[id]
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
mixes_in_class_methods ClassMethods
|
|
66
|
+
|
|
67
|
+
# The unique identifier of the permission.
|
|
68
|
+
sig { returns(String) }
|
|
69
|
+
def id
|
|
70
|
+
serialize[:id]
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# The subject that the permission belongs to.
|
|
74
|
+
# e.g. A model name ("User", "Post", "Comment") or a application area ("Dashboard", "Admin", "API")
|
|
75
|
+
sig { returns(String) }
|
|
76
|
+
def subject
|
|
77
|
+
serialize[:subject]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The scope that the permission is part of.
|
|
81
|
+
# Useful when working with multiple distinct sets of permissions (e.g. Global vs. Subject-specific).
|
|
82
|
+
sig { returns(T.nilable(String)) }
|
|
83
|
+
def scope
|
|
84
|
+
serialize[:scope]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A human-readable name of the permission.
|
|
88
|
+
sig { returns(String) }
|
|
89
|
+
def name
|
|
90
|
+
serialize[:name]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A human-readable description of the permission.
|
|
94
|
+
sig { returns(T.nilable(String)) }
|
|
95
|
+
def description
|
|
96
|
+
serialize[:description]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The list of permission IDs that are implied by having this permission.
|
|
100
|
+
sig { returns(T::Array[String]) }
|
|
101
|
+
def implied_permission_ids
|
|
102
|
+
serialize[:implies]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The list of permissions that are implied by having this permission.
|
|
106
|
+
sig { returns(T::Array[Phalanx::Permission]) }
|
|
107
|
+
def implied_permissions
|
|
108
|
+
implied_permission_ids.map { |id| T.cast(self.class, ClassMethods[Permission]).find(id) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The list of permission IDs that imply this permission.
|
|
112
|
+
# This is the inverse of #implied_permission_ids.
|
|
113
|
+
sig { returns(T::Array[String]) }
|
|
114
|
+
def implying_permission_ids
|
|
115
|
+
implying_permissions.map(&:id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The list of permissions that imply this permission.
|
|
119
|
+
# This is the inverse of #implied_permissions.
|
|
120
|
+
sig { returns(T::Array[Phalanx::Permission]) }
|
|
121
|
+
def implying_permissions
|
|
122
|
+
T.cast(self.class, ClassMethods[Permission]).implying_permissions_by_id.fetch(id) { [] }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The recursive list of permissions that are implied by having this permission, including this permission.
|
|
126
|
+
# If no permissions are implied by this permission, returns a list containing only itself.
|
|
127
|
+
sig { returns(T::Array[Phalanx::Permission]) }
|
|
128
|
+
def with_implied_permissions
|
|
129
|
+
[self, *implied_permissions.flat_map(&:with_implied_permissions)].uniq
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Phalanx
|
|
5
|
+
class PermissionNotFound < Phalanx::Error
|
|
6
|
+
extend T::Sig
|
|
7
|
+
|
|
8
|
+
sig { returns(String) }
|
|
9
|
+
attr_reader :id
|
|
10
|
+
|
|
11
|
+
sig { params(id: String).void }
|
|
12
|
+
def initialize(id)
|
|
13
|
+
super("Permission with id #{id.inspect} not found")
|
|
14
|
+
|
|
15
|
+
@id = id
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/phalanx.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sorbet-runtime'
|
|
5
|
+
|
|
6
|
+
require 'phalanx/version'
|
|
7
|
+
require 'phalanx/engine'
|
|
8
|
+
|
|
9
|
+
require 'phalanx/config'
|
|
10
|
+
require 'phalanx/error'
|
|
11
|
+
require 'phalanx/generator'
|
|
12
|
+
require 'phalanx/permission_not_found'
|
|
13
|
+
require 'phalanx/parser'
|
|
14
|
+
require 'phalanx/permission'
|
|
15
|
+
|
|
16
|
+
module Phalanx
|
|
17
|
+
extend T::Sig
|
|
18
|
+
|
|
19
|
+
autoload :MigrationExtensions, 'phalanx/migration_extensions'
|
|
20
|
+
|
|
21
|
+
PermissionClass = T.type_alias do
|
|
22
|
+
T.all(
|
|
23
|
+
T.class_of(T::Enum),
|
|
24
|
+
T::Class[Phalanx::Permission],
|
|
25
|
+
Phalanx::Permission::ClassMethods[Phalanx::Permission]
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
sig { returns(Phalanx::Config) }
|
|
30
|
+
def self.config
|
|
31
|
+
@config ||= T.let(Phalanx::Config.new.freeze, T.nilable(Phalanx::Config))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
sig { params(block: T.proc.params(config: Phalanx::Config).void).void }
|
|
35
|
+
def self.configure(&block)
|
|
36
|
+
config = self.config.dup
|
|
37
|
+
block.call(config) # rubocop:disable Performance/RedundantBlockCall
|
|
38
|
+
@config = config.freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
sig { returns(T::Array[Pathname]) }
|
|
42
|
+
def self.permission_file_paths
|
|
43
|
+
config.permission_file_paths.flat_map { |path| Pathname.glob(path) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
sig { returns(PermissionClass) }
|
|
47
|
+
def self.permission_class
|
|
48
|
+
config.permission_class_name.safe_constantize or
|
|
49
|
+
raise NotImplementedError, "Permission class #{config.permission_class_name.inspect} not found; " \
|
|
50
|
+
'have you run `rails phalanx:generate`?'
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
sig { void }
|
|
54
|
+
def self.generate_permission_class
|
|
55
|
+
output_class_name = config.permission_class_name
|
|
56
|
+
output_path = config.permission_file_path
|
|
57
|
+
|
|
58
|
+
permission_groups = Parser.new(schema_file_path: config.schema_file_path).parse(permission_file_paths)
|
|
59
|
+
Generator.new(permission_groups, output_class_name:, output_path:).generate
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
sig { returns(T::Boolean) }
|
|
63
|
+
def self.permission_class_stale?
|
|
64
|
+
output_class_name = config.permission_class_name
|
|
65
|
+
output_path = config.permission_file_path
|
|
66
|
+
|
|
67
|
+
permission_groups = Parser.new(schema_file_path: config.schema_file_path).parse(permission_file_paths)
|
|
68
|
+
Generator.new(permission_groups, output_class_name:, output_path:).stale?
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
namespace :phalanx do
|
|
5
|
+
desc 'Generate the permission class'
|
|
6
|
+
task generate: :environment do
|
|
7
|
+
Phalanx.generate_permission_class
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
desc 'Check if the permission class is stale'
|
|
11
|
+
task stale: :environment do
|
|
12
|
+
if Phalanx.permission_class_stale?
|
|
13
|
+
puts 'Permission class is stale'
|
|
14
|
+
exit 1
|
|
15
|
+
else
|
|
16
|
+
exit 0
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: phalanx
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.2.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Minty Fresh
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: json-schema
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: rails
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - ">="
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: 8.1.1
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - ">="
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: 8.1.1
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: sorbet
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - ">="
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
description: Fine-grained permissions for Rails + Sorbet.
|
|
55
|
+
email:
|
|
56
|
+
- 7896757+mintyfresh@users.noreply.github.com
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- MIT-LICENSE
|
|
62
|
+
- README.md
|
|
63
|
+
- Rakefile
|
|
64
|
+
- app/controllers/phalanx/application_controller.rb
|
|
65
|
+
- app/jobs/phalanx/application_job.rb
|
|
66
|
+
- app/models/concerns/phalanx/permission_assignable.rb
|
|
67
|
+
- app/models/phalanx/application_record.rb
|
|
68
|
+
- app/models/phalanx/permission_assignment.rb
|
|
69
|
+
- config/routes.rb
|
|
70
|
+
- db/migrate/20260216223657_create_phalanx_permission_assignments.rb
|
|
71
|
+
- lib/generators/phalanx/install/USAGE
|
|
72
|
+
- lib/generators/phalanx/install/install_generator.rb
|
|
73
|
+
- lib/generators/phalanx/install/templates/initializer.rb
|
|
74
|
+
- lib/phalanx.rb
|
|
75
|
+
- lib/phalanx/config.rb
|
|
76
|
+
- lib/phalanx/engine.rb
|
|
77
|
+
- lib/phalanx/error.rb
|
|
78
|
+
- lib/phalanx/generator.rb
|
|
79
|
+
- lib/phalanx/generator/dsl.rb
|
|
80
|
+
- lib/phalanx/migration_extensions.rb
|
|
81
|
+
- lib/phalanx/parser.rb
|
|
82
|
+
- lib/phalanx/parser/cycle_validator.rb
|
|
83
|
+
- lib/phalanx/parser/permission.rb
|
|
84
|
+
- lib/phalanx/parser/permission_group.rb
|
|
85
|
+
- lib/phalanx/parser/schema_validator.rb
|
|
86
|
+
- lib/phalanx/permission.rb
|
|
87
|
+
- lib/phalanx/permission_not_found.rb
|
|
88
|
+
- lib/phalanx/version.rb
|
|
89
|
+
- lib/tasks/phalanx_tasks.rake
|
|
90
|
+
homepage: https://github.com/mintyfresh/phalanx
|
|
91
|
+
licenses:
|
|
92
|
+
- MIT
|
|
93
|
+
metadata:
|
|
94
|
+
allowed_push_host: https://rubygems.org
|
|
95
|
+
homepage_uri: https://github.com/mintyfresh/phalanx
|
|
96
|
+
source_code_uri: https://github.com/mintyfresh/phalanx
|
|
97
|
+
rubygems_mfa_required: 'true'
|
|
98
|
+
rdoc_options: []
|
|
99
|
+
require_paths:
|
|
100
|
+
- lib
|
|
101
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
102
|
+
requirements:
|
|
103
|
+
- - ">="
|
|
104
|
+
- !ruby/object:Gem::Version
|
|
105
|
+
version: '3.4'
|
|
106
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0'
|
|
111
|
+
requirements: []
|
|
112
|
+
rubygems_version: 4.0.8
|
|
113
|
+
specification_version: 4
|
|
114
|
+
summary: Fine-grained permissions for Rails + Sorbet.
|
|
115
|
+
test_files: []
|