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
@@ -3,6 +3,8 @@
3
3
  module CommandTower
4
4
  module Services
5
5
  class ApplicationService < CommandTower::ServiceBase
6
+ include CommandTower::Transactional
7
+
6
8
  on_argument_validation :fail_early
7
9
 
8
10
  def self.inherited(subclass)
@@ -16,6 +18,41 @@ module CommandTower
16
18
  ServiceResult.from_interactor_context(interactor_context)
17
19
  end
18
20
  end
21
+
22
+ private
23
+
24
+ def acceptable_success_result?(result)
25
+ return true if result.nil?
26
+ return result.success? if result.is_a?(ServiceResult)
27
+
28
+ true
29
+ end
30
+
31
+ def acceptable_failure_result?(result)
32
+ return true if result.is_a?(ServiceResult) && result.failure?
33
+ return true if result.is_a?(CommandTower::Errors::ApplicationError)
34
+
35
+ false
36
+ end
37
+
38
+ def fail_transaction_type_error_message
39
+ "fail_transaction! requires a failed ServiceResult or ApplicationError"
40
+ end
41
+
42
+ def invalid_success_result_message(result)
43
+ "transaction block returned a non-success ServiceResult; " \
44
+ "use fail_transaction!(result) for expected failures (got #{result.class})"
45
+ end
46
+
47
+ def handle_transaction_failure(result)
48
+ context.fail!(application_error: application_error_from_transaction_result(result))
49
+ end
50
+
51
+ def application_error_from_transaction_result(result)
52
+ return result if result.is_a?(CommandTower::Errors::ApplicationError)
53
+
54
+ result.errors.first || CommandTower::Errors::InternalError.new
55
+ end
19
56
  end
20
57
  end
21
58
  end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Audit
