hecks 1.5.0 → 1.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/deploy/bluebook/deploy.bluebook +105 -0
- data/lib/hecks/deploy/oidc.json +5 -0
- data/lib/hecks/framework/bluebook/compliance.bluebook +221 -0
- data/lib/hecks/ports/key_vault.rb +27 -0
- data/lib/hecks/projections/deploy/fargate.rb +666 -0
- data/lib/hecks/projections/deploy/lambda.rb +2423 -0
- data/lib/hecks/projections/deploy/shared.rb +624 -0
- data/lib/hecks/projections.rb +3 -0
- data/lib/hecks/projector/target.rb +18 -5
- data/lib/hecks/projector.rb +31 -17
- data/lib/hecks/version.rb +1 -1
- metadata +4 -1
- data/lib/hecks/framework/bluebook/compliance.bluebook +0 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 29c54ce86e36189a63578e970929e42c213d5812282c5cf5ca6e56820eb02938
|
|
4
|
+
data.tar.gz: 1d564ee387fb38c619fbdfef209bcc757570a492e70dac16f1d5fb80889c37e8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24794f4aaa4f5d2268f27e063b79a23b61c7dffe7b4a87b4b58f83db73188d57716fd734d695082bdf11d78d3101fc41640a45f76510fe7cc45fd9c3cd9347a8
|
|
7
|
+
data.tar.gz: 9ce51558bbe7c55e9dc451140a21d7c83b7073b8a692304579f15c76db3c56371cb90c7cac07b6c02e6eb64ddd73c27572e786ab941659d2a76e649de5d2a699
|
|
@@ -133,6 +133,111 @@ Hecks.bluebook "Deploy" do
|
|
|
133
133
|
end
|
|
134
134
|
end
|
|
135
135
|
|
|
136
|
+
# A sibling of `LambdaTarget`, not a shared shape with it — value objects
|
|
137
|
+
# are not shared across aggregates in this DSL (`Tenant`'s own
|
|
138
|
+
# `DomainName`/`Realm`/`Schema` below already make the same point), so
|
|
139
|
+
# this aggregate carries its own copies of `DomainName`/`Region`/
|
|
140
|
+
# `Database`/`Web` rather than reaching into `LambdaTarget`'s.
|
|
141
|
+
aggregate "FargateTarget" do
|
|
142
|
+
description "One domain's validated AWS Fargate deploy target — what deployed_to(\"AwsFargate\") must resolve to before bin/project_deploy generates anything from it."
|
|
143
|
+
|
|
144
|
+
identified_by :domain
|
|
145
|
+
|
|
146
|
+
attribute :domain, DomainName
|
|
147
|
+
attribute :region, Region
|
|
148
|
+
attribute :cpu, Cpu
|
|
149
|
+
attribute :memory, Memory
|
|
150
|
+
attribute :database, Database
|
|
151
|
+
attribute :web, Web
|
|
152
|
+
attribute :port, Port
|
|
153
|
+
|
|
154
|
+
value_object "DomainName" do
|
|
155
|
+
attribute :value, String
|
|
156
|
+
invariant("a domain is named") { !value.to_s.empty? }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
value_object "Region" do
|
|
160
|
+
attribute :value, String
|
|
161
|
+
invariant("a region is named") { !value.to_s.empty? }
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# A positive integer, nothing more — AWS's real cpu/memory
|
|
165
|
+
# combinations (256 cpu units needs 512-2048 MB of `memory`, say)
|
|
166
|
+
# are a pairing `ECS` itself enforces at deploy time; encoding that
|
|
167
|
+
# whole table here would duplicate AWS's own rules for no benefit
|
|
168
|
+
# this generator needs. This only catches a typo (zero, negative)
|
|
169
|
+
# before a single file is generated, the same floor `Memory`/`Port`
|
|
170
|
+
# below hold for their own ranges.
|
|
171
|
+
value_object "Cpu" do
|
|
172
|
+
attribute :value, Integer
|
|
173
|
+
invariant("cpu is positive") { value.positive? }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Fargate's own valid range, not Lambda's 128-10240 MB —
|
|
177
|
+
# `LambdaTarget`'s own `Memory` value object (above) is scoped to
|
|
178
|
+
# that aggregate alone, so this is a distinct value object under the
|
|
179
|
+
# same name rather than a shared one. A plain sanity floor and
|
|
180
|
+
# ceiling, not AWS's own cpu-paired table (see `Cpu`'s own comment).
|
|
181
|
+
value_object "Memory" do
|
|
182
|
+
attribute :value, Integer
|
|
183
|
+
invariant("memory is positive") { value.positive? }
|
|
184
|
+
invariant("memory is at most 122880 MB") { value <= 122880 }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Same shape and reasoning as `LambdaTarget`'s own `Database` —
|
|
188
|
+
# "Postgres"/"Aurora" provision this domain's own RDS instance;
|
|
189
|
+
# "Shared" borrows another already-deployed domain's instance,
|
|
190
|
+
# isolated by a native Postgres schema. See that value object's own
|
|
191
|
+
# comment for the full reasoning; it applies unchanged here.
|
|
192
|
+
value_object "Database" do
|
|
193
|
+
attribute :value, String, one_of: ["Postgres", "Aurora", "Shared"]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Same shape and reasoning as `LambdaTarget`'s own `Web` — "Rust"
|
|
197
|
+
# means the domain's own task also serves its public web UI
|
|
198
|
+
# in-process, over the port this aggregate's own `Port` attribute
|
|
199
|
+
# declares.
|
|
200
|
+
value_object "Web" do
|
|
201
|
+
attribute :value, String, one_of: ["None", "Rust"]
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# The container port the task listens on and the target group
|
|
205
|
+
# routes to — a real TCP port, not an arbitrary integer.
|
|
206
|
+
value_object "Port" do
|
|
207
|
+
attribute :value, Integer
|
|
208
|
+
invariant("a port is at least 1") { value >= 1 }
|
|
209
|
+
invariant("a port is at most 65535") { value <= 65535 }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# All seven required, for the identical reason `LambdaTarget.Declare`
|
|
213
|
+
# requires all of its own attributes — see that command's own
|
|
214
|
+
# comment on `normalize_args`/`coerce_declared_arguments`. This
|
|
215
|
+
# validates a fully-resolved target, after `bin/project_deploy`'s own
|
|
216
|
+
# defaulting (`cpu`/`memory`/`port` each fall back to a Ruby-side
|
|
217
|
+
# default before this ever dispatches), not instead of it.
|
|
218
|
+
command "Declare" do
|
|
219
|
+
goal "Validate one domain's AwsFargate deploy target before anything is generated from it"
|
|
220
|
+
|
|
221
|
+
attribute :domain, DomainName
|
|
222
|
+
attribute :region, Region
|
|
223
|
+
attribute :cpu, Cpu
|
|
224
|
+
attribute :memory, Memory
|
|
225
|
+
attribute :database, Database
|
|
226
|
+
attribute :web, Web
|
|
227
|
+
attribute :port, Port
|
|
228
|
+
|
|
229
|
+
sets :domain
|
|
230
|
+
sets :region
|
|
231
|
+
sets :cpu
|
|
232
|
+
sets :memory
|
|
233
|
+
sets :database
|
|
234
|
+
sets :web
|
|
235
|
+
sets :port
|
|
236
|
+
|
|
237
|
+
emits "FargateTargetDeclared"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
136
241
|
# `bin/project_tenant`'S OWN VALIDATED SHAPE — same split LambdaTarget
|
|
137
242
|
# draws with `bin/project_deploy`: this aggregate validates, that
|
|
138
243
|
# script GENERATES/ACTS (writes `environments/<slug>.world`, ensures
|
data/lib/hecks/deploy/oidc.json
CHANGED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
Hecks.bluebook "Compliance" do
|
|
2
|
+
vision "Something elsewhere already acted to contain a risk; this domain tracks the human review that decides what happens next."
|
|
3
|
+
supporting
|
|
4
|
+
|
|
5
|
+
# LIVES HERE (lib/hecks/framework/bluebook/), NOT under some
|
|
6
|
+
# domain's own examples/ directory, but that's a statement about
|
|
7
|
+
# SOURCE SHARING, not about how it's consumed — unlike Governance/
|
|
8
|
+
# Identity (which every real attaching domain only ever wants IN-
|
|
9
|
+
# PROCESS), Compliance genuinely supports BOTH modes from the same
|
|
10
|
+
# source: a domain that wants Review handled locally can
|
|
11
|
+
# `uses_framework "Compliance"` exactly like those two; a domain that
|
|
12
|
+
# wants it as a real, separately-deployed service — Banking's own
|
|
13
|
+
# choice, examples/compliance/ — reaches it through `across
|
|
14
|
+
# "Compliance"` on a policy instead, genuine cross-Lambda delivery.
|
|
15
|
+
# Nothing about this bluebook's own declarations changes between the
|
|
16
|
+
# two; only whether the CONSUMER merges it in or deploys it apart.
|
|
17
|
+
#
|
|
18
|
+
# examples/compliance/bluebook/compliance.bluebook is a SYMLINK back
|
|
19
|
+
# to this file (not the other way around, as it briefly was in
|
|
20
|
+
# 1.5.0) — a symlink pointing outside lib/ never survives `gem
|
|
21
|
+
# build` (RubyGems drops it, warning "not supported on all
|
|
22
|
+
# platforms"), so the real content has to live in the tree the
|
|
23
|
+
# gemspec actually packages.
|
|
24
|
+
#
|
|
25
|
+
# CONTAIN FIRST, INVESTIGATE SECOND — the real pattern this domain
|
|
26
|
+
# exists for. Banking's own "Compliance officer" role already acts
|
|
27
|
+
# directly and synchronously in three places (Customer.Suspend/
|
|
28
|
+
# Reinstate, Account.Freeze/Unfreeze, OnboardingCase.Clear/Decline) —
|
|
29
|
+
# this domain is deliberately NOT a fourth. By the time either
|
|
30
|
+
# aggregate below is opened, the containing action (a freeze, a box
|
|
31
|
+
# surrender) has ALREADY happened, unilaterally, elsewhere; this
|
|
32
|
+
# domain never asks permission, it is TOLD a fact and gives the same
|
|
33
|
+
# officer a place to record what they decided about it afterward.
|
|
34
|
+
#
|
|
35
|
+
# TWO AGGREGATES, NOT ONE — found live, designing this: Banking's own
|
|
36
|
+
# `ReviewOnFreeze`/`ReviewOnBoxSurrender` policies forward their
|
|
37
|
+
# triggering event's payload VERBATIM (neither declares a `with:`
|
|
38
|
+
# projection, which policies have gained since), and the two events
|
|
39
|
+
# carry genuinely different fields (`AccountFrozen`: `number`;
|
|
40
|
+
# `BoxSurrendered`: `branch_code` + `box_number`). One shared
|
|
41
|
+
# aggregate would need an artificial union of both shapes — and while
|
|
42
|
+
# `with:` could now reshape either side into it, that would be
|
|
43
|
+
# inventing a sameness the domain does not have. Two small aggregates,
|
|
44
|
+
# each identified by its own subject's own natural key, need neither.
|
|
45
|
+
|
|
46
|
+
aggregate "AccountFreezeReview" do
|
|
47
|
+
description "The compliance review a frozen account gets, opened the moment Banking::Account.Freeze already happened."
|
|
48
|
+
|
|
49
|
+
attribute :number, AccountNumber
|
|
50
|
+
|
|
51
|
+
identified_by :number
|
|
52
|
+
|
|
53
|
+
value_object "AccountNumber" do
|
|
54
|
+
attribute :value, String
|
|
55
|
+
invariant("an account number is present") { !value.to_s.empty? }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
lifecycle :status, default: "open" do
|
|
59
|
+
transition "Clear" => "cleared", from: "open"
|
|
60
|
+
transition "Escalate" => "escalated", from: "open"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# SYSTEM-TRIGGERED, matching `AccountFrozen`'s own payload shape
|
|
64
|
+
# field for field — `Banking::Account.AccountFrozen`'s `number` is
|
|
65
|
+
# the ONLY field this ever receives (ReviewOnFreeze's own `on
|
|
66
|
+
# "Account.AccountFrozen"`), so this declares exactly that and
|
|
67
|
+
# nothing more; an unrecognized field would refuse at the JSON
|
|
68
|
+
# boundary rather than silently drop it.
|
|
69
|
+
command "Open" do
|
|
70
|
+
role "System"
|
|
71
|
+
goal "Register that a frozen account needs compliance review"
|
|
72
|
+
|
|
73
|
+
sets :number
|
|
74
|
+
|
|
75
|
+
emits AccountFreezeReviewOpened
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
command "Clear" do
|
|
79
|
+
role "Compliance officer"
|
|
80
|
+
goal "Confirm the freeze was warranted, or resolve it, with nothing further to escalate"
|
|
81
|
+
|
|
82
|
+
reference_to AccountFreezeReview
|
|
83
|
+
|
|
84
|
+
emits AccountFreezeReviewCleared
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
command "Escalate" do
|
|
88
|
+
role "Compliance officer"
|
|
89
|
+
goal "Send a genuine finding on to whatever handles it next — a formal filing, a longer hold, a referral"
|
|
90
|
+
|
|
91
|
+
reference_to AccountFreezeReview
|
|
92
|
+
|
|
93
|
+
emits AccountFreezeReviewEscalated
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
aggregate "BoxSurrenderReview" do
|
|
98
|
+
description "The compliance review a surrendered safe deposit box gets — audit/escheatment implications, not a fraud contain the way a freeze is, but the same after-the-fact review shape."
|
|
99
|
+
|
|
100
|
+
attribute :branch_code, BranchCode
|
|
101
|
+
attribute :box_number, BoxNumber
|
|
102
|
+
|
|
103
|
+
identified_by :branch_code, :box_number
|
|
104
|
+
|
|
105
|
+
value_object "BranchCode" do
|
|
106
|
+
attribute :value, String
|
|
107
|
+
invariant("a branch is coded") { !value.to_s.empty? }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
value_object "BoxNumber" do
|
|
111
|
+
attribute :value, Integer
|
|
112
|
+
invariant("a box is numbered from one") { value.positive? }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
lifecycle :status, default: "open" do
|
|
116
|
+
transition "Clear" => "cleared", from: "open"
|
|
117
|
+
transition "Escalate" => "escalated", from: "open"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Matches `BoxSurrendered`'s own payload field for field
|
|
121
|
+
# (`ReviewOnBoxSurrender`'s own `on SafeDepositBox::BoxSurrendered`) — the same
|
|
122
|
+
# discipline `AccountFreezeReview.Open` holds itself to, above.
|
|
123
|
+
command "Open" do
|
|
124
|
+
role "System"
|
|
125
|
+
goal "Register that a surrendered box needs compliance review"
|
|
126
|
+
|
|
127
|
+
sets :branch_code
|
|
128
|
+
sets :box_number
|
|
129
|
+
|
|
130
|
+
emits BoxSurrenderReviewOpened
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
command "Clear" do
|
|
134
|
+
role "Compliance officer"
|
|
135
|
+
goal "Confirm the surrender needs no further compliance action"
|
|
136
|
+
|
|
137
|
+
reference_to BoxSurrenderReview
|
|
138
|
+
|
|
139
|
+
emits BoxSurrenderReviewCleared
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
command "Escalate" do
|
|
143
|
+
role "Compliance officer"
|
|
144
|
+
goal "Send a genuine finding on to whatever handles it next"
|
|
145
|
+
|
|
146
|
+
reference_to BoxSurrenderReview
|
|
147
|
+
|
|
148
|
+
emits BoxSurrenderReviewEscalated
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# A THIRD SHAPE, NOT A UNION OF THE OTHER TWO — the same restraint the
|
|
153
|
+
# header above holds Banking's own two events to. `Privacy::Marking`'s
|
|
154
|
+
# own `Marked` event carries `domain`/`attribute_path`, matching
|
|
155
|
+
# neither `AccountFrozen` (`number`) nor `BoxSurrendered`
|
|
156
|
+
# (`branch_code`/`box_number`), so this gets its own aggregate rather
|
|
157
|
+
# than an artificial union.
|
|
158
|
+
#
|
|
159
|
+
# REACHED VIA `translates`, NOT a `policy` this bluebook declares —
|
|
160
|
+
# which foreign domain's event a consumer conforms to is a wiring
|
|
161
|
+
# decision (see `HecksagonBuilder#translates`'s own header), so the
|
|
162
|
+
# reaction lives in whichever `.hecksagon` attaches both Privacy and
|
|
163
|
+
# Compliance, not here.
|
|
164
|
+
aggregate "PrivacyReview" do
|
|
165
|
+
description "The compliance review a newly marked sensitive field gets, opened the moment Privacy::Marking.Mark already happened."
|
|
166
|
+
|
|
167
|
+
attribute :domain, Domain
|
|
168
|
+
attribute :attribute_path, AttributePath
|
|
169
|
+
|
|
170
|
+
identified_by :domain, :attribute_path
|
|
171
|
+
|
|
172
|
+
value_object "Domain" do
|
|
173
|
+
attribute :value, String
|
|
174
|
+
invariant("a review names the domain whose attribute was marked") { !value.to_s.empty? }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
value_object "AttributePath" do
|
|
178
|
+
attribute :value, String
|
|
179
|
+
invariant("a review names the attribute that was marked") { !value.to_s.empty? }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
lifecycle :status, default: "open" do
|
|
183
|
+
transition "Clear" => "cleared", from: "open"
|
|
184
|
+
transition "Escalate" => "escalated", from: "open"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# SYSTEM-TRIGGERED, matching only `Privacy::Marking`'s own identity
|
|
188
|
+
# fields — the `translates` block that reacts to `Marked` forwards
|
|
189
|
+
# just `domain`/`attribute_path`, the same restraint
|
|
190
|
+
# `AccountFreezeReview.Open`/`BoxSurrenderReview.Open` already hold
|
|
191
|
+
# themselves to above; `category`/`readable_by` stay on the
|
|
192
|
+
# marking itself, not duplicated here.
|
|
193
|
+
command "Open" do
|
|
194
|
+
role "System"
|
|
195
|
+
goal "Register that a newly marked sensitive field needs compliance review"
|
|
196
|
+
|
|
197
|
+
sets :domain
|
|
198
|
+
sets :attribute_path
|
|
199
|
+
|
|
200
|
+
emits PrivacyReviewOpened
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
command "Clear" do
|
|
204
|
+
role "Compliance officer"
|
|
205
|
+
goal "Confirm the marking was warranted, or resolve it, with nothing further to escalate"
|
|
206
|
+
|
|
207
|
+
reference_to PrivacyReview
|
|
208
|
+
|
|
209
|
+
emits PrivacyReviewCleared
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
command "Escalate" do
|
|
213
|
+
role "Compliance officer"
|
|
214
|
+
goal "Send a genuine finding on to whatever handles it next"
|
|
215
|
+
|
|
216
|
+
reference_to PrivacyReview
|
|
217
|
+
|
|
218
|
+
emits PrivacyReviewEscalated
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
@@ -34,6 +34,33 @@ module Hecks
|
|
|
34
34
|
# already destroyed, or was never issued
|
|
35
35
|
def destroy(registry, key_reference:) = adapter(registry).destroy(key_reference: key_reference)
|
|
36
36
|
|
|
37
|
+
# Erases one subject: destroys their key in the vault, then durably records that it
|
|
38
|
+
# happened — in that fixed order, so a caller cannot dispatch `Shred` before the key
|
|
39
|
+
# is actually gone the way calling {#destroy} and `dispatch_flat` separately would
|
|
40
|
+
# allow. `Privacy::SubjectKey.Shred`'s own lifecycle guard (`from: "active"`) still
|
|
41
|
+
# refuses a raw double-dispatch; this is what closes the gap one level up, where the
|
|
42
|
+
# danger isn't a duplicate `Shred` but `Shred` running *before* {#destroy} does.
|
|
43
|
+
#
|
|
44
|
+
# Idempotent: a subject with no live `SubjectKey` record, or one already shredded,
|
|
45
|
+
# is left alone and answered `false` — a retried erasure request never re-destroys
|
|
46
|
+
# an already-gone key or double-dispatches `Shred`.
|
|
47
|
+
#
|
|
48
|
+
# @param dispatcher [Runtime::Dispatcher, Runtime::RemoteDispatcher] the booted
|
|
49
|
+
# dispatcher both the lookup query and the `Shred` dispatch run through
|
|
50
|
+
# @param domain [String] the subject's own domain FQN, e.g. `"Lifeadelics::Registration"`
|
|
51
|
+
# @param subject_id [String] the data subject to erase
|
|
52
|
+
# @return [Boolean] true when a live key was found and shredded by this call; false
|
|
53
|
+
# when no `SubjectKey` record exists for this subject, or it was already shredded
|
|
54
|
+
def shred!(dispatcher, domain:, subject_id:) # rubocop:disable Naming/PredicateMethod
|
|
55
|
+
record = dispatcher.query("Privacy::SubjectKey.ForSubject", domain: domain, subject_id: subject_id).first
|
|
56
|
+
return false unless record
|
|
57
|
+
return false if record[:status].to_s == "shredded"
|
|
58
|
+
|
|
59
|
+
destroy(dispatcher.registry, key_reference: record[:key_reference].value)
|
|
60
|
+
dispatcher.dispatch_flat("Privacy::SubjectKey.Shred", domain: { value: domain }, subject_id: { value: subject_id })
|
|
61
|
+
true
|
|
62
|
+
end
|
|
63
|
+
|
|
37
64
|
# Finds the single adapter bound to this port, refusing an ambiguous wiring.
|
|
38
65
|
#
|
|
39
66
|
# @param registry [Runtime::Registry] the booted registry to search
|