Skip to main content

User deactivation and deletion

When someone leaves, you usually want one of two things: take their access away, or permanently delete their account and private data. TOW treats these as separate actions because they have very different consequences. Access removal is reversible; account deletion is not. The API and configuration call permanent deletion "erasure": both words mean the same thing on this page.

UI availability

In this release the deletion and lifecycle controls are not shown in the web app. All operations described here run through the permission-protected admin API (see /api/openapi.json). The one exception is the public Cancel account deletion page, which a user can open from an emailed cancellation link.

Choose the right action

ActionWhat it doesReversible?
Suspend a membershipBlocks access to one organisation. Data and grants stay.Yes; reactivate the membership.
Remove a membershipEnds access to one organisation and clears its grants. Shared history stays.A new invitation is required.
Deactivate the accountBlocks sign-in everywhere and revokes sessions and tokens. All data stays.Yes; reactivate the account.
Delete the accountBlocks sign-in immediately, then permanently deletes private data. Shared work stays, shown as Former user.Only before processing starts.

Use suspension or deactivation for offboarding, temporary leave, or any case where the data must remain. Use deletion only when your organisation has decided the data should be permanently removed, typically for a privacy ("right to erasure") request.

Delete organisation is a different feature: it deletes an organisation, not a user account.

Member lists keep showing lifecycle badges (Suspended, Account deactivated, Deletion pending). A deleted account appears as Former user under the Removed filter.

How deletion works

A deletion request moves through four stages:

  1. Locked. The moment a request is accepted, the account can no longer sign in. Sessions, tokens, and pending logins are revoked, running agent jobs are stopped, and anything the person owned operationally (assigned tickets, project lead, goal ownership) is transferred.
  2. Cancellation window. Self-service requests run 14 days later and the user gets a one-time cancellation link by email. An admin-created request can be scheduled immediately or for any date. An admin can also cancel any request that hasn't started processing. Cancelling restores the account, but not old sessions, tokens, or completed ownership transfers.
  3. Deletion. TOW deletes private data, scrubs copied identity data, verifies each step, and marks the account erased. Shared work remains, attributed to Former user.
  4. Backup expiry. The request stays open until every managed backup that could contain the old data has a verified destruction record. Only then is it completed.

What is deleted

  • Profile, credentials, sessions, tokens, and local identity links.
  • All private data: chats, drafts, private docs and knowledge, memories, preferences, snapshots, embeddings, and derived data.
  • Private and profile file uploads.
  • The user's entries in search indexes (verified against Meilisearch).
  • Unsent emails to the user, and the user's data in managed export files.

What is kept

  • Shared work, including tickets, documents, comments, and history, is attributed to a Former user tombstone. The stable internal ID is kept so shared records don't break; personal fields on them are cleared.
  • Files still referenced by shared work (their names and attribution are scrubbed).
  • Minimal, PII-free evidence that the deletion happened, for auditing.

What TOW cannot delete

Plan a separate process for copies outside TOW's control:

  • Personal data someone typed into shared text (TOW never rewrites prose).
  • Email already delivered to recipients, and exports already downloaded.
  • Infrastructure logs, Git history, screenshots, and backups TOW doesn't manage.
  • Corporate identity-provider accounts (LDAP, SAML, upstream OIDC).
  • Portal customers and public-form submitters, who are not TOW users.

These show up in the request's result as recorded exceptions; the workflow finishes as completed_with_exceptions rather than pretending the copies are gone.

Request statuses

StatusMeaning
draftRecorded by an admin; nothing has changed yet.
scheduledAccount locked; processing starts at the scheduled time.
processingDeletion is running.
blockedSomething needs an operator (see Monitoring).
failedA step failed; it retries automatically, or an admin retries it.
awaiting_backup_expiryLive data is gone; waiting for backups to expire.
completedEverything verified, including backup destruction.
completed_with_exceptionsDone, but recorded copies remain (shared work, delivered email, provider retention).
cancelledCancelled before processing began.

