Files

105 lines
5.0 KiB
Markdown
Raw Permalink Normal View History

# 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) §§67
**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.