← All posts
Backend8 min readProject: Central auth service

Turning off an app without breaking anything: disable, restore and hard delete in an auth service

Removing an application from a central auth service sounds like one delete. In reality, it touches groups, permissions, live sessions, Root Admins and foreign keys that cascade in an order PostgreSQL doesn't promise. Here is the two-step lifecycle we built, the cascade bug we had to fix, and why bumping versions alone doesn't end a session.

#PostgreSQL#Prisma#Data Lifecycle#Soft Delete#Authorization#Foreign Keys

The problem

Removing an app either couldn't be undone, failed on foreign keys, or left users with working tokens for an app that was supposed to be gone.

The result

Apps are disabled first (fully reversible), hard-deleted only on purpose; live sessions end immediately, and deletes cascade reliably.

Stack

NestJSPrismaPostgreSQLRedis

The problem

Our central auth service manages several connected applications. Each app has a permission catalog, group activations (which departments may enter it) and permission ticks (what each department may do there).

Sooner or later, an app has to go away: it's being replaced, retired, paused during a migration, or was registered by mistake. The first version of "remove an app" raised more questions than it answered:

  • If we delete the app, every group's activation and every tick for it is gone. What if we turn it back on next week? Someone has to re-tick everything, from memory.
  • The delete itself sometimes failed with foreign key errors.
  • After removing an app, people who were using it kept working in it for hours.
  • Could a new app reuse the old app's name?

Deleting an app turned out to be one of the most delicate operations in the whole system.

The details

Two very different needs

When people say "remove the app", they usually mean one of two things:

  • "Turn it off": stop everyone from using it, for now. Expect to turn it back on, exactly as it was.
  • "Delete it for good": it's never coming back, clean up everything.

The first one happens often. The second one is rare and irreversible. Treating both as one action means every "turn it off" is dangerous.

What depends on an app

Application
 ├── PermissionCatalog (its permissions)
 │     └── GroupAppPermission (ticks)  ─┐
 └── GroupAppAccess (group activations) │
       └── GroupAppPermission (ticks) ──┘   ← reachable through TWO paths

A permission tick references both the activation (group × app) and the catalog entry. Deleting an app reaches the ticks through two cascade paths at the same time.

The challenges

Cascades with no guaranteed order

Originally, two of those relations were onDelete: Restrict. When an app was deleted, PostgreSQL cascaded into both the catalog and the activations in the same statement. If it reached a catalog row before the ticks pointing to it were removed through the other path, the Restrict rule fired, and the whole delete aborted. PostgreSQL doesn't promise which path it walks first, so the delete could work one day and fail the next.

Live sessions

Users who were active in the app hold valid access tokens for up to 8 hours. Removing rows in the database doesn't touch tokens that were already issued.

Root Admins

Root Admins bypass groups. Any logic that finds "users of this app" through groups doesn't see them, and any access check placed inside permission resolution doesn't apply to them either.

Names are not just labels

An app's name is the prefix of every permission key it ever published: billing:invoices:view. If a brand-new app could take a retired app's name, its permissions would collide with whatever the old app left behind.

The solution

1. A two-step lifecycle

StepRouteWhat it doesReversible
DisableDELETE /applications/:idstatus = DISABLED. Nothing is removed. Every activation and tick stays.✅
RestorePOST /applications/:id/restorestatus = ACTIVE. Access comes back exactly as it was; nobody re-ticks anything.—
Hard deleteDELETE /applications/:id/hardThe app and its catalog, activations and ticks are gone for good. Only allowed on a disabled app.❌

The everyday action (DELETE) is the safe, reversible one. The irreversible one has its own route, and it requires the app to be disabled first, so you can't permanently delete a live app with one request.

Listing follows the same shape: GET /applications?status=active|trash|all, defaulting to active, so an admin screen never shows a deleted app by accident.

2. Fix the cascade: Restrict → Cascade

The relations that blocked the hard delete became Cascade, including the tick → catalog relation:

model GroupAppPermission {
  groupAppAccessId Int
  permissionId     Int

  groupAppAccess GroupAppAccess    @relation(fields: [groupAppAccessId], references: [id], onDelete: Cascade)

  /// Cascade, not Restrict: hard-deleting an Application wipes its catalog
  /// and its activation rows in the same statement, and PostgreSQL does not
  /// promise which path it walks first. Left as Restrict, a catalog row
  /// deleted before its tick would abort the whole delete.
  permission     PermissionCatalog @relation(fields: [permissionId], references: [id], onDelete: Cascade)

  @@id([groupAppAccessId, permissionId])
}

With every path set to Cascade, the order no longer matters: whichever path PostgreSQL takes first, the ticks are removed, and the delete always succeeds. The comment is there so nobody "tightens" it back to Restrict later.

Groups and users are never touched. A group is global (a department), so losing one app can't delete a group or unassign anybody. Only that app's activations and ticks go, and only on hard delete.

3. End live sessions: bump the versions…

