command_tower 0.10.0 → 0.11.1

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 (190) 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 +38 -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/0.11.1.md +25 -0
  139. data/docs/upgrades/README.md +2 -0
  140. data/lib/command_tower/admin_scope/apply_audit_scoping.rb +45 -0
  141. data/lib/command_tower/admin_scope/apply_users_narrowing.rb +21 -0
  142. data/lib/command_tower/admin_scope/manifest_projection.rb +102 -0
  143. data/lib/command_tower/admin_scope/resolve.rb +31 -0
  144. data/lib/command_tower/admin_scope/scope_context.rb +7 -0
  145. data/lib/command_tower/admin_scope/scope_option.rb +7 -0
  146. data/lib/command_tower/admin_scope.rb +20 -0
  147. data/lib/command_tower/admin_workspace.rb +16 -0
  148. data/lib/command_tower/audit/attribution.rb +90 -0
  149. data/lib/command_tower/audit/emit.rb +113 -0
  150. data/lib/command_tower/audit/masking.rb +41 -0
  151. data/lib/command_tower/audit/payload.rb +128 -0
  152. data/lib/command_tower/audit/persistence/subscriber.rb +85 -0
  153. data/lib/command_tower/audit.rb +27 -0
  154. data/lib/command_tower/authorization/assignable_roles.rb +36 -0
  155. data/lib/command_tower/authorization/default.yml +99 -5
  156. data/lib/command_tower/authorization/effective_entity_grants.rb +55 -0
  157. data/lib/command_tower/authorization/entity.rb +15 -5
  158. data/lib/command_tower/authorization/role.rb +5 -4
  159. data/lib/command_tower/authorization.rb +71 -10
  160. data/lib/command_tower/configuration/admin_scope/config.rb +104 -0
  161. data/lib/command_tower/configuration/admin_scope/tool_registration.rb +28 -0
  162. data/lib/command_tower/configuration/authorization/config.rb +6 -0
  163. data/lib/command_tower/configuration/config.rb +20 -0
  164. data/lib/command_tower/configuration/impersonation/config.rb +38 -0
  165. data/lib/command_tower/configuration/registry/admin_workspace/config.rb +199 -0
  166. data/lib/command_tower/configuration/registry/admin_workspace/tool_definition.rb +150 -0
  167. data/lib/command_tower/configuration/registry/audit/config.rb +403 -0
  168. data/lib/command_tower/configuration/registry/audit/event_definition.rb +155 -0
  169. data/lib/command_tower/configuration/registry/config.rb +34 -0
  170. data/lib/command_tower/configuration/registry/principal_capabilities/capability_definition.rb +43 -0
  171. data/lib/command_tower/configuration/registry/principal_capabilities/config.rb +136 -0
  172. data/lib/command_tower/current.rb +12 -0
  173. data/lib/command_tower/engine.rb +19 -4
  174. data/lib/command_tower/events.rb +184 -0
  175. data/lib/command_tower/execution.rb +111 -0
  176. data/lib/command_tower/impersonation/activity_declaration.rb +23 -0
  177. data/lib/command_tower/impersonation/apply_overlay.rb +88 -0
  178. data/lib/command_tower/impersonation/clear_overlay_for_audit.rb +23 -0
  179. data/lib/command_tower/impersonation/establish_identity.rb +44 -0
  180. data/lib/command_tower/install/baseline.rb +3 -0
  181. data/lib/command_tower/logging/lifecycle_declaration.rb +27 -0
  182. data/lib/command_tower/logging/projection.rb +67 -0
  183. data/lib/command_tower/logging/subscriber.rb +103 -0
  184. data/lib/command_tower/principal_capabilities.rb +15 -0
  185. data/lib/command_tower/version.rb +1 -1
  186. data/lib/command_tower.rb +12 -0
  187. data/spec/factories/impersonation_session.rb +17 -0
  188. data/spec/factories/user.rb +16 -0
  189. metadata +114 -3
  190. data/app/services/command_tower/messaging/contract/observability/structured_logger.rb +0 -56
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "command_tower/audit/attribution"
4
+ require "command_tower/audit/payload"
5
+
6
+ module CommandTower
7
+ module Audit
8
+ module Emit
9
+ module_function
10
+
11
+ def call(name:, subject: nil, affected_user: nil, changes: {}, metadata: {}, attribution_mode: nil, subject_label: nil, scope_class: :global, host_context: nil)
12
+ registry_name = normalized_event_name(name)
13
+ definition = CommandTower.config.registry.audit.fetch(registry_name)
14
+ return nil unless definition.enabled?
15
+
16
+ assert_subject!(definition, subject)
17
+ affected_user_id = extract_user_id(affected_user, required: definition.affected_user_required)
18
+ validated_changes = Payload.validate_changes!(changes || {}, allowed_keys: definition.allowed_changes)
19
+ validated_metadata = Payload.validate_metadata!(metadata)
20
+ attribution = Attribution.resolve(affected_user_id:, attribution_mode:)
21
+ snapshot = subject_snapshot(subject, subject_label)
22
+ normalized_scope_class = normalize_scope_class!(scope_class)
23
+ host_context_fields = normalize_host_context!(host_context, scope_class: normalized_scope_class)
24
+
25
+ CommandTower::Events.publish_audit(
26
+ name: registry_name,
27
+ envelope: attribution.merge(snapshot).merge(host_context_fields).merge(
28
+ action: registry_name,
29
+ occurred_at: Time.now.utc.iso8601(6),
30
+ scope_class: normalized_scope_class,
31
+ changes: validated_changes,
32
+ metadata: validated_metadata
33
+ )
34
+ )
35
+ end
36
+
37
+ def normalized_event_name(name)
38
+ CommandTower.config.registry.audit.fetch(name)
39
+ name.to_s.split(".").map { |segment| CommandTower::Events.normalize_segment(segment, field: "name") }.join(".")
40
+ end
41
+
42
+ def assert_subject!(definition, subject)
43
+ return unless definition.subject_required
44
+ return unless subject.nil?
45
+
46
+ raise MissingSubjectError, "audit event requires a subject"
47
+ end
48
+
49
+ def extract_user_id(value, required:)
50
+ if value.nil?
51
+ raise MissingAffectedUserError, "audit event requires an affected user" if required
52
+
53
+ return nil
54
+ end
55
+
56
+ return value if value.is_a?(Integer)
57
+ return value.id if value.respond_to?(:id)
58
+
59
+ raise InvalidPayloadError, "affected_user must be a user or id"
60
+ end
61
+
62
+ def subject_snapshot(subject, subject_label)
63
+ label = subject_label.nil? ? nil : subject_label.to_s
64
+ if subject.nil?
65
+ return { subject_type: nil, subject_id: nil, subject_label: label }
66
+ end
67
+
68
+ unless subject.respond_to?(:id)
69
+ raise InvalidPayloadError, "subject must provide an id"
70
+ end
71
+
72
+ {
73
+ subject_type: subject.class.name,
74
+ subject_id: subject.id,
75
+ subject_label: label
76
+ }
77
+ end
78
+
79
+ def normalize_scope_class!(value)
80
+ token = value.to_sym
81
+ normalized = CommandTower::Audit::Event::SCOPE_CLASSES[token]
82
+ unless normalized
83
+ raise InvalidPayloadError,
84
+ "scope_class must be one of #{CommandTower::Audit::Event::SCOPE_CLASSES.keys.join(", ")}"
85
+ end
86
+
87
+ normalized
88
+ end
89
+
90
+ def normalize_host_context!(host_context, scope_class:)
91
+ host_scope = CommandTower::Audit::Event::SCOPE_CLASSES[:host]
92
+ if scope_class != host_scope
93
+ return { host_context_type: nil, host_context_identifier: nil }
94
+ end
95
+
96
+ unless host_context.is_a?(Hash)
97
+ raise InvalidPayloadError, "host_context is required when scope_class is host"
98
+ end
99
+
100
+ context_type = host_context[:type] || host_context["type"]
101
+ context_identifier = host_context[:identifier] || host_context["identifier"]
102
+ if context_type.to_s.strip.empty? || context_identifier.to_s.strip.empty?
103
+ raise InvalidPayloadError, "host_context requires type and identifier when scope_class is host"
104
+ end
105
+
106
+ {
107
+ host_context_type: context_type.to_s.strip,
108
+ host_context_identifier: context_identifier.to_s.strip
109
+ }
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Audit
5
+ module Masking
6
+ REDACTED = "********"
7
+
8
+ module_function
9
+
10
+ def value(field:, raw:)
11
+ return if raw.nil?
12
+ return REDACTED unless scalar?(raw)
13
+
14
+ string = raw.to_s
15
+ case field.to_s
16
+ when "phone" then phone(string)
17
+ when "email" then email(string)
18
+ else REDACTED
19
+ end
20
+ end
21
+
22
+ def phone(string)
23
+ digits = string.gsub(/\D/, "")
24
+ return REDACTED if digits.length < 4
25
+
26
+ "#{'*' * 7}#{digits[-4, 4]}"
27
+ end
28
+
29
+ def email(string)
30
+ local, domain = string.split("@", 2)
31
+ return REDACTED if local.blank? || domain.blank?
32
+
33
+ "#{local[0]}***@#{domain}"
34
+ end
35
+
36
+ def scalar?(raw)
37
+ raw.is_a?(String) || raw.is_a?(Numeric)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module CommandTower
6
+ module Audit
7
+ module Payload
8
+ SCALAR_TYPES = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol].freeze
9
+ MAX_DEPTH = 4
10
+ MAX_BYTES = 16_384
11
+ CHANGE_KEYS = %w[from to].freeze
12
+
13
+ module_function
14
+
15
+ def validate!(value, depth: 0, path: "payload")
16
+ if depth > MAX_DEPTH
17
+ raise InvalidPayloadError, "#{path} exceeds max nesting depth #{MAX_DEPTH}"
18
+ end
19
+
20
+ case value
21
+ when Hash
22
+ value.each do |key, nested|
23
+ unless key.is_a?(String) || key.is_a?(Symbol)
24
+ raise InvalidPayloadError, "#{path} has a non-string key #{key.inspect}"
25
+ end
26
+
27
+ validate!(nested, depth: depth + 1, path: "#{path}.#{key}")
28
+ end
29
+ when Array
30
+ value.each_with_index do |item, index|
31
+ validate!(item, depth: depth + 1, path: "#{path}[#{index}]")
32
+ end
33
+ else
34
+ unless SCALAR_TYPES.any? { |type| value.is_a?(type) }
35
+ raise InvalidPayloadError, "#{path} contains unsafe type #{value.class}"
36
+ end
37
+ end
38
+
39
+ value
40
+ end
41
+
42
+ def validate_changes!(changes, allowed_keys:)
43
+ unless changes.nil? || changes.is_a?(Hash)
44
+ raise InvalidPayloadError, "changes must be a Hash"
45
+ end
46
+
47
+ normalized = {}
48
+ (changes || {}).each do |key, value|
49
+ unless key.is_a?(String) || key.is_a?(Symbol)
50
+ raise InvalidPayloadError, "changes has a non-string key #{key.inspect}"
51
+ end
52
+
53
+ token = key.to_s
54
+ unless token.match?(CommandTower::Events::SEGMENT)
55
+ raise InvalidPayloadError, "changes key #{key.inspect} is invalid"
56
+ end
57
+
58
+ unless allowed_keys.map(&:to_s).include?(token)
59
+ raise ForbiddenChangeKeyError,
60
+ "changes key #{token} is not allowed (allowed: #{allowed_keys.join(", ").presence || "none"})"
61
+ end
62
+
63
+ unless value.is_a?(Hash)
64
+ raise InvalidPayloadError, "changes.#{token} must be a Hash with from/to"
65
+ end
66
+
67
+ extra = value.keys.map(&:to_s) - CHANGE_KEYS
68
+ if extra.any? || (value.keys.map(&:to_s) & CHANGE_KEYS).empty?
69
+ raise InvalidPayloadError, "changes.#{token} must use from/to keys only"
70
+ end
71
+
72
+ from_value = value.key?(:from) ? value[:from] : value["from"]
73
+ to_value = value.key?(:to) ? value[:to] : value["to"]
74
+ validate!(from_value, depth: 2, path: "changes.#{token}.from")
75
+ validate!(to_value, depth: 2, path: "changes.#{token}.to")
76
+ normalized[token] = { "from" => from_value, "to" => to_value }
77
+ end
78
+
79
+ enforce_size!(normalized)
80
+ normalized
81
+ end
82
+
83
+ def validate_metadata!(metadata)
84
+ unless metadata.nil? || metadata.is_a?(Hash)
85
+ raise InvalidPayloadError, "metadata must be a Hash"
86
+ end
87
+
88
+ validated = metadata || {}
89
+ validate!(validated, path: "metadata")
90
+ enforce_size!(validated)
91
+ stringify_keys(validated)
92
+ end
93
+
94
+ def enforce_size!(value)
95
+ encoded = JSON.generate(jsonify(value))
96
+ return if encoded.bytesize <= MAX_BYTES
97
+
98
+ raise InvalidPayloadError, "audit payload exceeds #{MAX_BYTES} bytes"
99
+ end
100
+
101
+ def stringify_keys(value)
102
+ case value
103
+ when Hash
104
+ value.each_with_object({}) do |(key, nested), memo|
105
+ memo[key.to_s] = stringify_keys(nested)
106
+ end
107
+ when Array
108
+ value.map { |item| stringify_keys(item) }
109
+ else
110
+ value
111
+ end
112
+ end
113
+
114
+ def jsonify(value)
115
+ case value
116
+ when Hash
117
+ value.each_with_object({}) { |(key, nested), memo| memo[key.to_s] = jsonify(nested) }
118
+ when Array
119
+ value.map { |item| jsonify(item) }
120
+ when Symbol
121
+ value.to_s
122
+ else
123
+ value
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Audit
5
+ module Persistence
6
+ class Subscriber
7
+ PATTERN = /\Acommand_tower\.audit(?:\.|\z)/
8
+
9
+ class << self
10
+ def attach!
11
+ detach!
12
+ subscriber = new
13
+ @subscriptions = [
14
+ ActiveSupport::Notifications.subscribe(PATTERN) do |name, started, finished, id, payload|
15
+ event = ActiveSupport::Notifications::Event.new(name, started, finished, id, payload)
16
+ subscriber.call(event)
17
+ end
18
+ ]
19
+ end
20
+
21
+ def detach!
22
+ Array(@subscriptions).each { |subscription| ActiveSupport::Notifications.unsubscribe(subscription) }
23
+ @subscriptions = []
24
+ end
25
+
26
+ def subscriptions
27
+ Array(@subscriptions)
28
+ end
29
+ end
30
+
31
+ def call(event)
32
+ payload = event.payload.to_h.with_indifferent_access
33
+ return unless persistable?(payload)
34
+
35
+ persist!(payload)
36
+ end
37
+
38
+ private
39
+
40
+ def persistable?(payload)
41
+ payload[:action].present? && payload[:event_uuid].present?
42
+ end
43
+
44
+ def persist!(payload)
45
+ action = payload.fetch(:action).to_s
46
+ definition = CommandTower.config.registry.audit.fetch(action)
47
+
48
+ CommandTower::Audit::Event.create!(
49
+ event_uuid: payload.fetch(:event_uuid),
50
+ action: action,
51
+ occurred_at: parse_occurred_at(payload.fetch(:occurred_at)),
52
+ scope_class: payload.fetch(:scope_class).to_s,
53
+ host_context_type: payload[:host_context_type],
54
+ host_context_identifier: payload[:host_context_identifier],
55
+ execution_uuid: payload[:execution_uuid],
56
+ correlation_id: payload[:correlation_id],
57
+ request_id: payload[:request_id],
58
+ causation_id: payload[:causation_id],
59
+ source: payload[:source]&.to_s,
60
+ actor_user_id: payload[:actor_user_id],
61
+ affected_user_id: payload[:affected_user_id],
62
+ effective_user_id: payload[:effective_user_id],
63
+ originating_administrator_id: payload[:originating_administrator_id],
64
+ impersonation_active: payload[:impersonation_active] == true,
65
+ attribution_mode: payload.fetch(:attribution_mode).to_s,
66
+ subject_type: payload[:subject_type],
67
+ subject_id: payload[:subject_id],
68
+ subject_label: payload[:subject_label],
69
+ change_set: payload[:changes] || {},
70
+ metadata: payload[:metadata] || {},
71
+ user_history: definition.user_history,
72
+ sensitive_fields: Array(definition.sensitive_fields).map(&:to_s),
73
+ retention: definition.retention.to_s
74
+ )
75
+ end
76
+
77
+ def parse_occurred_at(value)
78
+ return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
79
+
80
+ Time.iso8601(value.to_s)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Audit
5
+ class Error < CommandTower::Error; end
6
+
7
+ class UnregisteredEventError < Error; end
8
+ class DuplicateEventError < Error; end
9
+ class HostOverrideError < Error; end
10
+ class InvalidEventNameError < Error; end
11
+ class ForbiddenChangeKeyError < Error; end
12
+ class InvalidPayloadError < Error; end
13
+ class InvalidAttributionError < Error; end
14
+ class MissingSubjectError < Error; end
15
+ class MissingAffectedUserError < Error; end
16
+ class FrozenRegistryError < Error; end
17
+ class InvalidEventDefinitionError < Error; end
18
+ class EnablementNotConfigurableError < Error; end
19
+ class ImmutableError < Error; end
20
+ end
21
+ end
22
+
23
+ require "command_tower/audit/payload"
24
+ require "command_tower/audit/attribution"
25
+ require "command_tower/audit/emit"
26
+ require "command_tower/audit/masking"
27
+ require "command_tower/audit/persistence/subscriber"
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Authorization
5
+ module AssignableRoles
6
+ OWNER_NAME = "owner"
7
+
8
+ module_function
9
+
10
+ def catalog
11
+ CommandTower::Authorization::Role.roles.values
12
+ .select { |role| assignable?(role) }
13
+ .sort_by { |role| role.name.to_s }
14
+ .map do |role|
15
+ { name: role.name.to_s, description: role.description.to_s }
16
+ end
17
+ end
18
+
19
+ def names
20
+ catalog.map { |entry| entry[:name] }.to_set
21
+ end
22
+
23
+ def assignable?(role)
24
+ return false if role.nil?
25
+ return false if role.allow_everything
26
+ return false if role.name.to_s == OWNER_NAME
27
+
28
+ role.source == :host
29
+ end
30
+
31
+ def assignable_name?(name)
32
+ names.include?(name.to_s)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -3,12 +3,106 @@ groups:
3
3
  owner:
