nexus_cqrs 0.3.0 → 0.4.3

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c4e6e4a145007fe8658503e9745e3be716436749d9bea2c714bca1d47b1ea4ab
4
- data.tar.gz: aedbcb7c36185faf7d546e3ea5c268cba6642d0880a0a2c2cdf612e761f5096c
3
+ metadata.gz: 2ad110fb96498636aa174c3d45aa7783177fb7e5a2475e828f6e694adf392b46
4
+ data.tar.gz: 35fd22cd65fd520c91c93629b365f4573fbc0787158212b12dd3fb1241e0da43
5
5
  SHA512:
6
- metadata.gz: 83c240bd9f056d0d82e5437f52c45527be6092e83f6356c765bfa127153d8d5c5c660437598707fb470879606ede7c9fc1a28f11e42355401b177c29b37e7c45
7
- data.tar.gz: 0dab29f1d52e3f4110cfb948ffbe425c342f682c01f1945ceced2385aa022201e84c7025b1cc7eb633110029efdf4e7b5e0366baf2b7466183a164a040df5ff3
6
+ metadata.gz: faee92b66ddcd2cdcd70fb315f444c2f226ff194ff528eb5c4e3f0a654c5e274e7715df987883b7a0512cbf0245a84824dd24829aa879277424c675435ea5169
7
+ data.tar.gz: 172b58d34c2da223e41e933fd0df3b379450d0d9ec0ce89095e6581ff894006224614cb62455d7d5223a376988413f3e6d7a98e28d1d502ad58b349c3a075fa8
data/.gitignore CHANGED
@@ -9,3 +9,8 @@
9
9
  /.gem
10
10
  /spec/lib/tmp
11
11
  *.gem
12
+
13
+ results/rubocop.html
14
+
15
+ # Ignore IDE configuration
16
+ nexus_cqrs.iml
data/.rubocop.yml CHANGED
@@ -3,9 +3,14 @@ inherit_gem:
3
3
 
4
4
  AllCops:
5
5
  Exclude:
6
- - 'lib/generators/nexus_cqrs/templates/**/*.rb'
6
+ - db/schema.rb
7
+ - db/seeds.rb
8
+ - db/migrate/*.rb
9
+ - config/initializers/devise.rb
10
+ - spec/**/*
11
+
12
+ Style/Documentation:
13
+ Enabled: false
7
14
 
8
15
  Style/GlobalVars:
9
16
  Enabled: false
10
- Style/FrozenStringLiteralComment:
11
- Enabled: false
data/Gemfile CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  source 'https://rubygems.org'
2
3
 
3
4
  # Specify your gem's dependencies in cqrs-core.gemspec
@@ -10,3 +11,4 @@ gem 'rspec'
10
11
  gem "generator_spec"
11
12
  gem 'thread_safe'
12
13
  gem 'ibsciss-middleware'
14
+ gem 'request_store'
data/Gemfile.lock CHANGED
@@ -1,107 +1,117 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- nexus_cqrs (0.3.0)
4
+ nexus_cqrs (0.1.0)
5
5
  generator_spec
6
6
  ibsciss-middleware
7
+ pundit
8
+ request_store
9
+ strings-case
7
10
  thread_safe
8
11
 
9
12
  GEM
10
13
  remote: https://rubygems.org/
11
14
  specs:
12
- actionpack (6.0.3.2)
13
- actionview (= 6.0.3.2)
14
- activesupport (= 6.0.3.2)
15
- rack (~> 2.0, >= 2.0.8)
15
+ actionpack (6.1.4.1)
16
+ actionview (= 6.1.4.1)
17
+ activesupport (= 6.1.4.1)
18
+ rack (~> 2.0, >= 2.0.9)
16
19
  rack-test (>= 0.6.3)
17
20
  rails-dom-testing (~> 2.0)
18
21
  rails-html-sanitizer (~> 1.0, >= 1.2.0)
19
- actionview (6.0.3.2)
20
- activesupport (= 6.0.3.2)
22
+ actionview (6.1.4.1)
23
+ activesupport (= 6.1.4.1)
21
24
  builder (~> 3.1)
22
25
  erubi (~> 1.4)
23
26
  rails-dom-testing (~> 2.0)
24
27
  rails-html-sanitizer (~> 1.1, >= 1.2.0)
25
- activesupport (6.0.3.2)
28
+ activesupport (6.1.4.1)
26
29
  concurrent-ruby (~> 1.0, >= 1.0.2)
27
- i18n (>= 0.7, < 2)
28
- minitest (~> 5.1)
29
- tzinfo (~> 1.1)
30
- zeitwerk (~> 2.2, >= 2.2.2)
31
- ast (2.4.1)
30
+ i18n (>= 1.6, < 2)
31
+ minitest (>= 5.1)
32
+ tzinfo (~> 2.0)
33
+ zeitwerk (~> 2.3)
34
+ ast (2.4.2)
32
35
  builder (3.2.4)
33
- concurrent-ruby (1.1.7)
36
+ concurrent-ruby (1.1.9)
34
37
  crass (1.0.6)
35
38
  diff-lcs (1.4.4)
36
- erubi (1.9.0)
39
+ erubi (1.10.0)
37
40
  generator_spec (0.9.4)
38
41
  activesupport (>= 3.0.0)
39
42
  railties (>= 3.0.0)
40
- i18n (1.8.5)
43
+ i18n (1.8.11)
41
44
  concurrent-ruby (~> 1.0)
