-
Notifications
You must be signed in to change notification settings - Fork 1
feat(audit_trail): record and expose record history in the agent, gated on an audit database #320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
35 commits
Select commit
Hold shift + click to select a range
c04359a
feat(audit_trail): add audit trail plugin gem
bexchauveto 05e4415
chore(audit_trail): exclude gemspec from Gemspec/RequireMFA rubocop cop
bexchauveto e6429a0
feat(audit_trail): add correlation-scoped record-history routes
bexchauveto 28adb4e
fix(audit_trail): bind each SqlStore to its own model class
bexchauveto d4574fb
fix(audit_trail): make migration DDL idempotent for non-pg race
bexchauveto 552b635
fix(audit_trail): treat a blank schema as no schema
bexchauveto 4869ca2
refactor(audit_trail): move the plugin into the agent, gated on the d…
bexchauveto cbf1865
fix(audit_trail): scope history reads to the caller, keep gem loadabl…
bexchauveto 12cd870
fix(audit_trail): keep deleted records readable, one payload shape, s…
bexchauveto 444b7f0
fix(audit_trail): add audit_trail option to RPC agent
bexchauveto f138a1d
fix(audit_trail): register the history routes before the collection r…
bexchauveto 545051f
feat(audit_trail): record smart action runs in the audit table
bexchauveto bff005b
refactor(audit_trail): drop the action name, the activity logs alread…
bexchauveto 31ad68a
fix(audit_trail): never let auditing break the request, and cover the…
bexchauveto d994897
docs(audit_trail): state that a concurrent overwrite can stale previo…
bexchauveto 7858c43
fix(audit_trail): record the write even when another after hook raises
bexchauveto cee3d21
feat(audit_trail): reconstruct a record's state, filter history by field
bexchauveto de72e58
test(audit_trail): pin the SQL of the adapters no local database exer…
bexchauveto c535e15
fix(audit_trail): drop an appended array element on revert, honour th…
bexchauveto 3a5d3cb
fix(audit_trail): read the record for /state through the caller's scope
bexchauveto d044bfc
refactor(audit_trail): return only data from /state, matching the Nod…
bexchauveto 1a2a56e
fix(audit_trail): insert the correlation middleware into the applicat…
bexchauveto 52a5c92
fix(audit_trail): count an Error result as a failed action run
bexchauveto 110d2a2
feat(audit_trail): record what an action answered, next to what was s…
bexchauveto d95e805
feat(audit_trail): pending/confirm protocol, denormalised identity, r…
bexchauveto 4ab1ea7
refactor(audit_trail): one migration, tracked beside the table it builds
bexchauveto 66bea98
fix(audit_trail): keep the critical invariant, and stop inventing wha…
bexchauveto f97981f
test(capabilities): cover canUseAuditTrail, and read it from one place
bexchauveto 4c5cef4
feat(audit_trail): search the history by free text
bexchauveto 55ef0eb
fix(audit_trail): follow a record's history across a primary-key change
bexchauveto 22cdb7b
fix(audit_trail): eight review findings on the search and rename work
bexchauveto 9bb4c5c
fix(audit_trail): bound a rename segment by (timestamp, row id), as t…
bexchauveto bf862d8
fix(audit_trail): compare rename bounds through <=>, since Array is n…
bexchauveto 87b05e9
fix(audit_trail): one cap for both agents, and refuse an over-cap act…
bexchauveto fcaa198
fix(audit_trail): pair a snapshot with its own operation, not the new…
bexchauveto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
68 changes: 68 additions & 0 deletions
68
packages/forest_admin_agent/lib/forest_admin_agent/audit_trail.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| module ForestAdminAgent | ||
| # The audit trail is inert unless `config.audit_trail[:database]` was set: the agent factory builds the | ||
| # store during setup — connecting and migrating there rather than on first write — and everything (capture | ||
| # layers and routes) resolves it from here. | ||
| module AuditTrail | ||
| # One operation must not materialise an unbounded number of records: a "delete all" would otherwise read | ||
| # every matched row and, with the pending/confirm protocol, write each of them twice. Truncation is logged, | ||
| # never silent. | ||
| # | ||
| # Matches the Node agent's `MAX_SNAPSHOT_RECORDS`: the same feature behind the same config key, so a bulk | ||
| # operation must not be audited on one agent and truncated on the other. Change it in both or neither. | ||
| MAX_RECORDS_PER_OPERATION = 1000 | ||
|
|
||
| # Auditing a subset while the write touches every match is the one thing `critical` exists to prevent, so | ||
| # over the cap the operation is refused instead — before anything is written, in both the write and the | ||
| # action path, which is why the message lives here rather than in either of them. | ||
| def self.refuse_over_cap! | ||
| raise ForestAdminDatasourceToolkit::Exceptions::ForestException, | ||
| 'The audit trail is configured as critical and cannot record an operation touching more than ' \ | ||
| "#{MAX_RECORDS_PER_OPERATION} records at once. Narrow the selection." | ||
| end | ||
|
|
||
| def self.log_truncation(kept, total) | ||
| skipped = total ? total - kept : 'further' | ||
|
|
||
| Facades::Container.logger.log( | ||
| 'Warn', | ||
| "[ForestAdmin] Audit trail: #{kept} records audited, #{skipped} skipped " \ | ||
| "(cap #{MAX_RECORDS_PER_OPERATION} per operation)" | ||
| ) | ||
| end | ||
|
|
||
| def self.options | ||
| config = Facades::Container.config_from_cache | ||
|
|
||
| (config && config[:audit_trail]) || {} | ||
| end | ||
|
|
||
| def self.store | ||
| options[:store] | ||
| end | ||
|
|
||
| # `critical: true` makes the pending insert a precondition of the write: if the audit trail cannot record | ||
| # that an operation is about to happen, the operation is refused. Nothing was written, so there is nothing | ||
| # to repair and no compensating write ever happens. Default false keeps today's behaviour, where a broken | ||
| # audit database costs rows rather than writes. | ||
| def self.critical? | ||
| options[:critical] == true | ||
| end | ||
|
|
||
| def self.log_failure(error) | ||
| Facades::Container.logger.log('Error', "[ForestAdmin] Audit trail unavailable, skipping: #{error.message}") | ||
| end | ||
|
|
||
| # Runs the pending insert under the configured policy: refusing the operation when critical, logging and | ||
| # carrying on otherwise. | ||
| def self.gate | ||
| return yield if critical? | ||
|
|
||
| begin | ||
| yield | ||
| rescue StandardError => e | ||
| log_failure(e) | ||
| nil | ||
| end | ||
| end | ||
| end | ||
| end | ||
99 changes: 99 additions & 0 deletions
99
packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/action_capture.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| require 'uri' | ||
|
|
||
| module ForestAdminAgent | ||
| module AuditTrail | ||
| # Records smart-action runs into the same table as the field-level history: the submitted form on the | ||
| # `previous_values` side, what the action answered on the `new_values` side. | ||
| # | ||
| # {Capture} cannot see them — the customizer has no `Execute` hook — and an action's writes are only | ||
| # audited when they go through the Forest data layer, so a direct ORM write stays invisible. What lands | ||
| # here is the run itself: who ran which action, on which records, with which form, and how it ended. | ||
| # | ||
| # Same protocol as a write: {#pending} before the action, {#confirm} after. The route owns the gate, so a | ||
| # pending insert that fails refuses the run under `critical: true`. | ||
| class ActionCapture | ||
| include Recording | ||
|
|
||
| EXECUTED = 'action'.freeze | ||
| FAILED = 'action_failed'.freeze | ||
| # A global action, and a bulk run over a selection wider than the cap, name no single target: they get | ||
| # one row attached to no record rather than none at all. | ||
| NO_RECORD = ''.freeze | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| # What of the action's answer is worth keeping — an allowlist, not a denylist: a result also carries the | ||
| # file's contents, a webhook's body and headers and arbitrary response headers. File bytes have no | ||
| # business in an audit table and the other two routinely hold credentials, and an allowlist means a field | ||
| # added to a result later is not stored until someone decides it should be. `html` is left out too: | ||
| # operator-facing markup, sometimes large, and the message already says what happened. | ||
| RESULT_FIELDS = %i[type message name mime_type method url path].freeze | ||
| # Either can carry userinfo credentials or a signed one-time token, which would then sit permanently in | ||
| # the one table nobody deletes from. | ||
| URL_FIELDS = %i[url path].freeze | ||
|
|
||
| def initialize(store, redact = {}) | ||
| @store = store | ||
| @redact = redact || {} | ||
| end | ||
|
|
||
| # One row per targeted record, provisionally an `action` — {#confirm} settles which it really was. Returns | ||
| # the row ids to confirm. | ||
| def pending(caller:, collection:, action_name:, form_values:, record_ids:) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| return [] unless @store | ||
|
|
||
| timestamp = now | ||
| correlation_key = correlation_key_for(caller) | ||
| identity = identity_of(caller) | ||
| submitted = redact(form_values || {}, @redact[collection] || []) | ||
| ids = record_ids.empty? ? [NO_RECORD] : record_ids | ||
|
|
||
| @store.append_all( | ||
| ids.map do |record_id| | ||
| AuditRecord.new( | ||
| timestamp: timestamp, operation: EXECUTED, collection: collection, record_id: record_id, | ||
| status: PENDING, action_name: action_name, correlation_key: correlation_key, | ||
| previous_values: submitted, new_values: {}, **identity | ||
| ) | ||
| end | ||
| ) | ||
| end | ||
|
|
||
| # Best-effort: the action has already run, so a failure here loses the answer, never the run. | ||
| def confirm(ids, result: nil, failed: false) | ||
| return if ids.nil? || ids.empty? | ||
|
|
||
| audit_safely do | ||
| answer = summarize(result) | ||
|
|
||
| ids.each { |id| @store.confirm(id, operation: failed ? FAILED : EXECUTED, new_values: answer) } | ||
| end | ||
|
qltysh[bot] marked this conversation as resolved.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| end | ||
|
|
||
| private | ||
|
|
||
| # Keys of an action's answer are Forest's own, so they are camelCase on the wire — unlike a record's | ||
| # column names, which pass through untouched. | ||
| def summarize(result) | ||
| return {} unless result.is_a?(Hash) | ||
|
|
||
| result.slice(*RESULT_FIELDS).compact.to_h do |field, value| | ||
| [field.to_s.camelize(:lower), URL_FIELDS.include?(field) ? sanitize_url(value) : value] | ||
| end | ||
| end | ||
|
|
||
| # `userinfo = nil` is a no-op on URI, so the credentials come off textually; the parser then takes care | ||
| # of the query and fragment. | ||
| def sanitize_url(value) | ||
| # Both `https://user:pass@host` and the scheme-relative `//user:pass@host`, which parses fine and | ||
| # would otherwise keep its credentials. | ||
| bare = value.to_s.sub(%r{\A([a-z][a-z0-9+.-]*:)?//[^/@]*@}i, '\1//') | ||
| uri = URI.parse(bare) | ||
| uri.query = nil | ||
| uri.fragment = nil | ||
|
|
||
| uri.to_s | ||
| rescue StandardError | ||
| # Not something the parser accepts: keep the shape, drop everything that can carry a secret. | ||
| bare.to_s.split(/[?#]/).first.to_s | ||
| end | ||
| end | ||
| end | ||
| end | ||
16 changes: 16 additions & 0 deletions
16
packages/forest_admin_agent/lib/forest_admin_agent/audit_trail/audit_record.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| module ForestAdminAgent | ||
| module AuditTrail | ||
| # One audited change. Mirrors the columns of `forest.audit_logs`. | ||
| # | ||
| # The actor's name and email are denormalised from the caller at write time: the row says who acted then, | ||
| # not whoever holds that user id today. `action_name` is set on smart-action rows only, and `status` | ||
| # follows the write protocol — inserted as {Recording::PENDING} before the write and confirmed | ||
| # {Recording::DONE} after, so a row left pending means the write may or may not have landed. | ||
| AuditRecord = Struct.new( | ||
| :id, :timestamp, :operation, :collection, :record_id, :previous_record_id, :status, | ||
| :user_id, :user_first_name, :user_last_name, :user_email, :action_name, | ||
| :correlation_key, :previous_values, :new_values, | ||
| keyword_init: true | ||
| ) | ||
| end | ||
| end |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
criticaldefaults to off, so the guarantee the pending/confirm protocol exists to provide is opt-in.The two-phase protocol is what makes "no unaudited write" achievable, and with
options[:critical]unset a failed pending insert is logged while the write proceeds unaudited. A customer who has configured an audit database — and therefore believes they have an audit trail — gets a best-effort one until they find a second flag. Configuring the database is already an explicit opt-in; requiring a second one to get the invariant puts the surprise in the wrong place.Node defaults the same way, so this is a shared product call rather than a Ruby oversight — flagged in both places for that reason.
Whichever way it goes,
AUDIT_TRAIL.mdshould say plainly that withoutcriticala write can succeed with no audit row. It documents the protocol today without documenting that the guarantee is optional.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not changed, and deliberately not: you have named it a shared product call and Node defaults the same way, so flipping it here would trade a documented gap for a silent divergence in the direction that changes customer behaviour. That one belongs to @bexchauveto, with both agents moving together.
The part you asked for whichever way it goes is done in 87b05e9.
AUDIT_TRAIL.mdnow says it outright rather than implying it:Worth adding for whoever makes the call: with
critical: truethe failure mode moves from "silently missing rows" to "refused operations" — an unreachable audit database stops writes, and a selection wider than the cap is refused outright (also newly true for smart actions, see the other thread). That is the right trade for a compliance deployment and the wrong one for someone who enabled the trail to get a nice Historic tab, which is the real argument for the default being a choice rather than a default.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Decision, from @bexchauveto: the default stays
false, in both agents. The reasoning, since it turns on something that changed after you wrote this.Your argument rests on the customer who configures an audit database, believes they have an audit trail, and silently gets a best-effort one. That case is now covered by something else: the store connects and migrates at boot. A wrong connection string, an unreachable host, a migration that cannot run — the agent refuses to start. The "records nothing forever while looking healthy" scenario, which is the one that makes an opt-in guarantee feel like a trap, no longer exists.
What
criticalstill governs is the transient failure: the audit database goes away mid-life. And that is where fail-closed is at its worst. It is a second database, usually a different host, on a connection nobody load-tested; withcritical: trueas the default, a blip there turns the admin panel read-only. That is an outage in the product caused by the subsystem whose only job is to observe it, and it lands hardest exactly when an extra database is most likely to be unhappy — during an incident, while people are using Forest to fix production.The asymmetry is what settles it.
falsecosts rows in a table nobody is reading yet, and the pending/confirm protocol you asked for is what makes that gap detectable rather than invisible: pending rows in the table, errors in the log.truecosts writes, immediately, to the people least able to work out why the panel stopped saving.So the shape is: configuring the database buys a best-effort trail with a visible failure mode;
critical: truebuys the invariant, and the docs now say so in the words you asked for, including which reader should set it.Two things I would take from you here: