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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/command_tower/me/account_controller.rb +35 -0
  3. data/app/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb +115 -0
  4. data/app/deserializers/command_tower/deserializers/me/delete_account_deserializer.rb +31 -0
  5. data/app/errors/command_tower/errors/auth/account_deleted_error.rb +21 -0
  6. data/app/models/user.rb +20 -2
  7. data/app/serializers/command_tower/serializers/intervention/blocker_serializer.rb +24 -0
  8. data/app/serializers/command_tower/serializers/intervention/envelope_serializer.rb +24 -0
  9. data/app/serializers/command_tower/serializers/intervention/remediation_serializer.rb +19 -0
  10. data/app/serializers/command_tower/serializers/me/delete_account_response_serializer.rb +13 -0
  11. data/app/services/command_tower/jwt/authenticate_user.rb +1 -1
  12. data/app/services/command_tower/services/admin/users.rb +2 -2
  13. data/app/services/command_tower/services/auth/email_availability.rb +1 -1
  14. data/app/services/command_tower/services/auth/plain_text/login.rb +1 -1
  15. data/app/services/command_tower/services/auth/purge_user_associated_data.rb +51 -0
  16. data/app/services/command_tower/services/auth/tombstone_user.rb +47 -0
  17. data/app/services/command_tower/services/me/capabilities.rb +6 -5
  18. data/app/services/command_tower/services/me/delete_account.rb +92 -0
  19. data/app/workflows/command_tower/workflows/me/delete_account_workflow.rb +34 -0
  20. data/config/routes.rb +1 -0
  21. data/db/migrate/20260826140000_add_deleted_at_to_users.rb +8 -0
  22. data/docs/admin_workspace.md +31 -11
  23. data/docs/upgrades/0.12.0.md +26 -0
  24. data/docs/upgrades/0.13.0.md +18 -0
  25. data/docs/upgrades/README.md +2 -0
  26. data/lib/command_tower/authorization/default.yml +3 -0
  27. data/lib/command_tower/configuration/account_deletion/config.rb +22 -0
  28. data/lib/command_tower/configuration/admin_scope/tool_registration.rb +17 -2
  29. data/lib/command_tower/configuration/config.rb +6 -0
  30. data/lib/command_tower/configuration/registry/audit/config.rb +14 -0
  31. data/lib/command_tower/install/baseline.rb +1 -0
  32. data/lib/command_tower/version.rb +1 -1
  33. metadata +18 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d495e295241fa7ac4f9179acc5fd2d4c47fcb8a0603dccdf5353ac08e7c98b28
4
- data.tar.gz: 9d56364ffd4a79ad9bcabe07e2713a2613b2d43112039bc5f31db855f3baad2d
3
+ metadata.gz: a7116094a133d9fc9edaf8cc0851bde09a3c2bebacded3ea64b1f4a7b4f445db
4
+ data.tar.gz: e4880f05a6c601fb07349d44d27d3a746e10051e8b4ef2b701dc961998862fc4
5
5
  SHA512:
6
- metadata.gz: f605fe2a8117b8e5b6af32dc7c65df55c9400a33a5d50220f5a0c992efd74f28545a8fbfe8c293982f062f398dcc3b86aa552de12d1ec87f733e5a2d6435169e
7
- data.tar.gz: 85072dd065a8589a239166286a2261a331a7cd9c59e0b0c7eb870e5bc60dcfdeb4d32df84671015a93d2edcd3a93592e7647210cf5f553c4a47a346abb214bdb
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
- validates :username, uniqueness: true
37
- validates :email, uniqueness: true
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
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Serializers
5
+ module Me
6
+ class DeleteAccountResponseSerializer
7
+ def self.serialize(message:)
8
+ { message: message }
9
+ end
10
+ end
11
+ end
12
+ end
13
+ 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:,
@@ -16,7 +16,7 @@ module CommandTower
16
16
  return
17
17
  end
18
18
 
19
- if ::User.exists?(email: normalized)
19
+ if ::User.not_deleted.exists?(email: normalized)
20
20
  context.valid = true
21
21
  context.available = false
22
22
  context.message = "Email is already registered"
@@ -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: true },
18
+ editName: { enabled: !user.deleted? },
19
19
  editUsername: { enabled: false },
20
20
  changeEmail: { enabled: false },
21
- changePassword: { enabled: true },
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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddDeletedAtToUsers < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :users, :deleted_at, :datetime
6
+ add_index :users, :deleted_at
7
+ end
8
+ end
@@ -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** narrows Admin tool data (Users, Audit, …) after RBAC. CommandTower remains **domain-blind** — it does not know League, Season, or tenant semantics.
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:** missing/malformed/unauthorized scope → **403**; authorized scope + resource absent from narrowed relation (nonexistent **or** out of scope) → **404** (indistinguishable).
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`); Pick'em League adoption is Phase 7 only.
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`
@@ -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
- REQUIRED_HOOKS = %i[options validate availability narrow_users narrow_audit affected_users_in_scope].freeze
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 = REQUIRED_HOOKS.reject { |hook| callable?(public_send(hook)) }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower
4
- VERSION = "0.11.1"
4
+ VERSION = "0.13.0"
5
5
  end
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.11.1
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-22 00:00:00.000000000 Z
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