-
Notifications
You must be signed in to change notification settings - Fork 522
fix: Enhance EFV audit logs #7535
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -261,20 +261,51 @@ | |
| ) | ||
|
|
||
|
|
||
| def _build_feature_state_change_summary(fs: FeatureState) -> str: | ||
| if fs.identity_id: | ||
| scope = f"Identity override ({fs.identity.identifier})" # type: ignore[union-attr] | ||
| elif fs.feature_segment_id: | ||
| scope = f"Segment override ({fs.feature_segment.segment.name})" # type: ignore[union-attr] | ||
| else: | ||
| scope = "Environment default" | ||
|
|
||
| state = "enabled" if fs.enabled else "disabled" | ||
|
Check failure on line 272 in api/features/versioning/tasks.py
|
||
| return f"{scope}: {state}" | ||
|
|
||
|
|
||
| @register_task_handler() | ||
| def create_environment_feature_version_published_audit_log_task( | ||
| environment_feature_version_uuid: str, | ||
| ) -> None: | ||
| environment_feature_version = EnvironmentFeatureVersion.objects.select_related( | ||
| "environment", "feature" | ||
| ).get(uuid=environment_feature_version_uuid) | ||
|
|
||
| header = ( | ||
| ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE | ||
| % environment_feature_version.feature.name | ||
| ) | ||
|
|
||
| changed_states = get_updated_feature_states_for_version(environment_feature_version) | ||
|
|
||
| if changed_states: | ||
| changed_states = list( | ||
| FeatureState.objects.filter( | ||
| id__in=[fs.id for fs in changed_states] | ||
| ).select_related("feature_segment__segment", "identity") | ||
| ) | ||
| change_lines = "\n".join( | ||
| f"- {_build_feature_state_change_summary(fs)}" for fs in changed_states | ||
| ) | ||
| log = f"{header}\n{change_lines}" | ||
| else: | ||
| log = header | ||
|
Check failure on line 302 in api/features/versioning/tasks.py
|
||
|
Comment on lines
281
to
+302
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. 🔴 The new audit log silently omits removed feature states. Extended reasoning...What the bug is
previous_feature_states_map = (
{get_match_key(fs): fs for fs in previous_version.feature_states.all()}
if previous_version
else {}
)
changed_feature_states = []
for feature_state in version.feature_states.all(): # NEW version only
previous_fs = previous_feature_states_map.get(get_match_key(feature_state))
...It never enumerates the previous map looking for keys absent in the new version, so feature states that existed in the previous version but were removed in the new one are invisible to the audit log. Why this triggers for deletionsThe versioning create serializer (api/features/versioning/serializers.py:312-315) handles def _delete_feature_states(self, segment_ids, version):
version.feature_segments.filter(segment_id__in=segment_ids).delete()Because Step-by-step proof
ImpactThe PR description explicitly promises that the audit log enumerates each changed Feature State's action and scope. A pure-deletion publish produces a log that's indistinguishable from a no-op publish, which is misleading for users auditing what changed. The same blind spot exists in the parallel webhook code ( How to fixEither extend |
||
|
|
||
| AuditLog.objects.create( | ||
| environment=environment_feature_version.environment, | ||
| related_object_type=RelatedObjectType.EF_VERSION.name, | ||
| related_object_uuid=environment_feature_version.uuid, | ||
| log=ENVIRONMENT_FEATURE_VERSION_PUBLISHED_MESSAGE | ||
| % environment_feature_version.feature.name, | ||
| log=log, | ||
| author_id=environment_feature_version.published_by_id, | ||
| master_api_key_id=environment_feature_version.published_by_api_key_id, | ||
| ) | ||
|
|
||
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.
🔴 The audit-log summary only reports each changed feature state's scope and current
enabled/disabledflag, butget_updated_feature_states_for_versionalso flags states whosefeature_state_valueor multivariate values changed whileenabledstayed the same. Such value-only edits produce a line indistinguishable from an enabled-toggle change (e.g.- Environment default: enabled), defeating this PR's stated goal of making EFV audit logs less opaque. Consider including the value/MV diff (or at least marking which attribute changed) in_build_feature_state_change_summary, and add a regression test for a value-only change.Extended reasoning...
What the bug is
The new
_build_feature_state_change_summary(api/features/versioning/tasks.py:264-272) only renders two pieces of information for each changed feature state: its scope (Environment default/Segment override (...)/Identity override (...)) and the current enabled/disabled flag. It never inspects (or surfaces) the feature-state value or the multivariate allocations.However,
get_updated_feature_states_for_version(api/features/versioning/versioning_service.py:510-522) classifies a feature state as "changed" if any of the following differs from the previous version:So a feature state whose value changed (e.g.
string_value"v1"→"v2") or whose multivariate allocation changed, withenabledunchanged, will be in the changed list — but the audit log line will only say the current enabled state.Step-by-step proof
enabled=True,string_value="v1"."v2"(stillenabled=True).create_environment_feature_version_published_audit_log_taskruns.get_updated_feature_states_for_versionreturns that env-default FS (value differs)._build_feature_state_change_summarybuilds: scope ="Environment default",state = "enabled"(becausefs.enabledis True), output:"Environment default: enabled".New version published for feature: foo\n- Environment default: enabled."v1"and toggleenabledfromFalsetoTrue. The audit log is identical:- Environment default: enabled.Two semantically distinct changes (value edit vs. enabled toggle) produce the same audit-log line, and a reader cannot tell what actually changed — exactly the problem this PR was meant to fix (linked issue #7526: "opaque audit logs … completely omitting which feature states were actually changed").
Why existing code does not prevent it
Nothing in the new code path reads
feature_state_valueor compares against the previous version. The line template isf"{scope}: {state}"withstatederived purely from the currentfs.enabled. The test suite added in this PR only exercises (a) no changes, (b) an env-default toggle, (c) a brand-new segment override, (d) a brand-new identity override — none cover a value-only or MV-only change against a previous version.Impact
Quality-of-message regression vs. the PR's stated goal. No crash, audit logs are still created, but they remain misleading/opaque for the very common case of users editing a feature state's value (e.g. config string, JSON payload) without toggling enabled. For multivariate features the line is similarly uninformative.
How to fix
In
_build_feature_state_change_summary, accept (or look up) the previous feature state and emit what actually changed — e.g.:Environment default: value changed ("v1" → "v2")Environment default: enabled (was disabled)Environment default: multivariate allocation changedOr, less ambitiously, append the current value alongside the enabled flag so at least the value is visible (still not great for MV). Either way, add a regression test that mutates
feature_state_valueonly and asserts the audit log differs from the equivalent enabled-toggle case.