Files
descrybe/docs/support-auto/09-security-perf.md
T
greeneclipse 8580c996c3 Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
2026-08-09 22:47:43 +02:00

105 lines
5.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.