6
+ module Events
7
+ # Projects safe filter-option metadata from the finalized audit registry.
8
+ # Not authorization — scoped catalogs only (Admin vs Me user_history).
9
+ class FilterOptions < CommandTower::Services::ApplicationService
10
+ ATTRIBUTION_MODE_LABELS = {
11
+ "self_service" => "Self-service",
12
+ "admin_direct" => "Admin action",
13
+ "impersonation" => "Impersonation",
14
+ "system" => "System"
15
+ }.freeze
16
+
17
+ validate :viewer_scope, is_a: Symbol, required: true
18
+
19
+ def call
20
+ unless %i[admin me].include?(viewer_scope)
21
+ raise CommandTower::Errors::ValidationError, "viewer_scope must be :admin or :me"
22
+ end
23
+
24
+ context.event_names = project_event_names
25
+ context.subject_types = project_subject_types
26
+ context.attribution_modes = project_attribution_modes
27
+ end
28
+
29
+ private
30
+
31
+ def scoped_definitions
32
+ definitions = CommandTower.config.registry.audit.definitions
33
+ if viewer_scope == :admin
34
+ definitions
35
+ else
36
+ definitions.select { |_name, definition| definition.user_history == true }
37
+ end
38
+ end
39
+
40
+ def project_event_names
41
+ scoped_definitions.sort_by { |name, _| name }.map do |name, definition|
42
+ label = definition.label.to_s.strip
43
+ label = name if label.empty?
44
+ {
45
+ value: name,
46
+ label:,
47
+ tags: Array(definition.tags)
48
+ }
49
+ end
50
+ end
51
+
52
+ def project_subject_types
53
+ scoped_definitions.filter_map do |_name, definition|
54
+ token = definition.subject_type.to_s.strip
55
+ next if token.empty?
56
+
57
+ { value: token, label: token }
58
+ end.uniq { |entry| entry[:value] }.sort_by { |entry| entry[:value] }
59
+ end
60
+
61
+ def project_attribution_modes
62
+ return [] unless viewer_scope == :admin
63
+
64
+ CommandTower::Audit::Event::ATTRIBUTION_MODES.map do |mode|
65
+ {
66
+ value: mode,
67
+ label: ATTRIBUTION_MODE_LABELS.fetch(mode, mode.tr("_", " ").split.map(&:capitalize).join(" "))
68
+ }
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Audit
6
+ module Events
7
+ class List < CommandTower::Services::ApplicationService
8
+ VIEWER_SCOPES = %i[user admin].freeze
9
+
10
+ validate :viewer_scope, is_one: VIEWER_SCOPES, required: true
11
+ validate :limit, is_a: Integer, required: true, gte: 1, lte: 100
12
+ validate :offset, is_a: Integer, required: true, gt: -1
13
+ validate :affected_user_id, is_a: Integer, required: false
14
+ validate :actor_user_id, is_a: Integer, required: false
15
+ validate :originating_administrator_id, is_a: Integer, required: false
16
+ validate :actions, is_a: Array, required: false
17
+ validate :subject_types, is_a: Array, required: false
18
+ validate :attribution_mode, is_a: String, required: false
19
+ validate :occurred_after, is_a: [Time, ActiveSupport::TimeWithZone], required: false
20
+ validate :occurred_before, is_a: [Time, ActiveSupport::TimeWithZone], required: false
21
+ validate :principal, is_a: User, required: false
22
+ validate :scope_context, is_a: [CommandTower::AdminScope::ScopeContext, NilClass], required: false
23
+
24
+ def call
25
+ if user_scope? && affected_user_id.nil?
26
+ return context.fail!(application_error: CommandTower::Errors::InternalError.new)
27
+ end
28
+
29
+ relation = CommandTower::Audit::Event.order(occurred_at: :desc, id: :desc)
30
+ relation = apply_viewer_scope(relation)
31
+ relation = apply_admin_scope(relation)
32
+ relation = apply_present_filters(relation)
33
+
34
+ context.events = relation.limit(limit).offset(offset).to_a
35
+ context.pagination = { limit:, offset:, total_count: relation.count }
36
+ end
37
+
38
+ private
39
+
40
+ def user_scope?
41
+ viewer_scope == :user
42
+ end
43
+
44
+ def apply_viewer_scope(relation)
45
+ return relation.where(affected_user_id:, user_history: true) if user_scope?
46
+
47
+ relation
48
+ end
49
+
50
+ def apply_admin_scope(relation)
51
+ return relation unless admin_scope?
52
+
53
+ CommandTower::AdminScope::ApplyAuditScoping.call(
54
+ relation:,
55
+ scope_context:,
56
+ principal: principal || scope_context_principal
57
+ )
58
+ end
59
+
60
+ def admin_scope?
61
+ viewer_scope == :admin && scope_context.present?
62
+ end
63
+
64
+ def scope_context_principal
65
+ principal
66
+ end
67
+
68
+ def apply_present_filters(relation)
69
+ action_list = Array(actions).compact_blank
70
+ relation = relation.where(action: action_list) if action_list.any?
71
+ type_list = Array(subject_types).compact_blank
72
+ relation = relation.where(subject_type: type_list) if type_list.any?
73
+ relation = relation.where(occurred_at: occurred_after..) if occurred_after
74
+ relation = relation.where(occurred_at: ..occurred_before) if occurred_before
75
+ return relation if user_scope?
76
+
77
+ relation = relation.where(affected_user_id:) if affected_user_id
78
+ relation = relation.where(actor_user_id:) if actor_user_id
79
+ relation = relation.where(originating_administrator_id:) if originating_administrator_id
80
+ relation = relation.where(attribution_mode:) if attribution_mode.present?
81
+ relation
82
+ end
83
+ end
84
+
85
+ class Show < CommandTower::Services::ApplicationService
86
+ VIEWER_SCOPES = %i[user admin].freeze
87
+
88
+ validate :viewer_scope, is_one: VIEWER_SCOPES, required: true
89
+ validate :id, is_a: Integer, required: true
90
+ validate :affected_user_id, is_a: Integer, required: false
91
+ validate :principal, is_a: User, required: false
92
+ validate :scope_context, is_a: [CommandTower::AdminScope::ScopeContext, NilClass], required: false
93
+
94
+ def call
95
+ if viewer_scope == :user && affected_user_id.nil?
96
+ return context.fail!(application_error: CommandTower::Errors::InternalError.new)
97
+ end
98
+
99
+ relation = CommandTower::Audit::Event.where(id:)
100
+ if viewer_scope == :user
101
+ relation = relation.where(affected_user_id:, user_history: true)
102
+ elsif scope_context.present?
103
+ relation = CommandTower::AdminScope::ApplyAuditScoping.call(
104
+ relation:,
105
+ scope_context:,
106
+ principal: principal || scope_context_principal
107
+ )
108
+ end
109
+
110
+ event = relation.first
111
+ if event.nil?
112
+ return context.fail!(application_error: CommandTower::Errors::NotFoundError.new)
113
+ end
114
+
115
+ context.event = event
116
+ end
117
+
118
+ private
119
+
120
+ def scope_context_principal
121
+ principal
122
+ end
123
+ end
124
+
125
+ class Project < CommandTower::Services::ApplicationService
126
+ validate :event, is_a: CommandTower::Audit::Event, required: true
127
+ validate :viewer, is_one: %i[user admin], required: true
128
+
129
+ def call
130
+ changes = duplicate_hash(event.change_set)
131
+ mask_sensitive_changes!(changes)
132
+
133
+ context.projection = {
134
+ id: event.id,
135
+ event_name: event.action,
136
+ event_label: registry_label_for(event.action),
137
+ occurred_at: event.occurred_at,
138
+ attribution_mode: event.attribution_mode,
139
+ actor_user_id: event.actor_user_id,
140
+ affected_user_id: event.affected_user_id,
141
+ subject_type: event.subject_type,
142
+ subject_id: event.subject_id,
143
+ subject_label: event.subject_label,
144
+ impersonation_active: event.impersonation_active,
145
+ originating_administrator_id: event.originating_administrator_id,
146
+ changes:,
147
+ metadata: duplicate_hash(event.metadata)
148
+ }
149
+ end
150
+
151
+ private
152
+
153
+ def registry_label_for(action)
154
+ registry = CommandTower.config.registry.audit
155
+ return "" unless registry.registered?(action)
156
+
157
+ registry.fetch(action).label.to_s
158
+ end
159
+
160
+ def mask_sensitive_changes!(changes)
161
+ sensitivity_keys.each do |key|
162
+ entry = changes[key] || changes[key.to_sym]
163
+ next unless entry.is_a?(Hash)
164
+
165
+ changes.delete(key)
166
+ changes.delete(key.to_sym)
167
+ changes[key] = {
168
+ "from" => CommandTower::Audit::Masking.value(field: key, raw: hash_value(entry, "from")),
169
+ "to" => CommandTower::Audit::Masking.value(field: key, raw: hash_value(entry, "to"))
170
+ }
171
+ end
172
+ end
173
+
174
+ def sensitivity_keys
175
+ snapshot = Array(event.sensitive_fields).map(&:to_s)
176
+ snapshot | current_sensitive_fields
177
+ end
178
+
179
+ def current_sensitive_fields
180
+ registry = CommandTower.config.registry.audit
181
+ return [] unless registry.registered?(event.action)
182
+
183
+ Array(registry.fetch(event.action).sensitive_fields).map(&:to_s)
184
+ end
185
+
186
+ def duplicate_hash(value)
187
+ return {} unless value.is_a?(Hash)
188
+
189
+ value.deep_dup
190
+ end
191
+
192
+ def hash_value(entry, key)
193
+ entry[key] || entry[key.to_sym]
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Auth
6
+ class AssignDefaultMembershipRole < CommandTower::Services::ApplicationService
7
+ validate :user, is_a: User, required: true
8
+
9
+ def call
10
+ role_name = CommandTower.config.authorization.default_membership_role
11
+ if role_name.nil?
12
+ context.user = user
13
+ return
14
+ end
15
+
16
+ unless role_present?(role_name)
17
+ context.fail!(
18
+ application_error: CommandTower::Errors::Auth::DefaultMembershipAssignmentError.new(
19
+ details: { role: role_name }
20
+ )
21
+ )
22
+ end
23
+
24
+ user.roles = (Array(user.roles) + [role_name.to_s]).uniq
25
+ unless user.save
26
+ context.fail!(
27
+ application_error: CommandTower::Errors::Auth::DefaultMembershipAssignmentError.new(
28
+ details: user.errors.to_hash
29
+ )
30
+ )
31
+ end
32
+
33
+ context.user = user
34
+ end
35
+
36
+ private
37
+
38
+ def role_present?(role_name)
39
+ role_name.to_s.strip.present? && CommandTower::Authorization::Role.roles[role_name].present?
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -6,6 +6,7 @@ module CommandTower
6
6
  class AuthenticateSession < CommandTower::Services::ApplicationService