4
4
  description: The owner of the application will have full access to all components
5
5
  entities: true
6
- admin:
7
- description: |
8
- Platform admin role for modern Engine admin surfaces (e.g. messaging announcements).
9
- entities:
10
- - admin_messaging_announcements
11
6
  entities:
7
+ - name: email_verification_send
8
+ controller: CommandTower::Auth::EmailVerification::SendController
9
+ only: create
10
+ - name: email_verification_verify
11
+ controller: CommandTower::Auth::EmailVerification::VerifyController
12
+ only: create
13
+ - name: session
14
+ controller: CommandTower::Auth::SessionController
15
+ only: show
16
+ - name: me
17
+ controller: CommandTower::MeController
18
+ only: show
19
+ - name: profile
20
+ controller: CommandTower::ProfileController
21
+ only: show
22
+ - name: me_name
23
+ controller: CommandTower::Me::NameController
24
+ only: update
25
+ - name: me_password
26
+ controller: CommandTower::Me::PasswordController
27
+ only: update
28
+ - name: me_inbox
29
+ controller: CommandTower::Me::InboxController
30
+ only:
31
+ - index
32
+ - show
33
+ - open
34
+ - archive
35
+ - destroy
36
+ - unread_count
37
+ - bulk_read
38
+ - bulk_unread
39
+ - bulk_archive
40
+ - bulk_restore
41
+ - bulk_delete
42
+ - name: me_preferences
43
+ controller: CommandTower::Me::PreferencesController
44
+ only:
45
+ - show
46
+ - update
47
+ - name: me_phone
48
+ controller: CommandTower::Me::PhoneController
49
+ only:
50
+ - update
51
+ - destroy
52
+ - name: me_phone_verification_send
53
+ controller: CommandTower::Me::PhoneVerification::VerificationsController
54
+ only: create
55
+ - name: me_phone_verification_verify
56
+ controller: CommandTower::Me::PhoneVerification::VerifyController
57
+ only: create
58
+ - name: me_pushover
59
+ controller: CommandTower::Me::PushoverController
60
+ only:
61
+ - show
62
+ - create
63
+ - update
64
+ - destroy
65
+ - name: me_pushover_verification
66
+ controller: CommandTower::Me::Pushover::VerificationsController
67
+ only: create
68
+ - name: me_audit_events
69
+ controller: CommandTower::Me::AuditEventsController
70
+ only:
71
+ - index
72
+ - show
73
+ - filter_options
12
74
  - name: admin_messaging_announcements
