← All posts
Backend8 min readProject: Central auth service

Permissions the apps declare themselves: a self-syncing permission catalog

If admins type permission names by hand, they drift from the code: typos, forgotten endpoints, permissions for routes that no longer exist. Here is how each connected app publishes a manifest of its own permissions, how the auth service pulls it with an encrypted API key, and how removed permissions are pruned without leaving anyone with stale access.

#Authorization#Microservices#API Design#Encryption#NestJS#Security

The problem

Permission names lived in two places, the code and the admin panel, and kept drifting apart.

The result

Apps publish their permissions from code; the auth service syncs, prunes removed ones safely, and the permission checked at runtime is always one that exists upstream.

Stack

NestJSPrismaPostgreSQLRedisNode.js crypto

The problem

In a central auth service, admins assign permissions to groups: "Finance can create invoices in billing". But where do those permissions come from?

The naive answer is: an admin types them into the admin panel. That breaks quickly:

  • a developer adds a new endpoint, and nobody adds its permission, so nobody can use it, or it ends up unprotected,
  • an admin types billing:invoice:create while the code checks billing:invoices:create, and the permission silently never matches,
  • an endpoint is removed from the code, but its permission stays in the admin panel forever, still ticked for groups, describing something that no longer exists.

The permission list ends up in two places (the code and the admin panel) that nobody keeps in sync. And in access control, a mismatch means either "users blocked for no visible reason" or "endpoints with no real protection".

The fix: the code is the only source of truth. Apps declare their permissions, and the auth service learns them automatically.

The details

Each route declares its own permission

In every connected app, protected routes carry a decorator that names the action:

@Controller('invoices')
export class InvoicesController {
  @Get()
  @Permission('view')
  findAll() { … }

  @Post()
  @Permission('create')
  create() { … }

  @Delete(':id')
  @Permission('delete')
  remove() { … }
}

A small helper builds the full permission name from the app name, the controller path and the action, in the format app:resource:action:

billing:invoices:view
billing:invoices:create
billing:invoices:delete

The app prefix keeps catalogs from different apps from ever colliding: billing:invoices:view and inventory:invoices:view are different permissions.

The manifest

Each app exposes a manifest endpoint that lists every permission it declares, with the HTTP method and path it protects:

{
  "data": [
    { "name": "billing:invoices:view",   "method": "GET",    "path": "/invoices" },
    { "name": "billing:invoices:create", "method": "POST",   "path": "/invoices" },
    { "name": "billing:invoices:delete", "method": "DELETE", "path": "/invoices/:id" }
  ]
}

The list is generated from the same decorators the guard reads, so it can't drift from the code.

The same name on both sides

Here's the property that matters most: when a request arrives, the app's guard builds the required permission name with the same helper that built the manifest. So the permission checked at runtime is, by construction, a permission that exists in the auth service's catalog. There's no second spelling anywhere.

The challenges

The manifest must not be public

A list of every endpoint and permission is useful information for an attacker. Only the auth service should be able to read it.

Secrets at rest

To call each app's manifest, the auth service needs that app's API key. Storing those keys in plain text in a database means a database leak hands out keys to every app.

Removing permissions safely

When an endpoint disappears from the code, its permission should disappear from the catalog. But that permission may still be ticked for groups, and members may hold tokens that include it. Deleting it carelessly leaves those tokens claiming a right that no longer exists, until they expire.

Order of operations

Removing a catalog entry cascades and deletes every group's tick for it. Once that's done, there's no way left to ask "who had this permission?".

Disabled apps

An app that has been disabled shouldn't be called over the network, and its catalog shouldn't change.

The solution

1. An API key per app, encrypted at rest

When the Root Admin registers an app, the auth service generates a strong random key:

export function generateApiKey(): string {
  return `ak_${crypto.randomBytes(32).toString('hex')}`; // 256 bits of randomness
}

The key is given to the app (which stores it in its environment) and stored by the auth service encrypted, with AES-256-GCM:

const ALGORITHM = 'aes-256-gcm';

function encryptionKey(): Buffer {
  // Derive a fixed 32-byte key from the configured secret
  return crypto.createHash('sha256').update(process.env.APIKEY_ENC_SECRET!).digest();
}

export function encrypt(plaintext: string): string {
  const iv = crypto.randomBytes(12);                       // unique per encryption
  const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey(), iv);
  const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
  const authTag = cipher.getAuthTag();                      // detects tampering
  return [iv, authTag, encrypted].map((b) => b.toString('hex')).join(':');
}

export function decrypt(payload: string): string {
  const [iv, tag, data] = payload.split(':').map((h) => Buffer.from(h, 'hex'));
  const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey(), iv);
  decipher.setAuthTag(tag);
  return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf8');
}

Why GCM: it's authenticated encryption. If someone modifies the stored value, decryption fails instead of silently producing garbage. A fresh random IV per encryption means the same key never produces the same ciphertext twice.

