command_tower 0.11.1 → 0.12.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/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb +115 -0
- 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/docs/admin_workspace.md +31 -11
- data/docs/upgrades/0.12.0.md +26 -0
- data/docs/upgrades/README.md +1 -0
- data/lib/command_tower/configuration/admin_scope/tool_registration.rb +17 -2
- data/lib/command_tower/version.rb +1 -1
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 478979c1e8cabe23c8a2b666d7d42ec610abef048a302f4e8e7a5b5b7d9d74b0
|
|
4
|
+
data.tar.gz: 82a5c9c35c78c0a22f1cddf038b203fd0d32433a78be139e386024971189fa05
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 22fe0f30d9fdd950b976dfad3cb8818bd51eb7cb4877a550fa1c58de69092e8e99ca940dc0c244c9c0343fb8d875b6ea2730f929ac722da7bcc980dabc45d1f0
|
|
7
|
+
data.tar.gz: b5db99a8faa23f2a9d872cae17354f4f005525e5105db924b338778912164fa52cab8c4456cc8c9ea0f6baab4a9a72de44243d713f0d4fa20abadd032294ecde
|
|
@@ -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,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
|
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
|
data/docs/upgrades/README.md
CHANGED
|
@@ -4,6 +4,7 @@ Host-facing upgrade / change summaries for CommandTower releases.
|
|
|
4
4
|
|
|
5
5
|
| Version | Summary |
|
|
6
6
|
|---------|---------|
|
|
7
|
+
| [0.12.0](0.12.0.md) | Intervention envelope serializers/deserializers; product-tool admin_scope without Users/Audit narrowing |
|
|
7
8
|
| [0.11.1](0.11.1.md) | Audit Event `attribute :json` (MariaDB); audit migration without `utf8mb4_0900_ai_ci` |
|
|
8
9
|
| [0.11.0](0.11.0.md) | Execution context, audit ledger, Admin Workspace, principal capabilities, impersonation, Admin Users |
|
|
9
10
|
| [0.10.0](0.10.0.md) | Modern Auth/Me/Messaging platform; SchemaHelper removal; host RBAC required |
|
|
@@ -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
|
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.12.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-24 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: phonelib
|
|
@@ -249,6 +249,7 @@ files:
|
|
|
249
249
|
- app/deserializers/command_tower/deserializers/clients/types.rb
|
|
250
250
|
- app/deserializers/command_tower/deserializers/coercion_result.rb
|
|
251
251
|
- app/deserializers/command_tower/deserializers/failure.rb
|
|
252
|
+
- app/deserializers/command_tower/deserializers/intervention/envelope_deserializer.rb
|
|
252
253
|
- app/deserializers/command_tower/deserializers/me/change_password_deserializer.rb
|
|
253
254
|
- app/deserializers/command_tower/deserializers/me/phone_verification/verify_deserializer.rb
|
|
254
255
|
- app/deserializers/command_tower/deserializers/me/pushover/credentials_deserializer.rb
|
|
@@ -353,6 +354,9 @@ files:
|
|
|
353
354
|
- app/serializers/command_tower/serializers/auth/user_serializer.rb
|
|
354
355
|
- app/serializers/command_tower/serializers/auth/username_availability_serializer.rb
|
|
355
356
|
- app/serializers/command_tower/serializers/impersonation/session_serializer.rb
|
|
357
|
+
- app/serializers/command_tower/serializers/intervention/blocker_serializer.rb
|
|
358
|
+
- app/serializers/command_tower/serializers/intervention/envelope_serializer.rb
|
|
359
|
+
- app/serializers/command_tower/serializers/intervention/remediation_serializer.rb
|
|
356
360
|
- app/serializers/command_tower/serializers/me/account_serializer.rb
|
|
357
361
|
- app/serializers/command_tower/serializers/me/change_password_response_serializer.rb
|
|
358
362
|
- app/serializers/command_tower/serializers/me/pushover_serializer.rb
|
|
@@ -760,6 +764,7 @@ files:
|
|
|
760
764
|
- docs/upgrades/0.10.0.md
|
|
761
765
|
- docs/upgrades/0.11.0.md
|
|
762
766
|
- docs/upgrades/0.11.1.md
|
|
767
|
+
- docs/upgrades/0.12.0.md
|
|
763
768
|
- docs/upgrades/README.md
|
|
764
769
|
- lib/command_tower.rb
|
|
765
770
|
- lib/command_tower/admin_scope.rb
|