42
45
  ibsciss-middleware (0.4.2)
43
- loofah (2.6.0)
46
+ loofah (2.12.0)
44
47
  crass (~> 1.0.2)
45
48
  nokogiri (>= 1.5.9)
46
49
  method_source (1.0.0)
47
- mini_portile2 (2.4.0)
48
- minitest (5.14.1)
49
- nokogiri (1.10.10)
50
- mini_portile2 (~> 2.4.0)
51
- parallel (1.19.2)
52
- parser (2.7.1.4)
50
+ mini_portile2 (2.6.1)
51
+ minitest (5.14.4)
52
+ nokogiri (1.12.5)
53
+ mini_portile2 (~> 2.6.1)
54
+ racc (~> 1.4)
55
+ parallel (1.21.0)
56
+ parser (3.0.2.0)
53
57
  ast (~> 2.4.1)
58
+ pundit (2.1.1)
59
+ activesupport (>= 3.0.0)
60
+ racc (1.6.0)
54
61
  rack (2.2.3)
55
62
  rack-test (1.1.0)
56
63
  rack (>= 1.0, < 3)
57
64
  rails-dom-testing (2.0.3)
58
65
  activesupport (>= 4.2.0)
59
66
  nokogiri (>= 1.6)
60
- rails-html-sanitizer (1.3.0)
67
+ rails-html-sanitizer (1.4.2)
61
68
  loofah (~> 2.3)
62
- railties (6.0.3.2)
63
- actionpack (= 6.0.3.2)
64
- activesupport (= 6.0.3.2)
69
+ railties (6.1.4.1)
70
+ actionpack (= 6.1.4.1)
71
+ activesupport (= 6.1.4.1)
65
72
  method_source
66
- rake (>= 0.8.7)
67
- thor (>= 0.20.3, < 2.0)
73
+ rake (>= 0.13)
74
+ thor (~> 1.0)
68
75
  rainbow (3.0.0)
69
- rake (13.0.1)
70
- regexp_parser (1.7.1)
71
- rexml (3.2.4)
72
- rspec (3.9.0)
73
- rspec-core (~> 3.9.0)
74
- rspec-expectations (~> 3.9.0)
75
- rspec-mocks (~> 3.9.0)
76
- rspec-core (3.9.2)
77
- rspec-support (~> 3.9.3)
78
- rspec-expectations (3.9.2)
76
+ rake (13.0.6)
77
+ regexp_parser (2.1.1)
78
+ request_store (1.5.0)
79
+ rack (>= 1.4)
80
+ rexml (3.2.5)
81
+ rspec (3.10.0)
82
+ rspec-core (~> 3.10.0)
83
+ rspec-expectations (~> 3.10.0)
84
+ rspec-mocks (~> 3.10.0)
85
+ rspec-core (3.10.1)
86
+ rspec-support (~> 3.10.0)
87
+ rspec-expectations (3.10.1)
79
88
  diff-lcs (>= 1.2.0, < 2.0)
80
- rspec-support (~> 3.9.0)
81
- rspec-mocks (3.9.1)
89
+ rspec-support (~> 3.10.0)
90
+ rspec-mocks (3.10.2)
82
91
  diff-lcs (>= 1.2.0, < 2.0)
83
- rspec-support (~> 3.9.0)
84
- rspec-support (3.9.3)
85
- rubocop (0.86.0)
92
+ rspec-support (~> 3.10.0)
93
+ rspec-support (3.10.3)
94
+ rubocop (1.22.3)
86
95
  parallel (~> 1.10)
87
- parser (>= 2.7.0.1)
96
+ parser (>= 3.0.0.0)
88
97
  rainbow (>= 2.2.2, < 4.0)
89
- regexp_parser (>= 1.7)
98
+ regexp_parser (>= 1.8, < 3.0)
90
99
  rexml
91
- rubocop-ast (>= 0.0.3, < 1.0)
100
+ rubocop-ast (>= 1.12.0, < 2.0)
92
101
  ruby-progressbar (~> 1.7)
93
- unicode-display_width (>= 1.4.0, < 2.0)
94
- rubocop-ast (0.3.0)
95
- parser (>= 2.7.1.4)
96
- rubocop-shopify (1.0.4)
97
- rubocop (>= 0.85, < 0.87)
98
- ruby-progressbar (1.10.1)
99
- thor (1.0.1)
102
+ unicode-display_width (>= 1.4.0, < 3.0)
103
+ rubocop-ast (1.13.0)
104
+ parser (>= 3.0.1.1)
105
+ rubocop-shopify (1.0.7)
106
+ rubocop (~> 1.4)
107
+ ruby-progressbar (1.11.0)
108
+ strings-case (0.3.0)
109
+ thor (1.1.0)
100
110
  thread_safe (0.3.6)
101
- tzinfo (1.2.7)
102
- thread_safe (~> 0.1)
103
- unicode-display_width (1.7.0)
104
- zeitwerk (2.4.0)
111
+ tzinfo (2.0.4)
112
+ concurrent-ruby (~> 1.0)
113
+ unicode-display_width (2.1.0)
114
+ zeitwerk (2.5.1)
105
115
 
106
116
  PLATFORMS
107
117
  ruby
@@ -110,6 +120,7 @@ DEPENDENCIES
110
120
  generator_spec
111
121
  ibsciss-middleware
112
122
  nexus_cqrs!
123
+ request_store
113
124
  rspec
114
125
  rubocop