Disabling, restoring and hard-deleting all change what users may do in that app, without touching any group. So each one bumps the permissions version of every affected user for that app:

async bumpForApp(appId: number) {
  const [memberIds, rootAdminIds] = await Promise.all([
    groups.findMemberIdsByApp(appId),   // members of every group activated for it
    users.findActiveRootAdminIds(),     // Root Admins hold no group: add them explicitly
  ]);
  const userIds = [...new Set([...memberIds, ...rootAdminIds])];
  await versions.bumpPermissionsForUsersInApps(userIds, [appId]);
}

Two details:

  • Only permissionsVersion is bumped, never tokenVersion. The token version is global, so bumping it would log everyone out of every other app too. Disabling one app must not behave like a security incident.
  • For hard delete, the bump runs before the delete: the activation rows it reads to find members are gone once the app is deleted.

4. …and refuse at every minting path

Here's the subtle part. A bump only makes tokens stale. When an app sees a stale token, it does what it always does: ask the auth service for a fresh one. If the auth service simply minted a new token, the bump would achieve nothing.

So every place that mints tokens checks the app's status:

  • the login callback (/auth/session),
  • the development login,
  • the session exchange (/auth/exchange-session),
  • the silent refresh.
// BEFORE the Root Admin branch on purpose: Root Admins skip the group
// check entirely, so a status check inside permission resolution would
// let them walk into a disabled app.
if (app.status !== 'ACTIVE') {
  throw new ForbiddenException(`Access denied. Application "${app.name}" is disabled.`);
}
if (!session.isRootAdmin) { /* resolve group access… */ }

So the sequence for a user who's active in an app when it's disabled is:

1. Admin disables the app       → permissionsVersion bumped for its users
2. User's next request           → app sees the mismatch → "stale, refresh"
3. Frontend refreshes            → backend calls the session exchange
4. Auth service                  → 403 "Application is disabled"
5. App shows its "this application is currently unavailable" screen

The bump triggers the check; the minting paths enforce it. Neither works alone.

5. Refuse admin work on a disabled app (mostly)

While an app is disabled:

  • activating a group for it, ticking new permissions, syncing its catalog, and creating service tokens for it all return 409 ("restore it first"),
  • unticking stays allowed: removing rights from a dead app is always safe.

This prevents a confusing situation where an admin carefully configures access for an app that can't be used, then restores it and accidentally opens more than intended.

6. Names stay reserved in the trash

Application.name stays unique, and the create handler's lookup ignores status. A disabled app's name can't be taken by a new registration (409); the answer is to restore the old app. After a hard delete, the name is free, because nothing is left to collide with.

7. What connected apps have to do

Almost nothing. The happy path didn't change: same cookies, same Redis keys, same "stale → refresh → retry" behavior. The only new case is step 5 above: a 403 on refresh means "this app is switched off", and the frontend should show a clear "unavailable" page instead of looping on refresh.

Lessons learned

  • "Remove" usually means "turn off". Make the reversible action the default, and the irreversible one explicit.
  • Disable keeps everything. If restoring requires re-configuring, people will be afraid to disable.
  • Hard delete only from the trash. Two deliberate steps prevent irreversible accidents.
  • Don't trust cascade order. When rows are reachable through several cascade paths, mixing Restrict and Cascade can fail depending on an order the database doesn't guarantee.
  • Invalidation and enforcement work together. A version bump only helps if every minting path re-checks and refuses.
  • Check status before shortcuts. Admin bypasses must come after "is this app even active?".
  • Bump narrowly. Disabling one app should refresh that app, not log people out everywhere.
  • Names carry history. When a name prefixes other identifiers, reserve it until the old data is truly gone.

FAQ

Should I use soft delete or hard delete?

Use soft delete (a status like DISABLED) as the everyday action when something might come back, and keep hard delete as a separate, deliberate step, ideally only allowed after a soft delete.

Why does my PostgreSQL cascade delete fail with a foreign key violation?

If rows are reachable through several cascade paths and one of the paths uses ON DELETE RESTRICT, the delete can fail depending on which path PostgreSQL processes first. Make all paths that should disappear together use CASCADE.

How do I log users out of one application but not others?

Use a per-application permission or session version. Bump it only for that application, so tokens for other apps stay valid, and make the token refresh refuse access to the removed app.

Can a new application reuse the name of a deleted one?

Only when no data from the old one remains. If the name prefixes permission keys or other identifiers, keep it reserved while the old app is merely disabled.

Facing a similar problem?

I help teams fix performance, backend and reliability issues in production.

Let's talk
Welcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have funWelcome to My WebSite please enjoy and have fun

Componentity

Thanks for visiting my portfolio. Every project here reflects my passion for building and learning. If you’re interested in working together, have a question, or just want to connect—don’t hesitate to reach out. I'm always open to meaningful conversations and new ideas.

© 2026 Zakariya Zouazou. All Rights Reserved.

Contact Us