Case study · Multi-tenancy + AI
AdmitDesk
LiveOne 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
| User | How they get in | What they can do |
|---|---|---|
| College admin | Institution code, email and password | Everything staff can, plus final decisions, document review and managing staff |
| College staff | Same | Triage applicants, add notes, request upload links, ask the agent |
| Candidate | No account: the public application form | Apply and attach documents on a 30-minute token |
| Candidate via staff link | A 7-day upload link | Attach documents to one applicant, never read them back |
| Student portal | Institution code, email and a one-time code | See 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
Clients
- Staff browser
- Candidate browser
- Student portal
Next.js app
- API route handlers
- Server Components
Server layer
- Auth + rate limit
- Tenant-scoped services
- Agent context builder
- Repositories
Backing services
- PostgreSQL
- S3 private bucket
- LLM chat endpoint
- External rate limiter
- A route handler parses input with zod and calls
requireStaff, which verifies the JWT and reads the tenant from it. - The service receives a context whose
tenantIdcame only from that verified token. Services do not accept a tenant id from the request body. - The repository puts the tenant in every
whereclause and returns nothing for another college's id. - 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
whereclause too. - Trade-off
- It is a convention, not a lint rule.
updateManyquietly 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 wrong | What happens |
|---|---|
| Staff of college B requests college A's applicant | Scoped query returns nothing, so 404. Dashboard pages render the 404 page. |
| Cross-tenant question to the agent | 404 before any prompt is built or the model is called |
| An upload link used for a different applicant | 404 |
| An upload link used to read a file back | 401: reading files needs a staff session |
| A token of one kind presented as another | 401: the token kind is checked |
| Uploaded text tries to close the untrusted-data fence | The closing tag is escaped, so the text stays inside its fence |
| The model times out or errors | 502 with a clear message instead of a hang |
| The rate limiter is down | Requests are allowed with a loud log, unless configured to fail closed |
| Staff tries to set a final decision | 403: final decisions need an admin |
| A hostile file name | Lower-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