115
126
  rubocop-shopify (~> 1.0.4)
data/README.md CHANGED
@@ -1,8 +1,11 @@
1
- # Cqrs::Core
1
+ # Nexus CQRS
2
2
 
3
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/cqrs/core`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Built by the NexusMods team for providing a universal way of organising CQRS logic (Command and Queries) and permission
4
+ management. Used by most of NexusMod's rails applications.
4
5
 
5
- TODO: Delete this and the text above, and describe your gem
6
+ The core concept of this gem is to provide a MessageBus that can invoked handlers registered to certain commands.
7
+ This allows developers to separate business and application logic, whilst also seperating read operations from write
8
+ operations (Queries and Commands).
6
9
 
7
10
  ## Installation
8
11
 
@@ -20,39 +23,119 @@ Or install it yourself as:
20
23
 
21
24
  $ gem install nexus_cqrs
22
25
 
23
- ## Usage
26
+ ## Getting Started
24
27
 
25
- Generators can be used to aide in the creation of Commands and Queries:
28
+ ### Generators
29
+
30
+ Generators can be used to aid in the creation of Commands and Queries:
26
31
 
27
32
  rails g nexus_cqrs:command CommandName
28
33
  rails g nexus_cqrs:query QueryName
29
34
 
30
- Once installed, a CommandBus is required to control the flow of Commands and/or Queries:
35
+ Optionally, a command can be scaffolded with basic authorisation logic with `--permission`
36
+
37
+ rails g nexus_cqrs:command CommandName --permission
38
+ rails g nexus_cqrs:query QueryName --permission
39
+
40
+ Once a command has been created, two new files will be created under `/app/domain/command` or `/app/domain/query`
41
+ depending on which generator was used. An initializer will also be created - `register_cqrs_handlers.rb`
42
+
43
+ ### Command Bus and Executor
44
+
45
+ In `register_cqrs_handlers.rb`, the command bus and executor will be created and used to register queries and commands:
46
+
47
+ ```ruby
48
+ middleware_stack = Middleware::Builder.new do |b|
49
+ # Configure additional middleware for the CQRS stack here
50
+ end
51
+
52
+ command_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
53
+ query_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
54
+
55
+ $COMMAND_EXECUTOR = NexusCqrs::CommandExecutor.new(command_bus)
56
+ $QUERY_EXECUTOR = NexusCqrs::CommandExecutor.new(query_bus)
57
+ ```
58
+
59
+ *NOTE: By default, all classes extending `BaseCommand` and `BaseQuery` in the `app/domain` directory will be
60
+ registered automatically.*
31
61
 
32
62
  ### Middleware
33
63
 
34
- Middleware can be created by extending the base middleware:
64
+ Middleware can be created by extending the base middleware and injecting into the middleware stack:
35
65
 
36
66
  ```ruby
67
+ # my_middleware.rb
37
68
  class MyMiddleware < NexusCqrs::BaseMiddleware
38
69
  def call(command)
39
70
  @next.call(command)
40
71
  end
41
72
  end
73
+
74
+ # register_cqrs_handlers.rb
75
+ middleware_stack = Middleware::Builder.new do |b|
76
+ b.use MyMiddleware
77
+ end
78
+
79
+ command_bus = Bus.new(middleware: middleware_stack)
42
80
  ```
43
81
 
44
- The above middleware will pass responsibility for execution to the next responder in the chain.
82
+ The above middleware will pass responsibility for execution to the next responder in the chain and will be ran BEFORE
83
+ the handler is invoked for every message executed through the `CommandExecutor`
45
84
 
46
85
  For more information on writing middleware see: https://github.com/Ibsciss/ruby-middleware
47
86
 
48
- When creating a bus, middleware can be attached like so:
87
+ ### Metadata
88
+
89
+ Commands/Queries can contain data, but data can also be injected into the message via metadata before the message is
90
+ executed:
49
91
 
50
92
  ```ruby
