feat(org): model grants, org memory, MCP gateway (v2 phases A1-3..5)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
+140
View File
@@ -0,0 +1,140 @@
# Agent 1 — Phase 3 report (owner model grants + org policy)
Branch: `dev` · Continues the org-platform workstream (PLAN-v2 §6, TODO-agent-1
Phase 3). Note on provenance: a complete Phase 3 already existed on the sibling
worktree branch `agent1/org-platform`; that branch is based on the foundation and
its Phase 2 diverged from `dev`'s (different `OrgSetupService`/container shapes),
so the code was reconciled onto `dev`'s structures and then fully re-verified here
rather than applied verbatim.
## What I did
### 3.1 `model_grant_service.py` (new)
`ModelGrantService` is the real `ModelGrantPort`: `granted_models(org)` returns
only models that are **both** active in the catalogue and actively granted to
*that* organization, each with a neutral `tier` (`fast`/`balanced`/`strong` ≈
«سریع»/«متعادل»/«قوی») derived by the frozen `tier_for` helper from capabilities
+ output price. Model names are exposed only by the owner-facing views
(`ModelView.label`, `OrgGrantView.label`), which the engine never calls.
### 3.2 `admin_grants.py` (owner-only router, deny-by-default)
| Method | Path | Role |
|---|---|---|
| GET | `/api/admin/model-grants/{org}` | platform owner |
| PUT | `/api/admin/model-grants/{org}/models/{model}` | platform owner |
| GET | `/api/admin/model-grants/{org}/active` | platform owner |
| DELETE | `/api/admin/model-grants/{org}/models/{model}` | platform owner |
| GET | `/api/admin/default-model-grants` | platform owner |
| PUT | `/api/admin/default-model-grants/{model}` | platform owner |
| DELETE | `/api/admin/default-model-grants/{model}` | platform owner |
| GET | `/api/admin/org-policies/{org}` | platform owner |
| PUT | `/api/admin/org-policies/{org}` | platform owner |
All mutations audited (`model_grant.set`, `model_grant.revoke`,
`default_model_grant.set`, `default_model_grant.remove`, `org_policy.update`).
Revocation deactivates the row instead of deleting it, so re-granting is
idempotent and the history survives.
### 3.3
`OrgSetupService.complete` now calls `_copy_default_grants` **inside** the
creation transaction (before the commit), materialising the platform owner's
default set into the new organization. An empty default set is legitimate: a new
org simply has no models until the owner grants them.
### 3.4
New `org_policies` table carries `max_employees` (clamped to `ENGINE_MAX_EMPLOYEES`
in the service), `monthly_credit_budget` (`None` = no explicit budget),
`automation_enabled` and `mcp_enabled` (reserved for Phase 5).
`ModelGrantService.get_org_policy(org_id)` / `mcp_enabled(org_id)` are public for
Agent 2 to read.
### 3.5
`container_org.build_model_grants` now returns the real `ModelGrantService`;
`container.py` still re-exports the frozen `build_model_grants` name. A second,
plain-typed builder `build_model_grant_service` serves the admin router (the
port interface alone cannot express grants/defaults/policy).
### 3.6 `/admin/model-grants`
Three tabs: «تخصیص سازمان‌ها» (org picker + matrix: checkbox, tier badge,
capabilities, daily cap with its own save button), «مجموعهٔ پیش‌فرض» (the same
matrix for new orgs, with an explicit "empty set" hint), «سیاست سازمان» (max
employees, monthly budget, automation and MCP checkboxes). Persian, RTL,
`@agy/ui` components, toasts for every mutation, per-row busy state.
## Files changed
New:
- `apps/api/app/application/services/model_grant_service.py`
- `apps/api/app/infrastructure/repositories/org_grants.py`
- `apps/api/app/domain/identity/audit_actions.py`
- `apps/api/alembic/versions/e6f7a8b9c0d1_model_grants.py`
- `apps/api/tests/test_model_grants.py`
Modified:
- `apps/api/app/infrastructure/db/models_org.py` (+`DefaultModelGrantRow`, `OrgPolicyRow`)
- `apps/api/app/application/container_org.py`
- `apps/api/app/application/services/org_setup_service.py`
- `apps/api/app/interfaces/http/routers/admin_grants.py`
- `apps/api/tests/test_foundation.py`
- `apps/web/src/lib/api-org.ts`
- `apps/web/src/app/(app)/admin/model-grants/page.tsx`
## Migration note (deviation from the TODO)
The Phase-2 integration on `dev` created its own draft migration
`d5e6f7a8b9c0_org_setup_drafts.py` chained off `a1b2c3d4e5f1`, rather than adding
the table into `a1b2c3d4e5f1` as the reference branch did. I followed `dev`'s own
precedent and chained a new, owned migration `e6f7a8b9c0d1_model_grants.py`
(`down_revision = "d5e6f7a8b9c0"`). Single head, no drift.
## Test output summary
```
apps/api: pytest -q → 553 passed, 1 skipped (baseline 536; +17 in test_model_grants.py)
apps/api: ruff check . → All checks passed!
apps/api: mypy app → Success: no issues found in 141 source files
apps/api: alembic upgrade head / downgrade -1 / upgrade head / check → clean, no drift
apps/web: tsc --noEmit → clean
apps/web: next lint → No ESLint warnings or errors
apps/web: vitest run → 89 passed (11 files)
```
`tests/test_model_grants.py` (3.7): non-owner is 403 on all nine endpoints
(parameterised), anonymous is 401, the wizard copies the default set (verified
through both HTTP and `ModelGrantPort`), grant/revoke is immediate + audited +
deactivates rather than deletes, revoking twice is 404, an inactive model is never
granted and granting it is 404, per-org isolation both ways, the default set can be
deactivated and removed without affecting existing orgs, and the policy
defaults/caps/audit/isolation.
## Existing test updated (and why)
`tests/test_foundation.py::test_default_model_grants_return_active_models_with_tiers`
→ `test_granted_models_carry_tiers_and_need_an_owner_grant`. The foundation test
asserted the placeholder wiring (every active model usable by every org). Phase 3
changes that on purpose, so the test now creates two real organizations and
asserts the stronger invariant: nothing is granted until the owner grants it, both
switches (model active + grant active) must agree, and a grant is per-organization.
No assertion was dropped.
## Deviations / notes
- **`org_model_grants` has no repository elsewhere.** `db/models.py` (and
`OrgModelGrantRow`) is foundation-owned and no other workstream owns a
repository for it, so these live in the new
`app/infrastructure/repositories/org_grants.py`.
- **`granted_models` sorts by cap** (uncapped first); nothing depends on the order
yet.
- The mutation endpoints use router-level `require_platform_owner`, plus a
redundant per-endpoint `owner` dependency only to obtain the actor id for audit.
## CHANGE REQUESTs
1. **`get_org_policy` on the `ModelGrantPort` contract.** `ports/model_grants.py`
is locked, so `get_org_policy` / `mcp_enabled` live on `ModelGrantService` only.
Please add `get_org_policy(organization_id) -> object with .max_employees /
.automation_enabled` (and ideally `.mcp_enabled`) to `ModelGrantPort` at
integration so Agent 2 reads the policy through the port it already holds.
2. **`container.py` re-export.** `build_model_grant_service` is importable only
from `container_org` (the locked `container.py` re-exports only
`build_model_grants`). Re-export it if a single import surface is preferred.