13
75
  controller: CommandTower::Admin::Messaging::AnnouncementsController
14
76
  only: create
77
+ - name: admin_audit_events
78
+ controller: CommandTower::Admin::Audit::EventsController
79
+ only:
80
+ - index
81
+ - show
82
+ - filter_options
83
+ - name: admin_workspace
84
+ controller: CommandTower::Admin::WorkspaceController
85
+ only: show
86
+ - name: admin_users
87
+ controller: CommandTower::Admin::UsersController
88
+ only:
89
+ - index
90
+ - show
91
+ - name: admin_users_update
92
+ controller: CommandTower::Admin::Users::IdentitiesController
93
+ only:
94
+ - update_name
95
+ - update_username
96
+ - update_email
97
+ - update_email_validation
98
+ - name: admin_rbac_assignments
99
+ controller: CommandTower::Admin::Users::RolesController
100
+ only:
101
+ - index
102
+ - update
103
+ - name: admin_impersonation
104
+ controller: CommandTower::Admin::Users::ImpersonationSessionsController
105
+ only: create
106
+ - name: principal_capabilities
107
+ controller: CommandTower::Auth::PrincipalCapabilitiesController
108
+ only: show
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Authorization
5
+ # Composed RBAC graph → effective entity name sets.
6
+ # Backend policy only. Not a frontend authorization shortcut.
7
+ module EffectiveEntityGrants
8
+ ALL = :all
9
+
10
+ module_function
11
+
12
+ def allow_everything?(user)
13
+ Array(user.roles).any? do |role_name|
14
+ role = CommandTower::Authorization::Role.roles[role_name]
15
+ role&.allow_everything
16
+ end
17
+ end
18
+
19
+ def for_user(user)
20
+ return ALL if allow_everything?(user)
21
+
22
+ for_role_names(Array(user.roles))
23
+ end
24
+
25
+ def for_role_names(role_names)
26
+ names = Array(role_names).map(&:to_s)
27
+ if names.any? { |name| CommandTower::Authorization::Role.roles[name]&.allow_everything }
28
+ return ALL
29
+ end
30
+
31
+ names.each_with_object(Set.new) do |name, grants|
32
+ role = CommandTower::Authorization::Role.roles[name]
33
+ next unless role
34
+
35
+ role.entities.each { |entity| grants << entity.name.to_s }
36
+ end
37
+ end
38
+
39
+ def for_role(role_name)
40
+ for_role_names([role_name])
41
+ end
42
+
43
+ def includes?(grants, entity_name)
44
+ grants == ALL || grants.include?(entity_name.to_s)
45
+ end
46
+
47
+ def subset?(candidate_grants, actor_grants)
48
+ return true if actor_grants == ALL
49
+ return false if candidate_grants == ALL
50
+
51
+ candidate_grants.subset?(actor_grants)
52
+ end
53
+ end
54
+ end
55
+ end
@@ -4,13 +4,21 @@ module CommandTower
4
4
  module Authorization
