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
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Deserializers
5
+ module Admin
6
+ module ScopeParameter
7
+ module_function
8
+
9
+ def extract(params, tool_id:)
10
+ definition = CommandTower.config.registry.admin_workspace.fetch(tool_id)
11
+ return nil unless definition.scope_required?
12
+
13
+ keys = parameter_keys(definition.scope_parameter)
14
+ raw = fetch_raw(params, keys)
15
+ return nil if raw.nil? || raw.to_s.strip.empty?
16
+
17
+ raw.to_s.strip
18
+ end
19
+
20
+ def fetch_raw(params, keys)
21
+ hash =
22
+ if params.respond_to?(:to_unsafe_h)
23
+ params.to_unsafe_h
24
+ elsif params.respond_to?(:to_h)
25
+ params.to_h
26
+ else
27
+ params
28
+ end
29
+
30
+ keys.each do |key|
31
+ string_key = key.to_s
32
+ symbol_key = key.to_sym
33
+ return hash[string_key] if hash.key?(string_key)
34
+ return hash[symbol_key] if hash.key?(symbol_key)
35
+ end
36
+ nil
37
+ end
38
+ private_class_method :fetch_raw
39
+
40
+ def parameter_keys(parameter)
41
+ snake = parameter.to_s
42
+ camel = snake.camelize(:lower)
43
+ [snake.to_sym, snake, camel.to_sym, camel]
44
+ end
45
+ private_class_method :parameter_keys
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Deserializers
5
+ module Admin
6
+ module Users
7
+ module IdentityId
8
+ module_function
9
+
10
+ def coerce(raw)
11
+ return raw if raw.is_a?(Integer) && raw.positive?
12
+ return if raw.nil?
13
+
14
+ text = raw.to_s.strip
15
+ return if text.empty? || !/\A\d+\z/.match?(text)
16
+
17
+ Integer(text, 10).then { |value| value.positive? ? value : nil }
18
+ rescue ArgumentError
19
+ nil
20
+ end
21
+ end
22
+
23
+ class ListDeserializer < CommandTower::Deserializers::ApplicationDeserializer
24
+ Input = Data.define(:limit, :offset, :search, :scope_value)
25
+ DEFAULT_LIMIT = 50
26
+ MAX_LIMIT = 100
27
+ MAX_SEARCH_LENGTH = 100
28
+ TOOL_ID = "users"
29
+
30
+ def call(params)
31
+ limit = pagination_integer(
32
+ fetch_param(params, :limit).value,
33
+ default: DEFAULT_LIMIT,
34
+ minimum: 1,
35
+ maximum: MAX_LIMIT
36
+ )
37
+ return failure(errors: { message: "invalid_limit" }) if limit.nil?
38
+
39
+ offset = pagination_integer(fetch_param(params, :offset).value, default: 0, minimum: 0)
40
+ return failure(errors: { message: "invalid_offset" }) if offset.nil?
41
+
42
+ search = optional_search(fetch_param(params, :search).value)
43
+ return search if deserializer_result?(search)
44
+
45
+ scope_value = CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: TOOL_ID)
46
+
47
+ success(Input.new(limit:, offset:, search:, scope_value:))
48
+ end
49
+
50
+ private
51
+
52
+ def pagination_integer(raw, default:, minimum:, maximum: nil)
53
+ return default if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
54
+
55
+ value = raw.is_a?(Integer) ? raw : (Integer(raw, 10) if raw.is_a?(String) && /\A-?\d+\z/.match?(raw.strip))
56
+ return if value.nil? || value < minimum || (maximum && value > maximum)
57
+
58
+ value
59
+ rescue ArgumentError
60
+ nil
61
+ end
62
+
63
+ # Returns nil, a trimmed String, or a DeserializerResult failure.
64
+ def optional_search(raw)
65
+ return nil if raw.nil?
66
+
67
+ text = raw.to_s.strip
68
+ return nil if text.empty?
69
+ return failure(errors: { message: "invalid_search" }) if text.length > MAX_SEARCH_LENGTH
70
+
71
+ text
72
+ end
73
+ end
74
+
75
+ class ShowDeserializer < CommandTower::Deserializers::ApplicationDeserializer
76
+ Input = Data.define(:id, :scope_value)
77
+ TOOL_ID = "users"
78
+
79
+ def call(params)
80
+ id = IdentityId.coerce(fetch_param(params, :id).value)
81
+ return failure(errors: { message: "invalid_id" }) if id.nil?
82
+
83
+ scope_value = CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: TOOL_ID)
84
+
85
+ success(Input.new(id:, scope_value:))
86
+ end
87
+ end
88
+
89
+ class UpdateNameDeserializer < CommandTower::Deserializers::ApplicationDeserializer
90
+ Input = Data.define(:id, :first_name, :last_name, :scope_value)
91
+
92
+ def call(params)
93
+ id = IdentityId.coerce(fetch_param(params, :id).value)
94
+ return failure(errors: { message: "invalid_id" }) if id.nil?
95
+
96
+ first_name = fetch_param(params, :first_name, :firstName).value.to_s.strip
97
+ last_name = fetch_param(params, :last_name, :lastName).value.to_s.strip
98
+ if first_name.blank? || last_name.blank?
99
+ return failure(errors: { message: "missing_required_fields" })
100
+ end
101
+
102
+ success(
103
+ Input.new(
104
+ id:,
105
+ first_name:,
106
+ last_name:,
107
+ scope_value: CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: ShowDeserializer::TOOL_ID)
108
+ )
109
+ )
110
+ end
111
+ end
112
+
113
+ class UpdateUsernameDeserializer < CommandTower::Deserializers::ApplicationDeserializer
114
+ Input = Data.define(:id, :username, :scope_value)
115
+
116
+ def call(params)
117
+ id = IdentityId.coerce(fetch_param(params, :id).value)
118
+ return failure(errors: { message: "invalid_id" }) if id.nil?
119
+
120
+ username = fetch_param(params, :username).value.to_s.strip
121
+ return failure(errors: { message: "missing_required_fields" }) if username.blank?
122
+
123
+ success(
124
+ Input.new(
125
+ id:,
126
+ username:,
127
+ scope_value: CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: ShowDeserializer::TOOL_ID)
128
+ )
129
+ )
130
+ end
131
+ end
132
+
133
+ class UpdateEmailDeserializer < CommandTower::Deserializers::ApplicationDeserializer
134
+ Input = Data.define(:id, :email, :scope_value)
135
+
136
+ def call(params)
137
+ id = IdentityId.coerce(fetch_param(params, :id).value)
138
+ return failure(errors: { message: "invalid_id" }) if id.nil?
139
+
140
+ email = fetch_param(params, :email).value.to_s.strip
141
+ return failure(errors: { message: "missing_required_fields" }) if email.blank?
142
+
143
+ success(
144
+ Input.new(
145
+ id:,
146
+ email:,
147
+ scope_value: CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: ShowDeserializer::TOOL_ID)
148
+ )
149
+ )
150
+ end
151
+ end
152
+
153
+ class UpdateEmailValidationDeserializer < CommandTower::Deserializers::ApplicationDeserializer
154
+ Input = Data.define(:id, :email_validated, :scope_value)
155
+
156
+ def call(params)
157
+ id = IdentityId.coerce(fetch_param(params, :id).value)
158
+ return failure(errors: { message: "invalid_id" }) if id.nil?
159
+
160
+ email_validated = coerce_boolean(fetch_param(params, :emailValidated, :email_validated).value)
161
+ return failure(errors: { message: "invalid_email_validated" }) if email_validated.nil?
162
+
163
+ success(
164
+ Input.new(
165
+ id:,
166
+ email_validated:,
167
+ scope_value: CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: ShowDeserializer::TOOL_ID)
168
+ )
169
+ )
170
+ end
171
+
172
+ private
173
+
174
+ def coerce_boolean(raw)
175
+ case raw
176
+ when true, false
177
+ raw
178
+ when "true", "1", 1
179
+ true
180
+ when "false", "0", 0
181
+ false
182
+ end
183
+ end
184
+ end
185
+
186
+ class UpdateRolesDeserializer < CommandTower::Deserializers::ApplicationDeserializer
187
+ Input = Data.define(:id, :roles, :scope_value)
188
+
189
+ def call(params)
190
+ id = IdentityId.coerce(fetch_param(params, :id).value)
191
+ return failure(errors: { message: "invalid_id" }) if id.nil?
192
+
193
+ raw = fetch_param(params, :roles).value
194
+ unless raw.is_a?(Array)
195
+ return failure(errors: { message: "invalid_roles" })
196
+ end
197
+
198
+ roles = raw.map { |value| value.to_s.strip }
199
+ if roles.any?(&:blank?)
200
+ return failure(errors: { message: "invalid_roles" })
201
+ end
202
+
203
+ success(
204
+ Input.new(
205
+ id:,
206
+ roles:,
207
+ scope_value: CommandTower::Deserializers::Admin::ScopeParameter.extract(
208
+ params,
209
+ tool_id: ShowDeserializer::TOOL_ID
210
+ )
211
+ )
212
+ )
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Deserializers
5
+ module Audit
6
+ module Events
7
+ class UserListDeserializer < CommandTower::Deserializers::ApplicationDeserializer
8
+ Input = Data.define(:limit, :offset, :actions, :occurred_after, :occurred_before, :subject_types)
9
+ DEFAULT_LIMIT = 50
10
+ MAX_LIMIT = 100
11
+ MAX_MULTI = 50
12
+ EVENT_NAME = /\A[a-z][a-z0-9_]*\z/
13
+ SUBJECT_TYPE = /\A[A-Z][A-Za-z0-9_:]*\z/
14
+
15
+ def call(params)
16
+ limit = pagination_integer(fetch_param(params, :limit).value, default: DEFAULT_LIMIT, minimum: 1, maximum: MAX_LIMIT)
17
+ return failure(errors: { message: "invalid_limit" }) if limit.nil?
18
+
19
+ offset = pagination_integer(fetch_param(params, :offset).value, default: 0, minimum: 0)
20
+ return failure(errors: { message: "invalid_offset" }) if offset.nil?
21
+
22
+ actions = optional_event_names(params)
23
+ return actions if deserializer_result?(actions)
24
+
25
+ occurred_after = optional_time(fetch_param(params, :occurredAfter, :occurred_after).value, field: "occurredAfter")
26
+ return occurred_after if deserializer_result?(occurred_after)
27
+
28
+ occurred_before = optional_time(fetch_param(params, :occurredBefore, :occurred_before).value, field: "occurredBefore")
29
+ return occurred_before if deserializer_result?(occurred_before)
30
+
31
+ subject_types = optional_subject_types(params)
32
+ return subject_types if deserializer_result?(subject_types)
33
+
34
+ success(Input.new(limit:, offset:, actions:, occurred_after:, occurred_before:, subject_types:))
35
+ end
36
+
37
+ private
38
+
39
+ def pagination_integer(raw, default:, minimum:, maximum: nil)
40
+ return default if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
41
+
42
+ value = raw.is_a?(Integer) ? raw : (Integer(raw, 10) if raw.is_a?(String) && /\A-?\d+\z/.match?(raw.strip))
43
+ return if value.nil? || value < minimum || (maximum && value > maximum)
44
+
45
+ value
46
+ rescue ArgumentError
47
+ nil
48
+ end
49
+
50
+ # Prefer eventNames[] / event_names[]; singular eventName remains a one-element alias.
51
+ def optional_event_names(params)
52
+ multi = fetch_param(params, :eventNames, :event_names).value
53
+ singular = fetch_param(params, :eventName, :event_name).value
54
+ normalize_token_list(
55
+ multi,
56
+ singular,
57
+ regex: EVENT_NAME,
58
+ invalid_message: "invalid_eventName",
59
+ too_many_message: "invalid_eventNames"
60
+ )
61
+ end
62
+
63
+ def optional_subject_types(params)
64
+ multi = fetch_param(params, :subjectTypes, :subject_types).value
65
+ singular = fetch_param(params, :subjectType, :subject_type).value
66
+ normalize_token_list(
67
+ multi,
68
+ singular,
69
+ regex: SUBJECT_TYPE,
70
+ invalid_message: "invalid_subjectType",
71
+ too_many_message: "invalid_subjectTypes"
72
+ )
73
+ end
74
+
75
+ def normalize_token_list(multi, singular, regex:, invalid_message:, too_many_message:)
76
+ raw_values =
77
+ if multi.nil? || (multi.is_a?(String) && multi.strip.empty?)
78
+ singular.nil? || (singular.is_a?(String) && singular.strip.empty?) ? [] : [singular]
79
+ elsif multi.is_a?(Array)
80
+ multi
81
+ elsif multi.is_a?(String)
82
+ [multi]
83
+ else
84
+ return failure(errors: { message: too_many_message })
85
+ end
86
+
87
+ tokens = []
88
+ raw_values.each do |raw|
89
+ next if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
90
+ return failure(errors: { message: invalid_message }) unless raw.is_a?(String) && regex.match?(raw.strip)
91
+
92
+ tokens << raw.strip
93
+ end
94
+
95
+ tokens = tokens.uniq
96
+ return failure(errors: { message: too_many_message }) if tokens.length > MAX_MULTI
97
+
98
+ tokens
99
+ end
100
+
101
+ def optional_time(raw, field:)
102
+ return if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
103
+ return failure(errors: { message: "invalid_#{field}" }) unless raw.is_a?(String)
104
+
105
+ Time.iso8601(raw.strip)
106
+ rescue ArgumentError
107
+ failure(errors: { message: "invalid_#{field}" })
108
+ end
109
+
110
+ def optional_positive_integer(raw, field:)
111
+ return if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
112
+
113
+ value = raw.is_a?(Integer) ? raw : (Integer(raw, 10) if raw.is_a?(String) && /\A\d+\z/.match?(raw.strip))
114
+ return failure(errors: { message: "invalid_#{field}" }) unless value&.positive?
115
+
116
+ value
117
+ rescue ArgumentError
118
+ failure(errors: { message: "invalid_#{field}" })
119
+ end
120
+ end
121
+
122
+ class AdminListDeserializer < UserListDeserializer
123
+ Input = Data.define(
124
+ :limit,
125
+ :offset,
126
+ :actions,
127
+ :occurred_after,
128
+ :occurred_before,
129
+ :subject_types,
130
+ :affected_user_id,
131
+ :actor_user_id,
132
+ :originating_administrator_id,
133
+ :attribution_mode,
134
+ :scope_value
135
+ )
136
+ TOOL_ID = "audit"
137
+
138
+ def call(params)
139
+ base = super
140
+ return base unless base.success?
141
+
142
+ affected_user_id = optional_positive_integer(
143
+ fetch_param(params, :affectedUserId, :affected_user_id).value,
144
+ field: "affectedUserId"
145
+ )
146
+ return affected_user_id if deserializer_result?(affected_user_id)
147
+
148
+ actor_user_id = optional_positive_integer(
149
+ fetch_param(params, :actorUserId, :actor_user_id).value,
150
+ field: "actorUserId"
151
+ )
152
+ return actor_user_id if deserializer_result?(actor_user_id)
153
+
154
+ originating_administrator_id = optional_positive_integer(
155
+ fetch_param(params, :originatingAdministratorId, :originating_administrator_id).value,
156
+ field: "originatingAdministratorId"
157
+ )
158
+ return originating_administrator_id if deserializer_result?(originating_administrator_id)
159
+
160
+ attribution_mode = optional_attribution_mode(fetch_param(params, :attributionMode, :attribution_mode).value)
161
+ return attribution_mode if deserializer_result?(attribution_mode)
162
+
163
+ scope_value = CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: TOOL_ID)
164
+
165
+ success(
166
+ Input.new(
167
+ limit: base.input.limit,
168
+ offset: base.input.offset,
169
+ actions: base.input.actions,
170
+ occurred_after: base.input.occurred_after,
171
+ occurred_before: base.input.occurred_before,
172
+ subject_types: base.input.subject_types,
173
+ affected_user_id:,
174
+ actor_user_id:,
175
+ originating_administrator_id:,
176
+ attribution_mode:,
177
+ scope_value:
178
+ )
179
+ )
180
+ end
181
+
182
+ private
183
+
184
+ def optional_attribution_mode(raw)
185
+ return if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
186
+ unless raw.is_a?(String) && CommandTower::Audit::Event::ATTRIBUTION_MODES.include?(raw.strip)
187
+ return failure(errors: { message: "invalid_attributionMode" })
188
+ end
189
+
190
+ raw.strip
191
+ end
192
+ end
193
+
194
+ class ShowDeserializer < CommandTower::Deserializers::ApplicationDeserializer
195
+ Input = Data.define(:id, :scope_value)
196
+ TOOL_ID = "audit"
197
+
198
+ def call(params)
199
+ id = optional_positive_integer(fetch_param(params, :id).value, field: "id")
200
+ return id if deserializer_result?(id)
201
+ return failure(errors: { message: "invalid_id" }) if id.nil?
202
+
203
+ scope_value = CommandTower::Deserializers::Admin::ScopeParameter.extract(params, tool_id: TOOL_ID)
204
+
205
+ success(Input.new(id:, scope_value:))
206
+ end
207
+
208
+ private
209
+
210
+ def optional_positive_integer(raw, field:)
211
+ return if raw.nil? || (raw.is_a?(String) && raw.strip.empty?)
212
+
213
+ value = raw.is_a?(Integer) ? raw : (Integer(raw, 10) if raw.is_a?(String) && /\A\d+\z/.match?(raw.strip))
214
+ return failure(errors: { message: "invalid_#{field}" }) unless value&.positive?
215
+
216
+ value
217
+ rescue ArgumentError
218
+ failure(errors: { message: "invalid_#{field}" })
219
+ end
220
+ end
221
+ end
222
+ end
223
+ end
224
+ end
@@ -22,6 +22,10 @@ module CommandTower
22
22
  BooleanType.instance