Each request records live_erased_at (live systems verified), backup_erasure_due_at (deadline for backup destruction), and fully_erased_at (backups verified destroyed).

Set up deletion

Deletion is off by default (privacy.full_erasure_enabled: false) and stays inert until a readiness check passes.

On a standard deploy-kit installation, one command asks the required questions and does the whole setup:

scripts/setup-erasure.sh

It records your retention-policy reference and backup-coverage attestation, declares email-relay and AI-provider retention, optionally turns on managed-authentik account deletion, applies Authentik's GDPR cleanup and enforces the selected event-retention maximum without lengthening a stricter existing window, runs the privacy backfill, checks readiness, and enables deletion only once readiness passes. It needs backups set up first, and the installer offers to run it at the end of a production install. Re-running it later updates your answers.

The numbered steps below are the manual path. Use them for non-standard setups (external backup systems, bare metal) or to understand exactly what the script writes; do them in order and enable deletion last.

  1. Name your retention policy. Set privacy.policy_reference to the name or version of your organisation's data-retention policy (for example data-retention-2026). TOW stamps this reference into deletion evidence so every record points at the policy that governed it. Use an identifier, not the policy text itself.

  2. Generate two secrets. Deletion evidence is authenticated with two independent HMAC secrets (32+ characters each):

    openssl rand -base64 48 # PRIVACY_IDENTITY_HMAC_SECRET
    openssl rand -base64 48 # PRIVACY_LEDGER_HMAC_SECRET
    warning

    Back both secrets up outside the application data. Losing or changing them after deletions exist makes identity and readiness checks fail.

  3. Set up encrypted backups and the deletion ledger. Follow Back up and restore: generate an age key pair (the public key becomes PRIVACY_BACKUP_AGE_RECIPIENT; keep the private key off the server), pick a host directory for the ledger such as TOW_PRIVACY_LEDGER_DIR=/var/lib/tow-privacy, initialise the ledger, and install the shipped daily backup and pruning timers. The ledger records completed deletions so a backup restore can never silently resurrect deleted data; never delete or recreate it.

  4. Add the configuration. In tow.yaml (secrets go in the environment or your secret manager, not in this file):

    privacy:
    full_erasure_enabled: false # switched on in step 7
    policy_reference: data-retention-2026
    email:
    relay_retention_mode: bounded_retention # for SMTP relays
    relay_retention_days: 30 # your relay's retention window
    processors:
    registry:
    openai: bounded_retention # or zdr if you have a zero-retention contract
    bounded_retention_days: 30
    backups:
    coverage_mode: bundled_only

    The processors.registry entry declares how long your AI provider keeps request data; remove it if no AI route is configured. bundled_only declares the bundled encrypted backup as your only backup system; if you run other backups, see external backup systems.

  5. Backfill and test. Upgrade to the latest migration, then index historical data:

    docker compose run --rm backend python -m app.scripts.privacy_backfill --dry-run
    docker compose run --rm backend python -m app.scripts.privacy_backfill

    Also set runtime.public_app_url to your browser URL and send a test email from Server SettingsEmail; cancellation links are delivered by email. Run one backup, prune, and restore drill.

  6. Check readiness. As a server admin, call GET /api/admin/privacy/readiness, or run the same check from the deployment directory:

    docker compose run --rm backend python -m app.scripts.privacy_readiness

    Fix each reported blocker until it returns ready: true.

  7. Enable it. Set privacy.full_erasure_enabled: true, restart the backend and workers, and check readiness again; a few checks (such as the ledger host path) only run once deletion is enabled. Confirm enabled: true and ready: true.

  8. Do a dry run. Create a self-service deletion request for a dedicated test account (non-admin, from a browser session), open the emailed cancellation link, cancel, and sign back in. This proves the email path and the cancellation window work before a real user needs them.

Turning full_erasure_enabled off later stops new requests but does not abandon accepted ones; they continue until they finish or need an operator.

