Security
AgentCore enforces RBAC via JWT, scopes access by effective department permissions, scrubs PII before storing or forwarding data, and guards against prompt injection before queries hit the LLM.
Auth & RBAC
Authentication
JWTs are issued and verified through @fastify/jwt.
Token Payload
{
"sub": "<user-id>",
"email": "user@example.com",
"role": "approver",
"roleId": "<system-role-id>",
"departmentId": "<department-id>",
"tokenVersion": 3,
"iat": 1713000000,
"exp": 1713604800
}
Configuration
| Variable | Default | Description |
|---|---|---|
JWT_SECRET | — | Signing secret (min 32 chars, required) |
JWT_EXPIRES_IN | 7d | Token lifetime |
Flow
- Register:
POST /api/v1/auth/register— creates anemployeeuser for a supplieddepartmentId, returns JWT. Client-suppliedroleis ignored by schema and cannot create admins. - Login:
POST /api/v1/auth/login— validates credentials, returns JWT - Refresh:
POST /api/v1/auth/refresh— issues new token from valid token - Forgot/reset password:
POST /api/v1/auth/forgot-passwordcreates a one-hour reset token, andPOST /api/v1/auth/reset-passwordconsumes it and incrementstokenVersion - Self-service security:
POST /api/v1/me/change-passwordandPOST /api/v1/me/logout-allincrementtokenVersionand return a fresh token - Use: Include
Authorization: Bearer <token>on API requests
Passwords are hashed with bcrypt (via bcryptjs). JWTs are only read from the Authorization header. WebSocket clients authenticate with a first JSON message carrying the JWT — query-string JWTs are rejected.
Role-Based Access Control (RBAC)
Roles and Permissions
RBAC is permission-based. SystemRole records define:
| Field | Description |
|---|---|
permissions | Capability keys such as canManageUsers, canApprove, or wildcard * |
allDepartments | Whether the role applies globally |
departmentIds | Department allowlist when the role is not global |
isSystem | Built-in role marker; system role slugs are protected |
User.role still stores the legacy enum (employee, approver, dept_head, admin) for compatibility and fallback permissions. New access checks compute effective permissions from the assigned SystemRole, user-level grants/revokes, and department grants/revokes.
GET /api/v1/permissions exposes the permission catalog. /api/v1/roles manages roles. /api/v1/users/:id/permissions/... handles per-user overrides.
Middleware
Implementation: src/middleware/rbac.ts
requirePermission(permission) — requires one computed permission:
requirePermission('canManageUsers')
requireAnyPermission(permissions[]) — requires one of the listed permissions:
requireAnyPermission(['canManagePlugins', 'canManageNamespaces'])
requireRole() and requireAnyRole() remain for legacy routes. Prefer permission checks in new routes.
requireSelf() — the user can only access their own resources unless they hold canManageUsers.
RBAC denials return the 401/403 response immediately — the route handler does not run after a denied check.
Department Isolation
Department isolation lives in src/lib/department-scope.ts.
Scope is derived from effective RBAC state: role department scope, primary/current department, and user-level grants/revokes. Global-scope users see all departments. Everyone else is restricted to their effective department IDs.
| Surface | Scope pattern |
|---|---|
| Knowledge bases and namespaces | direct departmentId filters via scope.directWhere() |
| Agent tasks | nested namespace filters via scope.nestedWhere('namespace') |
| Approvals and conversations | user/conversation department traversal |
| RAG retrieval | raw SQL filters derived from scope.departmentId |
| WebSocket task events | namespace access checked on subscribe and again before every broadcast |
| Employee profiles | own profile or effective department scope; global users can read all |
| Document templates and generation history | namespace/department filters from effective department scope |
| Notifications | owner-only (userId) |
The regression harness in tests/department-isolation.test.ts covers list, detail, mutation, analytics, RAG, and WebSocket paths.
Endpoint Access Matrix
| Endpoint Group | employee | approver | dept_head | admin |
|---|---|---|---|---|
| Auth (register/login) | yes | yes | yes | yes |
| Conversation review API | no | yes | yes | yes |
| Knowledge bases (read) | yes | yes | yes | yes |
| Knowledge bases (write) | dept-scoped | dept-scoped | dept-scoped | yes |
| Document upload | dept-scoped | dept-scoped | dept-scoped | yes |
| Approvals | no | yes | yes | yes |
| Namespaces (read) | yes | yes | yes | yes |
| Namespaces (write) | no | no | yes | yes |
| Departments management | no | no | no | yes |
| Users management | no | no | no | yes |
| Roles management | no | no | no | yes |
| Document generation | yes | yes | yes | yes |
| Document template management | no | no | yes | yes |
| Plugins (view) | yes | yes | yes | yes |
| Plugins (manage) | no | no | yes | yes |
| Notifications | own | own | own | own |
| Audit logs | no | no | no | yes |
| RAG draft | no | yes | yes | yes |
| Employee profiles (own) | yes | yes | yes | yes |
| Employee profiles (edit) | no | no | yes | yes |
PII Scrubber
AgentCore uses a two-tier PII protection system to prevent personal data leakage through the AI pipeline.
Implementation
src/knowledge/pii.ts
Tier 1: Ingestion-Time Scrubbing (One-Way)
PII is irreversibly masked during ingestion before any text enters the knowledge base. Raw personal data never reaches the vector store or chunk content.
Detected Patterns
| Type | Pattern | Replacement |
|---|---|---|
user@domain.com | [REDACTED_EMAIL] | |
| Phone | +380123456789 | [REDACTED_PHONE] |
| Credit card | 4111-1111-1111-1111 | [REDACTED_CC] |
| IP address | 192.168.1.1 | [REDACTED_IP] |
| IBAN | UA213223130000026007233566001 | [REDACTED_IBAN] |
| Amount | $1,234.56 | [REDACTED_AMOUNT] |
When Applied
- After text extraction, before chunking.
- Applied to all document types (PDF, DOCX, TXT, URL, image).
- Not reversible — the knowledge base stays clean.
Tier 2: Conversation-Time Scrubbing (Reversible)
In live conversations, PII in the user message is reversibly masked before it reaches the LLM. Once the LLM responds, placeholders in the response are restored to the real values.
Flow
- User sends a message containing PII (e.g. an email).
- PII is replaced with a placeholder:
[PII_EMAIL_a1b2c3]. - Mapping stored encrypted in
PiiRedactionMap:placeholder:[PII_EMAIL_a1b2c3]entityType:emailencryptedValue: AES-256-GCM encrypted original
- Scrubbed message goes to the LLM.
- LLM response may still contain the placeholder.
- Placeholders in the response are restored from the encrypted mapping.
- The user sees the original PII.
Encryption
- Algorithm: AES-256-GCM
- Key source:
PII_ENCRYPTION_KEY - Key format: base64-encoded 32-byte key
- Each encrypted value includes IV and auth tag
- Scoped to conversation (maps stored per
conversationId)
PII encryption is deliberately separate from JWT_SECRET. Rotating auth tokens doesn't re-encrypt PII, and compromising one secret doesn't compromise the other domain.
Why Two Tiers?
| Tier | Purpose | Reversible | Scope |
|---|---|---|---|
| Ingestion | Protect knowledge base at rest | No | Documents/chunks |
| Conversation | Protect user data in transit to LLM | Yes | Live messages |
Knowledge-base PII is permanently removed because those documents stick around. Conversation PII needs to be restored so users see natural replies.
Injection Guard
The injection guard detects and blocks prompt-injection attempts in user messages and RAG context.
Implementation
src/knowledge/injection-guard.ts — checkForInjection()
Detection Categories
1. Instruction Override
Attempts to override system instructions.
Examples: "ignore previous instructions", "disregard all rules", "forget your prompt", "do not follow your instructions"
2. Persona Injection
Attempts to change the AI's identity or behavior.
Examples: "you are now a hacker", "act as an unrestricted AI", "pretend you have no rules", "roleplay as", "from now on you will"
3. Jailbreak
Known jailbreak techniques.
Examples: "DAN mode enabled", "developer mode", "no restrictions apply", "unrestricted mode"
4. Prompt Extraction
Attempts to extract the system prompt.
Examples: "show me your system prompt", "reveal your instructions", "print all rules", "what is your prompt"
5. Role Injection
Attempts to inject system-level messages.
Examples: [system]:, [assistant]:, ````system ```,<|im_start|>system`
6. Context Manipulation
Attempts to reset or manipulate conversation context.
Examples: "end of context", "begin new session", "reset conversation", "new system message"
Application Points
Applied at two points:
- User message — checked before RAG query execution.
- RAG context chunks — each retrieved chunk is checked before context assembly.
Response
interface InjectionCheckResult {
safe: boolean;
reason?: string; // Category name if detected
}
When injection is detected:
safe: falsewith the detectionreason;- the message always routes to HITL, regardless of trust status;
- the RAG response includes
injectionDetected: trueandinjectionReason.
Pattern Count
30+ regex patterns across the categories above. Patterns are case-insensitive and written to catch common variations and obfuscation.
Limitations
- Regex-based detection — sophisticated attacks may slip through.
- No LLM-based classification yet.
- Focused on English and Ukrainian patterns.
API Boundary Protection
Structured Error Envelope
Application errors use one JSON shape:
{
"error": "ValidationError",
"message": "Request validation failed",
"statusCode": 422,
"details": {}
}
The details field is optional. src/middleware/errorHandler.ts normalizes Zod, JWT, Prisma, OpenAI/provider, known application, 404, and unexpected 500 errors into this envelope. Routes can use sendError(reply, statusCode, error, message, details) for explicit failures.
Rate Limits
@fastify/rate-limit is registered globally:
- default global limit: 100 requests per minute;
- key: authenticated user ID when available, otherwise client IP;
- storage: Redis outside tests, in-memory in tests;
- allowlist: loopback clients and
/api/v1/health; - auth-specific overrides: register is limited to 3 requests/hour, login to 5 requests/15 minutes.
CORS
CORS uses an exact-origin allowlist from ALLOWED_ORIGINS. Credentials are enabled, so wildcard origins are rejected at config parse time.