Skip to content

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 as gravatar-{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:

  1. Identity deleted the account.
  2. 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

  1. The API (AccountPictureRemoval) reads accountDeletionPictureRemoval through IFeatureDecisions. When the flag is on, it publishes IProfilePictureRemovalRequestedEvent { InvestigatorId }. This is an API event: the API's broker grant already covers SyRF.API.Messages.Events:*.
  2. Project management's RemoveProfilePicturesConsumer (durable queue SyRF.ProjectManagement.RemoveProfilePicturesConsumer, one message at a time) handles it:
  3. 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.
  4. S3ProfilePictureFolders.DeleteAllAsync lists the folder a page at a time and deletes each page with one DeleteObjects. A per-key error throws ProfilePictureDeletionException, so a partial failure is never taken as success.
  5. 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 _error queue is the record of a removal that needs attention: move the message back once the cause is fixed.
  6. 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.
  7. 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

  1. Bucket permissions. The S3Settings credentials project management uses for that environment must allow s3:ListBucket on the bucket, restricted to the public/profile-images/ prefix, and s3:DeleteObject on public/profile-images/*. Project management reads them from the namespace's aws-s3 secret, the same secret the API uses, because s3.authSecretName has no per-service override in cluster-gitops today. They are not managed in camarades-infrastructure, so check them in IAM directly. A refusal shows up as messages in the _error queue, never as a failed account deletion.
  2. Versioning. Production's syrfapp-uploads bucket is unversioned, so a delete erases the object. Staging's bucket is versioned with no noncurrent-version expiry (see cluster-gitops s3-notifier values), so a delete there adds a delete marker and the bytes stay as a noncurrent version. Previews follow the chart default (versioned).
  3. Project management deployed with this consumer (see Across services above).
  4. Set featureFlags.accountDeletionPictureRemoval: true in 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 DeletedFinalizationUnconfirmed deletion 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).