Compliance

TOW gives you the deletion mechanics and the evidence trail. Your organisation still owns its retention policy, response deadlines, and systems outside TOW. The UK ICO guidance on the right to erasure is a good plain-language reference.

Configuration reference

Non-secret settings live in tow.yaml; environment values override YAML. See Runtime configuration.

Core

tow.yaml settingEnvironment overrideDefaultPurpose
privacy.full_erasure_enabledPRIVACY_FULL_ERASURE_ENABLEDfalseMaster switch for accepting deletion requests.
privacy.policy_referencePRIVACY_POLICY_REFERENCEUnsetName/version of your data-retention policy, stamped into deletion evidence. Required.
security.privacy_identity_hmac_secretPRIVACY_IDENTITY_HMAC_SECRETUnsetSecret (32+ chars) authenticating identity evidence. Required; keep stable.
privacy.backups.ledger_hmac_secretPRIVACY_LEDGER_HMAC_SECRETUnsetDifferent secret (32+ chars) authenticating the deletion ledger. Required; keep stable.
privacy.backups.age_recipientPRIVACY_BACKUP_AGE_RECIPIENTUnsetPublic age key for backup encryption. Keep the private key off the server.
runtime.public_app_urlPUBLIC_APP_URLUnsetBrowser base URL used to build absolute cancellation links.

Email retention

SettingDefaultPurpose
privacy.email.relay_retention_modeunknownUse bounded_retention for SMTP relays. unknown blocks readiness when SMTP is configured.
privacy.email.relay_retention_daysUnsetYour SMTP relay's retention window. Required with SMTP.
privacy.email.content_retention_days30How long local email bodies and spool files are kept at most.
privacy.email.metadata_retention_days365How long delivery-audit rows are kept. Must be ≥ content retention.

The console, file, and disabled email transports skip the SMTP check, but users then get no cancellation email; an admin can always cancel a scheduled request instead. Bare-metal deployments must run the email worker for local retention to be enforced.

AI providers

privacy.processors.registry (PRIVACY_PROCESSOR_REGISTRY, JSON object) maps each configured AI route to a retention mode. Route keys: openai, openrouter, or custom:<hostname>.

ModeMeaning
zdrYou have a contractual zero-data-retention agreement with the provider. TOW records your declaration; it cannot verify the contract.
bounded_retentionThe provider keeps data for a declared window. Requires privacy.processors.bounded_retention_days.
delete_apiTOW should delete provider-side data itself. Not supported in this build; it blocks readiness.

These modes are operator attestations. They can reflect provider-account guardrails, an organisation contract, or other controls that are not repeated in each API request. TOW requires a declaration for every active or historical route and records it in deletion evidence, but does not second-guess a declared zdr or bounded_retention mode based on the endpoint hostname. Keep the route key accurate: for example, declare openrouter, not openai, when ai.openai_base_url points to OpenRouter.

Managed Authentik

By default TOW only removes its own link to the identity provider; the provider account itself is your identity administrator's job. If TOW manages the Authentik instance, it can delete the Authentik account too:

SettingDefaultPurpose
privacy.identity.managed_authentik_user_deletion_enabledfalseOpt in to deleting Authentik accounts in the managed boundary.
privacy.identity.managed_authentik_issuers[]Exact issuer URLs TOW is allowed to manage.
privacy.identity.authentik_event_retention_daysUnsetRequired maximum Authentik event retention (1–30 days). The standard erasure setup applies this value to the bundled Authentik instance.

When enabled, also set AUTHENTIK_PUBLIC_URL and AUTHENTIK_BOOTSTRAP_TOKEN plus AUTHENTIK_INTERNAL_URL for a directly reachable API route. The standard Compose kit sets the internal route to http://authentik-server:9000. TOW verifies Authentik's GDPR cleanup setting before accepting requests. Corporate LDAP, SAML, and upstream OIDC accounts are never touched.

Backup coverage and the deletion ledger