51
- middleware_stack = Middleware::Builder.new do |b|
52
- b.use MyMiddleware
53
- end
93
+ command.set_metadata(:current_user, user)
94
+ execute(command)
95
+ ```
54
96
 
55
- command_bus = Bus.new(middleware: middleware_stack)
97
+ ### Authorisation
98
+
99
+ There are various tools and helpers to aid with authorisation in this gem. Firstly, the system must be aware of the
100
+ user that is calling the command, this can be done by providing the current user and global permissions as metadata:
101
+
102
+ ```ruby
103
+ message.set_metadata(:current_user, current_user)
104
+ message.set_metadata(:global_permissions, @access_token[:global_permissions])
105
+ execute(message)
106
+ ```
107
+ Once the metadata is set, the handler can than access the metadata - which in turn can invoke the `authorize` method:
108
+
109
+ ```ruby
110
+ class ModerateHandler < BaseCommandHandler
111
+ include NexusCqrs::Helpers
112
+
113
+ # @param [Commands::Moderate] command
114
+ def call(command)
115
+ mod = Mod.kept.find(command.mod_id)
116
+
117
+ authorize(command, mod)
118
+ ...
119
+ ```
120
+
121
+ This will look up the correct Policy and automatically pass the metadata from the command, converting it into a
122
+ `UserContext` object:
123
+
124
+ ```ruby
125
+ # Pull context variables from command
126
+ user = message.metadata[:current_user]
127
+ global_permissions = message.metadata[:global_permissions]
128
+
129
+ # Instantiate new policy class, with context
130
+ policy = policy_class.new(UserContext.new(user, global_permissions), record)
131
+ ```
132
+
133
+ This will allow the policy class to access the `PermissionProvider` and retrieve any permission:
134
+
135
+ ```ruby
136
+ def moderate?
137
+ permissions.has_permission?('mod:moderate', ModPermission, record.id)
138
+ end
56
139
  ```
57
140
 
58
141
  ## Development
@@ -66,4 +149,4 @@ To contribute to this gem, simple pull the repository, run `bundle install` and
66
149
 
67
150
  ## Releasing
68
151
 
69
- The release process is tied to the git tags. Simply creating a new tag and pushing will trigger a new release to reubygems.
152
+ The release process is tied to the git tags. Simply creating a new tag and pushing will trigger a new release to rubygems.
data/Rakefile CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require "bundler/gem_tasks"
2
3
  require 'bundler/gem_tasks'
3
4
  task(default: :spec)
data/bin/console CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'bundler/setup'
4
5
  require 'cqrs/core'
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  Rails.configuration.to_prepare do
3
-
4
3
  middleware_stack = Middleware::Builder.new do |b|
5
- b.use(NexusCqrsAuth::AuthMiddleware)
6
4
  end
7
5
 
8
6
  command_bus = NexusCqrs::CommandBus.new(middleware: middleware_stack)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ require 'pundit'
3
+ require 'strings-case'
4
+
5
+ module NexusCqrs
6
+ # Concern used to provide authorisation abilities to handlers and other classes. Overrides pundit's `authorize` method
7
+ # and creates helpers for the permission_provider
8
+ module Auth
9
+ include Pundit
10
+
11
+ # Overrides pundit's `authorize` method, allowing the message to be passed
12
+ #
13
+ # @see Pundit#authorize
14
+ # @param [NexusCqrs::BaseMessage] message Message to authorise against
15
+ def authorize(message, record, query = nil, policy_class: nil)
16
+ # Populate the query from the command, or the params if it's being overriden
17
+ query ||= Strings::Case.snakecase(message.demodularised_class_name) + '?'
18
+
19
+ # Retreive the policy class object from the type of record we are passing in
20
+ policy_class ||= PolicyFinder.new(record).policy
21
+
22
+ # Pull context variables from command
23
+ user = message.metadata[:current_user]
24
+ global_permissions = message.metadata[:global_permissions]
25
+
26
+ # Instantiate new policy class, with context
27
+ policy = policy_class.new(UserContext.new(user, global_permissions), record)
28
+ raise NotAuthorizedError, query: query, record: record, policy: policy unless policy.public_send(query)
29
+
30
+ record.is_a?(Array) ? record.last : record
31
+ end
32
+
33
+ # Helper method for creating a permissions provider object from a query object. This allows certain permissions
34
+ # to be checked inside the command handler, as opposed to inside the policy
35
+ #
36
+ # @param [NexusCqrs::BaseMessage] message Create the PermissionProvider using the message metadata
37
+ # @return [PermissionProvider] A new instance of the permission provider
38
+ def permission_provider(message)
39
+ PermissionProvider.new(message.metadata[:current_user], message.metadata[:global_permissions])
40
+ end
41
+
42
+ def pundit_user
43
+ nil
44
+ end
45
+
46
+ def current_user
47
+ return super if defined?(super)
48
+
49
+ nil
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ module NexusCqrs
3
+ module Auth
4
+ # Concern used to integrate models to the permissions system. Including this module to a model will assume the
5
+ # model can be "owned" by a user. When the model is created, permissions will automatically be assigned to the user
6
+ # and permissions can be validated and "repaired" retroactively.
7
+ module Ownable
8
+ extend ActiveSupport::Concern
9
+
10
+ included do
11
+ class_attribute :granted_permissions, default: {}
12
+
13
+ # Default relationship to <class>_permissions
14
+ class_attribute :permissions_relation, default: top_ancestor_class.name.downcase + "_permissions"
15
+ class_attribute :owner_column, default: :user_id
16
+
17
+ before_create :assign_permissions
18
+
19
+ define_model_callbacks :create_permissions
20
+ end
21
+
22
+ module ClassMethods
23
+ # Gets the class that this module is included in
24
+ def top_ancestor_class
25
+ ancestors.first
26
+ end
27
+ end
28
+
29
+ def assign_permissions
30
+ # As we are doing this on before_create, we must "build" this association, as opposed to creating it. This
31
+ # ensures validation is passed before saving this parent Collection.
32
+ granted_permissions.each do |p|
33
+ unless permissions.where(permission: p, entity_id: id, user_id: owner_id).exists?
34
+ permissions.build(user_id: owner_id, permission: p)
35
+ end
36
+ end
37
+ end
38
+
39
+ def owner_id
40
+ send(owner_column)
41
+ end
42
+
43
+ def permissions
44
+ if respond_to?(permissions_relation)
45
+ return send(permissions_relation)
46
+ end
47
+
48
+ raise OwnableRelationshipNotSet, "Permissions relation not set.
49
+ Set it on your model with `self.permissions_relation = :xxx_permissions`, and ensure relationship has been created"
50
+ end
51
+ end
52
+
53
+ class OwnableRelationshipNotSet < StandardError
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+ module NexusCqrs
3
+ module Auth
4
+ class PermissionProvider
5
+ def initialize(user_id, global_permissions)
6
+ @user_id = user_id
7
+ @global_permissions = parse_permissions_array(global_permissions)
8
+ end
9
+
10
+ # Returns true if the current user has the requested permission on the requested entity (if passed), or globally
11
+ #
12
+ # @param [String] permission_key Permission key to check against
13
+ # @param [ApplicationRecord] permission_model Permission model
14
+ # @param [Integer] entity_id ID of the entity
15
+ # @return [Boolean] Returns true if the current user has this permission on this entity
16
+ # @example Check for permission
17
+ # permissions.has_permission?('collection:publish', CollectionPermissions, collection.id) #=> true
18
+ def has_permission?(permission_key, permission_model = nil, entity_id = nil)
19
+ return false if @user_id.nil?
20
+
21
+ return true if @global_permissions.include?(permission_key)
22
+
23
+ # check entity-specific permissions
24
+ unless permission_model.nil?
25
+
26
+ # get all permissions for this entity. NOTE: This will be cached per-request.
27
+ permissions = cached_permissions(permission_model)
28
+
29
+ # if there are no permissions for this entity and user, return false
30
+ return false if permissions[entity_id].nil?
31
+
32
+ # if the permission key is in the user's permissions for this entity, return true
33
+ return true if permissions[entity_id].include?(permission_key)
34
+ end
35
+
36
+ false
37
+ end
38
+
39
+ # Retrieves a list of permissions assigned to a user for a specific entity
40
+ #
41
+ # @param [ApplicationRecord] permission_model Permission model
42
+ # @param [Integer] entity_id ID of the entity
43
+ # @return [Array] Returns an array of hashes representing permission keys, along with their global status
44
+ # @example Get a list of permissions
45
+ # permissions.for_user_on_entity(CollectionPermissions, collection.id) #=>
46
+ # [
47
+ # {:global=>false, :key=>"collection:discard"},
48
+ # {:global=>false, :key=>"collection:publish"},
49
+ # {:global=>false, :key=>"collection:view_under_moderation"},
50
+ # {:global=>false, :key=>"collection:set_status"}
51
+ # ]
52
+ def for_user_on_entity(permission_model, entity_id)
53
+ return [] if @user_id.nil?
54
+
55
+ # retrieve entity-specific permissions from DB and map to hash
56
+ entity_permissions = permission_model.where(user_id: @user_id, entity_id: entity_id)
57
+ .map { |p| { global: false, key: p.permission } }
58
+
59
+ # Map global permissions to hash
60
+ global_permissions = @global_permissions.map { |p| { global: true, key: p } }
61
+
62
+ # Combine hashes and ensure global permissions take priority
63
+ (global_permissions + entity_permissions).uniq { |p| p[:key] }
64
+ end
65
+
66
+ # Retrieves a list of permissions assigned to a user for ANY entity ID
67
+ #
68
+ # @param [ApplicationRecord] permission_model Permission model
69
+ # @return [Hash] Returns a hash representing each entity and the user's permissions
70
+ # @example Get a list of permissions
71
+ # permissions.for_user(CollectionPermissions) #=>
72
+ # {
73
+ # 1 => ["collection:discard"],
74
+ # 2 => ["collection:discard"],
75
+ # 3 => ["collection:discard", "collection:edit"],
76
+ # 4 => ["collection:discard"],
77
+ # }
78
+ def for_user(permission_model)
79
+ return {} if @user_id.nil?
80
+
81
+ permissions = {}
82
+
83
+ # retrieve entity-specific permissions from DB and map to hash
84
+ permission_model.where(user_id: @user_id).each do |p|
85
+ if permissions[p.entity_id].nil?
86
+ permissions[p.entity_id] = [p.permission]
87
+ else
88
+ permissions[p.entity_id] << p.permission
89
+ end
90
+ end
91
+
92
+ permissions
93
+ end
94
+
95
+ private
96
+
97
+ def parse_permissions_array(permissions_array)
98
+ return [] if permissions_array.nil?
99
+
100
+ permissions = []
101
+
102
+ permissions_array.each do |entity, action_array|
103
+ action_array.each do |action|
104
+ permissions << entity + ":" + action
105
+ end
106
+ end
107
+
108
+ permissions
109
+ end
110
+
111
+ # Retrieve all the permissions for this user for a specific model and cache it for this request (as all calls to
112
+ # a PermissionProvider in the same request will all have the same permissions, this saves on many SQL calls)
113
+ #
114
+ # @param [ApplicationRecord] permission_model Permission model
115
+ # @see PermissionProvider#for_user
116
+ def cached_permissions(permission_model)
117
+ ::RequestStore.store[:permissions] ||= for_user(permission_model)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+ module NexusCqrs
3
+ module Auth
4
+ # Class used to provide additional context into pundit. This enables us to not only pass the user model, but also
5
+ # the global permissions for that user - as those are pulled from the user's request, not the model.
6
+ class UserContext
7
+ attr_reader :user, :global_permissions
8
+
9
+ def initialize(user, global_permissions)
10
+ @user = user
11
+ @global_permissions = global_permissions
12
+ end
13
+ end
14
+ end
15
+ end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all commands. Should declare value types for passing to handlers
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseCommand < BaseMessage
4
7
  end
5
8
  end
@@ -1,6 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all command handlers. Should always declare a `call` method for invoking the handler.
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseCommandHandler
7
+ include NexusCqrs::Auth
8
+
9
+ # Method for invoking this handler - all command logic should be contained in this method.
10
+ #
11
+ # @param [NexusCqrs::BaseCommand] command Command to be executed by this handler
4
12
  def call(command)
5
13
  end
6
14
  end
@@ -1,27 +1,52 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # All messages passed through the message bus will extend this class. Commands and Queries are both extended types of
4
+ # `BaseMessage`. This is mainly used to store metadata for Commands/Queries (such as the user context), as well
5
+ # as helper methods for retrieving the policy method for this Command/Query/Message
6
+ #
7
+ # @since 0.1.0
3
8
  class BaseMessage
9
+ # Sets metadata on this message.
10
+ #
11
+ # @param [Symbol] key Metadata key
12
+ # @param [Object] value Metadata value
13
+ # @example Set the current user on a command/query
14
+ # command.set_metadata(:current_user, user)
15
+ # @since 0.1.0
4
16
  def set_metadata(key, value)
5
17
  @metadata = {} unless @metadata
6
18
  @metadata[key.to_sym] = value
7
19
  end
8
20
 
21
+ # Getter for retrieving the metadata on this message
22
+ #
23
+ # @return [Hash] Any metadata attached to this message
24
+ # @since 0.1.0
9
25
  def metadata
10
26
  @metadata || {}
11
27
  end
12
28
 
13
- def to_h
14
- hash = instance_variables.each_with_object({}) do |var, acc|
15
- acc[var.to_s[1..-1].to_sym] = instance_variable_get(var)
16
- end
17
- hash[:metadata] = @metadata || {}
18
- hash
19
- end
20
-
29
+ # Helper method for retrieving the name of the class used to define the auth policy for this message
30
+ #
31
+ # @example Get the policy_class on a `Commands::Mods::DeleteMod` command
32
+ # command.policy_class #=> "DeleteModPolicy"
33
+ #
34
+ # @return [String] Name of the policy class
35
+ # @deprecated This used to be used to authorise policies before they hit the command bus - requiring a new policy
36
+ # for every message. E.g. `DeleteModPolicy`, `UpdateModPolicy`, `CreateModPolicy` etc. It is now recommended to
37
+ # simple call `authorise` within the handler as it is far more flexible
38
+ # @since 0.1.0
21
39
  def policy_class
22
40
  demodularised_class_name + 'Policy'
23
41
  end
24
42
 
43
+ # Helper method for retrieving the demodularised name of the class used to define the auth policy for this message
44
+ #
45
+ # @example Get the demodularised_class_name on a `Commands::Mods::DeleteMod` command
46
+ # command.policy_class #=> "DeleteMod"
47
+ #
48
+ # @return [String] Name of the policy class
49
+ # @since 0.1.0
25
50
  def demodularised_class_name
26
51
  self.class.name.split('::').last
27
52
  end
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base middleware class to allow custom middleware to be injected into the command bus
4
+ #
5
+ # @abstract
6
+ # @since 0.1.0
3
7
  class BaseMiddleware
4
8
  def initialize(next_)
5
9
  @next = next_
6
10
  end
7
11
 
8
- # @param [BaseMessage] message
12
+ # Invoke middleware
13
+ #
14
+ # @param [BaseMessage] message Message object being passed to the bus
9
15
  def call(message)
10
16
  end
11
17
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all queries. Should declare value types for passing to handlers
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseQuery < BaseMessage
4
7
  end
5
8
  end
@@ -1,6 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Base class for all query handlers. Should always declare a `call` method for invoking the handler.
4
+ #
5
+ # @since 0.1.0
3
6
  class BaseQueryHandler
7
+ include NexusCqrs::Auth
8
+
9
+ # Method for invoking this handler - all query logic should be contained in this method.
10
+ #
11
+ # @param [NexusCqrs::BaseQuery] query Query to be executed by this handler
4
12
  def call(query)
5
13
  end
6
14
  end
@@ -3,6 +3,11 @@ require 'thread_safe'
3
3
  require 'middleware'
4
4
 
5
5
  module NexusCqrs
6
+ # The command bus is responsible for registering and invoking the handlers. It stores a simple list of all registered
7
+ # commands, along with their handlers. When a message is passed to `call`, the middleware is triggered for the message
8
+ # then the handler is invoked.
9
+ #
10
+ # @since 0.1.0
6
11
  class CommandBus
7
12
  UnregisteredHandler = Class.new(StandardError)
8
13
  MultipleHandlers = Class.new(StandardError)
@@ -12,23 +17,44 @@ module NexusCqrs
12
17
  @middleware = middleware || Middleware::Builder.new
13
18
  end
14
19
 
15
- def register(klass, handler)
16
- raise MultipleHandlers, "Multiple handlers not allowed for #{klass}" if handlers[klass]
20
+ # Registers a handler for a command.
21
+ #
22
+ # @param [NexusCqrs::BaseMessage] message Message to register a handler for
23
+ # @param [NexusCqrs::BaseCommandHandler] handler CommandHandler/QueryHandler to be invoked for this message
24
+ # @raise [MultipleHandlers] If the message already has a handler registered, an error will be raised
25
+ #
26
+ # @since 0.1.0
27
+ def register(message, handler)
28
+ raise MultipleHandlers, "Multiple handlers not allowed for #{message}" if handlers[message]
17
29
 
18
- handlers[klass] = handler
30
+ handlers[message] = handler
19
31
  end
20
32
 
21
- def call(command)
33
+ # Invokes a handler from a message
34
+ #
35
+ # @param [NexusCqrs::BaseMessage] message Message used to determine which handler to invoke
36
+ #
37
+ # @since 0.1.0
38
+ def call(message)
22
39
  runner = Middleware::Builder.new
23
40
  runner.use(@middleware)
24
- runner.use(->(command_) { handler_for_command(command_).call(command_) })
25
- runner.call(command)
41
+ runner.use(->(message_) { handler_for_command(message_).call(message_) })
42
+ runner.call(message)
26
43
  end
27
44
 
45
+ # Return a list of registered handlers
46
+ #
47
+ # @return [ThreadSafe::Cache] ThreadSafe::Cache object containing a list of all the registered handlers
48
+ #
49
+ # @since 0.1.0
28
50
  def registered_handlers
29
51
  handlers
30
52
  end
31
53
 
54
+ # Removes all registered handlers from the bus. This can be useful in tests - as we don't want state to persist
55
+ # across handlers
56
+ #
57
+ # @since 0.1.0
32
58
  def clear_handlers
33
59
  @handlers = ThreadSafe::Cache.new
34
60
  end
@@ -37,6 +63,9 @@ module NexusCqrs
37
63
 
38
64
  private
39
65
 
66
+ # Looks up the handler for a specific command
67
+ #
68
+ # @since 0.1.0
40
69
  def handler_for_command(command)
41
70
  unregistered_handler = proc { raise UnregisteredHandler, "Missing handler for #{command.class}" }
42
71
  handlers.fetch(command.class, &unregistered_handler)
@@ -1,39 +1,49 @@
1
1
  # frozen_string_literal: true
2
+
2
3
  module NexusCqrs
3
4
  class CommandExecutor
4
5
  # @param [NexusCqrs::CommandBus] command_bus
5
6
  def initialize(command_bus)
6
7
  @bus = command_bus
8
+ @logger = logger
7
9
  end
8
10
 
9
- def autoregister(base_class, dir = 'app/domain/commands', ignore_strings = ['.rb', 'app/domain/'])
11
+ # Get logger instance depending on runtime environment.
12
+ def logger
13
+ defined?(Rails) && defined?(Rails.logger) ? Rails.logger : Logger.new(STDOUT)
14
+ end
10
15
 
11
- # Iterate over the director passed and find all ruby files, removing unwanted parts of the string.
12
- Dir["#{dir}/*.rb"].each do |file|
13
- ignore_strings.each { |i|
14
- file.slice!(i)
15
- }
16
- begin
17
- # Constantize class name to constant to force rails to autoload this class
16
+ def autoregister(base_class, dir = 'app/domain/commands', ignore_strings = ['.rb', 'app/domain/'])
17
+ if defined?(Rails)
18
+ # Iterate over the directory passed and find all ruby files.
19
+ Dir["#{dir}/**/*.rb"].each do |file|
20
+ # Constantize class name to constant to force rails to autoload this class.
21
+ # Note that autoloading won't work when testing this gem standalone, but this does trigger the necessary
22
+ # loading when in a rails environment.
23
+ ignore_strings.each do |i|
24
+ file.slice!(i)
25
+ end
26
+ @logger.debug { "Attempting constantize of #{file}" }
18
27
  file.camelize.constantize
19
28
  rescue NameError => e
20
- puts "WARN: Tried to autoregister #{file.camelize} but class could not be found"
29
+ @logger.warn { "Failed autoregister #{file.camelize}, received NameError: #{e}" }
21
30
  end
22
31
  end
23
32
 
24
33
  ObjectSpace.each_object(Class).select { |klass| klass < base_class }.each do |c|
34
+ @logger.debug { "Attempting auto registration of #{c}" }
25
35
  handler_name = (c.to_s + "Handler")
26
36
  begin
27
37
  handler = handler_name.constantize.new
28
- rescue NameError => e
29
- Rails.logger.error "WARN: A command/query called `#{c}` tried to autoregister `#{handler_name}` but the class was not found"
38
+ rescue NameError
39
+ @logger.warn { "Command `#{c}` tried to autoregister `#{handler_name}` but the class was not found" }
30
40
  next
