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,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "class_composer"
4
+
5
+ module CommandTower
6
+ module Configuration
7
+ module Registry
8
+ module AdminWorkspace
9
+ class ToolDefinition
10
+ include ClassComposer::Generator
11
+
12
+ ROUTE = %r{\A/admin(/[a-z][a-z0-9_-]*)+\z}
13
+
14
+ DESCRIPTION_MAX_LENGTH = 160
15
+
16
+ add_composer :label,
17
+ desc: "Human-readable tool name for dashboard and navigation",
18
+ allowed: String,
19
+ default: ""
20
+
21
+ add_composer :description,
22
+ desc: "Short launcher blurb: what the tool does (presentation only; soft target ≤100 chars)",
23
+ allowed: String,
24
+ default: ""
25
+
26
+ add_composer :route,
27
+ desc: "Frontend Admin Workspace path (navigation metadata, not an engine API path)",
28
+ allowed: String,
29
+ default: ""
30
+
31
+ add_composer :group,
32
+ desc: "Presentation group token (open set; domain-blind)",
33
+ allowed: [String, Symbol],
34
+ default: ""
35
+
36
+ add_composer :sort_order,
37
+ desc: "Order within and across groups (lower first)",
38
+ allowed: Integer,
39
+ default: 0
40
+
41
+ add_composer :required_entity,
42
+ desc: "RBAC entity name required for this tool to appear in the manifest",
43
+ allowed: [String, Symbol],
44
+ default: ""
45
+
46
+ add_composer :icon,
47
+ desc: "Optional icon token for the shared frontend",
48
+ allowed: [String, Symbol, NilClass],
49
+ default: nil
50
+
51
+ add_composer :scope_required,
52
+ desc: "When true, admin requests for this tool require a host scope parameter",
53
+ allowed: [TrueClass, FalseClass],
54
+ default: false
55
+
56
+ add_composer :scope_parameter,
57
+ desc: "Transport parameter name for the host scope value (snake_case token)",
58
+ allowed: String,
59
+ default: ""
60
+
61
+ add_composer :scope_label,
62
+ desc: "Human-readable label for the scope picker",
63
+ allowed: String,
64
+ default: ""
65
+
66
+ attr_accessor :owner, :id
67
+
68
+ def scope_required?
69
+ scope_required == true
70
+ end
71
+
72
+ def validate_definition!(name:)
73
+ self.id = name.to_s
74
+ self.owner = owner&.to_sym || :host
75
+ self.label = require_present_string!(label, field: "label", name:)
76
+ self.description = normalize_description!(name:)
77
+ self.route = require_route!(name:)
78
+ self.group = require_segment!(group, field: "group", name:)
79
+ self.required_entity = require_segment!(required_entity, field: "required_entity", name:)
80
+ self.icon = normalize_icon!(name:)
81
+ normalize_scope!(name:)
82
+ unless sort_order.is_a?(Integer)
83
+ raise CommandTower::AdminWorkspace::InvalidToolDefinitionError,
84
+ "admin workspace tool #{name} has invalid sort_order #{sort_order.inspect}"
85
+ end
86
+ end
87
+
88
+ private
89
+
90
+ def normalize_description!(name:)
91
+ text = description.to_s.strip
92
+ if text.length > DESCRIPTION_MAX_LENGTH
93
+ raise CommandTower::AdminWorkspace::InvalidToolDefinitionError,
94
+ "admin workspace tool #{name} description exceeds #{DESCRIPTION_MAX_LENGTH} characters"
95
+ end
96
+
97
+ text
98
+ end
99
+
100
+ def require_present_string!(value, field:, name:)
101
+ text = value.to_s.strip
102
+ if text.empty?
103
+ raise CommandTower::AdminWorkspace::InvalidToolDefinitionError,
104
+ "admin workspace tool #{name} is missing #{field}"
105
+ end
106
+
107
+ text
108
+ end
109
+
110
+ def require_route!(name:)
111
+ path = require_present_string!(route, field: "route", name:)
112
+ unless path.match?(ROUTE)
113
+ raise CommandTower::AdminWorkspace::InvalidToolDefinitionError,
114
+ "admin workspace tool #{name} has invalid route #{path.inspect}"
115
+ end
116
+
117
+ path
118
+ end
119
+
120
+ def require_segment!(value, field:, name:)
121
+ token = value.to_s.strip
122
+ unless token.match?(CommandTower::Events::SEGMENT)
123
+ raise CommandTower::AdminWorkspace::InvalidToolDefinitionError,
124
+ "admin workspace tool #{name} has invalid #{field} #{value.inspect}"
125
+ end
126
+
127
+ token
128
+ end
129
+
130
+ def normalize_icon!(name:)
131
+ return if icon.nil? || icon.to_s.strip.empty?
132
+
133
+ require_segment!(icon, field: "icon", name:)
134
+ end
135
+
136
+ def normalize_scope!(name:)
137
+ if scope_required?
138
+ self.scope_parameter = require_segment!(scope_parameter, field: "scope_parameter", name:)
139
+ self.scope_label = require_present_string!(scope_label, field: "scope_label", name:)
140
+ return
141
+ end
142
+
143
+ self.scope_parameter = ""
144
+ self.scope_label = ""
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,403 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "command_tower/configuration/registry/audit/event_definition"
4
+
5
+ module CommandTower
6
+ module Configuration
7
+ module Registry
8
+ module Audit
9
+ class Config
10
+ PLATFORM_EVENTS = {
11
+ user_registered: {
12
+ label: "User registered",
13
+ tags: %w[identity account registration],
14
+ enabled: true,
15
+ enablement_configurable: false,
16
+ user_history: true,
17
+ sensitive_fields: [],
18
+ allowed_changes: [],
19
+ retention: :permanent,
20
+ subject_required: true,
21
+ subject_type: "User",
22
+ affected_user_required: true,
23
+ global_visible_in_host_scope: true
24
+ },
25
+ role_assigned: {
26
+ label: "Role assigned",
27
+ tags: %w[identity rbac authorization],
28
+ enabled: true,
29
+ enablement_configurable: false,
30
+ user_history: true,
31
+ sensitive_fields: [],
32
+ allowed_changes: %i[role],
33
+ retention: :permanent,
34
+ subject_required: true,
35
+ subject_type: "User",
36
+ affected_user_required: true,
37
+ global_visible_in_host_scope: true
38
+ },
39
+ role_revoked: {
40
+ label: "Role revoked",
41
+ tags: %w[identity rbac authorization],
42
+ enabled: true,
43
+ enablement_configurable: false,
44
+ user_history: true,
45
+ sensitive_fields: [],
46
+ allowed_changes: %i[role],
47
+ retention: :permanent,
48
+ subject_required: true,
49
+ subject_type: "User",
50
+ affected_user_required: true,
51
+ global_visible_in_host_scope: true
52
+ },
53
+ password_changed: {
54
+ label: "Password changed",
55
+ tags: %w[identity password security],
56
+ enabled: true,
57
+ enablement_configurable: false,
58
+ user_history: true,
59
+ sensitive_fields: [],
60
+ allowed_changes: [],
61
+ retention: :permanent,
62
+ subject_required: true,
63
+ subject_type: "User",
64
+ affected_user_required: true,
65
+ global_visible_in_host_scope: true
66
+ },
67
+ email_verified: {
68
+ label: "Email verified",
69
+ tags: %w[identity verification email],
70
+ enabled: true,
71
+ enablement_configurable: false,
72
+ user_history: true,
73
+ sensitive_fields: [],
74
+ allowed_changes: [],
75
+ retention: :permanent,
76
+ subject_required: true,
77
+ subject_type: "User",
78
+ affected_user_required: true,
79
+ global_visible_in_host_scope: true
80
+ },
81
+ admin_user_name_changed: {
82
+ label: "Admin user name changed",
83
+ tags: %w[identity admin users],
84
+ enabled: true,
85
+ enablement_configurable: false,
86
+ user_history: true,
87
+ sensitive_fields: [],
88
+ allowed_changes: %i[first_name last_name],
89
+ retention: :permanent,
90
+ subject_required: true,
91
+ subject_type: "User",
92
+ affected_user_required: true,
93
+ global_visible_in_host_scope: true
94
+ },
95
+ admin_user_username_changed: {
96
+ label: "Admin user username changed",
97
+ tags: %w[identity admin users],
98
+ enabled: true,
99
+ enablement_configurable: false,
100
+ user_history: true,
101
+ sensitive_fields: [],
102
+ allowed_changes: %i[username],
103
+ retention: :permanent,
104
+ subject_required: true,
105
+ subject_type: "User",
106
+ affected_user_required: true,
107
+ global_visible_in_host_scope: true
108
+ },
109
+ admin_user_email_changed: {
110
+ label: "Admin user email changed",
111
+ tags: %w[identity admin users email],
112
+ enabled: true,
113
+ enablement_configurable: false,
114
+ user_history: true,
115
+ sensitive_fields: [],
116
+ allowed_changes: [],
117
+ retention: :permanent,
118
+ subject_required: true,
119
+ subject_type: "User",
120
+ affected_user_required: true,
121
+ global_visible_in_host_scope: true
122
+ },
123
+ admin_user_email_validation_changed: {
124
+ label: "Admin user email validation changed",
125
+ tags: %w[identity admin users verification email],
126
+ enabled: true,
127
+ enablement_configurable: false,
128
+ user_history: true,
129
+ sensitive_fields: [],
130
+ allowed_changes: %i[email_validated],
131
+ retention: :permanent,
132
+ subject_required: true,
133
+ subject_type: "User",
134
+ affected_user_required: true,
135
+ global_visible_in_host_scope: true
136
+ },
137
+ phone_updated: {
138
+ label: "Phone updated",
139
+ tags: %w[identity phone profile],
140
+ enabled: true,
141
+ enablement_configurable: false,
142
+ user_history: true,
143
+ sensitive_fields: %i[phone],
144
+ allowed_changes: %i[phone],
145
+ retention: :permanent,
146
+ subject_required: true,
147
+ subject_type: "User",
148
+ affected_user_required: true,
149
+ global_visible_in_host_scope: true
150
+ },
151
+ phone_cleared: {
152
+ label: "Phone cleared",
153
+ tags: %w[identity phone profile],
154
+ enabled: true,
155
+ enablement_configurable: false,
156
+ user_history: true,
157
+ sensitive_fields: %i[phone],
158
+ allowed_changes: %i[phone],
159
+ retention: :permanent,
160
+ subject_required: true,
161
+ subject_type: "User",
162
+ affected_user_required: true,
163
+ global_visible_in_host_scope: true
164
+ },
165
+ phone_verified: {
166
+ label: "Phone verified",
167
+ tags: %w[identity verification phone],
168
+ enabled: true,
169
+ enablement_configurable: false,
170
+ user_history: true,
171
+ sensitive_fields: [],
172
+ allowed_changes: [],
173
+ retention: :permanent,
174
+ subject_required: true,
175
+ subject_type: "User",
176
+ affected_user_required: true,
177
+ global_visible_in_host_scope: true
178
+ },
179
+ announcement_produced: {
180
+ label: "Announcement produced",
181
+ tags: %w[messaging announcement admin],
182
+ enabled: true,
183
+ enablement_configurable: false,
184
+ user_history: false,
185
+ sensitive_fields: [],
186
+ allowed_changes: [],
187
+ retention: :permanent,
188
+ subject_required: false,
189
+ affected_user_required: false
190
+ },
191
+ session_created: {
192
+ label: "Session created",
193
+ tags: %w[authentication session security],
194
+ enabled: true,
195
+ enablement_configurable: true,
196
+ user_history: false,
197
+ sensitive_fields: [],
198
+ allowed_changes: [],
199
+ retention: :ninety_days,
200
+ subject_required: false,
201
+ affected_user_required: true
202
+ },
203
+ session_cleared: {
204
+ label: "Session cleared",
205
+ tags: %w[authentication session security],
206
+ enabled: false,
207
+ enablement_configurable: true,
208
+ user_history: false,
209
+ sensitive_fields: [],
210
+ allowed_changes: [],
211
+ retention: :ninety_days,
212
+ subject_required: false,
213
+ affected_user_required: true
214
+ },
215
+ login_failed: {
216
+ label: "Login failed",
217
+ tags: %w[authentication login security],
218
+ enabled: false,
219
+ enablement_configurable: true,
220
+ user_history: false,
221
+ sensitive_fields: [],
222
+ allowed_changes: [],
223
+ retention: :ninety_days,
224
+ subject_required: false,
225
+ affected_user_required: false
226
+ },
227
+ impersonation_started: {
228
+ label: "Impersonation started",
229
+ tags: %w[authentication impersonation security],
230
+ enabled: true,
231
+ enablement_configurable: false,
232
+ user_history: true,
233
+ sensitive_fields: [],
234
+ allowed_changes: [],
235
+ retention: :permanent,
236
+ subject_required: true,
237
+ subject_type: "User",
238
+ affected_user_required: true,
239
+ global_visible_in_host_scope: true
240
+ },
241
+ impersonation_ended: {
242
+ label: "Impersonation ended",
243
+ tags: %w[authentication impersonation security],
244
+ enabled: true,
245
+ enablement_configurable: false,
246
+ user_history: true,
247
+ sensitive_fields: [],
248
+ allowed_changes: %i[reason],
249
+ retention: :permanent,
250
+ subject_required: true,
251
+ subject_type: "User",
252
+ affected_user_required: true,
253
+ global_visible_in_host_scope: true
254
+ }
255
+ }.freeze
256
+
257
+ def initialize
258
+ @definitions = {}
259
+ @finalized = false
260
+ seed_platform_events!
261
+ end
262
+
263
+ def event(name, owner: :host)
264
+ raise CommandTower::Audit::FrozenRegistryError, "audit registry is frozen" if @finalized
265
+
266
+ normalized = normalize_name!(name)
267
+ existing = @definitions[normalized]
268
+ if existing
269
+ if existing.owner == :command_tower && owner == :host
270
+ raise CommandTower::Audit::HostOverrideError,
271
+ "host cannot redefine CommandTower-owned audit event #{normalized}"
272
+ end
273
+
274
+ raise CommandTower::Audit::DuplicateEventError,
275
+ "audit event #{normalized} is already registered"
276
+ end
277
+
278
+ definition = EventDefinition.new
279
+ yield definition if block_given?
280
+ definition.owner = owner
281
+ definition.validate_policy!(name: normalized)
282
+ @definitions[normalized] = definition
283
+ definition
284
+ end
285
+
286
+ def set_enabled!(name, value)
287
+ raise CommandTower::Audit::FrozenRegistryError, "audit registry is frozen" if @finalized
288
+ unless [true, false].include?(value)
289
+ raise CommandTower::Audit::InvalidEventDefinitionError, "enabled must be true or false"
290
+ end
291
+
292
+ definition = fetch(name)
293
+ if definition.owner != :command_tower
294
+ raise CommandTower::Audit::HostOverrideError,
295
+ "set_enabled! is only for CommandTower-owned audit events"
296
+ end
297
+ unless definition.enablement_configurable?
298
+ raise CommandTower::Audit::EnablementNotConfigurableError,
299
+ "audit event #{normalize_name!(name)} enablement is not configurable"
300
+ end
301
+
302
+ definition.enabled = value
303
+ definition
304
+ end
305
+
306
+ def fetch(name)
307
+ normalized = normalize_name!(name)
308
+ definition = @definitions[normalized]
309
+ if definition.nil?
310
+ raise CommandTower::Audit::UnregisteredEventError,
311
+ "audit event #{normalized} is not registered"
312
+ end
313
+
314
+ definition
315
+ end
316
+
317
+ def registered?(name)
318
+ @definitions.key?(normalize_name!(name))
319
+ rescue CommandTower::Audit::InvalidEventNameError
320
+ false
321
+ end
322
+
323
+ def definitions
324
+ @definitions.dup.freeze
325
+ end
326
+
327
+ def finalize!
328
+ @definitions.each_value do |definition|
329
+ next unless definition.respond_to?(:class_composer_freeze_objects!)
330
+
331
+ definition.class_composer_freeze_objects!(behavior: :raise, children: true)
332
+ end
333
+ @definitions.freeze
334
+ @finalized = true
335
+ self
336
+ end
337
+
338
+ def finalized?
339
+ @finalized
340
+ end
341
+
342
+ def reset_host_definitions!
343
+ thaw_for_test!
344
+ @definitions.delete_if { |_name, definition| definition.owner == :host }
345
+ restore_platform_enablement!
346
+ self
347
+ end
348
+
349
+ private
350
+
351
+ def seed_platform_events!
352
+ PLATFORM_EVENTS.each do |name, attrs|
353
+ event(name, owner: :command_tower) do |definition|
354
+ definition.enabled = attrs[:enabled]
355
+ definition.enablement_configurable = attrs.fetch(:enablement_configurable, false)
356
+ definition.user_history = attrs[:user_history]
357
+ definition.sensitive_fields = attrs[:sensitive_fields]
358
+ definition.allowed_changes = attrs[:allowed_changes]
359
+ definition.retention = attrs[:retention]
360
+ definition.subject_required = attrs[:subject_required]
361
+ definition.affected_user_required = attrs[:affected_user_required]
362
+ definition.label = attrs.fetch(:label, "")
363
+ definition.tags = attrs.fetch(:tags, [])
364
+ definition.subject_type = attrs.fetch(:subject_type, "")
365
+ definition.global_visible_in_host_scope = attrs.fetch(:global_visible_in_host_scope, false)
366
+ end
367
+ end
368
+ end
369
+
370
+ def restore_platform_enablement!
371
+ PLATFORM_EVENTS.each_key { |name| @definitions.delete(name.to_s) }
372
+ seed_platform_events!
373
+ end
374
+
375
+ def thaw_for_test!
376
+ return unless @finalized || @definitions.frozen?
377
+
378
+ @definitions = @definitions.dup
379
+ @finalized = false
380
+ end
381
+
382
+ def normalize_name!(name)
383
+ raise CommandTower::Audit::InvalidEventNameError, "audit event name is required" if name.nil? || name.to_s.strip.empty?
384
+
385
+ segments = name.to_s.split(".")
386
+ raise CommandTower::Audit::InvalidEventNameError, "audit event name is required" if segments.empty?
387
+
388
+ normalized = segments.map do |segment|
389
+ unless segment.match?(CommandTower::Events::SEGMENT)
390
+ raise CommandTower::Audit::InvalidEventNameError,
391
+ "invalid audit event name #{name.inspect}"
392
+ end
393
+
394
+ segment
395
+ end
396
+
397
+ normalized.join(".")
398
+ end
399
+ end
400
+ end
401
+ end
402
+ end
403
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "class_composer"
4
+
5
+ module CommandTower
6
+ module Configuration
7
+ module Registry
8
+ module Audit
9
+ class EventDefinition
10
+ include ClassComposer::Generator
11
+
12
+ RETENTIONS = %i[permanent ninety_days one_year].freeze
13
+
14
+ add_composer :enabled,
15
+ desc: "When false, audit(...) does not emit a notification",
16
+ allowed: [TrueClass, FalseClass],
17
+ default: true
18
+
19
+ add_composer :enablement_configurable,
20
+ desc: "When true, hosts may change only the enabled setting for this CommandTower-owned event",
21
+ allowed: [TrueClass, FalseClass],
22
+ default: false
23
+
24
+ add_composer :user_history,
25
+ desc: "Whether this event type may appear in account history (query enforcement is later)",
26
+ allowed: [TrueClass, FalseClass],
27
+ default: true
28
+
29
+ add_composer :sensitive_fields,
30
+ desc: "Registered field names that later projections must treat as sensitive",
31
+ allowed: Array,
32
+ default: []
33
+
34
+ add_composer :allowed_changes,
35
+ desc: "Allowlisted keys for the runtime changes hash",
36
+ allowed: Array,
37
+ default: []
38
+
39
+ add_composer :retention,
40
+ desc: "Retention intent seam (enforcement is later)",
41
+ allowed: Symbol,
42
+ default: :permanent
43
+
44
+ add_composer :subject_required,
45
+ desc: "When true, audit(...) must supply a subject",
46
+ allowed: [TrueClass, FalseClass],
47
+ default: false
48
+
49
+ add_composer :affected_user_required,
50
+ desc: "When true, audit(...) must supply an affected user",
51
+ allowed: [TrueClass, FalseClass],
52
+ default: false
53
+
54
+ add_composer :label,
55
+ desc: "Optional human-readable display label for Explorer UI (empty falls back to event name)",
56
+ allowed: String,
57
+ default: ""
58
+
59
+ add_composer :tags,
60
+ desc: "Discovery tags for filter-option selectors (presentation only; not ledger/authz)",
61
+ allowed: Array,
62
+ default: []
63
+
64
+ add_composer :subject_type,
65
+ desc: "Optional canonical subject class name for Explorer Resource catalogs (empty = none)",
66
+ allowed: String,
67
+ default: ""
68
+
69
+ add_composer :global_visible_in_host_scope,
70
+ desc: "When true, global-scoped events may appear in host-scoped admin audit views for affected users in scope",
71
+ allowed: [TrueClass, FalseClass],
72
+ default: false
73
+
74
+ attr_accessor :owner
75
+
76
+ def enabled?
77
+ enabled == true
78
+ end
79
+
80
+ def enablement_configurable?
81
+ enablement_configurable == true
82
+ end
83
+
84
+ def global_visible_in_host_scope?
85
+ global_visible_in_host_scope == true
86
+ end
87
+
88
+ def validate_policy!(name:)
89
+ unless RETENTIONS.include?(retention.to_sym)
90
+ raise CommandTower::Audit::InvalidEventDefinitionError,
91
+ "audit event #{name} has invalid retention #{retention.inspect} " \
92
+ "(allowed: #{RETENTIONS.join(", ")})"
93
+ end
94
+
95
+ normalized_allowed = normalize_field_list!(allowed_changes, field: "allowed_changes", name:)
96
+ normalized_sensitive = normalize_field_list!(sensitive_fields, field: "sensitive_fields", name:)
97
+ extra_sensitive = normalized_sensitive - normalized_allowed
98
+ if extra_sensitive.any?
99
+ raise CommandTower::Audit::InvalidEventDefinitionError,
100
+ "audit event #{name} sensitive_fields #{extra_sensitive.inspect} " \
101
+ "must be included in allowed_changes"
102
+ end
103
+
104
+ self.allowed_changes = normalized_allowed
105
+ self.sensitive_fields = normalized_sensitive
106
+ self.tags = normalize_tags!(tags, name:)
107
+ self.subject_type = normalize_subject_type!(subject_type, name:)
108
+ self.retention = retention.to_sym
109
+ self.owner = owner&.to_sym || :host
110
+ end
111
+
112
+ def normalize_field_list!(values, field:, name:)
113
+ Array(values).map do |value|
114
+ segment = value.to_s
115
+ unless segment.match?(CommandTower::Events::SEGMENT)
116
+ raise CommandTower::Audit::InvalidEventDefinitionError,
117
+ "audit event #{name} #{field} contains invalid token #{value.inspect}"
118
+ end
119
+
120
+ segment.to_sym
121
+ end
122
+ end
123
+
124
+ # Lowercase, stripped, unique, deterministic. Rejects blank / invalid tokens.
125
+ def normalize_tags!(values, name:)
126
+ Array(values).filter_map do |value|
127
+ tag = value.to_s.strip.downcase
128
+ next if tag.empty?
129
+
130
+ unless tag.match?(/\A[a-z0-9_]+\z/)
131
+ raise CommandTower::Audit::InvalidEventDefinitionError,
132
+ "audit event #{name} tags contains invalid token #{value.inspect}"
133
+ end
134
+
135
+ tag
136
+ end.uniq.sort
137
+ end
138
+
139
+ SUBJECT_TYPE = /\A[A-Z][A-Za-z0-9_:]*\z/
140
+
141
+ def normalize_subject_type!(value, name:)
142
+ token = value.to_s.strip
143
+ return "" if token.empty?
144
+ unless SUBJECT_TYPE.match?(token)
145
+ raise CommandTower::Audit::InvalidEventDefinitionError,
146
+ "audit event #{name} subject_type is invalid #{value.inspect}"
147
+ end
148
+
149
+ token
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end