Why encrypt instead of hash (like passwords)? Because the auth service needs the original key to call the app. A hash can't be turned back into the key.

2. Pulling the manifest

Syncing an app's catalog (on app start, after a deploy, or from a button in the admin panel) does this:

async sync(appId: number) {
  const app = await apps.findForSync(appId);
  if (!app) throw new NotFoundException(`Application ${appId} not found.`);
  if (app.status !== 'ACTIVE') {
    throw new ConflictException(`Application ${appId} is disabled. Restore it before syncing its catalog.`);
  }

  let entries: ManifestEntry[];
  try {
    const res = await axios.get(app.manifestUrl, { headers: { 'x-api-key': decrypt(app.apiKey) } });
    entries = res.data.data;
  } catch (error) {
    logger.error(`Failed to fetch manifest for application ${appId}`, error);
    throw new HttpException('Failed to retrieve the permission manifest from the application.',
                            HttpStatus.FAILED_DEPENDENCY); // 424
  }

  const removed = await this.pruneMissing(appId, entries);
  const result = await catalog.upsertForApp(appId, entries);
  return { ...result, removed };
}
  • The app's manifest endpoint only answers requests carrying its own API key.
  • A disabled app isn't even called over the network (409).
  • A failed fetch is reported as 424 Failed Dependency: honest about the fact that the app didn't answer, not the auth service.
  • New permissions are upserted: added if new, updated if their method or path changed.

Permission keys are stored exactly as the app sends them, prefix included. No stripping, no transformation, so there's only ever one spelling.

3. Pruning: who had it, then delete

Because the manifest is always the app's complete list, anything in the catalog but missing from the manifest really was removed from the code. Pruning follows a strict order:

private async pruneMissing(appId: number, entries: ManifestEntry[]) {
  const reported = new Set(entries.map((e) => e.name));
  const existing = await catalog.findByApp(appId);
  const missing = existing.filter((p) => !reported.has(p.permissionKey));
  if (missing.length === 0) return 0;

  // 1. Find who holds these permissions BEFORE deleting:
  //    the delete cascades away the ticks that answer this question.
  const affectedUsers = await groups.findMembersHoldingPermissions(appId, missing.map((p) => p.id));

  // 2. Bump their permissions version for this app
  await versions.bumpPermissionsForUsersInApps(affectedUsers, [appId]);

  // 3. Now delete (ticks cascade away)
  await catalog.deleteMany(missing.map((p) => p.id));
  return missing.length;
}

Bumping first means every affected user's next request in that app sees their token as stale, refreshes it, and gets a token without the removed permission. No token keeps claiming a right that no longer exists.

4. A manual route that never deletes

For testing, admins can also push catalog entries by hand. That route is a pure upsert: it never prunes, because a partial manual list isn't the app's complete truth. Only the full manifest pull is allowed to remove things.

5. Same name, everywhere

The full chain:

@Permission('delete') on DELETE /invoices/:id
        │ same helper
        ▼
manifest: billing:invoices:delete ──► auth catalog ──► ticked for Finance
        │                                                    │
        ▼ same helper                                        ▼
guard requires billing:invoices:delete  ◄── user's permission list (Redis) has billing:invoices:delete

The permission names in the decorator, the manifest, the catalog, the admin panel, the user's permission list and the guard are all the same string, produced by the same code.

Lessons learned

  • Make the code the source of truth. Permissions typed by hand in an admin panel will drift.
  • Generate both sides from one helper. If the manifest and the guard build names the same way, a mismatch is impossible.
  • Namespace permissions per app. app:resource:action prevents collisions between catalogs.
  • Encrypt secrets you need to read back. Hash what you only need to verify; encrypt (authenticated, with a random IV) what you need to use.
  • Full lists may prune; partial lists may not. Only a complete manifest is allowed to delete permissions.
  • Read before you cascade. Find affected users before deleting the rows that identify them.
  • Refresh before you remove. Bump versions so no live token keeps a deleted permission.
  • Report whose failure it is. 424 Failed Dependency tells admins the app didn't answer.

FAQ

What is a permission manifest?

A list that an application exposes describing every permission it checks, usually with the endpoint each one protects. A central auth service reads it to know which permissions exist, instead of having them typed by hand.

How should API keys be stored in a database?

If you only need to verify them, store a hash. If your service must send the original key to another system, encrypt it with authenticated encryption such as AES-256-GCM, using a random IV per value and a key kept outside the database.

Why use the app:resource:action format for permissions?

It's readable, groups permissions naturally by resource, and the app prefix prevents collisions when several applications share one permission catalog.

What should happen when a permission is removed from an app?

Find the users who held it, invalidate their tokens or permission versions for that app, and then delete it. Otherwise existing tokens keep claiming a permission that no longer exists until they expire.

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