31
41
  end
32
42
 
33
43
  if handler.respond_to?(:call)
34
44
  register_command(c, handler)
35
45
  else
36
- Rails.logger.error "WARN: A command/query called `#{c}` tried to autoregister `#{handler_name}` but the class did not have a `call` method"
46
+ @logger.warn { "Command `#{c}` tried to autoregister `#{handler_name}` but `call` method did not exist" }
37
47
  end
38
48
  end
39
49
  end
@@ -45,16 +55,24 @@ module NexusCqrs
45
55
  @bus
46
56
  end
47
57
 
48
- # @param [NexusCqrs::BaseCommand] command
49
- def execute(command)
50
- @bus.call(command)
58
+ # Executes a specific handler on the bus, passing in the provided message (Query/Command)
59
+ #
60
+ # @param [NexusCqrs::BaseMessage] message
61
+ def execute(message)
62
+ @bus.call(message)
51
63
  end
52
64
 
65
+ # Helper method for registering a handler on the bus
66
+ #
67
+ # @see CommandBus#register
68
+ # @param [NexusCqrs::BaseMessage] message
53
69
  # @param [NexusCqrs::BaseCommandHandler] handler
54
- def register_command(klass, handler)
55
- @bus.register(klass, handler)
70
+ def register_command(message, handler)
71
+ @bus.register(message, handler)
56
72
  end
