diff --git a/.env.example b/.env.example index 4156412..3f5b7ee 100644 --- a/.env.example +++ b/.env.example @@ -95,7 +95,9 @@ ERROR_LOG_CLIENT_ERRORS=0 # >>> agent-1 (org platform) MCP_GATEWAY_HOST=127.0.0.1 MCP_GATEWAY_PORT=8765 +MCP_GATEWAY_ENABLED=true MCP_TOKEN_TTL_DAYS=90 +MCP_RATE_LIMIT_PER_MINUTE=60 MEMORY_ORG_CHAR_CAP=60000 PLATFORM_INVITE_TTL_HOURS=72 # <<< agent-1 diff --git a/apps/api/alembic/versions/a8b9c0d1e2f3_mcp_tokens.py b/apps/api/alembic/versions/a8b9c0d1e2f3_mcp_tokens.py new file mode 100644 index 0000000..ccb2fd1 --- /dev/null +++ b/apps/api/alembic/versions/a8b9c0d1e2f3_mcp_tokens.py @@ -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") diff --git a/apps/api/alembic/versions/e6f7a8b9c0d1_model_grants.py b/apps/api/alembic/versions/e6f7a8b9c0d1_model_grants.py new file mode 100644 index 0000000..1eff51f --- /dev/null +++ b/apps/api/alembic/versions/e6f7a8b9c0d1_model_grants.py @@ -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") diff --git a/apps/api/alembic/versions/f7a8b9c0d1e2_org_memory.py b/apps/api/alembic/versions/f7a8b9c0d1e2_org_memory.py new file mode 100644 index 0000000..513ac2f --- /dev/null +++ b/apps/api/alembic/versions/f7a8b9c0d1e2_org_memory.py @@ -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") diff --git a/apps/api/app/application/container_org.py b/apps/api/app/application/container_org.py index 2a3acfc..c5b37bc 100644 --- a/apps/api/app/application/container_org.py +++ b/apps/api/app/application/container_org.py @@ -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", +] diff --git a/apps/api/app/application/services/mcp_service.py b/apps/api/app/application/services/mcp_service.py new file mode 100644 index 0000000..6e1357f --- /dev/null +++ b/apps/api/app/application/services/mcp_service.py @@ -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", +] diff --git a/apps/api/app/application/services/memory_service.py b/apps/api/app/application/services/memory_service.py new file mode 100644 index 0000000..c2dd81c --- /dev/null +++ b/apps/api/app/application/services/memory_service.py @@ -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"] diff --git a/apps/api/app/application/services/model_grant_service.py b/apps/api/app/application/services/model_grant_service.py new file mode 100644 index 0000000..5a5ccef --- /dev/null +++ b/apps/api/app/application/services/model_grant_service.py @@ -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", +] diff --git a/apps/api/app/application/services/org_setup_service.py b/apps/api/app/application/services/org_setup_service.py index 2028168..a355f5c 100644 --- a/apps/api/app/application/services/org_setup_service.py +++ b/apps/api/app/application/services/org_setup_service.py @@ -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: diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py index 57aed1d..6da12f8 100644 --- a/apps/api/app/core/config.py +++ b/apps/api/app/core/config.py @@ -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 diff --git a/apps/api/app/domain/identity/audit_actions.py b/apps/api/app/domain/identity/audit_actions.py new file mode 100644 index 0000000..487bd1e --- /dev/null +++ b/apps/api/app/domain/identity/audit_actions.py @@ -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" diff --git a/apps/api/app/domain/memory/__init__.py b/apps/api/app/domain/memory/__init__.py new file mode 100644 index 0000000..e47737f --- /dev/null +++ b/apps/api/app/domain/memory/__init__.py @@ -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", +] diff --git a/apps/api/app/domain/memory/models.py b/apps/api/app/domain/memory/models.py new file mode 100644 index 0000000..016eec8 --- /dev/null +++ b/apps/api/app/domain/memory/models.py @@ -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", +] diff --git a/apps/api/app/infrastructure/db/models_org.py b/apps/api/app/infrastructure/db/models_org.py index 4e4c0a7..0db2df6 100644 --- a/apps/api/app/infrastructure/db/models_org.py +++ b/apps/api/app/infrastructure/db/models_org.py @@ -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", +] diff --git a/apps/api/app/infrastructure/repositories/memory.py b/apps/api/app/infrastructure/repositories/memory.py new file mode 100644 index 0000000..ff17b66 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/memory.py @@ -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"] diff --git a/apps/api/app/infrastructure/repositories/org_grants.py b/apps/api/app/infrastructure/repositories/org_grants.py new file mode 100644 index 0000000..96f3366 --- /dev/null +++ b/apps/api/app/infrastructure/repositories/org_grants.py @@ -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) diff --git a/apps/api/app/interfaces/http/routers/admin_grants.py b/apps/api/app/interfaces/http/routers/admin_grants.py index 2914eb9..0bcd476 100644 --- a/apps/api/app/interfaces/http/routers/admin_grants.py +++ b/apps/api/app/interfaces/http/routers/admin_grants.py @@ -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) diff --git a/apps/api/app/interfaces/http/routers/mcp_tokens.py b/apps/api/app/interfaces/http/routers/mcp_tokens.py index 16e67ce..2a61654 100644 --- a/apps/api/app/interfaces/http/routers/mcp_tokens.py +++ b/apps/api/app/interfaces/http/routers/mcp_tokens.py @@ -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"] diff --git a/apps/api/app/interfaces/http/routers/memory.py b/apps/api/app/interfaces/http/routers/memory.py index 06db1cd..814b4a4 100644 --- a/apps/api/app/interfaces/http/routers/memory.py +++ b/apps/api/app/interfaces/http/routers/memory.py @@ -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:`` 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}, + ) + ) diff --git a/apps/api/app/interfaces/mcp/__init__.py b/apps/api/app/interfaces/mcp/__init__.py index 9726d5d..d067633 100644 --- a/apps/api/app/interfaces/mcp/__init__.py +++ b/apps/api/app/interfaces/mcp/__init__.py @@ -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", +] diff --git a/apps/api/app/interfaces/mcp/gateway.py b/apps/api/app/interfaces/mcp/gateway.py new file mode 100644 index 0000000..639c6d6 --- /dev/null +++ b/apps/api/app/interfaces/mcp/gateway.py @@ -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", +] diff --git a/apps/api/app/interfaces/mcp/server.py b/apps/api/app/interfaces/mcp/server.py index 4fbd82e..c688191 100644 --- a/apps/api/app/interfaces/mcp/server.py +++ b/apps/api/app/interfaces/mcp/server.py @@ -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() diff --git a/apps/api/tests/test_foundation.py b/apps/api/tests/test_foundation.py index 1f70d84..d1a3290 100644 --- a/apps/api/tests/test_foundation.py +++ b/apps/api/tests/test_foundation.py @@ -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(): diff --git a/apps/api/tests/test_mcp.py b/apps/api/tests/test_mcp.py new file mode 100644 index 0000000..6e47818 --- /dev/null +++ b/apps/api/tests/test_mcp.py @@ -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"] diff --git a/apps/api/tests/test_memory.py b/apps/api/tests/test_memory.py new file mode 100644 index 0000000..9498bae --- /dev/null +++ b/apps/api/tests/test_memory.py @@ -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 diff --git a/apps/api/tests/test_model_grants.py b/apps/api/tests/test_model_grants.py new file mode 100644 index 0000000..3b42c68 --- /dev/null +++ b/apps/api/tests/test_model_grants.py @@ -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 diff --git a/apps/web/src/app/(app)/admin/mcp/page.tsx b/apps/web/src/app/(app)/admin/mcp/page.tsx index ce7bdf7..0d6c09a 100644 --- a/apps/web/src/app/(app)/admin/mcp/page.tsx +++ b/apps/web/src/app/(app)/admin/mcp/page.tsx @@ -1,16 +1,231 @@ "use client"; -import { PlaceholderPage } from "@/components/PlaceholderPage"; +/** + * Platform owner console: the MCP gateway's two switches. + * + * An external agent can only act inside the platform through a scoped token, and + * this page is where the owner closes the door when something is wrong: the + * gateway as a whole, and each organization independently. A switch takes effect + * on the next call, because the gateway checks it per request rather than + * trusting a cached state. + * + * A revoked token keeps its place in the list — after an incident, "who could + * have done this" is the question that matters. + */ + +import * as React from "react"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + ConfirmDialog, + EmptyState, + ErrorState, + LoadingState, + Stat, + ToastStack, + type ToastMessage, +} from "@agy/ui"; +import { ApiError } from "@/lib/api"; +import { mcpApi, type McpOrgState } from "@/lib/api-org"; + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof ApiError ? err.message : fallback; +} export default function AdminMcpPage() { + const [state, setState] = React.useState<{ + gateway_enabled: boolean; + organizations: McpOrgState[]; + total_tokens: number; + } | null>(null); + const [loading, setLoading] = React.useState(true); + const [busy, setBusy] = React.useState(null); + const [error, setError] = React.useState(null); + const [pendingSwitch, setPendingSwitch] = React.useState(null); + const [toasts, setToasts] = React.useState([]); + + const toast = React.useCallback( + (title: string, tone: ToastMessage["tone"] = "success", description?: string) => { + setToasts((current) => [ + ...current, + { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description }, + ]); + }, + [], + ); + + const load = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + setState(await mcpApi.adminState()); + } catch (err) { + setError(errorMessage(err, "دریافت وضعیت MCP ناموفق بود.")); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { + void load(); + }, [load]); + + const toggleGateway = async () => { + if (!state) return; + setBusy("gateway"); + try { + const next = !state.gateway_enabled; + await mcpApi.setGateway(next); + setState((current) => (current ? { ...current, gateway_enabled: next } : current)); + toast(next ? "دسترسی MCP سامانه روشن شد" : "دسترسی MCP سامانه خاموش شد"); + } catch (err) { + toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید.")); + } finally { + setBusy(null); + } + }; + + const confirmOrgSwitch = async (org: McpOrgState) => { + setBusy(`org-${org.organization_id}`); + try { + await mcpApi.setOrgEnabled(org.organization_id, !org.mcp_enabled); + setState((current) => + current + ? { + ...current, + organizations: current.organizations.map((row) => + row.organization_id === org.organization_id + ? { ...row, mcp_enabled: !row.mcp_enabled } + : row, + ), + } + : current, + ); + toast(org.mcp_enabled ? "دسترسی این سازمان خاموش شد" : "دسترسی این سازمان روشن شد"); + setPendingSwitch(null); + } catch (err) { + toast("تغییر وضعیت ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید.")); + } finally { + setBusy(null); + } + }; + + if (loading) return ; + if (error || !state) + return ; + + const enabledOrgs = state.organizations.filter((org) => org.mcp_enabled).length; + return ( - +
+
+