5
5
  class Entity
6
6
  class << self
7
- def create_entity(name:, controller:, only: nil, except: nil)
7
+ def create_entity(name:, controller:, only: nil, except: nil, source: :host)
8
8
  if entities[name]
9
- Rails.logger.warn("Warning: Authorization entity #{name} duplicated. Only the most recent one will persist")
9
+ raise Error, "Authorization entity [#{name}] already exists. Hosts must not redefine CommandTower-owned entities."
10
10
  end
11
11
 
12
- entities[name] = new(name:, controller:, only:, except:)
12
+ entity = new(name:, controller:, only:, except:, source:)
13
13
 
14
+ if source.to_sym == :host
15
+ ct_controllers = entities.values.select { |existing| existing.source == :command_tower }.map(&:controller)
16
+ if ct_controllers.include?(entity.controller)
17
+ raise Error, "Host entity [#{name}] redefines CommandTower-owned controller [#{entity.controller}]"
18
+ end
19
+ end
20
+
21
+ entities[name] = entity
14
22
  entities[name]
15
23
  end
16
24
 
@@ -23,11 +31,13 @@ module CommandTower
23
31
  end
24
32
  end
25
33
 
26
- attr_reader :name, :controller, :only, :except
27
- def initialize(name:, controller:, only: nil, except: nil)
34
+ attr_reader :name, :controller, :only, :except, :source
35
+ def initialize(name:, controller:, only: nil, except: nil, source: :host)
28
36
  @controller = controller
