change_requests 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -0
- data/docs/adr/0001-headless-domain-core.md +40 -0
- data/docs/adr/0002-mountable-engine-with-isolated-namespace.md +38 -0
- data/docs/adr/0003-actor-references-as-triples.md +43 -0
- data/docs/adr/0004-uuid-primary-keys.md +35 -0
- data/docs/adr/0005-string-states-with-check-constraints.md +43 -0
- data/docs/adr/0006-creation-time-immutability.md +39 -0
- data/docs/adr/0007-append-only-audit-trail.md +42 -0
- data/docs/adr/0008-staged-multi-quorum-schema.md +43 -0
- data/docs/adr/0009-host-owned-schema.md +43 -0
- data/docs/adr/0010-operations-must-be-declared.md +57 -0
- data/docs/adr/0011-system-sentinel-actor.md +36 -0
- data/docs/adr/0012-declared-error-taxonomy.md +40 -0
- data/docs/adr/0013-json-runtime-pin.md +37 -0
- data/docs/adr/0014-executable-architecture-rules.md +45 -0
- data/docs/adr/README.md +25 -0
- data/lib/change_requests/authorization/callable.rb +30 -0
- data/lib/change_requests/authorization/permissions.rb +66 -1
- data/lib/change_requests/commands/approve.rb +69 -0
- data/lib/change_requests/commands/base.rb +113 -0
- data/lib/change_requests/commands/cancel.rb +43 -0
- data/lib/change_requests/commands/comment.rb +40 -0
- data/lib/change_requests/commands/create.rb +169 -0
- data/lib/change_requests/commands/expire.rb +27 -0
- data/lib/change_requests/commands/reject.rb +77 -0
- data/lib/change_requests/commands/unapprove.rb +49 -0
- data/lib/change_requests/configuration.rb +22 -1
- data/lib/change_requests/errors.rb +45 -26
- data/lib/change_requests/guards/approve.rb +31 -0
- data/lib/change_requests/guards/base.rb +201 -0
- data/lib/change_requests/guards/cancel.rb +32 -0
- data/lib/change_requests/guards/comment.rb +32 -0
- data/lib/change_requests/guards/execute.rb +48 -0
- data/lib/change_requests/guards/expire.rb +37 -0
- data/lib/change_requests/guards/reject.rb +38 -0
- data/lib/change_requests/guards/unapprove.rb +41 -0
- data/lib/change_requests/models/approval.rb +1 -1
- data/lib/change_requests/models/attempt.rb +3 -1
- data/lib/change_requests/models/concerns/actor_columns.rb +12 -6
- data/lib/change_requests/models/event.rb +3 -1
- data/lib/change_requests/models/quorum.rb +7 -2
- data/lib/change_requests/models/request.rb +11 -2
- data/lib/change_requests/models/stage.rb +10 -3
- data/lib/change_requests/operation.rb +6 -2
- data/lib/change_requests/operations.rb +14 -4
- data/lib/change_requests/translation.rb +3 -1
- data/lib/change_requests/version.rb +1 -1
- data/lib/change_requests/workflow.rb +3 -1
- data/lib/change_requests.rb +9 -3
- data/lib/generators/change_requests/install/templates/migration.rb.tt +5 -0
- metadata +33 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Authorization
|
|
5
|
+
# Wraps the host-supplied policy of §9.2:
|
|
6
|
+
#
|
|
7
|
+
# config.authorization = ->(actor:, request:, stage:, action:) {
|
|
8
|
+
# Pundit.policy!(actor, request).public_send("#{action}?")
|
|
9
|
+
# }
|
|
10
|
+
#
|
|
11
|
+
# `Configuration#authorization=` wraps a bare callable in this, so a host writes the lambda and
|
|
12
|
+
# the gem still has one object answering `allows?`.
|
|
13
|
+
#
|
|
14
|
+
# The quorum is the gem's unit of eligibility; a host policy is written against the request, so
|
|
15
|
+
# the request and the stage are resolved from the quorum rather than asked for again.
|
|
16
|
+
class Callable
|
|
17
|
+
attr_reader :policy
|
|
18
|
+
|
|
19
|
+
def initialize(policy)
|
|
20
|
+
@policy = policy
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def allows?(actor:, quorum:, action: :approve)
|
|
24
|
+
stage = quorum.stage
|
|
25
|
+
|
|
26
|
+
!!policy.call(actor: actor, request: stage.change_request, stage: stage, action: action)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -2,8 +2,73 @@
|
|
|
2
2
|
|
|
3
3
|
module ChangeRequests
|
|
4
4
|
module Authorization
|
|
5
|
+
# The default policy: does this actor satisfy this quorum's eligibility rows (§5.3, §9.2)?
|
|
6
|
+
#
|
|
7
|
+
# Written once. M9c re-expresses the identical predicate as the SQL of
|
|
8
|
+
# `Request.awaiting_approval_from`, so "the button is enabled" and "it appears in my inbox"
|
|
9
|
+
# cannot drift apart.
|
|
5
10
|
class Permissions
|
|
6
|
-
#
|
|
11
|
+
# `action` is ignored here - eligibility is the same question whichever command asks it. It
|
|
12
|
+
# exists because Authorization::Callable hands it on to the host's own policy.
|
|
13
|
+
def allows?(actor:, quorum:, action: :approve) # rubocop:disable Lint/UnusedMethodArgument
|
|
14
|
+
type = registered_type(actor)
|
|
15
|
+
|
|
16
|
+
return false unless type.may_approve
|
|
17
|
+
|
|
18
|
+
named_approver?(actor, quorum) || satisfies_permission_rows?(actor, type, quorum)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# Raises UnknownActorType for a class that is not registered: the registry is the allowlist,
|
|
24
|
+
# and an unregistered class cannot enter the system through any path (§9.1).
|
|
25
|
+
def registered_type(actor)
|
|
26
|
+
name = actor.class.name
|
|
27
|
+
type = ChangeRequests.config.actor_types[name]
|
|
28
|
+
|
|
29
|
+
return type if type
|
|
30
|
+
|
|
31
|
+
fail UnknownActorType,
|
|
32
|
+
"#{name} is not a registered actor type. " \
|
|
33
|
+
"Register it with `config.actor_type \"#{name}\"`."
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def named_approver?(actor, quorum)
|
|
37
|
+
quorum.eligible_actors.any? do |row|
|
|
38
|
+
row.actor_type == actor.class.name && row.actor_id == actor.id.to_s
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def satisfies_permission_rows?(actor, type, quorum)
|
|
43
|
+
rows = quorum.permissions.to_a
|
|
44
|
+
|
|
45
|
+
# "all of nothing" is vacuously true, which would let a quorum that only names its approvers
|
|
46
|
+
# admit everyone. A quorum with no rows grants eligibility to nobody by permission.
|
|
47
|
+
return false if rows.empty?
|
|
48
|
+
|
|
49
|
+
held = permissions_of(actor, type)
|
|
50
|
+
matched = rows.select { |row| row_matches?(row, actor, held) }
|
|
51
|
+
|
|
52
|
+
quorum.all_match? ? matched.size == rows.size : matched.any?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The 2x2 of §5.3. A row constraining neither axis matches nobody: "constrains nothing" is a
|
|
56
|
+
# bug the model and the CHECK both refuse, never a wildcard.
|
|
57
|
+
def row_matches?(row, actor, held)
|
|
58
|
+
return false if row.permission.nil? && row.actor_type.nil?
|
|
59
|
+
|
|
60
|
+
(row.permission.nil? || held.include?(row.permission)) &&
|
|
61
|
+
(row.actor_type.nil? || row.actor_type == actor.class.name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Through the actor type's own lambda, never through a method on the actor: User and Admin may
|
|
65
|
+
# derive their permissions completely differently and still be compared against one stage
|
|
66
|
+
# definition (§9.2).
|
|
67
|
+
def permissions_of(actor, type)
|
|
68
|
+
return [] if type.permissions.nil?
|
|
69
|
+
|
|
70
|
+
Array(type.permissions.call(actor)).map(&:to_s)
|
|
71
|
+
end
|
|
7
72
|
end
|
|
8
73
|
end
|
|
9
74
|
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# Records one approver's decision and which quorums it counted toward (§7).
|
|
6
|
+
#
|
|
7
|
+
# Commands::Approve.call(request:, actor: current_user, comment: "Checked with HR")
|
|
8
|
+
class Approve < Base
|
|
9
|
+
# §15.3: two concurrent approvals by the same actor race the unique index, and the loser must
|
|
10
|
+
# hear the same refusal the guard would have given, not a 500.
|
|
11
|
+
on_conflict NotApprovable, reason: :already_decided
|
|
12
|
+
|
|
13
|
+
def self.call(request:, actor:, comment: nil)
|
|
14
|
+
new(request: request, actor: actor, comment: comment).call
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def perform
|
|
18
|
+
# Built once and asked twice: the reason it refuses and the quorums it links must come from
|
|
19
|
+
# one evaluation, not two (I7).
|
|
20
|
+
guard = Guards::Approve.new(request: request, actor: actor)
|
|
21
|
+
guard.check!
|
|
22
|
+
|
|
23
|
+
approval = record_decision
|
|
24
|
+
link(approval, guard.countable_quorums)
|
|
25
|
+
emit(:approved, body: comment, metadata: metadata_for(approval))
|
|
26
|
+
|
|
27
|
+
# Commands::EvaluateWorkflow is M1b-12. Until it lands nothing advances the stage.
|
|
28
|
+
request
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def comment
|
|
34
|
+
options[:comment]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def record_decision
|
|
38
|
+
stage.approvals.create!(
|
|
39
|
+
change_request: request,
|
|
40
|
+
approver: actor,
|
|
41
|
+
decision: "approved",
|
|
42
|
+
comment: comment,
|
|
43
|
+
decided_at: Time.current
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Written at decision time and never re-derived, so a later role change cannot silently
|
|
48
|
+
# un-approve a request (§5.3).
|
|
49
|
+
def link(approval, quorums)
|
|
50
|
+
quorums.each { |quorum| approval.approval_quorums.create!(quorum: quorum) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The quorum key is omitted for a stage of one nameless quorum, because "which quorum" is not
|
|
54
|
+
# a meaningful question there (§5.9).
|
|
55
|
+
def metadata_for(approval)
|
|
56
|
+
metadata = { stage: stage.name }
|
|
57
|
+
names = approval.quorums.map(&:name).compact
|
|
58
|
+
|
|
59
|
+
metadata[:quorums] = names if names.any?
|
|
60
|
+
|
|
61
|
+
metadata
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def stage
|
|
65
|
+
@stage ||= request.current_stage
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# Guard, mutate, emit - inside one lock (§7).
|
|
6
|
+
#
|
|
7
|
+
# class Approve < Base
|
|
8
|
+
# on_conflict NotApprovable, reason: :already_decided
|
|
9
|
+
#
|
|
10
|
+
# def perform
|
|
11
|
+
# Guards::Approve.new(request:, actor:).check!
|
|
12
|
+
# ...
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Commands raise rather than returning a result object (§19.3), and never accept permissions
|
|
17
|
+
# from the caller: they take an actor, and the gem resolves that actor's permissions through
|
|
18
|
+
# their registered type (§7, §9).
|
|
19
|
+
class Base
|
|
20
|
+
# Which TransitionError a lost race becomes. Only a command that actually competes for a
|
|
21
|
+
# unique index declares one.
|
|
22
|
+
class_attribute :conflict_mapping, instance_accessor: false
|
|
23
|
+
|
|
24
|
+
attr_reader :request, :actor, :options
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def call(request:, actor: nil, **)
|
|
28
|
+
new(request: request, actor: actor, **).call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def on_conflict(error_class, reason:)
|
|
32
|
+
self.conflict_mapping = { error_class: error_class, reason: reason }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(request:, actor: nil, **options)
|
|
37
|
+
@request = request
|
|
38
|
+
@actor = actor
|
|
39
|
+
@options = options
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# SELECT … FOR UPDATE, which reloads: a command reads the row it locked, never the object it
|
|
43
|
+
# was handed. Rails refuses to lock a record carrying unsaved changes, so a caller cannot lose
|
|
44
|
+
# an assignment it thought it was making.
|
|
45
|
+
def call
|
|
46
|
+
around_perform { perform }
|
|
47
|
+
rescue ActiveRecord::RecordNotUnique => e
|
|
48
|
+
refuse_conflict(e)
|
|
49
|
+
rescue ActiveRecord::StaleObjectError
|
|
50
|
+
raise StaleRequest, "Change request #{request.id} changed while this command was running."
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def perform
|
|
54
|
+
fail NotImplementedError, "#{self.class.name} must implement #perform"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Create overrides this: it has no row to lock until it has written one.
|
|
58
|
+
def around_perform(&)
|
|
59
|
+
request.with_lock(&)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The single write path for events (§5.5), so the stamped columns are populated in one place.
|
|
63
|
+
# M10 hangs config.on_event and ActiveSupport::Notifications here.
|
|
64
|
+
def emit(kind, body: nil, metadata: {})
|
|
65
|
+
request.events.create!(
|
|
66
|
+
**event_actor,
|
|
67
|
+
kind: kind.to_s,
|
|
68
|
+
operation_version: event_operation_version,
|
|
69
|
+
body: body,
|
|
70
|
+
metadata: metadata,
|
|
71
|
+
occurred_at: Time.current
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def config
|
|
76
|
+
ChangeRequests.config
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Resolved live, never from the columns on the row: those are audit data (§6.12).
|
|
80
|
+
def operation
|
|
81
|
+
ChangeRequests.operations[request.operation_key]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def event_actor
|
|
87
|
+
return Event::SYSTEM_ATTRIBUTES if actor.nil?
|
|
88
|
+
|
|
89
|
+
attributes = ChangeRequests.actor_attributes(actor)
|
|
90
|
+
|
|
91
|
+
{ actor_type: attributes[:type], actor_id: attributes[:id], actor_label: attributes[:label] }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# The version in force when this transition happened, which is deliberately not the request's
|
|
95
|
+
# creation-time value. They diverge whenever a declaration changes during a request's life.
|
|
96
|
+
# The fallback covers the only events ever written without a live declaration - Comment, and
|
|
97
|
+
# the operation_undeclared cancellation reporting that very disappearance (§5.5, §5.11).
|
|
98
|
+
def event_operation_version
|
|
99
|
+
operation&.version || request.operation_version
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Never a 500 for a race the gem can lose (§15.3) - but an undeclared conflict is a bug to
|
|
103
|
+
# see, not a refusal to dress it up as.
|
|
104
|
+
def refuse_conflict(error)
|
|
105
|
+
mapping = self.class.conflict_mapping
|
|
106
|
+
|
|
107
|
+
fail error if mapping.nil?
|
|
108
|
+
|
|
109
|
+
fail mapping[:error_class].new(request: request, reason: mapping[:reason])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# Calls the whole request off (§7.2).
|
|
6
|
+
#
|
|
7
|
+
# Commands::Cancel.call(request:, actor: current_user, reason: "No longer needed")
|
|
8
|
+
#
|
|
9
|
+
# `canceled` is final. Unlike a rejection there is no stage-level counterpart and no cooldown:
|
|
10
|
+
# cancelling is not a decision on a step, it is the end of the request.
|
|
11
|
+
class Cancel < Base
|
|
12
|
+
def self.call(request:, actor:, reason:)
|
|
13
|
+
new(request: request, actor: actor, reason: reason).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def perform
|
|
17
|
+
Guards::Cancel.new(request: request, actor: actor).check!
|
|
18
|
+
refuse_without_reason
|
|
19
|
+
|
|
20
|
+
# Emitted before the status changes, so the trail records the request as it was cancelled
|
|
21
|
+
# rather than as it ended up.
|
|
22
|
+
emit(:canceled, body: reason, metadata: { status: request.status })
|
|
23
|
+
request.update!(status: "canceled")
|
|
24
|
+
|
|
25
|
+
request
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def reason
|
|
31
|
+
options[:reason]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Authorization first, as in Reject: someone who may not cancel at all should not be told
|
|
35
|
+
# they merely forgot a sentence.
|
|
36
|
+
def refuse_without_reason
|
|
37
|
+
return if reason.present?
|
|
38
|
+
|
|
39
|
+
fail NotCancelable.new(request: request, reason: :reason_required)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# Leaves a note on a request, whatever state it is in (§7.2, §5.5).
|
|
6
|
+
#
|
|
7
|
+
# Commands::Comment.call(request:, actor: current_user, body: "Waiting on legal")
|
|
8
|
+
#
|
|
9
|
+
# Writes an event and nothing else - no request column changes, so the terminal-state guard is
|
|
10
|
+
# never in its way and a `successful` or `canceled` request can still be annotated.
|
|
11
|
+
class Comment < Base
|
|
12
|
+
def self.call(request:, actor:, body:)
|
|
13
|
+
new(request: request, actor: actor, body: body).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def perform
|
|
17
|
+
Guards::Comment.new(request: request, actor: actor).check!
|
|
18
|
+
refuse_without_body
|
|
19
|
+
|
|
20
|
+
# When no live declaration exists, emit stamps the request's creation-time
|
|
21
|
+
# operation_version - the only version there is to record (§5.5, §5.11).
|
|
22
|
+
emit(:commented, body: body)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def body
|
|
28
|
+
options[:body]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# An empty note is noise in a trail that can never be cleaned up. Authorization first, as in
|
|
32
|
+
# Reject and Cancel.
|
|
33
|
+
def refuse_without_body
|
|
34
|
+
return if body.present?
|
|
35
|
+
|
|
36
|
+
fail NotAuthorized.new(request: request, reason: :body_required)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# Records the intent instead of running it, and freezes the approval policy onto the row (§7.2).
|
|
6
|
+
#
|
|
7
|
+
# Commands::Create.call(
|
|
8
|
+
# operation_key: "members.update_roles",
|
|
9
|
+
# payload: { member_id: 42, roles: %w(editor) },
|
|
10
|
+
# requester: current_admin,
|
|
11
|
+
# tenant: current_organization # optional
|
|
12
|
+
# )
|
|
13
|
+
#
|
|
14
|
+
# M2 wraps this as `ChangeRequests.request!`.
|
|
15
|
+
#
|
|
16
|
+
# The call is identical however elaborate the workflow is: thresholds, permissions and quorum
|
|
17
|
+
# structure come from the declaration, never from the caller (§6.12 point 3).
|
|
18
|
+
class Create < Base
|
|
19
|
+
attr_reader :operation_key, :requester, :payload, :tenant
|
|
20
|
+
|
|
21
|
+
def self.call(operation_key:, requester:, payload: {}, tenant: nil)
|
|
22
|
+
new(operation_key: operation_key, requester: requester, payload: payload, tenant: tenant).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(operation_key:, requester:, payload: {}, tenant: nil)
|
|
26
|
+
super(request: nil, actor: requester)
|
|
27
|
+
|
|
28
|
+
@operation_key = operation_key.to_s
|
|
29
|
+
@requester = requester
|
|
30
|
+
@payload = payload
|
|
31
|
+
@tenant = tenant
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Base's helper resolves the operation from the request, which does not exist yet.
|
|
35
|
+
def operation
|
|
36
|
+
ChangeRequests.operations[operation_key]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def perform
|
|
40
|
+
declaration = usable_operation
|
|
41
|
+
refuse_unless_may_request
|
|
42
|
+
|
|
43
|
+
@request = write_request(declaration)
|
|
44
|
+
materialise(declaration.workflow)
|
|
45
|
+
emit(:requested)
|
|
46
|
+
|
|
47
|
+
request
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
# The whole graph, or none of it.
|
|
53
|
+
def around_perform(&)
|
|
54
|
+
Record.transaction(&)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def usable_operation
|
|
58
|
+
declaration = operation
|
|
59
|
+
|
|
60
|
+
fail UnknownOperation, "No operation is declared for #{operation_key.inspect} (§5.11)." if declaration.nil?
|
|
61
|
+
|
|
62
|
+
refuse_incomplete(declaration)
|
|
63
|
+
|
|
64
|
+
declaration
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Both would otherwise surface as a NOT NULL violation or as a request that can never leave
|
|
68
|
+
# `pending`. M2's `verify!` catches them at boot; this is the backstop. Reported together,
|
|
69
|
+
# like Configuration#validate!: one call should fix one round of mistakes.
|
|
70
|
+
def refuse_incomplete(declaration)
|
|
71
|
+
problems = []
|
|
72
|
+
|
|
73
|
+
if declaration.service.blank?
|
|
74
|
+
problems << "it declares no service, so nothing could ever execute it - set `op.service`"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
if declaration.workflow.empty?
|
|
78
|
+
problems << "it declares no approvals, so a request could never be approved - " \
|
|
79
|
+
"declare `op.approvals`"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
return if problems.empty?
|
|
83
|
+
|
|
84
|
+
fail ConfigurationError,
|
|
85
|
+
"Operation #{operation_key.inspect} cannot be requested (§6.4):\n- #{problems.join("\n- ")}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Which actions a given person may trigger is the host's own authorization question, answered
|
|
89
|
+
# before this call. The registry answers only which *classes* may raise a request (§19.4).
|
|
90
|
+
def refuse_unless_may_request
|
|
91
|
+
type = ChangeRequests.config.actor_types[requester.class.name]
|
|
92
|
+
|
|
93
|
+
# Unregistered classes are the allowlist's business, and actor_attributes raises for them.
|
|
94
|
+
return if type.nil? || type.may_request
|
|
95
|
+
|
|
96
|
+
fail NotAuthorized,
|
|
97
|
+
"#{requester.class.name} may not raise change requests. " \
|
|
98
|
+
"Set `t.may_request = true` on its registration (§9.1)."
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def write_request(declaration)
|
|
102
|
+
Request.create!(
|
|
103
|
+
operation_key: declaration.key,
|
|
104
|
+
operation_version: declaration.version,
|
|
105
|
+
service: declaration.service,
|
|
106
|
+
method_name: declaration.method_name.to_s,
|
|
107
|
+
payload: validated_payload,
|
|
108
|
+
payload_labels: labels,
|
|
109
|
+
requester: requester,
|
|
110
|
+
tenant: tenant,
|
|
111
|
+
max_attempts: declaration.max_attempts,
|
|
112
|
+
expires_at: expires_at(declaration)
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# The gem validates only that it is a JSON object; matching it to the target's signature is
|
|
117
|
+
# the host's responsibility (§6.12).
|
|
118
|
+
def validated_payload
|
|
119
|
+
return payload if payload.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
fail InvalidPayload,
|
|
122
|
+
"payload must be a JSON object, got #{payload.class}. It is dispatched as " \
|
|
123
|
+
"`**payload.symbolize_keys`, so its keys become the target's keyword arguments (§6.12)."
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Snapshotted so a request stays readable after the records the payload refers to are gone.
|
|
127
|
+
def labels
|
|
128
|
+
return {} if operation.payload_labels.nil?
|
|
129
|
+
|
|
130
|
+
operation.payload_labels.call(validated_payload).to_h.transform_keys(&:to_s)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def expires_at(declaration)
|
|
134
|
+
declaration.expires_in && (Time.current + declaration.expires_in)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# The frozen snapshot: editing the operation afterwards never reaches this request, and never
|
|
138
|
+
# leaves it wrongly approved or wrongly pending (§6.12 point 4).
|
|
139
|
+
def materialise(workflow)
|
|
140
|
+
workflow.stages.each do |described_stage|
|
|
141
|
+
stage = request.stages.create!(
|
|
142
|
+
position: described_stage.position,
|
|
143
|
+
name: described_stage.name.to_s,
|
|
144
|
+
satisfied_by: described_stage.satisfied_by.to_s
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
described_stage.quorums.each { |described| materialise_quorum(stage, described) }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def materialise_quorum(stage, described)
|
|
152
|
+
quorum = stage.quorums.create!(
|
|
153
|
+
position: described.position,
|
|
154
|
+
name: described.name&.to_s,
|
|
155
|
+
threshold: described.threshold,
|
|
156
|
+
permission_match: described.permission_match.to_s
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
described.permissions.each do |row|
|
|
160
|
+
quorum.permissions.create!(permission: row.permission, actor_type: row.actor_type)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The declaration kept the actor objects as the host wrote them; this is where they resolve
|
|
164
|
+
# to (type, id), and where an unregistered class is refused (§9.1).
|
|
165
|
+
described.eligible_actors.each { |actor| quorum.eligible_actors.create!(actor: actor) }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# The clock closing a request nobody acted on (§7.2, §8).
|
|
6
|
+
#
|
|
7
|
+
# Commands::Expire.call(request:)
|
|
8
|
+
#
|
|
9
|
+
# No actor: `emit` stamps `SYSTEM_ACTOR` rather than a NULL, so "who did this" is answerable for
|
|
10
|
+
# every row and no presenter branches on nil (§5.5, §19.15).
|
|
11
|
+
#
|
|
12
|
+
# This is the transition only. `Maintenance.expire_stale!`, which sweeps
|
|
13
|
+
# `Request.expired_candidates` on a schedule, is M3b.
|
|
14
|
+
class Expire < Base
|
|
15
|
+
def perform
|
|
16
|
+
Guards::Expire.new(request: request, actor: actor).check!
|
|
17
|
+
|
|
18
|
+
# Emitted before the status changes, so the trail records what expired rather than what it
|
|
19
|
+
# became - which is `expired` for every one of these rows.
|
|
20
|
+
emit(:expired, metadata: { status: request.status, expires_at: request.expires_at })
|
|
21
|
+
request.update!(status: "expired")
|
|
22
|
+
|
|
23
|
+
request
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# Rejection is a stop, not a count (§7.1).
|
|
6
|
+
#
|
|
7
|
+
# Commands::Reject.call(request:, actor: current_user, reason: "Wrong member")
|
|
8
|
+
#
|
|
9
|
+
# One rejection from any eligible approver, or from the requester, rejects the whole request.
|
|
10
|
+
# Rejection thresholds are deliberately not modelled.
|
|
11
|
+
#
|
|
12
|
+
# `config.only_record_rejections = true` records the decision and the event without
|
|
13
|
+
# short-circuiting: the workflow continues, and the rejector has spent their decision on that
|
|
14
|
+
# stage - the unique index is what guarantees they cannot later approve it.
|
|
15
|
+
class Reject < Base
|
|
16
|
+
on_conflict NotRejectable, reason: :already_decided
|
|
17
|
+
|
|
18
|
+
def self.call(request:, actor:, reason:)
|
|
19
|
+
new(request: request, actor: actor, reason: reason).call
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def perform
|
|
23
|
+
Guards::Reject.new(request: request, actor: actor).check!
|
|
24
|
+
refuse_without_reason
|
|
25
|
+
|
|
26
|
+
record_decision
|
|
27
|
+
emit(:rejected, body: reason, metadata: metadata)
|
|
28
|
+
|
|
29
|
+
stop unless config.only_record_rejections
|
|
30
|
+
|
|
31
|
+
# Commands::EvaluateWorkflow is M1b-12, and only the recorded-only branch would call it.
|
|
32
|
+
request
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def reason
|
|
38
|
+
options[:reason]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Authorization first: someone who may not reject at all should not be told they merely
|
|
42
|
+
# forgot a sentence.
|
|
43
|
+
def refuse_without_reason
|
|
44
|
+
return if reason.present?
|
|
45
|
+
|
|
46
|
+
fail NotRejectable.new(request: request, reason: :reason_required)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Written in both branches, so change_request_approvals stays the complete record of who
|
|
50
|
+
# decided what on each stage and the unique index behaves identically either way (Q26).
|
|
51
|
+
def record_decision
|
|
52
|
+
stage.approvals.create!(
|
|
53
|
+
change_request: request,
|
|
54
|
+
approver: actor,
|
|
55
|
+
decision: "rejected",
|
|
56
|
+
comment: reason,
|
|
57
|
+
decided_at: Time.current
|
|
58
|
+
)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Whether the workflow continued is the fact an audit asks about first, and it depends on a
|
|
62
|
+
# config flag that may since have been changed.
|
|
63
|
+
def metadata
|
|
64
|
+
{ stage: stage.name, recorded_only: config.only_record_rejections }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def stop
|
|
68
|
+
stage.update!(status: "rejected")
|
|
69
|
+
request.update!(status: "rejected")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stage
|
|
73
|
+
@stage ||= request.current_stage
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChangeRequests
|
|
4
|
+
module Commands
|
|
5
|
+
# Takes one actor's decision back (§7.1).
|
|
6
|
+
#
|
|
7
|
+
# Commands::Unapprove.call(request:, actor: current_user)
|
|
8
|
+
#
|
|
9
|
+
# The row goes; the trail does not. `Event` is append-only, so the `approved` event stays beside
|
|
10
|
+
# the `unapproved` one and the timeline records both decisions (§5.5).
|
|
11
|
+
class Unapprove < Base
|
|
12
|
+
def self.call(request:, actor:)
|
|
13
|
+
new(request: request, actor: actor).call
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def perform
|
|
17
|
+
guard = Guards::Unapprove.new(request: request, actor: actor)
|
|
18
|
+
guard.check!
|
|
19
|
+
|
|
20
|
+
decision = guard.decision
|
|
21
|
+
# Read before the row goes: the links are gone a line later.
|
|
22
|
+
metadata = metadata_for(decision)
|
|
23
|
+
|
|
24
|
+
# Deleting the approval is what removes its quorum links: ApprovalQuorum is Immutable, so
|
|
25
|
+
# `decision.quorums.destroy_all` - the obvious code - raises ReadOnlyRecord. The
|
|
26
|
+
# association carries no `dependent:`, and the database cascades (Q15).
|
|
27
|
+
decision.destroy!
|
|
28
|
+
|
|
29
|
+
emit(:unapproved, metadata: metadata)
|
|
30
|
+
|
|
31
|
+
# Commands::EvaluateWorkflow is M1b-12. Until it lands nothing re-counts the stage.
|
|
32
|
+
request
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Names what was retracted and where. Without it, a request that gained and lost the same
|
|
38
|
+
# approval twice would leave two indistinguishable pairs in the timeline (§5.9).
|
|
39
|
+
def metadata_for(decision)
|
|
40
|
+
metadata = { stage: decision.stage.name, decision: decision.decision }
|
|
41
|
+
names = decision.quorums.map(&:name).compact
|
|
42
|
+
|
|
43
|
+
metadata[:quorums] = names if names.any?
|
|
44
|
+
|
|
45
|
+
metadata
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|