Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
7.0 KiB
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.pyapps/api/app/infrastructure/repositories/org_grants.pyapps/api/app/domain/identity/audit_actions.pyapps/api/alembic/versions/e6f7a8b9c0d1_model_grants.pyapps/api/tests/test_model_grants.py
Modified:
apps/api/app/infrastructure/db/models_org.py(+DefaultModelGrantRow,OrgPolicyRow)apps/api/app/application/container_org.pyapps/api/app/application/services/org_setup_service.pyapps/api/app/interfaces/http/routers/admin_grants.pyapps/api/tests/test_foundation.pyapps/web/src/lib/api-org.tsapps/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_grantshas no repository elsewhere.db/models.py(andOrgModelGrantRow) is foundation-owned and no other workstream owns a repository for it, so these live in the newapp/infrastructure/repositories/org_grants.py.granted_modelssorts by cap (uncapped first); nothing depends on the order yet.- The mutation endpoints use router-level
require_platform_owner, plus a redundant per-endpointownerdependency only to obtain the actor id for audit.
CHANGE REQUESTs
get_org_policyon theModelGrantPortcontract.ports/model_grants.pyis locked, soget_org_policy/mcp_enabledlive onModelGrantServiceonly. Please addget_org_policy(organization_id) -> object with .max_employees / .automation_enabled(and ideally.mcp_enabled) toModelGrantPortat integration so Agent 2 reads the policy through the port it already holds.container.pyre-export.build_model_grant_serviceis importable only fromcontainer_org(the lockedcontainer.pyre-exports onlybuild_model_grants). Re-export it if a single import surface is preferred.