57
73
 
74
+ # Clears all handlers from the bus and invokes the block passed to `configure_handlers` to re-register all the
75
+ # handlers again. This can be useful in tests - as we don't want state to persist across handlers
58
76
  def reregister_handlers
59
77
  return if @register_handlers.nil?
60
78
 
@@ -62,6 +80,18 @@ module NexusCqrs
62
80
  @register_handlers.call(self)
63
81
  end
64
82
 
83
+ # Configuration method for allowing handlers to be registered in bulk
84
+ #
85
+ # @see CommandBus#register
86
+ # @param [Proc] block Block to execute to register handlers
87
+ # @example Registering all queries
88
+ # executor.configure_handlers do |executor|
89
+ # executor.autoregister(NexusCqrs::BaseQuery, 'app/domain/queries')
90
+ # end
91
+ # @example Registering a query manually
92
+ # executor.configure_handlers do |executor|
93
+ # executor.register_command(NexusCqrs::BaseQuery, NexusCqrs::BaseQueryHandler)
94
+ # end
65
95
  def configure_handlers(&block)
66
96
  @register_handlers = block
67
97
  @register_handlers.call(self)
@@ -1,21 +1,36 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
+ # Simple module to inject execution methods into a class. Used to execute commands and queries from controllers and/or
4
+ # graphql resolvers.
3
5
  module Helpers
