command_tower 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +7 -3
  3. data/app/auth/command_tower/auth/auth_context.rb +10 -4
  4. data/app/controllers/command_tower/admin/application_controller.rb +27 -0
  5. data/app/controllers/command_tower/admin/audit/events_controller.rb +61 -0
  6. data/app/controllers/command_tower/admin/messaging/announcements_controller.rb +1 -8
  7. data/app/controllers/command_tower/admin/users/identities_controller.rb +77 -0
  8. data/app/controllers/command_tower/admin/users/impersonation_sessions_controller.rb +33 -0
  9. data/app/controllers/command_tower/admin/users/roles_controller.rb +40 -0
  10. data/app/controllers/command_tower/admin/users_controller.rb +46 -0
  11. data/app/controllers/command_tower/admin/workspace_controller.rb +15 -0
  12. data/app/controllers/command_tower/application_controller.rb +17 -1
  13. data/app/controllers/command_tower/auth/impersonation_session_controller.rb +27 -0
  14. data/app/controllers/command_tower/auth/logout_controller.rb +3 -1
  15. data/app/controllers/command_tower/auth/principal_capabilities_controller.rb +19 -0
  16. data/app/controllers/command_tower/me/audit_events_controller.rb +59 -0
  17. data/app/controllers/concerns/command_tower/api/application_response_renderer.rb +5 -0
  18. data/app/controllers/concerns/command_tower/auth/authentication_boundary.rb +3 -2
  19. data/app/controllers/concerns/command_tower/execution/http_boundary.rb +65 -0
  20. data/app/deserializers/command_tower/deserializers/admin/scope_parameter.rb +49 -0
  21. data/app/deserializers/command_tower/deserializers/admin/users.rb +218 -0
  22. data/app/deserializers/command_tower/deserializers/audit/events.rb +224 -0
  23. data/app/deserializers/command_tower/deserializers/clients/types.rb +27 -0
  24. data/app/errors/command_tower/errors/auth/admin_unavailable_during_impersonation_error.rb +17 -0
  25. data/app/errors/command_tower/errors/auth/default_membership_assignment_error.rb +17 -0
  26. data/app/errors/command_tower/errors/auth/impersonation_session_expired_error.rb +17 -0
  27. data/app/errors/command_tower/errors/auth/impersonation_session_missing_error.rb +21 -0
  28. data/app/errors/command_tower/errors/auth/nested_impersonation_error.rb +17 -0
  29. data/app/errors/command_tower/errors/auth/self_impersonation_error.rb +21 -0
  30. data/app/errors/command_tower/errors/continuation_exhausted_error.rb +25 -0
  31. data/app/jobs/command_tower/application_job.rb +7 -0
  32. data/app/jobs/command_tower/execution/job_boundary.rb +19 -0
  33. data/app/mailers/command_tower/application_mailer.rb +11 -1
  34. data/app/mailers/command_tower/messaging/channel_mailer.rb +1 -2
  35. data/app/models/command_tower/audit/event.rb +36 -0
  36. data/app/models/command_tower/impersonation/session.rb +51 -0
  37. data/app/serializers/command_tower/serializers/admin/users.rb +69 -0
  38. data/app/serializers/command_tower/serializers/admin/workspace/manifest_serializer.rb +58 -0
  39. data/app/serializers/command_tower/serializers/audit/events/filter_options_serializer.rb +44 -0
  40. data/app/serializers/command_tower/serializers/audit/events.rb +42 -0
  41. data/app/serializers/command_tower/serializers/auth/principal_capabilities_serializer.rb +15 -0
  42. data/app/serializers/command_tower/serializers/auth/session_response_serializer.rb +26 -2
  43. data/app/serializers/command_tower/serializers/impersonation/session_serializer.rb +19 -0
  44. data/app/services/command_tower/README.md +4 -10
  45. data/app/services/command_tower/authorize/validate.rb +7 -4
  46. data/app/services/command_tower/jwt/authenticate_user.rb +8 -2
  47. data/app/services/command_tower/jwt/authentication_outcome.rb +5 -4
  48. data/app/services/command_tower/jwt/login_create.rb +7 -4
  49. data/app/services/command_tower/messaging/accept/operation_logger.rb +3 -3
  50. data/app/services/command_tower/messaging/contract/observability/correlation.rb +3 -1
  51. data/app/services/command_tower/messaging/contract/observability/operation_logger.rb +3 -3
  52. data/app/services/command_tower/messaging/contract/observability/publisher.rb +44 -0
  53. data/app/services/command_tower/messaging/execution/operation_logger.rb +1 -1
  54. data/app/services/command_tower/messaging/handoff/operation_logger.rb +1 -1
  55. data/app/services/command_tower/messaging/inbox/operation_logger.rb +1 -1
  56. data/app/services/command_tower/messaging/planner/operation_logger.rb +1 -1
  57. data/app/services/command_tower/messaging/recipient_readiness/evaluate.rb +1 -1
  58. data/app/services/command_tower/service_base.rb +30 -43
  59. data/app/services/command_tower/service_logging.rb +2 -36
  60. data/app/services/command_tower/services/account/clear_phone.rb +8 -0
  61. data/app/services/command_tower/services/account/phone_verification/verify.rb +1 -0
  62. data/app/services/command_tower/services/account/update_phone.rb +9 -1
  63. data/app/services/command_tower/services/admin/users.rb +377 -0
  64. data/app/services/command_tower/services/admin/workspace/manifest.rb +49 -0
  65. data/app/services/command_tower/services/application_service.rb +37 -0
  66. data/app/services/command_tower/services/audit/events/filter_options.rb +75 -0
  67. data/app/services/command_tower/services/audit/events.rb +199 -0
  68. data/app/services/command_tower/services/auth/assign_default_membership_role.rb +44 -0
  69. data/app/services/command_tower/services/auth/authenticate_session.rb +33 -6
  70. data/app/services/command_tower/services/auth/email_verification/verify.rb +2 -0
  71. data/app/services/command_tower/services/auth/password_reset/reset.rb +13 -3
  72. data/app/services/command_tower/services/auth/plain_text/login.rb +9 -1
  73. data/app/services/command_tower/services/auth/principal_capabilities/project.rb +48 -0
  74. data/app/services/command_tower/services/impersonation/create.rb +29 -0
  75. data/app/services/command_tower/services/impersonation/end.rb +28 -0
  76. data/app/services/command_tower/services/impersonation/record_activity.rb +30 -0
  77. data/app/services/command_tower/services/impersonation/terminate_open_sessions.rb +51 -0
  78. data/app/services/command_tower/services/me/change_password.rb +12 -0
  79. data/app/shared_sequences/command_tower/shared_sequences/admin/users/resolve_scoped_user.rb +42 -0
  80. data/app/workflows/command_tower/transactional.rb +65 -0
  81. data/app/workflows/command_tower/workflows/admin/messaging/create_announcement_workflow.rb +11 -0
  82. data/app/workflows/command_tower/workflows/admin/scope_resolution.rb +17 -0
  83. data/app/workflows/command_tower/workflows/admin/users/error_mapping.rb +26 -0
  84. data/app/workflows/command_tower/workflows/admin/users/identity_mutation.rb +30 -0
  85. data/app/workflows/command_tower/workflows/admin/users/list_assignable_roles_workflow.rb +30 -0
  86. data/app/workflows/command_tower/workflows/admin/users/list_workflow.rb +54 -0
  87. data/app/workflows/command_tower/workflows/admin/users/set_email_validated_workflow.rb +45 -0
  88. data/app/workflows/command_tower/workflows/admin/users/show_workflow.rb +49 -0
  89. data/app/workflows/command_tower/workflows/admin/users/update_email_workflow.rb +45 -0
  90. data/app/workflows/command_tower/workflows/admin/users/update_name_workflow.rb +49 -0
  91. data/app/workflows/command_tower/workflows/admin/users/update_roles_workflow.rb +61 -0
  92. data/app/workflows/command_tower/workflows/admin/users/update_username_workflow.rb +45 -0
  93. data/app/workflows/command_tower/workflows/admin/workspace/manifest_workflow.rb +25 -0
  94. data/app/workflows/command_tower/workflows/application_workflow.rb +163 -24
  95. data/app/workflows/command_tower/workflows/audit/error_mapping.rb +24 -0
  96. data/app/workflows/command_tower/workflows/audit/events/filter_options_for_admin_workflow.rb +34 -0
  97. data/app/workflows/command_tower/workflows/audit/events/filter_options_for_user_workflow.rb +34 -0
  98. data/app/workflows/command_tower/workflows/audit/events/list_for_admin_workflow.rb +74 -0
  99. data/app/workflows/command_tower/workflows/audit/events/list_for_user_workflow.rb +45 -0
  100. data/app/workflows/command_tower/workflows/audit/events/show_for_admin_workflow.rb +54 -0
  101. data/app/workflows/command_tower/workflows/audit/events/show_for_user_workflow.rb +42 -0
  102. data/app/workflows/command_tower/workflows/auth/authenticate_request_workflow.rb +3 -2
  103. data/app/workflows/command_tower/workflows/auth/authentication_response_effects.rb +5 -1
  104. data/app/workflows/command_tower/workflows/auth/logout_workflow.rb +41 -1
  105. data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +2 -0
  106. data/app/workflows/command_tower/workflows/auth/principal_capabilities/show_workflow.rb +27 -0
  107. data/app/workflows/command_tower/workflows/auth/register_workflow.rb +56 -24
  108. data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +15 -1
  109. data/app/workflows/command_tower/workflows/auth/session_error_status.rb +1 -0
  110. data/app/workflows/command_tower/workflows/impersonation/error_mapping.rb +27 -0
  111. data/app/workflows/command_tower/workflows/impersonation/start_workflow.rb +97 -0
  112. data/app/workflows/command_tower/workflows/impersonation/stop_workflow.rb +66 -0
  113. data/app/workflows/command_tower/workflows/me/update_name_workflow.rb +1 -0
  114. data/app/workflows/command_tower/workflows/profile/show_workflow.rb +1 -0
  115. data/app/workflows/command_tower/workflows/workflow_result.rb +31 -7
  116. data/config/routes.rb +20 -0
  117. data/db/migrate/20260816000001_create_command_tower_audit_events.rb +45 -0
  118. data/db/migrate/20260817000001_add_scope_columns_to_command_tower_audit_events.rb +34 -0
  119. data/db/migrate/20260817000003_create_command_tower_impersonation_sessions.rb +26 -0
  120. data/docs/admin_workspace.md +158 -0
  121. data/docs/api_reference.md +175 -14
  122. data/docs/architecture.md +5 -0
  123. data/docs/audit.md +195 -0
  124. data/docs/authentication.md +14 -0
  125. data/docs/authentication_authorization_guide.md +15 -11
  126. data/docs/authorization.md +25 -4
  127. data/docs/controllers.md +7 -1
  128. data/docs/eventing.md +179 -0
  129. data/docs/extending.md +32 -1
  130. data/docs/host_integration_guide.md +103 -12
  131. data/docs/initializing.md +2 -2
  132. data/docs/messaging_integration_guide.md +1 -1
  133. data/docs/models.md +4 -0
  134. data/docs/pagination.md +2 -2
  135. data/docs/principal_capabilities.md +89 -0
  136. data/docs/upgrades/0.10.0.md +2 -2
  137. data/docs/upgrades/0.11.0.md +92 -0
  138. data/docs/upgrades/README.md +1 -0
  139. data/lib/command_tower/admin_scope/apply_audit_scoping.rb +45 -0
  140. data/lib/command_tower/admin_scope/apply_users_narrowing.rb +21 -0
  141. data/lib/command_tower/admin_scope/manifest_projection.rb +102 -0
  142. data/lib/command_tower/admin_scope/resolve.rb +31 -0
  143. data/lib/command_tower/admin_scope/scope_context.rb +7 -0
  144. data/lib/command_tower/admin_scope/scope_option.rb +7 -0
  145. data/lib/command_tower/admin_scope.rb +20 -0
  146. data/lib/command_tower/admin_workspace.rb +16 -0
  147. data/lib/command_tower/audit/attribution.rb +90 -0
  148. data/lib/command_tower/audit/emit.rb +113 -0
  149. data/lib/command_tower/audit/masking.rb +41 -0
  150. data/lib/command_tower/audit/payload.rb +128 -0
  151. data/lib/command_tower/audit/persistence/subscriber.rb +85 -0
  152. data/lib/command_tower/audit.rb +27 -0
  153. data/lib/command_tower/authorization/assignable_roles.rb +36 -0
  154. data/lib/command_tower/authorization/default.yml +99 -5
  155. data/lib/command_tower/authorization/effective_entity_grants.rb +55 -0
  156. data/lib/command_tower/authorization/entity.rb +15 -5
  157. data/lib/command_tower/authorization/role.rb +5 -4
  158. data/lib/command_tower/authorization.rb +71 -10
  159. data/lib/command_tower/configuration/admin_scope/config.rb +104 -0
  160. data/lib/command_tower/configuration/admin_scope/tool_registration.rb +28 -0
  161. data/lib/command_tower/configuration/authorization/config.rb +6 -0
  162. data/lib/command_tower/configuration/config.rb +20 -0
  163. data/lib/command_tower/configuration/impersonation/config.rb +38 -0
  164. data/lib/command_tower/configuration/registry/admin_workspace/config.rb +199 -0
  165. data/lib/command_tower/configuration/registry/admin_workspace/tool_definition.rb +150 -0
  166. data/lib/command_tower/configuration/registry/audit/config.rb +403 -0
  167. data/lib/command_tower/configuration/registry/audit/event_definition.rb +155 -0
  168. data/lib/command_tower/configuration/registry/config.rb +34 -0
  169. data/lib/command_tower/configuration/registry/principal_capabilities/capability_definition.rb +43 -0
  170. data/lib/command_tower/configuration/registry/principal_capabilities/config.rb +136 -0
  171. data/lib/command_tower/current.rb +12 -0
  172. data/lib/command_tower/engine.rb +19 -4
  173. data/lib/command_tower/events.rb +184 -0
  174. data/lib/command_tower/execution.rb +111 -0
  175. data/lib/command_tower/impersonation/activity_declaration.rb +23 -0
  176. data/lib/command_tower/impersonation/apply_overlay.rb +88 -0
  177. data/lib/command_tower/impersonation/clear_overlay_for_audit.rb +23 -0
  178. data/lib/command_tower/impersonation/establish_identity.rb +44 -0
  179. data/lib/command_tower/install/baseline.rb +3 -0
  180. data/lib/command_tower/logging/lifecycle_declaration.rb +27 -0
  181. data/lib/command_tower/logging/projection.rb +67 -0
  182. data/lib/command_tower/logging/subscriber.rb +103 -0
  183. data/lib/command_tower/principal_capabilities.rb +15 -0
  184. data/lib/command_tower/version.rb +1 -1
  185. data/lib/command_tower.rb +12 -0
  186. data/spec/factories/impersonation_session.rb +17 -0
  187. data/spec/factories/user.rb +16 -0
  188. metadata +112 -2
  189. data/app/services/command_tower/messaging/contract/observability/structured_logger.rb +0 -56
