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.
- 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 +36 -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/README.md +1 -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 +112 -2
- 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
|