Deletion can only complete when TOW knows what backups exist:

SettingDefaultPurpose
privacy.backups.coverage_modeundeclaredbundled_only (the bundled scripts are your only backups) or external_contract (you registered external systems). undeclared blocks readiness.
privacy.backups.restore_data_roots[/app/data]Every path replaced during a data restore. The ledger must live outside all of them.
privacy.backups.ledger_path/app/privacy-ledger/erasure.jsonlContainer path of the deletion ledger.
privacy.backups.ledger_host_pathUnsetHost path declaration; Compose derives it from TOW_PRIVACY_LEDGER_DIR.
privacy.backups.registry_head_pathBeside the ledgerProtected file preventing backup history from moving backwards.
privacy.backups.external_systems[]External system declarations for external_contract mode.

Bundled backup retention is a script argument (--retention-days or PRIVACY_BACKUP_RETENTION_DAYS, 1–30 days, default 30); use the same value for backup and pruning. Host backup scripts read their configuration, including the ledger secret, from the deployment .env; see the backup configuration reference.

external_contract is for deployments with their own backup controller. Each declared system needs an id, contract_version, retention_days (1–30), and true for encrypted, journal_replay, and destruction_receipts, and must keep current backup and destruction records registered in TOW:

privacy:
backups:
coverage_mode: external_contract
external_systems:
- id: offsite-postgres
contract_version: offsite-backup-v1
retention_days: 14
encrypted: true
journal_replay: true
destruction_receipts: true

These declarations are your attestation, not something TOW can probe. Don't use this mode without a reviewed integration runbook and a successful backup, prune, and offline-restore drill. Bare-metal deployments need an external_contract-grade backup system; don't copy container /app/... paths into a bare-metal config.

Monitoring and recovery

Re-check GET /api/admin/privacy/readiness after configuration changes, provider or worker incidents, backup failures, and restore drills.

The privacy runner writes content-free privacy_operational_alert log records (most include key=<key> and count=<n>). Route them into your monitoring:

Alert keyWhat to do
request_approaching_30_daysA request is nearing your response deadline; clear its blocker.
blocked_request_stale, blocked_action_staleCheck readiness and the request's residual summary, fix the dependency, retry.
action_retry_exhaustedAutomatic retries stopped; treat as an incident.
managed_backup_overdue, backup_intent_staleCheck whether backup bytes exist, then prune or reconcile records.
journal_checkpoint_divergenceStop all writers and restore the canonical ledger or key. Use offline restore replay only when a valid ledger is ahead of PostgreSQL.
backup_registry_head_not_ready, backup_coverage_not_readyReconcile the backup-history head, coverage declaration, and destruction records.
privacy_backfill_not_readyRun the privacy backfill (setup step 5).
search_erasure_verification_failedRestore Meilisearch and its worker, then retry.
processor_*_not_readyFix the AI route declaration; don't weaken it to force readiness.
processor_retention_expiredVerify provider-side expiry through your own review; the alert is evidence, not a task.
authentik_event_privacy_not_readyRestore Authentik API access or fix its cleanup/retention settings.
readiness_refresh_failed, evaluation_failedInvestigate the runner exception.

When something goes wrong:

  • A request is blocked or failed: fix the reported cause, then use the admin retry endpoint. Never mark a step successful directly in PostgreSQL.
  • Waiting on backup expiry: run the supported prune command. Never record a backup as destroyed while its bytes still exist.
  • Ledger or its key missing/corrupt: keep every writer (app, search, backup, restore) offline and restore the protected copy. If none exists, contact support; never initialise a replacement ledger.
  • A valid ledger is ahead of PostgreSQL: run the supported offline restore flow so deletions are replayed before services start.
  • Cancellation email didn't arrive: an admin can cancel any request that hasn't started processing.

If Meilisearch is configured but down, deletion waits instead of reporting success. Forced completion, editing Former user records, or replacing the ledger are incident actions, not shortcuts.