23
23
  end
24
24
 
25
+ def float
26
+ FloatType.instance
27
+ end
28
+
25
29
  def array(element_type)
26
30
  ArrayType.new(normalize(element_type))
27
31
  end
@@ -127,6 +131,29 @@ module CommandTower
127
131
  end
128
132
  end
129
133
 
134
+ class FloatType < Type
135
+ include Singleton
136
+
137
+ def call(value, path:)
138
+ return value if value.is_a?(Float)
139
+ return value.to_f if value.is_a?(Integer)
140
+
141
+ if value.is_a?(String)
142
+ begin
143
+ return Float(value)
144
+ rescue ArgumentError
145
+ reject!(path, value, "float", "not a numeric string")
146
+ end
147
+ end
148
+
149
+ reject!(path, value, "float", "expected Float")
150
+ end
151
+
152
+ def expected_label
153
+ "float"
154
+ end
155
+ end
156
+
130
157
  class ResultInstanceType < Type
131
158
  def initialize(klass)
132
159
  @klass = klass
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Auth
6
+ class AdminUnavailableDuringImpersonationError < CommandTower::Errors::ApplicationError
7
+ def code
8
+ "admin_unavailable_during_impersonation"
9
+ end
10
+
11
+ def message
12
+ "Admin tools are unavailable while impersonating a user."
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Auth
6
+ class DefaultMembershipAssignmentError < CommandTower::Errors::ApplicationError
7
+ def code
8
+ "default_membership_assignment_failed"
9
+ end
10
+
11
+ def message
12
+ "Default membership role could not be assigned"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Auth
6
+ class ImpersonationSessionExpiredError < CommandTower::Errors::UnauthorizedError
7
+ def code
8
+ "impersonation_session_expired"
9
+ end
10
+
11
+ def message
12
+ "Impersonation session is no longer valid"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Auth
6
+ class ImpersonationSessionMissingError < CommandTower::Errors::ValidationError
7
+ def initialize
8
+ super(details: { impersonation: "not_active" })
9
+ end
10
+
11
+ def code
12
+ "impersonation_session_missing"
13
+ end
14
+
15
+ def message
16
+ "Impersonation is not active"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Auth
6
+ class NestedImpersonationError < CommandTower::Errors::ForbiddenError
7
+ def code
8
+ "nested_impersonation_forbidden"
9
+ end
10
+
11
+ def message
12
+ "Nested impersonation is forbidden"
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ module Auth
6
+ class SelfImpersonationError < CommandTower::Errors::ValidationError
7
+ def initialize
8
+ super(details: { target: "cannot_impersonate_self" })
9
+ end
10
+
11
+ def code
12
+ "self_impersonation_forbidden"
13
+ end
14
+
15
+ def message
16
+ "Cannot impersonate yourself"
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Errors
5
+ class ContinuationExhaustedError < ApplicationError
6
+ def code
7
+ "continuation_exhausted"
8
+ end
9
+
10
+ def message
11
+ attempt = details.is_a?(Hash) ? details[:attempt] : nil
12
+ max_attempts = details.is_a?(Hash) ? details[:max_attempts] : nil
13
+ if attempt && max_attempts
14
+ "delayed continuation exhausted after attempt #{attempt} of #{max_attempts}"
15
+ else
16
+ "delayed continuation exhausted"
17
+ end
18
+ end
19
+
20
+ def log_level
21
+ :error
22
+ end
23
+ end
24
+ end
25
+ end
@@ -1,4 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module CommandTower
2
4
  class ApplicationJob < ActiveJob::Base
5
+ include CommandTower::Execution::JobBoundary
6
+
7
+ def execute_workflow(workflow_class, **args)
8
+ workflow_class.call_from_job(job: self, **args)
9
+ end
3
10
  end
4
11
  end