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.
- checksums.yaml +4 -4
- data/README.md +7 -3
- data/app/auth/command_tower/auth/auth_context.rb +10 -4
- data/app/controllers/command_tower/admin/application_controller.rb +27 -0
- data/app/controllers/command_tower/admin/audit/events_controller.rb +61 -0
- data/app/controllers/command_tower/admin/messaging/announcements_controller.rb +1 -8
- data/app/controllers/command_tower/admin/users/identities_controller.rb +77 -0
- data/app/controllers/command_tower/admin/users/impersonation_sessions_controller.rb +33 -0
- data/app/controllers/command_tower/admin/users/roles_controller.rb +40 -0
- data/app/controllers/command_tower/admin/users_controller.rb +46 -0
- data/app/controllers/command_tower/admin/workspace_controller.rb +15 -0
- data/app/controllers/command_tower/application_controller.rb +17 -1
- data/app/controllers/command_tower/auth/impersonation_session_controller.rb +27 -0
- data/app/controllers/command_tower/auth/logout_controller.rb +3 -1
- data/app/controllers/command_tower/auth/principal_capabilities_controller.rb +19 -0
- data/app/controllers/command_tower/me/audit_events_controller.rb +59 -0
- data/app/controllers/concerns/command_tower/api/application_response_renderer.rb +5 -0
- data/app/controllers/concerns/command_tower/auth/authentication_boundary.rb +3 -2
- data/app/controllers/concerns/command_tower/execution/http_boundary.rb +65 -0
- data/app/deserializers/command_tower/deserializers/admin/scope_parameter.rb +49 -0
- data/app/deserializers/command_tower/deserializers/admin/users.rb +218 -0
- data/app/deserializers/command_tower/deserializers/audit/events.rb +224 -0
- data/app/deserializers/command_tower/deserializers/clients/types.rb +27 -0
- data/app/errors/command_tower/errors/auth/admin_unavailable_during_impersonation_error.rb +17 -0
- data/app/errors/command_tower/errors/auth/default_membership_assignment_error.rb +17 -0
- data/app/errors/command_tower/errors/auth/impersonation_session_expired_error.rb +17 -0
- data/app/errors/command_tower/errors/auth/impersonation_session_missing_error.rb +21 -0
- data/app/errors/command_tower/errors/auth/nested_impersonation_error.rb +17 -0
- data/app/errors/command_tower/errors/auth/self_impersonation_error.rb +21 -0
- data/app/errors/command_tower/errors/continuation_exhausted_error.rb +25 -0
- data/app/jobs/command_tower/application_job.rb +7 -0
- data/app/jobs/command_tower/execution/job_boundary.rb +19 -0
- data/app/mailers/command_tower/application_mailer.rb +11 -1
- data/app/mailers/command_tower/messaging/channel_mailer.rb +1 -2
- data/app/models/command_tower/audit/event.rb +38 -0
- data/app/models/command_tower/impersonation/session.rb +51 -0
- data/app/serializers/command_tower/serializers/admin/users.rb +69 -0
- data/app/serializers/command_tower/serializers/admin/workspace/manifest_serializer.rb +58 -0
- data/app/serializers/command_tower/serializers/audit/events/filter_options_serializer.rb +44 -0
- data/app/serializers/command_tower/serializers/audit/events.rb +42 -0
- data/app/serializers/command_tower/serializers/auth/principal_capabilities_serializer.rb +15 -0
- data/app/serializers/command_tower/serializers/auth/session_response_serializer.rb +26 -2
- data/app/serializers/command_tower/serializers/impersonation/session_serializer.rb +19 -0
- data/app/services/command_tower/README.md +4 -10
- data/app/services/command_tower/authorize/validate.rb +7 -4
- data/app/services/command_tower/jwt/authenticate_user.rb +8 -2
- data/app/services/command_tower/jwt/authentication_outcome.rb +5 -4
- data/app/services/command_tower/jwt/login_create.rb +7 -4
- data/app/services/command_tower/messaging/accept/operation_logger.rb +3 -3
- data/app/services/command_tower/messaging/contract/observability/correlation.rb +3 -1
- data/app/services/command_tower/messaging/contract/observability/operation_logger.rb +3 -3
- data/app/services/command_tower/messaging/contract/observability/publisher.rb +44 -0
- data/app/services/command_tower/messaging/execution/operation_logger.rb +1 -1
- data/app/services/command_tower/messaging/handoff/operation_logger.rb +1 -1
- data/app/services/command_tower/messaging/inbox/operation_logger.rb +1 -1
- data/app/services/command_tower/messaging/planner/operation_logger.rb +1 -1
- data/app/services/command_tower/messaging/recipient_readiness/evaluate.rb +1 -1
- data/app/services/command_tower/service_base.rb +30 -43
- data/app/services/command_tower/service_logging.rb +2 -36
- data/app/services/command_tower/services/account/clear_phone.rb +8 -0
- data/app/services/command_tower/services/account/phone_verification/verify.rb +1 -0
- data/app/services/command_tower/services/account/update_phone.rb +9 -1
- data/app/services/command_tower/services/admin/users.rb +377 -0
- data/app/services/command_tower/services/admin/workspace/manifest.rb +49 -0
- data/app/services/command_tower/services/application_service.rb +37 -0
- data/app/services/command_tower/services/audit/events/filter_options.rb +75 -0
- data/app/services/command_tower/services/audit/events.rb +199 -0
- data/app/services/command_tower/services/auth/assign_default_membership_role.rb +44 -0
- data/app/services/command_tower/services/auth/authenticate_session.rb +33 -6
- data/app/services/command_tower/services/auth/email_verification/verify.rb +2 -0
- data/app/services/command_tower/services/auth/password_reset/reset.rb +13 -3
- data/app/services/command_tower/services/auth/plain_text/login.rb +9 -1
- data/app/services/command_tower/services/auth/principal_capabilities/project.rb +48 -0
- data/app/services/command_tower/services/impersonation/create.rb +29 -0
- data/app/services/command_tower/services/impersonation/end.rb +28 -0
- data/app/services/command_tower/services/impersonation/record_activity.rb +30 -0
- data/app/services/command_tower/services/impersonation/terminate_open_sessions.rb +51 -0
- data/app/services/command_tower/services/me/change_password.rb +12 -0
- data/app/shared_sequences/command_tower/shared_sequences/admin/users/resolve_scoped_user.rb +42 -0
- data/app/workflows/command_tower/transactional.rb +65 -0
- data/app/workflows/command_tower/workflows/admin/messaging/create_announcement_workflow.rb +11 -0
- data/app/workflows/command_tower/workflows/admin/scope_resolution.rb +17 -0
- data/app/workflows/command_tower/workflows/admin/users/error_mapping.rb +26 -0
- data/app/workflows/command_tower/workflows/admin/users/identity_mutation.rb +30 -0
- data/app/workflows/command_tower/workflows/admin/users/list_assignable_roles_workflow.rb +30 -0
- data/app/workflows/command_tower/workflows/admin/users/list_workflow.rb +54 -0
- data/app/workflows/command_tower/workflows/admin/users/set_email_validated_workflow.rb +45 -0
- data/app/workflows/command_tower/workflows/admin/users/show_workflow.rb +49 -0
- data/app/workflows/command_tower/workflows/admin/users/update_email_workflow.rb +45 -0
- data/app/workflows/command_tower/workflows/admin/users/update_name_workflow.rb +49 -0
- data/app/workflows/command_tower/workflows/admin/users/update_roles_workflow.rb +61 -0
- data/app/workflows/command_tower/workflows/admin/users/update_username_workflow.rb +45 -0
- data/app/workflows/command_tower/workflows/admin/workspace/manifest_workflow.rb +25 -0
- data/app/workflows/command_tower/workflows/application_workflow.rb +163 -24
- data/app/workflows/command_tower/workflows/audit/error_mapping.rb +24 -0
- data/app/workflows/command_tower/workflows/audit/events/filter_options_for_admin_workflow.rb +34 -0
- data/app/workflows/command_tower/workflows/audit/events/filter_options_for_user_workflow.rb +34 -0
- data/app/workflows/command_tower/workflows/audit/events/list_for_admin_workflow.rb +74 -0
- data/app/workflows/command_tower/workflows/audit/events/list_for_user_workflow.rb +45 -0
- data/app/workflows/command_tower/workflows/audit/events/show_for_admin_workflow.rb +54 -0
- data/app/workflows/command_tower/workflows/audit/events/show_for_user_workflow.rb +42 -0
- data/app/workflows/command_tower/workflows/auth/authenticate_request_workflow.rb +3 -2
- data/app/workflows/command_tower/workflows/auth/authentication_response_effects.rb +5 -1
- data/app/workflows/command_tower/workflows/auth/logout_workflow.rb +41 -1
- data/app/workflows/command_tower/workflows/auth/plain_text/login_workflow.rb +2 -0
- data/app/workflows/command_tower/workflows/auth/principal_capabilities/show_workflow.rb +27 -0
- data/app/workflows/command_tower/workflows/auth/register_workflow.rb +56 -24
- data/app/workflows/command_tower/workflows/auth/session/show_workflow.rb +15 -1
- data/app/workflows/command_tower/workflows/auth/session_error_status.rb +1 -0
- data/app/workflows/command_tower/workflows/impersonation/error_mapping.rb +27 -0
- data/app/workflows/command_tower/workflows/impersonation/start_workflow.rb +97 -0
- data/app/workflows/command_tower/workflows/impersonation/stop_workflow.rb +66 -0
- data/app/workflows/command_tower/workflows/me/update_name_workflow.rb +1 -0
- data/app/workflows/command_tower/workflows/profile/show_workflow.rb +1 -0
- data/app/workflows/command_tower/workflows/workflow_result.rb +31 -7
- data/config/routes.rb +20 -0
- data/db/migrate/20260816000001_create_command_tower_audit_events.rb +45 -0
- data/db/migrate/20260817000001_add_scope_columns_to_command_tower_audit_events.rb +34 -0
- data/db/migrate/20260817000003_create_command_tower_impersonation_sessions.rb +26 -0
- data/docs/admin_workspace.md +158 -0
- data/docs/api_reference.md +175 -14
- data/docs/architecture.md +5 -0
- data/docs/audit.md +195 -0
- data/docs/authentication.md +14 -0
- data/docs/authentication_authorization_guide.md +15 -11
- data/docs/authorization.md +25 -4
- data/docs/controllers.md +7 -1
- data/docs/eventing.md +179 -0
- data/docs/extending.md +32 -1
- data/docs/host_integration_guide.md +103 -12
- data/docs/initializing.md +2 -2
- data/docs/messaging_integration_guide.md +1 -1
- data/docs/models.md +4 -0
- data/docs/pagination.md +2 -2
- data/docs/principal_capabilities.md +89 -0
- data/docs/upgrades/0.10.0.md +2 -2
- data/docs/upgrades/0.11.0.md +92 -0
- data/docs/upgrades/0.11.1.md +25 -0
- data/docs/upgrades/README.md +2 -0
- data/lib/command_tower/admin_scope/apply_audit_scoping.rb +45 -0
- data/lib/command_tower/admin_scope/apply_users_narrowing.rb +21 -0
- data/lib/command_tower/admin_scope/manifest_projection.rb +102 -0
- data/lib/command_tower/admin_scope/resolve.rb +31 -0
- data/lib/command_tower/admin_scope/scope_context.rb +7 -0
- data/lib/command_tower/admin_scope/scope_option.rb +7 -0
- data/lib/command_tower/admin_scope.rb +20 -0
- data/lib/command_tower/admin_workspace.rb +16 -0
- data/lib/command_tower/audit/attribution.rb +90 -0
- data/lib/command_tower/audit/emit.rb +113 -0
- data/lib/command_tower/audit/masking.rb +41 -0
- data/lib/command_tower/audit/payload.rb +128 -0
- data/lib/command_tower/audit/persistence/subscriber.rb +85 -0
- data/lib/command_tower/audit.rb +27 -0
- data/lib/command_tower/authorization/assignable_roles.rb +36 -0
- data/lib/command_tower/authorization/default.yml +99 -5
- data/lib/command_tower/authorization/effective_entity_grants.rb +55 -0
- data/lib/command_tower/authorization/entity.rb +15 -5
- data/lib/command_tower/authorization/role.rb +5 -4
- data/lib/command_tower/authorization.rb +71 -10
- data/lib/command_tower/configuration/admin_scope/config.rb +104 -0
- data/lib/command_tower/configuration/admin_scope/tool_registration.rb +28 -0
- data/lib/command_tower/configuration/authorization/config.rb +6 -0
- data/lib/command_tower/configuration/config.rb +20 -0
- data/lib/command_tower/configuration/impersonation/config.rb +38 -0
- data/lib/command_tower/configuration/registry/admin_workspace/config.rb +199 -0
- data/lib/command_tower/configuration/registry/admin_workspace/tool_definition.rb +150 -0
- data/lib/command_tower/configuration/registry/audit/config.rb +403 -0
- data/lib/command_tower/configuration/registry/audit/event_definition.rb +155 -0
- data/lib/command_tower/configuration/registry/config.rb +34 -0
- data/lib/command_tower/configuration/registry/principal_capabilities/capability_definition.rb +43 -0
- data/lib/command_tower/configuration/registry/principal_capabilities/config.rb +136 -0
- data/lib/command_tower/current.rb +12 -0
- data/lib/command_tower/engine.rb +19 -4
- data/lib/command_tower/events.rb +184 -0
- data/lib/command_tower/execution.rb +111 -0
- data/lib/command_tower/impersonation/activity_declaration.rb +23 -0
- data/lib/command_tower/impersonation/apply_overlay.rb +88 -0
- data/lib/command_tower/impersonation/clear_overlay_for_audit.rb +23 -0
- data/lib/command_tower/impersonation/establish_identity.rb +44 -0
- data/lib/command_tower/install/baseline.rb +3 -0
- data/lib/command_tower/logging/lifecycle_declaration.rb +27 -0
- data/lib/command_tower/logging/projection.rb +67 -0
- data/lib/command_tower/logging/subscriber.rb +103 -0
- data/lib/command_tower/principal_capabilities.rb +15 -0
- data/lib/command_tower/version.rb +1 -1
- data/lib/command_tower.rb +12 -0
- data/spec/factories/impersonation_session.rb +17 -0
- data/spec/factories/user.rb +16 -0
- metadata +114 -3
- data/app/services/command_tower/messaging/contract/observability/structured_logger.rb +0 -56
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "class_composer"
|
|
4
|
+
require "command_tower/configuration/registry/audit/config"
|
|
5
|
+
require "command_tower/configuration/registry/admin_workspace/config"
|
|
6
|
+
require "command_tower/configuration/registry/principal_capabilities/config"
|
|
7
|
+
|
|
8
|
+
module CommandTower
|
|
9
|
+
module Configuration
|
|
10
|
+
module Registry
|
|
11
|
+
class Config
|
|
12
|
+
include ClassComposer::Generator
|
|
13
|
+
|
|
14
|
+
add_composer :audit,
|
|
15
|
+
desc: "Registered semantic audit event policy (CommandTower-owned and host-owned names)",
|
|
16
|
+
allowed: Audit::Config,
|
|
17
|
+
dynamic_default: ->(_) { Audit::Config.new },
|
|
18
|
+
default_shown: "Audit::Config.new"
|
|
19
|
+
|
|
20
|
+
add_composer :admin_workspace,
|
|
21
|
+
desc: "Registered Admin Workspace tools (CommandTower-owned and host-owned)",
|
|
22
|
+
allowed: AdminWorkspace::Config,
|
|
23
|
+
dynamic_default: ->(_) { AdminWorkspace::Config.new },
|
|
24
|
+
default_shown: "AdminWorkspace::Config.new"
|
|
25
|
+
|
|
26
|
+
add_composer :principal_capabilities,
|
|
27
|
+
desc: "Curated frontend-projectable principal capabilities (CommandTower-owned and host-owned)",
|
|
28
|
+
allowed: PrincipalCapabilities::Config,
|
|
29
|
+
dynamic_default: ->(_) { PrincipalCapabilities::Config.new },
|
|
30
|
+
default_shown: "PrincipalCapabilities::Config.new"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "class_composer"
|
|
4
|
+
|
|
5
|
+
module CommandTower
|
|
6
|
+
module Configuration
|
|
7
|
+
module Registry
|
|
8
|
+
module PrincipalCapabilities
|
|
9
|
+
class CapabilityDefinition
|
|
10
|
+
include ClassComposer::Generator
|
|
11
|
+
|
|
12
|
+
add_composer :required_entity,
|
|
13
|
+
desc: "RBAC entity name required for this principal capability to appear in the projection " \
|
|
14
|
+
"(defaults to the capability id when omitted)",
|
|
15
|
+
allowed: [String, Symbol],
|
|
16
|
+
default: ""
|
|
17
|
+
|
|
18
|
+
attr_accessor :owner, :id
|
|
19
|
+
|
|
20
|
+
def validate_definition!(name:)
|
|
21
|
+
self.id = name.to_s
|
|
22
|
+
self.owner = owner&.to_sym || :host
|
|
23
|
+
entity = required_entity.to_s.strip
|
|
24
|
+
entity = name.to_s if entity.empty?
|
|
25
|
+
self.required_entity = require_segment!(entity, field: "required_entity", name:)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def require_segment!(value, field:, name:)
|
|
31
|
+
token = value.to_s.strip
|
|
32
|
+
unless token.match?(CommandTower::Events::SEGMENT)
|
|
33
|
+
raise CommandTower::PrincipalCapabilities::InvalidCapabilityDefinitionError,
|
|
34
|
+
"principal capability #{name} has invalid #{field} #{value.inspect}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
token
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "command_tower/principal_capabilities"
|
|
4
|
+
require "command_tower/configuration/registry/principal_capabilities/capability_definition"
|
|
5
|
+
|
|
6
|
+
module CommandTower
|
|
7
|
+
module Configuration
|
|
8
|
+
module Registry
|
|
9
|
+
module PrincipalCapabilities
|
|
10
|
+
class Config
|
|
11
|
+
# Curated CT-owned frontend-projectable capabilities (1:1 entity names).
|
|
12
|
+
# Deliberate registration only — never an automatic Entity.entities dump.
|
|
13
|
+
PLATFORM_CAPABILITIES = %i[
|
|
14
|
+
admin_workspace
|
|
15
|
+
admin_audit_events
|
|
16
|
+
admin_messaging_announcements
|
|
17
|
+
admin_users
|
|
18
|
+
admin_users_update
|
|
19
|
+
admin_rbac_assignments
|
|
20
|
+
admin_impersonation
|
|
21
|
+
me_audit_events
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def initialize
|
|
25
|
+
@definitions = {}
|
|
26
|
+
@finalized = false
|
|
27
|
+
seed_platform_capabilities!
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def capability(name, owner: :host)
|
|
31
|
+
raise CommandTower::PrincipalCapabilities::FrozenRegistryError,
|
|
32
|
+
"principal capabilities registry is frozen" if @finalized
|
|
33
|
+
|
|
34
|
+
normalized = normalize_name!(name)
|
|
35
|
+
existing = @definitions[normalized]
|
|
36
|
+
if existing
|
|
37
|
+
if existing.owner == :command_tower && owner == :host
|
|
38
|
+
raise CommandTower::PrincipalCapabilities::HostOverrideError,
|
|
39
|
+
"host cannot redefine CommandTower-owned principal capability #{normalized}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
raise CommandTower::PrincipalCapabilities::DuplicateCapabilityError,
|
|
43
|
+
"principal capability #{normalized} is already registered"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
definition = CapabilityDefinition.new
|
|
47
|
+
yield definition if block_given?
|
|
48
|
+
definition.owner = owner
|
|
49
|
+
definition.validate_definition!(name: normalized)
|
|
50
|
+
@definitions[normalized] = definition
|
|
51
|
+
definition
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def fetch(name)
|
|
55
|
+
normalized = normalize_name!(name)
|
|
56
|
+
definition = @definitions[normalized]
|
|
57
|
+
if definition.nil?
|
|
58
|
+
raise CommandTower::PrincipalCapabilities::UnregisteredCapabilityError,
|
|
59
|
+
"principal capability #{normalized} is not registered"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
definition
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def registered?(name)
|
|
66
|
+
@definitions.key?(normalize_name!(name))
|
|
67
|
+
rescue CommandTower::PrincipalCapabilities::InvalidCapabilityNameError
|
|
68
|
+
false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def definitions
|
|
72
|
+
@definitions.dup.freeze
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def finalize!
|
|
76
|
+
@definitions.each_value do |definition|
|
|
77
|
+
next unless definition.respond_to?(:class_composer_freeze_objects!)
|
|
78
|
+
|
|
79
|
+
definition.class_composer_freeze_objects!(behavior: :raise, children: true)
|
|
80
|
+
end
|
|
81
|
+
@definitions.freeze
|
|
82
|
+
@finalized = true
|
|
83
|
+
self
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def finalized?
|
|
87
|
+
@finalized
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_required_entities!(entities)
|
|
91
|
+
missing = @definitions.filter_map do |name, definition|
|
|
92
|
+
next if entities.key?(definition.required_entity)
|
|
93
|
+
|
|
94
|
+
"#{name} requires #{definition.required_entity}"
|
|
95
|
+
end
|
|
96
|
+
return self if missing.empty?
|
|
97
|
+
|
|
98
|
+
raise CommandTower::PrincipalCapabilities::MissingRequiredEntityError,
|
|
99
|
+
"principal capabilities reference unknown RBAC entities: #{missing.join(", ")}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def reset_host_definitions!
|
|
103
|
+
thaw_for_test!
|
|
104
|
+
@definitions.delete_if { |_name, definition| definition.owner == :host }
|
|
105
|
+
self
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def seed_platform_capabilities!
|
|
111
|
+
PLATFORM_CAPABILITIES.each do |name|
|
|
112
|
+
capability(name, owner: :command_tower)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def thaw_for_test!
|
|
117
|
+
return unless @finalized || @definitions.frozen?
|
|
118
|
+
|
|
119
|
+
@definitions = @definitions.dup
|
|
120
|
+
@finalized = false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def normalize_name!(name)
|
|
124
|
+
token = name.to_s.strip
|
|
125
|
+
unless token.match?(CommandTower::Events::SEGMENT)
|
|
126
|
+
raise CommandTower::PrincipalCapabilities::InvalidCapabilityNameError,
|
|
127
|
+
"invalid principal capability name #{name.inspect}"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
token
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module CommandTower
|
|
4
4
|
class Current < ActiveSupport::CurrentAttributes
|
|
5
|
+
attribute :execution_uuid
|
|
6
|
+
attribute :correlation_id
|
|
5
7
|
attribute :request_id
|
|
8
|
+
attribute :causation_id
|
|
9
|
+
attribute :source
|
|
10
|
+
attribute :user_id
|
|
11
|
+
attribute :originating_administrator_id
|
|
12
|
+
attribute :effective_user_id
|
|
13
|
+
attribute :impersonation_active, default: false
|
|
14
|
+
attribute :impersonation_session_id
|
|
15
|
+
attribute :impersonation_activity_recorded, default: false
|
|
16
|
+
attribute :remote_ip
|
|
17
|
+
attribute :user_agent
|
|
6
18
|
end
|
|
7
19
|
end
|
data/lib/command_tower/engine.rb
CHANGED
|
@@ -7,6 +7,8 @@ module CommandTower
|
|
|
7
7
|
class Engine < ::Rails::Engine
|
|
8
8
|
isolate_namespace CommandTower
|
|
9
9
|
|
|
10
|
+
paths.add "app/shared_sequences", eager_load: true
|
|
11
|
+
|
|
10
12
|
# Run after Rails loads the initializes and environment files
|
|
11
13
|
# Ensures User has already set their desired config before we lock this down
|
|
12
14
|
config.after_initialize do
|
|
@@ -22,6 +24,11 @@ module CommandTower
|
|
|
22
24
|
CommandTower::CredentialResolution::SmtpActionMailerBridge.apply!
|
|
23
25
|
|
|
24
26
|
unless Rails.env.test?
|
|
27
|
+
CommandTower.config.impersonation.validate!
|
|
28
|
+
CommandTower.config.registry.audit.finalize!
|
|
29
|
+
CommandTower.config.registry.admin_workspace.finalize!
|
|
30
|
+
CommandTower.config.registry.principal_capabilities.finalize!
|
|
31
|
+
CommandTower.config.admin_scope.finalize!
|
|
25
32
|
# Now that we can confirm all variables are defined, freeze all objects an their children
|
|
26
33
|
CommandTower.config.class_composer_freeze_objects!(behavior: :raise, children: true)
|
|
27
34
|
end
|
|
@@ -46,11 +53,19 @@ module CommandTower
|
|
|
46
53
|
# Add all RBAC based role definitions prior to fork/loading
|
|
47
54
|
# Load once use forever
|
|
48
55
|
config.to_prepare do
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
56
|
+
skip_composition_validation = defined?(Rake) && (Rake.application.top_level_tasks.any? { |task|
|
|
57
|
+
task =~ /db:|install:migrations|command_tower:install\z|command_tower:doctor/
|
|
58
|
+
} rescue nil)
|
|
52
59
|
|
|
53
|
-
CommandTower::Authorization.default_defined!
|
|
60
|
+
CommandTower::Authorization.default_defined!(validate: !skip_composition_validation)
|
|
61
|
+
unless skip_composition_validation
|
|
62
|
+
entities = CommandTower::Authorization::Entity.entities
|
|
63
|
+
CommandTower.config.registry.admin_workspace.validate_required_entities!(entities)
|
|
64
|
+
CommandTower.config.registry.principal_capabilities.validate_required_entities!(entities)
|
|
65
|
+
CommandTower.config.admin_scope.validate_scoped_tools!
|
|
66
|
+
end
|
|
67
|
+
CommandTower::Logging::Subscriber.attach!
|
|
68
|
+
CommandTower::Audit::Persistence::Subscriber.attach! unless skip_composition_validation
|
|
54
69
|
end
|
|
55
70
|
end
|
|
56
71
|
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Events
|
|
5
|
+
PREFIX = "command_tower"
|
|
6
|
+
SEGMENT = /\A[a-z][a-z0-9_]*\z/
|
|
7
|
+
SNAPSHOT_KEYS = %i[
|
|
8
|
+
execution_uuid
|
|
9
|
+
correlation_id
|
|
10
|
+
request_id
|
|
11
|
+
causation_id
|
|
12
|
+
source
|
|
13
|
+
user_id
|
|
14
|
+
originating_administrator_id
|
|
15
|
+
effective_user_id
|
|
16
|
+
impersonation_active
|
|
17
|
+
].freeze
|
|
18
|
+
SCALAR_TYPES = [NilClass, TrueClass, FalseClass, Integer, Float, String, Symbol].freeze
|
|
19
|
+
|
|
20
|
+
WORKFLOW_STARTED = "command_tower.lifecycle.workflow.started"
|
|
21
|
+
WORKFLOW_COMPLETED = "command_tower.lifecycle.workflow.completed"
|
|
22
|
+
SERVICE_STARTED = "command_tower.lifecycle.service.started"
|
|
23
|
+
SERVICE_COMPLETED = "command_tower.lifecycle.service.completed"
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def instrument_name(category:, name:)
|
|
28
|
+
category_segment = normalize_segment(category, field: "category")
|
|
29
|
+
name_segments = name.to_s.split(".")
|
|
30
|
+
raise ArgumentError, "name must contain at least one segment" if name_segments.empty?
|
|
31
|
+
|
|
32
|
+
normalized_name = name_segments.map { |segment| normalize_segment(segment, field: "name") }
|
|
33
|
+
"#{PREFIX}.#{category_segment}.#{normalized_name.join(".")}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def snapshot
|
|
37
|
+
SNAPSHOT_KEYS.each_with_object({}) do |key, memo|
|
|
38
|
+
memo[key] = CommandTower::Current.public_send(key)
|
|
39
|
+
end.freeze
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def publish(category:, name:, payload: {}, subject: nil, layer: nil)
|
|
43
|
+
event_payload = snapshot.merge(event_uuid: SecureRandom.uuid)
|
|
44
|
+
event_payload[:subject] = subject if subject
|
|
45
|
+
event_payload[:layer] = layer if layer
|
|
46
|
+
event_payload.merge!(sanitize_payload(payload))
|
|
47
|
+
ActiveSupport::Notifications.instrument(instrument_name(category:, name:), event_payload.freeze)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def publish_audit(name:, envelope:)
|
|
51
|
+
raise CommandTower::Audit::InvalidPayloadError, "audit envelope must be a Hash" unless envelope.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
CommandTower::Audit::Payload.validate!(envelope[:changes] || {}, path: "changes")
|
|
54
|
+
CommandTower::Audit::Payload.validate!(envelope[:metadata] || {}, path: "metadata")
|
|
55
|
+
CommandTower::Audit::Payload.enforce_size!(envelope)
|
|
56
|
+
|
|
57
|
+
event_payload = snapshot.merge(
|
|
58
|
+
event_uuid: SecureRandom.uuid,
|
|
59
|
+
action: name.to_s,
|
|
60
|
+
occurred_at: envelope[:occurred_at] || Time.now.utc.iso8601(6)
|
|
61
|
+
)
|
|
62
|
+
%i[
|
|
63
|
+
actor_user_id
|
|
64
|
+
affected_user_id
|
|
65
|
+
effective_user_id
|
|
66
|
+
originating_administrator_id
|
|
67
|
+
impersonation_active
|
|
68
|
+
attribution_mode
|
|
69
|
+
subject_type
|
|
70
|
+
subject_id
|
|
71
|
+
subject_label
|
|
72
|
+
user_id
|
|
73
|
+
scope_class
|
|
74
|
+
host_context_type
|
|
75
|
+
host_context_identifier
|
|
76
|
+
changes
|
|
77
|
+
metadata
|
|
78
|
+
].each do |key|
|
|
79
|
+
event_payload[key] = envelope[key] if envelope.key?(key)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
ActiveSupport::Notifications.instrument(instrument_name(category: :audit, name:), event_payload.freeze)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def publish_lifecycle(layer:, phase:, subject:, outcome: nil, duration_ms: nil, error_class: nil, error_codes: nil, log_level: nil, log_lifecycle: false)
|
|
86
|
+
extra = {}
|
|
87
|
+
extra[:outcome] = outcome if phase == :completed
|
|
88
|
+
extra[:duration_ms] = duration_ms if phase == :completed && !duration_ms.nil?
|
|
89
|
+
extra[:error_class] = error_class if error_class
|
|
90
|
+
extra[:error_codes] = error_codes if error_codes
|
|
91
|
+
extra[:log_level] = log_level if log_level
|
|
92
|
+
extra[:log_lifecycle] = log_lifecycle ? true : false
|
|
93
|
+
publish(
|
|
94
|
+
category: :lifecycle,
|
|
95
|
+
name: "#{layer}.#{phase}",
|
|
96
|
+
payload: extra,
|
|
97
|
+
subject:,
|
|
98
|
+
layer:
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def around_execution(layer:, subject:, log_lifecycle: false)
|
|
103
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
104
|
+
publish_lifecycle(layer:, phase: :started, subject:, log_lifecycle:)
|
|
105
|
+
record = { result: nil, unexpected: nil, error_codes: nil, log_level: nil }
|
|
106
|
+
begin
|
|
107
|
+
yield record
|
|
108
|
+
ensure
|
|
109
|
+
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
|
|
110
|
+
publish_lifecycle(
|
|
111
|
+
layer:,
|
|
112
|
+
phase: :completed,
|
|
113
|
+
subject:,
|
|
114
|
+
outcome: outcome_for(layer:, record:),
|
|
115
|
+
duration_ms:,
|
|
116
|
+
error_class: record[:unexpected]&.class&.name,
|
|
117
|
+
error_codes: error_codes_for(record),
|
|
118
|
+
log_level: log_level_for(record),
|
|
119
|
+
log_lifecycle:
|
|
120
|
+
)
|
|
121
|
+
end
|
|
122
|
+
record[:result]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def outcome_for(layer:, record:)
|
|
126
|
+
return :error if record[:unexpected]
|
|
127
|
+
|
|
128
|
+
result = record[:result]
|
|
129
|
+
return result if layer == :service && %i[success failure].include?(result)
|
|
130
|
+
return :success if result.respond_to?(:success?) && result.success?
|
|
131
|
+
return :deferred if result.respond_to?(:deferred?) && result.deferred?
|
|
132
|
+
return :failure if result.respond_to?(:failure?) && result.failure?
|
|
133
|
+
|
|
134
|
+
:error
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def error_codes_for(record)
|
|
138
|
+
return record[:error_codes] if record[:error_codes]
|
|
139
|
+
return nil unless record[:result].respond_to?(:errors)
|
|
140
|
+
|
|
141
|
+
codes = Array(record[:result].errors).filter_map { |error| error.code if error.respond_to?(:code) }
|
|
142
|
+
codes.empty? ? nil : codes
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def log_level_for(record)
|
|
146
|
+
return :error if record[:unexpected]
|
|
147
|
+
return record[:log_level] if record[:log_level]
|
|
148
|
+
return nil unless record[:result].respond_to?(:errors)
|
|
149
|
+
|
|
150
|
+
Array(record[:result].errors).filter_map { |error| error.log_level if error.respond_to?(:log_level) }.first
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def sanitize_payload(payload)
|
|
154
|
+
raise ArgumentError, "payload must be a Hash" unless payload.nil? || payload.is_a?(Hash)
|
|
155
|
+
|
|
156
|
+
(payload || {}).each_with_object({}) do |(key, value), memo|
|
|
157
|
+
next unless scalar_key?(key)
|
|
158
|
+
next unless scalar_value?(value)
|
|
159
|
+
|
|
160
|
+
memo[key.to_sym] = value
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def normalize_segment(value, field:)
|
|
165
|
+
segment = value.to_s
|
|
166
|
+
unless segment.match?(SEGMENT)
|
|
167
|
+
raise ArgumentError, "invalid #{field} segment #{value.inspect}"
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
segment
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def scalar_key?(key)
|
|
174
|
+
key.is_a?(String) || key.is_a?(Symbol)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def scalar_value?(value)
|
|
178
|
+
return true if SCALAR_TYPES.any? { |type| value.is_a?(type) }
|
|
179
|
+
return false unless value.is_a?(Array)
|
|
180
|
+
|
|
181
|
+
value.all? { |item| SCALAR_TYPES.any? { |type| item.is_a?(type) } }
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Execution
|
|
5
|
+
SOURCES = %i[http job rake console].freeze
|
|
6
|
+
|
|
7
|
+
module ContextAccess
|
|
8
|
+
def execution_context
|
|
9
|
+
CommandTower::Current
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def publish_event(category:, name:, payload: {})
|
|
13
|
+
CommandTower::Events.publish(category:, name:, payload:, subject: self.class.name)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def audit(name, subject: nil, affected_user: nil, changes: {}, metadata: {}, attribution_mode: nil, subject_label: nil, scope_class: :global, host_context: nil)
|
|
17
|
+
CommandTower::Audit::Emit.call(
|
|
18
|
+
name:,
|
|
19
|
+
subject:,
|
|
20
|
+
affected_user:,
|
|
21
|
+
changes:,
|
|
22
|
+
metadata:,
|
|
23
|
+
attribution_mode:,
|
|
24
|
+
subject_label:,
|
|
25
|
+
scope_class:,
|
|
26
|
+
host_context:
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def log_debug(msg)
|
|
31
|
+
publish_event(category: :log, name: :debug, payload: { message: msg.to_s })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def log_info(msg)
|
|
35
|
+
publish_event(category: :log, name: :info, payload: { message: msg.to_s })
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def log_warn(msg)
|
|
39
|
+
publish_event(category: :log, name: :warn, payload: { message: msg.to_s })
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def log_error(msg)
|
|
43
|
+
publish_event(category: :log, name: :error, payload: { message: msg.to_s })
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
module IdentityEnrichment
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def from_user(user)
|
|
51
|
+
return if user.nil?
|
|
52
|
+
|
|
53
|
+
CommandTower::Current.user_id = user.id
|
|
54
|
+
CommandTower::Current.effective_user_id = user.id
|
|
55
|
+
CommandTower::Current.originating_administrator_id = nil
|
|
56
|
+
CommandTower::Current.impersonation_active = false
|
|
57
|
+
CommandTower::Current.impersonation_session_id = nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def from_impersonation_session(session, actor:, target:)
|
|
61
|
+
CommandTower::Current.user_id = target.id
|
|
62
|
+
CommandTower::Current.effective_user_id = target.id
|
|
63
|
+
CommandTower::Current.originating_administrator_id = actor.id
|
|
64
|
+
CommandTower::Current.impersonation_active = true
|
|
65
|
+
CommandTower::Current.impersonation_session_id = session.id
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def self.with_execution(source:, **attrs, &block)
|
|
71
|
+
raise ArgumentError, "block required" unless block
|
|
72
|
+
|
|
73
|
+
source = source.to_sym
|
|
74
|
+
unless Execution::SOURCES.include?(source)
|
|
75
|
+
raise ArgumentError, "invalid execution source #{source.inspect} (allowed: #{Execution::SOURCES.join(", ")})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
return yield if Current.execution_uuid.present?
|
|
79
|
+
|
|
80
|
+
execution_uuid = attrs[:execution_uuid].presence || SecureRandom.uuid
|
|
81
|
+
assigned = {
|
|
82
|
+
execution_uuid:,
|
|
83
|
+
correlation_id: attrs[:correlation_id].presence || execution_uuid,
|
|
84
|
+
source:,
|
|
85
|
+
request_id: attrs[:request_id],
|
|
86
|
+
causation_id: attrs[:causation_id],
|
|
87
|
+
user_id: attrs[:user_id],
|
|
88
|
+
originating_administrator_id: attrs[:originating_administrator_id],
|
|
89
|
+
effective_user_id: attrs[:effective_user_id],
|
|
90
|
+
impersonation_active: attrs.key?(:impersonation_active) ? attrs[:impersonation_active] : false,
|
|
91
|
+
impersonation_session_id: attrs[:impersonation_session_id],
|
|
92
|
+
impersonation_activity_recorded: attrs.key?(:impersonation_activity_recorded) ? attrs[:impersonation_activity_recorded] : false,
|
|
93
|
+
remote_ip: attrs[:remote_ip],
|
|
94
|
+
user_agent: attrs[:user_agent]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
run = -> { Current.set(assigned, &block) }
|
|
98
|
+
|
|
99
|
+
if execution_wrapper_idle?
|
|
100
|
+
Rails.application.executor.wrap { run.call }
|
|
101
|
+
else
|
|
102
|
+
run.call
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.execution_wrapper_idle?
|
|
107
|
+
return true unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
108
|
+
|
|
109
|
+
!Rails.application.executor.active?
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Impersonation
|
|
5
|
+
module ActivityDeclaration
|
|
6
|
+
def self.included(base)
|
|
7
|
+
base.extend ClassMethods
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module ClassMethods
|
|
11
|
+
def impersonation_activity!
|
|
12
|
+
@impersonation_activity = true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def impersonation_activity?
|
|
16
|
+
return @impersonation_activity unless @impersonation_activity.nil?
|
|
17
|
+
|
|
18
|
+
superclass.respond_to?(:impersonation_activity?) ? superclass.impersonation_activity? : false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Impersonation
|
|
5
|
+
Overlay = Data.define(:actor, :target, :session, :status)
|
|
6
|
+
|
|
7
|
+
module ApplyOverlay
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(actor:, session_id:, mode: :enforce)
|
|
11
|
+
token = session_id.to_s.strip
|
|
12
|
+
return Overlay.new(actor:, target: actor, session: nil, status: :none) if token.empty?
|
|
13
|
+
|
|
14
|
+
session = CommandTower::Impersonation::Session.find_by(id: token)
|
|
15
|
+
if session.nil? || session.actor_user_id != actor.id
|
|
16
|
+
return reject_or_none(actor:, session: nil, mode:)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
unless session.open?
|
|
20
|
+
return reject_or_none(actor:, session:, mode:)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if session.expired?
|
|
24
|
+
return expire_open_session(actor:, session:, mode:)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
target = User.find_by(id: session.target_user_id)
|
|
28
|
+
if target.nil?
|
|
29
|
+
ended = end_session(session, reason: "revoked", actor:)
|
|
30
|
+
emit_ended(ended, reason: "revoked") if ended
|
|
31
|
+
return reject_or_none(actor:, session: session.reload, mode:)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Overlay.new(actor:, target:, session:, status: :active)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def reject_or_none(actor:, session:, mode:)
|
|
38
|
+
if mode.to_sym == :enforce
|
|
39
|
+
return Overlay.new(actor:, target: actor, session:, status: :expired)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Overlay.new(actor:, target: actor, session:, status: :none)
|
|
43
|
+
end
|
|
44
|
+
private_class_method :reject_or_none
|
|
45
|
+
|
|
46
|
+
def expire_open_session(actor:, session:, mode:)
|
|
47
|
+
reason = session.expiration_reason
|
|
48
|
+
if mode.to_sym == :enforce
|
|
49
|
+
ended = end_session(session, reason:, actor:)
|
|
50
|
+
emit_ended(ended, reason:) if ended
|
|
51
|
+
return Overlay.new(actor:, target: actor, session: session.reload, status: :expired)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
Overlay.new(actor:, target: actor, session:, status: :stale)
|
|
55
|
+
end
|
|
56
|
+
private_class_method :expire_open_session
|
|
57
|
+
|
|
58
|
+
def end_session(session, reason:, actor:)
|
|
59
|
+
result = CommandTower::Services::Impersonation::End.call(
|
|
60
|
+
session_id: session.id,
|
|
61
|
+
reason:,
|
|
62
|
+
actor_user_id: actor.id
|
|
63
|
+
)
|
|
64
|
+
return unless result.success? && result.data[:ended]
|
|
65
|
+
|
|
66
|
+
result.data[:session]
|
|
67
|
+
end
|
|
68
|
+
private_class_method :end_session
|
|
69
|
+
|
|
70
|
+
def emit_ended(session, reason:)
|
|
71
|
+
target = User.find_by(id: session.target_user_id)
|
|
72
|
+
return if target.nil?
|
|
73
|
+
|
|
74
|
+
CommandTower::Impersonation::ClearOverlayForAudit.call(actor_user_id: session.actor_user_id) do
|
|
75
|
+
CommandTower::Audit::Emit.call(
|
|
76
|
+
name: :impersonation_ended,
|
|
77
|
+
subject: target,
|
|
78
|
+
affected_user: target,
|
|
79
|
+
changes: { reason: { from: nil, to: reason } },
|
|
80
|
+
metadata: { impersonation_session_id: session.id },
|
|
81
|
+
attribution_mode: :admin_direct
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
private_class_method :emit_ended
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|