@@ -228,7 +228,7 @@ module CommandTower
228
228
  payload[:channel_delivery_id] = channel_delivery_id
229
229
  end
230
230
 
231
- Contract::Observability::StructuredLogger.public_send(level, payload)
231
+ Contract::Observability::Publisher.public_send(level, payload)
232
232
  end
233
233
  end
234
234
  end
@@ -69,7 +69,7 @@ module CommandTower
69
69
  payload[:communication_id] = communication_id
70
70
  end
71
71
 
72
- Contract::Observability::StructuredLogger.public_send(level, payload)
72
+ Contract::Observability::Publisher.public_send(level, payload)
73
73
  end
74
74
  end
75
75
  end
@@ -15,7 +15,7 @@ module CommandTower
15
15
  class << self
16
16
  def lifecycle_changed(operation:, item:, bulk: false)
17
17
  event = EVENT_BY_OPERATION.fetch(operation)
18
- Contract::Observability::StructuredLogger.info(
18
+ Contract::Observability::Publisher.info(
19
19
  event:,
20
20
  messaging_operation: "inbox",
21
21
  operation: operation.to_s,
@@ -11,7 +11,7 @@ module CommandTower
11
11
  channel_key:,
12
12
  reason_codes:
13
13
  )
14
- Contract::Observability::StructuredLogger.info(
14
+ Contract::Observability::Publisher.info(
15
15
  event: "messaging.planner.readiness_excluded",
16
16
  correlation_id: Contract::Observability::Correlation.resolve,
17
17
  messaging_operation: "planner",
@@ -299,7 +299,7 @@ module CommandTower
299
299
  end
300
300
 
301
301
  def log_evaluation(result)
302
- Contract::Observability::StructuredLogger.info(
302
+ Contract::Observability::Publisher.info(
303
303
  event: "messaging.recipient_readiness.evaluated",
304
304
  recipient_id: @recipient_id,
305
305
  channel_key: result.channel_key,
@@ -19,6 +19,8 @@ class CommandTower::ServiceBase
19
19
  include Interactor
20
20
  include CommandTower::ServiceLogging
21
21
  include CommandTower::ArgumentValidation
22
+ include CommandTower::Execution::ContextAccess
23
+ include CommandTower::Logging::LifecycleDeclaration
22
24
 
23
25
  ON_ARGUMENT_VALIDATION = [
24
26
  DEFAULT_VALIDATION = :raise,
@@ -27,11 +29,10 @@ class CommandTower::ServiceBase
27
29
  ]
28
30
 
29
31
  def self.inherited(subclass)
30
- # Add the base logging to the subclass.
31
- # Since this is done at inheritance time it should always be the first and last hook to run.
32
- subclass.around(:service_base_logging)
33
32
  subclass.around(:internal_validate)
34
33
  subclass.after(:sanitize_params)
34
+ # Registered last so Interactor treats it as the outermost around (reverse nest).
35
+ subclass.around(:command_tower_lifecycle)
35
36
  end
36
37
 
37
38
  def validate!
@@ -39,51 +40,37 @@ class CommandTower::ServiceBase
39
40
  end
40
41
 
41
42
  def internal_validate(interactor)
42
- # call validate that is overridden from child
43
- begin
44
- validate! # custom validations defined on the child class
45
- run_validations! # ArgumentValidation's based on defined settings on child
46
- rescue StandardError => e
47
- log_error("Error during validation. #{e.message}")
48
- raise
49
- end
50
-
51
- # call interactor
43
+ validate! # custom validations defined on the child class
44
+ run_validations! # ArgumentValidation's based on defined settings on child
52
45
  interactor.call
53
46
  end
54
47
 
55
- def service_base_logging(interactor)
56
- beginning_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
57
-
58
- # Pre processing stats
59
- log_info("Start")
60
-
61
- # Run the job!
62
- interactor.call
63
-
64
- # Set status for use in ensure block
65
- status = :complete
66
-
67
- # Capture Interactor::Failure for logging purposes, then reraise
68
- rescue ::Interactor::Failure
69
- # set status for use in ensure block
70
- status = :failure
48
+ def command_tower_lifecycle(interactor)
49
+ CommandTower::Events.around_execution(layer: :service, subject: self.class.name, log_lifecycle: self.class.lifecycle_loggable?) do |record|
50
+ begin
51
+ interactor.call
52
+ record[:result] = :success
53
+ rescue ::Interactor::Failure
54
+ record[:result] = :failure
55
+ record[:error_codes] = service_lifecycle_error_codes
56
+ record[:log_level] = service_lifecycle_log_level
57
+ raise
58
+ rescue ::Exception => e
59
+ record[:unexpected] = e
60
+ raise
61
+ end
62
+ end
63
+ end
71
64
 
72
- # Re-raise to let the core Interactor handle this
73
- raise
74
- # Capture exception explicitly for logging purposes, then reraise
75
- rescue ::Exception => e
76
- # set status for use in ensure block
77
- status = :error
65
+ def service_lifecycle_error_codes
66
+ error = context.application_error if context.respond_to?(:application_error)
67
+ return [error.code] if error.respond_to?(:code)
78
68
 
79
- # Log error
80
- log_error("Error #{e.class.name}")
69
+ nil
70
+ end
81
71
 
82
- raise
83
- ensure
84
- # Always log how long it took along with a status
85
- finished_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
86
- elapsed = ((finished_time - beginning_time) * 1000).round(2)
87
- log_info("Finished with [#{status}]...elapsed #{elapsed}ms")
72
+ def service_lifecycle_log_level
73
+ error = context.application_error if context.respond_to?(:application_error)
74
+ error.log_level if error.respond_to?(:log_level)
88
75
  end
89
76
  end
@@ -1,41 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower::ServiceLogging
4
- def log_info(msg)
5
- log(level: :info, msg:)
6
- end
7
-
8
- def log_warn(msg)
9
- log(level: :warn, msg:)
10
- end
11
-
12
- def log_error(msg)
13
- log(level: :error, msg:)
14
- end
15
-
16
- def log(level:, msg:)
17
- logger.public_send(level, aletered_message(msg))
18
- rescue StandardError
19
- Rails.logger.public_send(level, aletered_message(msg))
20
- end
21
-
22
- def aletered_message(msg)
23
- "#{log_prefix}: #{msg}"
24
- end
25
-
26
- def logger
27
- defined?(context) ? context.logger : Rails.logger
28
- end
29
-
30
- def log_prefix
31
- "[#{class_name}-#{service_id}]"
32
- end
33
-
34
- def class_name
35
- self.class.name
36
- end
37
-
38
- def service_id
39
- @service_id ||= SecureRandom.alphanumeric(10)
4
+ def self.included(base)
5
+ base.include CommandTower::Execution::ContextAccess
40
6
  end
41
7
  end
@@ -13,10 +13,18 @@ module CommandTower
13
13
  return
14
14
  end
15
15
 
16
+ previous = user.phone_number
16
17
  user.phone_number = nil
17
18
  user.phone_number_validated = false
18
19
  user.save!
19
20
 
21
+ audit(
22
+ :phone_cleared,
23
+ subject: user,
24
+ affected_user: user,
25
+ changes: { phone: { from: previous, to: nil } }
26
+ )
27
+
20
28
  context.user = user
21
29
  end
22
30
  end
@@ -36,6 +36,7 @@ module CommandTower
36
36
 
37
37
  locked.update!(phone_number_validated: true)
38
38
  CommandTower::Secrets::Cleanse.(user: locked, reason: CommandTower::Secrets::PHONE_VERIFICATION)
39
+ audit(:phone_verified, subject: locked, affected_user: locked, changes: {})
39
40
  context.user = locked.reload
40
41
  context.already_verified = false
41
42
  end
@@ -10,7 +10,7 @@ module CommandTower
10
10
  validate :user, is_a: User, required: true
11
11
  validate :phone_number, is_a: String, required: true
12
12
 
13
- def call
13
+ def call
14
14
  normalized = normalize
15
15
 
16
16
  if user.phone_number == normalized
@@ -18,6 +18,7 @@ module CommandTower
18
18
  return
19
19
  end
20
20
 
21
+ previous = user.phone_number
21
22
  user.phone_number = normalized
22
23
  user.phone_number_validated = false
23
24
 
@@ -27,6 +28,13 @@ module CommandTower
27
28
  reject!(DUPLICATE_MESSAGE)
28
29
  end
29
30
 
31
+ audit(
32
+ :phone_updated,
33
+ subject: user,
34
+ affected_user: user,
35
+ changes: { phone: { from: previous, to: normalized } }
36
+ )
37
+
30
38
  context.user = user
31
39
  end
32
40
 
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Admin
6
+ module Users
7
+ class List < CommandTower::Services::ApplicationService
8
+ validate :limit, is_a: Integer, required: true, gte: 1, lte: 100
9
+ validate :offset, is_a: Integer, required: true, gt: -1
10
+ validate :search, is_a: String, required: false
11
+ validate :principal, is_a: User, required: false
12
+ validate :scope_context, is_a: [CommandTower::AdminScope::ScopeContext, NilClass], required: false
13
+
14
+ def call
15
+ relation = ::User.order(id: :desc)
16
+ relation = CommandTower::AdminScope::ApplyUsersNarrowing.call(
17
+ relation:,
18
+ scope_context:,
19
+ principal: principal || scope_context_principal
20
+ )
21
+ relation = apply_search(relation)
22
+
23
+ context.users = relation.limit(limit).offset(offset).to_a
24
+ context.pagination = { limit:, offset:, total_count: relation.count }
25
+ end
26
+
27
+ private
28
+
29
+ def scope_context_principal
30
+ principal
31
+ end
32
+
33
+ def apply_search(relation)
34
+ token = search.to_s.strip
35
+ return relation if token.empty?
36
+
37
+ pattern = "%#{ActiveRecord::Base.sanitize_sql_like(token)}%"
38
+ relation.where(
39
+ "email LIKE :q OR username LIKE :q OR first_name LIKE :q OR last_name LIKE :q",
40
+ q: pattern
41
+ )
42
+ end
43
+ end
44
+
45
+ class Show < CommandTower::Services::ApplicationService
46
+ validate :id, is_a: Integer, required: true
47
+ validate :principal, is_a: User, required: false
48
+ validate :scope_context, is_a: [CommandTower::AdminScope::ScopeContext, NilClass], required: false
49
+
50
+ def call
51
+ relation = ::User.where(id:)
52
+ relation = CommandTower::AdminScope::ApplyUsersNarrowing.call(
53
+ relation:,
54
+ scope_context:,
55
+ principal: principal || scope_context_principal
56
+ )
57
+ user = relation.first
58
+ if user.nil?
59
+ return context.fail!(application_error: CommandTower::Errors::NotFoundError.new)
60
+ end
61
+
62
+ context.user = user
63
+ end
64
+
65
+ private
66
+
67
+ def scope_context_principal
68
+ principal
69
+ end
70
+ end
71
+
72
+ module IdentityUniqueness
73
+ DUPLICATE_MESSAGE = "has already been taken"
74
+
75
+ module_function
76
+
77
+ def uniqueness_error(field)
78
+ CommandTower::Errors::ValidationError.new(details: { field => DUPLICATE_MESSAGE })
79
+ end
80
+
81
+ def uniqueness_taken?(error, attribute)
82
+ record = error.respond_to?(:record) ? error.record : nil
83
+ return false if record.nil?
84
+
85
+ Array(record.errors.details[attribute]).any? { |entry| entry[:error] == :taken }
86
+ end
87
+ end
88
+
89
+ class UpdateName < CommandTower::Services::ApplicationService
90
+ validate :user, is_a: User, required: true
91
+ validate :first_name, is_a: String, required: true
92
+ validate :last_name, is_a: String, required: true
93
+
94
+ def call
95
+ normalized_first = first_name.to_s.strip
96
+ normalized_last = last_name.to_s.strip
97
+ first_changed = normalized_first != user.first_name.to_s
98
+ last_changed = normalized_last != user.last_name.to_s
99
+
100
+ unless first_changed || last_changed
101
+ context.user = user
102
+ context.changed = false
103
+ return
104
+ end
105
+
106
+ application_error = nil
107
+
108
+ ActiveRecord::Base.transaction do
109
+ if first_changed
110
+ application_error = mutate(first_name: normalized_first)
111
+ raise ActiveRecord::Rollback if application_error
112
+ end
113
+
114
+ if last_changed
115
+ application_error = mutate(last_name: normalized_last)
116
+ raise ActiveRecord::Rollback if application_error
117
+ end
118
+ end
119
+
120
+ if application_error
121
+ context.fail!(application_error:)
122
+ return
123
+ end
124
+
125
+ context.user = user.reload
126
+ context.changed = true
127
+ end
128
+
129
+ private
130
+
131
+ def mutate(**attribute)
132
+ result = CommandTower::UserAttributes::Mutate.call(user:, **attribute)
133
+ return nil if result.success?
134
+
135
+ details = result.invalid_argument_hash.transform_values { _1[:msg].to_s }
136
+ CommandTower::Errors::ValidationError.new(details: camelize_detail_keys(details))
137
+ rescue ActiveRecord::RecordInvalid => error
138
+ uniqueness_or_raise(error, attribute.keys.first)
139
+ rescue ActiveRecord::RecordNotUnique
140
+ IdentityUniqueness.uniqueness_error(camelize_field(attribute.keys.first))
141
+ end
142
+
143
+ def uniqueness_or_raise(error, attribute)
144
+ if IdentityUniqueness.uniqueness_taken?(error, attribute)
145
+ return IdentityUniqueness.uniqueness_error(camelize_field(attribute))
146
+ end
147
+
148
+ raise error
149
+ end
150
+
151
+ def camelize_detail_keys(details)
152
+ details.transform_keys { |key| camelize_field(key) }
153
+ end
154
+
155
+ def camelize_field(field)
156
+ case field.to_s
157
+ when "first_name" then "firstName"
158
+ when "last_name" then "lastName"
159
+ else field.to_s
160
+ end
161
+ end
162
+ end
163
+
164
+ class UpdateUsername < CommandTower::Services::ApplicationService
165
+ validate :user, is_a: User, required: true
166
+ validate :username, is_a: String, required: true
167
+
168
+ def call
169
+ normalized = username.to_s.strip
170
+ if user.username.to_s == normalized
171
+ context.user = user
172
+ context.changed = false
173
+ return
174
+ end
175
+
176
+ result = CommandTower::UserAttributes::Mutate.call(user:, username: normalized)
177
+ unless result.success?
178
+ details = result.invalid_argument_hash.transform_values { _1[:msg].to_s }
179
+ context.fail!(application_error: CommandTower::Errors::ValidationError.new(details:))
180
+ return
181
+ end
182
+
183
+ context.user = user.reload
184
+ context.changed = true
185
+ rescue ActiveRecord::RecordInvalid => error
186
+ fail_uniqueness_or_raise(error)
187
+ rescue ActiveRecord::RecordNotUnique
188
+ context.fail!(application_error: IdentityUniqueness.uniqueness_error("username"))
189
+ end
190
+
191
+ private
192
+
193
+ def fail_uniqueness_or_raise(error)
194
+ if IdentityUniqueness.uniqueness_taken?(error, :username)
195
+ context.fail!(application_error: IdentityUniqueness.uniqueness_error("username"))
196
+ return
197
+ end
198
+
199
+ raise error
200
+ end
201
+ end
202
+
203
+ class UpdateEmail < CommandTower::Services::ApplicationService
204
+ validate :user, is_a: User, required: true
205
+ validate :email, is_a: String, required: true
206
+
207
+ def call
208
+ normalized = email.to_s.strip
209
+ if user.email.to_s == normalized
210
+ context.user = user
211
+ context.changed = false
212
+ return
213
+ end
214
+
215
+ result = CommandTower::UserAttributes::Mutate.call(user:, email: normalized)
216
+ unless result.success?
217
+ details = result.invalid_argument_hash.transform_values { _1[:msg].to_s }
218
+ context.fail!(application_error: CommandTower::Errors::ValidationError.new(details:))
219
+ return
220
+ end
221
+
222
+ user.reload
223
+ user.update!(email_validated: false) if user.email_validated
224
+
225
+ context.user = user
226
+ context.changed = true
227
+ rescue ActiveRecord::RecordInvalid => error
228
+ fail_uniqueness_or_raise(error)
229
+ rescue ActiveRecord::RecordNotUnique
230
+ context.fail!(application_error: IdentityUniqueness.uniqueness_error("email"))
231
+ end
232
+
233
+ private
234
+
235
+ def fail_uniqueness_or_raise(error)
236
+ if IdentityUniqueness.uniqueness_taken?(error, :email)
237
+ context.fail!(application_error: IdentityUniqueness.uniqueness_error("email"))
238
+ return
239
+ end
240
+
241
+ raise error
242
+ end
243
+ end
244
+
245
+ class SetEmailValidated < CommandTower::Services::ApplicationService
246
+ validate :user, is_a: User, required: true
247
+ validate :email_validated, is_a: [TrueClass, FalseClass], required: true
248
+
249
+ def call
250
+ if user.email_validated == email_validated
251
+ context.user = user
252
+ context.changed = false
253
+ return
254
+ end
255
+
256
+ user.update!(email_validated:)
257
+ context.user = user
258
+ context.changed = true
259
+ end
260
+ end
261
+
262
+ class ListAssignableRoles < CommandTower::Services::ApplicationService
263
+ def call
264
+ context.roles = CommandTower::Authorization::AssignableRoles.catalog
265
+ end
266
+ end
267
+
268
+ class RoleAssignmentPolicy < CommandTower::Services::ApplicationService
269
+ validate :actor, is_a: User, required: true
270
+ validate :target, is_a: User, required: true
271
+ validate :desired_roles, is_a: Array, required: true
272
+
273
+ def call
274
+ desired = desired_roles.map(&:to_s)
275
+ assignable = CommandTower::Authorization::AssignableRoles.names
276
+
277
+ if desired.any? { |name| protected_role?(name) }
278
+ return context.fail!(application_error: CommandTower::Errors::ForbiddenError.new)
279
+ end
280
+
281
+ unknown = desired.reject { |name| assignable.include?(name) }
282
+ if unknown.any?
283
+ return context.fail!(
284
+ application_error: CommandTower::Errors::ValidationError.new(
285
+ details: { roles: "unknown_or_unassignable" }
286
+ )
287
+ )
288
+ end
289
+
290
+ current = Array(target.roles).map(&:to_s)
291
+ current_assignable = current.select { |name| assignable.include?(name) }
292
+ newly_assigned = desired - current_assignable
293
+ actor_grants = CommandTower::Authorization::EffectiveEntityGrants.for_user(actor)
294
+
295
+ newly_assigned.each do |role_name|
296
+ candidate_grants = CommandTower::Authorization::EffectiveEntityGrants.for_role(role_name)
297
+ next if CommandTower::Authorization::EffectiveEntityGrants.subset?(candidate_grants, actor_grants)
298
+
299
+ return context.fail!(application_error: CommandTower::Errors::ForbiddenError.new)
300
+ end
301
+
302
+ preserved = current.reject { |name| assignable.include?(name) }
303
+ proposed = preserved + desired.uniq
304
+
305
+ if target.id == actor.id && self_lockout?(actor_grants, proposed)
306
+ return context.fail!(application_error: CommandTower::Errors::ForbiddenError.new)
307
+ end
308
+
309
+ context.desired_roles = desired.uniq
310
+ end
311
+
312
+ private
313
+
314
+ def protected_role?(name)
315
+ role = CommandTower::Authorization::Role.roles[name]
316
+ name.to_s == CommandTower::Authorization::AssignableRoles::OWNER_NAME ||
317
+ role&.allow_everything
318
+ end
319
+
320
+ def self_lockout?(before_grants, proposed_role_names)
321
+ grants = CommandTower::Authorization::EffectiveEntityGrants
322
+ after_grants = grants.for_role_names(proposed_role_names)
323
+
324
+ if grants.includes?(before_grants, "admin_rbac_assignments") &&
325
+ !grants.includes?(after_grants, "admin_rbac_assignments")
326
+ return true
327
+ end
328
+
329
+ grants.includes?(before_grants, "admin_workspace") &&
330
+ !grants.includes?(after_grants, "admin_workspace")
331
+ end
332
+ end
333
+
334
+ class ReplaceUserRoles < CommandTower::Services::ApplicationService
335
+ validate :user, is_a: User, required: true
336
+ validate :desired_roles, is_a: Array, required: true
337
+
338
+ def call
339
+ assignable = CommandTower::Authorization::AssignableRoles.names
340
+ desired = desired_roles.map(&:to_s).uniq
341
+ current = Array(user.roles).map(&:to_s)
342
+ current_assignable = current.select { |name| assignable.include?(name) }
343
+ preserved = current.reject { |name| assignable.include?(name) }
344
+ next_roles = (preserved + desired).uniq
345
+
346
+ assigned = desired - current_assignable
347
+ revoked = current_assignable - desired
348
+ changed = assigned.any? || revoked.any?
349
+
350
+ unless changed
351
+ context.user = user
352
+ context.assigned_roles = []
353
+ context.revoked_roles = []
354
+ context.changed = false
355
+ return
356
+ end
357
+
358
+ user.roles = next_roles
359
+ unless user.save
360
+ return context.fail!(
361
+ application_error: CommandTower::Errors::ValidationError.new(
362
+ details: { roles: "could_not_save" }
363
+ )
364
+ )
365
+ end
366
+
367
+ context.user = user
368
+ context.assigned_roles = assigned
369
+ context.revoked_roles = revoked
370
+ context.changed = true
371
+ end
372
+ end
373
+ end
374
+ end
375
+ end
376
+ end
377
+
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Admin
6
+ module Workspace
7
+ class Manifest < CommandTower::Services::ApplicationService
8
+ validate :user, is_a: User, required: true
9
+
10
+ def call
11
+ context.tools = authorized_definitions.map { |definition| project(definition) }
12
+ end
13
+
14
+ private
15
+
16
+ def authorized_definitions
17
+ CommandTower.config.registry.admin_workspace.definitions.values.select do |definition|
18
+ granted?(definition.required_entity)
19
+ end.sort_by { |definition| [definition.group, definition.sort_order, definition.id] }
20
+ end
21
+
22
+ def granted?(entity_name)
23
+ Array(user.roles).any? do |role_name|
24
+ role = CommandTower::Authorization::Role.roles[role_name]
25
+ next false unless role
26
+
27
+ role.allow_everything || role.entities.any? { |entity| entity.name.to_s == entity_name }
28
+ end
29
+ end
30
+
31
+ def project(definition)
32
+ base = {
33
+ id: definition.id,
34
+ label: definition.label,
35
+ description: definition.description,
36
+ route: definition.route,
37
+ group: definition.group,
38
+ sort_order: definition.sort_order,
39
+ icon: definition.icon
40
+ }
41
+ base.merge(
42
+ CommandTower::AdminScope::ManifestProjection.call(definition:, principal: user)
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end