29
37
  @except = except.nil? ? nil : Array(except).map(&:to_sym)
30
38
  @only = only.nil? ? nil : Array(only).map(&:to_sym)
39
+ @source = source.to_sym
40
+ @name = name
31
41
 
32
42
  validate!
33
43
  end
@@ -4,7 +4,7 @@ module CommandTower
4
4
  module Authorization
5
5
  class Role
6
6
  class << self
7
- def create_role(name:, description:, entities: nil, allow_everything: false)
7
+ def create_role(name:, description:, entities: nil, allow_everything: false, source: :host)
8
8
  if roles[name]
9
9
  raise Error, "Role [#{name}] already exists. Must use different name"
10
10
  end
@@ -17,7 +17,7 @@ module CommandTower
17
17
  end
18
18
  end
19
19
 
20
- roles[name] = new(name:, description:, entities:, allow_everything:)
20
+ roles[name] = new(name:, description:, entities:, allow_everything:, source:)
21
21
  # A role is `intended` to be immutable (attr_reader)
22
22
  # Once the role is defined it will not get changed
23
23
  # After it is created, add the mapping to the source of truth list of mapped method names to their controllers
@@ -35,12 +35,13 @@ module CommandTower
35
35
  end
36
36
  end
37
37
 
38
- attr_reader :entities, :name, :description, :allow_everything
39
- def initialize(name:, description:, entities:, allow_everything: false)
38
+ attr_reader :entities, :name, :description, :allow_everything, :source
39
+ def initialize(name:, description:, entities:, allow_everything: false, source: :host)
40
40
  @name = name
41
41
  @entities = Array(entities)
42
42
  @description = description
43
43
  @allow_everything = allow_everything
44
+ @source = source.to_sym
44
45
  end
45
46
 
46
47
  def authorized?(controller:, method:, user:)