command_tower 0.11.1 → 0.13.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/app/controllers/command_tower/me/account_controller.rb +35 -0
- data/app/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb +115 -0
- data/app/deserializers/command_tower/deserializers/me/delete_account_deserializer.rb +31 -0
- data/app/errors/command_tower/errors/auth/account_deleted_error.rb +21 -0
- data/app/models/user.rb +20 -2
- data/app/serializers/command_tower/serializers/intervention/blocker_serializer.rb +24 -0
- data/app/serializers/command_tower/serializers/intervention/envelope_serializer.rb +24 -0
- data/app/serializers/command_tower/serializers/intervention/remediation_serializer.rb +19 -0
- data/app/serializers/command_tower/serializers/me/delete_account_response_serializer.rb +13 -0
- data/app/services/command_tower/jwt/authenticate_user.rb +1 -1
- data/app/services/command_tower/services/admin/users.rb +2 -2
- data/app/services/command_tower/services/auth/email_availability.rb +1 -1
- data/app/services/command_tower/services/auth/plain_text/login.rb +1 -1
- data/app/services/command_tower/services/auth/purge_user_associated_data.rb +51 -0
- data/app/services/command_tower/services/auth/tombstone_user.rb +47 -0
- data/app/services/command_tower/services/me/capabilities.rb +6 -5
- data/app/services/command_tower/services/me/delete_account.rb +92 -0
- data/app/workflows/command_tower/workflows/me/delete_account_workflow.rb +34 -0
- data/config/routes.rb +1 -0
- data/db/migrate/20260826140000_add_deleted_at_to_users.rb +8 -0
- data/docs/admin_workspace.md +31 -11
- data/docs/upgrades/0.12.0.md +26 -0
- data/docs/upgrades/0.13.0.md +18 -0
- data/docs/upgrades/README.md +2 -0
- data/lib/command_tower/authorization/default.yml +3 -0
- data/lib/command_tower/configuration/account_deletion/config.rb +22 -0
- data/lib/command_tower/configuration/admin_scope/tool_registration.rb +17 -2
- data/lib/command_tower/configuration/config.rb +6 -0
- data/lib/command_tower/configuration/registry/audit/config.rb +14 -0
- data/lib/command_tower/install/baseline.rb +1 -0
- data/lib/command_tower/version.rb +1 -1
- metadata +18 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7116094a133d9fc9edaf8cc0851bde09a3c2bebacded3ea64b1f4a7b4f445db
|
|
4
|
+
data.tar.gz: e4880f05a6c601fb07349d44d27d3a746e10051e8b4ef2b701dc961998862fc4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3633188b6f62b92945cf142ea65fff20a596420b9c0cc4f907d4688732b4d03f283843277e36ea662091b097f71d39d348f4bd06822604f2dace486b3484df38
|
|
7
|
+
data.tar.gz: 749c9b86dd4919c072e1a29af779a84bcd65892f1c95b80f06ded36b4e30844c9598eabf050902d13679cfe911c6b6214898236afd6472ef61fb17370a8b7757
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Me
|
|
5
|
+
class AccountController < CommandTower::ApplicationController
|
|
6
|
+
include CommandTower::Auth::AuthenticationBoundary
|
|
7
|
+
include CommandTower::Auth::AuthorizationBoundary
|
|
8
|
+
|
|
9
|
+
before_action :authenticate_request!
|
|
10
|
+
before_action :authorize_request!
|
|
11
|
+
|
|
12
|
+
def destroy
|
|
13
|
+
deserialized = CommandTower::Deserializers::Me::DeleteAccountDeserializer.call(params)
|
|
14
|
+
return render_deserializer_errors unless deserialized.success?
|
|
15
|
+
|
|
16
|
+
result = CommandTower::Workflows::Me::DeleteAccountWorkflow.call(
|
|
17
|
+
current_user: current_user,
|
|
18
|
+
password: deserialized.input.password
|
|
19
|
+
)
|
|
20
|
+
render_application_result(result)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def render_deserializer_errors
|
|
26
|
+
render_application_result(
|
|
27
|
+
CommandTower::Workflows::WorkflowResult.failure(
|
|
28
|
+
errors: [CommandTower::Errors::ValidationError.new(details: { base: "Missing required fields" })],
|
|
29
|
+
http_status: :unprocessable_entity
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Deserializers
|
|
5
|
+
module Intervention
|
|
6
|
+
# Parses a canonical intervention envelope from untrusted params/JSON.
|
|
7
|
+
class EnvelopeDeserializer < CommandTower::Deserializers::ApplicationDeserializer
|
|
8
|
+
Input = Data.define(:action, :allowed, :blockers)
|
|
9
|
+
|
|
10
|
+
def call(params)
|
|
11
|
+
source = normalize_source(params)
|
|
12
|
+
action = required_text(source[:action] || source["action"], field: "action")
|
|
13
|
+
return action if deserializer_result?(action)
|
|
14
|
+
|
|
15
|
+
allowed_raw = source[:allowed].nil? ? source["allowed"] : source[:allowed]
|
|
16
|
+
allowed = allowed_raw == true || allowed_raw.to_s == "true"
|
|
17
|
+
|
|
18
|
+
blockers_raw = source[:blockers] || source["blockers"] || []
|
|
19
|
+
unless blockers_raw.is_a?(Array)
|
|
20
|
+
return failure(errors: [{ field: "blockers", code: "invalid_type", message: "must be an array" }])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
blockers = []
|
|
24
|
+
blockers_raw.each_with_index do |row, index|
|
|
25
|
+
parsed = parse_blocker(row, index)
|
|
26
|
+
return parsed if deserializer_result?(parsed)
|
|
27
|
+
|
|
28
|
+
blockers << parsed
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
success(Input.new(action: action, allowed: allowed, blockers: blockers))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def normalize_source(params)
|
|
37
|
+
return params.to_unsafe_h if params.respond_to?(:to_unsafe_h)
|
|
38
|
+
return params.to_h if params.respond_to?(:to_h)
|
|
39
|
+
|
|
40
|
+
params.is_a?(Hash) ? params : {}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def required_text(raw, field:)
|
|
44
|
+
value = raw.is_a?(String) ? raw.strip : raw.to_s.strip
|
|
45
|
+
if value.empty?
|
|
46
|
+
return failure(errors: [{ field: field, code: "missing_required_fields", message: "is required" }])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse_blocker(row, index)
|
|
53
|
+
unless row.is_a?(Hash)
|
|
54
|
+
return failure(
|
|
55
|
+
errors: [{ field: "blockers[#{index}]", code: "invalid_type", message: "must be an object" }]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
code = required_text(row[:code] || row["code"], field: "blockers[#{index}].code")
|
|
60
|
+
return code if deserializer_result?(code)
|
|
61
|
+
|
|
62
|
+
action = required_text(row[:action] || row["action"], field: "blockers[#{index}].action")
|
|
63
|
+
return action if deserializer_result?(action)
|
|
64
|
+
|
|
65
|
+
title = required_text(row[:title] || row["title"], field: "blockers[#{index}].title")
|
|
66
|
+
return title if deserializer_result?(title)
|
|
67
|
+
|
|
68
|
+
message = required_text(row[:message] || row["message"], field: "blockers[#{index}].message")
|
|
69
|
+
return message if deserializer_result?(message)
|
|
70
|
+
|
|
71
|
+
blocker = {
|
|
72
|
+
code: code,
|
|
73
|
+
action: action,
|
|
74
|
+
title: title,
|
|
75
|
+
message: message
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
severity = row[:severity] || row["severity"]
|
|
79
|
+
blocker[:severity] = severity.to_s if severity.present?
|
|
80
|
+
|
|
81
|
+
remediation_raw = row[:remediation] || row["remediation"]
|
|
82
|
+
if remediation_raw.present?
|
|
83
|
+
remediation = parse_remediation(remediation_raw, index)
|
|
84
|
+
return remediation if deserializer_result?(remediation)
|
|
85
|
+
|
|
86
|
+
blocker[:remediation] = remediation
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
blocker
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_remediation(row, index)
|
|
93
|
+
unless row.is_a?(Hash)
|
|
94
|
+
return failure(
|
|
95
|
+
errors: [
|
|
96
|
+
{ field: "blockers[#{index}].remediation", code: "invalid_type", message: "must be an object" }
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
kind = required_text(row[:kind] || row["kind"], field: "blockers[#{index}].remediation.kind")
|
|
102
|
+
return kind if deserializer_result?(kind)
|
|
103
|
+
|
|
104
|
+
action = required_text(row[:action] || row["action"], field: "blockers[#{index}].remediation.action")
|
|
105
|
+
return action if deserializer_result?(action)
|
|
106
|
+
|
|
107
|
+
remediation = { kind: kind, action: action }
|
|
108
|
+
label = row[:label] || row["label"]
|
|
109
|
+
remediation[:label] = label.to_s if label.present?
|
|
110
|
+
remediation
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Deserializers
|
|
5
|
+
module Me
|
|
6
|
+
class DeleteAccountDeserializer < CommandTower::Deserializers::ApplicationDeserializer
|
|
7
|
+
Input = Data.define(:password)
|
|
8
|
+
|
|
9
|
+
def call(params)
|
|
10
|
+
password = params[:password]
|
|
11
|
+
|
|
12
|
+
if blank?(password)
|
|
13
|
+
return failure(errors: { message: "missing_required_fields" })
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
unless password.is_a?(String)
|
|
17
|
+
return failure(errors: { message: "invalid_field_types" })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
success(Input.new(password:))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def blank?(value)
|
|
26
|
+
value.nil? || (value.is_a?(String) && value.strip.empty?)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Errors
|
|
5
|
+
module Auth
|
|
6
|
+
class AccountDeletedError < CommandTower::Errors::UnauthorizedError
|
|
7
|
+
def code
|
|
8
|
+
"account_deleted"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def message
|
|
12
|
+
"Account has been deleted"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def log_level
|
|
16
|
+
:info
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/app/models/user.rb
CHANGED
|
@@ -33,8 +33,14 @@ require "securerandom"
|
|
|
33
33
|
class User < CommandTower::ApplicationRecord
|
|
34
34
|
has_secure_password
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
36
|
+
TOMBSTONE_EMAIL_DOMAIN = "@invalid.local"
|
|
37
|
+
TOMBSTONE_EMAIL_PREFIX = "deleted+"
|
|
38
|
+
TOMBSTONE_USERNAME_PREFIX = "deleted_"
|
|
39
|
+
|
|
40
|
+
scope :not_deleted, -> { where(deleted_at: nil) }
|
|
41
|
+
|
|
42
|
+
validates :username, uniqueness: { conditions: -> { not_deleted } }
|
|
43
|
+
validates :email, uniqueness: { conditions: -> { not_deleted } }
|
|
38
44
|
|
|
39
45
|
###
|
|
40
46
|
# Serialize the roles column to check for inclusion easily
|
|
@@ -49,6 +55,18 @@ class User < CommandTower::ApplicationRecord
|
|
|
49
55
|
"#{first_name} #{last_name}"
|
|
50
56
|
end
|
|
51
57
|
|
|
58
|
+
def deleted?
|
|
59
|
+
deleted_at.present?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.tombstone_email_for(id)
|
|
63
|
+
"#{TOMBSTONE_EMAIL_PREFIX}#{id}#{TOMBSTONE_EMAIL_DOMAIN}"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.tombstone_username_for(id)
|
|
67
|
+
"#{TOMBSTONE_USERNAME_PREFIX}#{id}"
|
|
68
|
+
end
|
|
69
|
+
|
|
52
70
|
def reset_verifier_token!
|
|
53
71
|
value = SecureRandom.alphanumeric(32)
|
|
54
72
|
update!(verifier_token: value, verifier_token_last_reset: Time.now)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Serializers
|
|
5
|
+
module Intervention
|
|
6
|
+
# One presentation-independent blocker fact. No presentation mode / routes / host policy.
|
|
7
|
+
class BlockerSerializer < CommandTower::Serializers::ApplicationSerializer
|
|
8
|
+
def self.serialize(code:, action:, title:, message:, remediation: nil, severity: nil)
|
|
9
|
+
payload = {
|
|
10
|
+
code: code.to_s,
|
|
11
|
+
action: action.to_s,
|
|
12
|
+
title: title.to_s,
|
|
13
|
+
message: message.to_s
|
|
14
|
+
}
|
|
15
|
+
payload[:severity] = severity.to_s if severity.present?
|
|
16
|
+
if remediation
|
|
17
|
+
payload[:remediation] = RemediationSerializer.serialize(**remediation)
|
|
18
|
+
end
|
|
19
|
+
payload
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Serializers
|
|
5
|
+
module Intervention
|
|
6
|
+
# Action-scoped intervention envelope for GET projections and mutation errors.
|
|
7
|
+
# `blockers` ordered; primary is first. No presentation-mode fields.
|
|
8
|
+
# Host/CT FE owns presentation modes (callout, intercept, sheet, blocking_region).
|
|
9
|
+
class EnvelopeSerializer < CommandTower::Serializers::ApplicationSerializer
|
|
10
|
+
def self.serialize(action:, allowed:, blockers: [])
|
|
11
|
+
{
|
|
12
|
+
action: action.to_s,
|
|
13
|
+
allowed: allowed == true,
|
|
14
|
+
blockers: map_serialize(blockers) { |blocker| BlockerSerializer.serialize(**blocker) }
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.serialize_many(envelopes)
|
|
19
|
+
map_serialize(envelopes) { |envelope| serialize(**envelope) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Serializers
|
|
5
|
+
module Intervention
|
|
6
|
+
# Canonical remediation facts (presentation-independent). Host maps `action` to routes.
|
|
7
|
+
class RemediationSerializer < CommandTower::Serializers::ApplicationSerializer
|
|
8
|
+
def self.serialize(kind:, action:, label: nil)
|
|
9
|
+
payload = {
|
|
10
|
+
kind: kind.to_s,
|
|
11
|
+
action: action.to_s
|
|
12
|
+
}
|
|
13
|
+
payload[:label] = label.to_s if label.present?
|
|
14
|
+
payload
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -27,7 +27,7 @@ module CommandTower::Jwt
|
|
|
27
27
|
return invalid_token if expires_at.nil?
|
|
28
28
|
|
|
29
29
|
user = User.find_by(id: payload[:user_id])
|
|
30
|
-
if user.nil?
|
|
30
|
+
if user.nil? || user.deleted?
|
|
31
31
|
log_warn("user_id [#{payload[:user_id]}] was not found. Cannot Continue")
|
|
32
32
|
return invalid_token
|
|
33
33
|
end
|
|
@@ -12,7 +12,7 @@ module CommandTower
|
|
|
12
12
|
validate :scope_context, is_a: [CommandTower::AdminScope::ScopeContext, NilClass], required: false
|
|
13
13
|
|
|
14
14
|
def call
|
|
15
|
-
relation = ::User.order(id: :desc)
|
|
15
|
+
relation = ::User.not_deleted.order(id: :desc)
|
|
16
16
|
relation = CommandTower::AdminScope::ApplyUsersNarrowing.call(
|
|
17
17
|
relation:,
|
|
18
18
|
scope_context:,
|
|
@@ -48,7 +48,7 @@ module CommandTower
|
|
|
48
48
|
validate :scope_context, is_a: [CommandTower::AdminScope::ScopeContext, NilClass], required: false
|
|
49
49
|
|
|
50
50
|
def call
|
|
51
|
-
relation = ::User.where(id:)
|
|
51
|
+
relation = ::User.not_deleted.where(id:)
|
|
52
52
|
relation = CommandTower::AdminScope::ApplyUsersNarrowing.call(
|
|
53
53
|
relation:,
|
|
54
54
|
scope_context:,
|
|
@@ -9,7 +9,7 @@ module CommandTower
|
|
|
9
9
|
validate :password, is_a: String, required: true, sensitive: true
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
|
-
user = User.where(username: identifier).or(User.where(email: identifier)).first
|
|
12
|
+
user = User.not_deleted.where(username: identifier).or(User.not_deleted.where(email: identifier)).first
|
|
13
13
|
if user.nil?
|
|
14
14
|
audit_login_failed(affected_user: nil, outcome: "unknown_identifier")
|
|
15
15
|
invalid_credentials!
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Services
|
|
5
|
+
module Auth
|
|
6
|
+
class PurgeUserAssociatedData < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :user, is_a: User, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
purge_user_secrets!
|
|
11
|
+
purge_messaging_inbox!
|
|
12
|
+
purge_messaging_endpoints!
|
|
13
|
+
revoke_impersonation_sessions!
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def purge_user_secrets!
|
|
19
|
+
UserSecret.where(user_id: user.id).delete_all
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def purge_messaging_inbox!
|
|
23
|
+
item_ids = CommandTower::Messaging::Inbox::Scope.for_recipient(user.id).pluck(:id)
|
|
24
|
+
return if item_ids.empty?
|
|
25
|
+
|
|
26
|
+
CommandTower::Messaging::Inbox.bulk_delete(recipient_id: user.id, inbox_item_ids: item_ids)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def purge_messaging_endpoints!
|
|
30
|
+
return unless defined?(CommandTower::Messaging::Endpoint)
|
|
31
|
+
|
|
32
|
+
CommandTower::Messaging::Endpoint.where(user_id: user.id).find_each(&:destroy!)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def revoke_impersonation_sessions!
|
|
36
|
+
CommandTower::Services::Impersonation::TerminateOpenSessions.call(
|
|
37
|
+
actor_user_id: user.id,
|
|
38
|
+
reason: "revoked"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
CommandTower::Impersonation::Session.open.where(target_user_id: user.id).find_each do |session|
|
|
42
|
+
CommandTower::Services::Impersonation::End.call(
|
|
43
|
+
session_id: session.id,
|
|
44
|
+
reason: "revoked"
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Services
|
|
5
|
+
module Auth
|
|
6
|
+
class TombstoneUser < CommandTower::Services::ApplicationService
|
|
7
|
+
validate :user, is_a: User, required: true
|
|
8
|
+
|
|
9
|
+
def call
|
|
10
|
+
if user.deleted?
|
|
11
|
+
context.user = user
|
|
12
|
+
context.changed = false
|
|
13
|
+
return
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
user_id = user.id
|
|
17
|
+
scrub_password = SecureRandom.hex(32)
|
|
18
|
+
user.assign_attributes(
|
|
19
|
+
deleted_at: Time.current,
|
|
20
|
+
first_name: "",
|
|
21
|
+
last_name: "",
|
|
22
|
+
email: User.tombstone_email_for(user_id),
|
|
23
|
+
username: User.tombstone_username_for(user_id),
|
|
24
|
+
phone_number: nil,
|
|
25
|
+
phone_number_validated: false,
|
|
26
|
+
email_validated: false,
|
|
27
|
+
roles: []
|
|
28
|
+
)
|
|
29
|
+
user.password = scrub_password
|
|
30
|
+
user.password_confirmation = scrub_password
|
|
31
|
+
|
|
32
|
+
unless user.save(validate: false)
|
|
33
|
+
context.fail!(
|
|
34
|
+
application_error: CommandTower::Errors::InternalError.new
|
|
35
|
+
)
|
|
36
|
+
return
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
user.reset_verifier_token!
|
|
40
|
+
|
|
41
|
+
context.user = user.reload
|
|
42
|
+
context.changed = true
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -15,14 +15,15 @@ module CommandTower
|
|
|
15
15
|
|
|
16
16
|
def project
|
|
17
17
|
{
|
|
18
|
-
editName: { enabled:
|
|
18
|
+
editName: { enabled: !user.deleted? },
|
|
19
19
|
editUsername: { enabled: false },
|
|
20
20
|
changeEmail: { enabled: false },
|
|
21
|
-
changePassword: { enabled:
|
|
22
|
-
editPhone: { enabled: sms_enabled? },
|
|
23
|
-
editPushover: { enabled: pushover_enabled? },
|
|
21
|
+
changePassword: { enabled: !user.deleted? },
|
|
22
|
+
editPhone: { enabled: sms_enabled? && !user.deleted? },
|
|
23
|
+
editPushover: { enabled: pushover_enabled? && !user.deleted? },
|
|
24
24
|
logoutAllDevices: { enabled: false },
|
|
25
|
-
verifyEmail: { enabled: !user.email_validated }
|
|
25
|
+
verifyEmail: { enabled: !user.deleted? && !user.email_validated },
|
|
26
|
+
deleteAccount: { enabled: !user.deleted? }
|
|
26
27
|
}
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Services
|
|
5
|
+
module Me
|
|
6
|
+
class DeleteAccount < CommandTower::Services::ApplicationService
|
|
7
|
+
FIELD_KEYS = {
|
|
8
|
+
password: :password
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
validate :user, is_a: User, required: true
|
|
12
|
+
validate :password, is_a: String, required: true, sensitive: true
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
if user.deleted?
|
|
16
|
+
context.fail!(
|
|
17
|
+
application_error: CommandTower::Errors::Auth::AccountDeletedError.new
|
|
18
|
+
)
|
|
19
|
+
return
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
reject!(password: "Incorrect password") unless user.authenticate(password)
|
|
23
|
+
|
|
24
|
+
infrastructure_failure = false
|
|
25
|
+
|
|
26
|
+
ActiveRecord::Base.transaction do
|
|
27
|
+
unless invoke_host_finalizer!
|
|
28
|
+
infrastructure_failure = true
|
|
29
|
+
raise ActiveRecord::Rollback
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
purge_result = CommandTower::Services::Auth::PurgeUserAssociatedData.call(user:)
|
|
33
|
+
unless purge_result.success?
|
|
34
|
+
infrastructure_failure = true
|
|
35
|
+
raise ActiveRecord::Rollback
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
tombstone_result = CommandTower::Services::Auth::TombstoneUser.call(user:)
|
|
39
|
+
unless tombstone_result.success?
|
|
40
|
+
infrastructure_failure = true
|
|
41
|
+
raise ActiveRecord::Rollback
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
user.reload
|
|
45
|
+
audit(
|
|
46
|
+
:account_deleted,
|
|
47
|
+
subject: user,
|
|
48
|
+
affected_user: user,
|
|
49
|
+
changes: {},
|
|
50
|
+
metadata: { mechanism: "self_service" }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
if infrastructure_failure
|
|
55
|
+
context.fail!(application_error: CommandTower::Errors::InternalError.new)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
user.reload
|
|
60
|
+
unless user.deleted?
|
|
61
|
+
log_error("Account deletion transaction did not persist for user [#{user.id}]")
|
|
62
|
+
context.fail!(application_error: CommandTower::Errors::InternalError.new)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
log_info("Account deleted for user [#{user.id}]")
|
|
66
|
+
context.message = "Your account has been deleted"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def invoke_host_finalizer!
|
|
72
|
+
finalizer = CommandTower.config.account_deletion.host_finalizer
|
|
73
|
+
return true if finalizer.nil?
|
|
74
|
+
|
|
75
|
+
outcome = finalizer.call(user:)
|
|
76
|
+
return true if outcome.nil?
|
|
77
|
+
return true if outcome.respond_to?(:success?) && outcome.success?
|
|
78
|
+
|
|
79
|
+
false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def reject!(errors)
|
|
83
|
+
details = errors.each_with_object({}) do |(key, message), mapped|
|
|
84
|
+
mapped[FIELD_KEYS.fetch(key.to_sym, key)] = message
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
context.fail!(application_error: CommandTower::Errors::ValidationError.new(details:))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module CommandTower
|
|
4
|
+
module Workflows
|
|
5
|
+
module Me
|
|
6
|
+
class DeleteAccountWorkflow < CommandTower::Workflows::ApplicationWorkflow
|
|
7
|
+
retry_strategy :none
|
|
8
|
+
|
|
9
|
+
def call(current_user:, password:)
|
|
10
|
+
delete_result = CommandTower::Services::Me::DeleteAccount.call(
|
|
11
|
+
user: current_user,
|
|
12
|
+
password:
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
unless delete_result.success?
|
|
16
|
+
error = delete_result.errors.first
|
|
17
|
+
return failure(
|
|
18
|
+
errors: delete_result.errors,
|
|
19
|
+
http_status: CommandTower::Workflows::Me::ErrorStatus.http_status_for(error)
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
success(
|
|
24
|
+
payload: CommandTower::Serializers::Me::DeleteAccountResponseSerializer.serialize(
|
|
25
|
+
message: delete_result.data[:message]
|
|
26
|
+
),
|
|
27
|
+
http_status: :ok,
|
|
28
|
+
response_effects: { clear_token: true }
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
data/config/routes.rb
CHANGED
|
@@ -60,6 +60,7 @@ CommandTower::Engine.routes.draw do
|
|
|
60
60
|
namespace :me do
|
|
61
61
|
patch "name", to: "name#update"
|
|
62
62
|
patch "password", to: "password#update"
|
|
63
|
+
delete "account", to: "account#destroy"
|
|
63
64
|
|
|
64
65
|
resources :inbox, only: [:index, :show, :destroy], controller: "inbox" do
|
|
65
66
|
collection do
|
data/docs/admin_workspace.md
CHANGED
|
@@ -46,7 +46,9 @@ Hosts configure scope behavior on `CommandTower.config.admin_scope` (see [Resour
|
|
|
46
46
|
|
|
47
47
|
## Resource scoping
|
|
48
48
|
|
|
49
|
-
Optional **host-defined resource scoping**
|
|
49
|
+
Optional **host-defined resource scoping** selects a scope value before entering a tool and, for Users/Audit, narrows Admin tool data after RBAC. CommandTower remains **domain-blind** — it does not know League, Season, or tenant semantics.
|
|
50
|
+
|
|
51
|
+
Every `admin_scope.register` requires **`options`**, **`validate`**, and **`availability`**. Resource-narrowing hooks (`narrow_users`, `narrow_audit`, `affected_users_in_scope`) are required for CT-owned **`users`** / **`audit`** (or whenever any narrowing hook is set). Product Admin host tools may register the three base hooks only — scope drives Workspace invocation (0/1/N), not CT SQL narrowing.
|
|
50
52
|
|
|
51
53
|
```ruby
|
|
52
54
|
CommandTower.configure do |config|
|
|
@@ -64,23 +66,41 @@ CommandTower.configure do |config|
|
|
|
64
66
|
registration.narrow_audit = ->(relation:, scope_value:, principal:, tool_id:) { relation }
|
|
65
67
|
registration.affected_users_in_scope = ->(scope_value:, principal:, tool_id:) { [] }
|
|
66
68
|
end
|
|
69
|
+
|
|
70
|
+
# Product Admin host tool — invocation scope only (no Users/Audit narrowing).
|
|
71
|
+
config.registry.admin_workspace.tool :example_product_admin do |tool|
|
|
72
|
+
tool.label = "Example Product Admin"
|
|
73
|
+
tool.route = "/admin/example-product"
|
|
74
|
+
tool.group = :product
|
|
75
|
+
tool.sort_order = 300
|
|
76
|
+
tool.required_entity = :host_example_entity
|
|
77
|
+
tool.scope_required = true
|
|
78
|
+
tool.scope_parameter = "resource_slug"
|
|
79
|
+
tool.scope_label = "Resource"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
config.admin_scope.register(:example_product_admin) do |registration|
|
|
83
|
+
registration.options = ->(principal:) { [CommandTower::AdminScope::ScopeOption.new(value: "x", label: "X")] }
|
|
84
|
+
registration.validate = ->(value:, principal:) { value.to_s == "x" }
|
|
85
|
+
registration.availability = ->(principal:) { { enabled: true, reason: nil } }
|
|
86
|
+
end
|
|
67
87
|
end
|
|
68
88
|
```
|
|
69
89
|
|
|
70
|
-
| Hook | Purpose |
|
|
71
|
-
|
|
72
|
-
| `options` | Eager scope choices for manifest (`scopeOptions`) |
|
|
73
|
-
| `availability` | `{ enabled:, reason: }` — disabled tools render non-navigable in FE |
|
|
74
|
-
| `validate` | Authorize requested scope value; fail closed → **403** |
|
|
75
|
-
| `narrow_users` | SQL narrowing for Users list/show |
|
|
76
|
-
| `narrow_audit` | SQL narrowing for host-scoped audit rows |
|
|
77
|
-
| `affected_users_in_scope` | User ids for eligible **global** audit events in scoped admin views |
|
|
90
|
+
| Hook | Purpose | Required |
|
|
91
|
+
|------|---------|----------|
|
|
92
|
+
| `options` | Eager scope choices for manifest (`scopeOptions`) | Always |
|
|
93
|
+
| `availability` | `{ enabled:, reason: }` — disabled tools render non-navigable in FE | Always |
|
|
94
|
+
| `validate` | Authorize requested scope value; fail closed → **403** | Always |
|
|
95
|
+
| `narrow_users` | SQL narrowing for Users list/show | `users` / `audit` only |
|
|
96
|
+
| `narrow_audit` | SQL narrowing for host-scoped audit rows | `users` / `audit` only |
|
|
97
|
+
| `affected_users_in_scope` | User ids for eligible **global** audit events in scoped admin views | `users` / `audit` only |
|
|
78
98
|
|
|
79
|
-
**HTTP disclosure policy
|
|
99
|
+
**HTTP disclosure policy** (Users/Audit resource APIs): missing/malformed/unauthorized scope → **403**; authorized scope + resource absent from narrowed relation (nonexistent **or** out of scope) → **404** (indistinguishable).
|
|
80
100
|
|
|
81
101
|
Unscoped tools (`scope_required: false`, default) behave exactly as before — no scope query param, no `admin_scope` registration required.
|
|
82
102
|
|
|
83
|
-
Synthetic proof lives in `rails_app` (`FoundationProof::AdminScope`)
|
|
103
|
+
Synthetic proof lives in `rails_app` (`FoundationProof::AdminScope`). Hosts own product-scoped destinations via FE `resolveScopedDestination` — CT does not interpret path params.
|
|
84
104
|
|
|
85
105
|
## Seeded CommandTower tools
|
|
86
106
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Upgrade: CommandTower 0.12.0
|
|
2
|
+
|
|
3
|
+
**From:** `0.11.1`
|
|
4
|
+
**To:** `0.12.0`
|
|
5
|
+
|
|
6
|
+
Minor release: canonical action-intervention envelope ser/de, and Admin Scope registrations that do not require Users/Audit resource-narrowing for host product tools.
|
|
7
|
+
|
|
8
|
+
## Host-visible changes
|
|
9
|
+
|
|
10
|
+
| Change | Host impact |
|
|
11
|
+
|--------|-------------|
|
|
12
|
+
| Intervention envelope | `CommandTower::Serializers::Intervention::EnvelopeSerializer` / `EnvelopeDeserializer` (plus blocker/remediation serializers). Canonical `{ action, allowed, blockers[] }` with ordered blockers; **no** presentation-mode fields. Host/CT FE owns `callout` / `intercept` / `sheet` / `blocking_region`. |
|
|
13
|
+
| Admin Scope hook requirements | Every `admin_scope.register` still requires `options`, `validate`, and `availability`. Resource-narrowing hooks (`narrow_users`, `narrow_audit`, `affected_users_in_scope`) are required for CT-owned **`users`** / **`audit`**, or whenever any narrowing hook is set. Host product tools may register the three base hooks only. |
|
|
14
|
+
|
|
15
|
+
## Host actions
|
|
16
|
+
|
|
17
|
+
1. Bump gem to `0.12.0`.
|
|
18
|
+
2. **No new migration.**
|
|
19
|
+
3. To emit interventions from host workflows, serialize with `EnvelopeSerializer` (and `serialize_many` for action maps). Do not invent a parallel envelope shape.
|
|
20
|
+
4. Hosts with scoped product Admin tools can drop unused Users/Audit narrowing hooks from those registrations.
|
|
21
|
+
|
|
22
|
+
## Not in this release
|
|
23
|
+
|
|
24
|
+
- Frontend `ActionIntervention` presentation (ships in `@commandtower/frontend`)
|
|
25
|
+
- Changing Users/Audit narrowing requirements
|
|
26
|
+
- New HTTP routes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# CommandTower 0.13.0
|
|
2
|
+
|
|
3
|
+
Account self-service deletion (CANON-020 / M1).
|
|
4
|
+
|
|
5
|
+
## Host actions
|
|
6
|
+
|
|
7
|
+
1. `rails command_tower:install:migrations` (adds `users.deleted_at`)
|
|
8
|
+
2. `rails db:migrate`
|
|
9
|
+
3. Grant `me_account` to authenticated member role in `config/rbac_groups.yml`
|
|
10
|
+
4. Optional: register `config.account_deletion.host_finalizer` for product cleanup
|
|
11
|
+
|
|
12
|
+
## API
|
|
13
|
+
|
|
14
|
+
- `DELETE /api/me/account` — body `{ "password": "..." }`; clears session on success
|
|
15
|
+
|
|
16
|
+
## RBAC
|
|
17
|
+
|
|
18
|
+
New entity: `me_account` → `CommandTower::Me::AccountController#destroy`
|
data/docs/upgrades/README.md
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
Host-facing upgrade / change summaries for CommandTower releases.
|
|
4
4
|
|
|
5
5
|
| Version | Summary |
|
|
6
|
+
| [0.13.0](0.13.0.md) | Account self-service deletion (`DELETE /api/me/account`); tombstone + PII scrub; `me_account` RBAC; `users.deleted_at` migration |
|
|
6
7
|
|---------|---------|
|
|
8
|
+
| [0.12.0](0.12.0.md) | Intervention envelope serializers/deserializers; product-tool admin_scope without Users/Audit narrowing |
|
|
7
9
|
| [0.11.1](0.11.1.md) | Audit Event `attribute :json` (MariaDB); audit migration without `utf8mb4_0900_ai_ci` |
|
|
8
10
|
| [0.11.0](0.11.0.md) | Execution context, audit ledger, Admin Workspace, principal capabilities, impersonation, Admin Users |
|
|
9
11
|
| [0.10.0](0.10.0.md) | Modern Auth/Me/Messaging platform; SchemaHelper removal; host RBAC required |
|
|
@@ -25,6 +25,9 @@ entities:
|
|
|
25
25
|
- name: me_password
|
|
26
26
|
controller: CommandTower::Me::PasswordController
|
|
27
27
|
only: update
|
|
28
|
+
- name: me_account
|
|
29
|
+
controller: CommandTower::Me::AccountController
|
|
30
|
+
only: destroy
|
|
28
31
|
- name: me_inbox
|
|
29
32
|
controller: CommandTower::Me::InboxController
|
|
30
33
|
only:
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "class_composer"
|
|
4
|
+
require "command_tower/configuration/base"
|
|
5
|
+
|
|
6
|
+
module CommandTower
|
|
7
|
+
module Configuration
|
|
8
|
+
module AccountDeletion
|
|
9
|
+
class Config < ::CommandTower::Configuration::Base
|
|
10
|
+
include ClassComposer::Generator
|
|
11
|
+
|
|
12
|
+
add_composer :host_finalizer,
|
|
13
|
+
desc: "Optional host-owned callable invoked synchronously during account deletion " \
|
|
14
|
+
"before the user row is tombstoned. Receives keyword `user:` (non-deleted User). " \
|
|
15
|
+
"Return value is ignored; raise to abort the deletion transaction.",
|
|
16
|
+
allowed: [Proc, NilClass],
|
|
17
|
+
default: nil,
|
|
18
|
+
default_shown: "nil"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -4,13 +4,15 @@ module CommandTower
|
|
|
4
4
|
module Configuration
|
|
5
5
|
module AdminScope
|
|
6
6
|
class ToolRegistration
|
|
7
|
-
|
|
7
|
+
BASE_HOOKS = %i[options validate availability].freeze
|
|
8
|
+
RESOURCE_NARROWING_HOOKS = %i[narrow_users narrow_audit affected_users_in_scope].freeze
|
|
9
|
+
RESOURCE_SCOPED_TOOL_IDS = %w[users audit].freeze
|
|
8
10
|
|
|
9
11
|
attr_accessor :options, :validate, :availability, :narrow_users, :narrow_audit,
|
|
10
12
|
:affected_users_in_scope, :host_context_type
|
|
11
13
|
|
|
12
14
|
def validate!(tool_id:)
|
|
13
|
-
missing =
|
|
15
|
+
missing = required_hooks_for(tool_id).reject { |hook| callable?(public_send(hook)) }
|
|
14
16
|
return self if missing.empty?
|
|
15
17
|
|
|
16
18
|
raise CommandTower::AdminScope::InvalidToolRegistrationError,
|
|
@@ -19,6 +21,19 @@ module CommandTower
|
|
|
19
21
|
|
|
20
22
|
private
|
|
21
23
|
|
|
24
|
+
def required_hooks_for(tool_id)
|
|
25
|
+
hooks = BASE_HOOKS.dup
|
|
26
|
+
normalized = tool_id.to_s
|
|
27
|
+
if RESOURCE_SCOPED_TOOL_IDS.include?(normalized) || resource_narrowing_hooks_present?
|
|
28
|
+
hooks.concat(RESOURCE_NARROWING_HOOKS)
|
|
29
|
+
end
|
|
30
|
+
hooks
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resource_narrowing_hooks_present?
|
|
34
|
+
RESOURCE_NARROWING_HOOKS.any? { |hook| !public_send(hook).nil? }
|
|
35
|
+
end
|
|
36
|
+
|
|
22
37
|
def callable?(value)
|
|
23
38
|
value.respond_to?(:call)
|
|
24
39
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "singleton"
|
|
4
4
|
require "class_composer"
|
|
5
|
+
require "command_tower/configuration/account_deletion/config"
|
|
5
6
|
require "command_tower/configuration/admin/config"
|
|
6
7
|
require "command_tower/configuration/admin_scope/config"
|
|
7
8
|
require "command_tower/configuration/application/config"
|
|
@@ -91,6 +92,11 @@ module CommandTower
|
|
|
91
92
|
allowed: Configuration::Messaging::Config,
|
|
92
93
|
default: Configuration::Messaging::Config.new
|
|
93
94
|
|
|
95
|
+
add_composer :account_deletion,
|
|
96
|
+
desc: "Account self-service deletion host hooks",
|
|
97
|
+
allowed: Configuration::AccountDeletion::Config,
|
|
98
|
+
default: Configuration::AccountDeletion::Config.new
|
|
99
|
+
|
|
94
100
|
add_composer :admin,
|
|
95
101
|
desc: "Admin configuration for the app",
|
|
96
102
|
allowed: Configuration::Admin::Config,
|
|
@@ -64,6 +64,20 @@ module CommandTower
|
|
|
64
64
|
affected_user_required: true,
|
|
65
65
|
global_visible_in_host_scope: true
|
|
66
66
|
},
|
|
67
|
+
account_deleted: {
|
|
68
|
+
label: "Account deleted",
|
|
69
|
+
tags: %w[identity account deletion security],
|
|
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
|
+
},
|
|
67
81
|
email_verified: {
|
|
68
82
|
label: "Email verified",
|
|
69
83
|
tags: %w[identity verification email],
|
|
@@ -14,6 +14,7 @@ module CommandTower
|
|
|
14
14
|
20260816000001_create_command_tower_audit_events.rb
|
|
15
15
|
20260817000001_add_scope_columns_to_command_tower_audit_events.rb
|
|
16
16
|
20260817000003_create_command_tower_impersonation_sessions.rb
|
|
17
|
+
20260826140000_add_deleted_at_to_users.rb
|
|
17
18
|
].freeze
|
|
18
19
|
|
|
19
20
|
module_function
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: command_tower
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- matt-taylor
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-26 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: phonelib
|
|
@@ -209,6 +209,7 @@ files:
|
|
|
209
209
|
- app/controllers/command_tower/auth/session_controller.rb
|
|
210
210
|
- app/controllers/command_tower/auth/signup_session/create_controller.rb
|
|
211
211
|
- app/controllers/command_tower/auth/username/availability_controller.rb
|
|
212
|
+
- app/controllers/command_tower/me/account_controller.rb
|
|
212
213
|
- app/controllers/command_tower/me/audit_events_controller.rb
|
|
213
214
|
- app/controllers/command_tower/me/inbox_controller.rb
|
|
214
215
|
- app/controllers/command_tower/me/name_controller.rb
|
|
@@ -249,7 +250,9 @@ files:
|
|
|
249
250
|
- app/deserializers/command_tower/deserializers/clients/types.rb
|
|
250
251
|
- app/deserializers/command_tower/deserializers/coercion_result.rb
|
|
251
252
|
- app/deserializers/command_tower/deserializers/failure.rb
|
|
253
|
+
- app/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb
|
|
252
254
|
- app/deserializers/command_tower/deserializers/me/change_password_deserializer.rb
|
|
255
|
+
- app/deserializers/command_tower/deserializers/me/delete_account_deserializer.rb
|
|
253
256
|
- app/deserializers/command_tower/deserializers/me/phone_verification/verify_deserializer.rb
|
|
254
257
|
- app/deserializers/command_tower/deserializers/me/pushover/credentials_deserializer.rb
|
|
255
258
|
- app/deserializers/command_tower/deserializers/me/update_name_deserializer.rb
|
|
@@ -271,6 +274,7 @@ files:
|
|
|
271
274
|
- app/errors/command_tower/errors/account/pushover_verification_failed_error.rb
|
|
272
275
|
- app/errors/command_tower/errors/account/sms_capability_unavailable_error.rb
|
|
273
276
|
- app/errors/command_tower/errors/application_error.rb
|
|
277
|
+
- app/errors/command_tower/errors/auth/account_deleted_error.rb
|
|
274
278
|
- app/errors/command_tower/errors/auth/admin_unavailable_during_impersonation_error.rb
|
|
275
279
|
- app/errors/command_tower/errors/auth/csrf_mismatch_error.rb
|
|
276
280
|
- app/errors/command_tower/errors/auth/csrf_missing_error.rb
|
|
@@ -353,8 +357,12 @@ files:
|
|
|
353
357
|
- app/serializers/command_tower/serializers/auth/user_serializer.rb
|
|
354
358
|
- app/serializers/command_tower/serializers/auth/username_availability_serializer.rb
|
|
355
359
|
- app/serializers/command_tower/serializers/impersonation/session_serializer.rb
|
|
360
|
+
- app/serializers/command_tower/serializers/intervention/blocker_serializer.rb
|
|
361
|
+
- app/serializers/command_tower/serializers/intervention/envelope_serializer.rb
|
|
362
|
+
- app/serializers/command_tower/serializers/intervention/remediation_serializer.rb
|
|
356
363
|
- app/serializers/command_tower/serializers/me/account_serializer.rb
|
|
357
364
|
- app/serializers/command_tower/serializers/me/change_password_response_serializer.rb
|
|
365
|
+
- app/serializers/command_tower/serializers/me/delete_account_response_serializer.rb
|
|
358
366
|
- app/serializers/command_tower/serializers/me/pushover_serializer.rb
|
|
359
367
|
- app/serializers/command_tower/serializers/messaging/inbox.rb
|
|
360
368
|
- app/serializers/command_tower/serializers/messaging/preferences/catalog_serializer.rb
|
|
@@ -615,6 +623,7 @@ files:
|
|
|
615
623
|
- app/services/command_tower/services/auth/password_reset/validate.rb
|
|
616
624
|
- app/services/command_tower/services/auth/plain_text/login.rb
|
|
617
625
|
- app/services/command_tower/services/auth/principal_capabilities/project.rb
|
|
626
|
+
- app/services/command_tower/services/auth/purge_user_associated_data.rb
|
|
618
627
|
- app/services/command_tower/services/auth/register.rb
|
|
619
628
|
- app/services/command_tower/services/auth/signup_rate_limits/check_availability.rb
|
|
620
629
|
- app/services/command_tower/services/auth/signup_rate_limits/check_register.rb
|
|
@@ -624,6 +633,7 @@ files:
|
|
|
624
633
|
- app/services/command_tower/services/auth/signup_session/decode.rb
|
|
625
634
|
- app/services/command_tower/services/auth/signup_session/encode.rb
|
|
626
635
|
- app/services/command_tower/services/auth/signup_session/validate.rb
|
|
636
|
+
- app/services/command_tower/services/auth/tombstone_user.rb
|
|
627
637
|
- app/services/command_tower/services/auth/username_availability.rb
|
|
628
638
|
- app/services/command_tower/services/impersonation/create.rb
|
|
629
639
|
- app/services/command_tower/services/impersonation/end.rb
|
|
@@ -631,6 +641,7 @@ files:
|
|
|
631
641
|
- app/services/command_tower/services/impersonation/terminate_open_sessions.rb
|
|
632
642
|
- app/services/command_tower/services/me/capabilities.rb
|
|
633
643
|
- app/services/command_tower/services/me/change_password.rb
|
|
644
|
+
- app/services/command_tower/services/me/delete_account.rb
|
|
634
645
|
- app/services/command_tower/services/me/pushover_product_gate.rb
|
|
635
646
|
- app/services/command_tower/services/me/sms_product_gate.rb
|
|
636
647
|
- app/services/command_tower/services/me/update_name.rb
|
|
@@ -704,6 +715,7 @@ files:
|
|
|
704
715
|
- app/workflows/command_tower/workflows/impersonation/stop_workflow.rb
|
|
705
716
|
- app/workflows/command_tower/workflows/me/change_password_workflow.rb
|
|
706
717
|
- app/workflows/command_tower/workflows/me/clear_phone_workflow.rb
|
|
718
|
+
- app/workflows/command_tower/workflows/me/delete_account_workflow.rb
|
|
707
719
|
- app/workflows/command_tower/workflows/me/error_mapping.rb
|
|
708
720
|
- app/workflows/command_tower/workflows/me/error_status.rb
|
|
709
721
|
- app/workflows/command_tower/workflows/me/phone_verification/send_workflow.rb
|
|
@@ -735,6 +747,7 @@ files:
|
|
|
735
747
|
- db/migrate/20260816000001_create_command_tower_audit_events.rb
|
|
736
748
|
- db/migrate/20260817000001_add_scope_columns_to_command_tower_audit_events.rb
|
|
737
749
|
- db/migrate/20260817000003_create_command_tower_impersonation_sessions.rb
|
|
750
|
+
- db/migrate/20260826140000_add_deleted_at_to_users.rb
|
|
738
751
|
- docs/admin_workspace.md
|
|
739
752
|
- docs/api_reference.md
|
|
740
753
|
- docs/architecture.md
|
|
@@ -760,6 +773,8 @@ files:
|
|
|
760
773
|
- docs/upgrades/0.10.0.md
|
|
761
774
|
- docs/upgrades/0.11.0.md
|
|
762
775
|
- docs/upgrades/0.11.1.md
|
|
776
|
+
- docs/upgrades/0.12.0.md
|
|
777
|
+
- docs/upgrades/0.13.0.md
|
|
763
778
|
- docs/upgrades/README.md
|
|
764
779
|
- lib/command_tower.rb
|
|
765
780
|
- lib/command_tower/admin_scope.rb
|
|
@@ -782,6 +797,7 @@ files:
|
|
|
782
797
|
- lib/command_tower/authorization/effective_entity_grants.rb
|
|
783
798
|
- lib/command_tower/authorization/entity.rb
|
|
784
799
|
- lib/command_tower/authorization/role.rb
|
|
800
|
+
- lib/command_tower/configuration/account_deletion/config.rb
|
|
785
801
|
- lib/command_tower/configuration/admin/config.rb
|
|
786
802
|
- lib/command_tower/configuration/admin_scope/config.rb
|
|
787
803
|
- lib/command_tower/configuration/admin_scope/tool_registration.rb
|