7
7
  validate :request_context, is_a: CommandTower::Auth::RequestContext, required: true
8
8
  validate :bypass_email_validation, is_one: [true, false], default: false
9
+ validate :overlay_mode, is_a: [Symbol, String, NilClass], required: false
9
10
 
10
11
  def call
11
12
  request = request_context.request
@@ -39,7 +40,23 @@ module CommandTower
39
40
  bypass_email_validation:
40
41
  )
41
42
  if auth_result.success?
42
- context.user = auth_result.user
43
+ established = CommandTower::Impersonation::EstablishIdentity.call(
44
+ actor: auth_result.user,
45
+ impersonation_session_id: auth_result.impersonation_session_id,
46
+ mode: resolved_overlay_mode
47
+ )
48
+ if established.expired?
49
+ fail_authentication!(
50
+ CommandTower::Errors::Auth::ImpersonationSessionExpiredError.new,
51
+ token_source:,
52
+ impersonation_session_expired: true
53
+ )
54
+ return
55
+ end
56
+
57
+ context.user = established.user
58
+ context.actor_user = established.actor
59
+ context.impersonation_session_id = auth_result.impersonation_session_id
43
60
  context.token_expires_at = auth_result.expires_at
44
61
  context.generated_token = auth_result.generated_token if with_reset
