Profile pictures removed on account deletion¶
Feature account-deletion-picture-removal, flag accountDeletionPictureRemoval (Operational, Developing,
default off in every environment). Issue #4607, PR
#4616.
What it does¶
Every profile picture an account stores lives in one folder of the uploads bucket,
public/profile-images/{investigatorId}/:
- uploads from the profile page are stored as
{guid}.png - imported Gravatar copies (#4602, #4623) are stored as
gravatar-{fingerprint}-{guid}.{ext}, or asgravatar-{guid}.{ext}for copies made before #4623
ProfilePictureKeys (SyRF.AppServices.FileServices) is the single definition of that folder. The upload builds its
key from it, and the Gravatar import builds its key through GravatarPicture.PictureFolder, which returns it. Before
this change, deleting an account left the whole folder in place, including pictures that had since been replaced.
With the flag on, a confirmed account deletion removes the whole folder: the current picture and every superseded one.
When a deletion counts as confirmed¶
A deletion is confirmed only when both of these are true:
- Identity deleted the account.
- The Investigator is known to be
Deactivated, which is the irreversible record of account deletion.
| Path | Requests removal | Requests nothing |
|---|---|---|
DELETE api/account/profile, authority off or shadow |
Identity DeleteUser succeeded and the deactivation save succeeded (204) |
deletion-refused, deletion-interrupted, and the "deactivation could not be confirmed" 503 |
GuardedAccountDeletion (M1c, authority enforced): the owner's own delete |
Deleted (finalization Finalized or AlreadyFinalized) |
Refused, Interrupted, Unknown, InProgress, LastActiveAdministrator, OtherOperationPending, DeletedFinalizationUnconfirmed |
POST api/investigators/{id}/pending-deactivation/reconcile |
the reconcile reaches Deleted. This is also how a DeletedFinalizationUnconfirmed deletion gets its removal |
any other reconcile outcome |
| M4a suspension, withdrawal, restore | never reaches this code | always |
The request is made after the existing order (revoke → rotate stamp → delete → deactivate/finalize, see
.claude/rules/identity-auth.md), and it changes nothing in that order. It is best effort: a failed or timed-out
publish (3 s, well inside the browser's 30 s wait) is logged with the exception type only, with the Investigator ID
kept in the tagged PII scope. It never changes the deletion's response.
How it runs¶
- The API (
AccountPictureRemoval) readsaccountDeletionPictureRemovalthroughIFeatureDecisions. When the flag is on, it publishesIProfilePictureRemovalRequestedEvent { InvestigatorId }. This is an API event: the API's broker grant already coversSyRF.API.Messages.Events:*. - Project management's
RemoveProfilePicturesConsumer(durable queueSyRF.ProjectManagement.RemoveProfilePicturesConsumer, one message at a time) handles it: - It re-reads the Investigator uncached and acts only if it is
Deactivated. A missing or still-active Investigator is logged and the message dropped, never retried. S3ProfilePictureFolders.DeleteAllAsynclists the folder a page at a time and deletes each page with oneDeleteObjects. A per-key error throwsProfilePictureDeletionException, so a partial failure is never taken as success.- On failure the message is retried in memory after 1 s, 10 s, 1 min and 5 min. A request that still fails moves
to
SyRF.ProjectManagement.RemoveProfilePicturesConsumer_error. The_errorqueue is the record of a removal that needs attention: move the message back once the cause is fixed. - Scheduled redelivery is deliberately not used, because Quartz's broker grant
(
src/services/quartz/broker-permissions.json) does not cover this queue. A redelivery would be refused, and the request lost before it reached_error. - Deleting is idempotent, so a redelivery, a moved-back error message or a second request (for example from a second reconcile) is harmless.
The consumer never reads the flag. Work accepted while the flag was on finishes after it is switched off, so turning the flag off stops new removals without stranding queued ones.
Flag decision¶
The feature is flagged and off by default, for three reasons:
- removal is irreversible
- the retention wording is on hold (#4610)
- the prerequisites below are not yet confirmed in any environment
Both states, and switching between them, are safe:
- Off: nothing is published. Account deletion is exactly as before, and pictures stay in storage.
- Switching back and forth: each deletion reads the flag once, when it is confirmed. Deletions confirmed while the flag is off are never revisited (see the residuals below). Requests published while the flag was on finish in either state.
- Across services: only the API reads the flag. Project management must be running a build with the consumer before the flag goes on anywhere. Otherwise nothing is bound to the event and the request is dropped.
Before switching it on in an environment¶
- Bucket permissions. The
S3Settingscredentials project management uses for that environment must allows3:ListBucketon the bucket, restricted to thepublic/profile-images/prefix, ands3:DeleteObjectonpublic/profile-images/*. Project management reads them from the namespace'saws-s3secret, the same secret the API uses, becauses3.authSecretNamehas no per-service override in cluster-gitops today. They are not managed incamarades-infrastructure, so check them in IAM directly. A refusal shows up as messages in the_errorqueue, never as a failed account deletion. - Versioning. Production's
syrfapp-uploadsbucket is unversioned, so a delete erases the object. Staging's bucket is versioned with no noncurrent-version expiry (see cluster-gitopss3-notifiervalues), so a delete there adds a delete marker and the bytes stay as a noncurrent version. Previews follow the chart default (versioned). - Project management deployed with this consumer (see Across services above).
- Set
featureFlags.accountDeletionPictureRemoval: truein that environment's cluster-gitops values. Production needs owner approval.
Known residuals (tracked as follow-ups)¶
- Deletions that never produced a request:
- the flag was off at the time
- the publish was lost
- a
DeletedFinalizationUnconfirmeddeletion was never reconciled - every account deleted before this feature
- Identity's own non-enforced self-service delete, which never deactivates the Investigator
A sweep keyed on Deactivated Investigators would cover these. Whether to backfill historic deletions is a retention
decision for the owner (#4620).
- Superseded pictures of live accounts are still never removed when a new picture is stored. Account deletion
removes them, because they share the folder (#4621).
- Noncurrent versions in versioned buckets (staging, previews) are not erased
(#4622).
- Writes racing the deletion: an upload that lands after the folder was listed, or a Gravatar import (#4602) whose
clean-up of an unused copy fails, can leave an object behind. Both are unlikely, because the account's sessions are
revoked before Identity deletes it.
- PictureUrl is not cleared on the deactivated Investigator. The avatar falls back to initials when the image no
longer loads (#4620).
The privacy notice is unchanged. Its wording is on hold (#4610).