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:
co-authored by
Claude Sonnet 5.5
parent
c93b25741e
commit
dee2f72c46
@@ -0,0 +1,51 @@
|
||||
"""MCP gateway: scoped bearer tokens for external AI agents
|
||||
|
||||
Revision ID: a8b9c0d1e2f3
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2026-10-14 12:00:00.000000
|
||||
|
||||
Owned by Agent 1 (PLAN-v2 §6). Only the token hash and a display prefix are
|
||||
stored; the raw token is shown once at issue time.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "a8b9c0d1e2f3"
|
||||
down_revision: str | None = "f7a8b9c0d1e2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"mcp_tokens",
|
||||
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||
sa.Column(
|
||||
"organization_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("name", sa.String(length=120), nullable=False),
|
||||
# ``unique=True`` carries the index (``ix_mcp_tokens_token_hash``), so no
|
||||
# separate create_index is needed — a second one would drift.
|
||||
sa.Column("token_hash", sa.String(length=128), nullable=False, unique=True),
|
||||
sa.Column("prefix", sa.String(length=16), nullable=False),
|
||||
sa.Column("scopes", sa.String(length=255), nullable=False, server_default=""),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
op.create_index("ix_mcp_tokens_organization_id", "mcp_tokens", ["organization_id"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_mcp_tokens_organization_id", table_name="mcp_tokens")
|
||||
op.drop_table("mcp_tokens")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""owner model grants: default grant set + per-org policy
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: d5e6f7a8b9c0
|
||||
Create Date: 2026-10-14 09:00:00.000000
|
||||
|
||||
Owned by Agent 1 (PLAN-v2 §6). Adds the two owner-side tables of the model-grant
|
||||
workstream: the default set copied into every new organization, and the
|
||||
per-organization policy (employee cap, monthly budget, automation/MCP switches).
|
||||
|
||||
``org_model_grants`` itself is foundation-owned and already exists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e6f7a8b9c0d1"
|
||||
down_revision: str | None = "d5e6f7a8b9c0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"default_model_grants",
|
||||
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||
sa.Column(
|
||||
"ai_model_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("ai_models.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("daily_credit_cap", sa.Float(), nullable=True),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.UniqueConstraint("ai_model_id", name="uq_default_model_grant"),
|
||||
)
|
||||
|
||||
op.create_table(
|
||||
"org_policies",
|
||||
sa.Column(
|
||||
"organization_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column("max_employees", sa.Integer(), nullable=False, server_default="5"),
|
||||
sa.Column("monthly_credit_budget", sa.Float(), nullable=True),
|
||||
sa.Column("automation_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("mcp_enabled", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("org_policies")
|
||||
op.drop_table("default_model_grants")
|
||||
@@ -0,0 +1,69 @@
|
||||
"""org memory: the organization's own long-term knowledge
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: e6f7a8b9c0d1
|
||||
Create Date: 2026-10-14 11:00:00.000000
|
||||
|
||||
Owned by Agent 1 (PLAN-v2 §6). ``org_memories`` holds rules, preferences,
|
||||
lessons, facts and the code map, with a content hash for dedupe and a soft
|
||||
delete so a removed memory stays in the audit story.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f7a8b9c0d1e2"
|
||||
down_revision: str | None = "e6f7a8b9c0d1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"org_memories",
|
||||
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||
sa.Column(
|
||||
"organization_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("kind", sa.String(length=16), nullable=False),
|
||||
sa.Column("key", sa.String(length=80), nullable=True),
|
||||
sa.Column("content", sa.String(length=1500), nullable=False),
|
||||
# The dedupe digest the domain defines (sha256 of the normalised
|
||||
# content, truncated to 32 chars).
|
||||
sa.Column("content_hash", sa.String(length=32), nullable=False, server_default=""),
|
||||
sa.Column("source", sa.String(length=16), nullable=False, server_default="user"),
|
||||
sa.Column(
|
||||
"evidence_task_id",
|
||||
sa.String(length=32),
|
||||
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
),
|
||||
sa.Column("pinned", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
sa.Column("confidence", sa.Float(), nullable=False, server_default="1"),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("hits", sa.Integer(), nullable=False, server_default="0"),
|
||||
sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("created_by_user_id", sa.String(length=32), sa.ForeignKey("users.id")),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_org_memories_organization_id", "org_memories", ["organization_id"])
|
||||
op.create_index(
|
||||
"ix_org_memories_org_kind", "org_memories", ["organization_id", "kind"]
|
||||
)
|
||||
op.create_index("ix_org_memories_org_key", "org_memories", ["organization_id", "key"])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_org_memories_org_key", table_name="org_memories")
|
||||
op.drop_index("ix_org_memories_org_kind", table_name="org_memories")
|
||||
op.drop_index("ix_org_memories_organization_id", table_name="org_memories")
|
||||
op.drop_table("org_memories")
|
||||
@@ -1,44 +1,86 @@
|
||||
"""Wiring for the org-platform workstream (memory, model grants).
|
||||
|
||||
Foundation defaults: no memory, and every active model granted to every org (today's
|
||||
behaviour). The org-platform agent replaces the bodies with the real services; the
|
||||
function names and return types are FROZEN so other workstreams can call them now.
|
||||
``build_memory`` is the frozen ``MemoryPort`` builder the engine calls; it now
|
||||
returns the real ``MemoryService``. ``build_memory_service`` is the same object,
|
||||
typed for the router (list, teach, pin, confirm, delete). ``build_model_grants``
|
||||
returns the real ``ModelGrantService``; ``build_model_grant_service`` is its
|
||||
typed twin for the admin router.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.memory import MemoryPort, NullMemory
|
||||
from app.application.ports.model_grants import (
|
||||
AllActiveModelsGrants,
|
||||
GrantedModel,
|
||||
ModelGrantPort,
|
||||
tier_for,
|
||||
from app.application.ports.memory import MemoryPort
|
||||
from app.application.ports.model_grants import ModelGrantPort
|
||||
from app.application.services.memory_service import MemoryService
|
||||
from app.application.services.model_grant_service import ModelGrantService
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
RateCardRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.memory import MemoryRepository
|
||||
from app.infrastructure.repositories.org_grants import (
|
||||
DefaultModelGrantRepository,
|
||||
OrgModelGrantRepository,
|
||||
OrgPolicyRepository,
|
||||
)
|
||||
from app.infrastructure.db.models import AiModelRow
|
||||
|
||||
_null_memory = NullMemory()
|
||||
|
||||
|
||||
def build_memory(session: AsyncSession) -> MemoryPort:
|
||||
return _null_memory
|
||||
"""The real memory service: recall, teach, and one reflection per task.
|
||||
|
||||
``get_model_provider`` is resolved from ``container``'s namespace rather
|
||||
than from the registry directly: that module is what the composition root
|
||||
re-exports, so a deployment (or the test suite) that swaps the adapter in
|
||||
one place swaps it here too.
|
||||
"""
|
||||
from app.application.container import get_model_provider
|
||||
|
||||
return build_memory_service(session, provider=get_model_provider())
|
||||
|
||||
|
||||
def build_memory_service(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
provider=None,
|
||||
) -> MemoryService:
|
||||
"""The same object, typed for the router (list, teach, pin, confirm, delete)."""
|
||||
if provider is None:
|
||||
from app.application.container import get_model_provider
|
||||
|
||||
provider = get_model_provider()
|
||||
return MemoryService(
|
||||
session=session,
|
||||
memories=MemoryRepository(session),
|
||||
models=AiModelRepository(session),
|
||||
credentials=ModelCredentialRepository(session),
|
||||
rate_cards=RateCardRepository(session),
|
||||
ledger=UsageLedgerRepository(session),
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
|
||||
def build_model_grants(session: AsyncSession) -> ModelGrantPort:
|
||||
async def _all_active() -> list[GrantedModel]:
|
||||
rows = (await session.scalars(select(AiModelRow).where(AiModelRow.is_active))).all()
|
||||
return [
|
||||
GrantedModel(
|
||||
ai_model_id=row.id,
|
||||
tier=tier_for(
|
||||
capabilities=list(row.capabilities or []),
|
||||
output_price_per_1m=row.output_price_per_1m,
|
||||
),
|
||||
capabilities=tuple(row.capabilities or []),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
"""The real grant service: an organization sees only what the owner gave it."""
|
||||
return build_model_grant_service(session)
|
||||
|
||||
return AllActiveModelsGrants(_all_active)
|
||||
|
||||
def build_model_grant_service(session: AsyncSession) -> ModelGrantService:
|
||||
"""The same object, typed for the admin router (grants, defaults, policy)."""
|
||||
return ModelGrantService(
|
||||
session=session,
|
||||
grants=OrgModelGrantRepository(session),
|
||||
defaults=DefaultModelGrantRepository(session),
|
||||
policies=OrgPolicyRepository(session),
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_memory",
|
||||
"build_memory_service",
|
||||
"build_model_grant_service",
|
||||
"build_model_grants",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
"""MCP tokens: issue, list, revoke, verify.
|
||||
|
||||
Owned by Agent 1. One table, one service, three rules that matter:
|
||||
|
||||
1. **A token is shown once.** Only ``sha256(token)`` and a six-character prefix
|
||||
are stored, so a database leak cannot authenticate anyone; the prefix is for
|
||||
the audit trail and the settings screen, and is never enough to log in.
|
||||
2. **Verification is constant-time and total.** ``hmac.compare_digest`` on the
|
||||
hash, and every failure mode — unknown, revoked, expired — returns the same
|
||||
``None``. A caller cannot tell "wrong token" from "revoked token", because a
|
||||
revoked token is not a fact about the token, it is a decision.
|
||||
3. **Scopes are a floor, not a ceiling.** A token carries the scopes it was
|
||||
minted with and nothing else; an empty scope list is "no scopes", never "all".
|
||||
The per-organization switch lives in ``org_policies.mcp_enabled`` (Phase 3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.infrastructure.db.models_org import McpTokenRow
|
||||
|
||||
#: Every scope the gateway understands. A scope nobody granted a token is a
|
||||
#: scope the gateway refuses, whatever the asks say.
|
||||
MCP_SCOPES: tuple[str, ...] = (
|
||||
"app:read",
|
||||
"tasks:read",
|
||||
"tasks:write",
|
||||
"memory:read",
|
||||
"memory:write",
|
||||
"delivery:read",
|
||||
)
|
||||
|
||||
#: Enough entropy that a token is unguessable, short enough to paste into a
|
||||
#: client config file.
|
||||
TOKEN_ENTROPY_BYTES = 24
|
||||
|
||||
#: How the raw token looks to a human: the prefix is what the UI shows back.
|
||||
TOKEN_PREFIX = "smo_"
|
||||
|
||||
#: Displayed characters; ``token_hash`` is what authenticates.
|
||||
PREFIX_DISPLAY_CHARS = 6
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class McpToken:
|
||||
"""A token as the service hands it back — never the raw secret."""
|
||||
|
||||
id: str
|
||||
organization_id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str]
|
||||
created_by_user_id: str | None = None
|
||||
expires_at: object | None = None
|
||||
last_used_at: object | None = None
|
||||
revoked_at: object | None = None
|
||||
created_at: object | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class IssuedToken:
|
||||
"""The one and only response that carries the raw token."""
|
||||
|
||||
token: str
|
||||
record: McpToken
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class TokenIdentity:
|
||||
"""What a verified token proves: which organization, and what it may do."""
|
||||
|
||||
organization_id: str
|
||||
token_id: str
|
||||
prefix: str
|
||||
scopes: frozenset[str] = field(default_factory=frozenset)
|
||||
|
||||
def allows(self, scope: str) -> bool:
|
||||
return scope in self.scopes
|
||||
|
||||
|
||||
def as_utc(value):
|
||||
"""Normalise a timestamp read from the database for comparison.
|
||||
|
||||
SQLite (the development and test store) hands back naive datetimes; every
|
||||
expiry and revocation check compares against ``utcnow()``, so a naive value
|
||||
is assumed to be UTC rather than raising in the middle of a refusal.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
from datetime import UTC
|
||||
|
||||
return value.replace(tzinfo=UTC) if value.tzinfo is None else value
|
||||
|
||||
|
||||
def hash_token(raw: str) -> str:
|
||||
"""The only form of a token that is ever stored."""
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _mint() -> str:
|
||||
return f"{TOKEN_PREFIX}{secrets.token_urlsafe(TOKEN_ENTROPY_BYTES)}"
|
||||
|
||||
|
||||
def _scopes_of(raw: str) -> list[str]:
|
||||
"""Parse a CSV scope field, keeping only known scopes and their order."""
|
||||
seen: list[str] = []
|
||||
for piece in (raw or "").split(","):
|
||||
scope = piece.strip()
|
||||
if scope and scope in MCP_SCOPES and scope not in seen:
|
||||
seen.append(scope)
|
||||
return seen
|
||||
|
||||
|
||||
def validate_scopes(scopes: list[str] | None) -> list[str]:
|
||||
"""Refuse an unknown scope rather than silently dropping it."""
|
||||
if not scopes:
|
||||
return []
|
||||
unknown = [scope for scope in scopes if scope not in MCP_SCOPES]
|
||||
if unknown:
|
||||
raise ValidationError(f"محدودهٔ ناشناخته: {', '.join(sorted(unknown))}")
|
||||
# De-duplicated, order preserved, so the UI's checkbox set round-trips.
|
||||
return list(dict.fromkeys(scopes))
|
||||
|
||||
|
||||
class TokenRateLimiter:
|
||||
"""In-memory, per-token call budget for the current minute.
|
||||
|
||||
Deliberately process-local: the gateway is one process, and a distributed
|
||||
limiter would need the Redis the deployment does not have. A restart forgives
|
||||
a burst — which is the right trade for a spare process only the owner's own
|
||||
agents reach.
|
||||
"""
|
||||
|
||||
def __init__(self, *, limit_per_minute: int | None = None) -> None:
|
||||
self.limit = int(limit_per_minute or settings.mcp_rate_limit_per_minute)
|
||||
self._calls: dict[str, deque[float]] = {}
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
import time
|
||||
|
||||
now = time.monotonic()
|
||||
window_start = now - 60.0
|
||||
bucket = self._calls.setdefault(key, deque())
|
||||
while bucket and bucket[0] < window_start:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= self.limit:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
|
||||
def reset(self) -> None:
|
||||
self._calls.clear()
|
||||
|
||||
|
||||
class McpService:
|
||||
"""Token lifecycle and verification for the MCP gateway."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def issue(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
name: str,
|
||||
scopes: list[str],
|
||||
created_by_user_id: str | None = None,
|
||||
ttl_days: int | None = None,
|
||||
) -> IssuedToken:
|
||||
clean_name = name.strip()
|
||||
if not clean_name:
|
||||
raise ValidationError("نام توکن نمیتواند خالی باشد.")
|
||||
wanted = validate_scopes(scopes)
|
||||
if not wanted:
|
||||
raise ValidationError("حداقل یک محدودهٔ دسترسی انتخاب کنید.")
|
||||
days = int(ttl_days or settings.mcp_token_ttl_days)
|
||||
if days < 1:
|
||||
raise ValidationError("مدت اعتبار توکن نامعتبر است.")
|
||||
|
||||
raw = _mint()
|
||||
row = McpTokenRow(
|
||||
id=new_id(),
|
||||
organization_id=organization_id,
|
||||
name=clean_name[:120],
|
||||
token_hash=hash_token(raw),
|
||||
prefix=raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS],
|
||||
scopes=",".join(wanted),
|
||||
created_by_user_id=created_by_user_id,
|
||||
expires_at=utcnow() + timedelta(days=days),
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return IssuedToken(token=raw, record=_to_domain(row))
|
||||
|
||||
async def list(self, organization_id: str) -> list[McpToken]:
|
||||
rows = (
|
||||
(
|
||||
await self.session.execute(
|
||||
select(McpTokenRow)
|
||||
.where(McpTokenRow.organization_id == organization_id)
|
||||
.order_by(McpTokenRow.created_at.desc())
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
return [_to_domain(row) for row in rows]
|
||||
|
||||
async def revoke(self, *, organization_id: str, token_id: str) -> None:
|
||||
"""Revoke a token. Revoking twice is a 404, not a silent success — the
|
||||
same idempotence rule the grant endpoints follow, so a caller can tell
|
||||
"already gone" from "never existed" only by looking, not by guessing."""
|
||||
row = await self._row(organization_id, token_id)
|
||||
if row is None or row.revoked_at is not None:
|
||||
raise NotFoundError("توکن یافت نشد.")
|
||||
row.revoked_at = utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
# -- verification --------------------------------------------------------
|
||||
|
||||
async def verify(self, raw_token: str) -> TokenIdentity | None:
|
||||
"""Resolve a raw token, or ``None`` for every kind of refusal.
|
||||
|
||||
The lookup is by hash (the token is never stored), and the comparison is
|
||||
constant-time so a token that shares a prefix with a real one cannot be
|
||||
discovered by timing. An expired or revoked token is simply not an
|
||||
identity any more.
|
||||
"""
|
||||
if not raw_token or not raw_token.startswith(TOKEN_PREFIX):
|
||||
return None
|
||||
digest = hash_token(raw_token)
|
||||
rows = (
|
||||
(
|
||||
await self.session.execute(
|
||||
select(McpTokenRow).where(
|
||||
McpTokenRow.token_hash == digest,
|
||||
McpTokenRow.revoked_at.is_(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
if not rows:
|
||||
return None
|
||||
row = rows[0]
|
||||
if not hmac.compare_digest(row.token_hash, digest):
|
||||
return None
|
||||
if as_utc(row.expires_at) is not None and as_utc(row.expires_at) <= utcnow():
|
||||
return None
|
||||
return TokenIdentity(
|
||||
organization_id=row.organization_id,
|
||||
token_id=row.id,
|
||||
prefix=row.prefix,
|
||||
scopes=frozenset(_scopes_of(row.scopes)),
|
||||
)
|
||||
|
||||
async def creator_of(self, token_id: str) -> str | None:
|
||||
"""The user who minted this token — the accountable actor for its writes.
|
||||
|
||||
A task created through the gateway is attributed to the administrator who
|
||||
created the token, not to a ghost user: the token is a delegated key, and
|
||||
delegation never erases who holds it.
|
||||
"""
|
||||
row = await self.session.get(McpTokenRow, token_id)
|
||||
return row.created_by_user_id if row else None
|
||||
|
||||
async def touch(self, token_id: str) -> None:
|
||||
"""Record last use. Never allowed to fail a call."""
|
||||
row = await self.session.get(McpTokenRow, token_id)
|
||||
if row is not None:
|
||||
row.last_used_at = utcnow()
|
||||
await self.session.flush()
|
||||
|
||||
async def count_per_org(self) -> dict[str, int]:
|
||||
"""Active tokens per organization, for the owner's /admin/mcp screen."""
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(McpTokenRow.organization_id).where(
|
||||
McpTokenRow.revoked_at.is_(None)
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
counts: dict[str, int] = {}
|
||||
for org_id in rows:
|
||||
counts[org_id] = counts.get(org_id, 0) + 1
|
||||
return counts
|
||||
|
||||
async def _row(self, organization_id: str, token_id: str) -> McpTokenRow | None:
|
||||
return await self.session.scalar(
|
||||
select(McpTokenRow).where(
|
||||
McpTokenRow.organization_id == organization_id,
|
||||
McpTokenRow.id == token_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _to_domain(row: McpTokenRow) -> McpToken:
|
||||
return McpToken(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
name=row.name,
|
||||
prefix=row.prefix,
|
||||
scopes=_scopes_of(row.scopes),
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
expires_at=row.expires_at,
|
||||
last_used_at=row.last_used_at,
|
||||
revoked_at=row.revoked_at,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MCP_SCOPES",
|
||||
"TOKEN_PREFIX",
|
||||
"IssuedToken",
|
||||
"McpService",
|
||||
"McpToken",
|
||||
"TokenIdentity",
|
||||
"TokenRateLimiter",
|
||||
"hash_token",
|
||||
"validate_scopes",
|
||||
]
|
||||
@@ -0,0 +1,535 @@
|
||||
"""Org memory use cases: what an organization remembers, and how it learns.
|
||||
|
||||
This module is the real ``MemoryPort`` implementation (see
|
||||
``container_org.build_memory``). The engine calls it three ways — build a
|
||||
context for a task, record something a human taught, and react to a finished
|
||||
task — and this service owns three invariants around those calls:
|
||||
|
||||
1. **Memory is capped.** One organization may spend at most
|
||||
``MEMORY_ORG_CHAR_CAP`` characters on live memory. When a write would cross
|
||||
it, the oldest unpinned *lesson* is evicted first — a lesson is disposable by
|
||||
definition, a rule or a preference is not.
|
||||
2. **Teaching the same thing twice teaches nothing.** A write whose content (or
|
||||
whose key) already exists updates that entry instead of adding a duplicate,
|
||||
which is also what lets "the port is 8001" correct "the port is 8000".
|
||||
3. **A reflection can never break the engine.** ``on_task_finished`` asks one
|
||||
cheap model for what the task taught. If the call fails, the error is
|
||||
recorded and the function returns: the task is finished, and the org simply
|
||||
learned nothing this time.
|
||||
|
||||
Nothing here returns a model name, a provider or an infrastructure detail — the
|
||||
engine's contract is a block of text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports import ChatMessage, ModelProviderPort, UsageRecord
|
||||
from app.application.ports.memory import TaskOutcome
|
||||
from app.application.ports.model_provider import ModelResponse
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.core.redact import redact_text
|
||||
from app.domain.ai_control.models import RateUnit, RoutingMode
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.models import ROLE_RANK, Role
|
||||
from app.domain.memory.models import (
|
||||
CONFIRMED_CONFIDENCE,
|
||||
KIND_LABELS,
|
||||
MAX_WRITES_PER_TASK,
|
||||
REFLECTION_CONFIDENCE,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
MemorySource,
|
||||
coerce_kind,
|
||||
coerce_source,
|
||||
content_hash,
|
||||
lesson_expiry,
|
||||
normalise,
|
||||
relevance,
|
||||
tokenise,
|
||||
validate_confidence,
|
||||
validate_content,
|
||||
validate_key,
|
||||
visible_kinds,
|
||||
)
|
||||
from app.infrastructure.error_log import record_error
|
||||
from app.infrastructure.repositories.ai_control import (
|
||||
AiModelRepository,
|
||||
ModelCredentialRepository,
|
||||
RateCardRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.audit import UsageLedgerRepository
|
||||
from app.infrastructure.repositories.memory import MemoryRepository
|
||||
|
||||
#: What a reflection is asked to produce. Deliberately narrow: two lessons and a
|
||||
#: code-map paragraph, in a shape the parser can read without guessing.
|
||||
REFLECTION_SYSTEM = (
|
||||
"تو بازبین کارهای یک سازمان هستی. حداکثر دو درس آموختنی و یک پاراگراف نقشهٔ "
|
||||
"کد بنویس. هر درس یک خط است و فقط یک واقعیت اجرایی را میگوید — نه تعریف. "
|
||||
"اگر درسی نیست، فقط کلمهٔ «هیچ» را بنویس."
|
||||
)
|
||||
|
||||
REFLECTION_TEMPLATE = """کار: {title}
|
||||
وضعیت: {status}
|
||||
خلاصه: {summary}
|
||||
فایلهای تغییرکرده: {files}
|
||||
خطاهای دروازه: {failures}
|
||||
|
||||
پاسخ را دقیقاً در این قالب بنویس:
|
||||
درس ۱: ...
|
||||
درس ۲: ...
|
||||
نقشهٔ کد: ..."""
|
||||
|
||||
#: The block header. Short on purpose: the block is a prompt fragment, and a
|
||||
#: prompt fragment pays for every character.
|
||||
CONTEXT_HEADER = "یادداشتهای سازمان:"
|
||||
|
||||
|
||||
class MemoryService:
|
||||
"""Recalls, teaches, corrects and prunes one organization's memory."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
memories: MemoryRepository,
|
||||
models: AiModelRepository,
|
||||
credentials: ModelCredentialRepository,
|
||||
rate_cards: RateCardRepository,
|
||||
ledger: UsageLedgerRepository,
|
||||
provider: ModelProviderPort,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.memories = memories
|
||||
self.models = models
|
||||
self.credentials = credentials
|
||||
self.rate_cards = rate_cards
|
||||
self.ledger = ledger
|
||||
self.provider = provider
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# MemoryPort #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def record(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
kind: str,
|
||||
content: str,
|
||||
source: str,
|
||||
evidence_task_id: str | None = None,
|
||||
key: str | None = None,
|
||||
created_by_user_id: str | None = None,
|
||||
) -> str | None:
|
||||
"""Store — or correct — one memory. Returns its id, or ``None``.
|
||||
|
||||
``None`` is an answer, not a failure: the write was refused because the
|
||||
task had already taught its three memories. The caller (usually the
|
||||
reflection path) must not treat that as an error.
|
||||
"""
|
||||
cleaned = validate_content(content)
|
||||
# A memory lands in a future prompt. A credential pasted into one would
|
||||
# be broadcast to every employee from then on, so nothing is stored
|
||||
# unredacted — not even what "looks safe".
|
||||
cleaned = redact_text(cleaned)
|
||||
entry_kind = coerce_kind(kind)
|
||||
entry_source = coerce_source(source)
|
||||
entry_key = validate_key(key)
|
||||
digest = content_hash(cleaned)
|
||||
|
||||
if evidence_task_id:
|
||||
writes = await self.memories.count_for_task(organization_id, evidence_task_id)
|
||||
if writes >= MAX_WRITES_PER_TASK:
|
||||
return None
|
||||
|
||||
duplicate = await self.memories.find_duplicate(
|
||||
organization_id, kind=entry_kind, key=entry_key, digest=digest
|
||||
)
|
||||
if duplicate is not None:
|
||||
# Same thought, same organization: correct the entry in place.
|
||||
# Confidence and pinned state survive, because re-reading a rule is
|
||||
# not a retraction of it.
|
||||
duplicate.content = cleaned
|
||||
duplicate.source = entry_source
|
||||
duplicate.confidence = _confidence_for(entry_source, duplicate.confidence)
|
||||
duplicate.expires_at = _expiry_for(entry_kind, duplicate)
|
||||
if created_by_user_id:
|
||||
duplicate.created_by_user_id = duplicate.created_by_user_id or created_by_user_id
|
||||
await self.memories.save(duplicate)
|
||||
return duplicate.id
|
||||
|
||||
memory = Memory(
|
||||
organization_id=organization_id,
|
||||
kind=entry_kind,
|
||||
key=entry_key,
|
||||
content=cleaned,
|
||||
source=entry_source,
|
||||
evidence_task_id=evidence_task_id,
|
||||
created_by_user_id=created_by_user_id,
|
||||
confidence=_confidence_for(entry_source, 1.0),
|
||||
)
|
||||
memory.expires_at = _expiry_for(entry_kind, memory)
|
||||
await self.memories.create(memory)
|
||||
await self._enforce_cap(organization_id)
|
||||
return memory.id
|
||||
|
||||
async def build_context(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
role: str,
|
||||
query: str,
|
||||
budget_chars: int = 2500,
|
||||
) -> str:
|
||||
"""The memory block for one task, within ``budget_chars``.
|
||||
|
||||
Pinned rules and preferences lead: they are standing decisions, not
|
||||
search results. Everything else follows by relevance to the query.
|
||||
Whatever does not fit is dropped — including a pinned entry, because a
|
||||
prompt over budget is worse than an incomplete one.
|
||||
"""
|
||||
kinds = visible_kinds(role)
|
||||
if budget_chars <= 0 or not kinds:
|
||||
return ""
|
||||
memories = await self.memories.list_for_org(organization_id, limit=200)
|
||||
live = [m for m in memories if m.kind in kinds and m.is_live()]
|
||||
if not live:
|
||||
return ""
|
||||
|
||||
query_tokens = tokenise(query)
|
||||
leading = [m for m in live if m.pinned and m.kind in _LEADING_KINDS]
|
||||
leading_ids = {m.id for m in leading}
|
||||
rest = [m for m in live if m.id not in leading_ids]
|
||||
|
||||
leading.sort(key=lambda m: (_kind_order(m.kind), -(m.updated_at or utcnow()).timestamp()))
|
||||
rest.sort(
|
||||
key=lambda m: (
|
||||
-relevance(m, query_tokens),
|
||||
-(m.updated_at or utcnow()).timestamp(),
|
||||
)
|
||||
)
|
||||
|
||||
budget = int(budget_chars)
|
||||
lines: list[str] = []
|
||||
used = len(CONTEXT_HEADER) + 1
|
||||
included: list[Memory] = []
|
||||
for memory in [*leading, *rest]:
|
||||
line = _format_entry(memory)
|
||||
if used + len(line) + 1 > budget:
|
||||
continue
|
||||
used += len(line) + 1
|
||||
lines.append(line)
|
||||
included.append(memory)
|
||||
if not lines:
|
||||
return ""
|
||||
await self.memories.mark_used([m.id for m in included])
|
||||
return "\n".join([CONTEXT_HEADER, *lines])
|
||||
|
||||
async def on_task_finished(self, *, organization_id: str, task: TaskOutcome) -> None:
|
||||
"""Let the organization learn from a finished task. Never raises.
|
||||
|
||||
One cheap completion, at most ``MAX_WRITES_PER_TASK`` memories, all
|
||||
written with ``source=reflection`` and half confidence. A failure is
|
||||
recorded and swallowed: the task is already finished, and the engine must
|
||||
not have to know whether an LLM happened to answer.
|
||||
"""
|
||||
try:
|
||||
answer = await self._reflect(organization_id=organization_id, task=task)
|
||||
except Exception as exc: # noqa: BLE001 - reflection must never raise
|
||||
await record_error(
|
||||
kind="memory.reflection_failed",
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
organization_id=organization_id,
|
||||
metadata={"task_id": task.task_id, "status": task.status},
|
||||
)
|
||||
return
|
||||
if not answer:
|
||||
return
|
||||
|
||||
for kind, content in _parse_reflection(answer)[:MAX_WRITES_PER_TASK]:
|
||||
await self.record(
|
||||
organization_id=organization_id,
|
||||
kind=str(kind),
|
||||
content=content,
|
||||
source=str(MemorySource.REFLECTION),
|
||||
evidence_task_id=task.task_id,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Writes the UI and the MCP gateway expose #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def list(
|
||||
self,
|
||||
organization_id: str,
|
||||
*,
|
||||
kind: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> list[Memory]:
|
||||
parsed = coerce_kind(kind) if kind else None
|
||||
return await self.memories.list_for_org(organization_id, kind=parsed, search=search)
|
||||
|
||||
async def teach(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
user_id: str,
|
||||
role: Role,
|
||||
kind: str,
|
||||
content: str,
|
||||
key: str | None = None,
|
||||
) -> Memory:
|
||||
"""A human teaching the organization — a rule or a preference.
|
||||
|
||||
The role check lives here and not only in the router: this service is
|
||||
also reached by the MCP gateway (Phase 5), and "someone with a token"
|
||||
must hit the same wall as someone with a session.
|
||||
"""
|
||||
entry_kind = coerce_kind(kind)
|
||||
if entry_kind not in _TEACHABLE_KINDS:
|
||||
raise PermissionDeniedError("فقط «قاعده» یا «ترجیح» قابل آموزش است.")
|
||||
if ROLE_RANK[role] < ROLE_RANK[Role.MEMBER]:
|
||||
raise PermissionDeniedError("برای آموزش سازمان باید عضو آن باشید.")
|
||||
memory_id = await self.record(
|
||||
organization_id=organization_id,
|
||||
kind=str(entry_kind),
|
||||
content=content,
|
||||
source=str(MemorySource.USER),
|
||||
key=key,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
if memory_id is None: # pragma: no cover - only the task cap returns None
|
||||
raise ValidationError("یادداشت ذخیره نشد.")
|
||||
memory = await self.memories.get(organization_id, memory_id)
|
||||
if memory is None: # pragma: no cover - created one call above
|
||||
raise NotFoundError("یادداشت یافت نشد.")
|
||||
return memory
|
||||
|
||||
async def get(self, organization_id: str, memory_id: str) -> Memory:
|
||||
memory = await self.memories.get(organization_id, memory_id)
|
||||
if memory is None or memory.deleted_at is not None:
|
||||
raise NotFoundError("یادداشت یافت نشد.")
|
||||
return memory
|
||||
|
||||
async def edit(
|
||||
self, *, organization_id: str, memory_id: str, content: str, key: str | None = None
|
||||
) -> Memory:
|
||||
memory = await self.get(organization_id, memory_id)
|
||||
memory.content = validate_content(content)
|
||||
if key is not None:
|
||||
memory.key = validate_key(key)
|
||||
memory.confidence = validate_confidence(memory.confidence)
|
||||
memory.expires_at = _expiry_for(memory.kind, memory)
|
||||
await self.memories.save(memory)
|
||||
return memory
|
||||
|
||||
async def set_pinned(
|
||||
self, *, organization_id: str, memory_id: str, pinned: bool
|
||||
) -> Memory:
|
||||
memory = await self.get(organization_id, memory_id)
|
||||
memory.pinned = bool(pinned)
|
||||
# Pinning is a promise the organization keeps, so a pinned lesson stops
|
||||
# expiring and becomes part of every context.
|
||||
memory.expires_at = _expiry_for(memory.kind, memory)
|
||||
await self.memories.save(memory)
|
||||
return memory
|
||||
|
||||
async def confirm(self, *, organization_id: str, memory_id: str) -> Memory:
|
||||
"""A reviewed lesson becomes settled knowledge: full confidence, no expiry."""
|
||||
memory = await self.get(organization_id, memory_id)
|
||||
if memory.kind is not MemoryKind.LESSON:
|
||||
raise ValidationError("فقط یک «درس» قابل تأیید است.")
|
||||
memory.confidence = CONFIRMED_CONFIDENCE
|
||||
memory.pinned = True
|
||||
memory.expires_at = None
|
||||
await self.memories.save(memory)
|
||||
return memory
|
||||
|
||||
async def delete(self, *, organization_id: str, memory_id: str) -> None:
|
||||
if not await self.memories.soft_delete(organization_id, memory_id):
|
||||
raise NotFoundError("یادداشت یافت نشد.")
|
||||
|
||||
async def expire_memories(self, organization_id: str | None = None) -> int:
|
||||
"""Soft-delete expired lessons. Exposed for the worker hook (see report)."""
|
||||
return await self.memories.expire(organization_id)
|
||||
|
||||
async def stats(self, organization_id: str) -> dict[str, int]:
|
||||
memories = await self.memories.list_for_org(organization_id, limit=200)
|
||||
return {
|
||||
"total": len(memories),
|
||||
"pinned": sum(1 for m in memories if m.pinned),
|
||||
"chars": await self.memories.total_chars(organization_id),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Internals #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def _reflect(self, *, organization_id: str, task: TaskOutcome) -> str:
|
||||
"""One cheap completion about the finished task, priced in the ledger."""
|
||||
model = await self.models.pick_for_mode(RoutingMode.CHEAP)
|
||||
if model is None:
|
||||
# No catalogue means nothing to route to. Record it once rather than
|
||||
# letting the engine wonder why nobody learned anything.
|
||||
await record_error(
|
||||
kind="memory.reflection_no_model",
|
||||
message="no active model for the cheap routing mode",
|
||||
organization_id=organization_id,
|
||||
metadata={"task_id": task.task_id},
|
||||
)
|
||||
return ""
|
||||
pool = await self.credentials.list_active_for_provider(model.provider_id)
|
||||
response = await self.provider.complete(
|
||||
model=model,
|
||||
credential=pool[0] if pool else None,
|
||||
credentials=pool or None,
|
||||
messages=[
|
||||
ChatMessage(
|
||||
role="user",
|
||||
content=REFLECTION_TEMPLATE.format(
|
||||
title=task.title,
|
||||
status=task.status,
|
||||
summary=task.summary or "—",
|
||||
files="، ".join(task.changed_files[:20]) or "—",
|
||||
failures="، ".join(task.gate_failures) or "—",
|
||||
),
|
||||
)
|
||||
],
|
||||
system=REFLECTION_SYSTEM,
|
||||
temperature=0.2,
|
||||
max_tokens=600,
|
||||
)
|
||||
await self._record_usage(organization_id=organization_id, response=response)
|
||||
return response.content or ""
|
||||
|
||||
async def _record_usage(self, *, organization_id: str, response: ModelResponse) -> None:
|
||||
"""Price a reflection exactly like any other model call."""
|
||||
for unit, quantity in (
|
||||
(RateUnit.MODEL_INPUT_TOKENS, float(response.input_tokens or 0)),
|
||||
(RateUnit.MODEL_OUTPUT_TOKENS, float(response.output_tokens or 0)),
|
||||
):
|
||||
card = await self.rate_cards.get_by_unit(unit)
|
||||
unit_price = card.customer_price if card else 0.0
|
||||
internal_cost = card.internal_cost_estimate if card else 0.0
|
||||
await self.ledger.record(
|
||||
usage=UsageRecord(
|
||||
organization_id=organization_id,
|
||||
unit=unit,
|
||||
quantity=quantity,
|
||||
unit_price=unit_price,
|
||||
internal_cost_estimate=quantity * internal_cost,
|
||||
total_price=quantity * unit_price,
|
||||
metadata_json={"memory_reflection": True},
|
||||
)
|
||||
)
|
||||
|
||||
async def _enforce_cap(self, organization_id: str) -> int:
|
||||
"""Trim the organization to its character budget. Returns evicted count."""
|
||||
cap = int(settings.memory_org_char_cap)
|
||||
evicted = 0
|
||||
while await self.memories.total_chars(organization_id) > cap:
|
||||
victims = await self.memories.evictable_lessons(organization_id, limit=1)
|
||||
if not victims:
|
||||
# Only rules and preferences are left: the organization chose to
|
||||
# spend its whole budget on standing decisions, and silently
|
||||
# deleting one of those is worse than being one character over.
|
||||
return evicted
|
||||
await self.memories.soft_delete(organization_id, victims[0].id)
|
||||
evicted += 1
|
||||
return evicted
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Kinds a human may teach directly; everything else is the system's voice.
|
||||
_TEACHABLE_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
|
||||
|
||||
#: A pinned rule outranks a pinned preference: one says what to do, the other
|
||||
#: says how to like it.
|
||||
_LEADING_KINDS = frozenset({MemoryKind.RULE, MemoryKind.PREFERENCE})
|
||||
|
||||
_KIND_ORDER: dict[MemoryKind, int] = {
|
||||
MemoryKind.RULE: 0,
|
||||
MemoryKind.PREFERENCE: 1,
|
||||
}
|
||||
|
||||
|
||||
def _kind_order(kind: MemoryKind) -> int:
|
||||
return _KIND_ORDER.get(kind, 9)
|
||||
|
||||
|
||||
def _confidence_for(source: MemorySource, fallback: float) -> float:
|
||||
"""Reflections get half confidence; a human's entry keeps its own."""
|
||||
if source is MemorySource.REFLECTION:
|
||||
return REFLECTION_CONFIDENCE
|
||||
return validate_confidence(fallback)
|
||||
|
||||
|
||||
def _expiry_for(kind: MemoryKind, memory: Memory):
|
||||
"""Lessons expire; a rule, a preference, a fact and the code map do not."""
|
||||
if kind is not MemoryKind.LESSON:
|
||||
return None
|
||||
return lesson_expiry(
|
||||
pinned=memory.pinned, confirmed=memory.confidence >= CONFIRMED_CONFIDENCE
|
||||
)
|
||||
|
||||
|
||||
def _format_entry(memory: Memory) -> str:
|
||||
label = KIND_LABELS.get(str(memory.kind), "یادداشت")
|
||||
suffix = ""
|
||||
if memory.kind is MemoryKind.LESSON:
|
||||
suffix = f" (اعتماد {int(memory.confidence * 100)}٪)"
|
||||
elif memory.source is MemorySource.REFLECTION:
|
||||
suffix = " (بازتاب)"
|
||||
return f"- [{label}]{suffix} {memory.content.strip()}"
|
||||
|
||||
|
||||
def _parse_reflection(text: str) -> list[tuple[MemoryKind, str]]:
|
||||
"""Read a reflection's answer into ``(kind, content)`` pairs.
|
||||
|
||||
Tolerant by design: the model is asked for a rigid template, but a model is
|
||||
a model. A line that does not start with a known marker is ignored rather
|
||||
than stored, so a chatty answer cannot smuggle in a third lesson.
|
||||
"""
|
||||
entries: list[tuple[MemoryKind, str]] = []
|
||||
for raw_line in (text or "").splitlines():
|
||||
line = normalise(raw_line).lstrip("-*• \t").strip()
|
||||
if not line:
|
||||
continue
|
||||
lowered = line.casefold()
|
||||
for marker, kind in _REFLECTION_MARKERS:
|
||||
if lowered.startswith(marker):
|
||||
body = line[len(marker):].strip(" :،-–")
|
||||
if body:
|
||||
entries.append((kind, body))
|
||||
break
|
||||
return entries
|
||||
|
||||
|
||||
#: Marker → kind. Longest marker first, so «نقشهٔ کد:» cannot be swallowed by a
|
||||
#: shorter prefix, and both the Persian and the English spelling is accepted.
|
||||
_REFLECTION_MARKERS: tuple[tuple[str, MemoryKind], ...] = (
|
||||
("نقشهٔ کد:", MemoryKind.CODE_MAP),
|
||||
("نقشه کد:", MemoryKind.CODE_MAP),
|
||||
("code map:", MemoryKind.CODE_MAP),
|
||||
("درس ۱:", MemoryKind.LESSON),
|
||||
("درس ۱", MemoryKind.LESSON),
|
||||
("درس ۲:", MemoryKind.LESSON),
|
||||
("درس ۲", MemoryKind.LESSON),
|
||||
("درس:", MemoryKind.LESSON),
|
||||
("درس", MemoryKind.LESSON),
|
||||
("lesson 1:", MemoryKind.LESSON),
|
||||
("lesson 1", MemoryKind.LESSON),
|
||||
("lesson 2:", MemoryKind.LESSON),
|
||||
("lesson 2", MemoryKind.LESSON),
|
||||
("lesson:", MemoryKind.LESSON),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["CONTEXT_HEADER", "MemoryService"]
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Owner model grants and per-organization policy.
|
||||
|
||||
Owned by Agent 1. This is the real ``ModelGrantPort`` implementation (see
|
||||
``container_org.build_model_grants``): the engine asks "which models may this
|
||||
organization use?" and gets a *neutral* answer — a tier (``fast`` /
|
||||
``balanced`` / ``strong`` ≈ «سریع» / «متعادل» / «قوی») and the capability list,
|
||||
never a model name. Model names stay an owner-only concept, visible in the
|
||||
admin grant matrix and nowhere else.
|
||||
|
||||
Two independent switches have to agree before a model is usable:
|
||||
|
||||
* the model itself is active in the catalogue (``ai_models.is_active``), and
|
||||
* the owner granted it to *this* organization (``org_model_grants.is_active``).
|
||||
|
||||
Either one being false means "not granted". That is what makes revoking a model
|
||||
from one organization a real revocation while other organizations keep working.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.ports.model_grants import GrantedModel, tier_for
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.infrastructure.db.models import AiModelRow
|
||||
from app.infrastructure.repositories.org_grants import (
|
||||
DefaultGrantRecord,
|
||||
DefaultModelGrantRepository,
|
||||
OrgGrantRecord,
|
||||
OrgModelGrantRepository,
|
||||
OrgPolicyRecord,
|
||||
OrgPolicyRepository,
|
||||
)
|
||||
|
||||
#: The only vocabulary an organization's users ever see for a model.
|
||||
TIER_LABELS: dict[str, str] = {
|
||||
"fast": "سریع",
|
||||
"balanced": "متعادل",
|
||||
"strong": "قوی",
|
||||
}
|
||||
|
||||
#: Policy defaults for an organization that has never been configured. Kept in
|
||||
#: code (not only in the row default) so ``get_policy`` can answer for any org
|
||||
#: without writing a row first.
|
||||
DEFAULT_MAX_EMPLOYEES = 5
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ModelView:
|
||||
"""One catalogue model as the owner sees it, plus its grant state for an org."""
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
provider_id: str
|
||||
capabilities: list[str] = field(default_factory=list)
|
||||
tier: str = "balanced"
|
||||
tier_label: str = "متعادل"
|
||||
output_price_per_1m: float = 0.0
|
||||
model_active: bool = True
|
||||
granted: bool = False
|
||||
daily_credit_cap: float | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OrgGrantView:
|
||||
ai_model_id: str
|
||||
label: str
|
||||
tier: str
|
||||
tier_label: str
|
||||
capabilities: list[str]
|
||||
daily_credit_cap: float | None
|
||||
is_active: bool
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class PolicyView:
|
||||
organization_id: str
|
||||
max_employees: int
|
||||
monthly_credit_budget: float | None
|
||||
automation_enabled: bool
|
||||
mcp_enabled: bool
|
||||
max_employees_limit: int
|
||||
|
||||
|
||||
def _tier_of(row: AiModelRow) -> str:
|
||||
return tier_for(
|
||||
capabilities=list(row.capabilities or []),
|
||||
output_price_per_1m=float(row.output_price_per_1m or 0.0),
|
||||
)
|
||||
|
||||
|
||||
def _tier_label(tier: str) -> str:
|
||||
return TIER_LABELS.get(tier, TIER_LABELS["balanced"])
|
||||
|
||||
|
||||
class ModelGrantService:
|
||||
"""Grants, default grant set and org policy — all in one place."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session: AsyncSession,
|
||||
grants: OrgModelGrantRepository,
|
||||
defaults: DefaultModelGrantRepository,
|
||||
policies: OrgPolicyRepository,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.grants = grants
|
||||
self.defaults = defaults
|
||||
self.policies = policies
|
||||
|
||||
# -- catalogue ----------------------------------------------------------
|
||||
|
||||
async def _model(self, ai_model_id: str) -> AiModelRow:
|
||||
"""The catalogue row, or a 404. Never returns an inactive model's row.
|
||||
|
||||
Inactive means "the owner retired it": granting it would be meaningless,
|
||||
so it is reported as missing rather than as a validation error.
|
||||
"""
|
||||
row = await self.session.scalar(
|
||||
select(AiModelRow).where(
|
||||
AiModelRow.id == ai_model_id, AiModelRow.is_active
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
raise NotFoundError("مدل مورد نظر یافت نشد.")
|
||||
return row
|
||||
|
||||
async def list_models(self, *, organization_id: str) -> list[ModelView]:
|
||||
"""The full grant matrix for one organization (owner view)."""
|
||||
models = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
|
||||
)
|
||||
).scalars().all()
|
||||
active_grants = {
|
||||
g.ai_model_id: g
|
||||
for g in await self.grants.list_for_org(organization_id, active_only=True)
|
||||
}
|
||||
return [
|
||||
self._view(row, active_grants.get(row.id)) for row in models
|
||||
]
|
||||
|
||||
def _view(self, row: AiModelRow, grant: OrgGrantRecord | None) -> ModelView:
|
||||
tier = _tier_of(row)
|
||||
return ModelView(
|
||||
ai_model_id=row.id,
|
||||
label=row.display_name_for_admin or row.internal_name,
|
||||
provider_id=row.provider_id,
|
||||
capabilities=list(row.capabilities or []),
|
||||
tier=tier,
|
||||
tier_label=_tier_label(tier),
|
||||
output_price_per_1m=float(row.output_price_per_1m or 0.0),
|
||||
model_active=bool(row.is_active),
|
||||
granted=grant is not None,
|
||||
daily_credit_cap=grant.daily_credit_cap if grant else None,
|
||||
)
|
||||
|
||||
# -- ModelGrantPort -----------------------------------------------------
|
||||
|
||||
async def granted_models(self, *, organization_id: str) -> list[GrantedModel]:
|
||||
"""Active models with an active grant for this organization."""
|
||||
grants = await self.grants.list_for_org(organization_id, active_only=True)
|
||||
if not grants:
|
||||
return []
|
||||
by_id = {g.ai_model_id: g for g in grants}
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(
|
||||
AiModelRow.is_active, AiModelRow.id.in_(list(by_id))
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
granted = [
|
||||
GrantedModel(
|
||||
ai_model_id=row.id,
|
||||
tier=_tier_of(row),
|
||||
capabilities=tuple(row.capabilities or []),
|
||||
daily_credit_cap=by_id[row.id].daily_credit_cap,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
# Cheapest first: a caller that picks the head of the list gets the
|
||||
# economical model, which is the sane default for bulk work.
|
||||
granted.sort(key=lambda m: m.daily_credit_cap if m.daily_credit_cap is not None else -1)
|
||||
return granted
|
||||
|
||||
async def is_granted(self, *, organization_id: str, ai_model_id: str) -> bool:
|
||||
grant = await self.grants.get(organization_id, ai_model_id)
|
||||
if grant is None or not grant.is_active:
|
||||
return False
|
||||
exists = await self.session.scalar(
|
||||
select(AiModelRow.id).where(
|
||||
AiModelRow.id == ai_model_id, AiModelRow.is_active
|
||||
)
|
||||
)
|
||||
return exists is not None
|
||||
|
||||
# -- grants -------------------------------------------------------------
|
||||
|
||||
async def list_grants(
|
||||
self, *, organization_id: str, active_only: bool = False
|
||||
) -> list[OrgGrantView]:
|
||||
records = await self.grants.list_for_org(organization_id, active_only=active_only)
|
||||
if not records:
|
||||
return []
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(
|
||||
AiModelRow.id.in_([r.ai_model_id for r in records])
|
||||
)
|
||||
)
|
||||
).scalars().all()
|
||||
by_id = {row.id: row for row in rows}
|
||||
views: list[OrgGrantView] = []
|
||||
for record in records:
|
||||
row = by_id.get(record.ai_model_id)
|
||||
if row is None: # deleted from the catalogue: keep it out of the UI
|
||||
continue
|
||||
tier = _tier_of(row)
|
||||
views.append(
|
||||
OrgGrantView(
|
||||
ai_model_id=row.id,
|
||||
label=row.display_name_for_admin or row.internal_name,
|
||||
tier=tier,
|
||||
tier_label=_tier_label(tier),
|
||||
capabilities=list(row.capabilities or []),
|
||||
daily_credit_cap=record.daily_credit_cap,
|
||||
is_active=record.is_active,
|
||||
)
|
||||
)
|
||||
return views
|
||||
|
||||
@staticmethod
|
||||
def _validate_cap(daily_credit_cap: float | None) -> float | None:
|
||||
if daily_credit_cap is None:
|
||||
return None
|
||||
cap = float(daily_credit_cap)
|
||||
if cap < 0:
|
||||
raise ValidationError("سقف اعتبار روزانه نمیتواند منفی باشد.")
|
||||
return cap
|
||||
|
||||
async def grant_model(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None = None,
|
||||
actor_user_id: str | None = None,
|
||||
) -> OrgGrantView:
|
||||
await self._model(ai_model_id)
|
||||
cap = self._validate_cap(daily_credit_cap)
|
||||
await self.grants.upsert(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=cap,
|
||||
is_active=True,
|
||||
created_by_user_id=actor_user_id,
|
||||
)
|
||||
views = await self.list_grants(organization_id=organization_id, active_only=True)
|
||||
return next(v for v in views if v.ai_model_id == ai_model_id)
|
||||
|
||||
async def revoke_model(
|
||||
self, *, organization_id: str, ai_model_id: str, actor_user_id: str | None = None
|
||||
) -> None:
|
||||
"""Revoke by deactivating, never by deleting.
|
||||
|
||||
The row is kept so the history ("was granted, then revoked") survives
|
||||
and re-granting is idempotent instead of racing a unique constraint.
|
||||
"""
|
||||
existing = await self.grants.get(organization_id, ai_model_id)
|
||||
if existing is None or not existing.is_active:
|
||||
raise NotFoundError("این مدل برای سازمان فعال نیست.")
|
||||
await self.grants.upsert(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=existing.daily_credit_cap,
|
||||
is_active=False,
|
||||
created_by_user_id=actor_user_id or existing.created_by_user_id,
|
||||
)
|
||||
|
||||
async def set_grant_cap(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None,
|
||||
actor_user_id: str | None = None,
|
||||
) -> OrgGrantView:
|
||||
existing = await self.grants.get(organization_id, ai_model_id)
|
||||
if existing is None or not existing.is_active:
|
||||
raise NotFoundError("این مدل برای سازمان فعال نیست.")
|
||||
return await self.grant_model(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=daily_credit_cap,
|
||||
actor_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
# -- default grant set --------------------------------------------------
|
||||
|
||||
async def list_defaults(self) -> list[ModelView]:
|
||||
models = (
|
||||
await self.session.execute(
|
||||
select(AiModelRow).where(AiModelRow.is_active).order_by(AiModelRow.id)
|
||||
)
|
||||
).scalars().all()
|
||||
active = {d.ai_model_id: d for d in await self.defaults.list_active()}
|
||||
return [self._view(row, _as_grant(active.get(row.id))) for row in models]
|
||||
|
||||
async def set_default(
|
||||
self,
|
||||
*,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None = None,
|
||||
is_active: bool = True,
|
||||
actor_user_id: str | None = None,
|
||||
) -> None:
|
||||
await self._model(ai_model_id)
|
||||
await self.defaults.upsert(
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=self._validate_cap(daily_credit_cap),
|
||||
is_active=is_active,
|
||||
created_by_user_id=actor_user_id,
|
||||
)
|
||||
|
||||
async def remove_default(self, *, ai_model_id: str) -> None:
|
||||
if not await self.defaults.delete(ai_model_id):
|
||||
raise NotFoundError("این مدل در مجموعهٔ پیشفرض نیست.")
|
||||
|
||||
async def copy_defaults_into(
|
||||
self, *, organization_id: str, actor_user_id: str | None = None
|
||||
) -> int:
|
||||
"""Materialise the default set into a new organization. Returns the count.
|
||||
|
||||
Only the *active* defaults are copied: a default the owner switched off
|
||||
must not silently reappear in the next organization created.
|
||||
"""
|
||||
copied = 0
|
||||
for record in await self.defaults.list_active():
|
||||
await self.grants.upsert(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=record.ai_model_id,
|
||||
daily_credit_cap=record.daily_credit_cap,
|
||||
is_active=True,
|
||||
created_by_user_id=actor_user_id,
|
||||
)
|
||||
copied += 1
|
||||
return copied
|
||||
|
||||
# -- org policy ---------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _policy_view(record: OrgPolicyRecord | None, organization_id: str) -> PolicyView:
|
||||
limit = max(1, int(settings.engine_max_employees))
|
||||
if record is None:
|
||||
return PolicyView(
|
||||
organization_id=organization_id,
|
||||
max_employees=min(DEFAULT_MAX_EMPLOYEES, limit),
|
||||
monthly_credit_budget=None,
|
||||
automation_enabled=True,
|
||||
mcp_enabled=True,
|
||||
max_employees_limit=limit,
|
||||
)
|
||||
return PolicyView(
|
||||
organization_id=organization_id,
|
||||
max_employees=min(int(record.max_employees), limit),
|
||||
monthly_credit_budget=record.monthly_credit_budget,
|
||||
automation_enabled=bool(record.automation_enabled),
|
||||
mcp_enabled=bool(record.mcp_enabled),
|
||||
max_employees_limit=limit,
|
||||
)
|
||||
|
||||
async def get_org_policy(self, organization_id: str) -> PolicyView:
|
||||
"""Readable by anyone who may manage the org (Agent 2 reads this later)."""
|
||||
return self._policy_view(await self.policies.get(organization_id), organization_id)
|
||||
|
||||
async def set_org_policy(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
max_employees: int | None = None,
|
||||
monthly_credit_budget: float | None = None,
|
||||
automation_enabled: bool | None = None,
|
||||
mcp_enabled: bool | None = None,
|
||||
actor_user_id: str | None = None,
|
||||
) -> PolicyView:
|
||||
current = await self.get_org_policy(organization_id)
|
||||
employees = current.max_employees if max_employees is None else int(max_employees)
|
||||
if employees < 1:
|
||||
raise ValidationError("حداقل تعداد پرسنل یک است.")
|
||||
limit = current.max_employees_limit
|
||||
if employees > limit:
|
||||
raise ValidationError(
|
||||
f"حداکثر تعداد پرسنل نمیتواند از سقف سامانه ({limit}) بیشتر باشد."
|
||||
)
|
||||
budget = (
|
||||
current.monthly_credit_budget
|
||||
if monthly_credit_budget is None
|
||||
else float(monthly_credit_budget)
|
||||
)
|
||||
if budget is not None and budget < 0:
|
||||
raise ValidationError("بودجهٔ ماهانه نمیتواند منفی باشد.")
|
||||
automation = (
|
||||
current.automation_enabled
|
||||
if automation_enabled is None
|
||||
else bool(automation_enabled)
|
||||
)
|
||||
mcp = current.mcp_enabled if mcp_enabled is None else bool(mcp_enabled)
|
||||
record = await self.policies.upsert(
|
||||
organization_id=organization_id,
|
||||
max_employees=employees,
|
||||
monthly_credit_budget=budget,
|
||||
automation_enabled=automation,
|
||||
mcp_enabled=mcp,
|
||||
)
|
||||
return self._policy_view(record, organization_id)
|
||||
|
||||
async def set_mcp_enabled(
|
||||
self, *, organization_id: str, enabled: bool, actor_user_id: str | None = None
|
||||
) -> PolicyView:
|
||||
"""Owner switch for the MCP gateway (Phase 5)."""
|
||||
return await self.set_org_policy(
|
||||
organization_id=organization_id, mcp_enabled=enabled, actor_user_id=actor_user_id
|
||||
)
|
||||
|
||||
async def mcp_enabled(self, organization_id: str) -> bool:
|
||||
return (await self.get_org_policy(organization_id)).mcp_enabled
|
||||
|
||||
|
||||
def _as_grant(record: DefaultGrantRecord | None) -> OrgGrantRecord | None:
|
||||
"""Default rows reuse the org-grant view; only the shape matters here."""
|
||||
if record is None:
|
||||
return None
|
||||
return OrgGrantRecord(
|
||||
id=record.id,
|
||||
organization_id="",
|
||||
ai_model_id=record.ai_model_id,
|
||||
daily_credit_cap=record.daily_credit_cap,
|
||||
is_active=record.is_active,
|
||||
created_by_user_id=record.created_by_user_id,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_EMPLOYEES",
|
||||
"ModelGrantService",
|
||||
"ModelView",
|
||||
"OrgGrantView",
|
||||
"PolicyView",
|
||||
"TIER_LABELS",
|
||||
]
|
||||
@@ -243,6 +243,9 @@ class OrgSetupService:
|
||||
if payload.deploy_target_kind:
|
||||
deploy_target_id = await self._setup_deploy_target(org.id, project_row.id, payload)
|
||||
|
||||
# -- default model grants (owner-provided, Phase 3) -------------------
|
||||
await self._copy_default_grants(org.id, user.id)
|
||||
|
||||
await self.session.flush()
|
||||
|
||||
# -- commit so hooks see a consistent world --------------------------
|
||||
@@ -377,6 +380,30 @@ class OrgSetupService:
|
||||
row = await self.deploys.upsert(values=values)
|
||||
return str(row.id)
|
||||
|
||||
async def _copy_default_grants(self, organization_id: str, user_id: str) -> int:
|
||||
"""Copy the platform owner's default grant set into the new organization.
|
||||
|
||||
Runs inside the creation transaction on purpose: an organization that
|
||||
exists without its grants would be an organization whose AI staff cannot
|
||||
think. An empty default set is legitimate (the owner granted nothing).
|
||||
"""
|
||||
from app.application.services.model_grant_service import ModelGrantService
|
||||
from app.infrastructure.repositories.org_grants import (
|
||||
DefaultModelGrantRepository,
|
||||
OrgModelGrantRepository,
|
||||
OrgPolicyRepository,
|
||||
)
|
||||
|
||||
service = ModelGrantService(
|
||||
session=self.session,
|
||||
grants=OrgModelGrantRepository(self.session),
|
||||
defaults=DefaultModelGrantRepository(self.session),
|
||||
policies=OrgPolicyRepository(self.session),
|
||||
)
|
||||
return await service.copy_defaults_into(
|
||||
organization_id=organization_id, actor_user_id=user_id
|
||||
)
|
||||
|
||||
async def _call_hooks(self, *, organization_id: str, project_id: str) -> None:
|
||||
if self.workforce_bootstrap:
|
||||
try:
|
||||
|
||||
@@ -130,7 +130,14 @@ class Settings(BaseSettings):
|
||||
# >>> agent-1 (org platform): add settings ONLY between these markers
|
||||
mcp_gateway_host: str = "127.0.0.1"
|
||||
mcp_gateway_port: int = 8765
|
||||
#: The owner's kill switch for the whole gateway (Phase 5). The per-org
|
||||
#: switch lives in ``org_policies.mcp_enabled``.
|
||||
mcp_gateway_enabled: bool = True
|
||||
mcp_token_ttl_days: int = 90
|
||||
#: Calls one token may make per minute. Enforced in memory, per gateway
|
||||
#: process — a restart forgives, which is the right trade for a spare
|
||||
#: gateway process that only the owner's own agents talk to.
|
||||
mcp_rate_limit_per_minute: int = 60
|
||||
memory_org_char_cap: int = 60000
|
||||
platform_invite_ttl_hours: int = 72
|
||||
# <<< agent-1
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Audit action names introduced by the org-platform workstream.
|
||||
|
||||
Kept in the identity domain instead of the shared ``app.domain.audit`` module so
|
||||
the parallel workstreams never edit the same file (``app.domain.audit`` is
|
||||
foundation-owned). Values are free-form strings — ``AuditAction`` accepts any.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# --- platform invites (owner creates a new manager account) ----------------
|
||||
PLATFORM_INVITE_CREATE = "platform_invite.create"
|
||||
PLATFORM_INVITE_REVOKE = "platform_invite.revoke"
|
||||
PLATFORM_INVITE_ACCEPT = "platform_invite.accept"
|
||||
|
||||
# --- org sharing -----------------------------------------------------------
|
||||
ORG_TRANSFER_OWNERSHIP = "organization.transfer_ownership"
|
||||
ORG_LEAVE = "organization.leave"
|
||||
ORG_INVITE_REVOKE = "member.invite_revoke"
|
||||
|
||||
# --- wizard ----------------------------------------------------------------
|
||||
ORG_CREATED_VIA_WIZARD = "organization.created_via_wizard"
|
||||
APP_PROFILE_UPDATE = "app_profile.update"
|
||||
APP_GIT_ROTATE = "app_git.rotate"
|
||||
APP_DEPLOY_TARGET_SET = "app_deploy_target.set"
|
||||
|
||||
# --- owner model grants and org policy -------------------------------------
|
||||
MODEL_GRANT_SET = "model_grant.set"
|
||||
MODEL_GRANT_REVOKE = "model_grant.revoke"
|
||||
DEFAULT_MODEL_GRANT_SET = "default_model_grant.set"
|
||||
DEFAULT_MODEL_GRANT_REMOVE = "default_model_grant.remove"
|
||||
ORG_POLICY_UPDATE = "org_policy.update"
|
||||
|
||||
# --- org memory ------------------------------------------------------------
|
||||
MEMORY_CREATE = "memory.create"
|
||||
MEMORY_UPDATE = "memory.update"
|
||||
MEMORY_PIN = "memory.pin"
|
||||
MEMORY_CONFIRM = "memory.confirm"
|
||||
MEMORY_DELETE = "memory.delete"
|
||||
|
||||
# --- MCP gateway -----------------------------------------------------------
|
||||
MCP_TOKEN_CREATE = "mcp_token.create"
|
||||
MCP_TOKEN_REVOKE = "mcp_token.revoke"
|
||||
MCP_GLOBAL_SWITCH = "mcp.global_switch"
|
||||
MCP_ORG_SWITCH = "mcp.org_switch"
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Org memory domain package (owned by Agent 1)."""
|
||||
|
||||
from app.domain.memory.models import (
|
||||
CONFIRMED_CONFIDENCE,
|
||||
CONTENT_MAX_CHARS,
|
||||
KEY_MAX_CHARS,
|
||||
KIND_LABELS,
|
||||
LESSON_TTL_DAYS,
|
||||
MAX_WRITES_PER_TASK,
|
||||
PREFERENCE_ROLES,
|
||||
REFLECTION_CONFIDENCE,
|
||||
SOURCE_LABELS,
|
||||
STRUCTURAL_KINDS,
|
||||
STRUCTURAL_ROLES,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
MemorySource,
|
||||
coerce_kind,
|
||||
coerce_source,
|
||||
content_hash,
|
||||
lesson_expiry,
|
||||
normalise,
|
||||
relevance,
|
||||
tokenise,
|
||||
validate_confidence,
|
||||
validate_content,
|
||||
validate_key,
|
||||
visible_kinds,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CONFIRMED_CONFIDENCE",
|
||||
"CONTENT_MAX_CHARS",
|
||||
"KEY_MAX_CHARS",
|
||||
"KIND_LABELS",
|
||||
"LESSON_TTL_DAYS",
|
||||
"MAX_WRITES_PER_TASK",
|
||||
"Memory",
|
||||
"MemoryKind",
|
||||
"MemorySource",
|
||||
"PREFERENCE_ROLES",
|
||||
"REFLECTION_CONFIDENCE",
|
||||
"SOURCE_LABELS",
|
||||
"STRUCTURAL_KINDS",
|
||||
"STRUCTURAL_ROLES",
|
||||
"coerce_kind",
|
||||
"coerce_source",
|
||||
"content_hash",
|
||||
"lesson_expiry",
|
||||
"normalise",
|
||||
"relevance",
|
||||
"tokenise",
|
||||
"validate_confidence",
|
||||
"validate_content",
|
||||
"validate_key",
|
||||
"visible_kinds",
|
||||
]
|
||||
@@ -0,0 +1,315 @@
|
||||
"""Org memory domain: the rules of what an organization may remember.
|
||||
|
||||
Pure Python — no FastAPI, no SQLAlchemy. Three concerns live here and nowhere
|
||||
else:
|
||||
|
||||
* **The vocabulary.** ``MemoryKind`` and ``MemorySource`` are the only two
|
||||
taxonomies the platform has. An org's memory is *small* on purpose: a handful
|
||||
of standing rules, a few preferences, the lessons a finished task leaves
|
||||
behind, and a code map of the one app the org owns.
|
||||
* **The limits.** A key is 80 characters, an entry 1,500. Anything longer is a
|
||||
document, and documents belong in the Knowledge Base (``knowledge.py``), which
|
||||
is why the memory page shows that second tab rather than re-implementing it.
|
||||
* **The shape of an entry.** ``normalise`` + ``content_hash`` turn "the same
|
||||
thought typed twice" into one stored memory, which is what keeps an org's
|
||||
memory from becoming a duplicate pile.
|
||||
|
||||
Nothing here touches the database: the repository (``repositories/memory.py``)
|
||||
moves rows, and the service (``services/memory_service.py``) decides what may be
|
||||
written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import unicodedata
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
|
||||
|
||||
class MemoryKind(StrEnum):
|
||||
"""What a memory *is*, which decides who may read it and how long it lives."""
|
||||
|
||||
#: A standing instruction from the org: "always write tests first".
|
||||
RULE = "rule"
|
||||
#: A taste the org expressed: "prefer Persian comments".
|
||||
PREFERENCE = "preference"
|
||||
#: Something a finished task taught the org. Expires, because a lesson that
|
||||
#: was true once is not a law.
|
||||
LESSON = "lesson"
|
||||
#: A durable fact about the app or the organisation ("the API runs on 8000").
|
||||
FACT = "fact"
|
||||
#: A short map of the codebase, rewritten as the code changes.
|
||||
CODE_MAP = "code_map"
|
||||
|
||||
|
||||
class MemorySource(StrEnum):
|
||||
"""Who wrote it. ``reflection`` means the org's own self-review did."""
|
||||
|
||||
USER = "user"
|
||||
MANAGER = "manager"
|
||||
SYSTEM = "system"
|
||||
REFLECTION = "reflection"
|
||||
|
||||
|
||||
#: Hard limits, enforced here so a router, an MCP tool and the reflection writer
|
||||
#: all share one definition of "too big".
|
||||
KEY_MAX_CHARS = 80
|
||||
CONTENT_MAX_CHARS = 1500
|
||||
|
||||
#: How long a lesson stays usable. ``30`` days is long enough to outlive the
|
||||
#: task that produced it and short enough that stale advice dies quietly.
|
||||
LESSON_TTL_DAYS = 30
|
||||
|
||||
#: One task may teach at most this many memories. Without a ceiling a single
|
||||
#: runaway reflection could fill the org's whole budget with one task's opinion.
|
||||
MAX_WRITES_PER_TASK = 3
|
||||
|
||||
#: A reflection's own confidence: it observed, it did not verify.
|
||||
REFLECTION_CONFIDENCE = 0.5
|
||||
#: A confirmed lesson is treated as settled knowledge and stops expiring.
|
||||
CONFIRMED_CONFIDENCE = 1.0
|
||||
|
||||
#: Kinds that describe the *code*: only the roles that read code get them.
|
||||
STRUCTURAL_KINDS = frozenset({MemoryKind.CODE_MAP, MemoryKind.FACT})
|
||||
#: ``programmer`` is the plan's spelling of the engine's ``developer`` role key;
|
||||
#: both are accepted so a caller cannot silently lose the code map.
|
||||
STRUCTURAL_ROLES = frozenset(
|
||||
{"planner", "developer", "programmer", "reviewer", "tester"}
|
||||
)
|
||||
#: Preferences are the manager's business — the employee who interprets them.
|
||||
PREFERENCE_ROLES = frozenset({"manager"})
|
||||
|
||||
#: One-line Persian labels for the context block and the UI.
|
||||
KIND_LABELS: dict[str, str] = {
|
||||
MemoryKind.RULE: "قاعده",
|
||||
MemoryKind.PREFERENCE: "ترجیح",
|
||||
MemoryKind.LESSON: "درس",
|
||||
MemoryKind.FACT: "واقعیت",
|
||||
MemoryKind.CODE_MAP: "نقشهٔ کد",
|
||||
}
|
||||
|
||||
SOURCE_LABELS: dict[str, str] = {
|
||||
MemorySource.USER: "کاربر",
|
||||
MemorySource.MANAGER: "مدیر",
|
||||
MemorySource.SYSTEM: "سامانه",
|
||||
MemorySource.REFLECTION: "بازتاب",
|
||||
}
|
||||
|
||||
_WHITESPACE = re.compile(r"\s+")
|
||||
#: Words that carry no meaning in either language; matching against them would
|
||||
#: make every memory "relevant" to every question.
|
||||
_STOP_WORDS = frozenset(
|
||||
{
|
||||
"the", "a", "an", "and", "or", "but", "if", "then", "else", "for", "with",
|
||||
"without", "from", "into", "that", "this", "these", "those", "is", "are",
|
||||
"was", "were", "be", "been", "to", "of", "in", "on", "at", "by", "as",
|
||||
"it", "its", "do", "does", "did", "not", "no", "so", "we", "you", "use",
|
||||
"using", "should", "must", "را", "که", "از", "به", "با", "این", "آن",
|
||||
"و", "در", "برای", "تا", "بر", "شود", "است", "هست", "می",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class Memory:
|
||||
"""One thing the organization knows. ``id`` and timestamps are domain-owned."""
|
||||
|
||||
organization_id: str
|
||||
kind: MemoryKind
|
||||
content: str
|
||||
source: MemorySource = MemorySource.USER
|
||||
id: str = field(default_factory=new_id)
|
||||
#: Optional stable handle: the same key always addresses the same memory,
|
||||
#: which is how "our port is 8000" is corrected instead of duplicated.
|
||||
key: str | None = None
|
||||
#: The task that produced a lesson, when it came from a reflection.
|
||||
evidence_task_id: str | None = None
|
||||
pinned: bool = False
|
||||
confidence: float = CONFIRMED_CONFIDENCE
|
||||
expires_at: datetime | None = None
|
||||
hits: int = 0
|
||||
last_used_at: datetime | None = None
|
||||
created_by_user_id: str | None = None
|
||||
created_at: datetime = field(default_factory=utcnow)
|
||||
updated_at: datetime = field(default_factory=utcnow)
|
||||
deleted_at: datetime | None = None
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return KIND_LABELS.get(str(self.kind), "یادداشت")
|
||||
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
"""Always the digest of the *current* content.
|
||||
|
||||
A property, not a column the writer has to remember to refresh: a memory
|
||||
whose text changed must dedupe as the new text, never as the old one.
|
||||
"""
|
||||
return content_hash(self.content)
|
||||
|
||||
def is_live(self, *, now: datetime | None = None) -> bool:
|
||||
"""Not deleted, not expired. Pinned entries never expire."""
|
||||
if self.deleted_at is not None:
|
||||
return False
|
||||
if self.pinned or self.expires_at is None:
|
||||
return True
|
||||
return self.expires_at > (now or utcnow())
|
||||
|
||||
def is_confirmed(self) -> bool:
|
||||
return self.confidence >= CONFIRMED_CONFIDENCE
|
||||
|
||||
|
||||
def normalise(content: str) -> str:
|
||||
"""Canonical form for comparison: NFC, whitespace collapsed, trimmed.
|
||||
|
||||
Two spellings of the same sentence («the api runs on 8000» / «The API runs
|
||||
on 8000») must dedupe, so the comparison is case-folded.
|
||||
"""
|
||||
text = unicodedata.normalize("NFC", content or "")
|
||||
return _WHITESPACE.sub(" ", text).strip()
|
||||
|
||||
|
||||
def content_hash(content: str) -> str:
|
||||
"""A stable short digest of the normalised content, for dedupe."""
|
||||
digest = hashlib.sha256(normalise(content).casefold().encode("utf-8"))
|
||||
return digest.hexdigest()[:32]
|
||||
|
||||
|
||||
def validate_key(key: str | None) -> str | None:
|
||||
"""``None`` (or empty) means "no key"; otherwise a short trimmed handle."""
|
||||
if key is None:
|
||||
return None
|
||||
trimmed = normalise(key)
|
||||
if not trimmed:
|
||||
return None
|
||||
if len(trimmed) > KEY_MAX_CHARS:
|
||||
raise ValidationError("کلید یادداشت نمیتواند بیش از ۸۰ نویسه باشد.")
|
||||
return trimmed
|
||||
|
||||
|
||||
def validate_content(content: str) -> str:
|
||||
cleaned = normalise(content)
|
||||
if not cleaned:
|
||||
raise ValidationError("محتوای یادداشت نمیتواند خالی باشد.")
|
||||
if len(cleaned) > CONTENT_MAX_CHARS:
|
||||
raise ValidationError("محتوای هر یادداشت نمیتواند بیش از ۱۵۰۰ نویسه باشد.")
|
||||
return cleaned
|
||||
|
||||
|
||||
def validate_confidence(value: float) -> float:
|
||||
try:
|
||||
confidence = float(value)
|
||||
except (TypeError, ValueError) as exc: # pragma: no cover - guarded by callers
|
||||
raise ValidationError("میزان اطمینان نامعتبر است.") from exc
|
||||
if confidence < 0 or confidence > 1:
|
||||
raise ValidationError("میزان اطمینان باید بین ۰ و ۱ باشد.")
|
||||
return round(confidence, 2)
|
||||
|
||||
|
||||
def coerce_kind(kind: str) -> MemoryKind:
|
||||
try:
|
||||
return MemoryKind(str(kind).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValidationError("نوع یادداشت نامعتبر است.") from exc
|
||||
|
||||
|
||||
def coerce_source(source: str) -> MemorySource:
|
||||
try:
|
||||
return MemorySource(str(source).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValidationError("منبع یادداشت نامعتبر است.") from exc
|
||||
|
||||
|
||||
def visible_kinds(role: str) -> frozenset[MemoryKind]:
|
||||
"""The kinds one employee role may be shown.
|
||||
|
||||
Rules and lessons are everyone's business. ``code_map`` and ``fact``
|
||||
describe the code, so only the roles that read code get them; preferences
|
||||
belong to the manager alone, because a preference is an instruction to
|
||||
interpret work, not a fact about it.
|
||||
"""
|
||||
role_key = str(role or "").strip().lower()
|
||||
kinds = {MemoryKind.RULE, MemoryKind.LESSON}
|
||||
if role_key in STRUCTURAL_ROLES:
|
||||
kinds.update(STRUCTURAL_KINDS)
|
||||
if role_key in PREFERENCE_ROLES:
|
||||
kinds.add(MemoryKind.PREFERENCE)
|
||||
return frozenset(kinds)
|
||||
|
||||
|
||||
def lesson_expiry(*, pinned: bool, confirmed: bool) -> datetime | None:
|
||||
"""Lessons expire; a pinned or confirmed lesson is treated as settled."""
|
||||
if pinned or confirmed:
|
||||
return None
|
||||
return utcnow() + timedelta(days=LESSON_TTL_DAYS)
|
||||
|
||||
|
||||
def tokenise(text: str) -> frozenset[str]:
|
||||
"""Lower-cased words of both scripts, stop words removed."""
|
||||
words = re.findall(r"[\w\u0600-\u06FF]+", (text or "").casefold())
|
||||
return frozenset(w for w in words if len(w) > 1 and w not in _STOP_WORDS)
|
||||
|
||||
|
||||
def relevance(memory: Memory, query_tokens: frozenset[str]) -> float:
|
||||
"""How well a memory answers a query: keyword overlap, then recency.
|
||||
|
||||
Deliberately simple and explainable. An embedding search is a Phase-X
|
||||
upgrade (the Knowledge Base already has a vector port to borrow); until an
|
||||
org's memory is large enough for that to matter, word overlap is the honest
|
||||
answer and it cannot surprise a reader.
|
||||
"""
|
||||
own = tokenise(memory.content)
|
||||
overlap = len(query_tokens & own) / len(query_tokens) if query_tokens and own else 0.0
|
||||
age_days = _age_days(memory.last_used_at or memory.created_at)
|
||||
recency = 1.0 / (1.0 + age_days / LESSON_TTL_DAYS)
|
||||
pinned_bonus = 0.1 if memory.pinned else 0.0
|
||||
return round(overlap * 3.0 + recency + pinned_bonus, 4)
|
||||
|
||||
|
||||
def _age_days(when: datetime | None) -> float:
|
||||
"""Days since ``when``, treating a naive timestamp as UTC.
|
||||
|
||||
SQLite (the development and test store) hands back naive datetimes; a
|
||||
recency score is not the place to discover that. A missing timestamp means
|
||||
"very old", not "brand new".
|
||||
"""
|
||||
if when is None:
|
||||
return LESSON_TTL_DAYS * 30
|
||||
if when.tzinfo is None:
|
||||
when = when.replace(tzinfo=UTC)
|
||||
return max(0.0, (utcnow() - when).days)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CONFIRMED_CONFIDENCE",
|
||||
"CONTENT_MAX_CHARS",
|
||||
"KEY_MAX_CHARS",
|
||||
"KIND_LABELS",
|
||||
"LESSON_TTL_DAYS",
|
||||
"MAX_WRITES_PER_TASK",
|
||||
"Memory",
|
||||
"MemoryKind",
|
||||
"MemorySource",
|
||||
"PREFERENCE_ROLES",
|
||||
"REFLECTION_CONFIDENCE",
|
||||
"SOURCE_LABELS",
|
||||
"STRUCTURAL_KINDS",
|
||||
"STRUCTURAL_ROLES",
|
||||
"coerce_kind",
|
||||
"coerce_source",
|
||||
"content_hash",
|
||||
"lesson_expiry",
|
||||
"normalise",
|
||||
"relevance",
|
||||
"tokenise",
|
||||
"validate_confidence",
|
||||
"validate_content",
|
||||
"validate_key",
|
||||
"visible_kinds",
|
||||
]
|
||||
@@ -24,7 +24,17 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, Text
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -103,4 +113,139 @@ class OrgSetupDraftRow(Base):
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["OrgSetupDraftRow", "PlatformInviteRow"]
|
||||
class DefaultModelGrantRow(Base):
|
||||
"""The platform owner's default grant set, copied into every new organization.
|
||||
|
||||
Kept in a table of its own (never a column on ``ai_models``) so the model
|
||||
catalogue stays a pure, owner-managed list.
|
||||
"""
|
||||
|
||||
__tablename__ = "default_model_grants"
|
||||
__table_args__ = (UniqueConstraint("ai_model_id", name="uq_default_model_grant"),)
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
ai_model_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("ai_models.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
daily_credit_cap: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = created()
|
||||
|
||||
|
||||
class OrgPolicyRow(Base):
|
||||
"""Per-organization policy the owner sets: limits and automation switches."""
|
||||
|
||||
__tablename__ = "org_policies"
|
||||
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
max_employees: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
|
||||
#: ``None`` means "no explicit budget" (an empty/unlimited wallet view).
|
||||
monthly_credit_budget: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
automation_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
#: MCP access for this organization (Phase 5); the owner may switch it off.
|
||||
mcp_enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class OrgMemoryRow(Base):
|
||||
"""One thing the organization remembers about itself.
|
||||
|
||||
A memory is *small* by design (≤ 1,500 characters): longer material is a
|
||||
document and belongs in the Knowledge Base. ``content_hash`` is what makes
|
||||
"teach the same thing twice" a correction instead of a duplicate, and
|
||||
``key`` gives a rule a stable address so the engine can update it.
|
||||
"""
|
||||
|
||||
__tablename__ = "org_memories"
|
||||
__table_args__ = (
|
||||
Index("ix_org_memories_org_kind", "organization_id", "kind"),
|
||||
Index("ix_org_memories_org_key", "organization_id", "key"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
#: Optional stable handle, unique in spirit within one org.
|
||||
key: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
||||
content: Mapped[str] = mapped_column(String(1500), nullable=False)
|
||||
#: A digest of the normalised content — the dedupe key the domain defines.
|
||||
content_hash: Mapped[str] = mapped_column(String(32), nullable=False, default="")
|
||||
source: Mapped[str] = mapped_column(String(16), default="user", nullable=False)
|
||||
evidence_task_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
pinned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
confidence: Mapped[float] = mapped_column(Float, default=1.0, nullable=False)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
hits: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = created()
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
#: Soft delete: a memory an admin removed stays in the audit story.
|
||||
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class McpTokenRow(Base):
|
||||
"""A scoped token an external AI agent uses to call the MCP gateway.
|
||||
|
||||
The token itself is shown exactly once, at creation; only its hash and a
|
||||
six-character prefix (for display) are stored. Every call is attributed to
|
||||
that prefix in the audit trail, so a leaked token can be identified and
|
||||
revoked long before anyone can reuse it.
|
||||
"""
|
||||
|
||||
__tablename__ = "mcp_tokens"
|
||||
|
||||
id: Mapped[str] = pk()
|
||||
organization_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
token_hash: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
|
||||
#: First six characters, for display only. Never enough to authenticate.
|
||||
prefix: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
#: Comma-separated scopes; an empty string means "no scopes", never "all".
|
||||
scopes: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(
|
||||
ForeignKey("users.id"), nullable=True
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
last_used_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = created()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DefaultModelGrantRow",
|
||||
"McpTokenRow",
|
||||
"OrgMemoryRow",
|
||||
"OrgPolicyRow",
|
||||
"OrgSetupDraftRow",
|
||||
"PlatformInviteRow",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""Repositories for org memory (Agent 1).
|
||||
|
||||
One table, three access shapes:
|
||||
|
||||
* reads are always scoped to one ``organization_id`` — there is no "list all
|
||||
memories" call, because a memory from another tenant must be unreachable by
|
||||
construction rather than by a filter someone remembers to add;
|
||||
* dedupe asks the database for the entry it suspects is the same one (by hash,
|
||||
by key), so "teach it twice" becomes an update;
|
||||
* eviction asks for the oldest unpinned lessons, which is the only thing this
|
||||
table is allowed to throw away.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import String, cast, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.memory.models import Memory, MemoryKind, MemorySource
|
||||
from app.infrastructure.db.models_org import OrgMemoryRow
|
||||
|
||||
|
||||
def _to_domain(row: OrgMemoryRow) -> Memory:
|
||||
return Memory(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
kind=MemoryKind(row.kind),
|
||||
key=row.key,
|
||||
content=row.content,
|
||||
source=MemorySource(row.source),
|
||||
evidence_task_id=row.evidence_task_id,
|
||||
pinned=bool(row.pinned),
|
||||
confidence=float(row.confidence or 0.0),
|
||||
expires_at=row.expires_at,
|
||||
hits=int(row.hits or 0),
|
||||
last_used_at=row.last_used_at,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
created_at=row.created_at,
|
||||
updated_at=row.updated_at,
|
||||
deleted_at=row.deleted_at,
|
||||
)
|
||||
|
||||
|
||||
class MemoryRepository:
|
||||
"""Org memory persistence. Every method is tenant-scoped or keyed by id."""
|
||||
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
# -- reads ---------------------------------------------------------------
|
||||
|
||||
async def list_for_org(
|
||||
self,
|
||||
organization_id: str,
|
||||
*,
|
||||
kind: MemoryKind | None = None,
|
||||
search: str | None = None,
|
||||
include_deleted: bool = False,
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[Memory]:
|
||||
stmt = select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id
|
||||
)
|
||||
if not include_deleted:
|
||||
stmt = stmt.where(OrgMemoryRow.deleted_at.is_(None))
|
||||
if kind is not None:
|
||||
stmt = stmt.where(OrgMemoryRow.kind == str(kind))
|
||||
if search:
|
||||
# Literal LIKE, not user-written pattern: a memory search must not
|
||||
# be able to inject SQL. Parameterised by SQLAlchemy either way.
|
||||
needle = f"%{search.strip()}%"
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
OrgMemoryRow.content.like(needle),
|
||||
cast(OrgMemoryRow.key, String).like(needle),
|
||||
)
|
||||
)
|
||||
stmt = stmt.order_by(
|
||||
OrgMemoryRow.pinned.desc(), OrgMemoryRow.updated_at.desc()
|
||||
).limit(limit).offset(offset)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return [_to_domain(row) for row in rows]
|
||||
|
||||
async def get(self, organization_id: str, memory_id: str) -> Memory | None:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.id == memory_id,
|
||||
)
|
||||
)
|
||||
return _to_domain(row) if row else None
|
||||
|
||||
async def find_duplicate(
|
||||
self, organization_id: str, *, kind: MemoryKind, key: str | None, digest: str
|
||||
) -> Memory | None:
|
||||
"""The entry this write would duplicate: same key, or same content.
|
||||
|
||||
Matching on the key first is what turns "our port is 8000" into a
|
||||
correction of one memory instead of a second, similar memory.
|
||||
"""
|
||||
if key:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.key == key,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if row is not None:
|
||||
return _to_domain(row)
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.kind == str(kind),
|
||||
OrgMemoryRow.content_hash == digest,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
return _to_domain(row) if row else None
|
||||
|
||||
async def count_for_task(self, organization_id: str, evidence_task_id: str) -> int:
|
||||
return int(
|
||||
await self.session.scalar(
|
||||
select(func.count())
|
||||
.select_from(OrgMemoryRow)
|
||||
.where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.evidence_task_id == evidence_task_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
|
||||
async def total_chars(self, organization_id: str) -> int:
|
||||
"""Characters the org currently spends on live memory."""
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(func.length(OrgMemoryRow.content)).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
return int(sum(int(value or 0) for value in rows))
|
||||
|
||||
# -- writes --------------------------------------------------------------
|
||||
|
||||
async def create(self, memory: Memory) -> Memory:
|
||||
row = OrgMemoryRow(
|
||||
id=memory.id,
|
||||
organization_id=memory.organization_id,
|
||||
kind=str(memory.kind),
|
||||
key=memory.key,
|
||||
content=memory.content,
|
||||
content_hash=memory.content_hash,
|
||||
source=str(memory.source),
|
||||
evidence_task_id=memory.evidence_task_id,
|
||||
pinned=memory.pinned,
|
||||
confidence=memory.confidence,
|
||||
expires_at=memory.expires_at,
|
||||
hits=memory.hits,
|
||||
last_used_at=memory.last_used_at,
|
||||
created_by_user_id=memory.created_by_user_id,
|
||||
created_at=memory.created_at,
|
||||
updated_at=memory.updated_at,
|
||||
deleted_at=memory.deleted_at,
|
||||
)
|
||||
self.session.add(row)
|
||||
await self.session.flush()
|
||||
return memory
|
||||
|
||||
async def save(self, memory: Memory) -> Memory:
|
||||
"""Persist a mutated in-memory record, hash and timestamp included."""
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == memory.organization_id,
|
||||
OrgMemoryRow.id == memory.id,
|
||||
)
|
||||
)
|
||||
if row is None: # pragma: no cover - the service always loads first
|
||||
return await self.create(memory)
|
||||
row.kind = str(memory.kind)
|
||||
row.key = memory.key
|
||||
row.content = memory.content
|
||||
row.content_hash = memory.content_hash
|
||||
row.source = str(memory.source)
|
||||
row.evidence_task_id = memory.evidence_task_id
|
||||
row.pinned = memory.pinned
|
||||
row.confidence = memory.confidence
|
||||
row.expires_at = memory.expires_at
|
||||
row.hits = memory.hits
|
||||
row.last_used_at = memory.last_used_at
|
||||
row.deleted_at = memory.deleted_at
|
||||
row.updated_at = utcnow()
|
||||
await self.session.flush()
|
||||
return memory
|
||||
|
||||
async def mark_used(self, ids: list[str]) -> None:
|
||||
"""Count a ``build_context`` hit. One UPDATE, never one per memory."""
|
||||
if not ids:
|
||||
return
|
||||
await self.session.execute(
|
||||
update(OrgMemoryRow)
|
||||
.where(OrgMemoryRow.id.in_(ids))
|
||||
.values(hits=OrgMemoryRow.hits + 1, last_used_at=utcnow())
|
||||
)
|
||||
|
||||
async def soft_delete(self, organization_id: str, memory_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.id == memory_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
row.deleted_at = utcnow()
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
# -- maintenance ---------------------------------------------------------
|
||||
|
||||
async def expire(self, organization_id: str | None = None) -> int:
|
||||
"""Soft-delete expired lessons. Pinned entries never expire.
|
||||
|
||||
Called from ``expire_memories`` — which the plan wants exposed as a
|
||||
plain function because the worker that owns the schedule is not mine.
|
||||
"""
|
||||
stmt = select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
OrgMemoryRow.pinned.is_(False),
|
||||
OrgMemoryRow.expires_at.is_not(None),
|
||||
OrgMemoryRow.expires_at < utcnow(),
|
||||
)
|
||||
if organization_id:
|
||||
stmt = stmt.where(OrgMemoryRow.organization_id == organization_id)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
now = utcnow()
|
||||
for row in rows:
|
||||
row.deleted_at = now
|
||||
await self.session.flush()
|
||||
return len(rows)
|
||||
|
||||
async def evictable_lessons(
|
||||
self, organization_id: str, *, limit: int
|
||||
) -> list[Memory]:
|
||||
"""The oldest unpinned lessons — the only entries this table may drop."""
|
||||
rows = (
|
||||
await self.session.execute(
|
||||
select(OrgMemoryRow)
|
||||
.where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.deleted_at.is_(None),
|
||||
OrgMemoryRow.pinned.is_(False),
|
||||
OrgMemoryRow.kind == str(MemoryKind.LESSON),
|
||||
)
|
||||
.order_by(OrgMemoryRow.created_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
).scalars().all()
|
||||
return [_to_domain(row) for row in rows]
|
||||
|
||||
async def hard_delete(self, organization_id: str, memory_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(OrgMemoryRow).where(
|
||||
OrgMemoryRow.organization_id == organization_id,
|
||||
OrgMemoryRow.id == memory_id,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
|
||||
__all__ = ["MemoryRepository"]
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Repositories for owner-granted models and per-organization policy (Agent 1).
|
||||
|
||||
``org_model_grants`` is foundation-owned but has no repository elsewhere; these
|
||||
live in a file no other workstream edits (the local shim the plan allows).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.infrastructure.db.models import OrgModelGrantRow
|
||||
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class OrgGrantRecord:
|
||||
organization_id: str
|
||||
ai_model_id: str
|
||||
daily_credit_cap: float | None = None
|
||||
is_active: bool = True
|
||||
created_by_user_id: str | None = None
|
||||
id: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class DefaultGrantRecord:
|
||||
ai_model_id: str
|
||||
daily_credit_cap: float | None = None
|
||||
is_active: bool = True
|
||||
created_by_user_id: str | None = None
|
||||
id: str = ""
|
||||
|
||||
|
||||
@dataclass(slots=True, kw_only=True)
|
||||
class OrgPolicyRecord:
|
||||
organization_id: str
|
||||
max_employees: int = 5
|
||||
monthly_credit_budget: float | None = None
|
||||
automation_enabled: bool = True
|
||||
mcp_enabled: bool = True
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class OrgModelGrantRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def _to_record(row: OrgModelGrantRow) -> OrgGrantRecord:
|
||||
return OrgGrantRecord(
|
||||
id=row.id,
|
||||
organization_id=row.organization_id,
|
||||
ai_model_id=row.ai_model_id,
|
||||
daily_credit_cap=row.daily_credit_cap,
|
||||
is_active=row.is_active,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
)
|
||||
|
||||
async def get(self, organization_id: str, ai_model_id: str) -> OrgGrantRecord | None:
|
||||
stmt = select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id,
|
||||
OrgModelGrantRow.ai_model_id == ai_model_id,
|
||||
)
|
||||
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||
return self._to_record(row) if row else None
|
||||
|
||||
async def list_for_org(
|
||||
self, organization_id: str, *, active_only: bool = False
|
||||
) -> list[OrgGrantRecord]:
|
||||
stmt = select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id
|
||||
)
|
||||
if active_only:
|
||||
stmt = stmt.where(OrgModelGrantRow.is_active)
|
||||
rows = (await self.session.execute(stmt)).scalars().all()
|
||||
return [self._to_record(r) for r in rows]
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None,
|
||||
is_active: bool,
|
||||
created_by_user_id: str | None,
|
||||
) -> OrgGrantRecord:
|
||||
row = await self.session.scalar(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id,
|
||||
OrgModelGrantRow.ai_model_id == ai_model_id,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
row = OrgModelGrantRow(
|
||||
id=new_id(),
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=daily_credit_cap,
|
||||
is_active=is_active,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.daily_credit_cap = daily_credit_cap
|
||||
row.is_active = is_active
|
||||
await self.session.flush()
|
||||
return self._to_record(row)
|
||||
|
||||
async def delete(self, organization_id: str, ai_model_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == organization_id,
|
||||
OrgModelGrantRow.ai_model_id == ai_model_id,
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
|
||||
class DefaultModelGrantRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def _to_record(row: DefaultModelGrantRow) -> DefaultGrantRecord:
|
||||
return DefaultGrantRecord(
|
||||
id=row.id,
|
||||
ai_model_id=row.ai_model_id,
|
||||
daily_credit_cap=row.daily_credit_cap,
|
||||
is_active=row.is_active,
|
||||
created_by_user_id=row.created_by_user_id,
|
||||
)
|
||||
|
||||
async def list_all(self) -> list[DefaultGrantRecord]:
|
||||
rows = (await self.session.execute(select(DefaultModelGrantRow))).scalars().all()
|
||||
return [self._to_record(r) for r in rows]
|
||||
|
||||
async def list_active(self) -> list[DefaultGrantRecord]:
|
||||
stmt = select(DefaultModelGrantRow).where(DefaultModelGrantRow.is_active)
|
||||
return [self._to_record(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
ai_model_id: str,
|
||||
daily_credit_cap: float | None,
|
||||
is_active: bool,
|
||||
created_by_user_id: str | None,
|
||||
) -> DefaultGrantRecord:
|
||||
row = await self.session.scalar(
|
||||
select(DefaultModelGrantRow).where(
|
||||
DefaultModelGrantRow.ai_model_id == ai_model_id
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
row = DefaultModelGrantRow(
|
||||
id=new_id(),
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=daily_credit_cap,
|
||||
is_active=is_active,
|
||||
created_by_user_id=created_by_user_id,
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.daily_credit_cap = daily_credit_cap
|
||||
row.is_active = is_active
|
||||
await self.session.flush()
|
||||
return self._to_record(row)
|
||||
|
||||
async def delete(self, ai_model_id: str) -> bool:
|
||||
row = await self.session.scalar(
|
||||
select(DefaultModelGrantRow).where(
|
||||
DefaultModelGrantRow.ai_model_id == ai_model_id
|
||||
)
|
||||
)
|
||||
if row is None:
|
||||
return False
|
||||
await self.session.delete(row)
|
||||
await self.session.flush()
|
||||
return True
|
||||
|
||||
|
||||
class OrgPolicyRepository:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.session = session
|
||||
|
||||
@staticmethod
|
||||
def _to_record(row: OrgPolicyRow) -> OrgPolicyRecord:
|
||||
return OrgPolicyRecord(
|
||||
organization_id=row.organization_id,
|
||||
max_employees=row.max_employees,
|
||||
monthly_credit_budget=row.monthly_credit_budget,
|
||||
automation_enabled=row.automation_enabled,
|
||||
mcp_enabled=row.mcp_enabled,
|
||||
updated_at=row.updated_at,
|
||||
)
|
||||
|
||||
async def get(self, organization_id: str) -> OrgPolicyRecord | None:
|
||||
row = await self.session.get(OrgPolicyRow, organization_id)
|
||||
return self._to_record(row) if row else None
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
*,
|
||||
organization_id: str,
|
||||
max_employees: int,
|
||||
monthly_credit_budget: float | None,
|
||||
automation_enabled: bool,
|
||||
mcp_enabled: bool | None = None,
|
||||
) -> OrgPolicyRecord:
|
||||
row = await self.session.get(OrgPolicyRow, organization_id)
|
||||
if row is None:
|
||||
row = OrgPolicyRow(
|
||||
organization_id=organization_id,
|
||||
max_employees=max_employees,
|
||||
monthly_credit_budget=monthly_credit_budget,
|
||||
automation_enabled=automation_enabled,
|
||||
mcp_enabled=True if mcp_enabled is None else mcp_enabled,
|
||||
)
|
||||
self.session.add(row)
|
||||
else:
|
||||
row.max_employees = max_employees
|
||||
row.monthly_credit_budget = monthly_credit_budget
|
||||
row.automation_enabled = automation_enabled
|
||||
if mcp_enabled is not None:
|
||||
row.mcp_enabled = mcp_enabled
|
||||
row.updated_at = utcnow()
|
||||
await self.session.flush()
|
||||
return self._to_record(row)
|
||||
@@ -2,10 +2,358 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
The rule this router enforces: **an organization may only use the models its
|
||||
owner granted it.** The endpoints here are the only way a grant appears, so
|
||||
revoking one is immediate (``ModelGrantService.is_granted`` returns false on the
|
||||
next call) rather than a change that only takes effect after a cache expires.
|
||||
|
||||
Everything is owner-only, guarded at the router level, and every mutation is
|
||||
audited. The model *names* live here and nowhere else: organization users only
|
||||
ever see a neutral tier («سریع» / «متعادل» / «قوی»).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from dataclasses import asdict
|
||||
|
||||
router = APIRouter(tags=["admin-grants"])
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.model_grant_service import (
|
||||
ModelGrantService,
|
||||
ModelView,
|
||||
OrgGrantView,
|
||||
PolicyView,
|
||||
)
|
||||
from app.core.database import get_session
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.identity.audit_actions import (
|
||||
DEFAULT_MODEL_GRANT_REMOVE,
|
||||
DEFAULT_MODEL_GRANT_SET,
|
||||
MODEL_GRANT_REVOKE,
|
||||
MODEL_GRANT_SET,
|
||||
ORG_POLICY_UPDATE,
|
||||
)
|
||||
from app.domain.identity.models import User
|
||||
from app.interfaces.http.deps import client_meta, require_platform_owner
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/admin",
|
||||
tags=["admin-grants"],
|
||||
# Deny-by-default, exactly like the rest of the console.
|
||||
dependencies=[Depends(require_platform_owner)],
|
||||
)
|
||||
|
||||
|
||||
async def get_model_grant_service(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelGrantService:
|
||||
from app.application.container_org import build_model_grant_service
|
||||
|
||||
return build_model_grant_service(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class ModelViewOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
provider_id: str
|
||||
capabilities: list[str]
|
||||
tier: str
|
||||
tier_label: str
|
||||
output_price_per_1m: float
|
||||
model_active: bool
|
||||
granted: bool
|
||||
daily_credit_cap: float | None = None
|
||||
|
||||
|
||||
class OrgGrantOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
ai_model_id: str
|
||||
label: str
|
||||
tier: str
|
||||
tier_label: str
|
||||
capabilities: list[str]
|
||||
daily_credit_cap: float | None = None
|
||||
is_active: bool
|
||||
|
||||
|
||||
class OrgGrantsOut(BaseModel):
|
||||
"""The grant matrix for one organization: every active model + its state."""
|
||||
|
||||
organization_id: str
|
||||
models: list[ModelViewOut]
|
||||
|
||||
|
||||
class SetGrantRequest(BaseModel):
|
||||
#: ``None`` means "no per-model daily cap"; ``0`` means "granted but zeroed".
|
||||
daily_credit_cap: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class SetDefaultGrantRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
daily_credit_cap: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class PolicyOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
organization_id: str
|
||||
max_employees: int
|
||||
monthly_credit_budget: float | None = None
|
||||
automation_enabled: bool
|
||||
mcp_enabled: bool
|
||||
max_employees_limit: int
|
||||
|
||||
|
||||
class UpdatePolicyRequest(BaseModel):
|
||||
max_employees: int | None = Field(default=None, ge=1)
|
||||
monthly_credit_budget: float | None = Field(default=None, ge=0)
|
||||
automation_enabled: bool | None = None
|
||||
mcp_enabled: bool | None = None
|
||||
|
||||
|
||||
def _model_out(view: ModelView) -> ModelViewOut:
|
||||
return ModelViewOut(**asdict(view))
|
||||
|
||||
|
||||
def _grant_out(view: OrgGrantView) -> OrgGrantOut:
|
||||
return OrgGrantOut(**asdict(view))
|
||||
|
||||
|
||||
def _policy_out(view: PolicyView) -> PolicyOut:
|
||||
return PolicyOut(**asdict(view))
|
||||
|
||||
|
||||
def _require_change(*values: object) -> None:
|
||||
if all(value is None for value in values):
|
||||
raise ValidationError("هیچ تغییری ارسال نشده است.")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grants per organization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/model-grants/{organization_id}", response_model=OrgGrantsOut)
|
||||
async def list_org_grants(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> OrgGrantsOut:
|
||||
"""Every active catalogue model, flagged with whether this org may use it."""
|
||||
models = await service.list_models(organization_id=organization_id)
|
||||
return OrgGrantsOut(
|
||||
organization_id=organization_id, models=[_model_out(m) for m in models]
|
||||
)
|
||||
|
||||
|
||||
@router.put("/model-grants/{organization_id}/models/{ai_model_id}", response_model=OrgGrantOut)
|
||||
async def set_org_grant(
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
payload: SetGrantRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
# FastAPI caches ``get_session`` per request, so this is the very session
|
||||
# the service writes through: the audit row lands in the same transaction.
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> OrgGrantOut:
|
||||
"""Grant a model to an organization (idempotent) and/or set its daily cap."""
|
||||
granted = await service.grant_model(
|
||||
organization_id=organization_id,
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=payload.daily_credit_cap,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MODEL_GRANT_SET,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
# The model label is a name, not a secret, but keeping the audit rows
|
||||
# id-only makes them stable when the owner renames a model.
|
||||
metadata={"daily_credit_cap": payload.daily_credit_cap},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return _grant_out(granted)
|
||||
|
||||
|
||||
@router.get("/model-grants/{organization_id}/active", response_model=list[OrgGrantOut])
|
||||
async def list_active_org_grants(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> list[OrgGrantOut]:
|
||||
"""Just the active grants — what the engine will actually be allowed to use."""
|
||||
grants = await service.list_grants(organization_id=organization_id, active_only=True)
|
||||
return [_grant_out(g) for g in grants]
|
||||
|
||||
|
||||
@router.delete("/model-grants/{organization_id}/models/{ai_model_id}")
|
||||
async def revoke_org_grant(
|
||||
organization_id: str,
|
||||
ai_model_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Revoke a model from one organization. Other organizations are unaffected."""
|
||||
await service.revoke_model(
|
||||
organization_id=organization_id, ai_model_id=ai_model_id, actor_user_id=owner.id
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MODEL_GRANT_REVOKE,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"organization_id": organization_id, "ai_model_id": ai_model_id, "granted": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The default set copied into every new organization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/default-model-grants", response_model=list[ModelViewOut])
|
||||
async def list_default_grants(
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> list[ModelViewOut]:
|
||||
return [_model_out(m) for m in await service.list_defaults()]
|
||||
|
||||
|
||||
@router.put("/default-model-grants/{ai_model_id}", response_model=ModelViewOut)
|
||||
async def set_default_grant(
|
||||
ai_model_id: str,
|
||||
payload: SetDefaultGrantRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> ModelViewOut:
|
||||
"""Add a model to the default set (or switch its default off without losing it)."""
|
||||
await service.set_default(
|
||||
ai_model_id=ai_model_id,
|
||||
daily_credit_cap=payload.daily_credit_cap,
|
||||
is_active=payload.is_active,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=DEFAULT_MODEL_GRANT_SET,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
metadata={
|
||||
"is_active": payload.is_active,
|
||||
"daily_credit_cap": payload.daily_credit_cap,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
view = next(
|
||||
(m for m in await service.list_defaults() if m.ai_model_id == ai_model_id), None
|
||||
)
|
||||
assert view is not None
|
||||
return _model_out(view)
|
||||
|
||||
|
||||
@router.delete("/default-model-grants/{ai_model_id}")
|
||||
async def remove_default_grant(
|
||||
ai_model_id: str,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Remove a model from the default set. Already-created orgs keep their grant."""
|
||||
await service.remove_default(ai_model_id=ai_model_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=DEFAULT_MODEL_GRANT_REMOVE,
|
||||
actor_user_id=owner.id,
|
||||
target_type="ai_model",
|
||||
target_id=ai_model_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"ai_model_id": ai_model_id, "in_default_set": False}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/org-policies/{organization_id}", response_model=PolicyOut)
|
||||
async def get_org_policy(
|
||||
organization_id: str,
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
) -> PolicyOut:
|
||||
return _policy_out(await service.get_org_policy(organization_id))
|
||||
|
||||
|
||||
@router.put("/org-policies/{organization_id}", response_model=PolicyOut)
|
||||
async def update_org_policy(
|
||||
organization_id: str,
|
||||
payload: UpdatePolicyRequest,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
service: ModelGrantService = Depends(get_model_grant_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> PolicyOut:
|
||||
"""Set the employee cap, monthly budget and automation switch for one org."""
|
||||
_require_change(
|
||||
payload.max_employees,
|
||||
payload.monthly_credit_budget,
|
||||
payload.automation_enabled,
|
||||
payload.mcp_enabled,
|
||||
)
|
||||
policy = await service.set_org_policy(
|
||||
organization_id=organization_id,
|
||||
max_employees=payload.max_employees,
|
||||
monthly_credit_budget=payload.monthly_credit_budget,
|
||||
automation_enabled=payload.automation_enabled,
|
||||
mcp_enabled=payload.mcp_enabled,
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=ORG_POLICY_UPDATE,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="organization",
|
||||
target_id=organization_id,
|
||||
metadata={
|
||||
"max_employees": policy.max_employees,
|
||||
"monthly_credit_budget": policy.monthly_credit_budget,
|
||||
"automation_enabled": policy.automation_enabled,
|
||||
"mcp_enabled": policy.mcp_enabled,
|
||||
},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return _policy_out(policy)
|
||||
|
||||
@@ -2,10 +2,341 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
Two audiences, one rule each:
|
||||
|
||||
* an **organization admin** mints, lists and revokes its own tokens, and picks the
|
||||
scopes — a token can never be broader than the admin's own intent, and it can
|
||||
never be issued for another organization;
|
||||
* the **platform owner** holds the two kill switches: the gateway as a whole, and
|
||||
one organization's access to it.
|
||||
|
||||
The raw token is returned exactly once, by the create call, and is never stored —
|
||||
only its hash. Everything after that is the prefix.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.container_org import build_model_grant_service
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.mcp_service import (
|
||||
MCP_SCOPES,
|
||||
McpService,
|
||||
as_utc,
|
||||
validate_scopes,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_session
|
||||
from app.domain.base import utcnow
|
||||
from app.domain.identity.audit_actions import (
|
||||
MCP_GLOBAL_SWITCH,
|
||||
MCP_ORG_SWITCH,
|
||||
MCP_TOKEN_CREATE,
|
||||
MCP_TOKEN_REVOKE,
|
||||
)
|
||||
from app.domain.identity.models import Role, User
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
require_platform_owner,
|
||||
require_role_at_least,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["mcp-tokens"])
|
||||
|
||||
#: Minting a token is admin+ (it grants a third party real access).
|
||||
_admin_only = require_role_at_least(Role.ADMIN)
|
||||
|
||||
|
||||
async def _service(session: AsyncSession = Depends(get_session)) -> McpService:
|
||||
return McpService(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class McpTokenOut(BaseModel):
|
||||
id: str
|
||||
organization_id: str
|
||||
name: str
|
||||
prefix: str
|
||||
scopes: list[str]
|
||||
expires_at: datetime | None = None
|
||||
last_used_at: datetime | None = None
|
||||
revoked_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
status: str
|
||||
|
||||
|
||||
class McpTokenCreatedOut(McpTokenOut):
|
||||
"""The only response that ever carries the raw token."""
|
||||
|
||||
token: str
|
||||
|
||||
|
||||
class McpTokenIn(BaseModel):
|
||||
name: str = Field(min_length=2, max_length=120)
|
||||
scopes: list[str] = Field(min_length=1, max_length=len(MCP_SCOPES))
|
||||
ttl_days: int | None = Field(default=None, ge=1, le=3650)
|
||||
|
||||
|
||||
class McpSettingsOut(BaseModel):
|
||||
organization_id: str
|
||||
mcp_enabled: bool
|
||||
gateway_enabled: bool
|
||||
scopes: list[str]
|
||||
token_count: int
|
||||
|
||||
|
||||
class McpGlobalOut(BaseModel):
|
||||
gateway_enabled: bool
|
||||
organizations: list[McpOrgStateOut]
|
||||
total_tokens: int
|
||||
|
||||
|
||||
class McpOrgStateOut(BaseModel):
|
||||
organization_id: str
|
||||
name: str
|
||||
slug: str
|
||||
mcp_enabled: bool
|
||||
token_count: int
|
||||
|
||||
|
||||
class McpGlobalIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class McpOrgSwitchIn(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
def _status(token) -> str:
|
||||
"""``active`` only while the token is neither revoked nor expired."""
|
||||
if token.revoked_at is not None:
|
||||
return "revoked"
|
||||
if as_utc(token.expires_at) is not None and as_utc(token.expires_at) <= utcnow():
|
||||
return "expired"
|
||||
return "active"
|
||||
|
||||
|
||||
def _out(token) -> McpTokenOut:
|
||||
return McpTokenOut(
|
||||
id=token.id,
|
||||
organization_id=token.organization_id,
|
||||
name=token.name,
|
||||
prefix=token.prefix,
|
||||
scopes=token.scopes,
|
||||
expires_at=token.expires_at,
|
||||
last_used_at=token.last_used_at,
|
||||
revoked_at=token.revoked_at,
|
||||
created_at=token.created_at,
|
||||
status=_status(token),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Organization-scoped tokens (admin+)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/organizations/{org_id}/mcp-tokens", response_model=list[McpTokenOut]
|
||||
)
|
||||
async def list_tokens(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: McpService = Depends(_service),
|
||||
) -> list[McpTokenOut]:
|
||||
"""Every token this organization ever minted, with its live status.
|
||||
|
||||
Deliberately includes revoked and expired rows: the history of who could
|
||||
reach the organization is the part that matters after an incident.
|
||||
"""
|
||||
return [_out(token) for token in await service.list(context.org_id)]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organizations/{org_id}/mcp-tokens",
|
||||
response_model=McpTokenCreatedOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_token(
|
||||
payload: McpTokenIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_admin_only),
|
||||
service: McpService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> McpTokenCreatedOut:
|
||||
"""Mint a token. The raw value appears in this response and nowhere else."""
|
||||
issued = await service.issue(
|
||||
organization_id=context.org_id,
|
||||
name=payload.name,
|
||||
scopes=validate_scopes(payload.scopes),
|
||||
created_by_user_id=context.user.id,
|
||||
ttl_days=payload.ttl_days,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_TOKEN_CREATE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="mcp_token",
|
||||
target_id=issued.record.id,
|
||||
metadata={"prefix": issued.record.prefix, "scopes": issued.record.scopes},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return McpTokenCreatedOut(**_out(issued.record).model_dump(), token=issued.token)
|
||||
|
||||
|
||||
@router.delete("/organizations/{org_id}/mcp-tokens/{token_id}")
|
||||
async def revoke_token(
|
||||
token_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_admin_only),
|
||||
service: McpService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
await service.revoke(organization_id=context.org_id, token_id=token_id)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_TOKEN_REVOKE,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="mcp_token",
|
||||
target_id=token_id,
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"id": token_id, "revoked": True}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/organizations/{org_id}/mcp-tokens/settings", response_model=McpSettingsOut
|
||||
)
|
||||
async def get_mcp_settings(
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: McpService = Depends(_service),
|
||||
) -> McpSettingsOut:
|
||||
"""The switches this organization's own admins may see and the scope list."""
|
||||
policy = await build_model_grant_service(service.session).get_org_policy(
|
||||
context.org_id
|
||||
)
|
||||
tokens = await service.list(context.org_id)
|
||||
return McpSettingsOut(
|
||||
organization_id=context.org_id,
|
||||
mcp_enabled=policy.mcp_enabled,
|
||||
gateway_enabled=settings.mcp_gateway_enabled,
|
||||
scopes=list(MCP_SCOPES),
|
||||
token_count=sum(1 for token in tokens if _status(token) == "active"),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Owner: the global switch and the per-organization switch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/admin/mcp", response_model=McpGlobalOut)
|
||||
async def get_mcp_state(
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> McpGlobalOut:
|
||||
"""Every organization with its MCP state and active token count."""
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.infrastructure.db.models import OrganizationRow
|
||||
|
||||
service = McpService(session)
|
||||
grants = build_model_grant_service(session)
|
||||
orgs = (
|
||||
(await session.execute(select(OrganizationRow).order_by(OrganizationRow.name)))
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
counts = await service.count_per_org()
|
||||
states = [
|
||||
McpOrgStateOut(
|
||||
organization_id=org.id,
|
||||
name=org.name,
|
||||
slug=org.slug,
|
||||
mcp_enabled=(await grants.get_org_policy(org.id)).mcp_enabled,
|
||||
token_count=counts.get(org.id, 0),
|
||||
)
|
||||
for org in orgs
|
||||
]
|
||||
return McpGlobalOut(
|
||||
gateway_enabled=settings.mcp_gateway_enabled,
|
||||
organizations=states,
|
||||
total_tokens=sum(counts.values()),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/admin/mcp")
|
||||
async def set_mcp_state(
|
||||
payload: McpGlobalIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""Disable (or re-enable) the gateway platform-wide.
|
||||
|
||||
The switch is a setting rather than a table: it must hold even before any
|
||||
token exists, and a gateway that cannot reach the database should not be the
|
||||
thing deciding it can serve traffic.
|
||||
"""
|
||||
settings.mcp_gateway_enabled = bool(payload.enabled)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_GLOBAL_SWITCH,
|
||||
actor_user_id=owner.id,
|
||||
target_type="platform",
|
||||
target_id="mcp",
|
||||
metadata={"enabled": bool(payload.enabled)},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"gateway_enabled": settings.mcp_gateway_enabled}
|
||||
|
||||
|
||||
@router.put("/admin/mcp/organizations/{organization_id}")
|
||||
async def set_org_mcp_state(
|
||||
organization_id: str,
|
||||
payload: McpOrgSwitchIn,
|
||||
request: Request,
|
||||
owner: User = Depends(require_platform_owner),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, object]:
|
||||
"""One organization's access to the gateway, independent of its tokens."""
|
||||
policy = await build_model_grant_service(session).set_mcp_enabled(
|
||||
organization_id=organization_id,
|
||||
enabled=bool(payload.enabled),
|
||||
actor_user_id=owner.id,
|
||||
)
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=MCP_ORG_SWITCH,
|
||||
organization_id=organization_id,
|
||||
actor_user_id=owner.id,
|
||||
target_type="organization",
|
||||
target_id=organization_id,
|
||||
metadata={"enabled": policy.mcp_enabled},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
return {"organization_id": organization_id, "mcp_enabled": policy.mcp_enabled}
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
|
||||
@@ -2,10 +2,317 @@
|
||||
|
||||
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
|
||||
route its full path (no prefix is applied here besides ``/api``).
|
||||
|
||||
Trust ladder, matching the plan: a viewer reads, a member teaches, an admin edits
|
||||
or deletes anything. The same rules are enforced inside ``MemoryService`` too,
|
||||
so the MCP gateway (Phase 5) hits the same wall — a rule that only the router
|
||||
knows is a rule an agent can bypass.
|
||||
|
||||
Everything stored here went through ``redact`` on the way in, so no endpoint can
|
||||
ever return a credential: what you can read is what was safe to write.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
from fastapi import APIRouter, Depends, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.application.container_org import build_memory_service
|
||||
from app.application.ports.realtime import RealtimeEvent, org_channel, publish
|
||||
from app.application.services.audit_service import record_audit
|
||||
from app.application.services.memory_service import MemoryService
|
||||
from app.core.database import get_session
|
||||
from app.domain.identity.audit_actions import (
|
||||
MEMORY_CONFIRM,
|
||||
MEMORY_CREATE,
|
||||
MEMORY_DELETE,
|
||||
MEMORY_PIN,
|
||||
MEMORY_UPDATE,
|
||||
)
|
||||
from app.domain.identity.models import Role
|
||||
from app.domain.memory.models import (
|
||||
KIND_LABELS,
|
||||
SOURCE_LABELS,
|
||||
Memory,
|
||||
MemoryKind,
|
||||
)
|
||||
from app.interfaces.http.deps import (
|
||||
OrgContext,
|
||||
client_meta,
|
||||
get_org_context,
|
||||
require_role_at_least,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["memory"])
|
||||
|
||||
#: Anyone with a membership reads; teaching is member+; editing is admin+.
|
||||
_teacher = require_role_at_least(Role.MEMBER)
|
||||
_editor = require_role_at_least(Role.ADMIN)
|
||||
|
||||
|
||||
async def _service(session: AsyncSession = Depends(get_session)) -> MemoryService:
|
||||
return build_memory_service(session)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class MemoryOut(BaseModel):
|
||||
id: str
|
||||
kind: str
|
||||
kind_label: str
|
||||
key: str | None = None
|
||||
content: str
|
||||
source: str
|
||||
source_label: str
|
||||
evidence_task_id: str | None = None
|
||||
pinned: bool
|
||||
confidence: float
|
||||
expires_at: str | None = None
|
||||
hits: int
|
||||
last_used_at: str | None = None
|
||||
created_by_user_id: str | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
|
||||
|
||||
class MemoryListOut(BaseModel):
|
||||
organization_id: str
|
||||
items: list[MemoryOut]
|
||||
total: int
|
||||
pinned: int
|
||||
chars: int
|
||||
|
||||
|
||||
class MemoryCreateIn(BaseModel):
|
||||
kind: str = Field(default=str(MemoryKind.RULE), max_length=16)
|
||||
content: str = Field(min_length=2, max_length=1500)
|
||||
key: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class MemoryUpdateIn(BaseModel):
|
||||
content: str = Field(min_length=2, max_length=1500)
|
||||
key: str | None = Field(default=None, max_length=80)
|
||||
|
||||
|
||||
class MemoryPinIn(BaseModel):
|
||||
pinned: bool = True
|
||||
|
||||
|
||||
def _out(memory: Memory) -> MemoryOut:
|
||||
return MemoryOut(
|
||||
id=memory.id,
|
||||
kind=str(memory.kind),
|
||||
kind_label=KIND_LABELS.get(str(memory.kind), "یادداشت"),
|
||||
key=memory.key,
|
||||
content=memory.content,
|
||||
source=str(memory.source),
|
||||
source_label=SOURCE_LABELS.get(str(memory.source), ""),
|
||||
evidence_task_id=memory.evidence_task_id,
|
||||
pinned=memory.pinned,
|
||||
confidence=memory.confidence,
|
||||
expires_at=memory.expires_at.isoformat() if memory.expires_at else None,
|
||||
hits=memory.hits,
|
||||
last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None,
|
||||
created_by_user_id=memory.created_by_user_id,
|
||||
created_at=memory.created_at.isoformat() if memory.created_at else None,
|
||||
updated_at=memory.updated_at.isoformat() if memory.updated_at else None,
|
||||
)
|
||||
|
||||
|
||||
async def _audit(
|
||||
session: AsyncSession,
|
||||
request: Request,
|
||||
*,
|
||||
action: str,
|
||||
context: OrgContext,
|
||||
target_id: str,
|
||||
metadata: dict | None = None,
|
||||
) -> None:
|
||||
ip, ua = client_meta(request)
|
||||
await record_audit(
|
||||
session,
|
||||
action=action,
|
||||
organization_id=context.org_id,
|
||||
actor_user_id=context.user.id,
|
||||
target_type="memory",
|
||||
target_id=target_id,
|
||||
metadata=metadata or {},
|
||||
ip=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
|
||||
|
||||
async def _announce(context: OrgContext, memory: Memory) -> None:
|
||||
"""Tell the tenant's open tabs that memory changed.
|
||||
|
||||
``memory.changed`` is published on the org channel and nowhere else: a
|
||||
memory belongs to one organization, and the realtime authorisation for
|
||||
``org:<id>`` is membership — exactly the same rule the endpoint enforced.
|
||||
A failed publish is a lost refresh, never a failed write.
|
||||
"""
|
||||
await publish(
|
||||
RealtimeEvent(
|
||||
type="memory.changed",
|
||||
channels=(org_channel(context.org_id),),
|
||||
organization_id=context.org_id,
|
||||
data={
|
||||
"memory_id": memory.id,
|
||||
"kind": str(memory.kind),
|
||||
"pinned": memory.pinned,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/organizations/{org_id}/memory", response_model=MemoryListOut)
|
||||
async def list_memory(
|
||||
kind: str | None = Query(default=None, max_length=16),
|
||||
q: str | None = Query(default="", max_length=120),
|
||||
context: OrgContext = Depends(get_org_context),
|
||||
service: MemoryService = Depends(_service),
|
||||
) -> MemoryListOut:
|
||||
"""The tab "قوانین و ترجیحات" and friends; also the source list for search.
|
||||
|
||||
Filtering by ``kind`` is a convenience, not a security boundary: an
|
||||
organization's whole memory is visible to any member by design.
|
||||
"""
|
||||
items = await service.list(organization_id=context.org_id, kind=kind, search=q)
|
||||
stats = await service.stats(context.org_id)
|
||||
return MemoryListOut(
|
||||
organization_id=context.org_id,
|
||||
items=[_out(m) for m in items],
|
||||
total=stats["total"],
|
||||
pinned=stats["pinned"],
|
||||
chars=stats["chars"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.post(
|
||||
"/organizations/{org_id}/memory",
|
||||
response_model=MemoryOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def teach_memory(
|
||||
payload: MemoryCreateIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_teacher),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
""""به سازمان یاد بده": a rule or a preference, stored redacted."""
|
||||
memory = await service.teach(
|
||||
organization_id=context.org_id,
|
||||
user_id=context.user.id,
|
||||
role=context.membership.role,
|
||||
kind=payload.kind,
|
||||
content=payload.content,
|
||||
key=payload.key,
|
||||
)
|
||||
await _audit(
|
||||
session,
|
||||
request,
|
||||
action=MEMORY_CREATE,
|
||||
context=context,
|
||||
target_id=memory.id,
|
||||
metadata={"kind": str(memory.kind), "key": memory.key},
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.patch("/organizations/{org_id}/memory/{memory_id}", response_model=MemoryOut)
|
||||
async def edit_memory(
|
||||
memory_id: str,
|
||||
payload: MemoryUpdateIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
memory = await service.edit(
|
||||
organization_id=context.org_id,
|
||||
memory_id=memory_id,
|
||||
content=payload.content,
|
||||
key=payload.key,
|
||||
)
|
||||
await _audit(session, request, action=MEMORY_UPDATE, context=context, target_id=memory_id)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.post("/organizations/{org_id}/memory/{memory_id}/pin", response_model=MemoryOut)
|
||||
async def pin_memory(
|
||||
memory_id: str,
|
||||
payload: MemoryPinIn,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
"""Pin → always in context and never evicted; unpin → back to search."""
|
||||
memory = await service.set_pinned(
|
||||
organization_id=context.org_id, memory_id=memory_id, pinned=payload.pinned
|
||||
)
|
||||
await _audit(
|
||||
session,
|
||||
request,
|
||||
action=MEMORY_PIN,
|
||||
context=context,
|
||||
target_id=memory_id,
|
||||
metadata={"pinned": payload.pinned},
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.post("/organizations/{org_id}/memory/{memory_id}/confirm", response_model=MemoryOut)
|
||||
async def confirm_memory(
|
||||
memory_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> MemoryOut:
|
||||
""""درس تأیید شد": the lesson becomes settled knowledge and stops expiring."""
|
||||
memory = await service.confirm(organization_id=context.org_id, memory_id=memory_id)
|
||||
await _audit(
|
||||
session, request, action=MEMORY_CONFIRM, context=context, target_id=memory_id
|
||||
)
|
||||
await _announce(context, memory)
|
||||
return _out(memory)
|
||||
|
||||
|
||||
@router.delete("/organizations/{org_id}/memory/{memory_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_memory(
|
||||
memory_id: str,
|
||||
request: Request,
|
||||
context: OrgContext = Depends(_editor),
|
||||
service: MemoryService = Depends(_service),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> None:
|
||||
await service.delete(organization_id=context.org_id, memory_id=memory_id)
|
||||
await _audit(
|
||||
session, request, action=MEMORY_DELETE, context=context, target_id=memory_id
|
||||
)
|
||||
await publish(
|
||||
RealtimeEvent(
|
||||
type="memory.changed",
|
||||
channels=(org_channel(context.org_id),),
|
||||
organization_id=context.org_id,
|
||||
data={"memory_id": memory_id, "deleted": True},
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1 +1,21 @@
|
||||
"""MCP gateway (owned by Agent 1)."""
|
||||
"""The MCP gateway package (owned by Agent 1, PLAN-v2 §6).
|
||||
|
||||
``gateway`` holds the tools and their rules; ``server`` binds them to the
|
||||
official SDK. ``python -m app.interfaces.mcp.server`` starts the gateway.
|
||||
"""
|
||||
|
||||
from app.interfaces.mcp.gateway import (
|
||||
TOOL_SPECS,
|
||||
McpCallResult,
|
||||
McpError,
|
||||
McpGateway,
|
||||
build_gateway,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"TOOL_SPECS",
|
||||
"McpCallResult",
|
||||
"McpError",
|
||||
"McpGateway",
|
||||
"build_gateway",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,619 @@
|
||||
"""The MCP tools themselves — framework-free, so they can be called directly.
|
||||
|
||||
``app.interfaces.mcp.server`` adapts these to the official SDK; this module
|
||||
holds the actual work and is what the tests exercise. Four rules are enforced
|
||||
here, once, for every tool:
|
||||
|
||||
1. **The organization comes from the token, never from the arguments.** A tool
|
||||
body receives an authenticated ``TokenIdentity`` and uses its
|
||||
``organization_id``; an ``organization_id`` in the arguments is ignored, so an
|
||||
agent cannot ask about a tenant it was not issued for.
|
||||
2. **A scope is checked before the work starts.** Not after.
|
||||
3. **Every call is audited** (``mcp.tool_call`` with the token prefix, the tool,
|
||||
and whether it succeeded), rate-limited, and its output redacted.
|
||||
4. **An error is an answer, not a crash**: the agent gets a plain message, and
|
||||
the exception never reaches the transport.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.application.container_org import (
|
||||
build_memory_service,
|
||||
build_model_grant_service,
|
||||
)
|
||||
from app.application.services.mcp_service import McpService, TokenIdentity
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, PermissionDeniedError, ValidationError
|
||||
from app.core.redact import redact
|
||||
from app.domain.memory.models import MemoryKind
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ToolSpec:
|
||||
"""One tool: its name, the scope it needs, and its JSON schema."""
|
||||
name: str
|
||||
scope: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
|
||||
TOOL_SPECS: tuple[ToolSpec, ...] = (
|
||||
ToolSpec(
|
||||
name="app_get_profile",
|
||||
scope="app:read",
|
||||
description="پروفایل اپلیکیشن این سازمان: نام، نوع، زبان، فریمورک، "
|
||||
"دستورهای ساخت و تست، پورت و مسیر سلامت.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_list",
|
||||
scope="tasks:read",
|
||||
description="فهرست کارهای این سازمان، به ترتیب جدیدترین.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "وضعیت اختیاری برای فیلتر (new، running، done، …)",
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 100, "default": 25},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_get",
|
||||
scope="tasks:read",
|
||||
description="یک کار بر اساس شناسهٔ آن، در همین سازمان.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"task_id": {"type": "string", "description": "شناسهٔ کار"},
|
||||
},
|
||||
"required": ["task_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="tasks_create",
|
||||
scope="tasks:write",
|
||||
description="ثبت یک کار تازه برای مدیر سازمان؛ مدیر خودش آن را تقسیم "
|
||||
"و به کارمندان میسپارد.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {"type": "string", "description": "عنوان کوتاه کار"},
|
||||
"description": {"type": "string", "description": "شرح کامل درخواست"},
|
||||
},
|
||||
"required": ["title"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="memory_search",
|
||||
scope="memory:read",
|
||||
description="جستوجو در حافظهٔ سازمان: قوانین، ترجیحات، درسها و نقشهٔ کد.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "واژههای جستوجو"},
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"description": "نوع اختیاری: rule، preference، lesson، fact، code_map",
|
||||
},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
|
||||
},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="memory_write",
|
||||
scope="memory:write",
|
||||
description="یادآوری یک واقعیت یا ترجیح به سازمان. گذرواژه و کلید "
|
||||
"هرگز ذخیره نمیشود.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["fact", "preference"],
|
||||
"description": "فقط «fact» یا «preference»",
|
||||
},
|
||||
"content": {"type": "string", "description": "یک جملهٔ کوتاه"},
|
||||
},
|
||||
"required": ["kind", "content"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="knowledge_search",
|
||||
scope="memory:read",
|
||||
description="جستوجو در منابع دانش سازمان (اسناد بلند).",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "واژههای جستوجو"},
|
||||
"limit": {"type": "integer", "minimum": 1, "maximum": 20, "default": 5},
|
||||
},
|
||||
"required": ["query"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
ToolSpec(
|
||||
name="pipeline_status",
|
||||
scope="delivery:read",
|
||||
description="وضعیت خط تحویل (استقرار و دمو). در این نسخه در دسترس نیست.",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": False,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
#: The only kinds an external agent may write. A rule is the organization's own
|
||||
#: to set through the UI, and a lesson is the engine's to learn.
|
||||
MCP_WRITABLE_KINDS = frozenset({MemoryKind.FACT, MemoryKind.PREFERENCE})
|
||||
|
||||
|
||||
class McpError(Exception):
|
||||
"""A refusal the agent can read. Never carries a secret."""
|
||||
|
||||
def __init__(self, message: str, *, code: str = "mcp_error") -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class McpCallResult:
|
||||
"""What one tool call produced: a redacted payload, or a readable refusal."""
|
||||
|
||||
ok: bool
|
||||
payload: dict[str, Any]
|
||||
error: str | None = None
|
||||
code: str = "mcp_error"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
if self.ok:
|
||||
return self.payload
|
||||
return {
|
||||
"error": {"code": self.code, "message": self.error or "عملیات ناموفق بود."}
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""The JSON text an agent receives. Always redacted, always short."""
|
||||
return json.dumps(redact(self.as_dict()), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
class McpGateway:
|
||||
"""Executes the tools for one authenticated token.
|
||||
|
||||
``session_factory`` is injectable so the tests can point the gateway at their
|
||||
in-memory database; production passes nothing and gets the application's own
|
||||
factory, exactly like the workers.
|
||||
"""
|
||||
|
||||
def __init__(self, *, session_factory=None, rate_limiter=None) -> None:
|
||||
from app.application.services.mcp_service import TokenRateLimiter
|
||||
|
||||
self._session_factory = session_factory
|
||||
self._limiter = rate_limiter or TokenRateLimiter()
|
||||
|
||||
# -- public API ----------------------------------------------------------
|
||||
|
||||
async def call(
|
||||
self,
|
||||
*,
|
||||
tool: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
token: str,
|
||||
identity: TokenIdentity | None = None,
|
||||
) -> McpCallResult:
|
||||
"""Run one tool for one token. Every failure mode is an answer."""
|
||||
spec = _SPECS_BY_NAME.get(tool)
|
||||
if spec is None:
|
||||
return McpCallResult(ok=False, payload={}, error=f"ابزار ناشناخته: {tool}")
|
||||
args = arguments or {}
|
||||
if not settings.mcp_gateway_enabled:
|
||||
# The owner's kill switch. Nothing is attributable yet, so this lands
|
||||
# in the error ledger rather than in one organization's audit trail.
|
||||
await _note("mcp.gateway_disabled", f"tool={spec.name}")
|
||||
return self._fail(
|
||||
"دسترسی MCP از سوی مالک سامانه غیرفعال شده است.",
|
||||
code="mcp_disabled",
|
||||
)
|
||||
|
||||
factory = self._session_factory or _default_factory()
|
||||
async with factory() as session:
|
||||
service = McpService(session)
|
||||
identity = identity or await service.verify(token)
|
||||
if identity is None:
|
||||
# A token that does not exist, was revoked, or expired is one
|
||||
# answer — and it is unattributable, so it goes to the error
|
||||
# ledger where a human can see the pattern.
|
||||
await _note("mcp.invalid_token", f"tool={spec.name}")
|
||||
return self._fail(
|
||||
"توکن نامعتبر یا منقضی است.", code="invalid_token"
|
||||
)
|
||||
|
||||
async def refuse(message: str, code: str) -> McpCallResult:
|
||||
"""Every refusal from here on is audited against this organization.
|
||||
|
||||
The audit row is committed inside the refusal: a denial nobody can
|
||||
see afterwards is not an audit trail, it is a hope.
|
||||
"""
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error=message
|
||||
)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=False, payload={}, error=message, code=code)
|
||||
|
||||
if not identity.allows(spec.scope):
|
||||
return await refuse(
|
||||
"این توکن به این ابزار دسترسی ندارد.", "scope_denied"
|
||||
)
|
||||
policy = await build_model_grant_service(session).get_org_policy(
|
||||
identity.organization_id
|
||||
)
|
||||
if not policy.mcp_enabled:
|
||||
return await refuse(
|
||||
"دسترسی MCP این سازمان غیرفعال است.", "org_disabled"
|
||||
)
|
||||
if not self._limiter.allow(identity.token_id):
|
||||
return await refuse(
|
||||
"تعداد درخواستها بیش از حد مجاز است؛ کمی بعد تلاش کنید.",
|
||||
"rate_limited",
|
||||
)
|
||||
|
||||
handler = _HANDLERS[spec.name]
|
||||
try:
|
||||
payload = await handler(self, session=session, identity=identity, **args)
|
||||
except (NotFoundError, PermissionDeniedError, ValidationError, McpError) as exc:
|
||||
message = str(getattr(exc, "message", exc))
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error=message
|
||||
)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=False, payload={}, error=message, code="tool_failed")
|
||||
except TypeError:
|
||||
# An argument shape the tool did not expect: the agent's fault,
|
||||
# not the platform's.
|
||||
return await refuse(
|
||||
"آرگومانهای ابزار نامعتبر است.", "bad_arguments"
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - an outage is an answer too
|
||||
# Anything else is infrastructure, and its message may contain a
|
||||
# DSN or a heap address. The agent gets a plain sentence; the
|
||||
# operator gets the real error in the error console.
|
||||
await record_error(
|
||||
kind="mcp.tool_crashed",
|
||||
message=str(exc),
|
||||
exc=exc,
|
||||
organization_id=identity.organization_id,
|
||||
metadata={"tool": spec.name, "prefix": identity.prefix},
|
||||
)
|
||||
await self._audit(
|
||||
session, identity=identity, spec=spec, ok=False, error="internal"
|
||||
)
|
||||
await session.commit()
|
||||
return self._fail(
|
||||
"خطای داخلی سامانه؛ این خطا ثبت شد.", code="internal_error"
|
||||
)
|
||||
await service.touch(identity.token_id)
|
||||
await self._audit(session, identity=identity, spec=spec, ok=True)
|
||||
await session.commit()
|
||||
return McpCallResult(ok=True, payload=payload)
|
||||
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _fail(message: str, *, code: str) -> McpCallResult:
|
||||
"""A refusal is a normal answer: the agent gets a message it can act on."""
|
||||
return McpCallResult(ok=False, payload={}, error=message, code=code)
|
||||
|
||||
async def _audit(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
identity: TokenIdentity,
|
||||
spec: ToolSpec,
|
||||
ok: bool,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""One audit row per call. Never allowed to fail the call itself."""
|
||||
from app.application.services.audit_service import record_audit
|
||||
|
||||
try:
|
||||
await record_audit(
|
||||
session,
|
||||
action="mcp.tool_call",
|
||||
organization_id=identity.organization_id,
|
||||
actor_user_id=None,
|
||||
target_type="mcp_tool",
|
||||
target_id=spec.name,
|
||||
metadata={
|
||||
"prefix": identity.prefix,
|
||||
"scope": spec.scope,
|
||||
"ok": ok,
|
||||
"error": error[:200],
|
||||
},
|
||||
ip="mcp",
|
||||
user_agent=f"mcp/{identity.prefix}",
|
||||
)
|
||||
except Exception: # noqa: BLE001 - auditing must not fail a tool call
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool bodies
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _app_profile(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
from app.infrastructure.repositories.app_profile import AppProfileRepository
|
||||
from app.infrastructure.repositories.projects import ProjectRepository
|
||||
|
||||
profile = await AppProfileRepository(session).get_for_org(identity.organization_id)
|
||||
if profile is None:
|
||||
raise NotFoundError("اپلیکیشنی برای این سازمان یافت نشد.")
|
||||
project = await ProjectRepository(session).get(profile.project_id)
|
||||
return {
|
||||
"project_name": project.name if project else "",
|
||||
"project_slug": project.slug if project else "",
|
||||
"app_type": profile.app_type,
|
||||
"language": profile.language,
|
||||
"framework": profile.framework,
|
||||
"runtime_version": profile.runtime_version,
|
||||
"package_manager": profile.package_manager,
|
||||
"setup_command": profile.setup_command,
|
||||
"build_command": profile.build_command,
|
||||
"test_command": profile.test_command,
|
||||
"start_command": profile.start_command,
|
||||
"app_port": profile.app_port,
|
||||
"healthcheck_path": profile.healthcheck_path,
|
||||
"default_branch": profile.default_branch,
|
||||
"git_source": profile.git_source,
|
||||
"repo_url": profile.external_repo_url,
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_list(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
status: str | None = None,
|
||||
limit: int = 25,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_workforce_service
|
||||
from app.domain.workforce.models import TaskStatus
|
||||
|
||||
service = build_workforce_service(session)
|
||||
parsed: TaskStatus | None = None
|
||||
if status:
|
||||
try:
|
||||
parsed = TaskStatus(str(status).strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValidationError("وضعیت کار نامعتبر است.") from exc
|
||||
tasks = await service.list_tasks(
|
||||
identity.organization_id, status=parsed, limit=max(1, min(int(limit), 100))
|
||||
)
|
||||
return {
|
||||
"tasks": [
|
||||
{
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"status": str(task.status),
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"assigned_employee_id": task.assigned_employee_id,
|
||||
}
|
||||
for task in tasks
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_get(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, task_id: str, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
if not task_id:
|
||||
raise ValidationError("شناسهٔ کار لازم است.")
|
||||
service = build_workforce_service(session)
|
||||
task = await service.get_task_or_404(
|
||||
organization_id=identity.organization_id, task_id=task_id
|
||||
)
|
||||
return {
|
||||
"task": {
|
||||
"id": task.id,
|
||||
"title": task.title,
|
||||
"description": task.description,
|
||||
"status": str(task.status),
|
||||
"created_at": task.created_at.isoformat() if task.created_at else None,
|
||||
"project_id": task.project_id,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async def _tasks_create(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
title: str,
|
||||
description: str = "",
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
"""A user order to the manager: the current public signature, untouched."""
|
||||
from app.application.container import build_workforce_service
|
||||
|
||||
if not title or not title.strip():
|
||||
raise ValidationError("عنوان کار لازم است.")
|
||||
creator = await McpService(session).creator_of(identity.token_id)
|
||||
service = build_workforce_service(session)
|
||||
task = await service.create_task(
|
||||
organization_id=identity.organization_id,
|
||||
created_by_user_id=creator or "",
|
||||
title=title,
|
||||
description=description,
|
||||
)
|
||||
return {"task": {"id": task.id, "title": task.title, "status": str(task.status)}}
|
||||
|
||||
|
||||
async def _memory_search(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
query: str = "",
|
||||
kind: str | None = None,
|
||||
limit: int = 10,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
service = build_memory_service(session)
|
||||
items = await service.list(
|
||||
identity.organization_id, kind=kind, search=query or None
|
||||
)
|
||||
return {
|
||||
"memories": [
|
||||
{
|
||||
"id": memory.id,
|
||||
"kind": str(memory.kind),
|
||||
"key": memory.key,
|
||||
"content": memory.content,
|
||||
"pinned": memory.pinned,
|
||||
"confidence": memory.confidence,
|
||||
}
|
||||
for memory in items[: max(1, min(int(limit), 50))]
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _memory_write(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
kind: str,
|
||||
content: str,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.domain.memory.models import coerce_kind
|
||||
|
||||
entry_kind = coerce_kind(kind)
|
||||
if entry_kind not in MCP_WRITABLE_KINDS:
|
||||
raise McpError("با توکن فقط «fact» یا «preference» قابل نوشتن است.")
|
||||
service = build_memory_service(session)
|
||||
memory_id = await service.record(
|
||||
organization_id=identity.organization_id,
|
||||
kind=str(entry_kind),
|
||||
content=content,
|
||||
source="user",
|
||||
)
|
||||
if memory_id is None: # pragma: no cover - the task cap cannot apply here
|
||||
raise McpError("یادداشت ذخیره نشد.")
|
||||
return {"memory": {"id": memory_id, "kind": str(entry_kind)}}
|
||||
|
||||
|
||||
async def _knowledge_search(
|
||||
gateway: McpGateway,
|
||||
*,
|
||||
session,
|
||||
identity: TokenIdentity,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
**_ignored: Any,
|
||||
) -> dict[str, Any]:
|
||||
from app.application.container import build_knowledge_service
|
||||
|
||||
if not query:
|
||||
raise ValidationError("عبارت جستوجو لازم است.")
|
||||
service = build_knowledge_service(session)
|
||||
hits = await service.search(
|
||||
organization_id=identity.organization_id,
|
||||
query=query,
|
||||
limit=max(1, min(int(limit), 20)),
|
||||
)
|
||||
return {
|
||||
"results": [
|
||||
{
|
||||
"content": hit.get("content", ""),
|
||||
"source_name": hit.get("source_name", ""),
|
||||
"document_title": hit.get("document_title", ""),
|
||||
}
|
||||
for hit in hits
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def _pipeline_status(
|
||||
gateway: McpGateway, *, session, identity: TokenIdentity, **_ignored: Any
|
||||
) -> dict[str, Any]:
|
||||
"""Delivery is another workstream's table; the tool exists and says so."""
|
||||
return {"available": False, "message": "خط تحویل در این نسخه در دسترس نیست."}
|
||||
|
||||
|
||||
_HANDLERS: dict[str, Callable[..., Awaitable[dict[str, Any]]]] = {
|
||||
"app_get_profile": _app_profile,
|
||||
"tasks_list": _tasks_list,
|
||||
"tasks_get": _tasks_get,
|
||||
"tasks_create": _tasks_create,
|
||||
"memory_search": _memory_search,
|
||||
"memory_write": _memory_write,
|
||||
"knowledge_search": _knowledge_search,
|
||||
"pipeline_status": _pipeline_status,
|
||||
}
|
||||
|
||||
_SPECS_BY_NAME: dict[str, ToolSpec] = {spec.name: spec for spec in TOOL_SPECS}
|
||||
|
||||
|
||||
def _default_factory():
|
||||
from app.core.database import SessionFactory
|
||||
|
||||
return SessionFactory
|
||||
|
||||
|
||||
async def _note(kind: str, detail: str) -> None:
|
||||
"""Record an unattributable attempt in the error ledger, best-effort.
|
||||
|
||||
A bad token and a disabled gateway happen before the caller is known, so they
|
||||
belong to nobody's audit trail — but they are exactly the patterns an operator
|
||||
needs to see, which is what the error console is for.
|
||||
"""
|
||||
from app.infrastructure.error_log import record_error
|
||||
|
||||
try:
|
||||
await record_error(kind=kind, message=detail, source="mcp")
|
||||
except Exception: # noqa: BLE001 - telemetry must never fail a call
|
||||
return
|
||||
|
||||
|
||||
def build_gateway(*, session_factory=None) -> McpGateway:
|
||||
"""The gateway the server and the tests both use.
|
||||
|
||||
Each gateway owns its own in-memory rate limiter: one process, one budget.
|
||||
"""
|
||||
return McpGateway(session_factory=session_factory)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"MCP_WRITABLE_KINDS",
|
||||
"TOOL_SPECS",
|
||||
"McpCallResult",
|
||||
"McpError",
|
||||
"McpGateway",
|
||||
"build_gateway",
|
||||
]
|
||||
@@ -1,14 +1,178 @@
|
||||
"""MCP gateway entry point (owned by Agent 1). Foundation stub."""
|
||||
"""The MCP gateway server: the official SDK, bound to our tools.
|
||||
|
||||
This module is the *adapter*, not the logic. The tools live in
|
||||
``app.interfaces.mcp.gateway`` (framework-free and directly testable); what
|
||||
happens here is transport only:
|
||||
|
||||
* a ``MCPServer`` (streamable HTTP) with the eight tools registered, each with
|
||||
the argument schema the gateway validates;
|
||||
* every call resolves the caller's token from the ``Authorization: Bearer``
|
||||
header — never from the arguments — and hands it to the gateway;
|
||||
* a refusal raises ``ToolError``, which the transport reports as an ``isError``
|
||||
tool result: an agent sees "this token does not have that scope" instead of a
|
||||
stack trace;
|
||||
* ``main()`` binds ``MCP_GATEWAY_HOST:MCP_GATEWAY_PORT`` — what ``pnpm mcp:dev``
|
||||
runs. Putting Caddy in front of it is the owner's job (see
|
||||
``services/mcp-gateway/README.md``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
def main() -> None:
|
||||
raise SystemExit(
|
||||
"MCP gateway is not implemented yet "
|
||||
"(see docs/plan/TODO-agent-1-org-platform.md, Phase 5)"
|
||||
from mcp.server.mcpserver.context import Context
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.redact import redact
|
||||
from app.interfaces.mcp.gateway import McpCallResult, McpGateway, build_gateway
|
||||
|
||||
#: How a client authenticates. One header, one token, no query parameter: a
|
||||
#: token in a URL ends up in proxy logs and browser history.
|
||||
BEARER_PREFIX = "bearer "
|
||||
|
||||
|
||||
def _bearer_token(ctx: Any) -> str:
|
||||
"""The bearer token of this request, or an empty string.
|
||||
|
||||
``ctx.headers`` raises when there is no request at all (a stdio transport, or
|
||||
a direct call in a test), so the read is defensive by design: no request
|
||||
means no token, which means no tool runs.
|
||||
"""
|
||||
try:
|
||||
headers = getattr(ctx, "headers", None) if ctx is not None else None
|
||||
except ValueError: # pragma: no cover - "Context not available"
|
||||
return ""
|
||||
if not headers:
|
||||
return ""
|
||||
raw = headers.get("authorization") or headers.get("Authorization") or ""
|
||||
if isinstance(raw, bytes): # pragma: no cover - transports hand us str
|
||||
raw = raw.decode("utf-8", "ignore")
|
||||
if raw.lower().startswith(BEARER_PREFIX):
|
||||
return raw[len(BEARER_PREFIX) :].strip()
|
||||
return raw.strip()
|
||||
|
||||
|
||||
def _build_server(gateway: McpGateway) -> Any:
|
||||
"""Build the SDK server with our eight tools registered."""
|
||||
from mcp.server.mcpserver import MCPServer
|
||||
|
||||
server: Any = MCPServer(
|
||||
name="smart-org-gateway",
|
||||
version="1.0.0",
|
||||
instructions=(
|
||||
"دسترسی به یک سازمان در سامانه سازمان هوشمند. سازمان از توکن خوانده "
|
||||
"میشود، نه از آرگومانها؛ توکنی که محدودهٔ لازم را نداشته باشد رد "
|
||||
"میشود."
|
||||
),
|
||||
)
|
||||
|
||||
async def _dispatch(tool: str, ctx: Any, **arguments: Any) -> dict[str, Any]:
|
||||
result: McpCallResult = await gateway.call(
|
||||
tool=tool, arguments=arguments, token=_bearer_token(ctx)
|
||||
)
|
||||
if not result.ok:
|
||||
raise ToolError(result.error or "عملیات ناموفق بود.")
|
||||
# Redacted on the way out as well as on the way in: a tool may return a
|
||||
# stored value that predates a redaction rule, and the agent is outside
|
||||
# the platform's trust boundary.
|
||||
payload: dict[str, Any] = redact(result.payload)
|
||||
return payload
|
||||
|
||||
async def app_get_profile(ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""پروفایل اپلیکیشن سازمان: نام، نوع، زبان، فریمورک، دستورها، پورت."""
|
||||
return await _dispatch("app_get_profile", ctx)
|
||||
|
||||
async def tasks_list(
|
||||
status: str | None = None, limit: int = 25, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""فهرست کارهای سازمان، به ترتیب جدیدترین."""
|
||||
return await _dispatch("tasks_list", ctx, status=status, limit=limit)
|
||||
|
||||
async def tasks_get(task_id: str, ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""یک کار بر اساس شناسهٔ آن."""
|
||||
return await _dispatch("tasks_get", ctx, task_id=task_id)
|
||||
|
||||
async def tasks_create(
|
||||
title: str, description: str = "", ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""ثبت یک کار تازه برای مدیر سازمان."""
|
||||
return await _dispatch("tasks_create", ctx, title=title, description=description)
|
||||
|
||||
async def memory_search(
|
||||
query: str = "", kind: str | None = None, limit: int = 10,
|
||||
ctx: Context | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""جستوجو در حافظهٔ سازمان: قوانین، ترجیحات، درسها و نقشهٔ کد."""
|
||||
return await _dispatch(
|
||||
"memory_search", ctx, query=query, kind=kind, limit=limit
|
||||
)
|
||||
|
||||
async def memory_write(
|
||||
kind: str, content: str, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""یادآوری یک واقعیت یا ترجیح به سازمان."""
|
||||
return await _dispatch("memory_write", ctx, kind=kind, content=content)
|
||||
|
||||
async def knowledge_search(
|
||||
query: str, limit: int = 5, ctx: Context | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""جستوجو در منابع دانش سازمان."""
|
||||
return await _dispatch("knowledge_search", ctx, query=query, limit=limit)
|
||||
|
||||
async def pipeline_status(ctx: Context | None = None) -> dict[str, Any]:
|
||||
"""وضعیت خط تحویل (استقرار و دمو)."""
|
||||
return await _dispatch("pipeline_status", ctx)
|
||||
|
||||
for tool in (
|
||||
app_get_profile,
|
||||
tasks_list,
|
||||
tasks_get,
|
||||
tasks_create,
|
||||
memory_search,
|
||||
memory_write,
|
||||
knowledge_search,
|
||||
pipeline_status,
|
||||
):
|
||||
server.add_tool(
|
||||
tool, name=tool.__name__, description=(tool.__doc__ or "").strip()
|
||||
)
|
||||
return server
|
||||
|
||||
|
||||
def build_app(gateway: McpGateway | None = None) -> Any:
|
||||
"""The ASGI app (streamable HTTP) for a uvicorn-style deployment."""
|
||||
server = _build_server(gateway or build_gateway())
|
||||
return server.streamable_http_app(
|
||||
streamable_http_path="/mcp", host=settings.mcp_gateway_host
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
def main(argv: list[str] | None = None) -> None:
|
||||
"""Run the gateway: ``python -m app.interfaces.mcp.server``."""
|
||||
import sys
|
||||
|
||||
import uvicorn
|
||||
|
||||
host, port = settings.mcp_gateway_host, settings.mcp_gateway_port
|
||||
if not settings.mcp_gateway_enabled:
|
||||
# Starting anyway is right: the switch is the owner's, and the process
|
||||
# answering "disabled" is more diagnosable than a missing port.
|
||||
print(
|
||||
"mcp gateway: MCP_GATEWAY_ENABLED=false — every call will be refused",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
print(f"mcp gateway: http://{host}:{port}/mcp", file=sys.stderr, flush=True)
|
||||
uvicorn.run(
|
||||
_build_server(build_gateway()).streamable_http_app(
|
||||
streamable_http_path="/mcp", host=host
|
||||
),
|
||||
host=host,
|
||||
port=port,
|
||||
log_level="info",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - the entry point
|
||||
main()
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
import app.application.container as container
|
||||
import app.application.container_org as container_org
|
||||
from app.application.ports.delivery_hooks import DeliveryHooks, NullDeliveryHooks
|
||||
from app.application.ports.deploy_target import (
|
||||
DeployTargetSpec,
|
||||
@@ -109,7 +110,16 @@ async def test_app_profile_repository_is_tenant_scoped(session_factory):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_model_grants_return_active_models_with_tiers(session_factory):
|
||||
async def test_granted_models_carry_tiers_and_need_an_owner_grant(session_factory):
|
||||
"""The real grant service (Phase 3) replaced the "all active models" stub.
|
||||
|
||||
The foundation version asserted the placeholder wiring (every active model
|
||||
usable by every organization). The behaviour the plan changes on purpose is
|
||||
exactly that: an organization now sees a model only after the owner granted
|
||||
it. The assertions below are strictly stronger — they still pin the tier
|
||||
labels, and additionally pin the two switches (the model must be active
|
||||
*and* granted) plus the per-organization scope.
|
||||
"""
|
||||
async with session_factory() as session:
|
||||
provider = AiProviderRow(id=new_id(), name="agy-test")
|
||||
session.add(provider)
|
||||
@@ -135,10 +145,28 @@ async def test_default_model_grants_return_active_models_with_tiers(session_fact
|
||||
|
||||
grants = container.build_model_grants(session)
|
||||
assert isinstance(grants, ModelGrantPort)
|
||||
granted = {g.ai_model_id: g.tier for g in await grants.granted_models(organization_id="o")}
|
||||
org_id, _ = await _org_with_user(session, "granted")
|
||||
other_id, _ = await _org_with_user(session, "untouched")
|
||||
# Nothing is usable until the owner grants it.
|
||||
assert await grants.granted_models(organization_id=org_id) == []
|
||||
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
|
||||
|
||||
# ``container.py`` is locked and re-exports only ``build_model_grants``;
|
||||
# the typed builder lives in the workstream's own container.
|
||||
service = container_org.build_model_grant_service(session)
|
||||
await service.grant_model(organization_id=org_id, ai_model_id="m-strong")
|
||||
await service.grant_model(organization_id=org_id, ai_model_id="m-fast")
|
||||
|
||||
granted = {
|
||||
g.ai_model_id: g.tier
|
||||
for g in await grants.granted_models(organization_id=org_id)
|
||||
}
|
||||
assert granted == {"m-strong": "strong", "m-fast": "fast"}
|
||||
assert await grants.is_granted(organization_id="o", ai_model_id="m-fast")
|
||||
assert not await grants.is_granted(organization_id="o", ai_model_id="m-off")
|
||||
assert await grants.is_granted(organization_id=org_id, ai_model_id="m-fast")
|
||||
# Inactive in the catalogue: never granted, whatever the grant table says.
|
||||
assert not await grants.is_granted(organization_id=org_id, ai_model_id="m-off")
|
||||
# Another organization is unaffected by this one's grants.
|
||||
assert await grants.granted_models(organization_id=other_id) == []
|
||||
|
||||
|
||||
def test_tier_for_never_uses_names():
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
"""MCP gateway: scoped tokens, tool calls, and what they may never do.
|
||||
|
||||
The invariants under test:
|
||||
|
||||
* a token from organization A can never read organization B — not by asking, not
|
||||
by passing an ``organization_id``, not by guessing an id;
|
||||
* a scope is a floor: a token minted ``tasks:read`` cannot create a task, and a
|
||||
token minted ``memory:write`` cannot write a *rule* (only ``fact`` and
|
||||
``preference``);
|
||||
* a revoked or expired token is indistinguishable from an unknown one;
|
||||
* the rate limit refuses the 61st call in a minute;
|
||||
* every call leaves an audit row carrying the token prefix and the tool;
|
||||
* no secret appears in any tool output — the reflection redacts, and a token
|
||||
never is part of a payload.
|
||||
|
||||
The tests drive the gateway directly (``McpGateway.call``), which is the layer
|
||||
the SDK server is a thin adapter around: the transport is the SDK's problem, the
|
||||
rules are ours.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.services.mcp_service import (
|
||||
PREFIX_DISPLAY_CHARS,
|
||||
TOKEN_PREFIX,
|
||||
McpService,
|
||||
TokenRateLimiter,
|
||||
)
|
||||
from app.core.config import settings
|
||||
from app.core.errors import ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.domain.identity.models import PlatformRole, Role
|
||||
from app.infrastructure.db.models import (
|
||||
AuditEventRow,
|
||||
MembershipRow,
|
||||
OrganizationRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.db.models_org import McpTokenRow
|
||||
from app.interfaces.mcp.gateway import McpGateway
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
|
||||
async with session_factory() as session:
|
||||
user = UserRow(
|
||||
id=new_id(),
|
||||
email=f"{slug}@example.com",
|
||||
password_hash="x",
|
||||
name=slug,
|
||||
platform_role=PlatformRole.MANAGER.value,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
session.add(
|
||||
MembershipRow(organization_id=org.id, user_id=user.id, role=role.value)
|
||||
)
|
||||
await session.commit()
|
||||
return org.id, user.id
|
||||
|
||||
|
||||
async def _gateway(session_factory, *, limit: int | None = None) -> McpGateway:
|
||||
return McpGateway(
|
||||
session_factory=session_factory,
|
||||
rate_limiter=TokenRateLimiter(limit_per_minute=limit),
|
||||
)
|
||||
|
||||
|
||||
async def _token(
|
||||
session_factory, gateway: McpGateway, org_id: str, *, scopes: list[str],
|
||||
user_id: str | None = None,
|
||||
) -> str:
|
||||
async with session_factory() as session:
|
||||
issued = await McpService(session).issue(
|
||||
organization_id=org_id, name="agent", scopes=scopes,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
await session.commit()
|
||||
return issued.token
|
||||
|
||||
|
||||
async def _call(gateway: McpGateway, token: str, tool: str, **arguments):
|
||||
return await gateway.call(tool=tool, arguments=arguments, token=token)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_token_is_shown_once_and_stored_only_as_a_hash(session_factory):
|
||||
org_id, user_id = await _org(session_factory, "issue")
|
||||
async with session_factory() as session:
|
||||
issued = await McpService(session).issue(
|
||||
organization_id=org_id,
|
||||
name="agent-1",
|
||||
scopes=["tasks:read", "tasks:write"],
|
||||
created_by_user_id=user_id,
|
||||
ttl_days=30,
|
||||
)
|
||||
await session.commit()
|
||||
raw = issued.token
|
||||
rows = (await session.execute(select(McpTokenRow))).scalars().all()
|
||||
assert len(rows) == 1
|
||||
# Only the hash and the display prefix exist; the token itself is gone.
|
||||
assert all(raw not in (row.token_hash or "") for row in rows)
|
||||
assert all(row.prefix == raw[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS] for row in rows)
|
||||
assert issued.record.scopes == ["tasks:read", "tasks:write"]
|
||||
|
||||
|
||||
async def test_unknown_revoked_and_expired_are_all_the_same_answer(session_factory):
|
||||
org_id, _ = await _org(session_factory, "verify")
|
||||
gateway = await _gateway(session_factory)
|
||||
raw = await _token(session_factory, gateway, org_id, scopes=["tasks:read"])
|
||||
|
||||
async with session_factory() as session:
|
||||
service = McpService(session)
|
||||
identity = await service.verify(raw)
|
||||
assert identity is not None and identity.organization_id == org_id
|
||||
# A token for another org id is not this org's business.
|
||||
assert (await service.verify("smo_" + "x" * 24)) is None
|
||||
|
||||
row = (await session.execute(select(McpTokenRow))).scalar_one()
|
||||
row.revoked_at = utcnow()
|
||||
await session.commit()
|
||||
assert await service.verify(raw) is None
|
||||
row.revoked_at = None
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
row.expires_at = datetime.now(UTC) - timedelta(minutes=1)
|
||||
await session.commit()
|
||||
assert await service.verify(raw) is None
|
||||
|
||||
|
||||
async def test_unknown_scopes_are_refused_at_mint_time(session_factory):
|
||||
org_id, _ = await _org(session_factory, "scopes")
|
||||
async with session_factory() as session:
|
||||
with pytest.raises(ValidationError):
|
||||
await McpService(session).issue(
|
||||
organization_id=org_id, name="x", scopes=["root:everything"]
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
await McpService(session).issue(organization_id=org_id, name="x", scopes=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenant isolation and scope enforcement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_one_organizations_token_cannot_read_anothers_app(session_factory, seeded):
|
||||
org_a, _ = await _org(session_factory, "mcp-a")
|
||||
org_b, user_b = await _org(session_factory, "mcp-b")
|
||||
gateway = await _gateway(session_factory)
|
||||
token_a = await _token(
|
||||
session_factory, gateway, org_a, scopes=["app:read", "tasks:read"]
|
||||
)
|
||||
|
||||
# org A has no project yet, so the honest answer is a refusal, not org B's
|
||||
# data. The important part is what it is *not*: never org B's app.
|
||||
result = await _call(gateway, token_a, "app_get_profile")
|
||||
assert result.ok is False
|
||||
|
||||
# Even with the id handed to it, the tool refuses: identity comes from the token.
|
||||
result = await _call(gateway, token_a, "tasks_list")
|
||||
assert result.ok is True
|
||||
assert result.payload == {"tasks": []}
|
||||
|
||||
# A token minted for B does not exist for A either.
|
||||
other = await _token(session_factory, gateway, org_b, scopes=["app:read"], user_id=user_b)
|
||||
assert (await _call(gateway, other, "app_get_profile")).ok is False
|
||||
|
||||
|
||||
async def test_scope_enforcement_is_per_tool(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "scope-org")
|
||||
gateway = await _gateway(session_factory)
|
||||
reader = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
|
||||
# Reading is allowed.
|
||||
listed = await _call(gateway, reader, "tasks_list")
|
||||
assert listed.ok is True
|
||||
# Writing is not: the scope is missing, and the refusal names the reason.
|
||||
created = await _call(gateway, reader, "tasks_create", title="کار تازه")
|
||||
assert created.ok is False
|
||||
assert created.code == "scope_denied"
|
||||
assert "دسترسی" in (created.error or "")
|
||||
|
||||
# A refused write leaves nothing behind.
|
||||
async with session_factory() as session:
|
||||
from app.infrastructure.db.models import TaskRow
|
||||
|
||||
rows = (await session.execute(select(TaskRow))).scalars().all()
|
||||
assert rows == []
|
||||
|
||||
|
||||
async def test_memory_write_through_a_token_is_only_fact_or_preference(
|
||||
session_factory, seeded
|
||||
):
|
||||
org_id, user_id = await _org(session_factory, "mem-write")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
ok = await _call(
|
||||
gateway, token, "memory_write", kind="fact", content="پورت اپ ۸۰۰۰ است."
|
||||
)
|
||||
assert ok.ok is True
|
||||
preference = await _call(
|
||||
gateway, token, "memory_write", kind="preference", content="ترجیح: فارسی بنویس."
|
||||
)
|
||||
assert preference.ok is True
|
||||
|
||||
# A rule is not an agent's to set, and a lesson is the engine's to learn.
|
||||
refused_rule = await _call(
|
||||
gateway, token, "memory_write", kind="rule", content="همیشه تست بنویس."
|
||||
)
|
||||
assert refused_rule.ok is False
|
||||
refused_lesson = await _call(
|
||||
gateway, token, "memory_write", kind="lesson", content="درس تازه."
|
||||
)
|
||||
assert refused_lesson.ok is False
|
||||
|
||||
# What it wrote is searchable, and what it may not write is not there.
|
||||
found = await _call(gateway, token, "memory_search", query="پورت")
|
||||
assert found.ok is True
|
||||
contents = [m["content"] for m in found.payload["memories"]]
|
||||
assert any("۸۰۰۰" in content for content in contents)
|
||||
assert not any("همیشه تست بنویس" in content for content in contents)
|
||||
|
||||
|
||||
async def test_a_secret_written_through_a_token_never_comes_back(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "mem-secret")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["memory:read", "memory:write"],
|
||||
user_id=user_id,
|
||||
)
|
||||
await _call(
|
||||
gateway, token, "memory_write", kind="fact",
|
||||
content="کلید CI استفادهشده ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است.",
|
||||
)
|
||||
found = await _call(gateway, token, "memory_search", query="کلید")
|
||||
assert found.ok is True
|
||||
text = found.to_json()
|
||||
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in text
|
||||
|
||||
|
||||
async def test_knowledge_and_pipeline_tools(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "knowledge-org")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["memory:read", "delivery:read"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# No sources yet: an empty result, not an error.
|
||||
empty = await _call(gateway, token, "knowledge_search", query="هرچی")
|
||||
assert empty.ok is True
|
||||
assert empty.payload["results"] == []
|
||||
|
||||
# Delivery is another workstream's table; the tool exists and says so.
|
||||
status = await _call(gateway, token, "pipeline_status")
|
||||
assert status.ok is True
|
||||
assert status.payload["available"] is False
|
||||
|
||||
|
||||
async def test_a_task_created_through_a_token_is_attributed_to_its_minter(
|
||||
session_factory, seeded
|
||||
):
|
||||
org_id, user_id = await _org(session_factory, "task-org")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:write", "tasks:read"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
created = await _call(
|
||||
gateway, token, "tasks_create", title="پیادهسازی ورود", description="صفحهٔ ورود"
|
||||
)
|
||||
assert created.ok is True, created.error
|
||||
task_id = created.payload["task"]["id"]
|
||||
|
||||
fetched = await _call(gateway, token, "tasks_get", task_id=task_id)
|
||||
assert fetched.ok is True
|
||||
assert fetched.payload["task"]["title"] == "پیادهسازی ورود"
|
||||
|
||||
# The write is attributed to the administrator who minted the token: a
|
||||
# delegated key never erases who holds it.
|
||||
async with session_factory() as session:
|
||||
from app.infrastructure.db.models import TaskRow
|
||||
|
||||
row = await session.get(TaskRow, task_id)
|
||||
assert row is not None
|
||||
assert row.created_by_user_id == user_id
|
||||
|
||||
# …and a task from another organization is a 404, not data.
|
||||
other_org, _ = await _org(session_factory, "task-other")
|
||||
other_token = await _token(
|
||||
session_factory, gateway, other_org, scopes=["tasks:read", "tasks:write"]
|
||||
)
|
||||
foreign = await _call(gateway, other_token, "tasks_get", task_id=task_id)
|
||||
assert foreign.ok is False
|
||||
|
||||
|
||||
async def test_a_bad_token_or_a_disabled_switch_is_refused_everywhere(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "off-switch")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
|
||||
assert (await _call(gateway, "smo_wrong", "tasks_list")).ok is False
|
||||
assert (await _call(gateway, "", "tasks_list")).ok is False
|
||||
assert (await _call(gateway, token, "no_such_tool")).ok is False
|
||||
|
||||
original = settings.mcp_gateway_enabled
|
||||
settings.mcp_gateway_enabled = False
|
||||
try:
|
||||
result = await _call(gateway, token, "tasks_list")
|
||||
assert result.ok is False
|
||||
assert result.code == "mcp_disabled"
|
||||
finally:
|
||||
settings.mcp_gateway_enabled = original
|
||||
|
||||
# The per-organization switch is the owner's, not the token's.
|
||||
async with session_factory() as session:
|
||||
from app.application.container_org import build_model_grant_service
|
||||
|
||||
await build_model_grant_service(session).set_mcp_enabled(
|
||||
organization_id=org_id, enabled=False, actor_user_id=user_id
|
||||
)
|
||||
await session.commit()
|
||||
result = await _call(gateway, token, "tasks_list")
|
||||
assert result.ok is False
|
||||
assert result.code == "org_disabled"
|
||||
|
||||
|
||||
async def test_the_rate_limit_refuses_the_sixty_first_call(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "rate")
|
||||
gateway = await _gateway(session_factory, limit=5)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
codes = [
|
||||
(await _call(gateway, token, "tasks_list")).ok for _ in range(7)
|
||||
]
|
||||
assert codes == [True] * 5 + [False, False]
|
||||
limited = await _call(gateway, token, "tasks_list")
|
||||
assert limited.code == "rate_limited"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_every_call_leaves_an_audit_row_with_the_prefix(session_factory, seeded):
|
||||
org_id, user_id = await _org(session_factory, "audit")
|
||||
gateway = await _gateway(session_factory)
|
||||
token = await _token(
|
||||
session_factory, gateway, org_id, scopes=["tasks:read"], user_id=user_id
|
||||
)
|
||||
await _call(gateway, token, "tasks_list")
|
||||
await _call(gateway, token, "tasks_create", title="رد شده")
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
(
|
||||
await session.execute(
|
||||
select(AuditEventRow).where(
|
||||
AuditEventRow.organization_id == org_id,
|
||||
AuditEventRow.action == "mcp.tool_call",
|
||||
)
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
assert len(rows) == 2
|
||||
by_tool = {row.target_id: row for row in rows}
|
||||
assert set(by_tool) == {"tasks_list", "tasks_create"}
|
||||
# The prefix identifies the token; the token itself never appears.
|
||||
prefix = token[: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
|
||||
assert all(row.metadata_json["prefix"] == prefix for row in rows)
|
||||
assert all(token not in str(row.metadata_json) for row in rows)
|
||||
assert by_tool["tasks_list"].metadata_json["ok"] is True
|
||||
assert by_tool["tasks_create"].metadata_json["ok"] is False
|
||||
assert by_tool["tasks_create"].metadata_json["error"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP surface: tokens and the owner's switches
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
|
||||
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
|
||||
org = await client.post(
|
||||
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
|
||||
)
|
||||
assert org.status_code == 201, org.text
|
||||
return {"token": body["access_token"], "org_id": org.json()["id"]}
|
||||
|
||||
|
||||
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
|
||||
invite = await client.post(
|
||||
f"/api/organizations/{org_id}/invites",
|
||||
json={"email": email, "role": role},
|
||||
headers=auth_header(owner_token),
|
||||
)
|
||||
assert invite.status_code == 201, invite.text
|
||||
account = await signup(client, email, name=role)
|
||||
accepted = await client.post(
|
||||
"/api/invites/accept",
|
||||
json={"token": invite.json()["invite_token"]},
|
||||
headers=auth_header(account["access_token"]),
|
||||
)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
return account["access_token"]
|
||||
|
||||
|
||||
async def test_only_an_admin_mints_tokens_and_only_the_owner_switches_the_gateway(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
):
|
||||
org = await _owner_with_org(client, "mcp-org")
|
||||
viewer = await _member(client, org["token"], org["org_id"], "v@example.com", "viewer")
|
||||
admin = await _member(client, org["token"], org["org_id"], "a@example.com", "admin")
|
||||
|
||||
# A viewer reads the list but cannot mint.
|
||||
listing = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(viewer)
|
||||
)
|
||||
assert listing.status_code == 200, listing.text
|
||||
assert listing.json() == []
|
||||
refused = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens",
|
||||
json={"name": "nope", "scopes": ["tasks:read"]},
|
||||
headers=auth_header(viewer),
|
||||
)
|
||||
assert refused.status_code == 403, refused.text
|
||||
|
||||
# The owner's switches are the owner's.
|
||||
not_owner = await client.get("/api/admin/mcp", headers=auth_header(manager_token))
|
||||
assert not_owner.status_code == 403
|
||||
anonymous = await client.get("/api/admin/mcp")
|
||||
assert anonymous.status_code == 401
|
||||
|
||||
# An admin mints one, sees the raw token once, and it never returns.
|
||||
created = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens",
|
||||
json={"name": "agent-1", "scopes": ["tasks:read", "tasks:write"]},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
body = created.json()
|
||||
assert body["token"].startswith("smo_")
|
||||
assert body["prefix"] == body["token"][: len(TOKEN_PREFIX) + PREFIX_DISPLAY_CHARS]
|
||||
assert body["scopes"] == ["tasks:read", "tasks:write"]
|
||||
assert body["status"] == "active"
|
||||
|
||||
again = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens", headers=auth_header(admin)
|
||||
)
|
||||
assert again.status_code == 200, again.text
|
||||
assert "token" not in again.json()[0]
|
||||
assert again.json()[0]["prefix"] == body["prefix"]
|
||||
|
||||
# An unknown scope is refused rather than silently dropped.
|
||||
bad_scope = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens",
|
||||
json={"name": "bad", "scopes": ["root:all"]},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert bad_scope.status_code == 422, bad_scope.text
|
||||
|
||||
# Revoke, then revoking again is a 404 — the same idempotence rule as grants.
|
||||
revoked = await client.delete(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
|
||||
)
|
||||
assert revoked.status_code == 200, revoked.text
|
||||
twice = await client.delete(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens/{body['id']}", headers=auth_header(admin)
|
||||
)
|
||||
assert twice.status_code == 404
|
||||
|
||||
# The owner sees the gateway state and may switch it and one organization.
|
||||
state = await client.get("/api/admin/mcp", headers=auth_header(platform_owner_token))
|
||||
assert state.status_code == 200, state.text
|
||||
assert state.json()["gateway_enabled"] is True
|
||||
assert any(
|
||||
org_row["organization_id"] == org["org_id"]
|
||||
for org_row in state.json()["organizations"]
|
||||
)
|
||||
|
||||
switched = await client.put(
|
||||
"/api/admin/mcp",
|
||||
json={"enabled": False},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert switched.status_code == 200, switched.text
|
||||
assert switched.json()["gateway_enabled"] is False
|
||||
org_switch = await client.put(
|
||||
f"/api/admin/mcp/organizations/{org['org_id']}",
|
||||
json={"enabled": False},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert org_switch.status_code == 200, org_switch.text
|
||||
assert org_switch.json()["mcp_enabled"] is False
|
||||
settings_view = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/mcp-tokens/settings",
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert settings_view.json()["mcp_enabled"] is False
|
||||
assert settings_view.json()["gateway_enabled"] is False
|
||||
assert "tasks:read" in settings_view.json()["scopes"]
|
||||
@@ -0,0 +1,647 @@
|
||||
"""Org memory: what an organization learns about itself.
|
||||
|
||||
The invariants under test:
|
||||
|
||||
* teaching the same thing twice teaches nothing (dedupe by hash, corrections by
|
||||
key);
|
||||
* memory is capped and evicts the oldest *unpinned lesson* first — never a rule;
|
||||
* one task may write at most three memories;
|
||||
* the context block respects its budget, leads with pinned rules, and filters by
|
||||
role (only code-reading roles see the code map; only the manager sees
|
||||
preferences);
|
||||
* a reflection that fails never raises into the engine;
|
||||
* nothing secret is ever stored, and no organization ever sees another's memory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.container_org import build_memory, build_memory_service
|
||||
from app.application.ports.memory import TaskOutcome
|
||||
from app.core.config import settings
|
||||
from app.core.errors import NotFoundError, ValidationError
|
||||
from app.domain.base import new_id, utcnow
|
||||
from app.domain.identity.models import PlatformRole, Role
|
||||
from app.domain.memory.models import (
|
||||
CONTENT_MAX_CHARS,
|
||||
KEY_MAX_CHARS,
|
||||
MAX_WRITES_PER_TASK,
|
||||
MemoryKind,
|
||||
MemorySource,
|
||||
content_hash,
|
||||
lesson_expiry,
|
||||
normalise,
|
||||
tokenise,
|
||||
validate_content,
|
||||
validate_key,
|
||||
visible_kinds,
|
||||
)
|
||||
from app.infrastructure.db.models import (
|
||||
AuditEventRow,
|
||||
MembershipRow,
|
||||
OrganizationRow,
|
||||
TaskRow,
|
||||
UsageEventRow,
|
||||
UserRow,
|
||||
)
|
||||
from app.infrastructure.db.models_org import OrgMemoryRow
|
||||
from tests.conftest import auth_header, signup
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _task(session_factory, org_id: str, task_id: str) -> str:
|
||||
"""A real task row: ``evidence_task_id`` is a real reference, not a string."""
|
||||
async with session_factory() as session:
|
||||
session.add(
|
||||
TaskRow(id=task_id, organization_id=org_id, title=f"کار {task_id}")
|
||||
)
|
||||
await session.commit()
|
||||
return task_id
|
||||
|
||||
|
||||
async def _org(session_factory, slug: str, role: Role = Role.OWNER) -> tuple[str, str]:
|
||||
"""An organization with one member, written directly to the database."""
|
||||
async with session_factory() as session:
|
||||
user = UserRow(
|
||||
id=new_id(),
|
||||
email=f"{slug}@example.com",
|
||||
password_hash="x",
|
||||
name=slug,
|
||||
platform_role=PlatformRole.MANAGER.value,
|
||||
)
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(id=new_id(), name=slug, slug=slug, owner_user_id=user.id)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
session.add(
|
||||
MembershipRow(organization_id=org.id, user_id=user.id, role=role.value)
|
||||
)
|
||||
await session.commit()
|
||||
return org.id, user.id
|
||||
|
||||
|
||||
class _ShapedProvider:
|
||||
"""A provider that answers the reflection prompt in the template it asks for."""
|
||||
|
||||
def __init__(self, answer: str) -> None:
|
||||
self.answer = answer
|
||||
self.calls = 0
|
||||
|
||||
async def complete(self, **_kwargs):
|
||||
from app.application.ports import ModelResponse
|
||||
|
||||
self.calls += 1
|
||||
return ModelResponse(
|
||||
content=self.answer,
|
||||
model_internal_name="fake",
|
||||
input_tokens=40,
|
||||
output_tokens=12,
|
||||
)
|
||||
|
||||
async def health(self):
|
||||
from app.application.ports import ModelHealth
|
||||
|
||||
return ModelHealth(ok=True, detail="shaped fake")
|
||||
|
||||
|
||||
class _BrokenProvider:
|
||||
"""A provider that fails exactly like a network outage would."""
|
||||
|
||||
async def complete(self, **_kwargs):
|
||||
raise RuntimeError("provider is unreachable: token=super-secret-token")
|
||||
|
||||
async def health(self):
|
||||
from app.application.ports import ModelHealth
|
||||
|
||||
return ModelHealth(ok=False, detail="down")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _service(session_factory, provider):
|
||||
"""A memory service on its own session, committed when the block exits."""
|
||||
session = session_factory()
|
||||
try:
|
||||
service = build_memory_service(session, provider=provider)
|
||||
yield service
|
||||
await session.commit()
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Domain rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_normalisation_and_dedupe_hash():
|
||||
assert normalise(" the API runs \n on 8000 ") == "the API runs on 8000"
|
||||
assert content_hash("The API runs on 8000") == content_hash(" the api runs on 8000 ")
|
||||
assert content_hash("the API runs on 8000") != content_hash("the API runs on 8001")
|
||||
|
||||
|
||||
def test_limits_are_enforced_in_the_domain():
|
||||
assert len(validate_key("k" * KEY_MAX_CHARS)) == KEY_MAX_CHARS
|
||||
with pytest.raises(ValidationError):
|
||||
validate_key("k" * (KEY_MAX_CHARS + 1))
|
||||
assert validate_content(" x ") == "x"
|
||||
with pytest.raises(ValidationError):
|
||||
validate_content("x" * (CONTENT_MAX_CHARS + 1))
|
||||
|
||||
|
||||
def test_role_filtering_of_kinds():
|
||||
# A reviewer reads the code map; support does not.
|
||||
assert MemoryKind.CODE_MAP in visible_kinds("reviewer")
|
||||
assert MemoryKind.CODE_MAP not in visible_kinds("support")
|
||||
assert MemoryKind.CODE_MAP in visible_kinds("developer")
|
||||
# Preferences belong to the manager alone.
|
||||
assert MemoryKind.PREFERENCE in visible_kinds("manager")
|
||||
assert MemoryKind.PREFERENCE not in visible_kinds("developer")
|
||||
# Rules and lessons are everyone's.
|
||||
for role in ("manager", "developer", "support", "researcher"):
|
||||
assert visible_kinds(role) >= {MemoryKind.RULE, MemoryKind.LESSON}
|
||||
|
||||
|
||||
def test_lessons_expire_unless_pinned_or_confirmed():
|
||||
assert lesson_expiry(pinned=False, confirmed=False) is not None
|
||||
assert lesson_expiry(pinned=True, confirmed=False) is None
|
||||
assert lesson_expiry(pinned=False, confirmed=True) is None
|
||||
|
||||
|
||||
def test_tokenise_drops_stop_words():
|
||||
words = tokenise("the API and the manager را")
|
||||
assert "api" in words and "manager" in words
|
||||
assert "the" not in words and "را" not in words
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# record: dedupe, correction, cap, task ceiling, redaction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_recording_the_same_thing_twice_updates_one_memory(session_factory):
|
||||
org_id, _ = await _org(session_factory, "dedupe")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
first = await service.record(
|
||||
organization_id=org_id, kind="rule", content="همیشه تست بنویس",
|
||||
source="user",
|
||||
)
|
||||
second = await service.record(
|
||||
organization_id=org_id, kind="rule", content=" همیشه تست بنویس ",
|
||||
source="user",
|
||||
)
|
||||
assert first == second
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
(await session.execute(select(OrgMemoryRow))).scalars().all()
|
||||
)
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
async def test_a_key_corrects_instead_of_duplicating(session_factory):
|
||||
org_id, _ = await _org(session_factory, "keyed")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.record(
|
||||
organization_id=org_id, kind="fact", content="پورت ۸۰۰۰ است",
|
||||
source="user", key="app_port",
|
||||
)
|
||||
memory_id = await service.record(
|
||||
organization_id=org_id, kind="fact", content="پورت ۸۰۰۱ است",
|
||||
source="user", key="app_port",
|
||||
)
|
||||
memory = await service.get(org_id, memory_id)
|
||||
assert memory.content == "پورت ۸۰۰۱ است"
|
||||
memories = await service.list(org_id)
|
||||
assert len(memories) == 1
|
||||
|
||||
|
||||
async def test_the_cap_evicts_the_oldest_unpinned_lesson_first(session_factory, monkeypatch):
|
||||
monkeypatch.setattr(settings, "memory_org_char_cap", 200)
|
||||
org_id, _ = await _org(session_factory, "capped")
|
||||
await _task(session_factory, org_id, "t-pinned")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
# A pinned lesson survives; the rest are evicted oldest-first.
|
||||
pinned = await service.record(
|
||||
organization_id=org_id, kind="lesson", content="پ" * 40,
|
||||
source="reflection", evidence_task_id="t-pinned",
|
||||
)
|
||||
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
|
||||
for index in range(6):
|
||||
await _task(session_factory, org_id, f"t-{index}")
|
||||
await service.record(
|
||||
organization_id=org_id, kind="lesson", content=f"درس {index}" + "ی" * 30,
|
||||
source="reflection", evidence_task_id=f"t-{index}",
|
||||
)
|
||||
memories = await service.list(org_id)
|
||||
# Budget spent, and whatever fell out is the oldest, never the pinned one.
|
||||
assert (await service.stats(org_id))["chars"] <= 200
|
||||
ids = {m.id for m in memories}
|
||||
assert pinned in ids
|
||||
# Only lessons are evictable, so a rule or a preference is never the
|
||||
# victim — and the budget is honoured.
|
||||
assert all(m.kind is MemoryKind.LESSON for m in memories)
|
||||
|
||||
|
||||
async def test_one_task_may_write_at_most_three_memories(session_factory):
|
||||
org_id, _ = await _org(session_factory, "three-writes")
|
||||
await _task(session_factory, org_id, "task-1")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
written = [
|
||||
await service.record(
|
||||
organization_id=org_id, kind="lesson", content=f"درس شمارهٔ {index}",
|
||||
source="reflection", evidence_task_id="task-1",
|
||||
)
|
||||
for index in range(MAX_WRITES_PER_TASK + 2)
|
||||
]
|
||||
assert written[:MAX_WRITES_PER_TASK]
|
||||
# The 4th write is refused — ``None``, not an exception.
|
||||
assert written[MAX_WRITES_PER_TASK] is None
|
||||
assert written[MAX_WRITES_PER_TASK + 1] is None
|
||||
rows = await service.list(org_id)
|
||||
assert len(rows) == MAX_WRITES_PER_TASK
|
||||
|
||||
|
||||
async def test_nothing_secret_is_ever_stored(session_factory):
|
||||
org_id, _ = await _org(session_factory, "redacted")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.record(
|
||||
organization_id=org_id, kind="rule",
|
||||
content="کلید API کل من ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123 است",
|
||||
source="user",
|
||||
)
|
||||
memories = await service.list(org_id)
|
||||
assert "ghp_ABCDEFGHIJKLMNOPQRSTUVWXyz123" not in memories[0].content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_context_leads_with_pinned_rules_and_respects_the_budget(session_factory):
|
||||
org_id, _ = await _org(session_factory, "context")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
pinned_rule = await service.record(
|
||||
organization_id=org_id, kind="rule", content="قاعدهٔ سنجاقشده", source="user"
|
||||
)
|
||||
await service.set_pinned(organization_id=org_id, memory_id=pinned_rule, pinned=True)
|
||||
await service.record(
|
||||
organization_id=org_id, kind="preference", content="ترجیح معمولی", source="user"
|
||||
)
|
||||
for index in range(20):
|
||||
await service.record(
|
||||
organization_id=org_id, kind="lesson", content=f"درس قدیمی {index}" + " ز" * 20,
|
||||
source="user",
|
||||
)
|
||||
|
||||
block = await service.build_context(
|
||||
organization_id=org_id, role="manager", query="درس", budget_chars=300
|
||||
)
|
||||
assert block
|
||||
assert len(block) <= 300
|
||||
assert "قاعدهٔ سنجاقشده" in block.splitlines()[1]
|
||||
assert block.index("قاعدهٔ سنجاقشده") < block.index("ترجیح معمولی")
|
||||
|
||||
# A budget so small that nothing fits returns nothing, not a broken block.
|
||||
assert await service.build_context(
|
||||
organization_id=org_id, role="manager", query="درس", budget_chars=4
|
||||
) == ""
|
||||
|
||||
|
||||
async def test_context_filters_kinds_by_role(session_factory):
|
||||
org_id, _ = await _org(session_factory, "roles")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.record(
|
||||
organization_id=org_id, kind="code_map",
|
||||
content="نقشهٔ کد: مدلها در db/models.py", source="system",
|
||||
)
|
||||
await service.record(
|
||||
organization_id=org_id, kind="preference", content="ترجیح: فارسی بنویس",
|
||||
source="user",
|
||||
)
|
||||
developer = await service.build_context(
|
||||
organization_id=org_id, role="developer", query="db", budget_chars=2000
|
||||
)
|
||||
manager = await service.build_context(
|
||||
organization_id=org_id, role="manager", query="db", budget_chars=2000
|
||||
)
|
||||
support = await service.build_context(
|
||||
organization_id=org_id, role="support", query="db", budget_chars=2000
|
||||
)
|
||||
assert "نقشهٔ کد" in developer and "ترجیح" not in developer
|
||||
assert "ترجیح" in manager and "نقشهٔ کد" not in manager
|
||||
assert support == ""
|
||||
|
||||
|
||||
async def test_context_counts_a_hit(session_factory):
|
||||
org_id, _ = await _org(session_factory, "hits")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
memory_id = await service.record(
|
||||
organization_id=org_id, kind="rule", content="هر تست قبل از کد", source="user"
|
||||
)
|
||||
await service.build_context(
|
||||
organization_id=org_id, role="developer", query="تست", budget_chars=500
|
||||
)
|
||||
memory = await service.get(org_id, memory_id)
|
||||
assert memory.hits == 1
|
||||
assert memory.last_used_at is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reflection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_reflection_stores_lessons_and_prices_the_call(session_factory, seeded):
|
||||
org_id, _ = await _org(session_factory, "reflect")
|
||||
await _task(session_factory, org_id, "task-77")
|
||||
provider = _ShapedProvider(
|
||||
"درس ۱: تستها را قبل از ادغام اجرا کن.\n"
|
||||
"درس ۲: دروازهٔ lint دو بار شکست.\n"
|
||||
"نقشهٔ کد: app/db/models.py منبع حقیقت است.\n"
|
||||
"یک خط اضافی که نادیده گرفته میشود."
|
||||
)
|
||||
async with _service(session_factory, provider) as service:
|
||||
await service.on_task_finished(
|
||||
organization_id=org_id,
|
||||
task=TaskOutcome(
|
||||
task_id="task-77", title="ساخت صفحهٔ ورود", status="completed",
|
||||
summary="تمام شد", changed_files=["a.py"], cycles=2, gate_failures=["lint"],
|
||||
),
|
||||
)
|
||||
memories = await service.list(org_id)
|
||||
kinds = [m.kind for m in memories]
|
||||
assert MemoryKind.LESSON in kinds
|
||||
assert MemoryKind.CODE_MAP in kinds
|
||||
assert len(memories) == 3
|
||||
assert all(m.source is MemorySource.REFLECTION for m in memories)
|
||||
assert all(m.confidence == 0.5 for m in memories)
|
||||
assert all(m.evidence_task_id == "task-77" for m in memories)
|
||||
|
||||
# The reflection is billed like any other model call.
|
||||
async with session_factory() as session:
|
||||
usage = (
|
||||
(await session.execute(select(UsageEventRow))).scalars().all()
|
||||
)
|
||||
assert usage
|
||||
assert all(row.metadata_json.get("memory_reflection") for row in usage)
|
||||
|
||||
|
||||
async def test_a_failing_reflection_never_raises_and_records_the_error(
|
||||
session_factory, redirect_error_log, seeded
|
||||
):
|
||||
org_id, _ = await _org(session_factory, "reflect-fail")
|
||||
async with _service(session_factory, _BrokenProvider()) as service:
|
||||
# No exception escapes, and the org keeps its memory untouched.
|
||||
await service.on_task_finished(
|
||||
organization_id=org_id,
|
||||
task=TaskOutcome(task_id="task-err", title="t", status="failed"),
|
||||
)
|
||||
assert await service.list(org_id) == []
|
||||
|
||||
from app.infrastructure.db.models import ErrorEventRow
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
|
||||
assert any(row.kind == "memory.reflection_failed" for row in rows)
|
||||
# A provider's failure text is not trusted verbatim: either it is redacted,
|
||||
# or it never reached the row at all.
|
||||
for row in rows:
|
||||
if "super-secret-token" in (row.message or ""):
|
||||
assert "REDACTED" in row.message
|
||||
|
||||
|
||||
async def test_reflection_without_a_model_is_recorded_not_raised(
|
||||
session_factory, redirect_error_log
|
||||
):
|
||||
"""No seeded catalogue → ``pick_for_mode`` returns None → something is recorded."""
|
||||
org_id, _ = await _org(session_factory, "no-model")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
await service.on_task_finished(
|
||||
organization_id=org_id,
|
||||
task=TaskOutcome(task_id="task-x", title="t", status="completed"),
|
||||
)
|
||||
assert await service.list(org_id) == []
|
||||
|
||||
from app.infrastructure.db.models import ErrorEventRow
|
||||
|
||||
async with session_factory() as session:
|
||||
rows = (await session.execute(select(ErrorEventRow))).scalars().all()
|
||||
assert any(row.kind == "memory.reflection_no_model" for row in rows)
|
||||
|
||||
|
||||
async def test_expired_lessons_are_removed_by_the_expiry_job(session_factory):
|
||||
org_id, _ = await _org(session_factory, "expiry")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
expired = await service.record(
|
||||
organization_id=org_id, kind="lesson", content="درس منقضی", source="reflection"
|
||||
)
|
||||
pinned = await service.record(
|
||||
organization_id=org_id, kind="lesson", content="درس سنجاقشده", source="reflection"
|
||||
)
|
||||
await service.set_pinned(organization_id=org_id, memory_id=pinned, pinned=True)
|
||||
async with session_factory() as session:
|
||||
row = await session.get(OrgMemoryRow, expired)
|
||||
row.expires_at = utcnow().replace(year=2020) # long past
|
||||
await session.commit()
|
||||
|
||||
assert await service.expire_memories() == 1
|
||||
remaining = {m.id for m in await service.list(org_id)}
|
||||
assert expired not in remaining
|
||||
assert pinned in remaining
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tenant isolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_one_organization_never_sees_anothers_memory(session_factory):
|
||||
org_a, _ = await _org(session_factory, "tenant-a")
|
||||
org_b, _ = await _org(session_factory, "tenant-b")
|
||||
async with _service(session_factory, _ShapedProvider("")) as service:
|
||||
memory_id = await service.record(
|
||||
organization_id=org_a, kind="rule", content="قاعدهٔ سازمان الف", source="user"
|
||||
)
|
||||
assert memory_id not in {m.id for m in await service.list(org_b)}
|
||||
with pytest.raises(NotFoundError):
|
||||
await service.get(org_b, memory_id)
|
||||
block = await service.build_context(
|
||||
organization_id=org_b, role="manager", query="قاعده", budget_chars=500
|
||||
)
|
||||
assert block == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP surface
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _owner_with_org(client: AsyncClient, slug: str) -> dict:
|
||||
body = await signup(client, f"{slug}-owner@example.com", name="Owner")
|
||||
org = await client.post(
|
||||
"/api/organizations", json={"name": slug}, headers=auth_header(body["access_token"])
|
||||
)
|
||||
assert org.status_code == 201, org.text
|
||||
return {"token": body["access_token"], "org_id": org.json()["id"]}
|
||||
|
||||
|
||||
async def _member(client: AsyncClient, owner_token: str, org_id: str, email: str, role: str):
|
||||
invite = await client.post(
|
||||
f"/api/organizations/{org_id}/invites",
|
||||
json={"email": email, "role": role},
|
||||
headers=auth_header(owner_token),
|
||||
)
|
||||
assert invite.status_code == 201, invite.text
|
||||
account = await signup(client, email, name=role)
|
||||
accepted = await client.post(
|
||||
"/api/invites/accept",
|
||||
json={"token": invite.json()["invite_token"]},
|
||||
headers=auth_header(account["access_token"]),
|
||||
)
|
||||
assert accepted.status_code == 200, accepted.text
|
||||
return account["access_token"]
|
||||
|
||||
|
||||
async def test_memory_http_permissions(client: AsyncClient):
|
||||
org = await _owner_with_org(client, "memory-org")
|
||||
viewer = await _member(client, org["token"], org["org_id"], "viewer@example.com", "viewer")
|
||||
member = await _member(client, org["token"], org["org_id"], "member@example.com", "member")
|
||||
admin = await _member(client, org["token"], org["org_id"], "admin@example.com", "admin")
|
||||
|
||||
# A viewer reads but cannot teach.
|
||||
empty = await client.get(
|
||||
f"/api/organizations/{org['org_id']}/memory", headers=auth_header(viewer)
|
||||
)
|
||||
assert empty.status_code == 200, empty.text
|
||||
assert empty.json()["items"] == []
|
||||
refused = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "rule", "content": "قاعدهٔ ممنوعه"},
|
||||
headers=auth_header(viewer),
|
||||
)
|
||||
assert refused.status_code == 403, refused.text
|
||||
|
||||
# A member teaches a rule; a lesson is refused (teaching is human-only).
|
||||
taught = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "rule", "content": "قاعدهٔ تازه", "key": "style"},
|
||||
headers=auth_header(member),
|
||||
)
|
||||
assert taught.status_code == 201, taught.text
|
||||
memory_id = taught.json()["id"]
|
||||
assert taught.json()["kind_label"] == "قاعده"
|
||||
lesson = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "lesson", "content": "درس دستنویس"},
|
||||
headers=auth_header(member),
|
||||
)
|
||||
assert lesson.status_code == 403, lesson.text
|
||||
|
||||
# Editing is admin+.
|
||||
edited = await client.patch(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
|
||||
json={"content": "قاعدهٔ ویرایششده"},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert edited.status_code == 200, edited.text
|
||||
assert edited.json()["content"] == "قاعدهٔ ویرایششده"
|
||||
member_edit = await client.patch(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
|
||||
json={"content": "تلاش عضو"},
|
||||
headers=auth_header(member),
|
||||
)
|
||||
assert member_edit.status_code == 403, member_edit.text
|
||||
|
||||
# Pin, then confirm (only a lesson may be confirmed).
|
||||
pinned = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
|
||||
json={"pinned": True},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert pinned.json()["pinned"] is True
|
||||
not_a_lesson = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}/confirm",
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert not_a_lesson.status_code == 422, not_a_lesson.text
|
||||
|
||||
# Remembering without a session is not a thing.
|
||||
anonymous = await client.get(f"/api/organizations/{org['org_id']}/memory")
|
||||
assert anonymous.status_code == 401
|
||||
|
||||
|
||||
async def test_memory_audit_and_isolation(client: AsyncClient, session_factory):
|
||||
org = await _owner_with_org(client, "audited-memory")
|
||||
other = await _owner_with_org(client, "other-memory")
|
||||
admin = await _member(client, org["token"], org["org_id"], "a2@example.com", "admin")
|
||||
|
||||
created = await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory",
|
||||
json={"kind": "rule", "content": "قاعدهٔ حسابرسی"},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
memory_id = created.json()["id"]
|
||||
|
||||
await client.patch(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}",
|
||||
json={"content": "قاعدهٔ حسابرسی، ویرایششده"},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
await client.post(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}/pin",
|
||||
json={"pinned": True},
|
||||
headers=auth_header(admin),
|
||||
)
|
||||
await client.delete(
|
||||
f"/api/organizations/{org['org_id']}/memory/{memory_id}", headers=auth_header(admin)
|
||||
)
|
||||
|
||||
async with session_factory() as session:
|
||||
actions = [
|
||||
row.action
|
||||
for row in (
|
||||
await session.execute(
|
||||
select(AuditEventRow).where(
|
||||
AuditEventRow.organization_id == org["org_id"]
|
||||
)
|
||||
)
|
||||
).scalars()
|
||||
]
|
||||
assert {"memory.create", "memory.update", "memory.pin", "memory.delete"} <= set(actions)
|
||||
|
||||
# Another organization's memory is invisible here, never another tenant's
|
||||
# data: the soft-deleted row exists, and the list is still empty.
|
||||
stranger = await client.get(
|
||||
f"/api/organizations/{other['org_id']}/memory",
|
||||
headers=auth_header(other["token"]),
|
||||
)
|
||||
assert stranger.status_code == 200, stranger.text
|
||||
assert stranger.json()["items"] == []
|
||||
|
||||
|
||||
async def test_the_real_port_is_wired_in_the_container(session_factory):
|
||||
from app.application.ports.memory import MemoryPort, NullMemory
|
||||
|
||||
org_id, _ = await _org(session_factory, "wired")
|
||||
async with session_factory() as session:
|
||||
memory = build_memory(session)
|
||||
assert isinstance(memory, MemoryPort)
|
||||
assert not isinstance(memory, NullMemory)
|
||||
recorded = await memory.record(
|
||||
organization_id=org_id, kind="rule", content="قاعدهٔ سیمکشیشده", source="user"
|
||||
)
|
||||
assert recorded
|
||||
block = await memory.build_context(
|
||||
organization_id=org_id, role="manager", query="سیم", budget_chars=200
|
||||
)
|
||||
assert "قاعدهٔ سیمکشیشده" in block
|
||||
@@ -0,0 +1,395 @@
|
||||
"""Owner model grants, the default set and org policy.
|
||||
|
||||
The invariant under test: **an organization may only use the models the owner
|
||||
granted it.** Two switches have to agree (model active + grant active), which is
|
||||
what makes revocation real, and a grant belongs to exactly one organization.
|
||||
|
||||
Nothing here depends on the engine's code: the checks go through the HTTP
|
||||
endpoints *and* through ``ModelGrantPort`` as the engine would call it, so a
|
||||
break in either path fails the suite.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.application.container_org import build_model_grant_service, build_model_grants
|
||||
from app.core.config import settings
|
||||
from app.infrastructure.db.models import AiModelRow, AuditEventRow, OrgModelGrantRow
|
||||
from app.infrastructure.db.models_org import DefaultModelGrantRow, OrgPolicyRow
|
||||
from tests.conftest import auth_header
|
||||
|
||||
GRANTS = "/api/admin/model-grants"
|
||||
DEFAULTS = "/api/admin/default-model-grants"
|
||||
POLICIES = "/api/admin/org-policies"
|
||||
|
||||
|
||||
def _payload(name: str, slug: str) -> dict:
|
||||
return {
|
||||
"org_name": name,
|
||||
"slug": slug,
|
||||
"app_name": f"app-{slug}",
|
||||
"app_type": "web_app",
|
||||
"language": "node",
|
||||
"framework": "next",
|
||||
"git_source": "platform",
|
||||
}
|
||||
|
||||
|
||||
async def _create_org(client: AsyncClient, token: str, name: str, slug: str) -> str:
|
||||
response = await client.post(
|
||||
"/api/setup/complete", json=_payload(name, slug), headers=auth_header(token)
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["organization"]["id"]
|
||||
|
||||
|
||||
async def _model_ids(session_factory) -> list[str]:
|
||||
async with session_factory() as session:
|
||||
rows = (
|
||||
await session.execute(select(AiModelRow).order_by(AiModelRow.internal_name))
|
||||
).scalars().all()
|
||||
return [row.id for row in rows]
|
||||
|
||||
|
||||
async def _audit_actions(session_factory, organization_id: str | None = None) -> list[str]:
|
||||
async with session_factory() as session:
|
||||
stmt = select(AuditEventRow.action)
|
||||
if organization_id is not None:
|
||||
stmt = stmt.where(AuditEventRow.organization_id == organization_id)
|
||||
return list((await session.execute(stmt)).scalars().all())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Authorization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path"),
|
||||
[
|
||||
("get", f"{GRANTS}/org-1"),
|
||||
("get", f"{GRANTS}/org-1/active"),
|
||||
("put", f"{GRANTS}/org-1/models/model-1"),
|
||||
("delete", f"{GRANTS}/org-1/models/model-1"),
|
||||
("get", DEFAULTS),
|
||||
("put", f"{DEFAULTS}/model-1"),
|
||||
("delete", f"{DEFAULTS}/model-1"),
|
||||
("get", f"{POLICIES}/org-1"),
|
||||
("put", f"{POLICIES}/org-1"),
|
||||
],
|
||||
)
|
||||
async def test_non_owner_is_forbidden_everywhere(
|
||||
client: AsyncClient, manager_token: str, method: str, path: str
|
||||
):
|
||||
call = getattr(client, method)
|
||||
kwargs: dict = {"headers": auth_header(manager_token)}
|
||||
if method == "put":
|
||||
kwargs["json"] = {"daily_credit_cap": 5, "is_active": True, "max_employees": 3}
|
||||
response = await call(path, **kwargs)
|
||||
assert response.status_code == 403, response.text
|
||||
|
||||
|
||||
async def test_anonymous_is_rejected(client: AsyncClient):
|
||||
assert (await client.get(f"{GRANTS}/org-1")).status_code == 401
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grant matrix and revocation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_wizard_copies_the_default_set(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
set_default = await client.put(
|
||||
f"{DEFAULTS}/{model_id}",
|
||||
json={"is_active": True, "daily_credit_cap": 12.5},
|
||||
headers=auth_header(platform_owner_token),
|
||||
)
|
||||
assert set_default.status_code == 200, set_default.text
|
||||
assert set_default.json()["granted"] is True
|
||||
|
||||
org_id = await _create_org(client, manager_token, "سازمان الف", "alpha-org")
|
||||
|
||||
matrix = await client.get(f"{GRANTS}/{org_id}", headers=auth_header(platform_owner_token))
|
||||
assert matrix.status_code == 200, matrix.text
|
||||
rows = {m["ai_model_id"]: m for m in matrix.json()["models"]}
|
||||
assert rows[model_id]["granted"] is True
|
||||
assert rows[model_id]["daily_credit_cap"] == 12.5
|
||||
# Every other model is listed but not granted.
|
||||
assert all(m["granted"] is False for m_id, m in rows.items() if m_id != model_id)
|
||||
|
||||
# The same answer through the engine's port.
|
||||
async with session_factory() as session:
|
||||
service = build_model_grants(session)
|
||||
granted = await service.granted_models(organization_id=org_id)
|
||||
assert [g.ai_model_id for g in granted] == [model_id]
|
||||
assert granted[0].tier in {"fast", "balanced", "strong"}
|
||||
assert granted[0].daily_credit_cap == 12.5
|
||||
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
|
||||
|
||||
async def test_grant_edit_and_revoke_are_immediate_and_audited(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
org_id = await _create_org(client, manager_token, "سازمان ب", "beta-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
grant = await client.put(
|
||||
f"{GRANTS}/{org_id}/models/{model_id}",
|
||||
json={"daily_credit_cap": 3},
|
||||
headers=owner,
|
||||
)
|
||||
assert grant.status_code == 200, grant.text
|
||||
assert grant.json()["is_active"] is True
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
|
||||
# Raising the cap keeps it granted.
|
||||
raised = await client.put(
|
||||
f"{GRANTS}/{org_id}/models/{model_id}",
|
||||
json={"daily_credit_cap": 40},
|
||||
headers=owner,
|
||||
)
|
||||
assert raised.json()["daily_credit_cap"] == 40
|
||||
|
||||
revoked = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
|
||||
assert revoked.status_code == 200, revoked.text
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
assert await service.granted_models(organization_id=org_id) == []
|
||||
# Revocation deactivates rather than deletes, so the history survives and
|
||||
# re-granting is idempotent instead of racing a unique constraint.
|
||||
row = (
|
||||
await session.execute(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == org_id,
|
||||
OrgModelGrantRow.ai_model_id == model_id,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
assert row.is_active is False
|
||||
|
||||
active = await client.get(f"{GRANTS}/{org_id}/active", headers=owner)
|
||||
assert active.json() == []
|
||||
|
||||
actions = await _audit_actions(session_factory, org_id)
|
||||
assert "model_grant.set" in actions
|
||||
assert "model_grant.revoke" in actions
|
||||
|
||||
# Revoking something that is not granted is a 404, not a silent success.
|
||||
again = await client.delete(f"{GRANTS}/{org_id}/models/{model_id}", headers=owner)
|
||||
assert again.status_code == 404
|
||||
|
||||
|
||||
async def test_inactive_model_is_never_granted(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
org_id = await _create_org(client, manager_token, "سازمان ج", "gamma-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
granted = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
|
||||
assert granted.status_code == 200, granted.text
|
||||
|
||||
# The owner retires the model from the catalogue.
|
||||
async with session_factory() as session:
|
||||
row = await session.get(AiModelRow, model_id)
|
||||
assert row is not None
|
||||
row.is_active = False
|
||||
await session.commit()
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert not await service.is_granted(organization_id=org_id, ai_model_id=model_id)
|
||||
assert await service.granted_models(organization_id=org_id) == []
|
||||
|
||||
# Granting a retired model is refused rather than quietly stored.
|
||||
refused = await client.put(f"{GRANTS}/{org_id}/models/{model_id}", json={}, headers=owner)
|
||||
assert refused.status_code == 404
|
||||
listing = await client.get(f"{GRANTS}/{org_id}", headers=owner)
|
||||
assert all(m["ai_model_id"] != model_id for m in listing.json()["models"])
|
||||
|
||||
|
||||
async def test_grants_are_isolated_per_organization(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
org_a = await _create_org(client, manager_token, "شرکت آلفا", "alpha-two")
|
||||
org_b = await _create_org(client, manager_token, "شرکت بتا", "beta-two")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
granted = await client.put(
|
||||
f"{GRANTS}/{org_a}/models/{model_id}", json={"daily_credit_cap": 7}, headers=owner
|
||||
)
|
||||
assert granted.status_code == 200, granted.text
|
||||
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert await service.is_granted(organization_id=org_a, ai_model_id=model_id)
|
||||
assert not await service.is_granted(organization_id=org_b, ai_model_id=model_id)
|
||||
assert await service.granted_models(organization_id=org_b) == []
|
||||
|
||||
matrix_b = await client.get(f"{GRANTS}/{org_b}", headers=owner)
|
||||
assert all(m["granted"] is False for m in matrix_b.json()["models"])
|
||||
# …and org B's grant is never visible in org A's active list either.
|
||||
active_a = await client.get(f"{GRANTS}/{org_a}/active", headers=owner)
|
||||
assert [g["ai_model_id"] for g in active_a.json()] == [model_id]
|
||||
|
||||
|
||||
async def test_default_set_can_be_removed_and_deactivated(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
model_id = (await _model_ids(session_factory))[0]
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
assert (await client.put(f"{DEFAULTS}/{model_id}", json={}, headers=owner)).status_code == 200
|
||||
# Switched off: still in the set for the admin UI, but not copied to new orgs.
|
||||
off = await client.put(
|
||||
f"{DEFAULTS}/{model_id}", json={"is_active": False}, headers=owner
|
||||
)
|
||||
assert off.status_code == 200, off.text
|
||||
|
||||
org_off = await _create_org(client, manager_token, "سازمان خاموش", "off-org")
|
||||
async with session_factory() as session:
|
||||
service = build_model_grant_service(session)
|
||||
assert await service.granted_models(organization_id=org_off) == []
|
||||
|
||||
removed = await client.delete(f"{DEFAULTS}/{model_id}", headers=owner)
|
||||
assert removed.status_code == 200, removed.text
|
||||
assert removed.json()["in_default_set"] is False
|
||||
|
||||
async with session_factory() as session:
|
||||
assert (
|
||||
await session.execute(
|
||||
select(DefaultModelGrantRow).where(
|
||||
DefaultModelGrantRow.ai_model_id == model_id
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none() is None
|
||||
|
||||
# Already-created organizations keep the grant they were given.
|
||||
async with session_factory() as session:
|
||||
assert (
|
||||
await session.execute(
|
||||
select(OrgModelGrantRow).where(
|
||||
OrgModelGrantRow.organization_id == org_off
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none() is None
|
||||
|
||||
actions = await _audit_actions(session_factory)
|
||||
assert "default_model_grant.set" in actions
|
||||
assert "default_model_grant.remove" in actions
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Org policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_policy_defaults_caps_and_audit(
|
||||
client: AsyncClient,
|
||||
manager_token: str,
|
||||
platform_owner_token: str,
|
||||
session_factory,
|
||||
seeded,
|
||||
):
|
||||
org_id = await _create_org(client, manager_token, "سازمان د", "delta-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
fresh = await client.get(f"{POLICIES}/{org_id}", headers=owner)
|
||||
assert fresh.status_code == 200, fresh.text
|
||||
body = fresh.json()
|
||||
assert body["max_employees"] == 5
|
||||
assert body["automation_enabled"] is True
|
||||
assert body["mcp_enabled"] is True
|
||||
assert body["monthly_credit_budget"] is None
|
||||
assert body["max_employees_limit"] == settings.engine_max_employees
|
||||
|
||||
too_many = await client.put(
|
||||
f"{POLICIES}/{org_id}",
|
||||
json={"max_employees": settings.engine_max_employees + 1},
|
||||
headers=owner,
|
||||
)
|
||||
assert too_many.status_code == 422
|
||||
|
||||
negative = await client.put(
|
||||
f"{POLICIES}/{org_id}", json={"monthly_credit_budget": -1}, headers=owner
|
||||
)
|
||||
assert negative.status_code == 422
|
||||
|
||||
empty = await client.put(f"{POLICIES}/{org_id}", json={}, headers=owner)
|
||||
assert empty.status_code == 422
|
||||
|
||||
updated = await client.put(
|
||||
f"{POLICIES}/{org_id}",
|
||||
json={
|
||||
"max_employees": 8,
|
||||
"monthly_credit_budget": 250.5,
|
||||
"automation_enabled": False,
|
||||
"mcp_enabled": False,
|
||||
},
|
||||
headers=owner,
|
||||
)
|
||||
assert updated.status_code == 200, updated.text
|
||||
assert updated.json()["max_employees"] == 8
|
||||
assert updated.json()["automation_enabled"] is False
|
||||
assert updated.json()["mcp_enabled"] is False
|
||||
|
||||
async with session_factory() as session:
|
||||
row = await session.get(OrgPolicyRow, org_id)
|
||||
assert row is not None
|
||||
assert row.max_employees == 8
|
||||
assert row.monthly_credit_budget == 250.5
|
||||
# Agent 2 reads the policy through the same service.
|
||||
service = build_model_grant_service(session)
|
||||
policy = await service.get_org_policy(org_id)
|
||||
assert policy.max_employees == 8
|
||||
assert policy.automation_enabled is False
|
||||
assert await service.mcp_enabled(org_id) is False
|
||||
|
||||
assert "org_policy.update" in await _audit_actions(session_factory, org_id)
|
||||
|
||||
|
||||
async def test_policy_is_isolated_per_organization(
|
||||
client: AsyncClient, manager_token: str, platform_owner_token: str, seeded
|
||||
):
|
||||
org_a = await _create_org(client, manager_token, "سازمان ه", "epsilon-org")
|
||||
org_b = await _create_org(client, manager_token, "سازمان و", "zeta-org")
|
||||
owner = auth_header(platform_owner_token)
|
||||
|
||||
await client.put(f"{POLICIES}/{org_a}", json={"max_employees": 2}, headers=owner)
|
||||
|
||||
a = await client.get(f"{POLICIES}/{org_a}", headers=owner)
|
||||
b = await client.get(f"{POLICIES}/{org_b}", headers=owner)
|
||||
assert a.json()["max_employees"] == 2
|
||||
assert b.json()["max_employees"] == 5
|
||||
Reference in New Issue
Block a user