4
6
  # Executes a CQRS Command
7
+ #
8
+ # @param [NexusCqrs::BaseCommand] command Command to execute
9
+ # @example Execute a command
10
+ # execute(DeleteMod.new(mod.id))
5
11
  def execute(command)
6
12
  command_executor.execute(command)
7
13
  end
8
14
 
9
15
  # Executes a CQRS Query
16
+ #
17
+ # @param [NexusCqrs::BaseQuery] query Query to execute
18
+ # @example Execute a query
19
+ # execute(GetMod.new(mod.id))
10
20
  def query(query)
11
21
  query_executor.execute(query)
12
22
  end
13
23
 
14
- # Provide access to the CQRS executor
24
+ # Provide access to the CQRS command executor
25
+ #
26
+ # @return [NexusCqrs::CommandExecutor] Returns the executor used for commands
15
27
  def command_executor
16
28
  @command_executor ||= $COMMAND_EXECUTOR
17
29
  end
18
30
 
31
+ # Provide access to the CQRS query executor
32
+ #
33
+ # @return [NexusCqrs::CommandExecutor] Returns the executor used for queries
19
34
  def query_executor
20
35
  @query_executor ||= $QUERY_EXECUTOR
21
36
  end
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
  module NexusCqrs
3
- VERSION = '0.3.0'
3
+ # Leave this as 0.4.3 in order for CI process to replace with the tagged version.
4
+ VERSION = '0.4.3'
4
5
  end
