نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
+340
View File
@@ -0,0 +1,340 @@
# نمای کلی معماری — نسخه وب مستقل AGY
> این سند تصویر فنی پروژهٔ `web/` است: چه چیزی کجاست، چرا این‌طور چیده شده، و
> مرزهای اعتماد کدام‌اند. برای چک‌لیست امنیتی به
> [`../security-checklist.md`](../security-checklist.md) مراجعه کنید.
---
## ۱. هدف و قابلیت‌های محصول
یک **SaaS چندمستأجری مستقل** به‌نام «AI Workforce Platform». مشتری وارد می‌شود،
سازمان می‌سازد، عضو دعوت می‌کند، پروژه تعریف می‌کند و به کارمندان هوش مصنوعی کار
می‌سپارد. مشتری هرگز نمی‌بیند که پشت صحنه چه مدلی، چه providerی یا چه کلیدی است.
**آنچه مشتری می‌بیند:** سازمان، اعضا، پروژه، مخزن Git، دامنهٔ preview، محیط
اجرای ایزوله، کارمند AI، گفتگو با مدیر، task و run، مصرف، صورتحساب، audit log.
**آنچه فقط مالک سامانه می‌بیند:** provider، مدل، سیاست مسیریابی، rate card،
کلیدهای رمزنگاری‌شده، همهٔ سازمان‌ها.
**مستقل از تلگرام:** نسخهٔ وب هیچ import یا وابستگی runtime به `bot.py`,
`web_panel.py`, `caddy_manager.py`, `git_manager.py` یا `gitea_api.py` ندارد. منطق
Git و domain routing فقط به‌عنوان مرجع خوانده و در `web/` به‌صورت adapter مستقل
بازنویسی شده است.
---
## ۲. استک
| لایه | فناوری | چرا |
| --- | --- | --- |
| UI | Next.js 15.1.6 (App Router) + React 19 + TypeScript | RSC، مسیریابی فایل‌محور، یک پکیج مشترک برای کل محصول |
| استایل | Tailwind CSS v3 | RTL و dark-first بدون فایل CSS پراکنده |
| State/Data | کلاینت `fetch` نازک + `localStorage` برای توکن | بدون وابستگی اضافه؛ TanStack Query لازم نشد |
| API | FastAPI + Pydantic v2 | قرارداد تایپ‌دار، OpenAPI خودکار |
| ORM | SQLAlchemy 2.x (async) | هم‌راستا با مدل async FastAPI |
| Migration | Alembic | تنها مسیر تغییر schema در production |
| DB | PostgreSQL (توسعه: SQLite حافظه‌ای برای تست) | تست‌ها بدون سرویس بیرونی اجرا می‌شوند |
| Cache/Queue | Redis | آماده برای workerهای background |
| امنیت | argon2 (رمز)، PyJWT (نشست)، cryptography/Fernet (رازها) | انتخاب‌های استاندارد و مقاوم |
| تست | pytest + pytest-asyncio (backend)، Vitest + Testing Library (frontend) | — |
---
## ۳. چیدمان مخزن
```
web/
├── apps/
│ ├── api/ ← بک‌اند FastAPI (Clean/Hexagonal)
│ └── web/ ← فرانت‌اند Next.js
├── packages/
│ ├── contracts/ ← تایپ‌های مشترک request/response
│ └── ui/ ← کامپوننت‌های پایه (RTL، dark-first)
├── services/ ← workerهای مستقل (ai-runtime, billing-worker, sandbox-gateway)
├── infra/docker/ ← docker-compose برای postgres/redis
├── docs/ ← همین اسناد
├── PLAN.md ← سند مرجع محصول و معماری
└── TODO.md ← فهرست اجرای مرحله‌به‌مرحله
```
---
## ۴. معماری بک‌اند — Clean / Hexagonal
وابستگی‌ها **همیشه به سمت داخل** است. لایهٔ domain به هیچ فریمورک، ORM، Redis یا
HTTP وابسته نیست (این با تست `test_domain_layer_has_no_framework_dependencies`
تضمین شده است).
```
┌───────────────────────────────────────────────────────────────────┐
│ interfaces/http routers + deps + error handlers │
│ فقط route، schema ورودی/خروجی و تبدیل خطا │
├───────────────────────────────────────────────────────────────────┤
│ application services (use case) + ports (driven iface) │
│ sandbox_service, workforce_service, project_service, ... │
├───────────────────────────────────────────────────────────────────┤
│ domain قواعد خالص، بدون I/O │
│ identity, projects, workforce, sandbox, billing, audit, │
│ knowledge, ai_control, platform/rules │
├───────────────────────────────────────────────────────────────────┤
│ infrastructure adapters + repositories + db/models │
│ mock, gitea, caddy, openai_compatible, sandbox_remote, stripe │
└───────────────────────────────────────────────────────────────────┘
↑ همهٔ پیکان‌ها به سمت domain
```
### ۴.۱ لایهٔ domain (بدون I/O)
هر bounded context یک پکیج است با `models.py` و قواعد مربوط به خود:
| Context | مسئولیت |
| --- | --- |
| `identity` | کاربر، سازمان، عضویت، نقش، دعوت |
| `projects` | پروژه، وضعیت مخزن، دامنهٔ preview |
| `workforce` | قالب کارمند، کارمند، thread، message، task، run، artifact، مجوز ابزار (`tools.py`) |
| `sandbox` | workspace، session، snapshot، `NetworkPolicy`، `SandboxResourceLimit` |
| `billing` | پلن، rate card، usage ledger |
| `audit` | audit event، trace، span، error، quality metric |
| `knowledge` | منبع دانش، chunk |
| `ai_control` | provider، مدل، سیاست مسیریابی |
| `platform/rules.py` | **قواعد امنیتی سکو:** اعتبارسنجی برچسب دامنه، ضدتزریق host، ضدSSRF، پورت‌های رزرو/محافظت‌شده |
`platform/rules.py` عمداً در domain است، نه در infrastructure: این قواعد باید
بدون DB و بدون شبکه قابل تست باشند، چون بیشترین بار امنیتی را دارند.
### ۴.۲ لایهٔ application
**سرویس‌ها (use case):**
- `project_service` — ساخت پروژه، اطمینان از مخزن، دامنهٔ preview
- `workforce_service` — کارمند، prompt، مجوز ابزار، thread، task، اجرای run
- `sandbox_service` — workspace، session، اجرای فرمان، clamp منابع، ثبت مصرف
- `billing_service` — خلاصهٔ مصرف، صورتحساب
- `knowledge_service` — منابع دانش و شمارش chunk
- `audit_service` — audit log، trace
**پورت‌ها (interfaceهای driven):** در `app/application/ports/`
`ModelProviderPort`, `PaymentProviderPort`, `SandboxProviderPort`,
`GitProviderPort`, `DomainRouterPort`, `StoragePort`, `VectorStorePort`,
`NotificationPort`, `UsageLedgerPort`, `AuditPort`.
هر پورت حداقل یک **adapter mock** دارد، بنابراین کل استک بدون هیچ credential
بیرونی بالا می‌آید.
### ۴.۳ لایهٔ infrastructure
**انتخاب adapter — یک نقطهٔ تصمیم:** `infrastructure/adapters/registry.py`
تنها جایی است که تصمیم می‌گیرد کدام adapter پشت هر پورت بنشیند، فقط بر اساس
سوئیچ‌های env:
| env | مقادیر مجاز | adapter واقعی |
| --- | --- | --- |
| `AI_DEFAULT_PROVIDER` | `mock` \| `openai_compatible` | `openai_compatible.py` |
| `GIT_PROVIDER` | `mock` \| `gitea` | `gitea.py` |
| `DOMAIN_ROUTER` | `mock` \| `caddy` | `caddy.py` |
| `SANDBOX_PROVIDER` | `mock` \| `daytona` \| `e2b` | `sandbox_remote.py` |
| `PAYMENT_PROVIDER` | `mock` \| `stripe` | `stripe_payments.py` |
مقدار ناشناخته به `ProviderUnavailableError` منجر می‌شود — **fail-closed**، نه
fallback بی‌صدا به mock.
**Repositoryها:** `infrastructure/repositories/` — `identity`, `projects`,
`workforce`, `sandbox`, `billing`, `knowledge`, `ai_control`, `audit`.
هر متد لیست، `limit`/`offset` می‌گیرد و سقف `min(limit, 500)` را اعمال می‌کند.
**مدل‌های DB:** `infrastructure/db/models.py` — ۴۸ جدول با indexهای ترکیبی روی
`(organization_id, created_at)` برای مسیرهای داغ داشبورد.
### ۴.۴ لایهٔ interfaces
`interfaces/http/` شامل `routers/` (۹ روتر: `health`, `auth`, `organizations`,
`projects`, `workforce`, `billing`, `knowledge`, `sandbox`, `admin`)،
`deps.py` (auth، org context، capability) و `errors.py` (تبدیل استثناها به
پاسخ‌های یکدست). `main.py` تنها composition root است: روترها را به هم می‌بندد و
CORS را تنظیم می‌کند.
---
## ۵. معماری فرانت‌اند
Next.js App Router با دو ناحیهٔ مسیر:
**ناحیهٔ عمومی (بدون احراز هویت):** `/login`, `/signup`, `/onboarding`
**ناحیهٔ اپلیکیشن (`(app)` — نیازمند نشست):**
`/dashboard`, `/workforce`, `/workforce/[id]`, `/manager`, `/tasks`,
`/tasks/[id]`, `/projects`, `/projects/[id]`, `/knowledge`, `/billing`,
`/settings`, `/traces`
**ناحیهٔ مالک سامانه:** `/admin`, `/admin/providers`, `/admin/models`,
`/admin/routing`, `/admin/rate-cards`, `/admin/organizations`, `/admin/errors`
**دو نقطهٔ اتصال مرکزی:**
- `src/lib/api.ts` — تنها جایی که base URL، bearer token و پوشش خطا را می‌شناسد.
base URL از `NEXT_PUBLIC_API_URL` می‌آید (یک URL عمومی، نه راز).
- `src/lib/session.tsx` — Context نشست: کاربر، سازمان‌ها، سازمان فعال، ورود/خروج.
**پکیج مشترک UI:** `packages/ui/src/index.tsx` کامپوننت‌های پایه را دارد —
`Button`, `Card`, `Input`, `Badge`, `Modal`, `Drawer`, `Table` (با حالت
empty/loading/error داخلی), `Tabs`, `ToastStack`, `ArtifactViewer`. RTL و
dark-first به‌صورت پیش‌فرض.
---
## ۶. مسیر یک درخواست (مثال: اجرای یک task)
```
مشتری: دکمهٔ «اجرا» در /tasks/[id]
│
├─ POST /api/organizations/{org}/tasks/{task}/runs (Bearer token)
│
├─ deps.get_current_user → توکن را decode می‌کند، کاربر را می‌خواند (۴۰۱ اگر نامعتبر)
├─ deps.get_org_context → عضویت فعال را می‌خواند (۴۰۴ اگر نباشد — نه ۴۰۳)
├─ deps.require_capability(...) → نقش کافی است؟ (۴۰۳ اگر نه)
│
├─ workforce_service.start_run(...)
│ ├─ انتخاب سیاست مسیریابی بر اساس mode (بدون افشای مدل به مشتری)
│ ├─ اجرای گام‌ها → در صورت نیاز، sandbox_service.run_command(...)
│ │ ├─ SandboxProviderPort (mock یا remote)
│ │ ├─ clamp timeout/output از policy (نه از caller)
│ │ └─ ثبت usage در UsageLedger
│ └─ ثبت گام‌های timeline (kind="output" — بدون افشای «model»)
│
└─ پاسخ: run با timeline، وضعیت، و مصرف ثبت‌شده
```
---
## ۷. مرزهای اعتماد
1. **مرز مستأجر:** هر endpoint چندسازمانی `organization_id` را enforce می‌کند.
نبود عضویت → **۴۰۴**. هیچ مسیری برای دیدن دادهٔ سازمان دیگر وجود ندارد.
2. **مرز مشتری/سکو:** provider، مدل و کلید هرگز از API به مشتری نمی‌رسند. مشتری
فقط یک *نام* سیاست مسیریابی می‌بیند.
3. **مرز اجرای کد:** هیچ مسیری برای اجرا روی host وجود ندارد. همهٔ کد کاربر فقط
از `SandboxProviderPort` عبور می‌کند، با egress پیش‌فرض بسته (`DENY_ALL`).
4. **مرز راز:** رازها در حالت سکون با Fernet رمزنگاری می‌شوند؛ در پاسخ‌ها فقط
نسخهٔ masked برمی‌گردد. frontend هیچ رازی ندارد.
---
## ۸. جریان داده و ثبت مصرف
**usage ledger منبع حقیقت صورتحساب است** — providerهای پرداخت فقط پول جابه‌جا
می‌کنند و در محاسبه دخالتی ندارند. هر مصرف (model call، زمان sandbox،
storage/artifact، tool call) یک رکورد در ledger می‌گذارد با `organization_id`,
`project_id`, `user_id`, `employee_id`, `run_id`, `unit`, `quantity`, `unit_price`.
هر عملیات حساس یک `AuditEvent` هم می‌نویسد (ساخت سازمان، دعوت عضو، ساخت repo،
ساخت دامنه، اجرای فرمان، حذف).
---
## ۹. تست
**بک‌اند (`apps/api/tests/`)** — ۲۱۶ تست:
| فایل | چه چیزی را می‌سنجد |
| --- | --- |
| `test_auth.py` | ثبت‌نام، ورود، نشست، نشت‌نداشتن hash |
| `test_tenant_isolation.py` | جداسازی مستأجر، ۴۰۴ به‌جای ۴۰۳، دعوت |
| `test_admin_control_plane.py` | دسترسی مالک سامانه، mask شدن کلید |
| `test_platform_rules.py` | برچسب دامنه، ضدتزریق host، ضدSSRF، پورت‌ها |
| `test_project_repo.py` | مخزن خصوصی، نشت‌نداشتن توکن |
| `test_workforce.py` | کارمند، مجوز ابزار (deny-by-default)، run، نشت‌نداشتن مدل |
| `test_config_guard.py` | اعتبارسنجی رازها در production |
| `test_pagination.py` | سقف صفحه‌بندی، رد offset نامعتبر |
| `test_domain_isolation.py` | نبود وابستگی فریمورک در domain |
| `test_e2e_smoke.py` | کل مسیر محصول + نامرئی‌بودن برای مستأجر دیگر |
| `test_workers.py` | اجرای task بدون کلیک، idempotency صورتحساب، reaping سندباکس |
| `test_seed.py` | قابل‌ورود بودن حساب‌های نمونه، idempotency seed |
| `test_redact.py` | پاک‌سازی secret در ۴۲ حالت (ساختاری، الگویی، مقداری) |
| `test_error_log.py` | مسیر کامل خطا: middleware → handler → recorder → دیتابیس |
| `test_error_console.py` | دسترسی مالک سامانه، گروه‌بندی، بستن/بازگشایی، audit |
**فرانت‌اند (`apps/web/src/test/`)** — ۲۵ تست: قرارداد API، کامپوننت‌های پایه،
design system، و همبستگی `request_id` در کلاینت.
---
## ۹.۱ خط لولهٔ خطا
یک استثنا از محل وقوع تا کنسول چهار مرحله را طی می‌کند:
```
route raises
│
├─ RequestContextMiddleware → request_id + method/path/ip/user_agent روی request.state
├─ deps.get_current_user → user_id (پس از تأیید توکن)
├─ deps.get_org_context → organization_id (پس از تأیید عضویت)
│
├─ exception handler → انتخاب اینکه ثبت شود یا نه + ساخت پاسخ استاندارد
│ (errors.py) (۵xx همیشه، ۴xx فقط با ERROR_LOG_CLIENT_ERRORS)
│
├─ error_log.record_error → redact → truncate → fingerprint
│ (infrastructure) سشن مستقل خودش؛ هرگز استثنا پرتاب نمی‌کند
│
└─ ObservabilityRepository → INSERT در error_events
```
سه نکته که طراحی را تعیین کرده‌اند:
1. **recorder سشن خودش را باز می‌کند.** سشن درخواست در لحظهٔ فراخوانی معمولاً
غیرقابل‌استفاده است: یک `IntegrityError` آن را در وضعیت
`PendingRollbackError` رها می‌کند. اگر از همان سشن استفاده می‌شد، سخت‌ترین
خطاها برای ثبت — خطاهای دیتابیس — هرگز ثبت نمی‌شدند.
2. **recorder هرگز پرتاب نمی‌کند.** یک خطای ثبتی نباید یک ۵۰۰ را به hang یا به
یک استثنای مدیریت‌نشده تبدیل کند.
3. **`X-Request-ID` در خود هندلر ست می‌شود، نه فقط در middleware.**
`ServerErrorMiddleware` استارلت بیرون از همهٔ middlewareهای کاربر است، پس
middleware هرگز پاسخی که برای یک استثنای مدیریت‌نشده ساخته می‌شود را
نمی‌بیند. این باگ واقعاً رخ داد و با
`test_error_responses_carry_the_header_for_every_handler` پوشش داده شد.
---
## ۱۰. اجرای پروژه
```bash
# ۱. زیرساخت
docker compose -f infra/docker/docker-compose.yml up -d postgres redis
# ۲. بک‌اند
cd apps/api
python3 -m venv .venv && .venv/bin/python -m pip install -e '.[dev]'
cp ../../.env.example ../../.env # مقادیر mock کافی است
.venv/bin/python -m alembic upgrade head # schema
.venv/bin/python -m app.seed # دادهٔ نمونه (provider/مدل/rate card/قالب)
# ۳. فرانت‌اند
cd ../.. && corepack pnpm install
corepack pnpm dev # web روی ۳۰۰۰، api روی ۸۰۰۰
```
**دستورهای کیفیت:**
```bash
pnpm lint # ESLint (web) + ruff (api)
pnpm typecheck # tsc --noEmit (web) + mypy app (api)
pnpm test # vitest (web) + pytest (api)
pnpm build # next build
```
---
## ۱۱. تصمیم‌های معماری کلیدی
| تصمیم | دلیل |
| --- | --- |
| Modular monolith، نه microservice از روز اول | مرزهای domain از ابتدا روشن‌اند؛ شکستن به سرویس بعداً ارزان است، اما پیچیدگی توزیع از روز اول گران است |
| یک adapter mock برای هر پورت | کل محصول بدون credential قابل اجرا و تست است؛ §۲۴ TODO را برآورده می‌کند |
| ۴۰۴ به‌جای ۴۰۳ برای نبود عضویت | `403` وجود منبع را تأیید می‌کند و اجازهٔ شمارش می‌دهد |
| قواعد امنیتی در domain، نه infrastructure | بدون DB/شبکه قابل تست‌اند و از لایهٔ I/O جدا می‌مانند |
| clamp منابع دولایه (service + adapter) | defense in depth؛ خطای پیکربندی policy سقف مطلق را نمی‌شکند |
| usage ledger به‌عنوان منبع حقیقت | محاسبهٔ صورتحساب از provider پرداخت جدا می‌شود |
| انتخاب adapter فقط با env، fail-closed | پیکربندی اشتباه باید بلند شکست بخورد، نه بی‌صدا به mock برگردد |
| `services/` فعلاً اسکلت است | `ai-runtime` و `billing-worker` فعلاً داخل فرآیند API اجرا می‌شوند؛ مرز پورت‌ها آمادهٔ استخراج است |
+127
View File
@@ -0,0 +1,127 @@
# زیرساخت فعلی — یادداشت مرجع
> این سند خلاصهٔ بررسی کد فعلی ربات تلگرام است، فقط برای درک زیرساخت موجود.
> **نسخهٔ وب جدید هیچ‌کدام از این فایل‌ها را import نمی‌کند.** هر قابلیت با یک
> adapter مستقل داخل `web/` بازنویسی می‌شود. این سند صرفاً قراردادها و محدودیت‌هایی
> را ثبت می‌کند که باید در adapterهای جدید حفظ شوند.
---
## ۱. Domain Routing و Preview Domain
منابع بررسی‌شده: `caddy_manager.py` (۲۵۹ خط)، `port_registry.py` (۵۳۱ خط).
### ۱.۱ Caddy
- مسیر فایل: `CADDYFILE_PATH = /etc/caddy/Caddyfile` (یک فایل مشترک سیستمی).
- دامنهٔ wildcard: `WILDCARD_DOMAIN = msa.artacloud.ir`.
- **allowlist سخت‌گیرانهٔ host:** فقط hostهای تک‌label زیر دامنهٔ wildcard مجازند.
الگوی فعلی: `[a-z0-9\-]{1,63}\.msa\.artacloud\.ir`. هر کاراکتری که بتواند از بلوک
سایت خارج شود (آکولاد، کوتیشن، فاصله، newline) رد می‌شود.
- **target فقط local:** الگوی فعلی `(https?://)?(localhost|127\.0\.0\.1):\d{1,5}`.
این برای جلوگیری از SSRF است — نباید هیچ‌وقت به host دیگر proxy شود.
- **چرخهٔ امن نوشتن:** خواندن فایل → ساخت/جایگزینی بلوک → نوشتن → `caddy validate`
→ در صورت خطا **بازگردانی به نسخهٔ قبلی** → `systemctl reload caddy` و در صورت
شکست `caddy reload --config ...`.
- پیدا کردن بلوک سایت با شمارش عمق آکولاد انجام می‌شود (نه regex)، چون بلوک‌ها
تودرتو هستند (`handle`, `route`, `tls`, `header`).
### ۱.۲ Port Registry
دو جدول در SQLite (`data/memory.db`):
| جدول | نقش |
|---|---|
| `port_registry` | `port` (PK)، `user_id`، `project_name`، `service_name`، `subdomain`، `description`، `created_at` — یکتا روی `(user_id, project_name, service_name)` |
| `subdomain_registry` | `host` (PK)، `user_id`، `project_name`، `port`، `protected`، `created_at` — ledger مالکیت دامنه |
قواعد کلیدی که باید در `DomainRouterPort` جدید حفظ شوند:
- محدودهٔ تخصیص port: `8080-8999`.
- `SYSTEM_RESERVED_PORTS = {22, 53, 80, 443, 2019, 3000, 5432, 6379, 8008}`.
- `SYSTEM_PROTECTED_PORTS = {3000, 35555, 35556, 35557} ∪ reserved` — یک پروژهٔ
کاربر هرگز نباید به این‌ها proxy کند، وگرنه سرویس tenant دیگر عمومی می‌شود.
- `MAX_SUBDOMAINS_PER_PROJECT = 5`.
- `SYSTEM_OWNER_PROJECT = "__system__"` برای hostهای بدون مالک شناخته‌شده.
- snapshot پورت‌های listening با `ss -tlnH` گرفته می‌شود. اگر snapshot ممکن نبود
(`None`)، تخصیص **رد می‌شود** — تفاوت «چیزی گوش نمی‌دهد» با «نمی‌دانیم» حفظ می‌شود.
- در تخصیص هم‌زمان، `IntegrityError` گرفته و تا ۵ بار rescan/retry می‌شود.
- هر host دقیقاً یک مالک دارد؛ بدون این ledger هر پروژه می‌توانست دامنهٔ پروژهٔ دیگر را
حذف یا hijack کند.
- hostهایی که قابل انتساب به پروژه نیستند `protected = 1` می‌شوند (فقط admin).
### ۱.۳ تصمیم برای نسخهٔ وب
- `DomainRouterPort` با متدهای `allocate_port` / `claim_preview_domain` /
`configure_reverse_proxy` / `remove_preview_domain` / `list_project_domains`.
- دو adapter: `ManagedDomainRouterAdapter` (mock، بدون لمس Caddy) و
`CaddyDomainRouterAdapter` (واقعی، پشت env flag `DOMAIN_ROUTER=caddy`).
- مالکیت دامنه به `organization_id` + `project_id` در PostgreSQL منتقل می‌شود،
نه `user_id` تلگرام.
---
## ۲. Git / Gitea
منابع بررسی‌شده: `git_manager.py` (۱۰۶۴ خط)، `gitea_api.py` (۲۱۵ خط).
### ۲.۱ تنظیمات فعلی (از `config.py`)
| متغیر | مقدار پیش‌فرض |
|---|---|
| `GITEA_URL` | `https://git.msa.artacloud.ir` (عمومی، برای نمایش) |
| `GITEA_INTERNAL_URL` | `http://127.0.0.1:3000` (برای تماس API) |
| `GITEA_ADMIN_USER` | `root` |
| `GITEA_ADMIN_TOKEN` | خالی — بدون آن همهٔ قابلیت‌های Gitea غیرفعال است |
### ۲.۲ رفتار GitManager
- `ensure_gitea_repo(repo_name, owner, description, private=True)`: اگر repo نبود
می‌سازد، اگر بود و private نبود آن را PATCH می‌کند به private. `auto_init=False`،
`default_branch=main`.
- `generate_project_credentials(project_name, owner_id)`: نام کاربری به شکل
`u{owner_id}_{clean_name[:room]}_{sha1(f"{owner_id}:{project_name}")[:8]}` و رمز
`secrets.token_urlsafe(12)`. هش کوتاه برای جلوگیری از collision دو پروژه با نام
سانیتایز یکسان ضروری است (وگرنه PATCH رمز کاربر مشترک، credential پروژهٔ اول را
بی‌صدا خراب می‌کند). سقف ۳۶ کاراکتر Gitea رعایت می‌شود.
- قفل per-repo (`_RepoLock`) که برای همان asyncio task قابل re-enter است؛
`git_publish`/`git_sync` داخل بخش بحرانی خودشان دوباره lock می‌گیرند، پس
`asyncio.Lock` ساده deadlock می‌داد.
- پاک‌سازی `index.lock` فقط اگر قدیمی‌تر از `STALE_LOCK_AGE_SECONDS = 30` باشد
(حذف lock جوان می‌تواند index را خراب کند).
### ۲.۳ GiteaApiClient
متدها: `create_pr`، `pr_status`، `merge_pr`، `list_open_prs`،
`list_labeled_issues(label="agybot")`، `comment_issue`، `close_issue`.
نکات:
- transport تزریق‌پذیر است (`request` callable) تا تست بدون شبکه اجرا شود.
- token هرگز در هیچ مقدار برگشتی یا prompt قرار نمی‌گیرد؛ `base_url` امن برای نمایش است.
- صفحه‌بندی با cursor `page` انجام می‌شود تا نتایج در یک صفحه بریده نشوند.
- خواندن پیام خطا از `message` / `error` / `errors` (نه فقط `error`).
- برچسب `agybot` روی issue آن را به task سازمانی تبدیل می‌کند.
### ۲.۴ تصمیم برای نسخهٔ وب
- `GitProviderPort` با متدهای `ensure_project_repo` / `create_branch` /
`commit_files` / `open_pull_request` / `merge_pull_request` / `get_repo_status`.
- `GiteaGitAdapter` (واقعی) و `MockGitAdapter` (پیش‌فرض dev).
- نام repo در نسخهٔ وب: `orgslug-projectslug-shortid` (collision-free).
- credential هر پروژه **encrypted at rest** در `project_git_credentials` ذخیره می‌شود؛
token/password هرگز خام به frontend برنمی‌گردد.
- admin token فقط سمت backend می‌ماند.
---
## ۳. جمع‌بندی
| قابلیت فعلی | منبع فعلی | جایگزین در نسخهٔ وب |
|---|---|---|
| Caddy subdomain routing | `caddy_manager.py` | `DomainRouterPort` + `CaddyDomainRouterAdapter` |
| Port/subdomain ledger | `port_registry.py` (SQLite) | جداول `preview_ports` / `preview_domains` (PostgreSQL) |
| Gitea repo + credentials | `git_manager.py` | `GitProviderPort` + `GiteaGitAdapter` |
| PR / issue workflow | `gitea_api.py` | همان port، متدهای PR/issue |
**هیچ‌کدام از این فایل‌ها در runtime نسخهٔ وب import نمی‌شوند.** فقط قراردادها،
الگوهای اعتبارسنجی و قواعد امنیتی بالا به عنوان مرجع استفاده شده‌اند.
+420
View File
@@ -0,0 +1,420 @@
# چک‌لیست امنیتی — نسخه وب مستقل AGY
> این سند «پذیرش مرحلهٔ ۱۹» در `web/TODO.md` را می‌بندد.
>
> **روش نوشتن این سند:** هر آیتم با سه چیز مستند شده است — (۱) محل دقیق کنترل در کد،
> (۲) نحوهٔ اعمال آن، (۳) تستی که در صورت شکستن کنترل، قرمز می‌شود. هیچ آیتمی بدون
> «تست مدافع» تأیید نشده است. اگر کنترلی تست نداشت، به‌جای علامت ✅ صریحاً
> «⚠️ بدون تست» نوشته شده است.
**وضعیت کل:** همهٔ تست‌های امنیتی سبز — `120 passed` (کل مجموعهٔ backend).
`ruff check` تمیز، `mypy app tests` روی ۹۲ فایل بدون خطا.
---
## ۱۹.۱ مدیریت Secret
### ✅ API keyها در حالت سکون رمزنگاری می‌شوند
- **محل:** `apps/api/app/core/crypto.py`
- **چگونه:** کلید Fernet از `ENCRYPTION_KEY` با SHA-256 مشتق می‌شود (`_derive_key`، خط ۱۹)
و `_cipher()` (خط ۲۸) یک نمونهٔ `Fernet` می‌سازد. `encrypt()` (خط ۳۶) و
`decrypt()` (خط ۴۰) تنها مسیر ورود/خروج رازها هستند. `mask()` (خط ۴۹) فقط چهار
کاراکتر آخر را برمی‌گرداند (`••••1234`).
- **نکتهٔ طراحی:** در حالت سکون فقط ciphertext ذخیره می‌شود؛ در پاسخ‌های API هرگز
plaintext برنمی‌گردد و فقط نسخهٔ masked نمایش داده می‌شود.
- **تست مدافع:**
- `test_credential_creation_returns_only_a_masked_key`
- `test_listing_credentials_never_exposes_the_key`
### ✅ رمز عبور با argon2 هش می‌شود
- **محل:** `apps/api/app/core/security.py` — `PasswordHasher` (خط ۲۰)،
`hash_password` (خط ۲۳)، `verify_password` (خط ۲۷)، `needs_rehash` (خط ۳۴).
- **چگونه:** argon2-cffi (برندهٔ PHC، مقاوم به GPU)؛ هیچ‌جا `md5`/`sha1`/`sha256`
خام برای رمز استفاده نشده است.
- **تست مدافع:**
- `test_signup_never_returns_password_hash` — پاسخ ثبت‌نام هیچ `password_hash` ندارد.
- `test_login_fails_with_wrong_password` — تأیید مسیر verify.
### ✅ JWT secret از env می‌آید و در production اعتبارسنجی می‌شود
- **محل:** `apps/api/app/core/config.py` — `jwt_secret` (خط ۴۶)،
`_refuse_insecure_production_secrets` (خط ۹۳)، `_INSECURE_SECRETS` (خط ۱۹)،
`MIN_SECRET_LENGTH = 32` (خط ۲۳). امضا در `security.py` خط ۵۱
(`jwt.encode(..., algorithm=settings.jwt_algorithm)`) و اعتبارسنجی خط ۵۶.
- **چگونه:** یک `model_validator(mode="after")` در زمان boot اجرا می‌شود. اگر
`APP_ENV=production` باشد و یکی از این‌ها برقرار باشد، فرآیند **بالا نمی‌آید**:
- `jwt_secret` در فهرست مقادیر placeholder باشد (`""`, `change-me`, `secret`, ...)،
- طول `jwt_secret` کمتر از ۳۲ بایت باشد،
- `encryption_key` خالی باشد،
- `cors_origins` شامل `*` باشد.
- **تست مدافع:** کل `tests/test_config_guard.py` (۶ تست):
`test_production_rejects_the_placeholder_secret`,
`test_production_rejects_a_short_secret`,
`test_production_requires_an_encryption_key`,
`test_production_rejects_wildcard_cors`,
`test_production_accepts_a_fully_configured_environment`,
`test_development_tolerates_placeholder_secrets`.
### ✅ frontend هیچ secret ندارد
- **محل:** `apps/web/src/lib/api.ts` (خط ۱۱–۱۲) و `apps/web/src/lib/session.tsx`.
- **چگونه:** تنها متغیر env که frontend می‌خواند `NEXT_PUBLIC_API_URL` است که یک
**URL عمومی** است، نه راز. جست‌وجوی `process.env.` در `apps/web/src` فقط همین یک
مورد را برمی‌گرداند. کلیدهای مدل، tokenهای Gitea، کلیدهای sandbox و کلید
پرداخت همه فقط در backend و از env خوانده می‌شوند (`config.py`).
- **توکن نشست:** bearer token در `localStorage` نگهداری می‌شود و در هدر
`Authorization` فرستاده می‌شود؛ هیچ‌گاه در URL یا query قرار نمی‌گیرد.
- **تست مدافع:** ⚠️ بدون تست خودکار. این یک بازبینی دستی است (پوشش: جست‌وجوی
`process.env` در کل `apps/web/src`). توصیه: افزودن یک تست که `NEXT_PUBLIC_*` را
در برابر یک allowlist بررسی کند.
---
## ۱۹.۲ کنترل دسترسی
### ✅ middleware/dependency مرکزی برای auth
- **محل:** `apps/api/app/interfaces/http/deps.py` — `get_current_user` (خط ۵۵)،
`get_optional_user` (خط ۷۱)، `require_platform_owner` (خط ۸۳).
- **چگونه:** یک dependency واحد توکن را decode می‌کند، کاربر را از DB می‌خواند و
در صورت نامعتبر بودن `401` می‌دهد. هیچ routerای دستی توکن را parse نمی‌کند.
- **تست مدافع:**
- `test_me_requires_authentication`
- `test_me_rejects_garbage_token`
- `test_admin_endpoints_require_authentication`
- `test_health_is_public` — تأیید اینکه استثنا (health) آگاهانه است.
### ✅ dependency مرکزی برای عضویت سازمان (tenant isolation)
- **محل:** `apps/api/app/interfaces/http/deps.py` — `get_org_context` (خط ۸۹)،
`OrgContext.require` (خط ۵۰).
- **چگونه:** **اصل کلیدی:** نبودِ عضویت **`404`** برمی‌گرداند، نه `403`
(خط ۹۶، ۱۰۳–۱۰۵). این آگاهانه است: `403` وجود یک منبع را تأیید می‌کند و به
مهاجم اجازهٔ شمارش می‌دهد. با `404`، سازمان دیگر «نامرئی» است.
- **تست مدافع:** کل `tests/test_tenant_isolation.py` + تست‌های cross-tenant در
`test_workforce.py` و `test_project_repo.py`:
- `test_foreign_organization_is_not_readable`
- `test_foreign_member_list_is_not_readable`
- `test_foreign_invite_is_rejected`
- `test_employee_of_another_tenant_is_not_readable`
- `test_task_of_another_tenant_is_not_readable`
- `test_repository_of_another_tenant_is_not_reachable`
- `test_smoke_path_is_invisible_to_another_tenant` (E2E)
### ✅ policy object برای role/permission
- **محل:** `apps/api/app/interfaces/http/deps.py` — `require_capability` (خط ۱۰۸)
و `require_role_at_least` (خط ۱۱۸)، به‌همراه `OrgContext.require` (خط ۵۰).
تعریف capabilityها و رتبهٔ نقش‌ها در domain (`ROLE_RANK`) است، نه در router.
- **چگونه:** مسیر پیشنهادی `Depends(require_capability(CAN_MANAGE_PROJECTS))` است؛
بررسی در یک نقطه انجام می‌شود. `ROLE_RANK` یک ترتیب عددی است تا «حداقل نقش»
قابل بیان باشد (owner > admin > member > viewer).
- **تست مدافع:**
- `test_cannot_invite_as_owner` — تلاش برای ارتقای نقش از طریق invite رد می‌شود.
- `test_owner_can_list_members_and_see_themselves`
- `test_normal_user_cannot_create_provider`
- `test_normal_user_cannot_list_providers`
### ✅ تست منفی برای unauthorized / forbidden
- **محل:** `tests/test_admin_control_plane.py`، `tests/test_auth.py`،
`tests/test_tenant_isolation.py`.
- **تست مدافع:** ۴ تست ۴۰۱ در `test_auth.py`، ۳ تست ۴۰۳/۴۰۴ در
`test_admin_control_plane.py`، ۶ تست cross-tenant در `test_tenant_isolation.py`.
### ✅ کاربر عادی مدل/provider خام نمی‌بیند
- **محل:** `apps/api/app/application/services/workforce_service.py` — نام‌گذاری
گام‌های run و پاسخ‌ها؛ `apps/api/app/infrastructure/adapters/mock.py` — متن پاسخ.
- **چگونه:** در یک پاس بازبینی، دو نشت واقعی پیدا و بسته شد:
1. متن پاسخ mock شامل `(provider=mock)` بود — حذف شد.
2. نوع گام run `kind="model_output"` بود — به `kind="output"` تغییر کرد
(کلمهٔ «model» در payload قابل‌مشاهدهٔ مشتری نشت می‌کرد).
- **تست مدافع:**
- `test_employee_payload_never_leaks_model_identity`
- `test_run_payload_never_leaks_model_identity`
- `test_routing_choices_expose_a_name_but_not_the_mode` — مشتری یک **نام**
(مثل «سریع») انتخاب می‌کند، اما نام مدل/provider را نمی‌بیند.
---
## ۱۹.۳ امنیت Sandbox
### ✅ اجرای مستقیم روی host ممنوع است
- **محل:** `apps/api/app/application/services/sandbox_service.py` و
`apps/api/app/application/ports/` (`SandboxProviderPort`).
- **چگونه:** هیچ مسیری برای اجرای کد روی host وجود ندارد. تنها راه اجرا، عبور از
`SandboxProviderPort` است که adapter آن یا `mock` است یا remote (Daytona/E2B).
انتخاب adapter در `infrastructure/adapters/registry.py` بر اساس
`SANDBOX_PROVIDER` انجام می‌شود. سرور هیچ‌جا `subprocess` برای کد کاربر صدا نمی‌زند.
- **تست مدافع:**
- `test_end_to_end_smoke_path` — کل مسیر فقط از طریق sandbox عبور می‌کند.
- `test_domain_layer_has_no_framework_dependencies` — لایهٔ domain به هیچ
فریمورک/درایوری وابسته نیست، پس نمی‌تواند مخفیانه اجرا کند.
### ✅ محدودیت‌ها از policy می‌آید، نه از caller
- **محل:** `apps/api/app/application/services/sandbox_service.py` — `_clamp`
(خط ۱۱۲)، و `apps/api/app/domain/sandbox/models.py` — `SandboxResourceLimit`
(خط ۱۲۳–۱۲۵).
- **چگونه:** درخواست اجرای فرمان (`RunCommandRequest`) **فقط `command`** را
می‌پذیرد. `timeout_seconds` و `max_output_bytes` هرگز از کاربر خوانده نمی‌شوند؛
سرویس آن‌ها را از scope سازمان/پروژه می‌گیرد و سپس با `_clamp` می‌بندد:
```
timeout_seconds = max(1, min(limit.timeout_seconds, ABSOLUTE_MAX_TIMEOUT_SECONDS))
max_output_bytes = max(1024, min(limit.max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES))
memory_mb = max(128, ...)
disk_mb = max(256, ...)
```
یعنی حتی اگر policy اشتباه پیکربندی شود، سقف مطلق نمی‌شکند.
- **تست مدافع:** `test_end_to_end_smoke_path` صریحاً یک درخواست با
`timeout_seconds: 999999` و `max_output_bytes: 10**9` می‌فرستد و بررسی می‌کند که
این کلیدها **در بدنهٔ پاسخ ظاهر نشوند** — یعنی caller نتوانسته آن‌ها را دیکته کند.
### ✅ command timeout
- **محل:** `sandbox_service.py` (`_clamp`، خط ۱۱۳–۱۱۵) و
`infrastructure/adapters/sandbox_remote.py` (خط ۱۴۰:
`timeout = max(1, min(int(timeout_seconds), ABSOLUTE_MAX_TIMEOUT_SECONDS))`).
- **چگونه:** دفاع دولایه: لایهٔ سرویس clamp می‌کند، و لایهٔ adapter مستقل دوباره
clamp می‌کند (defense in depth). adapter `mock` نیز قرارداد timeout را رعایت می‌کند
(کامنت `mock.py` خط ۳ و ۳۱۶).
- **تست مدافع:** پوشش غیرمستقیم در `test_end_to_end_smoke_path`.
### ✅ output limit
- **محل:** `sandbox_remote.py` خط ۸۷ (`min(max_output_bytes, ABSOLUTE_MAX_OUTPUT_BYTES)`)،
خط ۱۳۷؛ `mock.py` خط ۳۲۰ و ۳۴۹؛ برش نهایی در `sandbox_service.py` خط ۲۷۹–۲۸۰
(`result.stdout[: limits.max_output_bytes]`).
- **چگونه:** خروجی هم در adapter و هم در سرویس بریده می‌شود تا یک فرمان پرمصرف
حافظه/پاسخ را منفجر نکند.
- **تست مدافع:** پوشش غیرمستقیم در `test_end_to_end_smoke_path`.
### ✅ سیاست egress طراحی شده است (deny-by-default)
- **محل:** `apps/api/app/domain/sandbox/models.py` — `NetworkPolicy` (خط ۳۸):
`DENY_ALL = "deny_all"` (خط ۳۹) و `ALLOWLIST = "allowlist"` (خط ۴۰)؛
مقدار پیش‌فرض `SandboxResourceLimit.network_policy = NetworkPolicy.DENY_ALL`
(خط ۱۲۵).
- **چگونه:** پیش‌فرض **بستن کامل شبکه** است. اگر روزی allowlist لازم شد، یک حالت
صریح وجود دارد و باید آگاهانه انتخاب شود. سیاست در محدودیت ذخیره‌شده می‌نشیند،
نه در درخواست کاربر.
- **تست مدافع:** ⚠️ بدون تست اختصاصی برای مقدار پیش‌فرض. توصیه: یک تست واحد که
`SandboxResourceLimit().network_policy is NetworkPolicy.DENY_ALL` را تأیید کند.
### ✅ audit برای command
- **محل:** `apps/api/app/application/services/sandbox_service.py` خط ۲۹۳
(`await self.ledger.record(...)`) با `UsageLedgerRepository`
(`infrastructure/repositories/audit.py` خط ۸۳).
- **چگونه:** هر اجرای فرمان یک رکورد مصرف ثبت می‌کند (زمان CPU، run_id،
employee_id، organization_id). زمان sandbox قابل صورتحساب و قابل ردیابی است.
- **تست مدافع:** `test_end_to_end_smoke_path` (گام usage).
---
## ۱۹.۴ امنیت دامنه و Git
### ✅ تزریق host ممنوع است
- **محل:** `apps/api/app/domain/platform/rules.py` — `_LABEL_RE` (خط ۳۲)،
`validate_subdomain_label` (خط ۴۰)، `validate_managed_host` (خط ۵۹).
- **چگونه:** برچسب فقط `[a-z0-9-]`، باید با حرف/عدد شروع و تمام شود، طول ۳ تا ۴۰.
`validate_managed_host` علاوه بر این، پسوند `.<root_domain>` را الزامی می‌کند و
**برچسب تودرتو** (`a.b`) را رد می‌کند. این همان کنترل ضدتزریق است که نمی‌گذارد
یک host ساختگی از site block در Caddy خارج شود یا به میزبان دلخواه اشاره کند.
- **تست مدافع:**
- `test_managed_host_rejects_injection_attempt`
- `test_managed_host_rejects_nested_label`
- `test_managed_host_rejects_foreign_domain`
- `test_valid_labels` / `test_invalid_labels` (parametrized)
- `test_build_host_appends_root_domain`
### ✅ مقصد Caddy در برابر SSRF محافظت می‌شود
- **محل:** `apps/api/app/domain/platform/rules.py` — `_PROXY_TARGET_RE` (خط ۳۳) و
`validate_proxy_target` (خط ۷۷)؛ مصرف در `infrastructure/adapters/caddy.py`
(خط ۱۳۶) و `infrastructure/adapters/mock.py` (خط ۲۸۸).
- **چگونه:** الگو فقط `(https?://)?(localhost|127\.0\.0\.1):(\d{1,5})` را می‌پذیرد.
هر hostname عمومی، هر IP غیر-loopback و هر پورت غیرعددی رد می‌شود. یعنی یک
مستأجر نمی‌تواند دامنهٔ عمومی خود را به یک سرویس داخلی proxy کند.
- **نکتهٔ اضافه:** در adapter واقعی Caddy، قبل از reload فایل با `caddy validate`
بررسی می‌شود (`caddy.py` خط ۳۰۰، `_VALIDATE_TIMEOUT_SECONDS`) و reload تایم‌اوت
جدا دارد (خط ۳۰۹).
- **تست مدافع:**
- `test_ssrf_proxy_targets_rejected` (parametrized)
- `test_valid_proxy_targets` (parametrized)
### ✅ پورت‌های محافظت‌شده ممنوع‌اند
- **محل:** `apps/api/app/domain/platform/rules.py` — `SYSTEM_RESERVED_PORTS`
(خط ۱۷)، `SYSTEM_PROTECTED_PORTS` (خط ۲۳)، `is_port_allocatable` (خط ۹۵)،
`assert_port_allocatable` (خط ۱۰۶)، `assert_not_protected_port` (خط ۱۱۷).
- **چگونه:** دو مجموعهٔ جدا:
- `SYSTEM_RESERVED_PORTS = {22, 53, 80, 443, 2019, 3000, 5432, 6379, 8008}`
(سرویس‌های سیستم + data storeها) هرگز تخصیص نمی‌یابند.
- `SYSTEM_PROTECTED_PORTS = {3000, 35555, 35556, 35557} ∪ RESERVED` — یک دامنهٔ
preview هرگز نباید به این‌ها proxy شود، وگرنه یک hostname عمومی یک مؤلفهٔ داخلی
را افشا می‌کند.
- محدودهٔ تخصیص: `8080–8999` (`DEFAULT_PORT_RANGE_START/END`).
- **تست مدافع:**
- `test_protected_ports_cannot_be_proxied` (parametrized)
- `test_reserved_and_out_of_range_ports_rejected` (parametrized)
- `test_allocatable_ports` (parametrized)
### ✅ مخزن Git خصوصی است
- **محل:** `apps/api/app/infrastructure/adapters/gitea.py` و `mock.py`
(`ensure_repository`).
- **چگونه:** مخزن در حالت `private` ساخته می‌شود؛ عملیات idempotent است تا دوبار
ساخت، حالت را به public تغییر ندهد.
- **تست مدافع:**
- `test_ensure_repository_creates_a_private_repo`
- `test_ensure_repository_is_idempotent`
- `test_repo_status_is_reported_after_ensure`
### ✅ token هرگز برنمی‌گردد
- **محل:** `apps/api/app/infrastructure/adapters/gitea.py` (توکن فقط در
adapter/backend)؛ مسیر invite: `hash_token` در `core/security.py` (خط ۶۸).
- **چگونه:** توکن مدیریت Gitea هرگز در payload پاسخ ظاهر نمی‌شود. توکن‌های دعوت
فقط یک‌بار به **دعوت‌کننده** نمایش داده می‌شوند و در DB فقط به‌صورت hash ذخیره
می‌شوند (`hash_token`).
- **تست مدافع:**
- `test_git_password_never_leaves_the_api`
- `test_invite_returns_token_only_to_the_inviter`
---
## ۱۹.۵ امنیت ابزارهای کارمند AI
### ✅ دسترسی ابزارها deny-by-default است
- **محل:** `apps/api/app/domain/workforce/tools.py` (کامنت طراحی خط ۸:
*«Nothing is allowed by default; an employee needs an explicit permission row.»*)،
`TOOL_CATALOG` (خط ۵۰)، `KNOWN_TOOL_KEYS` (خط ۱۵۰)؛ مدل مجوز در
`apps/api/app/domain/workforce/models.py` — `ToolPermission.allowed: bool = False`
(خط ۱۵۷).
- **چگونه:** دو کنترل مستقل:
1. **fail-closed روی کلید ناشناخته:** اگر کلید ابزار در `KNOWN_TOOL_KEYS` نباشد،
درخواست با **`422`** رد می‌شود و ابزار **هرگز ذخیره نمی‌شود** (نه اینکه با
`allowed=False` ذخیره شود).
2. **پیش‌فرض بسته:** هر مجوز ذخیره‌شده به‌صورت پیش‌فرض `allowed=False` است و باید
صریحاً روشن شود.
- برخی ابزارهای پرخطر (مثل `git_merge_pr`, `sandbox_destroy`) نیازمند
**تأیید انسانی** هستند (فیلد approval در `ToolSpec`).
- **تست مدافع:**
- `test_unknown_tool_is_denied_by_default` — هم `422` و هم عدم‌ذخیره‌سازی را
تأیید می‌کند.
- `test_creating_an_employee_seeds_a_prompt_and_tool_permissions`
- `test_tool_permission_can_be_toggled`
- `test_tools_catalog_requires_no_secrets`
- `test_pending_approvals_are_org_scoped`
---
## ۱۹.۶ جداسازی لایه‌ها (پیش‌نیاز امنیتی معماری)
### ✅ لایهٔ domain به فریمورک وابسته نیست
- **محل:** `apps/api/app/domain/**` — هیچ import از `fastapi`, `sqlalchemy`,
`redis`, `httpx` یا provider خارجی.
- **چگونه:** این جداسازی، سطح حملهٔ لایهٔ قواعد را کوچک نگه می‌دارد: قواعد امنیتی
دامنه (اعتبارسنجی برچسب، SSRF، پورت) بدون DB و بدون HTTP قابل تست‌اند.
- **تست مدافع:**
- `test_domain_layer_has_no_framework_dependencies`
- `test_domain_rules_are_usable_without_a_database`
---
## ۱۹.۷ محدودسازی منابع (ورودی‌های بی‌کران)
### ✅ صفحه‌بندی اجباری روی لیست‌ها
- **محل:** همهٔ repositoryهای لیست (`limit`/`offset`) و routers مربوطه.
- **چگونه:** سقف `min(limit, 500)` اعمال می‌شود؛ `offset` منفی و `limit` خارج از
بازه با `422` رد می‌شوند.
- **تست مدافع:**
- `test_out_of_range_pagination_is_rejected` (parametrized)
- `test_tasks_list_is_paginated`
- `test_projects_and_employees_accept_pagination`
---
## ۱۹.۸ پاک‌سازی secretها در لاگ خطا
### ✅ هیچ خطایی بدون عبور از redactor ذخیره نمی‌شود
- **محل:** `app/core/redact.py` — فراخوانی‌شده از `app/infrastructure/error_log.py`.
- **چگونه:** هر خطا قبل از نوشتن در دیتابیس از سه لایه عبور می‌کند:
1. **ساختاری** — کلیدی که معنایش secret است (`password`، `api_key`،
`refresh_token`، `authorization`، …) مقدارش کامل جایگزین می‌شود.
2. **الگویی** — مقادیر شناختنی مستقل از کلید: بلوک PEM، JWT،
`scheme://user:pass@host`، `Bearer …`، `sk-…`/`ghp_…`/`AKIA…`.
3. **مقداری** — خودِ secretهای تنظیم‌شده (`JWT_SECRET`، `ENCRYPTION_KEY` و
هر `*_API_KEY`/`*_TOKEN`) به‌صورت تحت‌اللفظی جایگزین می‌شوند.
لایهٔ سوم مهم‌ترین است: secretی که فقط با الگو تشخیص داده شود، دیر یا زود
عیناً لاگ می‌شود؛ secretی که با مقدار تطبیق داده شود، نمی‌تواند.
- **چرا مهم است:** لاگ خطا جایی است که credentialها تجمع می‌کنند — بدنهٔ
درخواست، هدر `Authorization`، DSN، کلید provider. یک لاگ خطا که رمز عبور
ذخیره کند از نداشتن لاگ خطا **بدتر** است.
- **تست مدافع:** `tests/test_redact.py` (۴۲ تست) و
`test_a_secret_in_the_exception_message_never_reaches_the_database` که یک
DSN حاوی رمز را از یک استثنای واقعی رد می‌کند و بررسی می‌کند نه در
`message` و نه در `stack` نیامده باشد.
### ✅ redactor هرگز استثنا پرتاب نمی‌کند
- **محل:** همان ماژول؛ هر تابع در `try/except` کامل بسته شده است.
- **چگونه:** در بدترین حالت متن را حذف می‌کند، نه اینکه هندلر را بشکند. یک
redactor که داخل `except` پرتاب کند، یک ۵۰۰ را به یک hang تبدیل می‌کند.
- **تست مدافع:** `test_very_long_input_is_bounded`،
`test_non_string_keys_do_not_raise`،
`test_a_broken_recorder_does_not_break_the_response`.
### ✅ کنسول خطا فقط برای مالک سامانه است
- **محل:** `app/interfaces/http/routers/admin.py` — dependency سطح router.
- **چگونه:** کل `admin` router با `dependencies=[Depends(require_platform_owner)]`
محافظت می‌شود، پس endpoint جدید بدون یادآوری نویسنده هم محافظت‌شده است.
کنسول خطا همهٔ مستأجرها را نشان می‌دهد، پس این کنترل حیاتی است.
- **تست مدافع:** `test_a_regular_customer_cannot_reach_the_console`،
`test_an_anonymous_caller_cannot_reach_the_console`،
`test_resolving_requires_a_platform_owner`.
### ✅ کنسول خطا نیز خودش ورودی بی‌کران نمی‌پذیرد
- **محل:** `MAX_ERROR_PAGE_SIZE = 200` در router و
`MAX_MESSAGE_CHARS`/`MAX_STACK_CHARS`/`MAX_METADATA_JSON_CHARS` در recorder.
- **چگونه:** سقف صفحه‌بندی روی `limit`، و سقف طول روی هر فیلد متنی. یک
exception با پیام ۵ مگابایتی نباید جدول را پر کند.
- **تست مدافع:** `test_the_page_size_is_capped`، `test_very_long_input_is_bounded`.
---
## جمع‌بندی
| حوزه | آیتم‌های تأییدشده | ⚠️ بدون تست |
| --- | --- | --- |
| ۱۹.۱ Secret Management | ۴ | ۱ (نبود secret در frontend) |
| ۱۹.۲ Access Control | ۵ | ۰ |
| ۱۹.۳ Sandbox Security | ۵ | ۱ (پیش‌فرض DENY_ALL) |
| ۱۹.۴ Domain/Git Security | ۵ | ۰ |
| ۱۹.۵ ابزارهای کارمند | ۱ | ۰ |
| ۱۹.۶ جداسازی لایه‌ها | ۱ | ۰ |
| ۱۹.۷ محدودسازی منابع | ۱ | ۰ |
| ۱۹.۸ پاک‌سازی لاگ خطا | ۴ | ۰ |
**دو مورد ⚠️ باقی‌مانده** هر دو کنترلی هستند که **در کد وجود دارند و درست کار
می‌کنند**، اما تست خودکار مستقل ندارند (فقط بازبینی دستی/پوشش غیرمستقیم). هر دو
کم‌هزینه‌اند و در فهرست کار بعدی ثبت شده‌اند. هیچ‌کدام یک شکاف امنیتی فعال نیست.
**پذیرش مرحلهٔ ۱۹:**
- ✅ `web/docs/security-checklist.md` کامل است (همین سند).
- ✅ تست‌های امنیتی اصلی سبزند: `216 passed` (بک‌اند) و `25 passed` (فرانت‌اند).
- ✅ `ruff check` تمیز؛ `mypy app` روی ۹۰ فایل بدون خطا.