45
62
  context.service_metadata = observation_metadata(
@@ -53,7 +70,8 @@ module CommandTower
53
70
  if auth_result.status == 412
54
71
  fail_authentication!(
55
72
  CommandTower::Errors::Auth::EmailVerificationRequiredError.new,
56
- token_source:
73
+ token_source:,
74
+ email_verification_required: true
57
75
  )
58
76
  return
59
77
  end
@@ -63,24 +81,33 @@ module CommandTower
63
81
 
64
82
  private
65
83
 
66
- def fail_authentication!(error, token_source:)
84
+ def fail_authentication!(error, token_source:, email_verification_required: false, impersonation_session_expired: false)
67
85
  context.service_metadata = observation_metadata(
68
86
  token_source:,
69
87
  authentication_failed: true,
70
- cookie_authenticated: token_source == :cookie
88
+ cookie_authenticated: token_source == :cookie,
89
+ email_verification_required:,
90
+ impersonation_session_expired:
71
91
  )
72
92
  context.fail!(application_error: error)
73
93
  end
74
94
 
75
- def observation_metadata(token_source:, authentication_failed:, cookie_authenticated: nil)
95
+ def observation_metadata(token_source:, authentication_failed:, cookie_authenticated: nil, email_verification_required: false, impersonation_session_expired: false)
76
96
  {
77
97
  token_source:,
78
98
  authentication_mechanism: :jwt,
79
99
  authentication_failed:,
80
- cookie_authenticated: cookie_authenticated.nil? ? token_source == :cookie : cookie_authenticated
100
+ cookie_authenticated: cookie_authenticated.nil? ? token_source == :cookie : cookie_authenticated,
101
+ email_verification_required:,
102
+ impersonation_session_expired:
81
103
  }
82
104
  end
83
105
 
106
+ def resolved_overlay_mode
107
+ mode = overlay_mode.nil? ? :enforce : overlay_mode.to_sym
108
+ %i[enforce capture].include?(mode) ? mode : :enforce
109
+ end
110
+
84
111
  def csrf_error_for(message)
85
112
  case message
86
113
  when "csrf_mismatch"
@@ -31,6 +31,8 @@ module CommandTower
31
31
 
32
32
  user.update(email_validated: true)
33
33
 
34
+ audit(:email_verified, subject: user, affected_user: user, changes: {})
35
+
34
36
  context.user = user.reload
35
37
  context.message = VERIFIED_MESSAGE
36
38
  end
@@ -29,9 +29,19 @@ module CommandTower
29
29
  user.password = password
30
30
  user.password_confirmation = password_confirmation
31
31
 
32
- unless user.save
33
- log_error("Failed to update the password for user [#{user.id}]")
34
- reject!(user.errors.to_hash.transform_values { Array(_1).join(", ") })
32
+ ActiveRecord::Base.transaction do
33
+ unless user.save
34
+ log_error("Failed to update the password for user [#{user.id}]")
35
+ reject!(user.errors.to_hash.transform_values { Array(_1).join(", ") })
36
+ end
37
+
38
+ audit(
39
+ :password_changed,
40
+ subject: user,
41
+ affected_user: user,
42
+ changes: {},
43
+ metadata: { mechanism: "reset" }
44
+ )
35
45
  end
36
46
 
37
47
  log_info("Password reset completed for user [#{user.id}]")
@@ -10,7 +10,10 @@ module CommandTower
10
10
 
11
11
  def call
12
12
  user = User.where(username: identifier).or(User.where(email: identifier)).first
13
- invalid_credentials! if user.nil?
13
+ if user.nil?
14
+ audit_login_failed(affected_user: nil, outcome: "unknown_identifier")
15
+ invalid_credentials!
16
+ end
14
17
 
15
18
  if user.authenticate(password)
16
19
  user.successful_login += 1
@@ -20,6 +23,7 @@ module CommandTower
20
23
  user.password_consecutive_fail += 1
21
24
  user.save
22
25
  log_warn("Valid identifier. Incorrect password. Consecutive Password failures: #{user.password_consecutive_fail}")
26
+ audit_login_failed(affected_user: user, outcome: "invalid_password")
23
27
  invalid_credentials!
24
28
  end
25
29
 
@@ -34,6 +38,10 @@ module CommandTower
34
38
  context.fail!(application_error: CommandTower::Errors::Auth::InvalidCredentialsError.new)
35
39
  end
36
40
 
41
+ def audit_login_failed(affected_user:, outcome:)
42
+ audit(:login_failed, affected_user:, changes: {}, metadata: { outcome: })
43
+ end
44
+
37
45
  def token_expires_at_from_ttl
38
46
  CommandTower.config.jwt.ttl.from_now.to_time.to_s
39
47
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Auth
6
+ module PrincipalCapabilities
7
+ # Projects possessed frontend-projectable capability ids for the effective user.
8
+ # Derives from composed RBAC grants ∩ curated principal_capabilities registry.
9
+ class Project < CommandTower::Services::ApplicationService
10
+ validate :user, is_a: User, required: true
11
+
12
+ def call
13
+ context.principal_capability_ids = possessed_capability_ids
14
+ end
15
+
16
+ private
17
+
18
+ def possessed_capability_ids
19
+ definitions = CommandTower.config.registry.principal_capabilities.definitions.values
20
+ ids =
21
+ if allow_everything?
22
+ definitions.map(&:id)
23
+ else
24
+ definitions.select { |definition| granted?(definition.required_entity) }.map(&:id)
25
+ end
26
+ ids.uniq.sort
27
+ end
28
+
29
+ def allow_everything?
30
+ Array(user.roles).any? do |role_name|
31
+ role = CommandTower::Authorization::Role.roles[role_name]
32
+ role&.allow_everything
33
+ end
34
+ end
35
+
36
+ def granted?(entity_name)
37
+ Array(user.roles).any? do |role_name|
38
+ role = CommandTower::Authorization::Role.roles[role_name]
39
+ next false unless role
40
+
41
+ role.allow_everything || role.entities.any? { |entity| entity.name.to_s == entity_name }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Impersonation
6
+ class Create < CommandTower::Services::ApplicationService
7
+ validate :actor, is_a: User, required: true
8
+ validate :target, is_a: User, required: true
9
+
10
+ def call
11
+ now = Time.current
12
+ idle = CommandTower.config.impersonation.idle_timeout
13
+ absolute = CommandTower.config.impersonation.absolute_timeout
14
+
15
+ session = CommandTower::Impersonation::Session.create!(
16
+ actor_user_id: actor.id,
17
+ target_user_id: target.id,
18
+ started_at: now,
19
+ last_activity_at: now,
20
+ idle_expires_at: now + idle,
21
+ absolute_expires_at: now + absolute
22
+ )
23
+
24
+ context.session = session
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Impersonation
6
+ class End < CommandTower::Services::ApplicationService
7
+ validate :session_id, is_a: String, required: true
8
+ validate :reason, is_a: String, required: true
9
+ validate :actor_user_id, is_a: Integer, required: false
10
+
11
+ def call
12
+ unless CommandTower::Impersonation::Session::END_REASONS.include?(reason)
13
+ return context.fail!(application_error: CommandTower::Errors::InternalError.new)
14
+ end
15
+
16
+ now = Time.current
17
+ relation = CommandTower::Impersonation::Session.open.where(id: session_id)
18
+ relation = relation.where(actor_user_id:) if actor_user_id
19
+ updated = relation.update_all(ended_at: now, end_reason: reason, updated_at: now)
20
+
21
+ session = CommandTower::Impersonation::Session.find_by(id: session_id)
22
+ context.session = session
23
+ context.ended = updated.positive?
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Services
5
+ module Impersonation
6
+ class RecordActivity < CommandTower::Services::ApplicationService
7
+ validate :session_id, is_a: String, required: true
8
+
9
+ def call
10
+ now = Time.current
11
+ idle = CommandTower.config.impersonation.idle_timeout
12
+ CommandTower::Impersonation::Session.transaction do
13
+ session = CommandTower::Impersonation::Session.lock.find_by(id: session_id)
14
+ if session.nil? || !session.open? || session.expired?(at: now)
15
+ context.refreshed = false
16
+ return
17
+ end
18
+
19
+ session.update!(
20
+ last_activity_at: now,
21
+ idle_expires_at: now + idle
22
+ )
23
+ context.refreshed = true
24
+ context.session = session
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end