Skip to content

Case study · Multi-tenancy + AI

AdmitDesk

Live

One database, many colleges, and an AI agent that only ever sees one applicant.

A college signs up, gets its own public application page, and its staff triage applicants from a dashboard. Every college shares the same database and the same running app, and no college can see another's data. That constraint is the whole point of the project.

Tenancy model
Shared schema
Cross-tenant response
404, not 403
AI agent, no tools
Read-only
Signed file URL lifetime
60 s
  • Next.js 15
  • TypeScript
  • PostgreSQL
  • Prisma
  • S3
  • Tailwind

The problem

Admissions data is Aadhaar numbers, marksheets, fee receipts and category certificates. If college A can read college B's applicant, that is not a bug report, that is a phone call from a lawyer.

Multi-tenancy rarely fails in the architecture diagram. It fails in one specific place: a single query that forgets its tenant filter. The design goal was to make that query hard to write, and to make an AI agent on top of the data unable to cross the same line.

Who uses it

UserHow they get inWhat they can do
College adminInstitution code, email and passwordEverything staff can, plus final decisions, document review and managing staff
College staffSameTriage applicants, add notes, request upload links, ask the agent
CandidateNo account: the public application formApply and attach documents on a 30-minute token
Candidate via staff linkA 7-day upload linkAttach documents to one applicant, never read them back
Student portalInstitution code, email and a one-time codeSee a plain-language status, upload, ask a student agent

Constraints

  • One shared database and one running app for every college.
  • The most sensitive documents in the system are personal ID and financial records.
  • Free hosting tiers, where a second long-running service cold-starts for close to a minute.
  • An AI agent that must never answer across tenants, and never change a record even if tricked.

Architecture

  1. Clients

    • Staff browser
    • Candidate browser
    • Student portal
  2. Next.js app

    • API route handlers
    • Server Components
  3. Server layer

    • Auth + rate limit
    • Tenant-scoped services
    • Agent context builder
    • Repositories
  4. Backing services

    • PostgreSQL
    • S3 private bucket
    • LLM chat endpoint
    • External rate limiter
One Next.js app. Every request resolves a tenant from a signed token, and services query only inside that tenant before touching Postgres, S3 or the model.
  1. A route handler parses input with zod and calls requireStaff, which verifies the JWT and reads the tenant from it.
  2. The service receives a context whose tenantId came only from that verified token. Services do not accept a tenant id from the request body.
  3. The repository puts the tenant in every where clause and returns nothing for another college's id.
  4. A miss becomes a 404. For the agent, that happens before a prompt is built or the model is called.

Key decisions

Shared schema with a tenantId column

Why
The simplest model to build and operate, and good enough for the stage the product is at.
Trade-off
Isolation is enforced by the application, not the database. A new repository method that forgets the filter would compile and would leak. Postgres Row-Level Security is the planned second layer.
Rejected
Schema-per-tenant and database-per-tenant, which isolate better but cost more to build and run.

findFirst and updateMany on tenant-owned tables, never findUnique, update or delete

Why
All three of those key on the primary key alone and would happily read or write another college's row. The scoped versions put the tenant in the where clause too.
Trade-off
It is a convention, not a lint rule. updateMany quietly updates zero rows instead of throwing, so callers do a scoped existence check first.
Rejected
update({ where: { id } }), documented in the README as "this compiles, and it is wrong".

A cross-tenant id returns 404, not 403

Why
A 403 confirms the record exists. From another college's point of view the record simply does not exist, which is both the safer answer and the true one.
Trade-off
It is harder to tell a permission bug from a missing record while debugging.
Rejected
403 Forbidden.

The tenant comes only from the verified token, with branded ID types

Why
Passing an applicant id where a tenant id belongs becomes a compile error rather than an empty result set at 2am.
Trade-off
The brand is a compile-time cast. It proves nothing at runtime, which is why the token rule matters more.

The agent has no tools and no write path

Why
If it is tricked, it can only say something wrong. It cannot approve anyone. This is the main defence, not a limitation to remove later.
Trade-off
The agent cannot automate any triage action.

Document completeness is computed in code and handed to the model as fact

Why
The agent and the student status page both read a code-computed status instead of counting uploads themselves, so the numbers are right regardless of how the model behaves that day.
Trade-off
Document requirements are fixed in code rather than configurable per college.
Rejected
Asking the model to do the counting.

S3 only, with uploads going browser-to-bucket on presigned URLs

Why
Files never pass through the server; the app only decides whether to sign a URL. Applicant documents should not sit on a developer's laptop, and a dev-only storage driver is a second code path production never exercises.
Trade-off
Every developer needs a real bucket, and bucket CORS has to stay in sync with every deployed origin.
Rejected
A local-disk storage fallback.

One Next.js app instead of a separate API service

Why
Two services means two hosts, and the free tier of anywhere that runs a long-lived process cold-starts for the better part of a minute.
Trade-off
The front end now runs in a process that holds database credentials. ESLint import bans keep UI code away from the server layer.
Rejected
The earlier split: a Bun API plus a Next.js front end.

Failure modes

What goes wrongWhat happens
Staff of college B requests college A's applicantScoped query returns nothing, so 404. Dashboard pages render the 404 page.
Cross-tenant question to the agent404 before any prompt is built or the model is called
An upload link used for a different applicant404
An upload link used to read a file back401: reading files needs a staff session
A token of one kind presented as another401: the token kind is checked
Uploaded text tries to close the untrusted-data fenceThe closing tag is escaped, so the text stays inside its fence
The model times out or errors502 with a clear message instead of a hang
The rate limiter is downRequests are allowed with a loud log, unless configured to fail closed
Staff tries to set a final decision403: final decisions need an admin
A hostile file nameLower-cased, stripped to safe characters and trimmed before it reaches the storage key

What is verified

There are no production traffic numbers to show. On every pull request, CI runs typecheck, lint, 16 unit tests, the database migrations against a real Postgres, and a production build.

What I would change next

  • Add Postgres Row-Level Security, so a forgotten filter is caught by the database and not only by convention.
  • Turn the seeded prompt-injection example into an automated test instead of a manual check.
  • Re-check user and college status on each request instead of trusting an 8-hour session token.
  • Extend the audit log to uploads and upload-link issuance, not only status changes and staff questions.

Deep dive

An AI agent that can only be wrong: tenant isolation and prompt injection in AdmitDesk

Putting an LLM on top of multi-tenant admissions data. How AdmitDesk keeps the agent inside one college and one applicant, and why taking capabilities away beats asking the model to behave.

Read the post