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

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
co-authored by Claude Sonnet 5.5
parent c93b25741e
commit dee2f72c46
39 changed files with 9060 additions and 95 deletions
@@ -2,10 +2,358 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
The rule this router enforces: **an organization may only use the models its
owner granted it.** The endpoints here are the only way a grant appears, so
revoking one is immediate (``ModelGrantService.is_granted`` returns false on the
next call) rather than a change that only takes effect after a cache expires.
Everything is owner-only, guarded at the router level, and every mutation is
audited. The model *names* live here and nowhere else: organization users only
ever see a neutral tier («سریع» / «متعادل» / «قوی»).
"""
from __future__ import annotations
from fastapi import APIRouter
from dataclasses import asdict
router = APIRouter(tags=["admin-grants"])
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.services.audit_service import record_audit
from app.application.services.model_grant_service import (
ModelGrantService,
ModelView,
OrgGrantView,
PolicyView,
)
from app.core.database import get_session
from app.core.errors import ValidationError
from app.domain.identity.audit_actions import (
DEFAULT_MODEL_GRANT_REMOVE,
DEFAULT_MODEL_GRANT_SET,
MODEL_GRANT_REVOKE,
MODEL_GRANT_SET,
ORG_POLICY_UPDATE,
)
from app.domain.identity.models import User
from app.interfaces.http.deps import client_meta, require_platform_owner
router = APIRouter(
prefix="/admin",
tags=["admin-grants"],
# Deny-by-default, exactly like the rest of the console.
dependencies=[Depends(require_platform_owner)],
)
async def get_model_grant_service(
session: AsyncSession = Depends(get_session),
) -> ModelGrantService:
from app.application.container_org import build_model_grant_service
return build_model_grant_service(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class ModelViewOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
ai_model_id: str
label: str
provider_id: str
capabilities: list[str]
tier: str
tier_label: str
output_price_per_1m: float
model_active: bool
granted: bool
daily_credit_cap: float | None = None
class OrgGrantOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
ai_model_id: str
label: str
tier: str
tier_label: str
capabilities: list[str]
daily_credit_cap: float | None = None
is_active: bool
class OrgGrantsOut(BaseModel):
"""The grant matrix for one organization: every active model + its state."""
organization_id: str
models: list[ModelViewOut]
class SetGrantRequest(BaseModel):
#: ``None`` means "no per-model daily cap"; ``0`` means "granted but zeroed".
daily_credit_cap: float | None = Field(default=None, ge=0)
class SetDefaultGrantRequest(BaseModel):
is_active: bool = True
daily_credit_cap: float | None = Field(default=None, ge=0)
class PolicyOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
organization_id: str
max_employees: int
monthly_credit_budget: float | None = None
automation_enabled: bool
mcp_enabled: bool
max_employees_limit: int
class UpdatePolicyRequest(BaseModel):
max_employees: int | None = Field(default=None, ge=1)
monthly_credit_budget: float | None = Field(default=None, ge=0)
automation_enabled: bool | None = None
mcp_enabled: bool | None = None
def _model_out(view: ModelView) -> ModelViewOut:
return ModelViewOut(**asdict(view))
def _grant_out(view: OrgGrantView) -> OrgGrantOut:
return OrgGrantOut(**asdict(view))
def _policy_out(view: PolicyView) -> PolicyOut:
return PolicyOut(**asdict(view))
def _require_change(*values: object) -> None:
if all(value is None for value in values):
raise ValidationError("هیچ تغییری ارسال نشده است.")
# ---------------------------------------------------------------------------
# Grants per organization
# ---------------------------------------------------------------------------
@router.get("/model-grants/{organization_id}", response_model=OrgGrantsOut)
async def list_org_grants(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> OrgGrantsOut:
"""Every active catalogue model, flagged with whether this org may use it."""
models = await service.list_models(organization_id=organization_id)
return OrgGrantsOut(
organization_id=organization_id, models=[_model_out(m) for m in models]
)
@router.put("/model-grants/{organization_id}/models/{ai_model_id}", response_model=OrgGrantOut)
async def set_org_grant(
organization_id: str,
ai_model_id: str,
payload: SetGrantRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
# FastAPI caches ``get_session`` per request, so this is the very session
# the service writes through: the audit row lands in the same transaction.
session: AsyncSession = Depends(get_session),
) -> OrgGrantOut:
"""Grant a model to an organization (idempotent) and/or set its daily cap."""
granted = await service.grant_model(
organization_id=organization_id,
ai_model_id=ai_model_id,
daily_credit_cap=payload.daily_credit_cap,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MODEL_GRANT_SET,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
# The model label is a name, not a secret, but keeping the audit rows
# id-only makes them stable when the owner renames a model.
metadata={"daily_credit_cap": payload.daily_credit_cap},
ip=ip,
user_agent=ua,
)
return _grant_out(granted)
@router.get("/model-grants/{organization_id}/active", response_model=list[OrgGrantOut])
async def list_active_org_grants(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> list[OrgGrantOut]:
"""Just the active grants — what the engine will actually be allowed to use."""
grants = await service.list_grants(organization_id=organization_id, active_only=True)
return [_grant_out(g) for g in grants]
@router.delete("/model-grants/{organization_id}/models/{ai_model_id}")
async def revoke_org_grant(
organization_id: str,
ai_model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Revoke a model from one organization. Other organizations are unaffected."""
await service.revoke_model(
organization_id=organization_id, ai_model_id=ai_model_id, actor_user_id=owner.id
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MODEL_GRANT_REVOKE,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
ip=ip,
user_agent=ua,
)
return {"organization_id": organization_id, "ai_model_id": ai_model_id, "granted": False}
# ---------------------------------------------------------------------------
# The default set copied into every new organization
# ---------------------------------------------------------------------------
@router.get("/default-model-grants", response_model=list[ModelViewOut])
async def list_default_grants(
service: ModelGrantService = Depends(get_model_grant_service),
) -> list[ModelViewOut]:
return [_model_out(m) for m in await service.list_defaults()]
@router.put("/default-model-grants/{ai_model_id}", response_model=ModelViewOut)
async def set_default_grant(
ai_model_id: str,
payload: SetDefaultGrantRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> ModelViewOut:
"""Add a model to the default set (or switch its default off without losing it)."""
await service.set_default(
ai_model_id=ai_model_id,
daily_credit_cap=payload.daily_credit_cap,
is_active=payload.is_active,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=DEFAULT_MODEL_GRANT_SET,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
metadata={
"is_active": payload.is_active,
"daily_credit_cap": payload.daily_credit_cap,
},
ip=ip,
user_agent=ua,
)
view = next(
(m for m in await service.list_defaults() if m.ai_model_id == ai_model_id), None
)
assert view is not None
return _model_out(view)
@router.delete("/default-model-grants/{ai_model_id}")
async def remove_default_grant(
ai_model_id: str,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Remove a model from the default set. Already-created orgs keep their grant."""
await service.remove_default(ai_model_id=ai_model_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=DEFAULT_MODEL_GRANT_REMOVE,
actor_user_id=owner.id,
target_type="ai_model",
target_id=ai_model_id,
ip=ip,
user_agent=ua,
)
return {"ai_model_id": ai_model_id, "in_default_set": False}
# ---------------------------------------------------------------------------
# Org policy
# ---------------------------------------------------------------------------
@router.get("/org-policies/{organization_id}", response_model=PolicyOut)
async def get_org_policy(
organization_id: str,
service: ModelGrantService = Depends(get_model_grant_service),
) -> PolicyOut:
return _policy_out(await service.get_org_policy(organization_id))
@router.put("/org-policies/{organization_id}", response_model=PolicyOut)
async def update_org_policy(
organization_id: str,
payload: UpdatePolicyRequest,
request: Request,
owner: User = Depends(require_platform_owner),
service: ModelGrantService = Depends(get_model_grant_service),
session: AsyncSession = Depends(get_session),
) -> PolicyOut:
"""Set the employee cap, monthly budget and automation switch for one org."""
_require_change(
payload.max_employees,
payload.monthly_credit_budget,
payload.automation_enabled,
payload.mcp_enabled,
)
policy = await service.set_org_policy(
organization_id=organization_id,
max_employees=payload.max_employees,
monthly_credit_budget=payload.monthly_credit_budget,
automation_enabled=payload.automation_enabled,
mcp_enabled=payload.mcp_enabled,
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=ORG_POLICY_UPDATE,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="organization",
target_id=organization_id,
metadata={
"max_employees": policy.max_employees,
"monthly_credit_budget": policy.monthly_credit_budget,
"automation_enabled": policy.automation_enabled,
"mcp_enabled": policy.mcp_enabled,
},
ip=ip,
user_agent=ua,
)
return _policy_out(policy)
@@ -2,10 +2,341 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
Two audiences, one rule each:
* an **organization admin** mints, lists and revokes its own tokens, and picks the
scopes — a token can never be broader than the admin's own intent, and it can
never be issued for another organization;
* the **platform owner** holds the two kill switches: the gateway as a whole, and
one organization's access to it.
The raw token is returned exactly once, by the create call, and is never stored —
only its hash. Everything after that is the prefix.
"""
from __future__ import annotations
from fastapi import APIRouter
from datetime import datetime
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_model_grant_service
from app.application.services.audit_service import record_audit
from app.application.services.mcp_service import (
MCP_SCOPES,
McpService,
as_utc,
validate_scopes,
)
from app.core.config import settings
from app.core.database import get_session
from app.domain.base import utcnow
from app.domain.identity.audit_actions import (
MCP_GLOBAL_SWITCH,
MCP_ORG_SWITCH,
MCP_TOKEN_CREATE,
MCP_TOKEN_REVOKE,
)
from app.domain.identity.models import Role, User
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
require_platform_owner,
require_role_at_least,
)
router = APIRouter(tags=["mcp-tokens"])
#: Minting a token is admin+ (it grants a third party real access).
_admin_only = require_role_at_least(Role.ADMIN)
async def _service(session: AsyncSession = Depends(get_session)) -> McpService:
return McpService(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class McpTokenOut(BaseModel):
id: str
organization_id: str
name: str
prefix: str
scopes: list[str]
expires_at: datetime | None = None
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime | None = None
status: str
class McpTokenCreatedOut(McpTokenOut):
"""The only response that ever carries the raw token."""
token: str
class McpTokenIn(BaseModel):
name: str = Field(min_length=2, max_length=120)
scopes: list[str] = Field(min_length=1, max_length=len(MCP_SCOPES))
ttl_days: int | None = Field(default=None, ge=1, le=3650)
class McpSettingsOut(BaseModel):
organization_id: str
mcp_enabled: bool
gateway_enabled: bool
scopes: list[str]
token_count: int
class McpGlobalOut(BaseModel):
gateway_enabled: bool
organizations: list[McpOrgStateOut]
total_tokens: int
class McpOrgStateOut(BaseModel):
organization_id: str
name: str
slug: str
mcp_enabled: bool
token_count: int
class McpGlobalIn(BaseModel):
enabled: bool
class McpOrgSwitchIn(BaseModel):
enabled: bool
def _status(token) -> str:
"""``active`` only while the token is neither revoked nor expired."""
if token.revoked_at is not None:
return "revoked"
if as_utc(token.expires_at) is not None and as_utc(token.expires_at) <= utcnow():
return "expired"
return "active"
def _out(token) -> McpTokenOut:
return McpTokenOut(
id=token.id,
organization_id=token.organization_id,
name=token.name,
prefix=token.prefix,
scopes=token.scopes,
expires_at=token.expires_at,
last_used_at=token.last_used_at,
revoked_at=token.revoked_at,
created_at=token.created_at,
status=_status(token),
)
# ---------------------------------------------------------------------------
# Organization-scoped tokens (admin+)
# ---------------------------------------------------------------------------
@router.get(
"/organizations/{org_id}/mcp-tokens", response_model=list[McpTokenOut]
)
async def list_tokens(
context: OrgContext = Depends(get_org_context),
service: McpService = Depends(_service),
) -> list[McpTokenOut]:
"""Every token this organization ever minted, with its live status.
Deliberately includes revoked and expired rows: the history of who could
reach the organization is the part that matters after an incident.
"""
return [_out(token) for token in await service.list(context.org_id)]
@router.post(
"/organizations/{org_id}/mcp-tokens",
response_model=McpTokenCreatedOut,
status_code=status.HTTP_201_CREATED,
)
async def create_token(
payload: McpTokenIn,
request: Request,
context: OrgContext = Depends(_admin_only),
service: McpService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> McpTokenCreatedOut:
"""Mint a token. The raw value appears in this response and nowhere else."""
issued = await service.issue(
organization_id=context.org_id,
name=payload.name,
scopes=validate_scopes(payload.scopes),
created_by_user_id=context.user.id,
ttl_days=payload.ttl_days,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_TOKEN_CREATE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="mcp_token",
target_id=issued.record.id,
metadata={"prefix": issued.record.prefix, "scopes": issued.record.scopes},
ip=ip,
user_agent=ua,
)
return McpTokenCreatedOut(**_out(issued.record).model_dump(), token=issued.token)
@router.delete("/organizations/{org_id}/mcp-tokens/{token_id}")
async def revoke_token(
token_id: str,
request: Request,
context: OrgContext = Depends(_admin_only),
service: McpService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
await service.revoke(organization_id=context.org_id, token_id=token_id)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_TOKEN_REVOKE,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="mcp_token",
target_id=token_id,
ip=ip,
user_agent=ua,
)
return {"id": token_id, "revoked": True}
@router.get(
"/organizations/{org_id}/mcp-tokens/settings", response_model=McpSettingsOut
)
async def get_mcp_settings(
context: OrgContext = Depends(get_org_context),
service: McpService = Depends(_service),
) -> McpSettingsOut:
"""The switches this organization's own admins may see and the scope list."""
policy = await build_model_grant_service(service.session).get_org_policy(
context.org_id
)
tokens = await service.list(context.org_id)
return McpSettingsOut(
organization_id=context.org_id,
mcp_enabled=policy.mcp_enabled,
gateway_enabled=settings.mcp_gateway_enabled,
scopes=list(MCP_SCOPES),
token_count=sum(1 for token in tokens if _status(token) == "active"),
)
# ---------------------------------------------------------------------------
# Owner: the global switch and the per-organization switch
# ---------------------------------------------------------------------------
@router.get("/admin/mcp", response_model=McpGlobalOut)
async def get_mcp_state(
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> McpGlobalOut:
"""Every organization with its MCP state and active token count."""
from sqlalchemy import select
from app.infrastructure.db.models import OrganizationRow
service = McpService(session)
grants = build_model_grant_service(session)
orgs = (
(await session.execute(select(OrganizationRow).order_by(OrganizationRow.name)))
.scalars()
.all()
)
counts = await service.count_per_org()
states = [
McpOrgStateOut(
organization_id=org.id,
name=org.name,
slug=org.slug,
mcp_enabled=(await grants.get_org_policy(org.id)).mcp_enabled,
token_count=counts.get(org.id, 0),
)
for org in orgs
]
return McpGlobalOut(
gateway_enabled=settings.mcp_gateway_enabled,
organizations=states,
total_tokens=sum(counts.values()),
)
@router.put("/admin/mcp")
async def set_mcp_state(
payload: McpGlobalIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""Disable (or re-enable) the gateway platform-wide.
The switch is a setting rather than a table: it must hold even before any
token exists, and a gateway that cannot reach the database should not be the
thing deciding it can serve traffic.
"""
settings.mcp_gateway_enabled = bool(payload.enabled)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_GLOBAL_SWITCH,
actor_user_id=owner.id,
target_type="platform",
target_id="mcp",
metadata={"enabled": bool(payload.enabled)},
ip=ip,
user_agent=ua,
)
return {"gateway_enabled": settings.mcp_gateway_enabled}
@router.put("/admin/mcp/organizations/{organization_id}")
async def set_org_mcp_state(
organization_id: str,
payload: McpOrgSwitchIn,
request: Request,
owner: User = Depends(require_platform_owner),
session: AsyncSession = Depends(get_session),
) -> dict[str, object]:
"""One organization's access to the gateway, independent of its tokens."""
policy = await build_model_grant_service(session).set_mcp_enabled(
organization_id=organization_id,
enabled=bool(payload.enabled),
actor_user_id=owner.id,
)
ip, ua = client_meta(request)
await record_audit(
session,
action=MCP_ORG_SWITCH,
organization_id=organization_id,
actor_user_id=owner.id,
target_type="organization",
target_id=organization_id,
metadata={"enabled": policy.mcp_enabled},
ip=ip,
user_agent=ua,
)
return {"organization_id": organization_id, "mcp_enabled": policy.mcp_enabled}
__all__ = ["router"]
+308 -1
View File
@@ -2,10 +2,317 @@
Owned by Agent 1 (see docs/plan/PLAN-v2.md §6). Registered in ``rpc_app.py`` already; give each
route its full path (no prefix is applied here besides ``/api``).
Trust ladder, matching the plan: a viewer reads, a member teaches, an admin edits
or deletes anything. The same rules are enforced inside ``MemoryService`` too,
so the MCP gateway (Phase 5) hits the same wall — a rule that only the router
knows is a rule an agent can bypass.
Everything stored here went through ``redact`` on the way in, so no endpoint can
ever return a credential: what you can read is what was safe to write.
"""
from __future__ import annotations
from fastapi import APIRouter
from fastapi import APIRouter, Depends, Query, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.container_org import build_memory_service
from app.application.ports.realtime import RealtimeEvent, org_channel, publish
from app.application.services.audit_service import record_audit
from app.application.services.memory_service import MemoryService
from app.core.database import get_session
from app.domain.identity.audit_actions import (
MEMORY_CONFIRM,
MEMORY_CREATE,
MEMORY_DELETE,
MEMORY_PIN,
MEMORY_UPDATE,
)
from app.domain.identity.models import Role
from app.domain.memory.models import (
KIND_LABELS,
SOURCE_LABELS,
Memory,
MemoryKind,
)
from app.interfaces.http.deps import (
OrgContext,
client_meta,
get_org_context,
require_role_at_least,
)
router = APIRouter(tags=["memory"])
#: Anyone with a membership reads; teaching is member+; editing is admin+.
_teacher = require_role_at_least(Role.MEMBER)
_editor = require_role_at_least(Role.ADMIN)
async def _service(session: AsyncSession = Depends(get_session)) -> MemoryService:
return build_memory_service(session)
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class MemoryOut(BaseModel):
id: str
kind: str
kind_label: str
key: str | None = None
content: str
source: str
source_label: str
evidence_task_id: str | None = None
pinned: bool
confidence: float
expires_at: str | None = None
hits: int
last_used_at: str | None = None
created_by_user_id: str | None = None
created_at: str | None = None
updated_at: str | None = None
class MemoryListOut(BaseModel):
organization_id: str
items: list[MemoryOut]
total: int
pinned: int
chars: int
class MemoryCreateIn(BaseModel):
kind: str = Field(default=str(MemoryKind.RULE), max_length=16)
content: str = Field(min_length=2, max_length=1500)
key: str | None = Field(default=None, max_length=80)
class MemoryUpdateIn(BaseModel):
content: str = Field(min_length=2, max_length=1500)
key: str | None = Field(default=None, max_length=80)
class MemoryPinIn(BaseModel):
pinned: bool = True
def _out(memory: Memory) -> MemoryOut:
return MemoryOut(
id=memory.id,
kind=str(memory.kind),
kind_label=KIND_LABELS.get(str(memory.kind), "یادداشت"),
key=memory.key,
content=memory.content,
source=str(memory.source),
source_label=SOURCE_LABELS.get(str(memory.source), ""),
evidence_task_id=memory.evidence_task_id,
pinned=memory.pinned,
confidence=memory.confidence,
expires_at=memory.expires_at.isoformat() if memory.expires_at else None,
hits=memory.hits,
last_used_at=memory.last_used_at.isoformat() if memory.last_used_at else None,
created_by_user_id=memory.created_by_user_id,
created_at=memory.created_at.isoformat() if memory.created_at else None,
updated_at=memory.updated_at.isoformat() if memory.updated_at else None,
)
async def _audit(
session: AsyncSession,
request: Request,
*,
action: str,
context: OrgContext,
target_id: str,
metadata: dict | None = None,
) -> None:
ip, ua = client_meta(request)
await record_audit(
session,
action=action,
organization_id=context.org_id,
actor_user_id=context.user.id,
target_type="memory",
target_id=target_id,
metadata=metadata or {},
ip=ip,
user_agent=ua,
)
async def _announce(context: OrgContext, memory: Memory) -> None:
"""Tell the tenant's open tabs that memory changed.
``memory.changed`` is published on the org channel and nowhere else: a
memory belongs to one organization, and the realtime authorisation for
``org:<id>`` is membership — exactly the same rule the endpoint enforced.
A failed publish is a lost refresh, never a failed write.
"""
await publish(
RealtimeEvent(
type="memory.changed",
channels=(org_channel(context.org_id),),
organization_id=context.org_id,
data={
"memory_id": memory.id,
"kind": str(memory.kind),
"pinned": memory.pinned,
},
)
)
# ---------------------------------------------------------------------------
# Read
# ---------------------------------------------------------------------------
@router.get("/organizations/{org_id}/memory", response_model=MemoryListOut)
async def list_memory(
kind: str | None = Query(default=None, max_length=16),
q: str | None = Query(default="", max_length=120),
context: OrgContext = Depends(get_org_context),
service: MemoryService = Depends(_service),
) -> MemoryListOut:
"""The tab "قوانین و ترجیحات" and friends; also the source list for search.
Filtering by ``kind`` is a convenience, not a security boundary: an
organization's whole memory is visible to any member by design.
"""
items = await service.list(organization_id=context.org_id, kind=kind, search=q)
stats = await service.stats(context.org_id)
return MemoryListOut(
organization_id=context.org_id,
items=[_out(m) for m in items],
total=stats["total"],
pinned=stats["pinned"],
chars=stats["chars"],
)
# ---------------------------------------------------------------------------
# Teach
# ---------------------------------------------------------------------------
@router.post(
"/organizations/{org_id}/memory",
response_model=MemoryOut,
status_code=status.HTTP_201_CREATED,
)
async def teach_memory(
payload: MemoryCreateIn,
request: Request,
context: OrgContext = Depends(_teacher),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
""""به سازمان یاد بده": a rule or a preference, stored redacted."""
memory = await service.teach(
organization_id=context.org_id,
user_id=context.user.id,
role=context.membership.role,
kind=payload.kind,
content=payload.content,
key=payload.key,
)
await _audit(
session,
request,
action=MEMORY_CREATE,
context=context,
target_id=memory.id,
metadata={"kind": str(memory.kind), "key": memory.key},
)
await _announce(context, memory)
return _out(memory)
@router.patch("/organizations/{org_id}/memory/{memory_id}", response_model=MemoryOut)
async def edit_memory(
memory_id: str,
payload: MemoryUpdateIn,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
memory = await service.edit(
organization_id=context.org_id,
memory_id=memory_id,
content=payload.content,
key=payload.key,
)
await _audit(session, request, action=MEMORY_UPDATE, context=context, target_id=memory_id)
await _announce(context, memory)
return _out(memory)
@router.post("/organizations/{org_id}/memory/{memory_id}/pin", response_model=MemoryOut)
async def pin_memory(
memory_id: str,
payload: MemoryPinIn,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
"""Pin → always in context and never evicted; unpin → back to search."""
memory = await service.set_pinned(
organization_id=context.org_id, memory_id=memory_id, pinned=payload.pinned
)
await _audit(
session,
request,
action=MEMORY_PIN,
context=context,
target_id=memory_id,
metadata={"pinned": payload.pinned},
)
await _announce(context, memory)
return _out(memory)
@router.post("/organizations/{org_id}/memory/{memory_id}/confirm", response_model=MemoryOut)
async def confirm_memory(
memory_id: str,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> MemoryOut:
""""درس تأیید شد": the lesson becomes settled knowledge and stops expiring."""
memory = await service.confirm(organization_id=context.org_id, memory_id=memory_id)
await _audit(
session, request, action=MEMORY_CONFIRM, context=context, target_id=memory_id
)
await _announce(context, memory)
return _out(memory)
@router.delete("/organizations/{org_id}/memory/{memory_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_memory(
memory_id: str,
request: Request,
context: OrgContext = Depends(_editor),
service: MemoryService = Depends(_service),
session: AsyncSession = Depends(get_session),
) -> None:
await service.delete(organization_id=context.org_id, memory_id=memory_id)
await _audit(
session, request, action=MEMORY_DELETE, context=context, target_id=memory_id
)
await publish(
RealtimeEvent(
type="memory.changed",
channels=(org_channel(context.org_id),),
organization_id=context.org_id,
data={"memory_id": memory_id, "deleted": True},
)
)