105 lines
5.0 KiB
Markdown
105 lines
5.0 KiB
Markdown
# 09 — Security & performance: auto-reply + AI fallback
|
||||
|
|
|
|||
|
|
**Agent:** 9/10 · **Scope:** harden FAQ match + AI fallback (no git)
|
|||
|
|
**Contract:** [02-contract.md](./02-contract.md) §§6–7
|
|||
|
|
**Related:** [01-inventory.md](./01-inventory.md), `docs/security-notes.md`
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Goal
|
|||
|
|
|
|||
|
|
Make automatic first responses (FAQ match → AI fallback) safe under abuse:
|
|||
|
|
|
|||
|
|
| Control | Requirement |
|
|||
|
|
|---------|-------------|
|
|||
|
|
| Rate limits | AI jobs: 10 / company / hour + 30 / platform / minute (FAQ match excluded) |
|
|||
|
|
| Prompt injection | Ticket subject/body treated as **untrusted data** (delimiters + soft filters) |
|
|||
|
|
| Tenant isolation | Ticket reads/writes always filter `company_id`; KB snippets with foreign `Company` dropped |
|
|||
|
|
| Secret redaction | Strip keys/tokens/PEM/DSNs before match features, prompts, and logs |
|
|||
|
|
| Timeouts | `AutoReplyTimeout` = 25s on `TryAutoReplyLLM` |
|
|||
|
|
| Idempotency | No double public auto-post (row lock + unique partial index) |
|
|||
|
|
| Indexes | See migration `035_support_auto_security_perf.sql` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Implementation map
|
|||
|
|
|
|||
|
|
| Piece | Path |
|
|||
|
|
|-------|------|
|
|||
|
|
| Secret redact + untrusted wrap | `apps/api/internal/security/ticket_prompt.go` |
|
|||
|
|
| FAQ match uses shared redact | `RedactSecretsForMatch` → `security.RedactSecrets` |
|
|||
|
|
| AI rate limiter | `apps/api/internal/support/auto_ratelimit.go` |
|
|||
|
|
| Prompt builder (isolation) | `apps/api/internal/support/auto_prompt.go` → `BuildAutoReplyMessages` |
|
|||
|
|
| Idempotent claim / insert | `apps/api/internal/support/auto_idempotency.go` |
|
|||
|
|
| Gated LLM entry | `TryAutoReplyLLM` in `ai_auto_reply.go` |
|
|||
|
|
| Idempotency indexes | `apps/api/sql/schema/035_support_auto_security_perf.sql` |
|
|||
|
|
| AI jobs table | `apps/api/sql/schema/034_support_auto_jobs.sql` (agent 4) |
|
|||
|
|
| Abuse tests | `security/ticket_prompt_test.go`, `support/ai_auto_reply_test.go`, `support/auto_security_test.go` |
|
|||
|
|
|
|||
|
|
### `TryAutoReplyLLM` gate order
|
|||
|
|
|
|||
|
|
1. `context.WithTimeout(..., AutoReplyTimeout)`
|
|||
|
|
2. Load `company_id` for ticket (fail closed if no pool)
|
|||
|
|
3. **`SupportAI == nil` → `ErrAIAutoReplyDisabled`** (product still opt-in; agent 4 wires runner)
|
|||
|
|
4. `AIRateLimiter.Allow(companyID)` → else `ErrAIRateLimited`
|
|||
|
|
5. `ClaimAutoReplyAttempt` (FOR UPDATE; already-posted / disabled / closed)
|
|||
|
|
6. `SupportAI.RunAutoReply` with nested timeout; errors logged via `RedactForAutoLog`
|
|||
|
|
|
|||
|
|
### Prompt contract
|
|||
|
|
|
|||
|
|
- Fixed `AutoReplySystemPrompt` (server-owned; not admin free-text).
|
|||
|
|
- Customer text wrapped in `<<<UNTRUSTED_*_START/END>>>` after `SanitizeUntrustedTicketText`.
|
|||
|
|
- Platform KB only (`Company == uuid.Nil`) or matching `companyID`; never other tenants’ tickets.
|
|||
|
|
|
|||
|
|
### Idempotency
|
|||
|
|
|
|||
|
|
- Application: `PostMatchedAutoReply` + `ClaimAutoReplyAttempt` / `InsertAutoSystemMessage` check `auto_reply_message_id`, status, and existing public `is_auto_reply` rows; unique violations treated as soft skip.
|
|||
|
|
- DB: unique partial index `support_messages_one_public_auto_per_ticket_uidx` on `(ticket_id) WHERE is_auto_reply AND NOT is_internal_note`.
|
|||
|
|
- AI jobs: unique partial `support_auto_jobs_ticket_active_uidx` in `034_support_auto_jobs.sql` for `pending|running`.
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Threat model (abuse cases covered by tests)
|
|||
|
|
|
|||
|
|
| Case | Test |
|
|||
|
|
|------|------|
|
|||
|
|
| Injection phrases in ticket body | `TestSanitizeUntrustedTicketTextFiltersInjection`, `TestBuildAutoReplyMessages_treatsBodyAsUntrusted` |
|
|||
|
|
| API keys / Stripe / PEM / DSN in body | `TestRedactSecretsTicketAbuse`, `TestRedactSecretsForMatch_delegates` |
|
|||
|
|
| Cross-tenant KB snippet | `TestFilterKBSnippetsForCompany_blocksCrossTenant`, `TestBuildAutoReplyMessages_dropsForeignCompanySnippets` |
|
|||
|
|
| Rate limit company / platform | `TestAIRateLimiter_*` |
|
|||
|
|
| Secrets in log strings | `TestRedactForAutoLog_stripsSecrets` |
|
|||
|
|
| LLM disabled by default | `TestTryAutoReplyLLM_refuses` |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Performance notes
|
|||
|
|
|
|||
|
|
| Path | Guidance |
|
|||
|
|
|------|----------|
|
|||
|
|
| FAQ match | Sync, corpus cache (agent 3), GIN on keywords (032), no external I/O |
|
|||
|
|
| AI | Async preferred (jobs table in 033); HTTP create must not await LLM |
|
|||
|
|
| Lists | Do not embed full `customer_context` (agent 5) |
|
|||
|
|
| Rate limits | In-process only — effective RPM ≈ N × replicas (same as marketing/processing) |
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## ASSUMPTIONs
|
|||
|
|
|
|||
|
|
1. Platform KB has no `company_id` (global help center). Per-tenant KB remains out of scope; if added later, every query **must** filter `company_id` and prompt builders must keep using `FilterKBSnippetsForCompany`.
|
|||
|
|
2. Agent 4 implements `SupportAIRunner` using `BuildAutoReplyMessages` + `InsertAutoSystemMessage` + `aiprovider.ResolveCompleterForRole(..., RoleSupport)` — no second secret store.
|
|||
|
|
3. FAQ match stays free of AI rate limits (contract §6).
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## Verify
|
|||
|
|
|
|||
|
|
```bash
|
|||
|
|
cd apps/api
|
|||
|
|
go test ./internal/security ./internal/support -count=1 -run "Redact|SanitizeUntrusted|WrapUntrusted|AIRate|BuildAutoReply|FilterKB|TryAutoReplyLLM_refuses|AutoReplyTimeout"
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
Apply migrations through `035_support_auto_security_perf.sql` (and `034_support_auto_jobs.sql`) before relying on unique indexes / jobs table.
|
|||
|
|
|
|||
|
|
## Rollback
|
|||
|
|
|
|||
|
|
Revert `035_*` down migration; remove SupportAI / AIRateLimiter fields usage; restore stub-only `TryAutoReplyLLM` if needed. FAQ match continues to work without AI runner.
|