We build an AI assistant for a healthcare scenario: clinical staff talk to it inside WeCom (WeChat Work), and there is a web admin console on the other side. Once the product worked end to end, we went back and reviewed the permission model properly — and found that the most dangerous path was the entry point we added last.
This is the write-up of that architecture fix: the WeCom entry point talked straight to the agent engine, bypassing the entire permission system. When teams ship AI agents, 90% of the effort goes into "how do we make it smarter". The remaining 10% is when someone finally asks: "What gives it the right to do any of this?"
1. Where the problem started
We have an AI assistant product for a healthcare scenario: clinical staff talk to it inside WeCom, and the web side is an admin console (user binding, permission configuration, data viewing).
The original deployment topology looked like this:
Cloudflare
│
Nginx
┌────────────┼─────────────┐
│ │ │
/api/* /wecom-app /*
:4021 :18789 :4020
NestJS agent engine Next.js
(RBAC here) (admin console)
Looks clean enough. But when we walked the permission model end to end, we found a fatal problem:
The
/wecom-apppath talks straight to the agent engine and completely bypasses NestJS's RBAC and data-scope enforcement.
What that actually means:
- An account that should only see patients in its own department can ask for hospital-wide data through the WeCom entry point
- The agent has no idea who is asking, so it cannot apply any identity-dependent permission
- The whole
RBAC + DataScopestructure we carefully built on the web side is never traversed on the WeCom path
This is not a missing check in some function. It is a missing layer in the architecture.
2. Why it ended up like this
Looking back, this hole is the product of "natural evolution", not of someone writing bad code.
The evolution path
Step 1 Build the web platform first; the permission system grows around web requests
Step 2 Add an agent engine, which ships with its own runtime and its own conversation entry point
Step 3 To avoid rework, expose the agent engine directly through a dedicated Nginx path
Step 4 Point WeCom at that path — and identity is lost right at the entry point
Every step is reasonable. Together they are a hole.
Root cause
The agent engine is a separate system with its own notion of identity — or rather, with no notion of identity.
When a request hits it directly:
- It does not know who the caller is
- It does not know what data the caller is allowed to see
- It certainly does not know whether this particular tool call should be permitted
And those are exactly the questions that can only be answered on the business side — because the rules are defined by the business, not by the agent framework.
3. The core idea: separate decision from execution
The fix centres on a split that is classic in security architecture but often overlooked in AI agent work:
PDP Policy Decision Point decides "may this happen"
PEP Policy Enforcement Point performs "the action that was allowed"
We turned this into a product-level invariant:
The platform is the PDP; the agent engine only executes. Signature checks fail closed.
In architecture terms:
| Who | Responsibility | Never does |
|---|---|---|
| Platform (NestJS) | Identify the caller, determine the data scope, decide whether this call is allowed | — |
| Agent engine | Take an already-authorised instruction and execute the conversation / tool call | Makes no permission decisions (it has nothing to decide with) |
| Transport | Verify the signature; reject if verification fails | Lets no unsigned request through |
The request path after the fix
WeCom user sends a message
│
▼
Platform (PDP)
├─ resolve identity: who is this, which tenant
├─ apply data scope: which data may they see
├─ decide the action: is this tool call allowed
└─ sign and forward ──────────► Agent engine (PEP)
│
executes only, never decides
The key point: the agent engine never receives a raw request that the platform has not already judged.
Consequences:
- There is exactly one copy of the permission logic (on the platform), so you cannot end up with "strict on the web, loose on WeCom"
- If the agent engine is replaced by another framework, the permission model does not have to be rebuilt
- New entry points (DingTalk, Feishu, …) get permissions for free, as long as they go through the platform
4. Isolating the data layer: why we do not share a database
Funnelling permissions through the platform only answers "who may initiate a call". There is a second question: should the agent engine's own runtime data live together with business data?
Our conclusion: a separate database, on the same PostgreSQL instance.
PostgreSQL instance (same server)
├── agent_core ← owned exclusively by the agent engine
│ ├── workflow definitions
│ ├── agent state
│ └── tool-call records
│
└── followup_web ← owned exclusively by the business platform
├── business master data
├── tasks and records
├── organisation and staff
└── audit log
Interaction: API only. Never read across databases directly.
Why not a single database
Four reasons, most important first:
① Schema sovereignty conflicts
An agent engine upgrade may run migrations automatically. Sharing a database with application tables means an upgrade can drop or conflict with them — the classic way to have your data damaged by a third-party tool.
② Security boundaries must be able to differ
Business data needs field-level encryption (AES-256); the agent's runtime data (workflow definitions, execution logs) does not need that level. Share one database and you cannot apply differentiated encryption.
③ Replaceability
If we swap the agent engine later (frameworks differ a lot in capability), we switch one
database: agent_core. Business data does not move.
④ Different backup policies
| Data | Backup requirement |
|---|---|
| Business data (follow-up records, etc.) | 30 days incremental + log archive |
| Agent runtime data | 7 days is enough |
Why not go all the way to two servers
That is a matter of stage, not of principle:
- Two PostgreSQL servers early on is over-investment with poor operational return
- Separate databases inside one instance already give a permission boundary — the
agent_coredatabase user has no rights on the business tables - The two systems have no cross-database transaction requirement (they talk over asynchronous APIs)
In other words: take isolation to database level first, and to instance level once volume justifies it. But "never read across databases directly" holds from day one.
The single data exchange channel
Business platform → POST /api/mcp/call → agent (initiate an action)
Agent → POST /api/webhooks/callback → business platform (write results back)
The business platform never SELECTs from the agent database, and the agent never INSERTs
directly into business tables.
The cost is explicit: you cannot do a cross-domain JOIN in one SQL statement. That is the point — crossing the boundary means going through an interface, and going through an interface leaves permission checks and audit records behind.
5. Transport: signatures must fail closed
If that single channel can be spoofed, all the isolation above is decoration.
Two things:
① Signature verification fails closed
Missing signature, invalid signature, or unconfigured key → reject, always. There is no degraded path where a failed check lets the request through.
This sounds obvious, and it is precisely where engineering practice breaks down: during development it is convenient to add "skip verification if no key is configured", and that branch ships. So we made it a hard production constraint:
In production (NODE_ENV=production),
if the signature key is missing, empty, or equal to the documented development
default → refuse both signing and verification.
② Per-tenant signing keys
In a multi-tenant deployment a single shared signing key is a liability: one tenant's key leaking means every tenant's channel can be forged.
So each tenant can configure a dedicated signing key, and the verifying side picks the key based on the tenant identifier in the request header:
Per-tenant key (falls back to a global key when absent)
→ signer and verifier share the same resolution logic
→ keys live in environment variables only, never in the database
One detail deserves its own paragraph: "keys never touch the database" is a hard prohibition. Once a key is stored, database backups, read replicas, audit queries — every one of them becomes a leak surface.
6. Related problems we fixed at the same time
While chasing this hole we realised it was one instance of a whole class of problems. The same batch also fixed:
| Problem | Fix |
|---|---|
| Query filter fields could be set arbitrarily by the client | Field allow-list; anything outside it is rejected |
| Production could fall back to a development key | Hard fail-closed in production |
| Sensitive operations left no audit trail | Login success/failure, password change, refresh and logout all emit audit events |
| Deployment samples shipped with real keys | Sample files may contain placeholders only; enforced in CI |
| Edge proxy leaked version information | server_tokens off + HSTS + a baseline of security headers |
They share one design principle:
For anything security-related, failure must route to "reject", never to "allow".
7. Four judgments worth reusing
After the fix we compressed the experience into four judgments. They are independent of the technology stack and transfer to any agent project:
① An agent should not own permission decisions
It is an executor. Permission decisions must happen on the business side, because only the business side knows who may see what.
If you find permission logic written into an agent's prompt or tool definitions, you have gone the wrong way.
② Every new entry point must answer "where does identity come from"
Every time you add an entry point (web, WeCom, DingTalk, API), it must be able to answer:
For requests entering here, where is identity resolved?
Where is data scope applied?
An entry point that cannot answer is a new privilege-escalation channel.
③ Isolation granularity can be staged; "never read directly" cannot be compromised
Database-level → instance-level isolation can be upgraded as volume grows. But "the two systems never read across databases directly" must hold from day one, because once it breaks, every permission design downstream leaks.
④ Security branches must fail closed, and production must enforce it
Convenience branches like "skip verification when no key is configured" must be hard-rejected in production. Developer discipline is not a control.
8. One sentence
The capability ceiling of an AI agent is set by the model. Its permission boundary must be set by the architecture.
Most discussion about agents is about how to make them do more. When you land one in a real business system, the harder and more important question is how to make sure it cannot do what it must not.
Author: A Liang · Chada Software — next up: the concrete implementation of multi-tenant data isolation and agent action auditing. Happy to compare notes if you are working on something similar.
