- users.is_platform_owner → users.platform_role (owner|manager|user) با مهاجرت d4e5f6a7b8c9 و backfill. ایندکس یکتای جزئی uq_single_platform_owner تضمین میکند دقیقاً یک مالک وجود داشته باشد — در سطح دیتابیس، نه فقط در کد. - ثبتنام عمومی حذف شد (POST /auth/signup). حسابها فقط با POST /admin/users (فقط مالک، با رمز موقت یکبارمصرف) یا با پذیرش لینک دعوت ساخته میشوند. در مسیر دعوت، اگر حساب وجود نداشته باشد خودِ دعوتشده رمز تعیین میکند؛ اگر وجود داشته باشد لینک هرگز جای reset رمز را نمیگیرد. - ساخت سازمان فقط برای مالک و مدیران؛ کاربر عادی با دعوت عضو میشود. - تنزل یا حذف مدیر، سازمانهایش را به مالک سامانه منتقل میکند (مالکیت + عضویت، با audit). حساب مالک از API تغییرناپذیر است؛ جابهجایی صندلی فقط با POST /admin/transfer-ownership و در یک تراکنش انجام میشود. - افزودن POST /auth/change-password برای تبدیل رمز موقت به رمز خصوصی. - فرانت: صفحهٔ /admin/users، صفحهٔ /account، بازنویسی /accept-invite، حذف /signup، گارد /onboarding برای کاربر عادی، و نشان نقش پلتفرم در پوستهٔ اپ. - PRAGMA foreign_keys=ON برای SQLite تا ON DELETE CASCADE در dev و prod یکسان رفتار کند (پیشتر حذف کاربر، عضویتهایش را یتیم میگذاشت). - تست: ۳۰۰ تست بکاند (شامل tests/test_platform_hierarchy.py با ۲۲ تست) و ۶۴ تست فرانتاند؛ ruff، mypy، tsc، eslint و build تمیز.
453 lines
28 KiB
Markdown
453 lines
28 KiB
Markdown
# چکلیست امنیتی — نسخه وب مستقل Smart Organization
|
||
|
||
> این سند «پذیرش مرحلهٔ ۱۹» در `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_helper_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`، `tests/test_platform_hierarchy.py`.
|
||
- **تست مدافع:** ۴ تست ۴۰۱ در `test_auth.py`، ۳ تست ۴۰۳/۴۰۴ در
|
||
`test_admin_control_plane.py`، ۶ تست cross-tenant در `test_tenant_isolation.py`،
|
||
و کل `test_platform_hierarchy.py` (۲۲ تست).
|
||
|
||
### ✅ سلسلهمراتب پلتفرم: یک مالک، مدیرانِ ساختهٔ مالک
|
||
|
||
- **محل:** `apps/api/app/domain/identity/models.py` (`PlatformRole`)،
|
||
`apps/api/app/infrastructure/db/models.py` (ایندکس یکتا)،
|
||
`apps/api/app/interfaces/http/routers/admin_users.py`،
|
||
`apps/api/app/application/services/platform_users.py`.
|
||
- **چگونه:** چهار قاعده در کد و دیتابیس قفل شدهاند:
|
||
1. **دقیقاً یک مالک.** ایندکس یکتای جزئی
|
||
(`uq_single_platform_owner ... WHERE platform_role = 'owner'`) در سطح دیتابیس
|
||
جلوی مالک دوم را میگیرد؛ API هم ساخت/تنزل مالک را رد میکند. جابهجایی صندلی
|
||
فقط با `POST /admin/transfer-ownership` و در یک تراکنش است (اول تنزل مالک فعلی،
|
||
بعد ارتقای جانشین — تا ایندکس هرگز دو مالک نبیند).
|
||
2. **ثبتنام عمومی وجود ندارد.** حسابها فقط از `POST /admin/users` (مالک) یا از
|
||
پذیرش دعوت ساخته میشوند. مسیرهای `/api/invites/*` تنها فراخوانیهای ناشناس
|
||
مجاز در لایهٔ RPC هستند (`router.py`)، چون خود توکن دعوت مجوز است.
|
||
3. **لینک دعوت هرگز reset رمز نمیشود.** اگر برای ایمیل دعوتشده حساب وجود داشته
|
||
باشد، پذیرش ناشناس با رمز `403` میگیرد و کاربر باید اول وارد شود
|
||
(`has_account` در preview به UI میگوید کدام مسیر است).
|
||
4. **سازمانهای مدیرِ رفته به مالک میرسند.** تنزل یا حذف مدیر،
|
||
`owner_user_id` و عضویتها را جابهجا میکند؛ داده هرگز یتیم یا حذف نمیشود.
|
||
- **تست مدافع:** `tests/test_platform_hierarchy.py`:
|
||
- `test_only_one_platform_owner_may_exist` — درج مستقیم مالک دوم `IntegrityError`.
|
||
- `test_owner_cannot_be_created_through_the_api` / `test_owner_account_is_immutable_from_the_api`
|
||
- `test_transfer_ownership_moves_the_seat_and_keeps_exactly_one`
|
||
- `test_plain_user_cannot_create_an_organization`
|
||
- `test_manager_cannot_create_accounts` / `test_manager_cannot_reach_the_admin_console`
|
||
- `test_demoting_a_manager_transfers_organizations_to_the_owner`
|
||
- `test_deleting_a_manager_transfers_organizations_and_memberships`
|
||
- `test_a_link_cannot_reset_an_existing_account` (در `test_invites.py`)
|
||
- `test_every_seat_move_is_audited`
|
||
|
||
### ✅ کاربر عادی مدل/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` روی ۹۰ فایل بدون خطا.
|