data/lib/nexus_cqrs.rb CHANGED
@@ -1,6 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
  require 'nexus_cqrs/base_message'
3
3
  require 'nexus_cqrs/base_middleware'
4
+ require 'nexus_cqrs/auth/auth'
5
+ require 'nexus_cqrs/auth/user_context'
6
+ require 'nexus_cqrs/auth/permission_provider'
7
+ require 'nexus_cqrs/auth/ownable'
4
8
  require 'nexus_cqrs/base_command'
5
9
  require 'nexus_cqrs/base_command_handler'
6
10
  require 'nexus_cqrs/base_query'
data/nexus_cqrs.gemspec CHANGED
@@ -1,3 +1,4 @@
1
+ # frozen_string_literal: true
1
2
  require_relative 'lib/nexus_cqrs/version'
2
3
 
3
4
  Gem::Specification.new do |spec|
@@ -20,4 +21,7 @@ Gem::Specification.new do |spec|
20
21
  spec.add_dependency('generator_spec')
21
22
  spec.add_dependency('thread_safe')
22
23
  spec.add_dependency('ibsciss-middleware')
24
+ spec.add_dependency('pundit')
25
+ spec.add_dependency('strings-case')
26
+ spec.add_dependency('request_store')
23
27
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nexus_cqrs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dean Lovett
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2021-11-11 00:00:00.000000000 Z
11
+ date: 2021-11-25 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: generator_spec
@@ -52,6 +52,48 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: pundit
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: strings-case
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: request_store
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
55
97
  description:
56
98
  email:
57
99
  - dean.lovett@nexusmods.com
@@ -79,6 +121,10 @@ files:
79
121
  - lib/generators/nexus_cqrs/templates/query_handler.rb
80
122
  - lib/generators/nexus_cqrs/templates/register_cqrs_handlers.rb
81
123
  - lib/nexus_cqrs.rb
124
+ - lib/nexus_cqrs/auth/auth.rb
125
+ - lib/nexus_cqrs/auth/ownable.rb
126
+ - lib/nexus_cqrs/auth/permission_provider.rb
127
+ - lib/nexus_cqrs/auth/user_context.rb
82
128
  - lib/nexus_cqrs/base_command.rb
83
129
  - lib/nexus_cqrs/base_command_handler.rb
84
130
  - lib/nexus_cqrs/base_message.rb
@@ -108,7 +154,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
108
154
  - !ruby/object:Gem::Version
109
155
  version: '0'
110
156
  requirements: []
111
- rubygems_version: 3.2.31
157
+ rubygems_version: 3.2.32
112
158
  signing_key:
113
159
  specification_version: 4
114
160
  summary: Core package for the nexus cqrs gem