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,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
|
-
|
|
9
|
+
raise Error, "Authorization entity [#{name}] already exists. Hosts must not redefine CommandTower-owned entities."
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
|
|
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:)
|