دسترسی MCP

+

+ عامل‌های بیرونی با توکن‌های محدود به یک سازمان کار می‌کنند. اینجا کل درگاه و + دسترسی هر سازمان را می‌توان خاموش کرد؛ توکن‌ها را مدیران خودشان در تنظیمات + سازمان می‌سازند. +

+
+ +
+ + + +
+ + + کلید اصلی + + خاموش‌کردن درگاه، همهٔ توکن‌ها را بی‌اثر می‌کند بدون آنکه حذف شوند: وقتی دوباره + روشن شود، همان توکن‌ها از جایشان کار می‌کنند. + +
+ +
+
+ + + سازمان‌ها + هر سازمان جداگانه قابل خاموش‌کردن است. +
+ {state.organizations.length === 0 ? ( + + ) : ( + + + + + + + + + + + {state.organizations.map((org) => ( + + + + + + + + ))} + +
سازماناسلاگتوکن فعالوضعیت +
{org.name} + {org.slug} + {org.token_count} + + {org.mcp_enabled ? "فعال" : "خاموش"} + + + +
+ )} +
+
+ + pendingSwitch && void confirmOrgSwitch(pendingSwitch)} + onCancel={() => setPendingSwitch(null)} + /> + + setToasts((current) => current.filter((t) => t.id !== id))} + /> +
); } diff --git a/apps/web/src/app/(app)/admin/model-grants/page.tsx b/apps/web/src/app/(app)/admin/model-grants/page.tsx index 4b50fbe..b4cabc4 100644 --- a/apps/web/src/app/(app)/admin/model-grants/page.tsx +++ b/apps/web/src/app/(app)/admin/model-grants/page.tsx @@ -1,17 +1,514 @@ "use client"; -import { PlaceholderPage } from "@/components/PlaceholderPage"; +/** + * Platform owner console: which models each organization may use. + * + * Two independent switches decide whether a model is usable, and this page is + * the only place both can be set: the model is active in the catalogue, and the + * owner granted it to *this* organization. Revoking here takes effect on the + * engine's next query — there is no cached grant set to expire. + * + * The tabs mirror the three decisions: the per-organization matrix, the default + * set copied into every newly created organization, and the organization's + * policy (employee cap, monthly budget, automation and MCP switches). + * + * Model names are visible here and nowhere else: an organization's own users + * only ever see a neutral tier. + */ + +import * as React from "react"; +import type { AdminOrganization } from "@agy/contracts"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + Checkbox, + EmptyState, + ErrorState, + Field, + LoadingState, + Select, + Stat, + Tabs, + ToastStack, + type ToastMessage, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { orgApi, type ModelGrantRow, type OrgPolicyView } from "@/lib/api-org"; + +const TIER_TONES: Record = { + fast: "success", + balanced: "info", + strong: "warning", +}; + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof ApiError ? err.message : fallback; +} export default function AdminModelGrantsPage() { + const [organizations, setOrganizations] = React.useState([]); + const [orgId, setOrgId] = React.useState(""); + const [models, setModels] = React.useState([]); + const [defaults, setDefaults] = React.useState([]); + const [policy, setPolicy] = React.useState(null); + //: Two independent editors, so they need independent inputs: the org matrix + //: and the default set can hold different caps for the same model. + const [caps, setCaps] = React.useState>({}); + const [defaultCaps, setDefaultCaps] = React.useState>({}); + const [tab, setTab] = React.useState("org"); + + const [loading, setLoading] = React.useState(true); + const [busy, setBusy] = React.useState(null); + const [error, setError] = React.useState(null); + const [orgError, setOrgError] = React.useState(null); + const [toasts, setToasts] = React.useState([]); + + const [budget, setBudget] = React.useState(""); + const [maxEmployees, setMaxEmployees] = React.useState("5"); + + const toast = React.useCallback( + (title: string, tone: ToastMessage["tone"] = "success", description?: string) => { + setToasts((current) => [ + ...current, + { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description }, + ]); + }, + [], + ); + + const loadOrgs = React.useCallback(async () => { + setLoading(true); + setError(null); + try { + const [nextOrganizations, nextDefaults] = await Promise.all([ + api.adminOrganizations(), + orgApi.listDefaultGrants(), + ]); + setOrganizations(nextOrganizations); + setDefaults(nextDefaults); + setDefaultCaps( + Object.fromEntries( + nextDefaults.map((m) => [ + m.ai_model_id, + m.daily_credit_cap === null ? "" : String(m.daily_credit_cap), + ]), + ), + ); + setOrgId((current) => current || (nextOrganizations[0]?.id ?? "")); + } catch (err) { + setError(errorMessage(err, "دریافت داده‌های تخصیص مدل ناموفق بود.")); + } finally { + setLoading(false); + } + }, []); + + const loadOrgDetail = React.useCallback(async (id: string) => { + if (!id) { + setModels([]); + setPolicy(null); + return; + } + setOrgError(null); + try { + const [grants, nextPolicy] = await Promise.all([ + orgApi.listOrgGrants(id), + orgApi.getOrgPolicy(id), + ]); + setModels(grants.models); + setCaps( + Object.fromEntries( + grants.models.map((m) => [ + m.ai_model_id, + m.daily_credit_cap === null ? "" : String(m.daily_credit_cap), + ]), + ), + ); + setPolicy(nextPolicy); + setMaxEmployees(String(nextPolicy.max_employees)); + setBudget( + nextPolicy.monthly_credit_budget === null + ? "" + : String(nextPolicy.monthly_credit_budget), + ); + } catch (err) { + setOrgError(errorMessage(err, "دریافت تخصیص‌های این سازمان ناموفق بود.")); + } + }, []); + + React.useEffect(() => { + void loadOrgs(); + }, [loadOrgs]); + + React.useEffect(() => { + void loadOrgDetail(orgId); + }, [loadOrgDetail, orgId]); + + const parseCap = (raw: string): number | null => { + const trimmed = raw.trim(); + if (trimmed === "") return null; + const value = Number(trimmed); + return Number.isFinite(value) && value >= 0 ? value : null; + }; + + const toggleGrant = async (model: ModelGrantRow, next: boolean) => { + setBusy(model.ai_model_id); + try { + if (next) { + await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? "")); + } else { + await orgApi.revokeOrgGrant(orgId, model.ai_model_id); + } + await loadOrgDetail(orgId); + toast(next ? `مدل «${model.label}» تخصیص یافت` : `دسترسی مدل «${model.label}» لغو شد`); + } catch (err) { + toast("تغییر تخصیص ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید.")); + } finally { + setBusy(null); + } + }; + + const saveCap = async (model: ModelGrantRow) => { + setBusy(model.ai_model_id); + try { + await orgApi.setOrgGrant(orgId, model.ai_model_id, parseCap(caps[model.ai_model_id] ?? "")); + await loadOrgDetail(orgId); + toast("سقف اعتبار روزانه ذخیره شد"); + } catch (err) { + toast("ذخیرهٔ سقف ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید.")); + } finally { + setBusy(null); + } + }; + + const toggleDefault = async (model: ModelGrantRow, next: boolean) => { + setBusy(model.ai_model_id); + try { + await orgApi.setDefaultGrant(model.ai_model_id, { + is_active: next, + daily_credit_cap: parseCap(defaultCaps[model.ai_model_id] ?? ""), + }); + const nextDefaults = await orgApi.listDefaultGrants(); + setDefaults(nextDefaults); + setDefaultCaps( + Object.fromEntries( + nextDefaults.map((m) => [ + m.ai_model_id, + m.daily_credit_cap === null ? "" : String(m.daily_credit_cap), + ]), + ), + ); + toast(next ? "به مجموعهٔ پیش‌فرض افزوده شد" : "از مجموعهٔ پیش‌فرض حذف شد"); + } catch (err) { + toast("تغییر مجموعهٔ پیش‌فرض ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید.")); + } finally { + setBusy(null); + } + }; + + const savePolicy = async (event: React.FormEvent) => { + event.preventDefault(); + if (!policy) return; + setBusy("policy"); + try { + const updated = await orgApi.updateOrgPolicy(orgId, { + max_employees: Number(maxEmployees), + monthly_credit_budget: budget.trim() === "" ? null : Number(budget), + automation_enabled: policy.automation_enabled, + mcp_enabled: policy.mcp_enabled, + }); + setPolicy(updated); + toast("سیاست سازمان ذخیره شد"); + } catch (err) { + toast("ذخیرهٔ سیاست ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید.")); + } finally { + setBusy(null); + } + }; + + const setPolicyFlag = (key: "automation_enabled" | "mcp_enabled", value: boolean) => { + setPolicy((current) => (current ? { ...current, [key]: value } : current)); + }; + + if (loading) return ; + if (error) return ; + + const grantedCount = models.filter((m) => m.granted).length; + const defaultCount = defaults.filter((m) => m.granted).length; + const activeDefaults = defaults.filter((m) => m.granted); + return ( - +
+
+

مدل‌های سازمان‌ها

+

+ هر سازمان فقط می‌تواند از مدل‌هایی استفاده کند که مالک سامانه تخصیص داده است. کاربران + سازمان نام مدل را نمی‌بینند؛ فقط سطح آن («سریع»، «متعادل»، «قوی») را می‌بینند. +

+
+ +
+ + + +
+ + + + {tab !== "defaults" && ( + + انتخاب سازمان +
+ + setCaps((current) => ({ + ...current, + [model.ai_model_id]: e.target.value, + })) + } + className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100" + /> + +
+ + + ))} + + +
+ + )} + + )} + + {tab === "defaults" && ( + + مجموعهٔ پیش‌فرض سازمان‌های جدید +
+ + این مجموعه هنگام ساخت هر سازمان جدید به‌صورت خودکار کپی می‌شود. خاموش‌کردن یک مدل + آن را در فهرست نگه می‌دارد اما به سازمان‌های بعدی کپی نمی‌شود؛ سازمان‌های قبلی + دسترسی خود را از دست نمی‌دهند. + +
+
+ + + + + + + + + + + {defaults.map((model) => ( + + + + + + + ))} + +
مدلسطحدر مجموعهسقف روزانه
{model.label} + {model.tier_label} + + void toggleDefault(model, e.target.checked)} + /> + +
+ + setDefaultCaps((current) => ({ + ...current, + [model.ai_model_id]: e.target.value, + })) + } + className="w-24 rounded-lg border border-surface-border bg-surface px-2 py-1 text-xs text-slate-100" + /> + +
+
+
+ {activeDefaults.length === 0 && ( +
+ + مجموعهٔ پیش‌فرض خالی است: سازمان‌های جدید تا زمانی که تخصیص دستی نگیرند هیچ + مدلی ندارند. + +
+ )} +
+ )} + + {tab === "policy" && ( + <> + {orgError && void loadOrgDetail(orgId)} />} + {!orgError && policy && ( + + سیاست سازمان +
+ + سقف پرسنل نمی‌تواند از سقف سامانه ({policy.max_employees_limit}) بیشتر باشد. + بودجهٔ ماهانه خالی یعنی بدون سقف صریح. + +
+
+ setMaxEmployees(e.target.value)} + /> + setBudget(e.target.value)} + hint="خالی بگذارید تا سقف صریحی اعمال نشود." + /> + setPolicyFlag("automation_enabled", e.target.checked)} + /> + setPolicyFlag("mcp_enabled", e.target.checked)} + /> +
+ +
+ +
+ )} + + )} + + setToasts((current) => current.filter((t) => t.id !== id))} + /> + ); } diff --git a/apps/web/src/app/(app)/knowledge/page.tsx b/apps/web/src/app/(app)/knowledge/page.tsx index 6590575..474b081 100644 --- a/apps/web/src/app/(app)/knowledge/page.tsx +++ b/apps/web/src/app/(app)/knowledge/page.tsx @@ -1,19 +1,23 @@ "use client"; -import { PlaceholderPage } from "@/components/PlaceholderPage"; +/** + * Knowledge is now a tab of ``/memory``: a document and a one-line rule are + * different things, and they belong on one page so a reader never has to guess + * which half of the organization's knowledge lives where. + * + * This route stays alive as a permanent redirect so an old bookmark (or a link + * in a chat message) lands on the page that replaced it. + */ -export default function KnowledgePage() { - return ( - - ); +import { useEffect } from "react"; +import { useRouter } from "next/navigation"; + +export default function KnowledgeRedirectPage() { + const router = useRouter(); + + useEffect(() => { + router.replace("/memory"); + }, [router]); + + return null; } diff --git a/apps/web/src/app/(app)/memory/page.tsx b/apps/web/src/app/(app)/memory/page.tsx index 834f98a..9bc1e54 100644 --- a/apps/web/src/app/(app)/memory/page.tsx +++ b/apps/web/src/app/(app)/memory/page.tsx @@ -1,18 +1,538 @@ "use client"; -import { PlaceholderPage } from "@/components/PlaceholderPage"; +/** + * Org memory: what the organization has learned about itself. + * + * Four kinds of memory, four tabs, one page — the plan's "one place" rule. The + * Knowledge Base keeps its own endpoints and appears here as a second family of + * tabs, because a document and a one-line rule are different things and + * pretending otherwise is how knowledge bases become unreadable. + * + * Live by design: the API publishes ``memory.changed`` on ``org:`` after + * every write, so a page left open on one tab sees a lesson that another member + * (or the engine's own reflection) just recorded — including the realtime + * refresh, not a stale list. + */ + +import * as React from "react"; +import { + Badge, + Button, + Card, + CardHint, + CardTitle, + ConfirmDialog, + EmptyState, + ErrorState, + Field, + LoadingState, + Modal, + Select, + ToastStack, + type ToastMessage, +} from "@agy/ui"; +import { ApiError, api } from "@/lib/api"; +import { + MEMORY_CHANGED_EVENT, + memoryApi, + memoryKinds, + type KnowledgeSourceRow, + type MemoryRow, +} from "@/lib/api-org"; +import { useLiveChannel } from "@/lib/realtime"; +import { useSession } from "@/lib/session"; + +type Tab = "rules" | "lessons" | "code" | "knowledge"; + +const TABS: { key: Tab; label: string; hint: string }[] = [ + { key: "rules", label: "قوانین و ترجیحات", hint: "تصمیم‌های همیشگی سازمان" }, + { key: "lessons", label: "درس‌های آموخته", hint: "آنچه کارهای تمام‌شده یاد دادند" }, + { key: "code", label: "نقشهٔ کد", hint: "واقعیت‌ها و نقشهٔ اپلیکیشن" }, + { key: "knowledge", label: "منابع دانش", hint: "اسناد و متن‌های بلند" }, +]; + +/** Which kinds each tab shows. */ +const TAB_KINDS: Record = { + rules: ["rule", "preference"], + lessons: ["lesson"], + code: ["code_map", "fact"], + knowledge: [], +}; + +const KIND_TONES: Record = { + rule: "success", + preference: "info", + lesson: "warning", + fact: "neutral", + code_map: "info", +}; + +function errorMessage(err: unknown, fallback: string): string { + return err instanceof ApiError ? err.message : fallback; +} + +function formatDate(value: string | null): string { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? "—" : date.toLocaleDateString("fa-IR"); +} export default function MemoryPage() { + const { activeOrganization } = useSession(); + const orgId = activeOrganization?.id ?? null; + + const [tab, setTab] = React.useState("rules"); + const [items, setItems] = React.useState([]); + const [stats, setStats] = React.useState({ total: 0, pinned: 0, chars: 0 }); + const [sources, setSources] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [busy, setBusy] = React.useState(null); + const [toasts, setToasts] = React.useState([]); + + const [teachOpen, setTeachOpen] = React.useState(false); + const [teachKind, setTeachKind] = React.useState<"rule" | "preference">("rule"); + const [teachContent, setTeachContent] = React.useState(""); + const [editing, setEditing] = React.useState(null); + const [editContent, setEditContent] = React.useState(""); + const [pendingDelete, setPendingDelete] = React.useState(null); + + const [sourceName, setSourceName] = React.useState(""); + const [sourceText, setSourceText] = React.useState(""); + const [pendingSource, setPendingSource] = React.useState(null); + + const toast = React.useCallback( + (title: string, tone: ToastMessage["tone"] = "success", description?: string) => { + setToasts((current) => [ + ...current, + { id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description }, + ]); + }, + [], + ); + + const load = React.useCallback(async () => { + if (!orgId) return; + setLoading(true); + setError(null); + try { + const [memory, knowledge] = await Promise.all([ + memoryApi.list(orgId), + memoryApi.listKnowledge(orgId), + ]); + setItems(memory.items); + setStats({ total: memory.total, pinned: memory.pinned, chars: memory.chars }); + setSources(knowledge); + } catch (err) { + setError(errorMessage(err, "دریافت حافظهٔ سازمان ناموفق بود.")); + } finally { + setLoading(false); + } + }, [orgId]); + + React.useEffect(() => { + void load(); + }, [load]); + + // Live: another member (or a reflection) changed memory while this page was open. + useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => { + if ((event.type as string) === MEMORY_CHANGED_EVENT) void load(); + }); + + const visible = React.useMemo( + () => items.filter((item) => TAB_KINDS[tab].includes(item.kind)), + [items, tab], + ); + + const run = async (key: string, action: () => Promise) => { + setBusy(key); + try { + await action(); + await load(); + } catch (err) { + toast("عملیات ناموفق بود", "danger", errorMessage(err, "دوباره تلاش کنید.")); + } finally { + setBusy(null); + } + }; + + const teach = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId || !teachContent.trim()) return; + await run("teach", async () => { + const created = await memoryApi.teach(orgId, { + kind: teachKind, + content: teachContent.trim(), + }); + setTeachOpen(false); + setTeachContent(""); + toast(created.kind_label === "قاعده" ? "قاعده یادآوری شد" : "ترجیح ذخیره شد"); + }); + }; + + const saveEdit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId || !editing) return; + const content = editContent.trim(); + if (!content) return; + await run(`edit-${editing.id}`, async () => { + await memoryApi.edit(orgId, editing.id, { content }); + setEditing(null); + toast("یادداشت ویرایش شد"); + }); + }; + + const togglePin = (item: MemoryRow) => + void run(`pin-${item.id}`, async () => { + await memoryApi.setPinned(orgId!, item.id, !item.pinned); + toast(item.pinned ? "سنجاق برداشته شد" : "سنجاق شد: همیشه در متن کارها"); + }); + + const confirmLesson = (item: MemoryRow) => + void run(`confirm-${item.id}`, async () => { + await memoryApi.confirm(orgId!, item.id); + toast("درس تأیید شد: دیگر منقضی نمی‌شود"); + }); + + const remove = (item: MemoryRow) => + void run(`delete-${item.id}`, async () => { + await memoryApi.remove(orgId!, item.id); + setPendingDelete(null); + toast("یادداشت حذف شد"); + }); + + const addSource = async (event: React.FormEvent) => { + event.preventDefault(); + if (!orgId || !sourceName.trim() || !sourceText.trim()) return; + await run("source", async () => { + const created = await memoryApi.createKnowledge(orgId, { + name: sourceName.trim(), + content: sourceText, + }); + setSources((current) => [created, ...current]); + setSourceName(""); + setSourceText(""); + toast(`منبع «${created.name}» افزوده شد`); + }); + }; + + const removeSource = (source: KnowledgeSourceRow) => + void run(`source-${source.id}`, async () => { + await memoryApi.removeKnowledge(orgId!, source.id); + setSources((current) => current.filter((row) => row.id !== source.id)); + setPendingSource(null); + toast("منبع دانش حذف شد"); + }); + + if (!orgId) return ; + if (loading && items.length === 0) return ; + if (error && items.length === 0) return ; + return ( - +
+
+

حافظهٔ سازمان

+

+ آنچه سازمان دربارهٔ شیوهٔ کار و اپلیکیشنش یاد گرفته است. کارمندهای هوش مصنوعی + پیش از هر کار، یادداشت‌های مرتبط را می‌خوانند؛ هر چه سازمان بیشتر یاد بدهد، + کارها دقیق‌تر انجام می‌شود. +

+
+ +
+
+

یادداشت‌ها

+

{stats.total}

+
+
+

سنجاق‌شده

+

{stats.pinned}

+
+
+

حجم متن

+

{stats.chars.toLocaleString("fa-IR")}

+
+
+ +
+ {TABS.map((entry) => ( + + ))} +
+ {tab !== "knowledge" && ( + + )} +
+
+ + {error && } + + {tab === "knowledge" ? ( + <> + + افزودن منبع متنی + + منابع دانش اسناد بلند هستند: متن را یک‌جا می‌دهیم، سامانه آن را تکه‌تکه و + قابل جست‌وجو می‌کند. یادداشت‌های کوتاه‌تر را در تب‌های دیگر بیاموزید. + +
+ setSourceName(e.target.value)} + placeholder="مثلاً قواعد کدنویسی" + required + /> +