نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -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 اجرا میشوند؛ مرز پورتها آمادهٔ استخراج است |
|
||||
@@ -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 نمیشوند.** فقط قراردادها،
|
||||
الگوهای اعتبارسنجی و قواعد امنیتی بالا به عنوان مرجع استفاده شدهاند.
|
||||
@@ -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` روی ۹۰ فایل بدون خطا.
|
||||
Reference in New Issue
Block a user