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 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,9 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'bundler/setup'
5
+
6
+ APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
7
+ load 'rails/tasks/engine.rake'
8
+
9
+ require 'bundler/gem_tasks'
@@ -0,0 +1,7 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Phalanx
5
+ class ApplicationController < ActionController::API
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Phalanx
5
+ class ApplicationJob < ActiveJob::Base
6
+ end
7
+ end
@@ -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,8 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Phalanx
5
+ class ApplicationRecord < ActiveRecord::Base
6
+ self.abstract_class = true
7
+ end
8
+ 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,6 @@
1
+ # typed: false
2
+ # frozen_string_literal: true
3
+
4
+ Phalanx::Engine.routes.draw do
5
+ # No routes yet
6
+ end
@@ -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,8 @@
1
+ Description:
2
+ Installs Phalanx, creating a default initializer file.
3
+
4
+ Example:
5
+ bin/rails generate phalanx:install
6
+
7
+ This will create:
8
+ config/initializers/phalanx.rb
@@ -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,7 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Phalanx
5
+ class Error < StandardError
6
+ end
7
+ 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,12 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Phalanx
5
+ class Parser
6
+ class PermissionGroup < T::Struct
7
+ const :subject, String
8
+ const :scope, T.nilable(String)
9
+ const :permissions, T::Array[Parser::Permission]
10
+ end
11
+ end
12
+ 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
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Phalanx
5
+ VERSION = '0.2.0'
6
+ 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: []