diff --git a/.gitignore b/.gitignore index c100c783..e3cba05e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,7 @@ deploy/docker/.venv-modelscope/ .vscode/ .claude/skills/gitcode-config.json CLAUDE.md + +# 安全模块 +# 开发子plan +/security-plans diff --git a/agent_plugin/jiuwenswarm/agent_memory_provider.py b/agent_plugin/jiuwenswarm/agent_memory_provider.py index b9696d29..3b30b96e 100644 --- a/agent_plugin/jiuwenswarm/agent_memory_provider.py +++ b/agent_plugin/jiuwenswarm/agent_memory_provider.py @@ -631,6 +631,10 @@ class _InProcessClient(_AgentMemoryClient): def __init__(self, config_path: str | None) -> None: from api import build_kernel + from common.security import internal_context + from common.security.authentication.authentication_impl.dev_authenticator import ( + DevAuthenticator, + ) from config.config import Config config = None @@ -645,6 +649,9 @@ def __init__(self, config_path: str | None) -> None: kernel = build_kernel(config=config) self._api = kernel.api self._kv = kernel.kv + # 身份由认证能力产出,不由调用方传入的 scope 充当(F05 §进程内调用): + # scope 说「操作哪个范围」,security 说「谁在操作」。 + self._security = internal_context(DevAuthenticator()) @staticmethod def _to_api_scope(scope): @@ -663,7 +670,7 @@ async def write(self, content, scope, *, tags=None, metadata=None) -> str | None units = await self._api.write_async( content, api_scope, - source=Modality.TEXT, identity=api_scope, + source=Modality.TEXT, security=self._security, tags=tags, metadata=metadata, ) return units[0].id if units else None @@ -680,7 +687,7 @@ async def search( self._api.recall, query, Context(scope=api_scope), - identity=api_scope, + security=self._security, filters=filters, top_k=top_k, disclosure=DisclosureLevel.L2, @@ -712,7 +719,11 @@ async def evolve_extract(self, scope) -> None: # evolve 是同步+asyncio.run,必须 to_thread await asyncio.to_thread( - self._api.evolve, api_scope, EvolveMode.EXTRACT, Channel.BACKGROUND, identity=api_scope + self._api.evolve, + api_scope, + EvolveMode.EXTRACT, + Channel.BACKGROUND, + security=self._security, ) async def close(self) -> None: diff --git a/bootstrap/cli/__main__.py b/bootstrap/cli/__main__.py index f36d7a53..340025f2 100644 --- a/bootstrap/cli/__main__.py +++ b/bootstrap/cli/__main__.py @@ -41,14 +41,27 @@ def build_parser() -> argparse.ArgumentParser: description="agent-memory memory engine CLI", ) parser.add_argument( - "--server", "--base-url", dest="server", - metavar="URL", default=os.environ.get("AGENT_MEMORY_SERVER"), + "--server", + "--base-url", + dest="server", + metavar="URL", + default=os.environ.get("AGENT_MEMORY_SERVER"), help="drive a running server over HTTP (Mem0 --base-url; default: in-process)", ) parser.add_argument( - "--config", action="append", default=[], metavar="PATH", + "--config", + action="append", + default=[], + metavar="PATH", help="JSON config layer stacked on OFFLINE (in-process only; repeatable)", ) + parser.add_argument( + "--api-key", + dest="api_key", + metavar="KEY", + default=None, + help="API key for --server mode (default: $AGENT_MEMORY_API_KEY)", + ) sub = parser.add_subparsers(dest="command", required=True) @@ -78,7 +91,7 @@ def main(argv: list[str] | None = None) -> int: sys.stderr.write("note: --config is ignored in --server (HTTP) mode\n") try: - client = make_client(args.server, args.config) + client = make_client(args.server, args.config, args.api_key) if args.command in ("health", "status"): return commands.run_health(client, args) if args.command == "batch": diff --git a/bootstrap/cli/client.py b/bootstrap/cli/client.py index 192e5469..1284efa3 100644 --- a/bootstrap/cli/client.py +++ b/bootstrap/cli/client.py @@ -41,9 +41,11 @@ class EngineClient(Protocol): """A backend the CLI can drive: turn a (verb, payload) into (status, body).""" def call(self, verb: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + """Dispatch one memory-engine verb.""" ... def healthz(self) -> tuple[int, dict[str, Any]]: + """Return the backend health response.""" ... @@ -74,9 +76,33 @@ def server(self): return self._srv def call(self, verb: str, payload: dict[str, Any]) -> tuple[int, dict[str, Any]]: + from auth_middleware import authenticated from handler import dispatch - return dispatch(self._srv, verb, payload) + from common.errors import AuthenticationError + from common.security.types import Credentials, Surface + + # 进程内直连没有 HTTP header,故过一个空 Credentials。DEV 模式下得到 + # ROOT,与现状一致(CLI 一直是全权限的);API_KEY 模式下会认证失败—— + # 这是**正确的**:没有凭据就不该有权限。要在 API_KEY 模式下用 CLI, + # 走 HttpClient 带 --api-key。 + # + # 走的是与 HTTP 完全相同的中间件与 dispatch 签名(迁移计划 §5.4「HTTP、 + # MCP、CLI、SDK 和进程内调用使用相同安全契约」)——差别只在 surface 标识 + # 和「没有网络对端故不限流」。 + # + # 认证失败转成 (401, body) 而非抛出:本方法的契约是返回状态码, + # 与 HttpClient.call 一致。 + try: + with authenticated( + self._srv.authenticator, + Credentials(), + self._srv.audit, + surface=Surface.CLI, + ) as security: + return dispatch(self._srv, verb, payload, security) + except AuthenticationError as exc: + return 401, {"error": type(exc).__name__, "message": str(exc)} def healthz(self) -> tuple[int, dict[str, Any]]: return 200, {"status": "ok", "profile": self._srv.config.profile} @@ -85,17 +111,21 @@ def healthz(self) -> tuple[int, dict[str, Any]]: class HttpClient: """Drive a running ``bootstrap`` server over HTTP (``POST /v1/``).""" - def __init__(self, base_url: str, timeout: float = 30.0) -> None: + def __init__(self, base_url: str, timeout: float = 30.0, api_key: str = "") -> None: self.base_url = base_url.rstrip("/") self.timeout = timeout + self.api_key = api_key def _request(self, method: str, path: str, body: dict | None) -> tuple[int, dict[str, Any]]: url = f"{self.base_url}{path}" data = json.dumps(body).encode("utf-8") if body is not None else None + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" req = urllib.request.Request( url, data=data, - headers={"Content-Type": "application/json"}, + headers=headers, method=method, ) try: @@ -124,8 +154,16 @@ def _read_json(resp) -> dict[str, Any]: return {"error": "BadResponse", "message": raw.decode("utf-8", "replace")} -def make_client(server_url: str | None, configs: list[str] | None = None) -> EngineClient: - """Pick a backend: HTTP when ``server_url`` is given, else in-process.""" +def make_client( + server_url: str | None, + configs: list[str] | None = None, + api_key: str | None = None, +) -> EngineClient: + """Pick a backend: HTTP when ``server_url`` is given, else in-process. + + ``api_key`` 缺省读环境变量 ``AGENT_MEMORY_API_KEY``——让 key 不必出现在 + shell history 与 ``ps`` 输出里。 + """ if server_url: - return HttpClient(server_url) + return HttpClient(server_url, api_key=api_key or os.environ.get("AGENT_MEMORY_API_KEY", "")) return InProcessClient(configs) diff --git a/bootstrap/core/auth_middleware.py b/bootstrap/core/auth_middleware.py new file mode 100644 index 00000000..942cc9e7 --- /dev/null +++ b/bootstrap/core/auth_middleware.py @@ -0,0 +1,186 @@ +"""请求作用域的安全上下文——凭据提取 + ``RequestSecurityContext`` 构造。 + +各 surface(HTTP / MCP / CLI 直连)用同一条中间件:把本形态的凭据材料归一成 +:class:`~common.security.types.Credentials`,交给装配好的 ``Authenticator``,把产出的 +``AuthContext`` 包成 :class:`~common.security.types.RequestSecurityContext` 交给 +``handler.dispatch``——这是 ``MemoryAPI`` 的唯一显式安全输入(迁移计划 §5.2 第 7 项)。 + +**本模块不决定认证策略**——模式(dev / trusted / api_key)由配置在装配期选定, +这里只负责「在正确的时机调用它、并保证退出时清理干净」。 + +上下文经**参数**下传,不经 ContextVar:ContextVar 在本模块仍会设置,但已降级为 +日志/trace 的辅助传播(迁移计划 §5.2 第 10 项),授权判定不得依赖它存在。 +""" + +from __future__ import annotations + +import os +import sys +from contextlib import contextmanager +from importlib import import_module +from typing import Any, Iterator, Mapping + +_SRC = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "src" +) +if _SRC not in sys.path: + sys.path.append(_SRC) + +_security_types = import_module("common.security.types") +reset_current = _security_types.reset_current +set_current = _security_types.set_current +Credentials = _security_types.Credentials +Surface = _security_types.Surface + +# RequestSecurityContext 的构造规则(服务端生成 request_id、服务端时钟、attributes +# 只由系统组件写)收在 common.security.request_context 一处,本模块只提供本形态的 +# surface 与 peer。 +new_request_context = import_module("common.security.request_context").new_request_context + +Scope = import_module("common.type_def").Scope +AuditEvent = import_module("common.type_def").AuditEvent +_errors = import_module("common.errors") +AuthenticationError = _errors.AuthenticationError +RateLimitedError = _errors.RateLimitedError + +_BEARER = "bearer " + +_RATE_LIMITED = "too many requests" + + +def credentials_from_headers(headers: Mapping[str, Any], peer_address: str = "") -> Credentials: + """从 HTTP header 提取凭据。 + + HTTP header 名大小写不敏感(RFC 9110 §5.1)。``http.client.HTTPMessage`` 的 + ``get`` 自己会做不敏感匹配,但传给 authenticator 的是普通 Mapping——故在这里 + 统一归一成小写键,authenticator 侧按小写常量查,两边不必各写一次 ``.lower()``。 + """ + normalized = {str(k).lower(): str(v) for k, v in headers.items()} + + api_key = "" + auth = normalized.get("authorization", "") + bearer_len = len(_BEARER) + if auth[:bearer_len].lower() == _BEARER: + api_key = auth[bearer_len:].strip() + if not api_key: + api_key = normalized.get("x-api-key", "").strip() + + return Credentials(api_key=api_key, headers=normalized, peer_address=peer_address) + + +@contextmanager +def authenticated( + authenticator, + credentials, + audit=None, + limiter=None, + *, + workload_guard=None, + surface=None, +) -> Iterator[Any]: + """在请求作用域内建立可信 :class:`RequestSecurityContext`;退出时**必定** reset。 + + 产出的上下文由调用方**显式**传给 ``dispatch``——它是 ``MemoryAPI`` 的唯一安全 + 输入。ContextVar 仍在这里设置,但只供日志/trace 关联,授权不读它。 + + reset 放 ``finally`` 是硬性要求:``ThreadingHTTPServer`` 每请求一线程, + 但线程可能被池化复用;漏 reset 会让下一个请求继承上一个请求的身份—— + 最严重的一类越权。 + + ``authenticate`` 故意放在 ``try`` 之外:认证失败时没有 token 可 reset, + 放进 try 会需要一个 ``token = None`` 的分支判断,反而更容易写错。 + + ``limiter`` 在 ``authenticate`` **之前**执行(F05 §请求执行流程):认证本身就是 + 要保护的资源——API_KEY 模式下每次 authenticate 跑一次 Argon2id verify(128 MiB × + time_cost=4),放在认证之后限流就等于「先让攻击者把 CPU 用掉,再告诉他 + 超限了」。``limiter=None`` 表示不限流(进程内直连 / MCP stdio 无网络对端)。 + + ``workload_guard`` 是昂贵操作的全局并发预算(F05 §Protection §WorkloadGuard): + IP 桶限请求速率,限不住「同时在跑的 Argon2 verify 数」。耗尽即快速拒绝(429) + 而不是排队——无界排队只是把资源耗尽从 CPU/内存转移到线程和请求队列。在 limiter + 之后、authenticate 之前执行;acquire 成功后用 ``finally`` 释放。``None`` 表示 + 该认证实现声明不需要预算保护(见 ``Authenticator.requires_concurrency_guard``)。 + + ``surface`` 由适配层写入(迁移计划 §5.2 第 7 项),调用方不能经 payload 声明; + 缺省 ``INTERNAL`` 对应进程内装配。 + """ + if limiter is not None and not limiter.allow(credentials.peer_address): + _record_denial(audit, authenticator, credentials, "rate_limit") + raise RateLimitedError(_RATE_LIMITED) + + guard_acquired = False + if workload_guard is not None: + if not workload_guard.acquire(): + _record_denial(audit, authenticator, credentials, "workload_budget") + raise RateLimitedError(_RATE_LIMITED) + guard_acquired = True + + try: + ctx = authenticator.authenticate(credentials) + except AuthenticationError: + _record_denial(audit, authenticator, credentials, "authenticate") + raise + finally: + if guard_acquired: + workload_guard.release() + + security = new_request_context( + ctx, + surface=surface if surface is not None else Surface.INTERNAL, + peer=_normalized_peer(credentials), + # attributes 留空:本层没有可写入的系统属性,而业务 payload 一律不得注入 + # (迁移计划 §5.2 第 7 项)。将来要加(如可信代理链、mTLS 主体)只能由 + # 服务端组件在此处写。 + ) + + token = set_current(ctx) + try: + yield security + finally: + reset_current(token) + + +def _normalized_peer(credentials) -> str: + """规范化连接来源:只采信传输层对端地址。 + + 刻意**不读** ``X-Forwarded-For`` / ``X-Real-IP``:没有可信代理白名单时采信这类 + header,等于让调用方自述来源——限流分桶、审计溯源和将来基于 peer 的策略会同时 + 被绕过。要支持反向代理部署,得先有「哪些前置跳是可信的」这项配置,那是独立设计。 + """ + return str(credentials.peer_address or "").strip() + + +def _record_denial(audit, authenticator, credentials, action) -> None: + """入口拒绝落一条审计(security.md §7.2):``action`` 区分限流与认证失败。 + + 每次拒绝都记,无阈值聚合——限流器的计数器目前只用于准入判断,不对外暴露 + 统计;要做「同一 peer 连续失败 N 次告警」还需要一个独立的失败计数维度 + (限流桶按请求数计,不区分成功与失败),那是可观测性设计,不在本期。 + + ``actor`` 是空 ``Scope()``——身份未知,**不可用调用方声明的任何值填充**。 + ``detail`` 里不放 api_key、不放 key 前缀(§7.5 PII 脱敏),也不放桶余量 + (那能用来反推限流参数)。 + + 暂不记录认证失败的细分原因(``missing_credentials`` / ``unknown_principal`` / + ``bad_gateway_key``):三个 authenticator 都刻意只抛同一个笼统消息,要拿到 + 细分原因得在 authenticator 侧另开一条只进审计的通道。那是独立设计, + 不顺手塞进本期。 + """ + if audit is None: + return + try: + mode = authenticator.mode() + audit.record( + AuditEvent( + actor=Scope(), + action=action, + decision="deny", + layer="security", + detail={ + "mode": str(getattr(mode, "value", mode)), + "peer": credentials.peer_address, + }, + ) + ) + except Exception: # pragma: no cover - 审计后端故障不该把 401/429 变成 500 + pass diff --git a/bootstrap/core/handler.py b/bootstrap/core/handler.py index f7588efa..26cd5759 100644 --- a/bootstrap/core/handler.py +++ b/bootstrap/core/handler.py @@ -1,16 +1,22 @@ """Verb dispatch — the single code path both the CLI and HTTP surfaces share. -``dispatch(srv, verb, payload) -> (status, body)`` routes a ``(verb, payload)`` -to the assembled :class:`~server.Server`'s ``MemoryAPI`` and shapes a JSON-able -envelope the surfaces render. Routing is a table (A20 "route by table"), not an -if/else ladder; domain exceptions map to HTTP-ish status codes. +``dispatch(srv, verb, payload, security) -> (status, body)`` routes a +``(verb, payload)`` to the assembled :class:`~server.Server`'s ``MemoryAPI`` and +shapes a JSON-able envelope the surfaces render. Routing is a table (A20 "route by +table"), not an if/else ladder; domain exceptions map to HTTP-ish status codes. Scope mapping (DESIGN.md "Two id spaces" / "Mem0 compatibility"): the kernel scopes by ``tenant_id`` + optional ``space`` / ``space_id`` + a single ``scope`` string, mapped onto the native ``Scope(org=tenant_id, space=space, user=scope)``. The request shape keeps old -empty-space payloads compatible, while allowing an optional claimed actor -override via ``actor_tenant_id`` / ``actor_space`` / ``actor_scope`` fields. +empty-space payloads compatible, and still describes the **target** scope +("which resource"); the **actor** ("who is asking") no longer comes from the +payload at all — it comes from the ``RequestSecurityContext`` the auth +middleware built (security.md §9 铁律 #1). Payloads that still carry ``actor_*`` +fields are rejected outright rather than silently ignored. + +每个 handler 拿到的 ``security`` 原样转交 ``MemoryAPI``:本层不拆包、不改写、也不 +自己判权——授权判定统一在 API 这个唯一 PEP 上(迁移计划 §5.2 第 6 项)。 """ from __future__ import annotations @@ -29,6 +35,7 @@ _errors_module = import_module("common.errors") AgentMemoryError = _errors_module.AgentMemoryError +AuthenticationError = _errors_module.AuthenticationError ConflictError = _errors_module.ConflictError NotFoundError = _errors_module.NotFoundError PermissionDeniedError = _errors_module.PermissionDeniedError @@ -47,11 +54,11 @@ _control_types_module = import_module("control.types") Action = _control_types_module.Action +BatchWriteItem = _control_types_module.BatchWriteItem DeleteMode = _control_types_module.DeleteMode DeleteSelector = _control_types_module.DeleteSelector Grant = _control_types_module.Grant MemoryPatch = _control_types_module.MemoryPatch -BatchWriteItem = _control_types_module.BatchWriteItem PrincipalPath = _control_types_module.PrincipalPath SpaceMember = _control_types_module.SpaceMember SpacePatch = _control_types_module.SpacePatch @@ -64,7 +71,8 @@ _STATUS = { NotFoundError: 404, - PermissionDeniedError: 403, + AuthenticationError: 401, # 不知道你是谁 + PermissionDeniedError: 403, # 知道你是谁,但不许 ConflictError: 409, ValidationError: 400, PolicyError: 400, @@ -139,7 +147,7 @@ def _target_scope(payload: Body) -> Scope: def _scope_from_payload(payload: Body, base: Scope | None = None) -> Scope: - """Parse an optional batch item scope override over a default target scope.""" + """把批量项的可选 target scope 覆盖合并到默认 target scope。""" base = base or Scope() return Scope( org=str(payload.get("tenant_id") or base.org or "default"), @@ -150,43 +158,52 @@ def _scope_from_payload(payload: Body, base: Scope | None = None) -> Scope: ) -def _actor_scope(payload: Body) -> Scope: - """Claimed actor scope; defaults to payload scope, with optional explicit override.""" - has_actor_override = False - actor_fields = ( - "actor_tenant_id", - "actor_space", - "actor_space_id", - "actor_scope", - "actor_agent", - "actor_session", - ) - for key in actor_fields: - if key in payload: - has_actor_override = True - break - - if has_actor_override: - actor_org = str(payload.get("actor_tenant_id", "")) - if actor_org == "": - actor_org = str(payload.get("tenant_id", "default")) or "default" - actor_space = ( - _space_value(payload, prefix="actor_") - if "actor_space" in payload or "actor_space_id" in payload - else _space_value(payload) - ) - return Scope( - org=actor_org, - space=actor_space, - user=str(payload.get("actor_scope", "")), - agent=str(payload.get("actor_agent", "")), - session=str(payload.get("actor_session", "")), +def _require_security(security): + """本层唯一的安全上下文入口:由中间件构造并**显式**传进来。 + + security.md §9 铁律 #1:身份来自上下文,不来自参数。本函数的前身 + ``_identity()`` 读 ContextVar,更早的 ``_actor_scope(payload)`` 直接读 + ``payload["actor_tenant_id"]`` 等字段——任何人提交 ``{"actor_scope": "victim"}`` + 即可读到 victim 的记忆。现在两条路都断了:``RequestSecurityContext`` 只能由 + ``auth_middleware.authenticated`` 产出,dispatch 的调用方必须把它传下来。 + + ``None`` 即中间件未挂载或漏传——fail-closed,绝不回退到 payload、ContextVar + 或默认身份。装配错误应该让所有请求失败,而不是让所有请求以未知身份成功。 + """ + if security is None: + raise AuthenticationError("authentication required") + return security + + +# ``actor_space`` / ``actor_space_id`` 是 space 五维化时一并加进来的伪造面: +# 声明字段每多一维,可冒充的主体就多一维。禁止列表必须与 ``Scope`` 的维数同步—— +# 将来 ``Scope`` 再加维,这里要跟着加。 +_FORBIDDEN_IDENTITY_KEYS = ( + "actor_tenant_id", + "actor_space", + "actor_space_id", + "actor_scope", + "actor_agent", + "actor_session", +) + +# ``audit`` verb 用 actor_agent / actor_session 作**查询过滤谓词**(筛历史事件的 +# 操作者是谁),与身份声明同名但语义不同——它们不参与本次请求的授权。 +# 对该 verb 只拒其余四个(它们不是 audit 的过滤键,出现在那里同样是误以为能声明身份)。 +_AUDIT_FILTER_KEYS = ("actor_agent", "actor_session") + + +def _reject_claimed_identity(payload: Body, allow: tuple[str, ...] = ()) -> None: + """payload 里出现身份声明字段一律报错,不静默忽略。 + + 静默忽略会让「我传了 actor_scope」被误认为仍然生效,写出错误的安全认知; + 显式报错迫使调用方改用认证凭据。 + """ + present = [key for key in _FORBIDDEN_IDENTITY_KEYS if key in payload and key not in allow] + if present: + raise ValidationError( + f"identity must come from credentials, not payload: {sorted(present)}" ) - return Scope( - org=str(payload.get("tenant_id", "default")) or "default", - space=_space_value(payload), - user=str(payload.get("scope", "")), - ) def _require(payload: Body, key: str) -> Any: @@ -297,9 +314,7 @@ def _space_policy(payload: Body) -> SpacePolicy: return SpacePolicy( require_space=_bool_value(raw.get("require_space"), default=False), principal_path=_enum_value(PrincipalPath, principal_path, name="principal_path"), - storage_isolation_strategy=str( - raw.get("storage_isolation_strategy", "metadata_filter") - ), + storage_isolation_strategy=str(raw.get("storage_isolation_strategy", "metadata_filter")), retention=_string_map(raw.get("retention")), quotas=_string_map(raw.get("quotas")), index_profiles=_string_map(raw.get("index_profiles", raw.get("indexes"))), @@ -373,8 +388,8 @@ def _usage_view(usage) -> Body: # --- per-verb handlers ----------------------------------------------------- # -def _add(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) +def _add(srv, payload: Body, security) -> Body: + scope = _target_scope(payload) modality = Modality(payload.get("modality", "text")) # metadata 透传:infer 等调用级开关经 metadata 下推到引擎(engine.write 从 # metadata["infer"]=="true" 判定是否同步走 evolve(EXTRACT) 抽取派生记忆)。 @@ -390,7 +405,7 @@ def _add(srv, payload: Body) -> Body: _require(payload, "content"), scope, modality, - identity=actor, + security=security, tags=payload.get("tags"), assets=payload.get("assets"), metadata=metadata or None, @@ -400,13 +415,18 @@ def _add(srv, payload: Body) -> Body: # 此时不伪造 item_id, # 如实返回 deduped 语义;非空则照常取首条返回。 if not units: - return {"ok": True, "op": "add", "item_id": None, "item": None, - "skipped": "all derived memories deduped (update/noop)"} + return { + "ok": True, + "op": "add", + "item_id": None, + "item": None, + "skipped": "all derived memories deduped (update/noop)", + } unit = units[0] return {"ok": True, "op": "add", "item_id": unit.id, "item": _unit_view(unit)} -def _batch_add(srv, payload: Body) -> Body: +def _batch_add(srv, payload: Body, security) -> Body: raw_defaults = payload.get("defaults", {}) if not isinstance(raw_defaults, dict): raise ValidationError("batch_add defaults must be an object") @@ -475,7 +495,7 @@ def _batch_add(srv, payload: Body) -> Body: items, default_scope, default_source, - identity=_actor_scope(defaults), + security=security, tags=default_tags, metadata=raw_metadata, occurred_at=default_occurred_at, @@ -502,8 +522,8 @@ def _batch_add(srv, payload: Body) -> Body: } -def _search(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) +def _search(srv, payload: Body, security) -> Body: + scope = _target_scope(payload) # extensions:把调用方在请求里给的自定义配置透传给(可能自定义的) # 检索模块。显式校验 dict:extensions 为 truthy 非 dict(字符串/列表等 # 畸形 JSON)时兜底为空, @@ -521,7 +541,7 @@ def _search(srv, payload: Body) -> Body: res = srv.api.recall( _require(payload, "query"), Context(scope, extensions=extensions), - identity=actor, + security=security, filters=payload.get("filters"), # dict DSL / 旧 list:由 API 边界 normalize,非法则 400 top_k=int(payload.get("k", 10)), disclosure=DisclosureLevel.L2, @@ -548,8 +568,8 @@ def _search(srv, payload: Body) -> Body: return body -def _list(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) +def _list(srv, payload: Body, security) -> Body: + scope = _target_scope(payload) offset = _parse_non_negative_int(payload.get("offset"), name="offset", default=0) limit = _parse_positive_int(payload.get("limit"), name="limit", default=100) memory_types = _parse_string_list( @@ -560,7 +580,7 @@ def _list(srv, payload: Body) -> Body: filters = payload.get("filters", payload.get("filter")) result = srv.api.list( scope, - identity=actor, + security=security, offset=offset, limit=limit, memory_types=memory_types, @@ -577,44 +597,41 @@ def _list(srv, payload: Body) -> Body: } -def _get(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) - unit = srv.api.get(_require(payload, "item_id"), scope, identity=actor) +def _get(srv, payload: Body, security) -> Body: + scope = _target_scope(payload) + unit = srv.api.get(_require(payload, "item_id"), scope, security=security) return {"ok": True, "op": "get", "item": _unit_view(unit)} -def _update(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) +def _update(srv, payload: Body, security) -> Body: + scope = _target_scope(payload) patch = MemoryPatch(content=payload.get("content"), tags=payload.get("tags")) - unit = srv.api.update(_require(payload, "item_id"), scope, patch, identity=actor) + unit = srv.api.update(_require(payload, "item_id"), scope, patch, security=security) return {"ok": True, "op": "update", "item": _unit_view(unit)} -def _delete(srv, payload: Body) -> Body: - scope, actor = _target_scope(payload), _actor_scope(payload) +def _delete(srv, payload: Body, security) -> Body: + scope = _target_scope(payload) mode = DeleteMode.PURGE if payload.get("hard") else DeleteMode.FORGET - selector = DeleteSelector( - unit_ids=[_require(payload, "item_id")], scope=scope, mode=mode - ) - deleted = srv.api.delete(selector, identity=actor) + selector = DeleteSelector(unit_ids=[_require(payload, "item_id")], scope=scope, mode=mode) + deleted = srv.api.delete(selector, security=security) return {"ok": True, "op": "delete", "item_id": payload["item_id"], "deleted": deleted} # --- 管理面 / 治理 / 演进 verbs ------------------------------------------- # -def _evolve(srv, payload: Body) -> Body: +def _evolve(srv, payload: Body, security) -> Body: """触发演进(extract/associate/consolidate/forget)→ Evolver 全链路 + Scheduler。""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope = _target_scope(payload) mode = EvolveMode(payload.get("mode", "extract")) - job_id = srv.api.evolve(scope, mode, identity=actor) + job_id = srv.api.evolve(scope, mode, security=security) return {"ok": True, "op": "evolve", "mode": mode.value, "job_id": job_id} -def _job(srv, payload: Body) -> Body: +def _job(srv, payload: Body, security) -> Body: """查询演进任务状态(Scheduler)。""" - actor = _actor_scope(payload) - info = srv.api.job_status(_require(payload, "job_id"), identity=actor) + info = srv.api.job_status(_require(payload, "job_id"), security=security) return { "ok": True, "op": "job", @@ -624,24 +641,23 @@ def _job(srv, payload: Body) -> Body: } -def _inspect(srv, payload: Body) -> Body: +def _inspect(srv, payload: Body, security) -> Body: """治理检视:按 id 读完整单元(含失效版本)→ Governor。""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope = _target_scope(payload) ids = payload.get("item_ids") or [_require(payload, "item_id")] - units = srv.api.inspect(ids, scope, identity=actor) + units = srv.api.inspect(ids, scope, security=security) return {"ok": True, "op": "inspect", "items": [_unit_view(u) for u in units]} -def _trace(srv, payload: Body) -> Body: +def _trace(srv, payload: Body, security) -> Body: """血缘回溯:沿 supersedes 版本链 → Governor。""" - scope, actor = _target_scope(payload), _actor_scope(payload) - chain = srv.api.trace(_require(payload, "item_id"), scope, identity=actor) + scope = _target_scope(payload) + chain = srv.api.trace(_require(payload, "item_id"), scope, security=security) return {"ok": True, "op": "trace", "items": [_unit_view(u) for u in chain]} -def _audit(srv, payload: Body) -> Body: +def _audit(srv, payload: Body, security) -> Body: """审计查询(Governor + AuditLogger)。""" - actor = _actor_scope(payload) filters = {} for key in ( "action", @@ -665,7 +681,7 @@ def _audit(srv, payload: Body) -> Body: filters[key] = payload[key] events = srv.api.audit( filters, - identity=actor, + security=security, limit=_parse_positive_int(payload.get("limit"), name="limit", default=100), ) return { @@ -676,31 +692,30 @@ def _audit(srv, payload: Body) -> Body: } -def _admin(srv, payload: Body) -> Body: - """运行时策略读写:给 value 即 set、给 key 即 get、否则列全部。""" - actor = _actor_scope(payload) +def _admin(srv, payload: Body, security) -> Body: + """运行时策略读写(PolicyManager):给 value 即 set、给 key 即 get、否则列全部。""" key, value = payload.get("key"), payload.get("value") if key and value is not None: - srv.api.admin_set(key, str(value), identity=actor) + srv.api.admin_set(key, str(value), security=security) return { "ok": True, "op": "admin", "key": key, - "value": srv.api.admin_get(key, identity=actor), + "value": srv.api.admin_get(key, security=security), } if key: return { "ok": True, "op": "admin", "key": key, - "value": srv.api.admin_get(key, identity=actor), + "value": srv.api.admin_get(key, security=security), } - return {"ok": True, "op": "admin", "policies": srv.api.admin_all(identity=actor)} + return {"ok": True, "op": "admin", "policies": srv.api.admin_all(security=security)} -def _grant(srv, payload: Body) -> Body: +def _grant(srv, payload: Body, security) -> Body: """跨 scope 授权(PermissionManager)。""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope = _target_scope(payload) grantee = Scope( org=str(payload.get("grantee_tenant_id", scope.org)) or scope.org, space=_space_value(payload, prefix="grantee_") or scope.space, @@ -709,7 +724,7 @@ def _grant(srv, payload: Body) -> Body: session=str(payload.get("grantee_session", "")), ) grant = Grant(grantor=scope, grantee=grantee, actions=[Action.READ]) - srv.api.grant(grant, identity=actor) + srv.api.grant(grant, security=security) return { "ok": True, "op": "grant", @@ -718,9 +733,9 @@ def _grant(srv, payload: Body) -> Body: } -def _revoke(srv, payload: Body) -> Body: +def _revoke(srv, payload: Body, security) -> Body: """Cross-scope revoke (PermissionManager).""" - scope, actor = _target_scope(payload), _actor_scope(payload) + scope = _target_scope(payload) grantee = Scope( org=str(payload.get("grantee_tenant_id", scope.org)) or scope.org, space=_space_value(payload, prefix="grantee_") or scope.space, @@ -729,7 +744,7 @@ def _revoke(srv, payload: Body) -> Body: session=str(payload.get("grantee_session", "")), ) grant = Grant(grantor=scope, grantee=grantee, actions=[Action.READ]) - srv.api.revoke(grant, identity=actor) + srv.api.revoke(grant, security=security) return { "ok": True, "op": "revoke", @@ -738,8 +753,7 @@ def _revoke(srv, payload: Body) -> Body: } -def _create_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _create_space(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" space = _require_space(payload) policy = _space_policy(payload) @@ -752,26 +766,24 @@ def _create_space(srv, payload: Body) -> Body: policy=policy, metadata=_string_map(payload.get("metadata")), ), - identity=actor, + security=security, ) return {"ok": True, "op": "create_space", "space": _space_info_view(info)} -def _get_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _get_space(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" - info = srv.api.get_space(org, _require_space(payload), identity=actor) + info = srv.api.get_space(org, _require_space(payload), security=security) return {"ok": True, "op": "get_space", "space": _space_info_view(info)} -def _list_spaces(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _list_spaces(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" raw_status = payload.get("status") status = _enum_value(SpaceStatus, raw_status, name="status") if raw_status else None spaces = srv.api.list_spaces( org, - identity=actor, + security=security, status=status, limit=_parse_positive_int(payload.get("limit"), name="limit", default=100), cursor=payload.get("cursor"), @@ -784,8 +796,7 @@ def _list_spaces(srv, payload: Body) -> Body: } -def _update_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _update_space(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" patch = SpacePatch( display_name=payload.get("display_name"), @@ -798,22 +809,20 @@ def _update_space(srv, payload: Body) -> Body: policy=_space_policy(payload) if payload.get("policy") else None, metadata=_string_map(payload.get("metadata")) if payload.get("metadata") else None, ) - info = srv.api.update_space(org, _require_space(payload), patch, identity=actor) + info = srv.api.update_space(org, _require_space(payload), patch, security=security) return {"ok": True, "op": "update_space", "space": _space_info_view(info)} -def _archive_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _archive_space(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" - info = srv.api.archive_space(org, _require_space(payload), identity=actor) + info = srv.api.archive_space(org, _require_space(payload), security=security) return {"ok": True, "op": "archive_space", "space": _space_info_view(info)} -def _delete_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _delete_space(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" mode = _enum_value(DeleteMode, payload.get("mode", "purge"), name="mode") - result = srv.api.delete_space(org, _require_space(payload), identity=actor, mode=mode) + result = srv.api.delete_space(org, _require_space(payload), security=security, mode=mode) return { "ok": True, "op": "delete_space", @@ -824,48 +833,43 @@ def _delete_space(srv, payload: Body) -> Body: } -def _export_space(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _export_space(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" export_id = srv.api.export_space( org, _require_space(payload), - identity=actor, + security=security, include_audit=_bool_value(payload.get("include_audit"), default=True), ) return {"ok": True, "op": "export_space", "export_id": export_id} -def _space_usage(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _space_usage(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" - usage = srv.api.space_usage(org, _require_space(payload), identity=actor) + usage = srv.api.space_usage(org, _require_space(payload), security=security) return {"ok": True, "op": "space_usage", "usage": _usage_view(usage)} -def _get_space_policy(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _get_space_policy(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" - policy = srv.api.get_space_policy(org, _require_space(payload), identity=actor) + policy = srv.api.get_space_policy(org, _require_space(payload), security=security) return {"ok": True, "op": "get_space_policy", "policy": _space_policy_view(policy)} -def _set_space_policy(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _set_space_policy(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" policy = srv.api.set_space_policy( org, _require_space(payload), _space_policy(payload), - identity=actor, + security=security, ) return {"ok": True, "op": "set_space_policy", "policy": _space_policy_view(policy)} -def _list_space_members(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _list_space_members(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" - members = srv.api.list_space_members(org, _require_space(payload), identity=actor) + members = srv.api.list_space_members(org, _require_space(payload), security=security) return { "ok": True, "op": "list_space_members", @@ -874,21 +878,24 @@ def _list_space_members(srv, payload: Body) -> Body: } -def _add_space_member(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _add_space_member(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" - srv.api.add_space_member(org, _require_space(payload), _space_member(payload), identity=actor) + srv.api.add_space_member( + org, + _require_space(payload), + _space_member(payload), + security=security, + ) return {"ok": True, "op": "add_space_member"} -def _remove_space_member(srv, payload: Body) -> Body: - actor = _actor_scope(payload) +def _remove_space_member(srv, payload: Body, security) -> Body: org = str(payload.get("tenant_id", "default")) or "default" srv.api.remove_space_member( org, _require_space(payload), _member_scope(payload), - identity=actor, + security=security, ) return {"ok": True, "op": "remove_space_member"} @@ -925,17 +932,24 @@ def _remove_space_member(srv, payload: Body) -> Body: } -def dispatch(srv, verb: str, payload: Body) -> tuple[int, Body]: - """Route ``verb`` through the kernel; return ``(status, body)``.""" +def dispatch(srv, verb: str, payload: Body, security=None) -> tuple[int, Body]: + """Route ``verb`` through the kernel; return ``(status, body)``. + + ``security`` 是 ``auth_middleware.authenticated`` 产出的 + :class:`~common.security.types.RequestSecurityContext`,由各 surface 显式传入; + 缺失即 401(见 :func:`_require_security`)。它默认 ``None`` 而非必填,是为了让 + 「漏传」落在 fail-closed 的 401 上、并统一走本函数的异常→状态码映射,而不是变成 + 调用点的 TypeError → 500。 + """ handler = _ROUTES.get(verb) if handler is None: return 404, {"error": "UnknownVerb", "message": f"no such verb: {verb!r}"} try: - return 200, handler(srv, payload) + # 入口统一拒身份声明,不是每个 verb 各拒一次——单点更难漏。 + _reject_claimed_identity(payload, allow=_AUDIT_FILTER_KEYS if verb == "audit" else ()) + return 200, handler(srv, payload, _require_security(security)) except AgentMemoryError as exc: - status = next( - (code for cls, code in _STATUS.items() if isinstance(exc, cls)), 400 - ) + status = next((code for cls, code in _STATUS.items() if isinstance(exc, cls)), 400) return status, {"error": type(exc).__name__, "message": str(exc)} except Exception as exc: # surface unexpected failures as 500 return 500, {"error": "InternalError", "message": str(exc)} diff --git a/bootstrap/core/server.py b/bootstrap/core/server.py index 75184db3..e27f2964 100644 --- a/bootstrap/core/server.py +++ b/bootstrap/core/server.py @@ -18,6 +18,7 @@ from __future__ import annotations +import logging import os import sys from importlib import import_module @@ -37,14 +38,26 @@ Kernel = _api_module.Kernel build_kernel = _api_module.build_kernel KernelConfig = import_module("config").Config +Factory = import_module("common.factory.factory").Factory +SecurityRuntimeProducer = import_module("common.security.runtime").SecurityRuntimeProducer +register_plugins = import_module("common.bootstrap").register_plugins +ValidationError = import_module("common.errors").ValidationError + +_LOG = logging.getLogger(__name__) class Server: """Assembled kernel + shared dispatch; base for all protocol surfaces.""" - def __init__(self, config: Config, kernel: Kernel) -> None: + def __init__( + self, + config: Config, + kernel: Kernel, + security: Any = None, + ) -> None: self.config = config self.kernel = kernel + self.security = security @property def api(self): @@ -54,6 +67,35 @@ def api(self): def kv(self): return self.kernel.kv + @property + def audit(self): + """装配好的审计器(可能为 None)——认证中间件记入口事件用。""" + return self.kernel.audit + + @property + def authenticator(self): + return self.security.authenticator if self.security is not None else None + + @property + def rate_limiter(self): + return self.security.rate_limiter if self.security is not None else None + + @property + def workload_guard(self): + """昂贵认证操作的并发预算;认证实现声明不需要时为 ``None``。""" + if self.security is None: + return None + if not self.security.authenticator.requires_concurrency_guard(): + return None + return self.security.workload_guard + + @property + def binding_policy(self): + return self.security.binding_policy if self.security is not None else None + + # ``rate_limiter`` 只在有网络对端的 surface(HTTP)传给中间件:进程内直连与 + # MCP stdio 没有远端,限流无对象可分桶,见 ``common.security.protection.RateLimiter.allow``。 + @classmethod def build(cls, config: Config, spaces: Any = None) -> "Server": """Assemble a kernel from ``config`` and return a ``cls`` instance. @@ -65,17 +107,65 @@ def build(cls, config: Config, spaces: Any = None) -> "Server": ``profile`` / ``policies`` 撞上新配置解析期的顶层段名校验而报错。无该段时(纯 ``OFFLINE`` 档)``from_dict(None)`` 返回空配置,回落进程内默认实现,与原行为一致。 """ + # 必须在 from_dict 之前:security / authenticator / key_store 等顶层段名要先进 + # Factory.known_top_names(),否则配置解析期会把它们当未知段拒掉。 + register_plugins() kernel_config = KernelConfig.from_dict(config.settings.get("memory_api")) - return cls( - config, - build_kernel(policies=config.policies or None, config=kernel_config), - ) + kernel = build_kernel(policies=config.policies or None, config=kernel_config) + security = build_security_runtime(kernel_config) + return cls(config, kernel, security) + + def dispatch( + self, verb: str, payload: Dict[str, Any], security: Any = None + ) -> Tuple[int, Dict[str, Any]]: + """Route a ``(verb, payload)`` through the shared handler. - def dispatch(self, verb: str, payload: Dict[str, Any]) -> Tuple[int, Dict[str, Any]]: - """Route a ``(verb, payload)`` through the shared handler.""" + ``security`` 是中间件产出的 ``RequestSecurityContext``;缺失时 handler 返回 + 401(fail-closed),本层不代为构造。 + """ from handler import dispatch as _dispatch - return _dispatch(self, verb, payload) + return _dispatch(self, verb, payload, security) + + +def build_security_runtime(kernel_config: Any): + """按配置的 ``security`` 段装配 :class:`SecurityRuntime`;无该段时回落 DEV 并警告。 + + 回落到 DEV(而非拒绝启动)是刻意的:不打断任何人的本地开发。它把「无认证」 + 从**隐式且不可改**变成**显式、可切换、且非 loopback 时拒绝启动**——DEV 的绑定 + 约束由 Runtime 的 ``binding_policy`` 在 socket 绑定前执行(F05 §Protection + §BindingPolicy)。 + + 装配完成后立刻 ``health()``:能力不健康必须在启动期拒绝,不能等到第一个请求 + 打进来才在 500 里暴露(F05 §默认拒绝)。 + """ + ctx = kernel_config.context(known_top_names=Factory.known_top_names()) + names = sorted(ctx.namespaces.get(SecurityRuntimeProducer.TOP_NAME, {})) + if not names: + _LOG.warning( + "未配置 security 段,回落 DEV 模式:所有请求以 ROOT 身份放行。" + "生产部署须显式配置 security.default 并把 authenticator 指向 api_key 或 trusted。" + ) + runtime = SecurityRuntimeProducer.build( + "standard", {"authenticator": {"target": "dev"}}, ctx + ) + else: + name = _select_configured_instance(SecurityRuntimeProducer.TOP_NAME, names) + runtime = SecurityRuntimeProducer.build_named(name, ctx) + runtime.health() + return runtime + + +def _select_configured_instance(top_name: str, names: list[str]) -> str: + """选定安全组件实例;多实例无 ``default`` 时拒绝歧义配置。""" + if "default" in names: + return "default" + if len(names) == 1: + return names[0] + raise ValidationError( + f"{top_name} 定义了多个具名实例 {names!r},但未定义 'default';" + "安全组件选择存在歧义,拒绝启动。" + ) def default_spaces() -> Dict[str, Any]: diff --git a/bootstrap/http_server/__main__.py b/bootstrap/http_server/__main__.py index 35a91e3a..44ac1def 100644 --- a/bootstrap/http_server/__main__.py +++ b/bootstrap/http_server/__main__.py @@ -16,8 +16,10 @@ import argparse import json +import logging import os import sys +import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from importlib import import_module @@ -33,6 +35,103 @@ load_config = _profiles_module.load_config Server = import_module("server").Server +_auth_middleware = import_module("auth_middleware") +authenticated = _auth_middleware.authenticated +credentials_from_headers = _auth_middleware.credentials_from_headers + +# ``server`` / ``auth_middleware`` 导入时已把仓库 src/ 追加进 sys.path。 +Surface = import_module("common.security.types").Surface +_errors = import_module("common.errors") +AuthenticationError = _errors.AuthenticationError +RateLimitedError = _errors.RateLimitedError +ValidationError = _errors.ValidationError + +# 请求体大小硬上限(审计 P2-4):无上限意味着超大或慢速上传能吃满内存与线程。 +# 4 MiB 覆盖任何合理的记忆写入请求;超大资产本就该走 FS + 分片而非塞进单次 POST。 +_MAX_BODY_BYTES = 4 * 1024 * 1024 +# 读/写超时(秒):慢速上传与慢客户端会长期占住 ThreadingHTTPServer 的线程。 +_READ_TIMEOUT = 30 +# 并发连接/线程硬上限(审计验收 P1-HTTP):timeout 只限单连接占用时长,攻击者持续 +# 补充连接即可维持线程耗尽。有界 semaphore 让超出上限的连接快速被拒(503),在 +# limiter/认证之前生效--未认证来源不能靠慢上传占满处理容量。 +_MAX_CONCURRENT_REQUESTS = 256 + + +def _parse_content_length(headers) -> tuple[int, int]: + """只校验 Content-Length,不读 body。返回 (status, length)。 + + status != 200 时 length 无意义。两阶段准入的第一阶段(审计验收 P1-HTTP): + 只依赖 header,在 limiter/认证之前,通过后才由调用方按 length 读 body。 + """ + raw_len = headers.get("Content-Length", "0") + try: + length = int(raw_len) + except ValueError: + return 400, 0 + if length < 0: + return 400, 0 + if length > _MAX_BODY_BYTES: + return 413, 0 + return 200, length + + +def _read_body(rfile, length: int) -> bytes: + """按已校验的 length 读 body。length 已由 _parse_content_length 约束。""" + return rfile.read(length) if length else b"" + + +class _BoundedThreadingHTTPServer(ThreadingHTTPServer): + """有界并发的 ThreadingHTTPServer(审计验收 P1-HTTP)。 + + process_request 入口用 semaphore 限并发:耗尽时直接拒绝(503),不进 handle + 路径、不占处理线程的认证/读 body 预算。把慢连接攻击的容量从无界线程收束到 + ``_MAX_CONCURRENT_REQUESTS``。 + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._slots = threading.BoundedSemaphore(_MAX_CONCURRENT_REQUESTS) + + def process_request(self, request, client_address): + if not self._slots.acquire(blocking=False): + try: + self._send_503(request) + except OSError: + pass # 客户端已断开 + self.shutdown_request(request) + return + # 不在这里 release:ThreadingHTTPServer.process_request 会 spawn 线程后立即 + # 返回,若在这里 release 等于没限。release 下移到 process_request_thread + # (处理线程真正结束时)。 + t = threading.Thread(target=self._process_and_release, args=(request, client_address)) + t.daemon = self.daemon_threads + t.start() + + def _process_and_release(self, request, client_address): + try: + self.finish_request(request, client_address) + except Exception: + self.handle_error(request, client_address) + finally: + self.shutdown_request(request) + self._slots.release() + + @staticmethod + def _send_503(request) -> None: + body = b'{"error":"ServiceUnavailable","message":"too many connections"}' + crlf = bytes([13, 10]) + request.sendall( + b"HTTP/1.0 503 Service Unavailable" + + crlf + + b"Content-Length: " + + str(len(body)).encode() + + crlf + + b"Content-Type: application/json" + + crlf + + crlf + + body + ) + class HttpServer(Server): """The HTTP/socket surface over the shared kernel dispatch.""" @@ -41,6 +140,10 @@ def _handler_cls(self): srv = self class Handler(BaseHTTPRequestHandler): + # 慢速上传/慢客户端的读写超时(审计 P2-4):无超时会让一个慢连接 + # 长期占住 ThreadingHTTPServer 的线程。 + timeout = _READ_TIMEOUT + def _send(self, status: int, body: dict) -> None: data = json.dumps(body, ensure_ascii=False).encode("utf-8") self.send_response(status) @@ -50,6 +153,9 @@ def _send(self, status: int, body: dict) -> None: self.wfile.write(data) def handle_get(self) -> None: + # /healthz 不认证(§2.1 原则 2 的明文例外)。响应体只含 status + + # profile 名——profile 名是部署配置的一部分但不是秘密,且改它会破坏 + # 现有客户端的 healthz() 契约,第一期保持原样。 if self.path.rstrip("/") == "/healthz": self._send(200, {"status": "ok", "profile": srv.config.profile}) else: @@ -61,14 +167,47 @@ def handle_post(self) -> None: return prefix_len = len("/v1/") verb = self.path[prefix_len:].strip("/") - length = int(self.headers.get("Content-Length", 0)) - raw = self.rfile.read(length) if length else b"" - try: - payload = json.loads(raw) if raw else {} - except ValueError as exc: - self._send(400, {"error": "BadRequest", "message": str(exc)}) + # 两阶段准入(审计验收 P1-HTTP): + # 1) 只校验 Content-Length,不读 body; + # 2) 提凭据 + limiter/认证(慢连接在读 body 前就被挡住); + # 3) 通过后才按已校验长度读 body。 + status, length = _parse_content_length(self.headers) + if status == 413: + self._send( + 413, + { + "error": "PayloadTooLarge", + "message": f"body exceeds {_MAX_BODY_BYTES}B limit", + }, + ) + return + if status == 400: + self._send(400, {"error": "BadRequest", "message": "invalid Content-Length"}) return - status, body = srv.dispatch(verb, payload) # 复用基类 dispatch + creds = credentials_from_headers(self.headers, self.client_address[0]) + try: + # 认证在读 body 之前:未认证/被限流的请求不占读 body 的内存预算。 + # 中间件负责退出时 reset ContextVar。 + with authenticated( + srv.authenticator, + creds, + srv.audit, + srv.rate_limiter, + workload_guard=srv.workload_guard, + surface=Surface.HTTP, + ) as security: + raw = _read_body(self.rfile, length) + try: + payload = json.loads(raw) if raw else {} + except ValueError as exc: + self._send(400, {"error": "BadRequest", "message": str(exc)}) + return + status, body = srv.dispatch(verb, payload, security) + except AuthenticationError as exc: + status, body = 401, {"error": type(exc).__name__, "message": str(exc)} + except RateLimitedError as exc: + # 429 而非 401:限流发生在认证之前,此时还不知道凭据对不对。 + status, body = 429, {"error": type(exc).__name__, "message": str(exc)} self._send(status, body) def log_message(self, *args) -> None: # quiet by default @@ -79,7 +218,28 @@ def log_message(self, *args) -> None: # quiet by default return Handler def serve(self, host: str, port: int) -> None: - httpd = ThreadingHTTPServer((host, port), self._handler_cls()) + # 绑定校验必须位于公开 serve() 内,而不是只放 CLI main():嵌入式调用方 + # 直接调用 serve() 也不能把 DEV/未知认证实现暴露到非 loopback 网络 + # (F05 §Protection §BindingPolicy)。requires_loopback 是认证实现自己声明的 + # capability,不按 target 名推断。 + # + # 没有 SecurityRuntime 就没有绑定策略可执行——此时**拒绝监听**而不是放行: + # 缺少安全能力时开一个不设防的端口,正是 F05 §默认拒绝要排除的情况。 + if self.binding_policy is None: + raise ValidationError( + "HTTP surface 未装配 SecurityRuntime,无法执行绑定策略,拒绝监听。" + ) + self.binding_policy.check( + host, + requires_loopback=( + self.authenticator is None or self.authenticator.requires_loopback_binding() + ), + ) + httpd = _BoundedThreadingHTTPServer((host, port), self._handler_cls()) + # daemon_threads:serve_forever 退出时(KeyboardInterrupt)不等待慢请求线程, + # 否则一个挂住的连接能让进程退不掉(审计 P2-4)。并发上限由 + # _BoundedThreadingHTTPServer 的 semaphore 管(审计验收 P1-HTTP)。 + httpd.daemon_threads = True sys.stderr.write( f"agent-memory server (profile={self.config.profile}) on http://{host}:{port}\n" ) @@ -103,7 +263,12 @@ def main(argv: list[str] | None = None) -> int: for path in args.config: layers.append(load_layer(path)) srv = HttpServer.build(load_config(layers)) # 基类 build → HttpServer 实例 - srv.serve(args.host, args.port) + + try: + srv.serve(args.host, args.port) + except ValidationError as exc: + logging.error("FATAL: %s", exc) + return 1 return 0 diff --git a/bootstrap/mcp_server/__main__.py b/bootstrap/mcp_server/__main__.py index 10cd78a2..a2243b7c 100644 --- a/bootstrap/mcp_server/__main__.py +++ b/bootstrap/mcp_server/__main__.py @@ -11,10 +11,19 @@ 或 Streamable HTTP:: MCP_TRANSPORT=http MCP_PORT=8138 scripts/run-mcp.sh /config/config.yml + +**第一期认证限制(务必知悉)**:MCP 协议自己的凭据传递机制(OAuth 2.1 资源服务器、 +工具调用级的 token 下发)是第二期内容,本 surface 目前只把一个**空凭据**过认证中间件。 +后果是:DEV 模式(缺省 OFFLINE 档)下所有工具照常可用;一旦配成 API_KEY / TRUSTED 模式, +**所有 MCP 工具调用都会失败**(认证失败)。这是有意的—— +``docs/features/common/F04-security-interfaces-and-encryption.md`` +§8.2「MCP 协议的攻击面」需要专门设计,在设计落地前,让 MCP 在生产模式下不可用, +好过让它无认证可用。 """ from __future__ import annotations +import logging import os import sys from importlib import import_module @@ -34,12 +43,20 @@ load_config = _profiles_module.load_config Server = import_module("server").Server +_auth_middleware = import_module("auth_middleware") +authenticated = _auth_middleware.authenticated +AuthenticationError = import_module("common.errors").AuthenticationError +ValidationError = import_module("common.errors").ValidationError +_security_types = import_module("common.security.types") +Credentials = _security_types.Credentials +Surface = _security_types.Surface + try: FastMCP = import_module("mcp.server.fastmcp").FastMCP -except ImportError as exc: # pragma: no cover +except ImportError as import_error: # pragma: no cover raise RuntimeError( 'MCP surface 需要 mcp SDK:pip install ".[mcp]"(或 pip install mcp)' - ) from exc + ) from import_error # --- 内核:进程内装配一次,跨工具调用共享 --- # _SRV = Server.build(load_config([OFFLINE] + [load_layer(p) for p in sys.argv[1:]])) @@ -54,8 +71,22 @@ def _call(verb: str, payload: dict) -> dict: """ 走共享 dispatch;非 2xx 抛错,让 MCP 客户端看到失败原因(None 入参不下发)。 + + 与 ``InProcessClient`` 同样过一个空 ``Credentials()``:MCP 尚无凭据通道(见模块 + docstring 的第一期限制)。DEV 模式下得到 ROOT,非 DEV 模式下这里就会抛认证失败。 """ - status, body = _SRV.dispatch(verb, {k: v for k, v in payload.items() if v is not None}) + try: + with authenticated( + _SRV.authenticator, Credentials(), _SRV.audit, surface=Surface.MCP + ) as security: + status, body = _SRV.dispatch( + verb, {k: v for k, v in payload.items() if v is not None}, security + ) + except AuthenticationError as auth_error: + raise RuntimeError( + f"{type(auth_error).__name__}: {auth_error}" + "(MCP surface 尚未支持凭据传递,仅可在 DEV 模式下使用)" + ) from auth_error if status >= 400: raise RuntimeError(f"{body.get('error', 'Error')}: {body.get('message', '')}") return body @@ -138,6 +169,17 @@ def memory_evolve(tenant_id: str, scope: str, mode: str = "extract") -> dict: def main() -> int: transport = os.environ.get("MCP_TRANSPORT", "stdio") if transport in ("http", "streamable-http"): + # MCP 尚无凭据通道(见模块 docstring):DEV 模式下空凭据即 ROOT,绑非 + # loopback 等于把 ROOT 级记忆工具暴露给整个网络。与 HTTP surface 走同一条 + # BindingPolicy(F05 §Protection §BindingPolicy)。 + try: + _SRV.binding_policy.check( + os.environ.get("MCP_HOST", "127.0.0.1"), + requires_loopback=_SRV.authenticator.requires_loopback_binding(), + ) + except ValidationError as validation_error: + logging.error("FATAL: %s", validation_error) + return 1 mcp.run(transport="streamable-http") # host/port 已在 FastMCP(...) 设好 else: mcp.run() # stdio(默认)——Claude Desktop / Claude Code 直接挂载 diff --git a/docs/features/api/F01-memory-api-impl-design.md b/docs/features/api/F01-memory-api-impl-design.md index fb022098..bde48714 100644 --- a/docs/features/api/F01-memory-api-impl-design.md +++ b/docs/features/api/F01-memory-api-impl-design.md @@ -12,6 +12,11 @@ > 本文档归档**记忆接口层实现的设计与取舍**:`MemoryAPI` 的单进程实现 `LocalMemoryAPI`(鉴权/审计执行点)与装配落点 `assembly.py`(`build_kernel`/`assemble`/`Kernel`)。 > `MemoryAPI` 的**公开方法签名 / 参数语义 / 返回类型**以接口 `src/api/memory_api.py` 为准(归 spec/接口源码),本文不重复罗列签名,只记录「为什么这样实现」。 +> **后续演进(F05,2026-08-05)**:本文中以 `identity: Scope` 和 +> `PermissionManager.check` 描述的鉴权形态是当期历史设计,已由必填 +> `security: RequestSecurityContext` + `common.security.authorization.Authorizer` 取代; +> 当前接口与 PEP 契约以 S02/S09 为准,`security` 同样不下沉到 Engine。 + --- ## 背景 diff --git a/docs/features/api/F03-batch-write-api.md b/docs/features/api/F03-batch-write-api.md index 5f81e62f..dddfafb6 100644 --- a/docs/features/api/F03-batch-write-api.md +++ b/docs/features/api/F03-batch-write-api.md @@ -129,7 +129,7 @@ api.batch_write( ), ], Scope(org="acme", space="prod", user="alice"), - identity=Scope(org="acme", space="prod", user="alice"), + security=request_security_context, metadata={"infer": "true"}, stream_id="session-1", ) @@ -153,7 +153,7 @@ def batch_write( scope: Scope | None = None, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, @@ -167,7 +167,7 @@ async def batch_write_async( scope: Scope | None = None, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, @@ -184,8 +184,8 @@ def batch_write(...): ``` 每个归一化 item 的字段语义与 `write` 完全一致。顶层 `scope/source/tags/metadata/occurred_at` -表达批量默认值;item 仍可覆盖,支持同一批导入多个 scope。`identity` 仍是整个 batch 的 -调用方身份,不进入 item。 +表达批量默认值;item 仍可覆盖,支持同一批导入多个 scope。`security` 是整个 batch 的 +唯一调用方安全上下文,不进入 item,也不由 payload 声明。 ### 3. 鉴权、空间状态和审计按 item 粒度执行 @@ -196,7 +196,7 @@ def batch_write(...): 归一化顶层默认参数 + item → 校验 metadata → 构造 write PermissionContext -→ PermissionManager.check(identity, item.scope, WRITE, context) +→ Authorizer.authorize(security.auth, item resource, request environment) → _ensure_space_writable(item.scope) → 委托 Engine 写入 → 记录 item 级 audit @@ -263,7 +263,7 @@ async def batch_write(self, items: list[BatchWriteItem]) -> BatchWriteResult API 入口。 API 层仍负责归一化、鉴权、空间状态和审计;Engine 只接收已鉴权、已归一化的 target item, -不接收 identity。 +不接收 `security`。 ### 8. HTTP handler 增加 `/v1/batch_add` @@ -298,14 +298,15 @@ HTTP 面建议新增独立 verb,而不是让 `/v1/add` 同时接受 object/lis ``` handler 负责把 `defaults` 解析为 `batch_write` 顶层默认参数,把每个 item 解析为 -`BatchWriteItem`,并沿用现有 actor override 规则生成统一 `identity`。item 级范围覆盖使用 +`BatchWriteItem`,并把认证中间件产出的统一 `security` 原样传给 API。item 级范围覆盖使用 `target_scope` 嵌套对象,按 `tenant_id` / `space` / `scope` / `agent` / `session` 覆盖 defaults。 `occurred_at` 在 HTTP 中接受 ISO 8601 字符串,defaults 作为顶层默认值、item 可逐项覆盖; 非法 defaults 属于顶层 payload 校验并返回 HTTP 400,非法 item 则保留为结构化失败 outcome。 `target_scope.tenant_id` 为 `null` 或空值时继承 defaults,避免把 JSON null 解释为字符串 `"None"`。 响应固定为 HTTP 200 的 `{ok, op: "batch_add", outcomes}`:每项包含原始 `input`、归一化 `item`、`items`(MemoryUnit view)、`ok`、`error` 和 `error_type`;部分失败不使用 HTTP 207。 -如果未来需要每个 item 不同 actor,应另开管理接口,不在普通 batch 写入里混用。 +payload 中的 actor override 一律拒绝。如果未来需要每个 item 不同 actor,应另开管理接口, +不在普通 batch 写入里混用。 ## 拒绝的方案 @@ -370,5 +371,5 @@ handler 负责把 `defaults` 解析为 `batch_write` 顶层默认参数,把每 后续再评估 `insert_many`、`build_many` 或 extractor 批量 prompt。 4. **跨 item 事务暂不做**:批量写入不是 all-or-nothing 事务。需要事务语义的导入任务应在 更高层维护 staging 和补偿。 -5. **HTTP actor 逐项变化暂不做**:普通 batch 共享同一个 `identity`。多 actor 批处理属于 +5. **HTTP actor 逐项变化暂不做**:普通 batch 共享同一个 `security`。多 actor 批处理属于 管理面导入能力,应另行设计。 diff --git a/docs/features/common/F03-scope-space-isolation.md b/docs/features/common/F03-scope-space-isolation.md index 61256654..f459b928 100644 --- a/docs/features/common/F03-scope-space-isolation.md +++ b/docs/features/common/F03-scope-space-isolation.md @@ -44,7 +44,7 @@ ID 全局唯一的前提,与 Store 的“完整 Scope 内唯一”契约冲突 目标 `Scope` 字段集合为: ```python -@dataclass +@dataclass(frozen=True) # 安全加固:身份/隔离值不可变(F01 决策 16) class Scope: org: str = "" space: str = field(default="", kw_only=True) @@ -53,6 +53,11 @@ class Scope: session: str = "" ``` +> **当前状态(安全加固后)**:`Scope` 已是 frozen value object。改某维用 +> `dataclasses.replace(scope, org=...)` 返回新值,禁止原地 `scope.x = ...` +> (抛 `FrozenInstanceError`)。防的是「签发 key 后改原 actor 的 org 让已签发身份 +> 跟着变」的越权。详见 S07 不变量 10 与 F01 决策 16。 + 字段语义: - `org`:组织、账务、合同、平台管理边界。 diff --git a/docs/features/common/F04-security-interfaces-and-encryption.md b/docs/features/common/F04-security-interfaces-and-encryption.md index aed08d06..e540b2b3 100644 --- a/docs/features/common/F04-security-interfaces-and-encryption.md +++ b/docs/features/common/F04-security-interfaces-and-encryption.md @@ -1,23 +1,60 @@ -# 安全接口与加密设计 +# F04 — 安全架构总纲 ## 元信息 | 项 | 值 | |---|---| | 日期 | 2026-07-27 | -| 影响范围 | `src/common/security/`、`src/storage/kv_impl/`、`src/control/engine_impl/`、`docs/specs/S07-common.md`、`docs/specs/S06-storage.md` | -| 测试基线 | `local` SecurityProvider 直接行为校验通过,`EncryptedKVStore` 单测函数直接执行通过;当前环境缺少 pytest/ruff runner | +| 最近更新 | 2026-08-07 | +| 影响范围 | `src/common/security/`(认证、授权、加密、防护子模块)、`src/storage/kv_impl/`、`src/storage/fs_impl/`、`docs/specs/S03-control.md`、`docs/specs/S06-storage.md`、`docs/specs/S07-common.md`、`docs/specs/S09-security.md` | +| 测试基线 | `local` CryptographyProvider 直接行为校验通过,`EncryptedKVStore` 与 `EncryptedFSStore` 单测通过;认证、授权与防护模块镜像测试通过 | +| 关联特性文档 | [F07 认证与加密](F07-authentication-kernel.md)、[F08 授权与安全上下文](F08-authorization-context.md) | +| 规范契约 | [S09 安全横切契约](../../specs/S09-security.md) | +| Refs | — | -本文由原 `docs/security/security.md` 迁入 common 特性归档,作为认证、授权、隔离、加密与审计的安全设计基线。后续 `common/security` 接口、`EncryptedKVStore`、`cloud_engine` 读写编排与安全配置均以本文为设计入口。 +## 术语说明 -当前落地状态(2026-07-27):`common/security` 接口已提供 `SecurityProvider` / -`SecurityProducer`,`storage/kv_impl/encrypted_kv_store.py` 已提供 KV 加密装饰器; -`security_impl/local_envelope_security_provider.py` 已提供 `local` ENC1 AES-GCM -真实加解密 provider。KMS / Vault provider 仍未实现。 +本文档作为安全架构总纲,定义以下实施阶段术语,供关联特性文档引用: + +- **认证与加密期(PR1)**:security 基础能力建立阶段,包括认证内核(dev/api_key/trusted 三档认证模式)、凭据管理、速率限制、加密 provider、信封加密与 key 管理。对应 [F07 认证与加密](F07-authentication-kernel.md) 所记录的工作。 + +- **授权与上下文期(PR2)**:角色感知授权与显式上下文传播阶段,包括角色体系(USER/ADMIN/ROOT)、`Authorizer` / Grant / Delegation、唯一 PEP 与 `RequestSecurityContext` 统一安全输入。对应 [F08 授权与安全上下文](F08-authorization-context.md) 所记录的工作。 + +这些阶段在时间上有先后依赖(授权期依赖认证期产出的 `AuthContext`),但在代码组织上均归属 `src/common/security/` 统一模块。 + +> **归档性质**:本文由早期 `docs/security/security.md` 迁入,保留威胁模型、方案取舍和 +> 历史设计草图;其中接口签名、伪代码与 YAML 片段均非现行契约。当前公共契约以 +> S03 / S06 / S07 / S09 为准,当前实现地图以各 `src/*/AGENTS.md` 为准。本文与代码冲突时 +> 不得据此反向修改代码,应先按上述 spec 核对并更新本文的状态注记。 + +当前落地状态(2026-08-07):全部安全能力归 `src/common/security/`,按能力域分子包: + +- `authentication/`:`Authenticator`、`PrincipalKeyStore`、`CredentialStatusRegistry`,内置 dev / api_key / trusted 认证与 memory 凭据存储。 +- `authorization/`:`Authorizer`、`GrantStore`、`DelegationStore` 与 scope 规则;内置 standard / routing / allow_all 判定及 memory / sqlite 记录存储。`allow_all` 是仅测试 capability,生产装配默认拒绝。 +- `cryptography/`:`CryptographyProvider` 与独立 `KeyProvider`;`cryptography_impl/local_envelope.py` 同时注册 `cryptography.local` 和 `key_provider.local`。 +- `protection/`:`BindingPolicy`、`RateLimiter`、`WorkloadGuard`,内置 loopback / token_bucket / unlimited / semaphore 实现。 +- `types.py`:定义安全域值对象;`Scope` 仍由 `common.type_def.scope` 定义并被安全类型引用。 +- `request_context.py`:提供 `new_request_context` / `internal_context` 两个受控构造入口。 +- `runtime.py`:`SecurityRuntime` 持有认证、授权、防护与可选密码学能力,统一做健康检查和关闭;凭据撤销复核注册表由唯一 PEP `LocalMemoryAPI` 持有,不放入 Runtime 或 Authorizer。 +- `key_source.py`:保留外部密钥源的抽象接缝,当前不属于注册式 `KeyProvider` 装配链。 + +当前安全链路的关键事实: + +- ROOT 只由可信 `AuthContext.role` 表达;dev 与 Root API Key 分别使用具名 actor `system/dev`、`system/root`,空 `Scope()` 在 PDP 中拒绝。 +- 公开 `MemoryAPI` verb 以 `security: RequestSecurityContext` 为必填 keyword-only 输入;ContextVar 只辅助日志与 trace,不参与授权结论。 +- Agent 委托来自服务端 `DelegationStore` 按 `delegation_id` 复核;`acting_user` 已从 PR2 的 `AuthContext` 删除。 +- 可撤销 API Key 由 PEP 按 `(credential_type, credential_issuer)` 路由到发证 Store 在线复核;`AuthContext` 不携带 Callable 或 Store 引用。 +- ENC1 写出 v2(含 key id / epoch),读取兼容 v1;不存在明文回退开关。`LocalKeyProvider.rotate()` 支持进程内多代轮换,但新根密钥与历史 epoch 不持久化,跨重启轮换需外部 KMS / Vault。 +- `EncryptedKVStore` 与 `EncryptedFSStore` 是显式选择的存储装饰器;只写密码学 YAML 不会自动改变主业务存储链路。 +- 审计仍由 `src/common/audit/` 承载;F05 目标态的审计完整性 capability 属 PR3,当前未实现。 + +本文以下正文是迁移前的历史设计输入,路径、类名、伪代码及其中标为「实现注记」的段落也只代表当期状态;它们不覆盖上面的当前事实与 S09。旧平铺路径(`common/encryption`、`common/authentication`、`common/credential_store`、`common/admission`、`type_def/auth.py`)不再作为新代码约束。 --- -## 1. 安全模型总览 +## 背景 + +### 1. 安全模型总览 ### 1.1 三道防线 @@ -39,7 +76,7 @@ 加密 + 完整性校验 ``` -**核心不变量**:身份信息(org / user 或 agent / role)**永远来自认证层产出的 AuthContext**,不来自 URI、不来自请求体参数、不来自未经校验的 HTTP header(trusted 模式也必须有明确的网关信任边界)。user 与 agent 是同级主体;Agent 代 user 操作时,委托关系来自已验证的 `acting_user`,不能由调用方自报。 +**核心不变量(现行)**:身份信息(org / user 或 agent / role)**永远来自认证层产出的 AuthContext**,不来自 URI、不来自请求体参数、不来自未经校验的 HTTP header(trusted 模式也必须有明确的网关信任边界)。user 与 agent 是同级主体;Agent 代 user 操作时,委托关系由服务端 `DelegationStore` 根据 `delegation_id` 复核,不能由调用方自报目标 user。 > **关联设计文档**:本框架的三道防线与项目的「透明可治理」设计原则一脉相承——见 [`design/vision.md` §3 设计原则](../../design/vision.md)(记忆可检视、可编辑、可审计、可回溯、可遗忘)与 [`design/architecture.md` §12 横切关注点](../../design/architecture.md)(安全合规:scope 权限、端侧数据不出端、传输/存储加密、可遗忘)。 @@ -56,17 +93,22 @@ --- -## 2. 认证(Authentication) +## 决策 + +### 2. 认证(Authentication) -> **关联设计文档**:认证的执行点(PEP, Policy Enforcement Point)落在接口层——每个 API 方法先 `check(identity, scope, action)`、落带 identity 的入口审计,通过后才委托业务。详见 [`design/architecture.md` §9 记忆接口层](../../design/architecture.md)。 +> **现行接线**:认证由各 surface 的中间件执行;授权执行点(PEP)唯一落在 `MemoryAPI`。 +> 每个公开 verb 接收 `RequestSecurityContext`,构造 `ResourceDescriptor` 与 +> `AuthorizationEnvironment` 后调用 `Authorizer.authorize(...)`,通过后才委托业务。 ### 2.1 设计原则 -1. **可插拔认证模式**:框架应支持多种认证模式,在启动时由配置决定,不要硬编码。 +1. **可插拔认证模式**:框架通过 Producer 选择实现,通过 capability 决定 loopback 与 + 重型校验保护;核心不按封闭 `AuthMode` 枚举分支。未知实现默认仅 loopback 且启用并发 guard。 2. **每个请求必须过认证**:没有任何 endpoint 能绕过认证层(健康检查可例外)。 3. **单次验证、上下文传播**:认证中间件只验证一次身份,结果注入请求上下文,后续流程不再重复校验身份。 4. **常时间比较(timing-safe)**:所有密钥比对必须使用 `hmac.compare_digest` 或等价的常时间函数。 -5. **可插拔算子用注册式工厂(Factory + Producer)**:`agent-memory` mem2.0 的所有核心抽象(PermissionManager / AuditLogger / Governor / Engine / KVStore 等)用 `XxxProducer(Factory)` + `@Producer.register("name")` 自注册,装配时 `Producer.dep(root, default="name")` 按名取实例。安全模块的认证/权限/审计算子同样遵循此模式。 +5. **可插拔算子用注册式工厂(Factory + Producer)**:`agent-memory` mem2.0 的核心抽象通过注册式工厂装配。安全模块的认证、限流与加密 provider 同样遵循此模式;FSStore 虽可独立装配,但当前主 `build_kernel` 尚无资产消费者,不能据此宣称已自动接入主链路。当前契约见 S09。 6. **应用层 bootstrap 已生成**:`bootstrap/` 下有 CLI / HTTP server / MCP server / SDK 四种接入形态的薄封装,安全模块通过 bootstrap 挂载。`deploy/` 下有 Docker / local 部署方案。 ### 2.2 三种认证模式 @@ -115,6 +157,11 @@ if auth_mode == AuthMode.DEV: return AuthContext(actor=Scope(org="*"), role=Role.ROOT) ``` +> **现行实现注记**:DEV 返回 `AuthContext(actor=Scope(org="system", user="dev"), +> role=Role.ROOT)`。ROOT 只由 role 表达;空 `Scope()` 不再是 platform-admin, +> `StandardAuthorizer` 会拒绝它。见 +> `src/common/security/authentication/authentication_impl/dev_authenticator.py`。 + **约束**:DEV 模式只允许监听 localhost。启动时如果检测到非 localhost 绑定,应当 `sys.exit(1)` 并打印错误消息。**注意覆盖容器化场景下 `0.0.0.0` 这种最危险的情况**: ```python @@ -143,6 +190,14 @@ def enforce_dev_localhost_binding(bind_host): > DEV 模式唯一正确的用途:本地开发、单机调试。**永远不要**在非 localhost 上 DEV 模式运行。容器化场景下,即使绑了 `127.0.0.1`,也要保证 Docker/K8s 的网络配置不会把端口转发出去——这一层 guard 无法替你检查。生产部署必须显式配 `auth_mode: api_key` 或 `trusted`。 +> **主干实现注记**(F01,路径经 F05 迁移更新):主干把这段拆成两半——绑定校验现由 +> `common.security.protection.binding_policy.BindingPolicy.check(hosts, *, requires_loopback)` +> 表达(内置 `loopback` 实现,是否强制由 `Authenticator.requires_loopback_binding()` capability 决定), +> 是**纯函数**,非 localhost 抛 `ValidationError`,容器场景走 `logging.warning`; +> `sys.exit(1)` 与 stderr 上的 `FATAL:` 留在 `bootstrap/http_server/__main__.py:main`。 +> 这样 guard 本身可被单测直接断言(`tests/unit/common/security/protection/test_binding_policy.py`), +> 而不必在测试里捕获 `SystemExit`。 + #### 2.2.2 TRUSTED 模式 **语义**:信任上游网关(如 nginx、API Gateway)已经完成认证,**网关负责校验身份**,框架只读取网关注入的 header。 @@ -176,6 +231,13 @@ if auth_mode == AuthMode.TRUSTED: **关键设计**:role 不来自 header——header 说「你是谁」,框架自己要查「你能干什么」。这样即使网关被攻破或误配,也无法任意提权。 +> **主干实现注记**(F01):`TrustedAuthenticator` 查的 header 名一律是**小写常量** +> ——归一在 `bootstrap.core.auth_middleware.credentials_from_headers` 里做了一次 +> (RFC 9110 §5.1,header 名大小写不敏感),authenticator 侧不再重复处理大小写。 +> `principal_role_store.get_role` 对应主干的 `PrincipalKeyStore.get_role(actor)`: +> 参数是一个 `Scope` 而非三元组,与本仓 `Scope` 的实际形状对齐。 +> 主体查不到时抛 `AuthenticationError`(不回落任何默认 role)。 + #### 2.2.3 API_KEY 模式 **语义**:框架自己验证 API Key。Root API Key 比对成功后直接返回 ROOT 身份;普通主体的 API Key 查注册表。 @@ -196,6 +258,12 @@ if auth_mode == AuthMode.API_KEY: return identity ``` +> **主干实现注记**(F01):`compare_digest` 在主干里两边都 `.encode("utf-8")` +> 成 **bytes** 再比。str 版本在参数含非 ASCII 字符时抛 `TypeError`,那会让一次 +> 认证失败变成 500 而不是 401——把「凭据错误」暴露成「服务器错误」, +> 且绕过了统一的失败审计路径。见 +> `src/common/security/authentication/authentication_impl/api_key_authenticator.py`。 + ### 2.3 API Key 系统 #### 2.3.1 Key 的存储 @@ -249,6 +317,15 @@ class PrincipalKeyStore: return key ``` +> **主干实现注记**(F01):主干把 `store_key` 拆成对外的 +> `PrincipalKeyStore.issue(actor: Scope, role: Role) -> str` 与实现内部的前缀 +> 索引维护——索引是**实现细节**,不该出现在跨实现的 ABC 上。另有三处收紧: +> `api_key_hashing_enabled` 开关**不提供**(缺 `argon2-cffi` 时在装配期抛 +> `ValidationError`,绝不回落明文,铁律 #3);`role=ROOT` 抛 +> `PermissionDeniedError`(§3.2 禁止自签发 ROOT);`actor` 必须且只能指定 +> `user` 或 `agent` 之一。第一期唯一实现注册名为 **`memory`**(进程内), +> Argon2 是它的内部细节而非后端名。 + **重要**:`api_key_hashing_enabled` 建议**默认开启**。Argon2id 推荐参数(2024+ 标准):**`time_cost=4, memory_cost=128 * 1024 (128 MB), parallelism=2`**。这是当前 OWASP 推荐的最低值,适合 2026 年的硬件水准。金融、医疗等合规场景应进一步提高(`time_cost=6+`)。如果默认关闭,当加密层也关闭时,key 就是磁盘上的裸明文。 ```python @@ -490,7 +567,7 @@ def get_ctx() -> AuthContext: --- -## 3. 授权(Authorization) +### 3. 授权(Authorization) > **关联设计文档**:本框架的授权以 `org > user = agent > session` scope 模型为载体——user 与 agent 是同级主体,检索/写入默认限制在各自主体 scope 内,跨主体访问需显式授权。授权检查在接口层以 `identity`(调用方)与 `scope`(目标)分离的形式执行,见 [`design/architecture.md` §3.2 作用域与多租户](../../design/architecture.md) 与 [`design/architecture.md` §9 记忆接口层](../../design/architecture.md)。 @@ -501,10 +578,10 @@ def get_ctx() -> AuthContext: > **Demo 实现注记**:demo 三档角色为 **user / org_admin / ROOT**(对应指南 USER/ADMIN/ROOT)。 > demo 的 org_admin 比指南的 ADMIN 更细:绑具体 org、**org 首个 user 自动成为 admin**(引导)、可自治提拔/降级本 org > admin(对称)、受**最后一个 admin 保护**、永不能签 ROOT、走 api_key 不进数据面。 -> `agent-memory` mem2.0 的 `permission_impl/` 已有两个实现:`AllowAllPermissionManager`(全放行,测试用)和 -> `SQLitePermissionManager`(SQLite ACL:grant 持久化 + revoke 软撤销 `revoked_at` + owner scope covers + 跨 org 拒 + grants 表查询)。 -> demo 的 `DemoPermissionManager` 多一层 `acting_user`(agent 经 user 授权代其操作时从 ContextVar 取)。这是同级主体间的委托关系,不是 agent 从属于 user;该信息计划通过 AuthContext 侧车与 SQLitePermissionManager 协作。 -> 使用 Factory/Producer 注册模式:`@PermissionProducer.register("sqlite")` 自注册,装配时 `PermissionProducer.dep(root, default="sqlite")` 取实例。 +> **现行实现注记**:授权判定已迁入 `common.security.authorization.Authorizer`;内置 +> standard / routing / allow_all,其中 allow_all 只允许测试装配。Grant 与 Delegation +> 分别由独立 Store 保存,委托通过 `delegation_id` 回真源复核,不读取 `acting_user` +> 或 ContextVar。旧 `PermissionManager` 不再位于请求判定路径。 ```python class Role(str, Enum): @@ -663,7 +740,7 @@ T=3: ROOT 通过 PUT .../role 可把某个 user 或 agent 提升为 ROOT --- -## 4. 多租户隔离(Isolation) +### 4. 多租户隔离(Isolation) > **关联设计文档**:本框架的路径前缀注入是项目 scope 模型的存储层落地。scope 层级为 `org > space > user/agent > session`:`space` 是 org 下的逻辑隔离单元;`user` 与 `agent` 在 space 内的归属顺序由 `principal_path` 决定。跨主体或跨 space 访问必须经显式授权。详见 [`design/architecture.md` §3.2 作用域与多租户](../../design/architecture.md)。 @@ -824,7 +901,7 @@ def get_search_roots(context_type, ctx): --- -## 5. 数据加密(Encryption at Rest) +### 5. 数据加密(Encryption at Rest) > **关联设计文档**:存储加密是「端侧数据不出端、传输/存储加密」原则的落地。端云协同场景下,热/私有记忆留端、冷/共享上云,选择性同步需加密传输——见 [`design/vision.md` §4 支柱四 端云协同](../../design/vision.md) 与 [`design/architecture.md` §11 部署架构](../../design/architecture.md)。可插拔存储后端(SQLite/PostgreSQL/Milvus 等)的加密生效边界见 [`design/architecture.md` §5.2 存储抽象](../../design/architecture.md)。 > @@ -867,6 +944,12 @@ EFK长度(2B) | KeyIV长度(2B) | DataIV长度(2B) | ← 12B 定长头 密文自描述——头里记录 provider 类型,解密时按头里的 provider 类型走对应路径。 +> **现行实现注记**:信封实现在 +> `src/common/security/cryptography/cryptography_impl/local_envelope.py`。v1 头为 +> `!4sBBHHH`(12 字节,无 key id / epoch,只读兼容);当前写出的 v2 头为 +> `!4sBBHHHBI`(17 字节),增加 key-id 长度与 key epoch,变长体携带 key id。 +> `LocalKeyProvider` 能在单进程内保留旧 epoch 并轮换,但轮换状态尚不能跨重启持久化。 + ```python ENVELOPE_MAGIC = b"ENC1" VERSION = 0x01 @@ -983,6 +1066,27 @@ async def decrypt(self, org_id: str, raw: bytes) -> bytes: ... ``` +> **现行实现注记**:F05 已拒绝这条宽松兼容方案。当前 +> `LocalEnvelopeCryptographyProvider` 不提供 `allow_plaintext`;非 ENC1、损坏信封、 +> AAD 不匹配与未知版本均抛出并由存储装饰器 fail-closed。旧明文迁移必须使用边界清晰的 +> 离线迁移工具或显式选择未加密 Store,不能在同一 provider 内降级。 +> +> 历史方案曾考虑在 provider 上增加开关,理由是两个部署阶段的正确答案相反: +> +> - **迁移期**必须宽松。加密层上线时,库里全是加密前写的明文;一律拒绝就等于 +> 上线即全量不可读。 +> - **迁移完成后必须收紧**。此时「读到明文」只可能意味着有人绕过了加密层直接写 +> 底层存储,或者配置被改坏了。宽松模式下这两种情况都会被静默放行——而这正是 +> 降级攻击的着力点:攻击者只要能往底层写明文,就能让读路径完全跳过解密。 +> +> 开关只有 provider 上这一个,两个存储装饰器(KV / FS)都不重复提供同语义旋钮 +> ——两个开关意味着两处配置、两种组合,其中「装饰器宽松 + provider 严格」这类 +> 组合没有任何意义,只会在排查时多一个要查的地方。 +> +> 无论开关如何,**写路径永远加密** +> (`test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed`)。 +> 开关若顺带放松了写,迁移期写进去的数据会永远是明文而调用方毫无察觉。 + ### 5.2 Key Provider 抽象 框架应通过 Key Provider 这个策略接口来解耦上层的加密逻辑与底层的密钥托管方式: @@ -1018,6 +1122,28 @@ class KeyProvider(ABC): ... ``` +> **实现注记(F05 迁移后已落地)**:本节设想的独立 `KeyProvider` 顶层抽象**已经存在** +> ——`common.security.cryptography.key_provider.KeyProvider`,独立 Producer +> (`TOP_NAME` 为 `key_provider`),换 KMS / Vault 不必改加密实现。策略接口是 +> `common.security.cryptography.CryptographyProvider` +> (`encrypt(plaintext, *, context, aad)` / `decrypt(...)` / `health()`),它经 +> `KeyProvider` 取密钥,**不得自己读环境变量或配置文件里的根密钥**(F05 §KeyProvider)。 +> 内置 `LocalKeyProvider` 做 HKDF 派生与 data key 包装。仍有一处偏离: +> +> 1. **接口是同步的,不是 `async def`**。`KVStore` / `FSStore` 的方法全是同步的 +> (`get(scope, key) -> bytes`)。异步 provider 会逼着同步的 `get` 内部调 +> `asyncio.run(...)`,而这在一个已有事件循环的进程里直接抛 +> `RuntimeError: asyncio.run() cannot be called from a running event loop`—— +> 也就是说,在真实的 ASGI 部署下必炸。要么整个存储层改异步(远超本期范围), +> 要么 provider 同步。选后者。远程 provider(Vault/KMS)用同步 HTTP 客户端实现, +> 这是它们的库都支持的形态。 +> 2. **`get_encryption_root_key()` 不在对外接口上**。`EncryptionProvider` 只暴露 +> `encrypt` / `decrypt` / `health`,根密钥不跨接口边界。这是收紧不是缺失:把根 +> 密钥交出接口边界,就等于要求每个调用方都正确处理它的生命周期(不落日志、 +> 不进异常、用完清零)——而 KMS/HSM 类 provider **根本交不出来**,根密钥永远 +> 不离开硬件。(`LocalKeyProvider` 上还有这个方法,但那是实现内部的类,不是 +> 存储层能看到的接口。) + #### 5.2.1 LocalProvider(本地开发/单机) Encryption Root Key 存在本地文件(hex 32 字节,+ 0600 权限): @@ -1211,17 +1337,22 @@ FileEncryptor.decrypt(data, org_id) ← ★ 解密 ### 5.4 配置 -当前落地的 KV 加密通过组合 `security` provider 与 `kv_store` 装饰器启用;不存在全局 -`encryption.enabled` 开关。未把业务 KV 指向 `target: encrypted` 时,存储仍按 raw KV +当前落地的 KV 加密通过组合 `cryptography` provider 与 `kv_store` 装饰器启用;不存在全局 +`cryptography.enabled` 开关。未把业务 KV 指向 `target: encrypted` 时,存储仍按 raw KV 后端的原始行为运行。 ```yaml -security: - default: +key_provider: + local_keys: target: local params: key_file: "~/.agent-memory/security/master.key" - allow_plaintext: false + +cryptography: + default: + target: local + params: + key_provider: local_keys kv_store: raw: @@ -1233,7 +1364,7 @@ kv_store: target: encrypted params: raw_kv_store: raw - security: default + cryptography: default ``` **默认不启用加密包装**,因为加密增加复杂度:随机读必须全量解密、grep 必须应用层解密、append 必须读全重写。部署者在确认需要 before storage encryption at rest 场景(如文件磁盘 on laptop、S3 bucket)时才把业务 KV 指向 encrypted wrapper。 @@ -1267,7 +1398,7 @@ class KeyMismatchError(EncryptionError): --- -## 6. Key 管理与分发 +### 6. Key 管理与分发 本章管理的是**认证凭据**,即第 2 章的 API Key;它不管理第 5 章用于静态数据加密的 Encryption Root Key。两者必须使用独立随机值、独立配置项和独立轮换流程,禁止复用。 @@ -1433,7 +1564,7 @@ def verify_token(token_value: str) -> Token | None: --- -## 7. 审计日志(Audit Logging) +### 7. 审计日志(Audit Logging) > **关联设计文档**:审计是「可治理」原则(可检视/编辑/审计/回溯/遗忘)的一环。记忆的 `lifecycle` 用「标记失效」而非物理删除(非破坏式更新),`delete` 支持 `purge` 合规删除(物理删除真源与全部派生索引,仅留审计记录)——这两种删除都需审计留痕。见 [`design/architecture.md` §3.1 记忆单元](../../design/architecture.md)、[`design/architecture.md` §12 横切关注点](../../design/architecture.md)、[`design/architecture.md` §14 关键数据流](../../design/architecture.md)(写入路径含审计落点)、[`design/vision.md` §3 设计原则](../../design/vision.md)。 @@ -1448,7 +1579,7 @@ def verify_token(token_value: str) -> Token | None: - `AuthContext.acting_user` 表示当前操作对应的 user:user 自操作时等于 `actor.user`;Agent 经 user 授权代其操作时,是委托目标。它来自服务端验证过的 OAuth claim、授权记录或 session,不来自请求 body/URI;该字段不表示 user 与 agent 存在从属关系。 - PEP 使用完整 `AuthContext` 做授权和审计;鉴权通过后只把 target scope 下沉到 Engine/Store,避免认证元数据污染存储接口。 -参考 `D:\agent-memory-mem2.0\examples\security_demo\auth\auth_context.py`,当前最小字段如下: +参考早期 `security_demo/auth/auth_context.py`,当期 demo 的最小字段如下(已由下方现行实现注记取代): | 字段 | 来源 | 用途 | |---|---|---| @@ -1457,19 +1588,39 @@ def verify_token(token_value: str) -> Token | None: | `role: str` | 服务端角色注册表或已验证 claim | ROOT/org_admin/user 等特权闸门与审计 | | `from_oauth: bool` | 认证分流器 | 区分 OAuth 与 API Key 路径,阻止 OAuth 凭据签发新的 OAuth 状态 | | `authorizing_key_fp: str` | 签发 token/session 时绑定的 Principal API Key fingerprint | key 轮换后的 token/session 级联失效与审计追责 | +| `auth_mode: str` | authenticator(dev/trusted/api_key/oauth) | 认证路径,供审计(§7.2)。由 authenticator 填,不来自请求 | ```python -@dataclass +@dataclass(frozen=True) class AuthContext: actor: Scope acting_user: str = "" from_oauth: bool = False role: str = "user" authorizing_key_fp: str = "" + auth_mode: str = "" ``` 中间件构造 `AuthContext` 后,应通过显式参数或 `ContextVar` 在单次请求内传播,并在请求结束时可靠 reset。任何 handler、LLM tool_call 或业务参数都不能覆盖其中字段。 +> **现行实现注记**:实现在 +> `src/common/security/types.py`——安全类型归安全域,**不再住 `type_def/`**: +> `type_def` 被所有层 import,身份类型放进去会让「谁能构造/改写身份」的边界消失。 +> `role` 是 `Role` 枚举(默认最小权限 `USER`),`actor` 无默认值,整个 dataclass +> `frozen=True`;字段还包括 credential type/id/issuer、auth method、认证/失效时间与 +> delegation id,已没有 `acting_user` / `from_oauth` / `authorizing_key_fp`。 +> `RequestSecurityContext` 通过显式参数传到 PEP;`ContextVar` 仅承载裸 `AuthContext` +> 供日志与 trace 使用。`set_current` / `reset_current` / `get_current` 的 +> **reset 必须在 `finally`**(`ThreadingHTTPServer` 复用线程,泄漏的 ContextVar +> 会让日志错误归因);`get_current()` 未认证时返回 `None`, +> **不返回默认上下文**。 +> +> 下表的候选字段中,`authenticated_at` / `credential_type` / `credential_id` / +> `expires_at` / `delegation_id` 已在 F05 迁移中落地,`auth_method` 取代了 +> `auth_mode`;`from_oauth` 与 `authorizing_key_fp` 已删除——布尔式的 +> `from_oauth` 被开放的 `credential_type` 取代,`authorizing_key_fp` 更名为 +> 更中性的 `credential_id`。 + 未来可按审计和协议演进增加以下字段: | 候选字段 | 语义与约束 | @@ -1574,7 +1725,7 @@ class AuditLogger: --- -## 8. 附加攻击面指引 +### 8. 附加攻击面指引 > **关联设计文档**:分层记忆结构(L0 摘要 / L1 片段 / L2 全文,原始数据为唯一真源)与检索层(scope 为独立轴、各 Store 查询的专用 `scope` 字段做原生隔离)共同决定了索引层攻击面。向量库的明文 abstract 列、各 Store 的索引,需与内容层分开评估访问控制。见 [`design/architecture.md` §4 分层记忆结构](../../design/architecture.md)、[`design/architecture.md` §7 记忆检索层](../../design/architecture.md)、[`design/vision.md` §4 支柱二](../../design/vision.md)。 @@ -1626,7 +1777,7 @@ class RateLimiter: --- -## 9. 安全开发 7 条铁律(Checklist) +### 9. 安全开发 7 条铁律(Checklist) 写完代码后,对照检查每条: @@ -1643,7 +1794,11 @@ def read_file(uri: str, target: Scope, ctx: AuthContext): path = f"{scope_namespace(target)}/{normalize_uri_parts(uri)}" ``` -**自查**:代码里的 org、user/agent、role、`acting_user` 是从认证中间件的 `AuthContext` 取的,还是从 request body / URL parameter / 未验证 header 读的?如果是后者,攻击者就能在单次请求里声明身份或伪造委托。`MemoryAPI` 用 `identity: Scope`(keyword-only)作为调用方身份参数,`PermissionManager.check(identity, scope, action)` 在接口层执行;Agent 代 user 的补充委托只从可信 `AuthContext` 读取,identity/AuthContext 均不下沉到 Engine。 +**自查**:代码里的 org、user/agent、role 是从认证中间件产出的 +`RequestSecurityContext.auth` 取的,还是从 request body / URL parameter / 未验证 header 读的? +如果是后者,攻击者就能在单次请求里声明身份或伪造委托。现行 `MemoryAPI` 用必填 +`security: RequestSecurityContext`(keyword-only)作为唯一安全输入,并由 Authorizer 在接口层 +判定;委托只从服务端 `DelegationStore` 复核,`security` 不下沉到 Engine。现行契约以 S09 为准。 ### 2. 所有加密比对都是常时间的 @@ -1675,7 +1830,7 @@ except AuthenticationFailedError: raise # 不 fallback ``` -**自查**:哪个 catch 了加密/解密/认证函数异常,然后 fallback 到了不安全路径?encrypt, decrypt, hmac, PasswordHasher.verify 都要 fail-closed。另外,解密函数要考虑兼容性好:不是 ENC1 魔数的直接原样返回;但只要是 ENC1 信封,解密失败就必须要拒绝,不能返回部分数据。 +**自查**:哪个 catch 了加密/解密/认证函数异常,然后 fallback 到了不安全路径?encrypt、decrypt、hmac、PasswordHasher.verify 都要 fail-closed。现行加密 provider 对非 ENC1 输入同样拒绝,不把「不是信封」解释成「可按明文读取」。 ### 4. 写操作经过 ensure_mutable_access @@ -1742,3 +1897,25 @@ def read_file(uri: str, target: Scope, ctx: AuthContext): ``` **自查**:加密通常只覆盖「文件/对象存储」这一层。向量库的 `abstract` 列、embedding queue 的 sqlite、缓存层的 kv 存储,**往往不在加密范围内**。不要认为「文件加密了」等于「全链路安全了」。你的威胁模型里,索引层和内容层应该分开评估,并分别配置访问控制。 + +## 拒绝的方案 + +- 由客户端 payload 声明可信身份:无法阻止调用方伪造 actor。 +- 把认证模式、限流或持久化审计后端写成核心枚举/名称白名单:新增 target 必须修改核心, + 且容易让未知实现绕过安全默认值。 +- 缺少加密依赖或密钥时回退明文:部署会在无明显信号的情况下裸存数据。 +- 在每个 Store 或业务入口重复密码学逻辑:新增路径容易漏加密,轮换和 AAD 规则也会漂移。 + +当前分支的 PR1 认证/加密与 PR2 授权取舍分别归档在 [F07 认证与加密](F07-authentication-kernel.md)、[F08 授权与安全上下文](F08-authorization-context.md);存储包装细节见 storage/F02。审计完整性由后续 PR3 特性提交归档。 + +## 验证 + +本文是历史设计输入,不单独维护一套可能漂移的测试总数。当前认证/授权/审计/加密验证 +基线见对应 feature 文档;跨模块接口以 S03 / S06 / S07 / S09 和镜像单测为准。 + +## 已知遗留 + +- OAuth/MCP 凭据通道仍待独立设计;当前 MCP 非 DEV 调用使用空凭据并失败关闭。 +- 外部插件尚无自动 entry-point 发现,宿主应用必须在配置解析前显式 import 注册入口。 +- FSStore 可独立装配加密装饰器,但 `build_kernel` 主业务链路尚无资产消费者。 +- KMS/Vault/HSM、可跨重启持久化的根密钥轮换,以及审计防尾删/回滚的外部可信锚点仍未落地;本地 provider 只支持进程内多代轮换。 diff --git a/docs/features/common/F07-authentication-kernel.md b/docs/features/common/F07-authentication-kernel.md new file mode 100644 index 00000000..0b232694 --- /dev/null +++ b/docs/features/common/F07-authentication-kernel.md @@ -0,0 +1,533 @@ +# F07 — 认证、凭据保护与静态加密 + +## 元信息 + +| 项 | 值 | +|---|---| +| 原始编号 | security/F01(已迁移到 common/F07) | +| 日期 | 2026-07-29 | +| 实施阶段 | 认证与加密期(对应 [F04](F04-security-interfaces-and-encryption.md) §术语说明中的"认证与加密期") | +| 影响范围 | `src/common/security/authentication/`、`src/common/security/cryptography/`、`src/common/security/protection/`、`src/common/security/types.py`、`src/storage/{kv_impl,fs_impl}/`、`bootstrap/core/auth_middleware.py`、对应镜像测试目录与各 surface 装配入口 | +| 测试基线 | 改动前 `2 failed, 656 passed, 60 skipped`;改动后 `2 failed, 788 passed, 60 skipped`。**两个失败是同一对**(`test_bge_m3_embedder.py` 的 `torch` 未安装,`embed` extra 未装),与本改动无关 | +| 依据 | [F04 安全架构总纲](F04-security-interfaces-and-encryption.md) §1.1 核心不变量、§2 认证、§3 授权角色、§7 审计、§8.1 速率限制、§9 铁律 #1 | +| 规范契约 | [S09 安全横切契约](../../specs/S09-security.md) | +| Refs | — | + +> **2026-08-07 现行落点。** 本文正文记录 2026-07-29 的第一版认证设计,保留原貌用于 +> 追溯;下列现行结论与 [S09](../../specs/S09-security.md) 优先于正文: +> +> - **目录**:`common/authentication/` + `credential_store/` + `admission/` + `type_def/auth.py` +> 收敛为 `common/security/{authentication,protection,cryptography}/` 与 `security/types.py`。 +> 旧平铺路径只是历史状态,不再作为约束。 +> - **开放认证实现**:内置 dev / api_key / trusted 是三个已注册 target;`mode()` 返回 +> 开放字符串,核心不按 target 或 mode 分支,差异由 capability 声明。 +> - **ROOT 不靠 actor 形状**:dev 与 Root API Key 分别产出具名 actor `system/dev`、 +> `system/root`,权限仅由 `role=ROOT` 表达;空 `Scope()` 在现行 PDP 中拒绝。正文决策 1 +> 及相关 `PermissionManager` 描述属于迁移前状态。 +> - **上下文与撤销**:`AuthContext` 是 frozen 纯数据;可撤销 API Key 由 PEP 持有的 +> `CredentialStatusRegistry` 按 `(credential_type, credential_issuer)` 在线复核,不在 +> `AuthContext` 中放 Callable 或 Store 引用。PR2 的公开 API 显式接收 +> `RequestSecurityContext`,授权不读取 ContextVar。 +> - **PR2 已知接线缺口**:`TrustedAuthenticator` 当前会产出非空 gateway +> `credential_id`,但没有写 `credential_issuer`,装配也不会把它的 Store 注册到撤销 +> Registry;因此该上下文进入 PEP 在线复核时按 fail-closed 规则被拒。修复前不能把 +> trusted 描述为端到端可用的 PR2 数据面认证方式。 +> - **资源保护**:`Argon2Guard` 已泛化为 `WorkloadGuard`;内置 `semaphore`,共享通过 +> 具名实例显式表达,不靠模块级单例。 +> - **静态加密**:`CryptographyProvider` 与 `KeyProvider` 是两个独立 Producer;内置 +> `local` 写 ENC1 v2(key id / epoch)、读兼容 v1,且不存在 `allow_plaintext`。本地 +> `rotate()` 只保留进程内多代状态;跨重启轮换需外部 KMS / Vault。 +> - **装配面收敛**:`Server.build` 装配一个 `SecurityRuntime`,由它持有能力引用并统一 +> 健康检查;真正授权仍只在 `MemoryAPI` PEP 执行。 +> +> 因此,正文各“决策”是当期 why / why-not 的归档,不是当前 API、路径或配置参考。 + +> **行文简称**:下文(及本模块所有代码注释)里的 **security.md** 一律指上表「依据」 +> 那份文档(现为 [F04 安全架构总纲](F04-security-interfaces-and-encryption.md))。 +> 它原在 `docs/security/security.md`,上游 `c76eb90` 迁入 common 特性归档并改名为 +> `F04-security-interfaces-and-encryption.md`,2026-08-06 更新为当前安全架构总纲; +> 章节编号未变,故简称与 §号沿用不改。 + +> **为什么速率限制在这份文档里而不是单开一份**:它唯一的存在目的是保护认证。 +> API_KEY 模式下每次 `authenticate` 跑一次 Argon2id verify(128 MiB × time_cost=4, +> 约 50~200ms),无限制触发能把进程的 CPU 与内存同时打满——**这个可用性风险 +> 是引入 Argon2 时一并带进来的**,不是一件独立的事。把「留了个洞」和「补上了」 +> 记在同一份文档里,比拆成两份、再让读者去两处对照要诚实。 + +## 背景 + +### 漏洞:身份可由请求体伪造 + +改动前 `bootstrap/core/handler.py` 的 `_actor_scope(payload)` 直接从请求体 +读取调用方身份: + +```python +def _actor_scope(payload: Body) -> Scope: + """Claimed actor scope; defaults to payload scope, with optional explicit override.""" + if any(key in payload for key in ("actor_tenant_id", "actor_scope", ...)): + ... + return Scope(org=actor_org, user=str(payload.get("actor_scope", "")), ...) +``` + +docstring 自己写了 "Claimed" —— 这是**调用方声明的**身份,未经任何校验。 + +利用链(已在改动前的 HEAD 上端到端实测): + +```python +srv.dispatch("add", {"tenant_id": "acme", "scope": "alice", "content": "alice secret"}) +# → 200,alice 写入 + +srv.dispatch("search", {"tenant_id": "acme", "scope": "alice", "query": "secret", + "actor_tenant_id": "evil", "actor_scope": "mallory"}) +# → 403,攻击者用真实身份读,被正确拒绝 + +srv.dispatch("search", {"tenant_id": "acme", "scope": "alice", "query": "secret", + "actor_tenant_id": "acme", "actor_scope": "alice"}) # 改两个字段 +# → 200 ['alice secret'] +``` + +**授权层是对的**(honest read 正确返回 403);洞在于**认证层根本不存在**, +攻击者可以任意填写 `actor_*` 把自己变成任何人。这两行 payload 的差异就是本 +特性要消除的东西。 + +`_actor_scope` 在 `handler.py` 有 13 处调用点,覆盖 add / search / get / +update / delete / evolve / job / inspect / trace / audit / admin / grant / +revoke —— 即**全部动词**,含管理面与授权面。 + +> **一处曾经的误判,留作记录**:起初以为最短利用链是「提交 +> `{"actor_tenant_id": " "}` 得到空 `Scope()` → 命中 +> `SQLitePermissionManager.check` 的 platform-admin 全局放行」。实测不成立: +> `_actor_scope` 不做 strip,`" "` 原样进 `Scope(org=" ")`,与空 `Scope()` +> 不相等;空 org 分支会回退到 `tenant_id`(默认 `"default"`)。危害不因此 +> 降低——「冒充任意已知主体」已是完全的越权读写,只是不能一步登顶 +> platform admin。测试按实测形态写。 + +### 三道防线的覆盖变化 + +| 防线 | security.md | 改动前 | 改动后 | +|---|---|---|---| +| ① 认证 | §2 | **完全没有** | DEV / TRUSTED / API_KEY 三档,配置选定 | +| ② 授权 | §3 | 有(`PermissionManager` + PEP 在 `LocalMemoryAPI._authorize`) | 不变,但**输入端从「调用方声明」换成「认证层产出」** | +| ③ 数据保护 | §5 | 没有 | 不变(见 [storage/F02 加密存储](../storage/F02-encrypted-storage.md)) | +| 审计 | §7 | 有 | 增记认证失败与限流拒绝事件 | +| 速率限制 | §8.1 | 没有(也不需要) | `RateLimiter` 抽象 + 令牌桶实现,挂在认证之前 | + +### 速率限制要挡的是什么 + +认证挡住了「冒充身份」,但它自己成了新的攻击面:`Argon2` 的 50~200ms 单次成本 +在无限制调用下是**放大器**而非防护,几十个并发失败请求就能把 CPU 打满,认证 +本身变成 DoS 面。这不是理论风险——`memory_key_store.py` 用的是 OWASP 2024+ +推荐参数(128 MiB × time_cost=4),单次 verify 的内存占用就是 128 MiB。 + +所以三道防线之外还要补第四件事,且它必须挂在**认证之前**:等认证跑完再限流, +被保护的资源已经消耗掉了。 + +## 决策 + +### 决策 1:ROOT 的 actor 是空 `Scope()`,不是 `Scope(org="*")` + +security.md §2.2.1 的示例写 `Scope(org="*")`。在本仓这**不能用**: +`SQLitePermissionManager.check` 的第一条规则是 `actor == Scope() → True` +(platform admin 全局放行),而 `org="*"` 会先撞上「跨 org 一律拒绝」规则 +——ROOT 反而寸步难行。 + +连带结论:`AuthContext.actor` **不给默认值**。若给了,「忘了传 actor」会 +静默得到空 `Scope()` 即全局权限——最糟糕的 fail-open 形态。 + +### 决策 2:认证不进 `build_kernel` + +认证是**传输层相关**的(凭据从 HTTP header / MCP / CLI 各自的形态来), +内核形态无关。放进 `build_kernel` 会让 `LocalMemoryAPI` 同时承担 AuthN 与 +AuthZ 两件事。 + +落点:`src/common/security/{authentication,protection}/` 提供契约与实现,`Server.build`(bootstrap 层)装配 +authenticator,`auth_middleware` 在各 surface 的请求入口调用。内核只接收 +已认证的 `identity`。 + +`common.bootstrap.register_plugins()` 必须在 `KernelConfig.from_dict` **之前**调用—— +`authenticator` / `key_store` 两个顶层段名要先进 +`Factory.known_top_names()`,否则配置解析期会把它们当未知段拒掉。 + +### 决策 3:无 argon2-cffi 时 fail-closed,不回退明文 + +`argon2-cffi` 是 `security` extra 的可选依赖。缺失时 `key_store` 在**装配期** +抛 `ValidationError`,绝不降级为明文比对或 sha256 单轮。security.md §2.3.1 +明说那让 key 变成磁盘裸明文;铁律 #3 fail-closed。 + +启动失败比静默地用一个不安全的存储好。 + +### 决策 4:MCP 在非 DEV 模式下不可用 + +MCP 的凭据传递机制(security.md §2.5)需要专门设计。第一期 MCP surface 与 +CLI 的 `InProcessClient` 一样过一个**空** `Credentials()`:DEV 模式下可用, +非 DEV 模式下全部工具调用认证失败。 + +**这是有意的**:在 §8.2「MCP 协议的攻击面」设计落地前,让 MCP 在生产模式下 +不可用,好过让它无认证可用。限制已写进 `mcp_server` 的模块 docstring。 + +### 决策 5:payload 里的 `actor_*` 字段报 400,不静默忽略 + +删掉读取逻辑后客户端仍会继续发这些字段。静默忽略会让运维以为「我加了 +actor_scope 限制」仍然生效,写出错误的安全认知。显式报错迫使调用方改用凭据。 + +例外:`audit` verb 的 `actor_agent` / `actor_session` 是**查询过滤谓词** +(筛「历史事件的操作者是谁」),与身份声明同名但语义不同,对该 verb 放行。 + +### 决策 6:默认配置(无 `authenticator` 段)回落 DEV 并 WARNING + +不打断任何人的本地开发。第一期只是把「无认证」从**隐式且不可改**变成 +**显式、可切换、且非 localhost 时拒绝启动**。 + +DEV 模式绑非 loopback 地址时进程**拒绝启动**(返回码 1 + stderr FATAL), +而不是警告——警告会被忽略,而这个错配的后果是全部数据。 + +### 决策 7:限流按调用方地址分桶,不按 `key_fp` + +security.md §8.1 的草图按 `key_fp` 分桶。那防的是「单个合法 key 打爆配额」 +(配额公平),不是这里要防的「攻击者打爆 CPU」——攻击者每次换一把随机 key 就 +换一个新桶,按 `key_fp` 分桶对枚举与耗尽两种攻击都不生效。真正能收敛攻击的是 +来源地址。按 key 的配额公平是独立需求,本期不做。 + +### 决策 8:`allow()` 返回 `bool`,不抛异常 + +限流是**事实陈述**,翻译成 HTTP 429 是 `auth_middleware` 的事。这与 +`PrincipalKeyStore.resolve` 返回 `None` 同理,且不构成 fail-open——调用方拿到 +`False` 唯一能做的就是拒绝。 + +`RateLimitedError` 进 `common/errors.py`(与 `AuthenticationError` 并列):它 +**是**跨层契约,429 与 401 的语义完全不同——一个该稍后重试,一个该换凭据。 +回归防线:`test_rate_limited_is_not_an_authentication_error`。 + +### 决策 9:`peer` 为空串时放行 + +进程内直连与 MCP stdio 没有网络对端,没有可收敛的攻击面,限流只会把本地 CLI +卡住。所以 `Server.build` 只在 HTTP surface 传 limiter,其余 surface 传 `None`。 + +### 决策 10:默认按认证 capability 分岔,远程可达实现默认**开** + +默认选择不按封闭 `AuthMode` 分支,而读 `requires_loopback_binding()`:仅限 loopback +的实现默认 `unlimited`,显式声明可远程暴露的实现默认 `token_bucket`。这允许业务新增 +认证 target 而不修改 Server 枚举分支;网关后部署可显式选择 `unlimited` 把限流交给网关。 + +非 DEV 默认开而不是默认关:默认关等于「必须读过 §8.1 才知道要配」,而没配的 +后果是一个能打挂进程的可用性漏洞。默认开的代价是运维可能撞上 429,但那会伴随 +一个明确的状态码和一个明确的配置项;默认关的代价是**没有信号**。 + +### 决策 11:桶表 LRU 有界 + +桶按 peer 建,而 peer 由远端决定。无界字典会让这个「防资源耗尽」的组件自己变成 +资源耗尽的入口。超出 `max_tracked`(默认 10000)时淘汰最久未活跃的那个——它最 +可能已经补满,淘汰等于重建成满桶,不丢有效状态。 + +关闭限流必须显式配 `target: unlimited`,不能靠把 `capacity` 写成 0:那种反着读 +的魔法值在配置文件里读不出意图(`capacity: 0` 是「一个令牌都不给」还是「不 +限流」?),而读不出来的配置就是会被写错的配置。参数非法在**装配期**报错。 + +### 决策 12:Argon2 verify 进程级并发上限(审计 P1-3) + +IP 令牌桶限的是「单地址的请求速率」,限不住「同时在跑的 Argon2 verify 数」-- +后者才是 CPU/内存耗尽向量:单 IP 30 个并发错误 key = 30 × 128 MiB 同时驻留 ≈ +3.75 GiB。新增 `common/admission/concurrency_guard.py` 的 `Argon2Guard`(进程级 +`BoundedSemaphore`), +在 `auth_middleware.authenticated` 里 limiter 之后、 +authenticate 之前 acquire,耗尽即 429(非阻塞,不排队--排队会让线程无界堆积)。 +acquire 成功后用 `finally` 释放。默认上限 4(按「给认证留 512 MiB」算),由 +`argon2.max_concurrent` 配置。是否装配由认证实现的 +`requires_concurrency_guard()` capability 决定:API_KEY 需要,TRUSTED/DEV 不需要, +未知第三方实现默认需要(fail closed)。Argon2Guard 不进 Factory: +进程级状态按配置实例化多份没有意义,用 `default_argon2_guard()` 取单例。同进程 +重复装配不同 `max_concurrent` 报错(不静默忽略);`max_concurrent=0` 装配期炸 +(不用 `or` 吞成默认)。 + +### 决策 13:加密默认 fail-closed,`allow_plaintext` 默认 False(审计 P2-3) + +`LocalEnvelopeEncryptionProvider` 此前默认 `allow_plaintext=True`:读不带 ENC1 magic +的内容直接原样返回。迁移期方便,但迁移完成后,拥有底层存储写权限的攻击者可用任意 +明文替换密文,绕过 AES-GCM tag 与 AAD。改为默认 `False`(fail-closed);迁移期读 +旧明文须显式 `allow_plaintext=true`,且应有结束条件(迁移完成后关闭、计数归零)。 + +### 决策 14:HTTP 两阶段准入与全局连接上限(审计 P2-4 / 验收 P1-HTTP) + +此前 `handle_post` 先 `rfile.read(length)` 再进认证/限流,无上限意味着超大 body、 +负数/非数字 Content-Length、慢速上传都能耗尽内存与线程。改为**两阶段准入**: +(1) `_parse_content_length` 只校验 header(非数字/负数 -> 400,超 4 MiB -> 413), +不读 body;(2) 提凭据 + limiter/认证--慢连接在读 body 前就被 limiter/认证挡住; +(3) 通过后才 `_read_body` 按已校验长度读。`Handler.timeout`(默认 30s)防单连接 +慢速上传占线程;`daemon_threads=True` 让慢请求线程不阻塞进程退出。 + +验收补强:单连接 timeout 限不住「持续补充连接」的线程耗尽。新增 +`_BoundedThreadingHTTPServer`:`process_request` 入口用 `BoundedSemaphore` +(`_MAX_CONCURRENT_REQUESTS` 默认 256)限并发,耗尽直接 503 拒绝(不进 handle、 +不占读 body 预算)。release 在处理线程结束(`_process_and_release`)而非 spawn 后, +否则限不住。慢上传与超限连接测试见 `test_http_body_limits.py` / `test_http_slow_upload.py`。 + +### 决策 15:FS 文件大小硬上限(审计 P2-5 / 验收 P2-FS / 复验 P2-FS) + +AES-GCM 整块认证要求把整个明文读入内存再加密(见 `encrypted_fs_store.py` 的已知 +代价),无上限意味着一个超大输入能把进程内存吃满。`EncryptedFSStore` 新增 +`max_plaintext_bytes`(默认 64 MiB)与 `max_ciphertext_bytes`(默认明文上限 + +安全余量,可显式配)。**读写两侧都用循环有界读取**(`_read_bounded_stream`): +反复 `read(remaining)` 直到 EOF 或累计达到 `limit+1`,超限即拒。 + +为何用循环而非单次 `read(limit+1)`:`BinaryIO.read(n)` 允许短读(返回 < n 字节 +而未 EOF),单次调用会把第一段当完整文件,造成**静默数据截断**(复验问题 1)。 + +读取侧 `stat` 只作**快速早拒**,不是唯一边界--stat 与随后 `get` 之间内容可能变化 +(TOCTOU),故真正读取仍用循环有界,且解密后**复核**明文上限(密文被替换成另一个 +合法但解出超大的信封也要拒)。密文开销不硬编码某个 provider 的精确值(EncryptionProvider +ABC 不暴露 ciphertext bound),用宽松余量,需精确控制时显式配 `max_ciphertext_bytes`。 + +chunked 加密(第一期不做)落地后可放宽。完整 chunked format(每块绑 chunk index、 +防重排截断拼接、spooled buffer)是独立设计,不在本期。 + +### 决策 16:`Scope` 改为 frozen 值对象(验收第三次 P2-1) + +`AuthContext(frozen=True)` 此前只是浅冻结--`actor: Scope` 可变,签发 key 后改原 +actor 的 org/user 会让已签发 key 的身份跟着变(越权)。`_Record.actor` 也直接保存 +调用方原始引用。`Scope` 改为 `@dataclass(frozen=True)`:身份/隔离是值对象,可变性 +是安全缺陷;改某维用 `dataclasses.replace(scope, org=...)` 返回新值。影响面仅 +`kv_space_manager` 两处原地修改(已改为 `replace`),不改变入参出参类型契约。 + +FS 短读修复(验收第三次 P2-2):`_read_bounded_stream` 改用 `bytearray` 累积而非 +`list[bytes]` + `join`--恶意 1-byte 短读会让 list 长出百万级元素,8 MiB 内容放大到 +~700 MiB。bytearray 是连续缓冲区,内存与字节数成正比,不随分片数放大。 + +## 落地范围与现行契约索引 + +本特性落地了请求级 `AuthContext`、认证/凭据/准入三个 capability、DEV 绑定 guard 与 +统一认证中间件。接口签名和错误语义不在 feature 文档重复维护: + +- 认证上下文、Authenticator capability、YAML 选择与启动不变量:S09; +- `AuthContext`、Factory 与公共类型:S07; +- 角色授权与 agent 代操作:S03; +- 当前实现文件、注册 target 和本地行为铁律:`src/common/AGENTS.md`。 + +这一分工避免 feature 中的历史设计草案被误当成现行公共 API。 + +## 配置草案 + +> 以下是 2026-07-29 的草案形态(顶层嵌在 `memory_api` 下)。**现行配置形态见 +> [S09 §注册与配置](../../specs/S09-security.md)**:安全能力由顶层 `security` 段组合, +> 各能力段与本草案的 target 名一致,但不再嵌套在 `memory_api` 下。 + +```yaml +memory_api: + authenticator: + default: + target: api_key # dev(缺省) / trusted / api_key + params: + root_api_key: ${AGENT_MEMORY_ROOT_KEY} # 部署级凭据,不入注册表 + key_store: shared # 引用下方具名实例 + + key_store: + shared: + target: memory # 进程内;生产需 SQLite 后端(见「已知遗留」2) +``` + +TRUSTED 模式: + +```yaml +memory_api: + authenticator: + default: + target: trusted + params: + gateway_key: ${GATEWAY_SHARED_SECRET} # 默认必须配置;缺则装配期拒绝启动(决策 P1-2) + key_store: shared +``` + +网关须注入 `X-Org-Id` / `X-Principal-Type`(`user` \| `agent`)/ +`X-Principal-Id`。**角色不从 header 读**——框架查 +`PrincipalKeyStore.get_role`。 + +CLI 在 `--server` 模式下带 key:`--api-key`,缺省读环境变量 +`AGENT_MEMORY_API_KEY`(让 key 不出现在 shell history 与 `ps` 输出里)。 + +速率限制(不配整段时按认证模式给默认,见决策 10): + +```yaml +rate_limiter: + default: + target: token_bucket # 或 unlimited + params: + capacity: 30 # 突发额度 + refill_per_sec: 5.0 # 持续速率 + max_tracked: 10000 # 桶表上界(LRU 淘汰) +``` + +默认值面向「交互式使用不该被限流,脚本化枚举必须被限流」这条线:30 个突发够 +任何人工操作和常规客户端启动时的几次探测;持续 5 QPS 远低于 Argon2 verify 打满 +一个核所需的速率。 + +## 破坏性变更 + +| 变更 | 谁受影响 | 迁移方式 | +|---|---|---| +| payload 的 `actor_*` 字段报 400 | 显式传这些字段的客户端 | 删掉这些字段,改用凭据 | +| 非 DEV 模式下无凭据请求返回 401 | 所有现有客户端 | 保持默认(DEV),或签发 key 并带 `Authorization: Bearer` | +| MCP 在非 DEV 模式下全部工具调用失败 | MCP 客户端 | 第二期解决;当前用 DEV | +| `Kernel` 新增 `audit` 字段 | 直接构造 `Kernel(...)` 的代码 | dataclass 带默认值字段,向后兼容 | +| `Server.__init__` 新增 `authenticator` 参数 | 直接构造 `Server(...)` 的代码 | 带默认值 `None`,向后兼容;但 `dispatch` 需要中间件已挂载,否则 401 | +| `handler.dispatch` 在无认证上下文时返回 401 | 直接调 `dispatch` 的测试与脚本 | 用 `set_current` / `authenticated` 包一层 | +| 非 DEV 模式下 HTTP 请求默认受限流(30 突发 / 5 QPS) | 高频客户端、压测脚本 | 调 `rate_limiter` 段的参数,或配 `target: unlimited` | + +**默认配置下(无 `authenticator` 段 → DEV)现有行为不变**:所有请求得到 ROOT, +且不限流。 + +一个**部署形态**注意事项:网关后部署(TRUSTED 模式的常见形态)所有请求共用网关 +出口 IP,会被当成同一个 peer。这种部署应显式配 `target: unlimited` 把限流交给 +网关,或按聚合流量调大 `capacity`。 + +## 验证 + +| 文件 | 覆盖 | 结果 | +|---|---|---| +| `tests/unit/common/security/test_types.py` | `AuthContext` frozen / `actor` 无默认 / ContextVar 线程隔离与 reset | 11 passed | +| `tests/unit/common/security/authentication/test_authenticator.py` | ABC 契约 / Producer 注册 / bootstrap 幂等 | passed | +| `tests/unit/common/security/authentication/test_key_store.py` | issue / resolve / revoke / ROOT 禁签 / **timing pad** / 不存明文 | passed | +| `tests/unit/common/security/authentication/test_authentication_impl.py` | 三实现的正反路径 / 错误消息一致 | passed | +| `tests/unit/common/security/protection/test_binding_policy.py` | loopback 绑定策略的各类拒绝 | passed | +| `tests/unit/common/security/protection/test_rate_limit.py` | 突发/补充/并发/LRU/空 peer/装配期参数校验 | 16 passed | +| `tests/unit/bootstrap/test_auth_middleware.py` | header 归一 / bearer 提取 / **reset 保证** / 限流接线 | 28 collected | +| `tests/integration/test_identity_forgery_rejected.py` | **端到端伪造身份被拒** | 5 passed | + +`tests/unit/common/security/` 当前共 169 passed(F05 迁移后含 `test_runtime.py` +与密码学子目录); +`tests/unit/bootstrap/test_server_security_config.py` 另有 4 条配置歧义与开放 target 回归。 + +限流侧的关键断言: + +| 断言 | 落点 | +|---|---| +| 超出突发额度即拒绝;补充速率生效后恢复 | `test_burst_up_to_capacity_then_denied` / `test_tokens_refill_over_time` | +| **并发下不超发**(多线程抢最后一个令牌) | `test_concurrent_requests_do_not_exceed_capacity` | +| 桶表 LRU 有界,不随 peer 数无限增长 | `test_bucket_table_is_bounded` / `test_eviction_drops_least_recently_used` | +| `peer` 为空串放行 | `test_empty_peer_is_never_limited` | +| **限流跑在认证之前**(认证器一次都没被调到) | `test_rate_limit_runs_before_authentication` | +| 429 与 401 可分;限流拒绝不留下上下文 | `test_rate_limited_is_not_an_authentication_error` / `test_rate_limited_leaves_no_context` | +| 审计里限流与认证失败分得开,且**不记桶余量** | `test_rate_limit_denial_is_audited_distinctly` / `test_rate_limit_audit_carries_no_bucket_state` | +| 关闭限流只能显式配 `unlimited`,`capacity: 0` 报错 | `test_disabling_is_explicit_not_a_magic_value` | + +> 「不记桶余量」是一条容易漏的:余量能用来反推限流参数,然后贴着阈值发请求。 +> 审计 detail 恰好只有 `mode` 与 `peer` 两个键,测试用 `set(detail) == {...}` +> 精确断言,多一个键就红。 + +### 招牌测试的实现顺序 + +按 CLAUDE.md §5「写测试先于修 bug」:先写 +`test_identity_forgery_rejected.py`、跑一遍**看它全红**(证明漏洞真实存在)、 +再做改动、再看它全绿。其中 `test_identity_comes_from_context_not_payload` +比 `test_claimed_identity_in_payload_is_rejected` 更重要——前者证明「堵上 +之后认证与授权确实串起来了」,后者只证明「洞堵上了」。 + +### timing 测试的 flaky 防护 + +`test_resolve_pads_time_on_miss` 各跑 5 次取**中位数**(不是平均,避免单次 +GC 抖动主导),断言比值在 `[0.5, 2.0]`。区间宽是因为要检出的是「差一整个 +Argon2 verify」(~100x),不是微小偏差。 + +> **这条测试实测抓到过一个真实缺陷**:初版实现里「前缀有候选但 key 错」 +> 会跑**两次** verify(候选一次 + 落空后的 dummy 一次),而「前缀无候选」 +> 只跑一次,ratio=0.49。修的是实现不是断言——加 `verified_any` 标志, +> 只在没跑过任何候选 verify 时才补 dummy。三条路径各恰好一次。 + +### 手工验收(不进自动化测试) + +- DEV 模式 + `--host 0.0.0.0` → 进程返回码 1,stderr 有 FATAL ✓ +- DEV 模式 + `--host 127.0.0.1` → 正常启动 ✓ +- API_KEY 模式下 `/healthz` 无凭据 → 200 ✓ +- API_KEY 模式下 `POST /v1/add` 无凭据 → 401;带 root key → 200 ✓ +- `examples/quickstart.py` 行为不变——注意它**改动前就有一个既有失败** + (最后一步 `admin_all` 用普通 user 身份调管理面得 `PermissionDeniedError`)。 + 改动后仍是**同一个**失败 ✓ + +## 拒绝的方案 + +### 拒绝 1:认证做进 `build_kernel` + +内核形态无关,认证是传输层相关的;且会让 `LocalMemoryAPI` 同时承担 AuthN +与 AuthZ。见决策 2。 + +### 拒绝 2:无 argon2-cffi 时回退明文存储 + +security.md §2.3.1 明说那让 key 变成磁盘裸明文;铁律 #3 fail-closed。 +装配期抛错,见决策 3。 + +### 拒绝 3:`get_current()` 返回默认 `AuthContext` + +fail-open。中间件漏挂时请求会带着默认身份跑完,而且**没有任何症状**—— +系统看起来完全正常,直到有人发现所有操作都以同一个身份记在审计里。 +返回 `None` 迫使调用方显式处理。 + +### 拒绝 4:静默忽略 payload 里的 `actor_*` 字段 + +见决策 5。 + +### 拒绝 5:`AuthDispatcher` 一个类里 if/else 分流三种模式 + +参考 demo 的写法。拆成三个各自只做一件事的 `Authenticator` 实现 + Producer +按配置选:模式在**装配期**选定,运行期不再分流。一个 if/else 分流器意味着 +每次请求都要重新判断「我是哪种模式」,而那是启动时就确定的事。 + +### 拒绝 6:错误消息区分「主体不存在」与「凭据错误」 + +区分即主体枚举侧信道(§2.3.2)。三个 authenticator 一律抛 +`"authentication failed"`。具体原因应写进审计——但见「已知遗留」9。 + +### 拒绝 7:限流按 `key_fp` 分桶 + +见决策 7。攻击者每次换一把随机 key 就换一个新桶。 + +### 拒绝 8:`capacity: 0` 表示不限流 + +反着读的魔法值在配置文件里读不出意图,而读不出来的配置就是会被写错的配置。 +关闭限流走显式的 `target: unlimited`,见决策 11。 + +## 已知遗留 + +1. **Argon2 128MiB×4 使单次 `resolve` 约 50~200ms**,API 吞吐上限约 + 5~20 QPS/核。高 QPS 需要带撤销传播的验证缓存(第二期)。参数取 + OWASP 2024+ 推荐值,不下调。 +2. **`InMemoryKeyStore` 进程重启即丢全部 key**。生产需 SQLite 后端。 + 注册名是 `memory`(Argon2 是内部实现细节,不是后端名)。 +3. **MCP 在非 DEV 模式下全部工具调用失败**。§2.5 的凭据传递待第二期设计。 + 见决策 4。 +4. **限流是进程内的,多副本各算各的**:N 个副本 = N 倍实际额度。真正的多副本 + 限流要 Redis 之类的共享计数器,届时在 `security/protection/protection_impl/` 下新增一个实现, + 中间件不用改(契约已留在 `common/security/protection/rate_limit.py`)。 +5. **按地址分桶挡不住僵尸网络**:来源足够分散时每个 IP 都拿到一个新满桶。能 + 收敛这种攻击的是**对 Argon2 verify 本身做并发上限**(一个信号量,把同时 + 进行的 verify 数压到内存能承受的范围)--已由决策 12 的 `WorkloadGuard` 实现。 +6. **无按 key 的配额公平**。§8.1 草图里的 `key_fp` 分桶防的是「单个合法 key + 打爆配额」,与本期防的攻击不是一件事(决策 7)。它是独立需求。 +7. **审计无链式 HMAC 完整性保护**。§7.3,第二期。 +8. ~~**`handler.py:_event_view` 硬编码 `Scope` 四字段** + (`org` / `user` / `agent` / `session`)。F03 加 `space` 后这里会漏字段。~~ + **不成立**:上游 `c76eb90` 落地五维 `Scope` 时已一并改全了三处渲染点—— + `handler.py:_scope_view`、`storage/_support.py:scope_segments`(五段占位)、 + `SqliteAuditLogger` 的 `actor_space` 列(含 `ALTER TABLE` 迁移)。写下这条时 + 只查了四维版本的 handler,没复核上游同批提交,是我的疏漏。 +9. **`/healthz` 返回 profile 名**,未做信息暴露评估。profile 名是部署配置的 + 一部分但不是秘密,且改它会破坏现有客户端的 `healthz()` 契约,第一期保持 + 原样。 +10. **`Role.ADMIN` 无任何管理接口**。角色枚举已定义但**无任何消费方**: + `PermissionManager.check(actor, target, action, context)` 的签名里没有 role + 的位置,故 §3.2 权限清单里「管理本租户 user/agent」「创建/删除租户」 + 「系统级配置修改」三行**无法表达**,ADMIN 与 USER 走完全相同的判定路径。 + §3.5 的「提升式 ROOT」同理不存在(`check` 首条是 `actor == Scope()`,认的是 + actor 形状不是 role)。这是授权侧的缺口,归隔离/权限那一期。 +11. **认证失败审计不记细分原因**。`_record_failure` 只记 + `mode` + `peer`。真实原因(`missing_credentials` / `unknown_principal` / + `bad_gateway_key`)需要在 authenticator 侧另开一条**只进审计**的通道 + ——异常消息必须保持笼统(拒绝 6)。那是独立设计,不塞进本期。 +12. **`AuditEvent` 缺 `acting_user` / `role` / `key_fp` / `auth_mode` 字段**。 + security.md §7.2 要求记录这四样,第一期塞进 `detail`(`dict[str, str]`) + 并在 `audit.py` 的「常见约定」注释里登记。不改 `AuditEvent` 结构——那是 + 跨层结构体,改它要动 `common` / `control` / 两个 `AuditLogger` 实现 + + `handler._event_view`。若这些键稳定使用,第二期应提升为一等字段。 +13. ~~**`Scope` 仍是四维**。安全模块按四维实现,但所有 `Scope` 构造一律用 + **keyword 参数**,为 F03 插入 `space` 留接缝(位置参数会错位)。~~ + **已解除**:上游 `c76eb90` 落地了五维 `Scope`(`space` 是 `kw_only`)。 + 因为构造全用 keyword,安全模块无需任何改动即兼容;`_FORBIDDEN_IDENTITY_KEYS` + 跟着补了 `actor_space` / `actor_space_id` 两个新伪造面 + (`test_space_dimension_identity_claims_are_rejected`)。 diff --git a/docs/features/common/F08-authorization-context.md b/docs/features/common/F08-authorization-context.md new file mode 100644 index 00000000..29383ac7 --- /dev/null +++ b/docs/features/common/F08-authorization-context.md @@ -0,0 +1,197 @@ +# F08 — 角色感知授权与 Agent 代操作委托 + +## 元信息 + +| 项 | 值 | +|---|---| +| 原始编号 | security/F02(已迁移到 common/F08) | +| 日期 | 2026-07-29 | +| 实施阶段 | 授权与上下文期(对应 [F04](F04-security-interfaces-and-encryption.md) §术语说明中的"授权与上下文期") | +| 现行影响范围 | `src/common/security/authorization/`、`src/common/security/{types.py,request_context.py,runtime.py}`、`src/api/memory_api_impl/`、`bootstrap/`、`tests/unit/common/security/authorization/`、`tests/unit/api/` | +| 当期历史落点 | `src/control/permission*.py`、`src/api/memory_api_impl/local_memory_api.py`、旧 `src/common/{authentication,type_def}/` 与对应测试;均已由 F05 / PR2 迁移或取代 | +| 测试基线 | 改动前 `15 failed, 657 passed, 60 skipped`;改动后 `15 failed, 710 passed, 60 skipped`。**15 个失败是同一组**(`test_jieba_tokenizer.py` 的 `jieba` 未装、`test_bge_m3_embedder.py` 的 `torch` 未装、`test_local_encryption_provider_encrypts_enc1_and_round_trips` 的 Windows POSIX 权限位限制),与本改动无关 | +| 依据 | [F04 安全架构总纲](F04-security-interfaces-and-encryption.md) §3.1 角色、§3.2 操作与角色映射、§3.5 ROOT 等价性、§4.3 路径 1(Agent 代操作)、§9 铁律 #3(fail-closed) | +| 规范契约 | [S09 安全横切契约](../../specs/S09-security.md) | +| Refs | — | + +> **2026-08-07 现行落点。** 本文正文保留 PR2 早期基于 `PermissionManager + acting_user` +> 的问题分析与取舍过程;该实现已在 F05 迁移中被最终方案取代。当前事实如下: +> +> - PDP 是 `common.security.authorization.Authorizer`,输入封闭为 `AuthContext + +> ResourceDescriptor + AuthorizationEnvironment`,输出 `AuthorizationDecision`。 +> - 唯一 PEP 是 `MemoryAPI`;公开 verb 的 `security: RequestSecurityContext` 为必填 +> keyword-only 参数,缺失或来源证明无效时 fail-closed。 +> - ROOT 只由 `AuthContext.role` 表达;空 actor 不再是管理员兼容入口。 +> - 委托只由 `DelegationStore` 按 `delegation_id` 复核,`AuthContext.acting_user` 与 +> `X-Acting-User` 已删除;可委托动作使用 `DELEGATABLE_ACTIONS` 显式白名单。 +> - 授权记录与委托记录分别由 `GrantStore` / `DelegationStore` 管理,内置 memory / sqlite; +> standard / routing / allow_all Authorizer 均通过 Producer 注册,allow_all 仅供测试。 +> - ContextVar 只用于日志与 trace,PDP 和 PEP 均不读取它决定权限。 +> +> 下文至“后续演进”之前是被取代的当期方案,不是现行 API 说明;当前契约以 S09 为准。 + +## 背景 + +F01 落地后,`AuthContext` 的 `role` 与 `acting_user` 是两个**孤儿字段**:认证层算出来、 +进审计 detail、然后在授权边界被丢掉。这留下三个缺口: + +### 缺口 1:ROOT 由 actor 形状识别,不是由 role + +`SQLitePermissionManager.check` 第一条规则是 `actor == Scope()` 即全局放行。这是 +**声明式 ROOT** 的 actor 形态。但 security.md §3.5 明写「提升式 ROOT」(绑了具体 +org/user、`role=ROOT`)与它在运行时权限检查中**等价**。今天不等价:一个提升式 ROOT +在 PDP 眼里就是普通用户。 + +更严重的是反方向:PDP **没有纵深防御**。`PrincipalKeyStore.issue` 拒绝签发空 actor 的 +key,但那道闸在 `security/` 层。换一个 authenticator 实现、或将来加 OAuth 通道,没人 +保证那个前置假设还在。`AuthContext(actor=Scope(), role=USER)` 这种「空 actor + 非ROOT +role」的产物今天能拿到全局放行--靠的是数据形状的巧合,不是显式判定。 + +### 缺口 2:agent 代 user 操作不可能放行 + +`_owner_scope_covers(Scope(agent="a1"), Scope(user="u1"))` 恒 `False`:primary 维 +(默认 `user`)不等。grants 表里也没有这条。所以 §4.3 路径 1(用户授权 Agent 代操作) +必然 403--`acting_user` 这个字段没有任何消费方。 + +### 缺口 3:PEP 的 `identity` 与 ContextVar 的 `AuthContext` 可以不一致 + +`_authorize(identity, ...)` 的 `identity` 是调用方传的;`AuthContext` 在 ContextVar 里。 +两者指向不同主体时没有任何东西强制相等。今天 handler 传的就是 `get_current().actor`, +恒等;但直接调 `LocalMemoryAPI` 的代码(另一个 surface、一段脚本)可以传一个不相干的 +`identity`,接线前那会被当成真身份。 + +## 决策 + +### 决策 1:`auth` 是 keyword-only,默认 `None`,`actor` 保留 + +```python +def check(self, actor, target, action, context=None, *, auth: AuthContext | None = None) -> bool +``` + +`auth` 放 keyword-only 且默认 `None`:33 处既有 `_authorize` 调用点、所有单测、 +`build_kernel` 直连路径都不传它也能跑--`None` 时退回纯 ACL。`actor` 保留:它是 ACL +的主语,且 `auth is None` 的兼容路径要用它。`auth` 不是 `actor` 的替代,是 `actor` +**推不出来**的两样东西(`role`、`acting_user`)的载体。 + +### 决策 2:`auth.actor != actor` 即拒绝(fail-closed,铁律 #3) + +两个身份来源不一致,要么是接线错误要么是攻击,两种都拒。返回 `False` 而非抛异常-- +`check` 的契约是给出布尔判定,`PermissionDeniedError` 留给 PEP 翻译成 403。 + +### 决策 3:ROOT 按 role 判定;空 actor 降级为兼容回退 + +`auth is not None` 时:`role is ROOT` 即全局通过;**空 `actor` 不再**自动等于 +platform admin(见缺口 1 反方向)。`auth is None` 时保留旧的 `actor == Scope()` +规则--那是后台 job、单测、`build_kernel` 直连的路径,没有认证上下文,不该被角色闸门 +打红。 + +> 实现注记:空 actor 的显式拒绝不能省。否则它会命中 `_owner_scope_covers` 顶部的 +> 「parent 为空即覆盖一切」通配分支--那个分支是给 grant 行匹配用的,不该被 actor +> 借道。这是实现中暴露的第三处「靠形状表达语义」的坑。 + +### 决策 4:委托在 owner-cover 之后、grants 查询之前 + +`_delegation_covers(auth, target, action)` 条件全部取自服务端认证产物,**没有一项来自请求体**: + +- `auth.actor.agent` 非空(只有 agent 主体能代操作,反向不成立); +- `auth.acting_user` 非空; +- 同 `org` + `space`(org 是硬边界 §4.2;同名 user 在别的 space 不是同一份数据); +- `target.user == acting_user`(委托目标只能是该 user 本人); +- `target.agent` 为空(代 user 操作的目标是该 user 的分支,不是它名下另一个 agent 的分支)。 +- `action` 落在 `_DELEGATABLE_ACTIONS`(READ/WRITE/UPDATE/DELETE)内:委托只覆盖记忆 + CRUD,**不含 SHARE**--否则 agent 拿到一次请求级委托后,可对 `acting_user` 的 + scope 发 SHARE 给自己写长期 Grant,把临时委托升级成永久访问(审计 P1-1)。 + 用显式 allowlist 而非「非管理面即允许」,是为了让新增 Action 默认**不**落入委托。 + +放在 owner-cover 之后:能被 owner-cover 放行的不必走委托。放在 grants 之前:委托是 +比显式 grant 更强的声明(「我就是替这个 user 做的」),先判它能让代操作不必额外建 +grant 记录。 + +### 决策 5:管理面靠 `resource_type`,不靠 target 形状 + +`_management_plane_denies(auth, action, context)` 要求 ROOT 的资源由 +`PermissionContext.resource_type` 表达: + +- `admin` / `audit`:任何动作都要 ROOT; +- `space` + `WRITE`/`DELETE`:创建/删除租户要 ROOT(§3.2)。同为 `resource_type="space"` + 的 `get`/`update`/`archive` 走 READ/UPDATE,不在此列--否则普通用户连自己所在 space + 的名字都拿不到。 + +> **实现中对计划的修正**:计划初稿把 `grant` 也列进管理面。落地时否掉了:§3.2 那行 +> 说的是「**跨租户**修改权限」,而跨 org 的 grant 今天已被 `actor.org != target.org` +> 挡住;对自有 scope 发 grant 是 Grant 模型的主用途,闸进 ROOT 会废掉正常共享。见 +> `test_sharing_own_scope_is_not_a_management_operation`。 + +`auth is None` 时不闸:没有认证上下文时无从判定角色,沿用旧 ACL。 + +### 决策 6:`AuthContext` 在 PEP 取,不在 PDP 取 + +`LocalMemoryAPI._authorize` 调 `get_current()` 取出后透传。`PermissionManager` **不得** +自行读 ContextVar:PDP 应当是其入参的纯函数,否则单测要先布置环境态才能跑,判定依据 +也不再显式可见。`get_current()` 返回 `None` 时 PDP 退回纯 ACL。 + +> **接线验证**:`set_current` / `reset_current` 的调用方在 `bootstrap/core/auth_middleware.py` +> 的 `authenticated()` 上下文管理器(line 86/90),HTTP / MCP / CLI 三个 surface 都用它 +> 包住 dispatch。故真实请求路径里 ContextVar 会被填充,`get_current()` 在 PEP 拿得到值。 +> 这条在实现时专门核过--它正是「看起来通了、真请求时没通」那一类坑。 + +## 拒绝的方案 + +| 不做 | 为什么 | +|---|---| +| `Role.ADMIN` 的额外权限 | §3.2 属 ADMIN 的那行(管理本租户 user/agent)在本仓一个接口都没有:`PrincipalKeyStore.issue` 只有测试调用方。凭空造闸门守一扇不存在的门是 dead flexibility(CLAUDE.md §3)。`test_admin_role_is_not_enough_for_admin_plane` 钉住当前行为;租户管理面落地时它应当改,改动会撞在这里,那正是它存在的意义。 | +| `require_role` 装饰器 | 它会在 PEP 之外造第二道角色检查点,而两道点不一致时没有规则说谁赢。角色判定留在 PDP 一处(决策 5),PEP 只管取上下文透传。 | + +这两项不是遗漏,是**诚实的范围**:PR② 接通已存在的接口所需要的东西,不造没有消费方的 +接口。 + +## 落地影响(现行契约见 S03 / S09) + +授权调用新增可信认证上下文输入,三个 PermissionManager 实现各自对齐: + +- `SQLitePermissionManager`:决策 2~5 的全部判定; +- `AllowAllPermissionManager`:忽略 `auth`,恒 `True`(它的全部语义就是「不鉴权」,掺进角色逻辑只会让这个前提变得需要逐条确认); +- `RoutingPermissionManager`:原样透传 `auth` 给 delegate(路由不改变授权语义,吞掉 `auth` 会让角色闸门与委托在路由型部署下静默失效)。 + +`TrustedAuthenticator` 增加 `X-Acting-User` header 的读取:user 主体 `acting_user` 是 +它自己(与改造前逐字一致);agent 主体读该 header。`_acting_user` 的 docstring 记了 +「为什么这个 header 可信」与「为什么它和 `role` 不同处理」的对照。 + +## 验证 + +- `tests/unit/control/test_permission_role_aware.py`:PDP 自身当前 22 条判定(角色闸门、 + 委托边界、管理面、向后兼容、另外两个实现的透传)。 +- `tests/unit/api/test_authorization_with_auth_context.py`:PEP 接线当前 8 条(认证上下文 + 确实穿过 API 抵达 PDP,含提升式 ROOT 用管理面、agent 代 user 读写、identity 与 + auth.actor 不一致被拒)。 +- `tests/unit/common/authentication/test_authentication_impl.py`:`acting_user` 生产方 3 条(user 主体 + 自带、agent 无 header 为空、agent 带 header 透传)。 + +向后兼容由 `test_no_auth_context_preserves_every_legacy_rule` 与既有 permission 测试 +逐字不变地撑着:`auth=None` 时回到纯 ACL。 + +## 已知遗留 + +- ~~`auth=None` 兼容线仍服务于内核直连、后台任务和既有测试~~——已在 F05 迁移中删除, + 见下节。 +- ADMIN 的租户管理接口尚未落地,因此本特性不凭空增加 ADMIN 管理面权限;对应接口出现时 + 需扩展 S03 与角色门槛测试。 + +## 后续演进(F05 Common Security 迁移,2026-08-05) + +本文档记录的是**当期**(2026-07-29)的落地事实,保留原貌以备追溯。F05 把安全收敛成 +横切能力域后,下列描述**已被取代**,现行契约以 [S09](../../specs/S09-security.md) 为准: + +| 本文档中的描述 | 现状 | +|---|---| +| PDP 是 `control.permission.PermissionManager.check`,返回 `bool` | PDP 是 `common.security.authorization.Authorizer.authorize`,返回带 `reason` code 与 `rule` 的 `AuthorizationDecision`。`PermissionManager` 只剩 grant/revoke 的记录写入通道 | +| `auth: AuthContext \| None`,`None` 时退回纯 ACL | 输入封闭为 `AuthContext + ResourceDescriptor + AuthorizationEnvironment`,无 `None` 分支 | +| 空 `Scope()` 是 platform admin(`auth is None` 时) | 空 actor 直接拒(`CONTEXT_MISMATCH` / `empty_actor`);ROOT 只由 `role` 表达,dev/root 主体改为具名的 `system/dev`、`system/root` | +| `AuthContext.acting_user` 表达代操作 | 该字段已删除。委托只来自服务端 `DelegationStore`,由 Authorizer 按 `delegation_id` 复核;可委托动作见 `DELEGATABLE_ACTIONS`(不含 SHARE 与管理动作) | +| PEP 从 ContextVar 取 `AuthContext` 后透传 | 调用方显式传 `security: RequestSecurityContext`;ContextVar 降级为日志/trace 辅助传播,授权不依赖它 | +| `MemoryAPI.method(..., identity=caller)` | `MemoryAPI.method(..., security=RequestSecurityContext)`,必填 keyword-only | +| 管理面一律要求 ROOT | 按动作分级:`MANAGE_*` / `READ_AUDIT` 要 ADMIN 及以上(止于本 org),`VERIFY_AUDIT` / `ADMINISTER_SYSTEM` 与无 org 归属的系统级资源要 ROOT | +| 验证用例路径 `tests/unit/control/`、`tests/unit/common/authentication/` | 安全单测镜像到 `tests/unit/common/security/<能力域>/` | + +`TrustedAuthenticator` 的 `X-Acting-User` 读取随 `acting_user` 字段一并移除——代操作 +不再由请求 header 声明。 diff --git a/docs/features/control/F05-cloud-engine-design.md b/docs/features/control/F05-cloud-engine-design.md index 6bb7d30d..65ddc600 100644 --- a/docs/features/control/F05-cloud-engine-design.md +++ b/docs/features/control/F05-cloud-engine-design.md @@ -5,7 +5,7 @@ | 项 | 值 | |---|---| | 日期 | 2026-07-27 | -| 影响范围 | `src/control/engine_impl/cloud_engine.py`、`src/control/engine_impl/__init__.py`、`src/control/pipeline.py`、`src/construction/`、`src/common/security/`、`src/storage/kv_impl/`、`docs/specs/S03-control.md`、`docs/specs/S06-storage.md`、`docs/specs/S07-common.md` | +| 影响范围 | `src/control/engine_impl/cloud_engine.py`、`src/control/engine_impl/__init__.py`、`src/control/pipeline.py`、`src/construction/`、`src/common/encryption/`、`src/storage/kv_impl/`、`docs/specs/S03-control.md`、`docs/specs/S06-storage.md`、`docs/specs/S07-common.md` | | 测试基线 | 已新增 `tests/unit/control/test_cloud_engine.py`;本地无 pytest,使用 `runpy` 显式调用测试函数通过 | ## 背景 @@ -188,16 +188,16 @@ CloudEngine 只能看到 `ENC1` 密文字节;若加密关闭,装配层直接使用原始 KVStore。系统没有“空实现 encryptor”,避免配置声称启用加密但实际透传明文。 -### 决策 6:安全接口放 `common/security`,KV 装饰器放 `storage/kv_impl` +### 决策 6:安全接口放 `common/encryption`,KV 装饰器放 `storage/kv_impl` 云侧安全能力拆成两层: | 层 | 位置 | 职责 | |---|---|---| -| 安全接口与加密实现 | `src/common/security/` | `SecurityProvider`、`SecurityContext`、本地密钥实现、`ENC1` envelope、加密错误 | +| 安全接口与加密实现 | `src/common/encryption/` | `EncryptionProvider`、`EncryptionContext`、本地密钥实现、`ENC1` envelope、加密错误 | | KV 加密装饰器 | `src/storage/kv_impl/encrypted_kv_store.py` | 实现 `KVStore`,写前加密、读后解密、明文兼容、fail-closed | -`SecurityContext` 与显式 AAD 至少绑定: +`EncryptionContext` 与显式 AAD 至少绑定: - `org` - `space` @@ -289,7 +289,7 @@ SpacePolicy 中的 `require_space`、`pipeline_profiles`、`index_profiles`、 拒绝直接修改 `InMemoryEngine` 承载云侧能力。这样会把本地最小实现和云侧强隔离、安全合规、profile-aware evolve 绑在一起,破坏旧路径的简单性。 -拒绝让 `CloudEngine` 直接调用 `SecurityProvider.encrypt/decrypt`。加密是所有 KV 路径的 +拒绝让 `CloudEngine` 直接调用 `EncryptionProvider.encrypt/decrypt`。加密是所有 KV 路径的 横切能力,应该由 `EncryptedKVStore` 统一保证,否则治理、生命周期、evolver 上下文等路径 容易漏加密。 @@ -330,7 +330,7 @@ SpacePolicy 中的 `require_space`、`pipeline_profiles`、`index_profiles`、 - `Scope.space`、storage scope key、API payload、PermissionManager owner-cover、CloudEngine 按完整 Scope 的 get/update/delete/lifecycle/index 清理已落地;仍需补不同 `space` 下相同 content 的端到端 recall 集成测试。 -- `common/security` 接口、`local` SecurityProvider 和 `EncryptedKVStore` wrapper 已落地;CloudEngine 仍需补开启 `EncryptedKVStore` 后的端到端静态加密集成测试。 +- `common/encryption` 接口、`local` EncryptionProvider 和 `EncryptedKVStore` wrapper 已落地;CloudEngine 仍需补开启 `EncryptedKVStore` 后的端到端静态加密集成测试。 - 现有 `Scheduler` 接口没有 job context;当前 `CloudEngine.evolve(scope, mode)` 仍委托注入的 Scheduler,profile-aware evolve 需要新增 cloud executor 或扩展 Scheduler 规约。 - **增量(2026-07,[`F06`](F06-middle-term-memory.md))**:`Scheduler.submit` 改为 `async def submit(job, channel)`——task 内容由 `Job` 封装,不再持 mode/state,Scheduler 只调度。`CloudEngine.evolve` 经 `JobFactory.get_job(JobType.EVOLVE, scope, mode=mode)` 取实例 + `await scheduler.submit(job, channel)` 提交。**注意 profile-aware evolve 当前未解决**:`EvolveJobSpec.with_scope` 仍只持 default Evolver,未支持运行时覆盖入参 `evolver=`——多 profile evolve 场景下 Job 仍用 default evolver,是已知遗留(见 F06 已知遗留)。`write` 路径 `infer=true + middle=true` 子分支同款经 JobFactory 取 `MiddleToLongJob` 实例 + 经 `AsyncTimerScheduler` per scope TimerWheel 周期触发;多 profile 适配时 CloudEngine 通过 `get_job(evolver=, index=)` 运行时覆盖入参注入 binding 的(保证 Job 内部的 evolver/index 与原文落盘时一致),详见 F06 决策 4。 - 索引层仍可能保存明文摘要、文本、向量或图节点属性。KV 加密只能保护真源与 KV value,不等于全链路加密。 diff --git a/docs/features/storage/F02-encrypted-storage.md b/docs/features/storage/F02-encrypted-storage.md index be0b3548..fe896e40 100644 --- a/docs/features/storage/F02-encrypted-storage.md +++ b/docs/features/storage/F02-encrypted-storage.md @@ -1,19 +1,37 @@ -# F02 — 加密 KV 存储设计(EncryptedKVStore) +# F02 — 加密存储设计(EncryptedKVStore / EncryptedFSStore) ## 元信息 | 项 | 值 | |---|---| -| 日期 | 2026-07-27 | -| 影响范围 | src/storage/kv_impl/encrypted_kv_store.py,src/storage/kv_impl/__init__.py,src/common/security/,docs/specs/S06-storage.md,docs/features/common/F04-security-interfaces-and-encryption.md | -| 测试基线 | `tests/unit/storage/test_encrypted_kv_store.py` 覆盖加密写入、读后解密、scan 解密、透传操作、工厂装配与失败关闭;`pytest -q tests/unit/storage`、相关模块测试、ruff 与 `git diff --check` 已通过 | +| 日期 | 2026-07-27(KV 侧);2026-07-29(FS 侧补入) | +| 影响范围 | src/storage/kv_impl/encrypted_kv_store.py,src/storage/kv_impl/__init__.py,src/storage/fs_impl/encrypted_fs_store.py,src/storage/fs_impl/__init__.py,src/common/security/cryptography/,docs/specs/S06-storage.md,docs/features/common/F04-security-interfaces-and-encryption.md | +| 测试基线 | KV 侧:`tests/unit/storage/test_encrypted_kv_store.py` 覆盖加密写入、读后解密、scan 解密、透传操作、工厂装配与失败关闭。FS 侧:`tests/unit/storage/test_encrypted_fs_store.py` 13 条全绿;单元全量 `15 failed, 814 passed, 1 skipped`,15 个失败全为预存在的环境失败(14 个 `test_jieba_tokenizer.py` 缺 `nlp` extra,1 个上游 `test_local_envelope.py` 断言 `0o600` 权限位、Windows `os.chmod` 设不出来,已在纯上游代码上复现)。相关模块测试、ruff 与 `git diff --check` 已通过 | | Refs | — | +> **2026-08-05 F05 迁移后记。** 本文记录的是 2026-07 的决策过程,正文保留原貌。 +> 三处已被 F05 Common Security 推翻或改名,以下述为准: +> +> 1. **`EncryptionProvider` → `CryptographyProvider`**,落点从 `src/common/encryption/` +> 迁到 `src/common/security/cryptography/`;`EncryptionContext` → `CryptoContext`, +> 对象标识与格式版本提为专有字段 `object_id` / `format_version`,不再塞 `metadata`。 +> 配置顶层段名与装配参数名均由 `encryption` 改为 `cryptography`。 +> 2. **决策 11 与 `allow_plaintext` 已作废**(F05 §明文策略)。不再有任何明文回退开关: +> 不是合法信封就拒绝读取,解密失败绝不返回原始 bytes。是否允许未加密存储,由上层 +> 选 `encrypted` 还是 raw target 表达。相应的明文兼容测试已删除。 +> 3. **「无根密钥轮换接缝」已部分补上**:信封升级到 v2,头部自述 key id 与 key epoch, +> 根密钥改由独立的 `KeyProvider` 提供(`TOP_NAME` 为 `key_provider`),换 KMS/Vault +> 不必改加密实现。写出一律 v2、v1 只读兼容;跨代 keyring 轮换仍未实现。 +> +> 决策 1–10、12 与其余「拒绝的方案」不受影响。 + ## 背景 KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字节存储。未加密时,落盘后端或远端 KV 后端可以直接看到 value 明文;但如果把加解密逻辑分散到 `write`、`recall`、`get` 等上层接口,会导致每条读写路径都要重复处理开关、密钥、AAD 与错误语义,也容易让新增入口绕过加密。 -因此加密能力需要落在 KV 边界:对调用方保持 `KVStore` 合同不变,对底层后端只写入密文。算法、密钥来源、明文兼容策略不归 storage 层管理,而是由 `src/common/security/` 的 `SecurityProvider` 提供。 +因此加密能力需要落在 KV 边界:对调用方保持 `KVStore` 合同不变,对底层后端只写入密文。算法、密钥来源、明文兼容策略不归 storage 层管理,而是由 `src/common/encryption/` 的 `EncryptionProvider` 提供。 + +**FS 侧同理,且更迫切**:原模态资产(上传的文档、图片、音视频)走 `FSStore`,它们往往比 KV 里的结构化记忆更敏感,却完全裸着落盘。静态加密保护的是**访问路径之外**的泄露面——拿到磁盘快照的人绕过了认证与授权,因为快照根本不走访问路径。KV 侧先落地,FS 侧随后按同一形态补齐。 ## 决策 @@ -27,9 +45,9 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 读出后解密再返回。`list` 扫描并解密 `/memory/` 条目后,再执行公共 MemoryUnit 过滤、计数、排序和分页,不能把明文过滤条件委托给 raw KV。 -3. **算法与密钥管理委托给 SecurityProvider** +3. **算法与密钥管理委托给 EncryptionProvider** - storage 层只构造 `SecurityContext` 与 AAD,然后调用 `SecurityProvider.encrypt/decrypt`。AES-GCM、本地密钥文件、KMS、Vault、轮换策略、明文兼容策略都属于 `common/security` 或具体 provider 的职责。 + storage 层只构造 `EncryptionContext` 与 AAD,然后调用 `EncryptionProvider.encrypt/decrypt`。AES-GCM、本地密钥文件、KMS、Vault、轮换策略、明文兼容策略都属于 `common/encryption` 或具体 provider 的职责。 4. **AAD 绑定 scope、key 与用途** @@ -52,12 +70,17 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 推荐把 raw KV 作为内部实例声明,再把默认 KV 指向 `encrypted`: ```yaml - security: + cryptography: + default: + target: local + params: + key_provider: default + + key_provider: default: target: local params: key_file: ~/.agent-memory/security/master.key - allow_plaintext: false kv_store: raw: @@ -69,7 +92,7 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 target: encrypted params: raw_kv_store: raw - security: default + cryptography: default ``` 其他模块继续依赖 `kv_store.default` 时,读写路径自然经过加密包装;`kv_store.raw` 只作为加密装饰器的内部依赖,不应暴露给业务读写入口。 @@ -82,13 +105,94 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 `exists`、`delete`、`scopes` 只依赖 key/scope,不需要读取 value,因此直接透传给 raw KV。租户删除、session 清理、TTL 过期等生命周期操作删除的是密文记录,不要求先解密。 +## FS 侧:EncryptedFSStore + +### 9. FS 装饰器与 KV 装饰器同构,不自带任何密码学 + +`EncryptedFSStore` 做的事和 `EncryptedKVStore` 逐条对应:构造 `EncryptionContext` 与 AAD,转发给注入的 `EncryptionProvider`。密码学一行都不在 storage 里。 + +依赖方向 `storage → common.encryption`,单向;encryption 不认识 Store。回归防线:`test_encrypted_fs_is_registered_by_storage_bootstrap`。 + +### 10. 装饰器住在 `src/storage/`,不住在 `src/common/encryption/` + +理由不是分层美学,是**消费边界**:存储装饰器随 storage bootstrap 注册,密码学实现由 +`common.bootstrap.register_plugins()` 注册。直接调用 `build_kernel` 与经 `Server.build` 的入口 +使用同一套已注册 target,不会出现只在某种入口缺实现的故障。 + +这与 KV 侧的落点一致,FS 侧只是照做。 + +### 11. 明文兼容开关只在 provider 上,装饰器不重复提供 + +「读到非 ENC1 的数据怎么办」有两个对立的正确答案,各自对应一个部署阶段: + +- **迁移期必须宽松**——加密层上线时库里全是加密前的明文,一律拒绝就是上线即全量不可读。 +- **迁移完成后必须收紧**——此时「读到明文」只可能是有人绕过加密层直接写了底层存储。宽松模式会静默放行,而这正是降级攻击的着力点。 + +`LocalEnvelopeEncryptionProvider` 的 `allow_plaintext` 参数已经管这件事(决策 5 的最后一句)。两个装饰器都不再重复提供同语义旋钮:两个开关意味着两处配置、两种组合,其中「装饰器宽松 + provider 严格」这类组合没有任何意义,只会在排查时多一个要查的地方。 + +**写路径永远加密**,与开关无关——`test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed` 钉住这条。开关若顺带放松了写,迁移期写进去的数据会永远是明文而调用方毫无察觉。 + +### 12. FS 加密整个文件内容,`ref` 与 scope 保持明文 + +与决策 2(KV 只加密 value)同理:路径要能寻址,加密 `ref` 就没法 `get`/`stat`/`delete`。泄露的信息是「有哪些文件」,不是文件里是什么。 + +### 13. AAD 绑满五维 scope + `ref` + +与决策 4 同构,`key` 换成 `ref`,`purpose` 固定为 `fs_object`。 + +不绑 AAD 时,只要根密钥相同(同一部署),把 org A 的密文块搬进 org B 的存储位置就能解开——加密在这种攻击下等于没有。只绑 `org` 会让同 org 内的用户互读。存储层的 scope 隔离是**访问控制**,可以被绕过(直接写底层、备份恢复串了);AAD 是密码学的,绕不过。 + +`space` 是 `Scope` 五维化时新加的维度,漏了它同 org 下的两个 space 就能互读。回归防线:`test_encrypted_fs_store_aad_binds_all_five_scope_dimensions`、`test_encrypted_fs_store_cross_scope_ciphertext_move_fails`。 + +### 14. 解密失败一律 `BackendError`,不透传底层异常 + +与决策 5 一致。provider 抛的 `KeyMismatchError` / `AuthenticationFailedError` 对运维有诊断价值,但它们不是跨层契约——装饰器把它们收敛成 `BackendError` 并在消息里带上 `ref`,原异常经 `raise ... from exc` 保留在 `__cause__` 里,traceback 上一行不丢。 + +### 15. `inner` 无默认值 + +给 `inner` 一个默认会让「配错了」静默变成「加密了一个空的内存 store」——数据写得进去,重启后全没了。未配置时在装配期抛 `ValidationError`,并拒绝自引用。回归防线:`test_encrypted_fs_store_factory_requires_inner_dependency`。 + +(KV 侧的 `raw_kv_store` 同此约束;FS 侧的参数名是 `inner`,与 `fs_store` 既有的装饰器命名一致。) + +### 16. 默认关闭 + +与决策 6 一致:不配 `target: encrypted` 就没有任何加密行为。现有部署零影响,不需要迁移。 + +FS 侧配置形态: + +```yaml +cryptography: + main_sec: + target: local + params: + key_provider: main_key + +key_provider: + main_key: + target: local + params: + key_file: /etc/agent-memory/master.key + +fs_store: + raw_fs: + target: local + params: { root: /var/lib/agent-memory/files } + main_fs: # 上层引用这个 + target: encrypted + params: + inner: raw_fs + cryptography: main_sec +``` + ## 拒绝的方案 -- **在 MemoryAPI/write/recall/get 中分别调用 security**:被拒。上层入口太多,且未来新增 engine 或批处理入口时容易遗漏;KV 装饰器可以把加密收敛到单一边界。 +- **在 MemoryAPI/write/recall/get 中分别调用 encryption**:被拒。上层入口太多,且未来新增 engine 或批处理入口时容易遗漏;KV 装饰器可以把加密收敛到单一边界。 - **每个 raw KV 后端各自实现加密**:被拒。memory/sqlite/redis 会重复实现 AAD、失败关闭与明文兼容策略,后续增加后端时也会复制安全逻辑。 - **storage 层直接实现加密算法和密钥管理**:被拒。storage 只负责存取语义,不应持有算法选择、密钥加载、KMS/Vault 访问、轮换策略等安全治理能力。 - **同时加密 key 和 scope 命名空间**:本阶段拒绝。完全隐藏 key/scope 会破坏 scan、exists、delete、TTL、space 清理与审计定位。后续如需隐藏元数据,应单独设计 opaque key 或索引加密方案。 - **解密失败时返回密文或跳过记录**:被拒。这会把安全错误伪装成业务数据,导致调用方在不知情的情况下继续处理损坏或越界数据。 +- **chunked encryption(FS 侧分块加密以支持流式读)**:本阶段拒绝。F04 §5.3 自己就说了它不适合作默认方案——chunk 之间没有密码学绑定,可以被重排、截断、拼接。代价是 `FSStore.get` 必须读全文件到内存才能解密,大文件会吃内存,见「已知遗留」。 +- **在装饰器上再开一个 `allow_plaintext_read`**:被拒。见决策 11,两个同语义开关只会制造无意义的组合与多余的排查点。 ## 验证 @@ -97,7 +201,7 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 - `insert` / `update` 写入 raw KV 的 value 不是明文,`get` 返回原始明文。 - `scan` 对每个 key 单独构造 AAD 并返回解密后的 `(key, value)`。 - `exists`、`delete`、`scopes` 透传给 raw KV,不触发解密。 -- factory 可以通过 `raw_kv_store` 与 `security` 依赖装配出 encrypted KV。 +- factory 可以通过 `raw_kv_store` 与 `encryption` 依赖装配出 encrypted KV。 - `raw_kv_store` 缺失或指向自身时构造失败,避免递归装配。 - 解密失败统一抛 `BackendError`,不返回密文或部分结果。 - provider 开启明文兼容时可以读取历史明文数据;关闭时保持严格失败关闭。 @@ -108,10 +212,37 @@ KVStore 是 `MemoryUnit` 内容、原始消息与部分控制数据的真源字 git diff --check ``` +### FS 侧断言(`tests/unit/storage/test_encrypted_fs_store.py`,13 条全绿) + +| 断言 | 落点 | +|---|---| +| 内层存的是密文,且不含明文片段 | `test_encrypted_fs_store_encrypts_content_and_decrypts_get` | +| 交给 provider 的 `EncryptionContext` 带对 scope / purpose / ref | 同上 | +| AAD 绑满五维 scope + ref | `test_encrypted_fs_store_aad_binds_all_five_scope_dimensions` | +| 换 scope 搬密文解不开(绕过访问控制后仍拦得住) | `test_encrypted_fs_store_cross_scope_ciphertext_move_fails` | +| `update` 也加密(第二条写路径) | `test_encrypted_fs_store_update_also_encrypts` | +| 空文件 roundtrip | `test_encrypted_fs_store_roundtrips_empty_file` | +| `stat.size` 是密文长度(已知代价,显式钉住) | `test_encrypted_fs_store_stat_reports_ciphertext_size` | +| `get`/`delete` 的 NotFound 与幂等语义不被加密改变 | `test_encrypted_fs_store_passes_through_missing_and_delete` | +| ~~迁移期明文可读(provider 允许时)~~ | ~~`test_encrypted_fs_store_supports_plaintext_compatibility_via_provider`~~(F05 §明文策略作废,用例已删) | +| ~~明文兼容开着时写路径**仍然**加密~~ | ~~`test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed`~~(同上) | +| 解密失败 fail-closed 成 `BackendError` | `test_encrypted_fs_store_decryption_failure_is_fail_closed` | +| 具名依赖装配 / 缺 `inner` 报错 | `test_encrypted_fs_store_factory_*` | +| `encrypted` 在只调 `register_backends()` 时已注册 | `test_encrypted_fs_is_registered_by_storage_bootstrap` | + ## 已知遗留 -- ~~默认配置不会自动切到 encrypted KV~~ **已更新**:`build_kernel`(`assembly.py`)现已强制加密——无论配置里 `kv_store.default` 指向 memory/sqlite/redis,装配出来的 KV 一定是 `EncryptedKVStore`,security provider 从 `security` 命名空间取 `local`(AES-256-GCM)。要关闭加密需修改 `assembly.py` 的强制包装逻辑。 -- 当前只保护 KV value;vector/fulltext/fusion/graph/fs 中的索引字段、文本、向量、图边、文件资产不在该装饰器保护范围内。 -- key、scope 维度、TTL 与 raw 后端中的记录数量仍对后端可见。 -- KMS/Vault provider、密钥轮换、密钥版本迁移、批量重加密仍需在 `common/security` 与运维流程中补齐。 -- cloud engine 与 encrypted KV 的端到端集成测试、space 删除后的密文清理验证、严格关闭明文兼容后的迁移验证仍需补充。 +- 默认配置不会自动切到 encrypted KV,调用方必须显式把业务使用的 `kv_store` 实例指向 `target: encrypted`。FS 侧同理(`fs_store` 的 `target: encrypted`)。 +- 当前只保护 KV value 与 FS 文件内容;vector/fulltext/fusion/graph 中的索引字段、文本、向量、图边不在装饰器保护范围内。**向量本身可被反演出近似原文**,这是一个真实的信息泄露面,但加密向量就没法做 ANN 检索——需要的是加密检索方案,不是装饰器能解决的。 +- key、ref、scope 维度、TTL 与 raw 后端中的记录数量仍对后端可见。 +- **`FSStore.get` 必须读全文件到内存**才能解密(见「拒绝的方案」里的 chunked encryption)。大文件(视频、模型权重)会吃内存。 +- **`FileStat.size` 返回密文长度**,比明文长(信封头 + 包装后的数据密钥 + 两个 nonce + 两个 16B GCM tag)。不修正——修正需要先解密才能知道明文长度,代价荒谬。调用方拿它分配缓冲区只会偏大,不影响正确性。 +- ~~**无根密钥轮换接缝**~~:已在 F05 迁移中补上。信封升级到 v2,头部自述 key id 与 + key epoch,根密钥由独立的 `KeyProvider` 提供。**跨代轮换仍未实现**——keyring 保留旧 + epoch、按信封自述的 key ref 选密钥这一步还没有,换根密钥依旧要重加密历史密文。 +- **`LocalKeyProvider` 的根密钥是磁盘上的明文文件**。生产应走 KMS/Vault。 +- **根密钥文件权限在 Windows 上设不出 `0o600`**,对应断言已加 `os.name != "nt"` 守卫, + 只在 POSIX 上校验。不影响 Linux 部署。 +- KMS/Vault KeyProvider、跨代密钥轮换、密钥版本迁移、批量重加密仍需在 + `common/security/cryptography/` 与运维流程中补齐。 +- cloud engine 与 encrypted KV 的端到端集成测试、space 删除后的密文清理验证仍需补充。 diff --git a/docs/specs/S02-memory-api.md b/docs/specs/S02-memory-api.md index 4168c6dc..dea68b8e 100644 --- a/docs/specs/S02-memory-api.md +++ b/docs/specs/S02-memory-api.md @@ -5,13 +5,13 @@ | 项 | 值 | |---|---| | 关联模块 | src/api/ | -| 最近一次修订日期 | 2026-08-05 | -| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/api/F02-write-infer-extract.md,docs/features/api/F03-batch-write-api.md,docs/features/construction/F02-dynamic-extraction-consolidation.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/common/F03-scope-space-isolation.md,docs/features/retrieval/F03-metadata-filtering.md,docs/features/control/F04-permission-context-routing.md,docs/features/control/F05-cloud-engine-design.md,docs/features/config/F01-config-source.md | +| 最近一次修订日期 | 2026-08-07 | +| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/api/F02-write-infer-extract.md,docs/features/api/F03-batch-write-api.md,docs/features/construction/F02-dynamic-extraction-consolidation.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F08-authorization-context.md,docs/features/retrieval/F03-metadata-filtering.md,docs/features/control/F04-permission-context-routing.md,docs/features/control/F05-cloud-engine-design.md | ## 范围 / 边界 **管什么**: - 统一对外 Core API(形态无关):所有接入形态(SDK/CLI/Skill/MCP/HTTP·gRPC)最终映射到 `MemoryAPI` -- 鉴权执行点(PEP):调用 `PermissionManager.check(identity, scope, action)` 做入口鉴权 +- 鉴权执行点(PEP,唯一):调用 `Authorizer.authorize(auth, resource, environment)` 做入口鉴权 - 入口审计:写审计事件到 `AuditLogger` - 参数装配:将调用侧参数装配为控制层可消费的内部结构 - 同步/异步桥接:为同步形态桥接引擎异步协程 @@ -24,22 +24,24 @@ ## 不变量 -1. **本层是薄封装 + PEP**:数据面委托 `MemoryEngine`,治理面委托 `Governor`,授权面委托 `PermissionManager`,调度面委托 `Scheduler`,策略面直达 `PolicyManager`。 -2. **`identity` 不下沉**:鉴权通过后只透传已鉴权的 target `scope`,`identity` 不传入控制层。 -3. **`identity` 为必填 keyword-only 参数**:与 target `scope` 同为 `Scope` 类型,强制具名传入防止位置传反。 +1. **本层是薄封装 + PEP**:数据面委托 `MemoryEngine`,治理面委托 `Governor`,授权判定委托 `common.security.authorization.Authorizer`(PDP),调度面委托 `Scheduler`,策略面直达 `PolicyManager`。 +2. **`security` 不下沉**:鉴权通过后只透传已鉴权的 target `scope`,`security` 不传入控制层。 +3. **`security` 为必填 keyword-only 参数**:类型是 `RequestSecurityContext`(不是 `Scope`),由认证中间件或 `common.security.request_context` 的受控入口产出。调用方不能自行声明身份,也不存在 `auth=None` 或空 `Scope()` 自动管理员的旁路(见 [S09](S09-security.md))。 4. **recall 参数拆分**:`context: Context` 在本层边界拆开——`context.scope` 作独立轴穿透,`context.extensions` 写入调用级 options;约定 key `context.extensions["max_tokens"]` 由 API 边界解析为 int 后写入 `RetrievalQuery.max_tokens`,并从透传 extensions 中移除;`Context` 对象本身不进控制层。 5. **admin 不经 Engine**:admin_get/set/all 直达 PolicyManager。 6. **管理面闸门 = 根 scope**:无具体 target scope 的方法(admin_*、全局 audit)以根 scope `Scope()` 为鉴权目标——「能对根 scope 行权」即管理员闸门;租户数据/治理方法仍按各自 target scope 鉴权。 7. **`as_of` = valid-time 回溯点**:`recall`/`get` 的 `as_of` 沿系统相信时间轴回溯,返回「那时被认为有效」的版本(`get` 沿 `supersedes` 版本链定位);`None` 表示当前态。 8. **target scope 兜底**:`delete` 等以 `selector.scope or 根 scope` 为鉴权目标——未限定 scope 的跨范围操作退到根闸门,要求更高权限。 -9. **路由鉴权绑定数据范围**:路由型 PermissionManager 依据请求中的字段选择策略时, +9. **路由鉴权绑定数据范围**:路由型 Authorizer 依据请求中的字段选择策略时, API 必须把同一个授权路由值作为系统过滤谓词回注查询,避免「按 A 类型授权、读取 B 类型数据」。系统谓词与用户 `filters` 以外层 `AND` 合并。 10. **space 是租户隔离单元**:`Scope.space` 参与鉴权、存储命名空间、索引过滤和审计 actor/target 过滤;`scope.require_space=true` 时,具体 target scope 缺少 `space` 的数据/治理操作在 API 层拒绝。org 级 `create_space/list_spaces` 使用 `Scope(org=...)` 做管理面鉴权,不受该策略拦截。 -11. **space policy 在 API 边界生效**:已创建 space 的 `principal_path` 由 `SpaceManager.get_policy` 提供,API 在调用 `PermissionManager.check` 前写入 `PermissionContext.metadata["principal_path"]`;调用级 metadata 不能覆盖 space policy。 +11. **space policy 在 API 边界生效**:已创建 space 的 `principal_path` 由 `SpaceManager.get_policy` 提供,API 在构造 `ResourceDescriptor` 前把 `PermissionContext.metadata["principal_path"]` 摊平到资源属性;调用级 metadata 不能覆盖 space policy。 12. **list 按实际资源二次鉴权**:请求显式给出的 `memory_types` 先做类型级鉴权;Engine 再以当前分页实际命中的 MemoryUnit 真源元数据返回权限上下文,API 逐条 READ 鉴权,全部通过后才返回内容。参与权限路由的 extensions 值必须作为系统过滤条件回注。 13. **list 过滤和计数在 KV 内完成**:API 复制 `extensions`、规范化 `filters` 后完整下推;返回 `MemoryListResult.items` 当前页和分页前精确 `count`,不以 `len(items)` 代替总数。 -14. **六类动态配置不走业务入参**:能力开关、prompt 全文、LLM/Embedder/Reranker 的 model/api_key/url、Store 连接或 `*.active` 等由 `ConfigSource.fetch` 提供(见 S08);`write`/`recall`/`evolve`/`list` 不得把上述值解释为配置写入。调用侧可传 prompt **key**、`memory_type`/pipeline 等业务选择子。 +14. **batch_write 逐项经过 PEP**:批量请求共享一个 `security`,但每个归一化后的 item + 以自身最终 `scope/tags/metadata` 独立执行 WRITE 鉴权、space 校验与审计;不得用一次 + 粗粒度鉴权覆盖整批,也不得把 `security` 下沉到 Engine。 ## 接口契约 @@ -49,21 +51,21 @@ | 方法 | 签名 | 语义 | |------|------|------| -| `write` | `(content, scope, source=TEXT, *, identity, assets, tags, metadata, occurred_at) -> list[MemoryUnit]` | 同步写入:鉴权 WRITE→委托 Engine→阻塞至 hot path 完成。infer/procedural 触发时返回 `created_ids` 对应的派生单元(可空),否则返回原始单元 | +| `write` | `(content, scope, source=TEXT, *, security, assets, tags, metadata, occurred_at) -> list[MemoryUnit]` | 同步写入:鉴权 WRITE→委托 Engine→阻塞至 hot path 完成。infer/procedural 触发时返回 `created_ids` 对应的派生单元(可空),否则返回原始单元 | | `write_async` | `async (同签名) -> list[MemoryUnit]` | 异步写入:直通 Engine 协程,供事件循环形态使用 | -| `batch_write` | `(items: list[BatchWriteItem], scope=None, source=TEXT, *, identity, tags, metadata, occurred_at, stream_id="", continue_on_error=True) -> BatchWriteResult` | 同步桥接批量写入;逐项归一化、WRITE 鉴权、space 校验与审计,结果始终按输入索引对齐 | -| `batch_write_async` | `async (同签名) -> BatchWriteResult` | 串行保序批量写入;默认归集单项错误,`continue_on_error=False` 时后续项为 `Skipped` | -| `recall` | `(query, context: Context, *, identity, filters, as_of, top_k, disclosure, with_trajectory) -> RetrievalResult` | 混合检索:鉴权 READ→拆 Context→装配 RetrievalQuery→委托 Engine | -| `list` | `(scope, *, identity, offset=0, limit=100, memory_types=None, extensions=None, filters=None) -> MemoryListResult` | 列出已建索引记忆:支持类型/FilterExpr 过滤、自定义参数透传和分页前精确总数;只返回 `/memory/` 真源记录 | -| `get` | `(unit_id, scope, *, identity, as_of=None) -> MemoryUnit` | 真源点读:鉴权 READ→委托 Engine | -| `update` | `(unit_id, scope, patch: MemoryPatch, *, identity) -> MemoryUnit` | 修正记忆:鉴权 UPDATE→委托 Engine | -| `delete` | `(selector: DeleteSelector, *, identity) -> list[str]` | 删除/归档/降权:鉴权 DELETE→委托 Engine | -| `evolve` | `(scope, mode: EvolveMode, channel=BACKGROUND, *, identity) -> str` | 触发演进:鉴权→委托 Engine→返回 job_id | -| `job_status` | `(job_id, *, identity) -> JobInfo` | 查询任务状态(委托 Scheduler) | -| `job_cancel` | `(job_id, *, identity) -> None` | 取消任务(委托 Scheduler) | -| `admin_get` | `(key, *, identity) -> str` | 读策略(直达 PolicyManager) | -| `admin_set` | `(key, value, *, identity) -> None` | 写策略(直达 PolicyManager) | -| `admin_all` | `(*, identity) -> dict[str, str]` | 列全部策略(直达 PolicyManager) | +| `batch_write` | `(items, scope=None, source=TEXT, *, security, tags, metadata, occurred_at, stream_id, continue_on_error) -> BatchWriteResult` | 同步批量写入:归一化默认值,每项独立鉴权并按输入顺序返回 outcome | +| `batch_write_async` | `async (同签名) -> BatchWriteResult` | 异步批量写入;同步入口仅以 `asyncio.run` 桥接本方法 | +| `recall` | `(query, context: Context, *, security, filters, as_of, top_k, disclosure, with_trajectory) -> RetrievalResult` | 混合检索:鉴权 READ→拆 Context→装配 RetrievalQuery→委托 Engine | +| `list` | `(scope, *, security, offset=0, limit=100, memory_types=None, extensions=None, filters=None) -> MemoryListResult` | 列出已建索引记忆:支持类型/FilterExpr 过滤、自定义参数透传和分页前精确总数;只返回 `/memory/` 真源记录 | +| `get` | `(unit_id, scope, *, security, as_of=None) -> MemoryUnit` | 真源点读:鉴权 READ→委托 Engine | +| `update` | `(unit_id, scope, patch: MemoryPatch, *, security) -> MemoryUnit` | 修正记忆:鉴权 UPDATE→委托 Engine | +| `delete` | `(selector: DeleteSelector, *, security) -> list[str]` | 删除/归档/降权:鉴权 DELETE→委托 Engine | +| `evolve` | `(scope, mode: EvolveMode, channel=BACKGROUND, *, security) -> str` | 触发演进:鉴权→委托 Engine→返回 job_id | +| `job_status` | `(job_id, *, security) -> JobInfo` | 查询任务状态(委托 Scheduler) | +| `job_cancel` | `(job_id, *, security) -> None` | 取消任务(委托 Scheduler) | +| `admin_get` | `(key, *, security) -> str` | 读策略(直达 PolicyManager) | +| `admin_set` | `(key, value, *, security) -> None` | 写策略(直达 PolicyManager) | +| `admin_all` | `(*, security) -> dict[str, str]` | 列全部策略(直达 PolicyManager) | `list` 的 `memory_types` 用于数据过滤,也参与权限路由:显式传一个或多个类型时,API 层为每个 类型分别构造 `PermissionContext(memory_type=)` 并逐个执行 READ 鉴权;未传类型时先按 @@ -120,7 +122,7 @@ - **KV key 前缀分离**:真源 key 按「是否建索引」带前缀——`/memory/{id}`(建索引记忆)、`/messages/{id}`(未建索引 infer 原文)。前缀常量与 helper 在 `common.type_def.memory`/`raw`。详见 F02 决策6。 - **engine.write infer=false 调 classify**:默认路径调 `classifier.classify` 给原文打 tier+tags(纯 LLM 抽取 episodic/semantic/procedural + tags);infer=true 不经 classifier(extractor 产派生时自定)。详见 F02 决策9。 - **`/v1/list` 收窄并上收为 API 契约**:handler `_list` 委托 - `MemoryAPI.list(scope, identity=..., offset, limit, memory_types, extensions, filters)`; + `MemoryAPI.list(scope, security=..., offset, limit, memory_types, extensions, filters)`; `KVStore.list` 只查询 `/memory/` 记忆并返回当前页与分页前总数。详见 F02 决策10与 F01 的 list 决策。 @@ -130,34 +132,34 @@ | 方法 | 签名 | 语义 | |------|------|------| -| `inspect` | `(unit_ids, scope, *, identity) -> list[MemoryUnit]` | 检视完整内容与治理字段(含已失效版本) | -| `trace` | `(unit_id, scope, *, identity) -> list[MemoryUnit]` | 沿 provenance 追溯演进来源链 | -| `audit` | `(filters: dict[str, str], *, identity, limit=100) -> list[AuditEvent]` | 按条件检索审计留痕 | +| `inspect` | `(unit_ids, scope, *, security) -> list[MemoryUnit]` | 检视完整内容与治理字段(含已失效版本) | +| `trace` | `(unit_id, scope, *, security) -> list[MemoryUnit]` | 沿 provenance 追溯演进来源链 | +| `audit` | `(filters: dict[str, str], *, security, limit=100) -> list[AuditEvent]` | 按条件检索审计留痕 | -#### 授权面(委托 PermissionManager) +#### 授权面(写入 Authorizer 读取的 GrantStore) | 方法 | 签名 | 语义 | |------|------|------| -| `grant` | `(grant: Grant, *, identity) -> None` | 新增跨 scope 授权 | -| `revoke` | `(grant: Grant, *, identity) -> None` | 回收授权(幂等) | +| `grant` | `(grant: Grant, *, security) -> None` | 新增跨 scope 授权 | +| `revoke` | `(grant: Grant, *, security) -> None` | 回收授权(幂等) | #### Space 管理面(委托 SpaceManager) | 方法 | 签名 | 语义 | |------|------|------| -| `create_space` | `(spec: SpaceSpec, *, identity) -> SpaceInfo` | 创建全局唯一 space id;以 `Scope(org=spec.org)` 做 WRITE 鉴权,成功后记录目标 space 审计 | -| `get_space` | `(org, space, *, identity) -> SpaceInfo` | 读取单个 space 的基础信息与策略 | -| `list_spaces` | `(org, *, identity, status=None, limit=100, cursor=None) -> list[SpaceInfo]` | 列出 org 下 spaces;以 `Scope(org=org)` 做 READ 鉴权 | -| `update_space` | `(org, space, patch: SpacePatch, *, identity) -> SpaceInfo` | 修改 display name、status、principal_path、policy 或 metadata | -| `archive_space` | `(org, space, *, identity) -> SpaceInfo` | 归档 space;已归档 space 的 `write/update/evolve` 会被拒绝 | -| `delete_space` | `(org, space, *, identity, mode=PURGE) -> SpaceDeleteResult` | 删除 space;当前只支持 PURGE,API 先经 Engine 清该 `org + space` 下全部 user/agent/session 子 Scope 的 `/memory/` 真源与索引,再委托 SpaceManager 清 KV/messages/metadata | -| `export_space` | `(org, space, *, identity, include_audit=True) -> str` | 创建导出记录并返回 export id | -| `space_usage` | `(org, space, *, identity) -> SpaceUsage` | 查询 space 级 memory/message/KV bytes 用量 | -| `get_space_policy` | `(org, space, *, identity) -> SpacePolicy` | 读取 space policy | -| `set_space_policy` | `(org, space, policy: SpacePolicy, *, identity) -> SpacePolicy` | 替换 space policy,并同步主体路径 | -| `list_space_members` | `(org, space, *, identity) -> list[SpaceMember]` | 列出 space 成员与角色 | -| `add_space_member` | `(org, space, member: SpaceMember, *, identity) -> None` | 添加或更新成员角色 | -| `remove_space_member` | `(org, space, member: Scope, *, identity) -> None` | 移除成员 | +| `create_space` | `(spec: SpaceSpec, *, security) -> SpaceInfo` | 创建全局唯一 space id;以 `Scope(org=spec.org)` 做 WRITE 鉴权,成功后记录目标 space 审计 | +| `get_space` | `(org, space, *, security) -> SpaceInfo` | 读取单个 space 的基础信息与策略 | +| `list_spaces` | `(org, *, security, status=None, limit=100, cursor=None) -> list[SpaceInfo]` | 列出 org 下 spaces;以 `Scope(org=org)` 做 READ 鉴权 | +| `update_space` | `(org, space, patch: SpacePatch, *, security) -> SpaceInfo` | 修改 display name、status、principal_path、policy 或 metadata | +| `archive_space` | `(org, space, *, security) -> SpaceInfo` | 归档 space;已归档 space 的 `write/update/evolve` 会被拒绝 | +| `delete_space` | `(org, space, *, security, mode=PURGE) -> SpaceDeleteResult` | 删除 space;当前只支持 PURGE,API 先经 Engine 清该 `org + space` 下全部 user/agent/session 子 Scope 的 `/memory/` 真源与索引,再委托 SpaceManager 清 KV/messages/metadata | +| `export_space` | `(org, space, *, security, include_audit=True) -> str` | 创建导出记录并返回 export id | +| `space_usage` | `(org, space, *, security) -> SpaceUsage` | 查询 space 级 memory/message/KV bytes 用量 | +| `get_space_policy` | `(org, space, *, security) -> SpacePolicy` | 读取 space policy | +| `set_space_policy` | `(org, space, policy: SpacePolicy, *, security) -> SpacePolicy` | 替换 space policy,并同步主体路径 | +| `list_space_members` | `(org, space, *, security) -> list[SpaceMember]` | 列出 space 成员与角色 | +| `add_space_member` | `(org, space, member: SpaceMember, *, security) -> None` | 添加或更新成员角色 | +| `remove_space_member` | `(org, space, member: Scope, *, security) -> None` | 移除成员 | ## 数据结构 @@ -166,7 +168,7 @@ `org > space > user/agent > session` 五维归属,同时支撑隔离与共享。各维默认 `""`。API 里 `Scope` 出现在两个**不同语义**的位置(均为 `Scope` 类型,勿混淆): - **目标范围(target)**:操作作用于「谁的」记忆——`scope` 参数(或 `Context.scope` / `DeleteSelector.scope`)。 -- **调用方身份(identity)**:「谁」在发起调用——`identity` 参数(必填 keyword-only)。 +- **调用方身份**:「谁」在发起调用——**不由 `Scope` 表达**。它在 `security: RequestSecurityContext` 里,actor 由认证层产出;业务参数里的 `Scope` 一律是目标范围。 `space` 是全局唯一的逻辑隔离标识,`org` 表示其归属组织并继续参与权限边界;不同 org 不能创建相同的非空 space id。空 `space` 只表示兼容旧数据/单租户默认域,不参与 Space @@ -245,12 +247,6 @@ scope 不走 filters。metadata 比较严格保留类型:number、string、boo - `items: list[MemoryUnit]`:当前分页结果。 - `count: int`:同一 Scope 和过滤条件下的分页前精确总数,不受 offset/limit 影响。 -### BatchWriteItem / BatchWriteOutcome / BatchWriteResult(batch_write,`control/types.py`) - -- `BatchWriteItem` 表达单项内容与可选 scope/source/tags/metadata/occurred_at 覆盖;`stream_id`、`sequence`、`idempotency_key` 首版仅用于调度和回显,不写入真源。 -- `BatchWriteOutcome` 包含输入索引、归一化 item、该项产生的 `units` 与可归集的 `error` / `error_type`;成功且 units 为空仍是成功。Engine 的非领域异常也必须归集为 `InternalError`,不能使整批 HTTP 请求退化为 500。 -- `BatchWriteResult.outcomes` 与输入严格一一对应。相同 `(Scope, stream_id)` 的非空 `sequence` 不得重复;接口不自动重排。 - ### DisclosureLevel / RetrievalResult(recall 返回,`retrieval/types.py`) `DisclosureLevel`:`L0`(摘要)/ `L1`(片段)/ `L2`(全文)/ `ADAPTIVE`(按 `max_tokens` 预算自动选层级)。 @@ -297,7 +293,7 @@ scope 不走 filters。metadata 比较严格保留类型:number、string、boo | 异常 | 触发场景 | |------|----------| -| `PermissionDeniedError` | 鉴权不通过(identity 对 target scope 无相应 Action 权限) | +| `PermissionDeniedError` | 鉴权不通过(`security.actor` 对 target scope 无相应 Action 权限) | | `NotFoundError` | `get` 等按 id 读取但记忆不存在 | | `ValidationError` | 入参非法(如 `recall` 的 `top_k <= 0`) | | `PolicyError` | `admin_set` 的键未知或为不可变配置 | @@ -307,12 +303,14 @@ scope 不走 filters。metadata 比较严格保留类型:number、string、boo ## 鉴权流程 ``` -调用方 → MemoryAPI.method(scope=target, identity=caller) - → PermissionManager.check(actor=identity, target=scope, action=<对应动作>, context=...) - # list/get/update/delete/inspect/trace 的已有资源上下文来自真源和已鉴权 target scope - → 通过 → 委托 Engine/Governor/PolicyManager(仅传 scope,不传 identity) - → 拒绝 → 抛 PermissionDeniedError - → 落审计事件(含 identity + action + target_id + 时间) +调用方 → MemoryAPI.method(scope=target, security=RequestSecurityContext) + → 构造 ResourceDescriptor(action + resource_type + scope + resource_id + attributes) + # list/get/update/delete/inspect/trace 的已有资源属性来自真源,请求 metadata 不能覆盖 + → 由 security 派生 AuthorizationEnvironment.from_request(security, now=<服务端时钟>) + → Authorizer.authorize(auth=security.auth, resource=..., environment=...) + → allow → 委托 Engine/Governor/PolicyManager(仅传 scope,不传 security) + → deny → 抛 PermissionDeniedError,并落 deny audit(含 DenyReason code + rule) + → 落审计事件(含 security.actor + action + target_id + 时间) ``` ## 实现注册机制 @@ -329,5 +327,4 @@ src/api/memory_api_impl/ | S01-ingest_access | write 路径中 Engine 内部调用 Ingestor | | S03-control | 数据面委托 MemoryEngine,治理/授权/调度面委托对应算子 | | S04-retrieval | recall 路径中 Engine 委托 Retriever | -| S08-config | 六类动态配置经 ConfigSource;不经本层业务入参写入 | | architecture.md §9 | 记忆接口层语义定义 | diff --git a/docs/specs/S03-control.md b/docs/specs/S03-control.md index f5557ed6..8fa37624 100644 --- a/docs/specs/S03-control.md +++ b/docs/specs/S03-control.md @@ -5,8 +5,8 @@ | 项 | 值 | |---|---| | 关联模块 | src/control/ | -| 最近一次修订日期 | 2026-08-05 | -| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/api/F02-write-infer-extract.md,docs/features/api/F03-batch-write-api.md,docs/features/construction/F02-dynamic-extraction-consolidation.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/control/F03-control-pipeline-routing.md,docs/features/control/F04-permission-context-routing.md,docs/features/control/F05-cloud-engine-design.md,docs/features/common/F03-scope-space-isolation.md,docs/features/retrieval/F03-metadata-filtering.md,docs/features/config/F01-config-source.md | +| 最近一次修订日期 | 2026-08-07 | +| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/api/F02-write-infer-extract.md,docs/features/api/F03-batch-write-api.md,docs/features/construction/F02-dynamic-extraction-consolidation.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/control/F03-control-pipeline-routing.md,docs/features/control/F04-permission-context-routing.md,docs/features/control/F05-cloud-engine-design.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F08-authorization-context.md,docs/features/retrieval/F03-metadata-filtering.md | ## 范围 / 边界 **管什么**: @@ -31,7 +31,8 @@ 1. **引擎不实现具体算法能力**:`MemoryEngine` 只编排,Ingestor/构建算子/Retriever/Store 全部由装配注入;可通过 Store 抽象完成真源语义,但不得绑定具体后端或直接调用 LLM。 2. **引擎方法一律异步协程**:同步调用由 `src/api` 层自行桥接(`asyncio.run`),engine 内不做同步阻塞。 -3. **鉴权不在本层执行**:`PermissionManager.check` 由 `src/api/MemoryAPI` 在入口调用,engine 信任传入的 scope 已鉴权。禁止在 engine 内部重复 check。 +3. **鉴权不在本层执行**:`src/api/MemoryAPI` 作为唯一 PEP 调用 `Authorizer`,engine + 信任传入的 scope 或 `BatchWriteItem.scope` 已逐项鉴权。禁止在 engine 内部重复判权。 4. **LifecycleManager 只做非破坏式标记**:`transition` 标记状态(superseded/archived/forgotten),绝不物理删除。物理删除(purge)走 Engine 的 `delete` 路径 + `DeleteMode.PURGE`。 5. **接口与实现严格分离**:顶层 `.py` 是纯抽象,不 import `*_impl/`。`*_impl/` 通过 Producer 自注册后被外部装配消费,不被顶层接口引用。 6. **types.py 零依赖本层其他文件**:纯数据定义,被本层各接口和 `src/api/` 共同依赖。 @@ -43,8 +44,8 @@ 12. **Pipeline 只做跨层 profile 选择**:`MemoryPipeline` 可以选择不同的构建/查询组件绑定,但不得实现抽取、索引、检索算法;construction/retrieval 不反向依赖 control。 13. **权限上下文由 API/Engine 解析,不信任调用方声明**:write/recall/list 的请求条件可由 API 构造 `PermissionContext`;list 当前分页实际命中的 unit 以及 get/update/delete 这类已有 unit 操作必须由 Engine 从真源元数据解析 memory_type/tags 后再鉴权。 14. **权限路由与执行路由同源但职责独立**:两者对 recall 都使用 - extensions 优先、FilterExpr 强制唯一等值兜底的取值规则;PermissionManager 选择 - 授权策略,MemoryPipeline 选择执行组件,互不代替。 + extensions 优先、FilterExpr 强制唯一等值兜底的取值规则;Authorizer 选择授权策略, + MemoryPipeline 选择执行组件,互不代替。 15. **路由授权绑定数据范围**:路由型权限根据某字段授权后,API 必须把同一值回注为 系统过滤谓词;routing fallback 必须是最小权限策略,不得使用 `allow_all`。 16. **目标操作使用完整 Scope**:MemoryUnit id 仅在 Scope 内唯一。LifecycleManager、Governor 与 IndexBuilder 的目标修改/读取/删除不得依赖全局 `id -> scope` 猜测,调用方必须显式提供 Scope 或携带 Scope 的 MemoryUnit。 @@ -70,7 +71,7 @@ class ControlOperator(ABC): | 方法 | 签名 | 语义 | |------|------|------| | `write` | `async (content, scope, source, *, assets, tags, metadata: dict[str, Any] \| None, occurred_at) -> list[MemoryUnit]` | 规约→可选抽取/分类→落盘+建索引;`infer=true` 时返回 `created_ids` 对应的派生结果,否则处理原始单元(直写不去重) | -| `batch_write` | `async (items: list[BatchWriteItem], *, continue_on_error=True) -> BatchWriteResult` | 只接收 API 已归一化并完成鉴权/space 前置校验的项;按输入顺序复用 `write`,归集领域异常及非领域异常(后者为 `InternalError`);fail-fast 时填充 `Skipped` outcomes | +| `batch_write` | `async (items: list[BatchWriteItem], *, continue_on_error=True) -> BatchWriteResult` | 接收 API 已归一化、逐项鉴权的 item,按输入顺序执行并返回逐项 outcome;不接收 `security` | | `recall` | `async (scope, query: RetrievalQuery) -> RetrievalResult` | 委托 Retriever 完整检索链路 | | `list` | `async (scope, *, offset=0, limit=100, memory_types=None, extensions=None, filters=None) -> MemoryListResult` | 校验分页参数并完整委托 `KVStore.list`;返回当前页和分页前匹配总数 | | `permission_context_for_unit` | `async (unit_id, scope) -> PermissionContext` | 读取已有记忆的权限上下文,只返回 memory_type/tags/metadata 等鉴权元数据,不返回 content/assets | @@ -194,22 +195,25 @@ active → archived → forgotten | 方法 | 签名 | 语义 | |------|------|------| -| `grant` | `(grant: Grant) -> None` | 新增跨 scope 授权 | -| `revoke` | `(grant: Grant) -> None` | 回收授权(幂等) | -| `check` | `(actor: Scope, target: Scope, action: Action, context: PermissionContext \| None = None) -> bool` | 校验 actor 对 target 是否可执行 action;context 为资源类型、memory_type、pipeline、unit_id、tags 等可选上下文 | +| `grant` | `(grant: Grant) -> None` | 新增跨 scope 授权**记录** | +| `revoke` | `(grant: Grant) -> None` | 回收授权记录(幂等) | +| `check` | `(actor: Scope, target: Scope, action: Action, context: PermissionContext \| None = None, *, auth: AuthContext \| None = None) -> bool` | **已不在请求路径上**:授权判定归 `common.security.authorization.Authorizer`(见 S09),本方法只剩历史实现与既有回归覆盖 | | `routing_fields` | `() -> tuple[str, ...]` | 返回本实现鉴权路由所依据的 metadata 字段;非路由实现返回空元组 | -**check 规则**: -1. `actor == Scope()`(platform admin)→ 全局通过 -2. actor owner-cover target → 通过:先要求同 `org + space`,再按 `PermissionContext.metadata["principal_path"]`(`user_agent` / `agent_user`,默认 `user_agent`)判断 actor scope 是否为 target scope 的合法前缀;空字段不能跳过中间层 -3. `actor.org != target.org` 且 actor 非 root → 拒绝;跨 org grant 不属于默认授权契约 -4. 存在匹配 Grant(未过期 + action 在授权集合内 + grantee 覆盖 actor + grantor 覆盖 target)→ 通过;grantor/grantee 都持久化 `space`,显式 grant 可跨 space -5. 否则 → 拒绝 - -权限后端由配置选择;无具体 target scope 的管理面方法(`admin_get` / `admin_set` / -`admin_all` / 全局 `audit`)统一以根 scope `Scope()` 作为鉴权目标,普通租户 -scope 不默认具备管理面访问权;`grant` / `revoke` 则以 grantor scope 为 target -做 `Action.SHARE` 校验。 +**授权判定不在本层**。`LocalMemoryAPI._authorize` 这个唯一 PEP 调的是 +`Authorizer.authorize(auth=..., resource=..., environment=...)`,输入固定为 +`AuthContext + ResourceDescriptor + AuthorizationEnvironment`,**不读 ContextVar**, +也不存在 `auth=None` 退回纯 ACL、空 `Scope()` 即 platform admin 这两条旧兼容线—— +它们在 PR2 已删除。判定顺序与 truth table 见 [S09](S09-security.md)。本层的 +`PermissionManager` 在当前主干已不再是 grant/revoke 的写入通道--API 的 grant/revoke +改写 Authorizer 读取的 `GrantStore`;`PermissionManager` 仅作后续 PR 待删除的遗留。 + +权限/授权后端由配置选择;无具体 target scope 的管理面方法(`admin_get` / `admin_set` / +`admin_all` / 全局 `audit`)统一以根 scope `Scope()` 作为鉴权目标并携带 +`resource_type`(`admin` / `audit`),普通租户 scope 不默认具备管理面访问权; +`grant` / `revoke` 则以 grantor scope 为 target 做 `Action.SHARE` 校验。管理面资源 +中无 org 归属的(全局治理策略、跨 org 审计)要求 ROOT,带 org 的(space、主体) +ADMIN 可管但止于本 org。 `routing` 权限后端按 `PermissionContext` 分派到不同具名 permission policy。示例: @@ -254,8 +258,6 @@ recall 完成权限检查后,API 读取 `PermissionManager.routing_fields()` | `set` | `(key: str, value: str) -> None` | 调整策略(未知键/不可变配置抛 `PolicyError`) | | `all` | `() -> dict[str, str]` | 列出全部运行时策略及当前值 | -> **与 ConfigSource 的边界(S08)**:PolicyManager 只管理少量**已知策略键**(如 lifecycle 清扫目标、`scope.require_space`、既有 `rerank.enabled` 占位键)。能力开关/prompt 全文/模型凭证/Store 端点与 `*.active` 等六类动态配置走 `ConfigSource.fetch`,不通过 `admin_set` 扩展为任意配置树。 - ### SpaceManager(`space.py`) space 是 `org` 下的逻辑隔离单元。API 层负责鉴权与审计,`SpaceManager` 负责 @@ -337,5 +339,4 @@ src/control/<算子>_impl/ | architecture.md §8 | 演进调度(EvolveMode / Channel)映射到 Scheduler 双通道 + Evolver 四阶段 | | architecture.md §9 | `src/api/MemoryAPI` 是控制层的薄封装 + PEP;数据面委托 Engine,管理面直达各算子 | | architecture.md §12 | 横切可观测/治理——Governor.audit 消费 `common/audit/AuditLogger` 记录的审计事件 | -| architecture.md §13.4 | PolicyManager 是少量已知策略键的 admin 落点;六类动态配置见 S08 ConfigSource | -| S08-config | ConfigSource 与 PolicyManager 分工 | +| architecture.md §13.4 | PolicyManager 是运行时可变策略的 admin 落点 | diff --git a/docs/specs/S06-storage.md b/docs/specs/S06-storage.md index 2c1262e6..b6b2d2f6 100644 --- a/docs/specs/S06-storage.md +++ b/docs/specs/S06-storage.md @@ -5,8 +5,8 @@ | 项 | 值 | |---|---| | 关联模块 | src/storage/ | -| 最近一次修订日期 | 2026-08-04 | -| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/control/F05-cloud-engine-design.md,docs/features/retrieval/F03-metadata-filtering.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F04-security-interfaces-and-encryption.md,docs/features/storage/F02-encrypted-storage.md,docs/features/storage/F03-postgres-backend.md,docs/features/storage/F04-storage-ssl.md | +| 最近一次修订日期 | 2026-08-05 | +| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/control/F05-cloud-engine-design.md,docs/features/retrieval/F03-metadata-filtering.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F04-security-interfaces-and-encryption.md,docs/features/storage/F02-encrypted-storage.md | ## 范围 / 边界 **管什么**: @@ -29,25 +29,20 @@ 2. **记录 id 在 scope 内唯一**:`insert` 冲突 / `update` 缺失按 `(scope, id)` 判定;后端可用 `scope + id` 生成物理主键,保证同一逻辑 id 在不同 space 下互不冲突。 3. **统一 CRUD 动词**:insert(增)/ delete(删)/ update(改)/ get(查),各存储接口保持同一命名。 4. **检索型存储额外提供 search**:fulltext / vector / graph / fusion 在 CRUD 之上再提供 `search` 查询。 -5. **vector 的 recall 为可选能力**:`VectorStore.recall` 在 `search` 之上按需回带命中行 payload(`metadata`),基类默认抛 `NotImplementedError`,**不强制**每个后端实现;未实现者由调用方(`VectorRecaller`)捕获后回退 `search + get` 两段式,功能不退化。`search`/`get` 返回 `ScoredID`/`VectorRecord` 的契约不变。 -6. **kv 区分 list 与 scan**:`list` 是 `/memory/` MemoryUnit 的过滤、计数、排序和分页查询;`scan` 是无业务语义的 scope 内原始 key-value 扫描;`scopes` 枚举已有 scope。`mget` 是 `get` 的批量互补:一次召回多条、省逐条 `get` 的接口往返。返回与 `keys` 下标一一对应的 `list[bytes]`,**不去重、按位置返回**(调用方可传重复 key、各下标独立返回,语义同 Redis `MGET`);缺失语义与 `get` 一致——任一 key 不存在即抛 `NotFoundError`,不静默省略(「索引↔真源短暂不一致」的兜底由调用方负责)。重复 key 的去重亦由调用方(如 `UnitReader.load`)负责,不下沉到本接口。 -7. **fs 提供 stat**:stat(文件元信息查询)。 -8. **scope 对 key/路径做命名空间隔离**:kv / fs 是通用原语,`scope` 入参用于对 key / 路径做命名空间隔离(同一逻辑 key 在不同 scope 下是相互隔离的不同物理键)。 -9. **接口与实现严格分离**:顶层 `.py` 是纯抽象,不 import `*_impl/`。 -10. **生产过滤先于截断**:生产检索后端必须完整编译所支持的 `FilterExpr`,并在 - `limit/top_k` 前执行;不允许依赖检索层后置过滤替代生产下推。 -11. **metadata 原生类型入库**:Document / VectorRecord 的 metadata 保留 JSON 标量 +5. **kv 区分 list 与 scan**:`list` 是 `/memory/` MemoryUnit 的过滤、计数、排序和分页查询;`scan` 是无业务语义的 scope 内原始 key-value 扫描;`scopes` 枚举已有 scope。 +6. **fs 提供 stat**:stat(文件元信息查询)。 +7. **scope 对 key/路径做命名空间隔离**:kv / fs 是通用原语,`scope` 入参用于对 key / 路径做命名空间隔离(同一逻辑 key 在不同 scope 下是相互隔离的不同物理键)。 +8. **接口与实现严格分离**:顶层 `.py` 是纯抽象,不 import `*_impl/`。 +9. **生产过滤先于截断**:Milvus VectorStore 与 Elasticsearch FulltextStore 对 + `FilterExpr` 完整编译,并在 `limit/top_k` 前执行;不允许依赖检索层后置过滤替代 + 生产下推。 +10. **metadata 原生类型入库**:Document / VectorRecord 的 metadata 保留 JSON 标量 原生类型,不统一字符串化;不同类型之间不做隐式比较转换。 -12. **metadata 过滤区分标量与数组**:`EQ` / `IN` 的正向匹配只命中标量, - `CONTAINS` 只命中数组成员;`NE` / `NOT_IN` 是对应正向谓词的逻辑否定;范围算子 - 只作用于标量,数组字段不按「任一成员命中」判定。后端若原生不保留单值/数组形态, - 必须用内部派生字段恢复语义。 -13. **所有 Store 必须实现 `store_type()` 和 `health()`**:继承自 `BaseStore`。 -14. **多租户隔离默认依赖逻辑 scope 边界**:当前不要求物理分库/分 collection,但要求同一逻辑 key/id 在不同 scope 下严格命名空间隔离。 -15. **EncryptedKVStore 只装饰 KV,不实现算法**:写前加密、读后解密通过注入的 `SecurityProvider` 完成;`list` 在解密后执行 MemoryUnit 过滤,不能把过滤下推到密文 raw KV。 -16. **space 是 scope 的硬分区维度**:`scope_segments(scope)` 使用 `org/space/user/agent/session` 五段;`scope_dims(scope)` 在 `org` 非空时即使 `space==""` 也下推 `space == ""`,避免空 space 查询跨到非空 space。 -17. **标识唯一性分层**:非空 Space id 在 Space 资源注册表中全局唯一;MemoryUnit 与各 Store 记录 id 只要求在完整 Scope 内唯一。 -18. **SSL 声明即生效**:接外部后端的实现统一接受 `ssl_verify` / `ssl_ca_cert` 两个装配参数(默认关闭)。`ssl_verify` 只表示**是否校验服务端证书**,不负责开启加密——加密开关落在连接串上(`rediss://` / `https://` / `sslmode=`)。开启后不得静默降级:缺证书、连接串仍为明文、或连接串自带会覆盖本设置的 TLS 参数,一律在**装配阶段**报错。 +11. **所有 Store 必须实现 `store_type()` 和 `health()`**:继承自 `BaseStore`。 +12. **多租户隔离默认依赖逻辑 scope 边界**:当前不要求物理分库/分 collection,但要求同一逻辑 key/id 在不同 scope 下严格命名空间隔离。 +13. **EncryptedKVStore 只装饰 KV,不实现算法**:写前加密、读后解密通过注入的 `CryptographyProvider` 完成;`list` 在解密后执行 MemoryUnit 过滤,不能把过滤下推到密文 raw KV。 +14. **space 是 scope 的硬分区维度**:`scope_segments(scope)` 使用 `org/space/user/agent/session` 五段;`scope_dims(scope)` 在 `org` 非空时即使 `space==""` 也下推 `space == ""`,避免空 space 查询跨到非空 space。 +15. **标识唯一性分层**:非空 Space id 在 Space 资源注册表中全局唯一;MemoryUnit 与各 Store 记录 id 只要求在完整 Scope 内唯一。 ## 接口契约 @@ -72,7 +67,6 @@ class BaseStore(ABC): | `update` | `(scope, key, value: bytes, ttl=0.0) -> None` | 覆写 scope 下已有 key;不存在时报缺失 | | `delete` | `(scope, key) -> None` | 删除 scope 下的 key(幂等) | | `get` | `(scope, key) -> bytes` | 读取 scope 下 key 的值;不存在时报缺失 | -| `mget` | `(scope, keys: list[str]) -> list[bytes]` | 批量读取 scope 下多个 key 的值;返回与 `keys` **按下标一一对应**。缺失语义与 `get` 一致:任一 key 不存在即抛 `NotFoundError`,不静默省略。一次召回省去逐条 `get` 的接口往返。**不去重、按位置返回**:调用方可传重复 key,各下标独立返回该 key 的值(语义同 Redis `MGET`);重复 key 的去重由调用方负责,本接口不做 | | `exists` | `(scope, key) -> bool` | 返回 scope 下 key 是否存在 | | `list` | `(scope, *, offset=0, limit=100, memory_types=None, filters=None, extensions=None) -> KVMemoryListResult` | 查询 `/memory/` MemoryUnit;先执行 `memory_types AND filters`,再精确计数、稳定排序和分页 | | `scan` | `(scope, prefix="") -> list[tuple[str, bytes]]` | 扫描 scope 下的全部 (key, value)(可选只取 prefix 开头的 key);顺序由实现定义 | @@ -86,8 +80,8 @@ class BaseStore(ABC): | 方法 | 行为 | |------|------| -| `insert` / `update` | 构造 `SecurityContext(scope, purpose, metadata)` 与 AAD,调用 `SecurityProvider.encrypt` 后写入 raw KV | -| `get` / `scan` / `mget` | 从 raw KV 读取密文字节,调用 `SecurityProvider.decrypt` 后返回明文字节;任一解密失败抛 `BackendError`,不跳过坏数据。`mget` 委托 raw 一次性批量取密文(raw 缺失即抛 `NotFoundError`)后**逐项解密**——AAD 绑定 scope+key+purpose,各 key AAD 不同,不能批量统一解密 | +| `insert` / `update` | 构造 `CryptoContext(scope, purpose, object_id, format_version)` 与 AAD,调用 `CryptographyProvider.encrypt` 后写入 raw KV | +| `get` / `scan` | 从 raw KV 读取密文字节,调用 `CryptographyProvider.decrypt` 后返回明文字节;任一解密失败抛 `BackendError`,不跳过坏数据 | | `list` | 扫描目标 Scope 的 `/memory/` 密文并逐条解密,再执行统一过滤、计数、排序和分页;不调用 raw KV 的 `list` | | `exists` / `delete` / `scopes` | 直接委托 raw KV,不读取或改写 value | @@ -96,7 +90,7 @@ class BaseStore(ABC): | 参数 | 语义 | |------|------| | `raw_kv_store` | 必填,指向被装饰的 raw KVStore 具名实例或内联配置;不得指向当前 encrypted 实例自身 | -| `security` | 必填,指向 `common.security.SecurityProvider` 具名实例或内联配置 | +| `cryptography` | 必填,指向 `common.security.cryptography.CryptographyProvider` 具名实例或内联配置 | AAD 版本当前为 `1`,绑定 `scope(org/space/user/agent/session)`、KV `key` 与 `purpose`。`purpose` 由 key 前缀推导:`/memory/` 为 `memory_unit`,`/messages/` 为 `raw_message`,其他为 `kv_value`。 @@ -123,7 +117,6 @@ AAD 版本当前为 `1`,绑定 `scope(org/space/user/agent/session)`、KV `key | `delete` | `(scope, ids: list[str]) -> None` | 在 scope 内按 id 删除向量行(幂等) | | `get` | `(scope, ids: list[str]) -> list[VectorRecord]` | 在 scope 内按 id 点查向量行;缺失的 id 从结果中省略 | | `search` | `(scope, query: VectorQuery) -> list[ScoredID]` | 在 scope 内做 ANN 近邻检索,按相似度返回 top-k | -| `recall` | `(scope, query: VectorQuery, output_fields: list[str]\|None=None) -> list[ScoredHit]` | 在 scope 内做 ANN 近邻检索,并按需在同一次请求内回带命中行 payload(当前仅认 `metadata`);**可选能力**,基类默认抛 `NotImplementedError`,子类按需 override;未实现时调用方回退 `search + get` | ### GraphStore(`graph.py`) @@ -162,6 +155,25 @@ AAD 版本当前为 `1`,绑定 `scope(org/space/user/agent/session)`、KV `key | `get` | `(scope, ref) -> BinaryIO` | 打开 scope 下 ref 处的文件用于读取,由调用方负责关闭 | | `stat` | `(scope, ref) -> FileStat` | 返回 scope 下 ref 处文件的元信息 | +#### 加密 FS 装饰器契约 + +`encrypted` FS target 包装任意 inner FSStore,写入时整体加密文件内容,读取时有界读完整 +密文后整体解密;`delete` 与 `stat` 委托 inner。`ref` 与 Scope 保持可寻址的明文,但二者 +必须进入 AAD。`FileStat.size` 表示 inner 中的密文长度,不承诺等于明文长度。 + +装配参数: + +| 参数 | 语义 | +|------|------| +| `inner` | 必填,指向被装饰的 FSStore 具名实例或内联配置;不得指向当前实例自身 | +| `cryptography` | 必填,指向 CryptographyProvider 具名实例或内联配置 | +| `max_plaintext_bytes` | 单文件明文硬上限,必须大于等于 1 | +| `max_ciphertext_bytes` | 密文读取上限;`0` 表示按明文上限加 provider 无关的安全余量计算,负数非法 | + +大小检查必须同时覆盖:写入时循环有界读取明文、读取前按 `stat` 快速早拒、真正读取时 +循环有界读取密文,以及解密后再次校验明文。`stat` 不能作为唯一边界,因为它与随后 +`get` 之间存在 TOCTOU 窗口。 + ## 数据结构 ### KV(`types.py`) @@ -210,7 +222,6 @@ AAD 版本当前为 `1`,绑定 `scope(org/space/user/agent/session)`、KV `key | 类型 | 关键字段 | |------|----------| | `ScoredID` | id / score | -| `ScoredHit` | id / score / metadata | **注**:所有 `metadata` / `filters` / `scalar_filters` 只承载 scope 之外的额外谓词,scope 作为显式第一入参,不混进这些结构体。 @@ -235,5 +246,4 @@ Store 抽象、跨后端不变量与注册机制。 | S03-control | Engine 通过 KVStore 读写真源;目标生命周期/治理操作按显式 Scope 定位,全局 sweep/offboarding 才跨 Scope 枚举 | | S04-retrieval | 检索层各 Recaller 消费本层索引 Store | | S05-construction | 构建层通过本层抽象做真源与索引持久化 | -| S08-config | Store 连接参数与 `*.active` 可由 ConfigSource 晚绑定;切换后端不包含数据迁移 | | architecture.md §5 | 可配置真源形态(文档/结构化)与多后端 | diff --git a/docs/specs/S07-common.md b/docs/specs/S07-common.md index 9538d860..30495e0d 100644 --- a/docs/specs/S07-common.md +++ b/docs/specs/S07-common.md @@ -5,8 +5,8 @@ | 项 | 值 | |---|-------------| | 关联模块 | src/common/ | -| 最近一次修订日期 | 2026-08-05 | -| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/common/F01-memory-layer.md,docs/features/common/F02-dashscope-llm-provider.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F04-security-interfaces-and-encryption.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/retrieval/F03-metadata-filtering.md,docs/features/common/F05-model-service-ssl.md,docs/features/common/F06-distributed-lock.md,docs/features/config/F01-config-source.md | +| 最近一次修订日期 | 2026-08-07 | +| 关联特性文档 | docs/features/F01-system-spec-design.md,docs/features/api/F01-memory-api-impl-design.md,docs/features/construction/F04-cc-memory-compat.md,docs/features/common/F01-memory-layer.md,docs/features/common/F02-dashscope-llm-provider.md,docs/features/common/F03-scope-space-isolation.md,docs/features/common/F04-security-interfaces-and-encryption.md,docs/features/common/F07-authentication-kernel.md,docs/features/common/F08-authorization-context.md,docs/features/control/F02-control-isolation-and-audit.md,docs/features/retrieval/F03-metadata-filtering.md | ## 范围 / 边界 @@ -15,8 +15,7 @@ - 核心数据类型定义(MemoryUnit/Scope/Context/Relation 等) - 工厂注册机制(Factory/Producer 基础设施) - 审计日志(AuditLogger) -- 数据保护横切接口(SecurityProvider) -- 跨实例互斥横切接口(LockProvider) +- 安全横切能力(认证、授权判定、资源保护与密码学,归 `common/security/`) - 错误类型(自定义异常) - 工具函数(ID 生成/时间解析等) @@ -24,31 +23,27 @@ - 不做具体算子实现(算子由各层 `*_impl/` 实现) - 不做存储后端实现 - 不做业务编排逻辑 -- 不做鉴权/策略管理 +- 不承载授权执行点(PEP)或业务权限编排;PEP 归 `api/MemoryAPI`,本层只提供 PDP 与安全能力接口 ## 不变量 1. **共享插件必须双侧同一**:Embedder/Tokenizer/FeatureExtractor 等必须在构建侧与检索侧使用同一实现/同一配置,保证同词表/同向量空间。 2. **接口与实现严格分离**:顶层 `.py` 是纯抽象,不 import `*_impl/`。 -3. **所有插件必须实现 `plugin_type()` 和 `health()`**:继承自 `Plugin` 基类。 -4. **types.py 零依赖其他文件**:纯数据定义,被全局共享依赖。 +3. **模型插件遵循 `Plugin` 契约**:继承 `Plugin` 的模型插件实现 `plugin_type()` 和 + `health()`;审计能力实现自己 `base.py` 的接口;认证、资源保护、密码学等安全能力 + 统一归 `common/security/`,实现各自能力域的契约(见 S09)。 +4. **type_def 不依赖能力实现**:`type_def/*.py` 可在目录内引用基础数据类型,但不得 + import security、audit、storage 等能力实现。 5. **工厂注册发生在 import 时**:实现文件尾部 `@XxxProducer.register("name")` 绑定构建函数,`__init__.py` 导入实现文件触发注册。 6. **LLM Provider 参数不上浮到业务层**:厂商专属请求字段只能由对应 Adapter 生成;消费 `LLM` 的算子只传递通用生成选项。 -7. **SecurityProvider 是字节级横切接口**:调用方在持久化字节写入前加密、读取后解密;接口不绑定 `MemoryUnit` 或存储后端,是否启用由装配配置决定。 +7. **CryptographyProvider 是字节级横切接口**:调用方在持久化字节写入前加密、读取后解密;接口不绑定 `MemoryUnit` 或存储后端,也不决定数据是否应该加密——是否启用由上层选择不同的存储适配器表达。 8. **标识唯一性分层**:非空 Space id 全局唯一;`MemoryUnit.id` 只要求在完整 Scope 内唯一。 9. **Scope 位置参数兼容**:`space` 可为空但只能按关键字传入;旧位置参数顺序保持 `Scope(org, user, agent, session)`。 -10. **出站客户端 SSL 声明即生效**:LLM / Embedder / Reranker 统一接受 - `_ssl_verify` / `_ssl_ca_cert`(默认关闭)。`ssl_verify` 只决定是否 - 接管信任锚,不负责开启加密——加密开关在 `base_url` 的 scheme。关闭时完全不干预 - 客户端(`http://` 明文直连、`https://` 仍走 SDK 默认校验);开启后 `base_url` 必须是 - `https://`、证书文件必须存在,否则在**装配阶段**报错。缺证书不报错而回落系统 CA, - 这是与 storage 侧唯一的矩阵差异(公网端点走公共 CA 属正常状态)。 -11. **LockProvider 是基于租约的协调机制,不是共识算法**:租约到期、进程停顿超过租约、 - Redis 主从切换丢失未同步写入都会导致短暂双持。依赖方必须能容忍偶发互斥失效,或自备 - 第二道防线(幂等键、唯一约束、乐观并发控制)。重入以 `asyncio.current_task()` 为身份 - 边界,`create_task` 派生的子任务不视为重入;重入记账与租约有效性正交,持有权状态一律 - 以 `LockHandle.lost` 为准。后端不可用时 fail-closed 抛 `BackendError`,不静默降级为无锁。 +10. **Scope 是 frozen value object**(安全加固,F01 决策 16):`@dataclass(frozen=True)`, + 身份/隔离值不可变是跨模块安全不变量。改某维用 `dataclasses.replace(scope, org=...)` + 返回新值,禁止原地 `scope.x = ...`(抛 `FrozenInstanceError`)。frozen 同时使 Scope + 可哈希。防的是「签发 key 后改原 actor 的 org 让已签发身份跟着变」的越权。 ## 接口契约 @@ -145,36 +140,24 @@ DashScope Adapter 的 `params.enable_thinking` 由 Adapter 转换为 治理层通过 `Governor.audit(filters, limit)` 提供对外查询入口;`AuditLogger.query(...)` 是控制层消费审计后端的内部接口,不直接暴露为用户 API。 -### SecurityProvider(`security/security.py`) +### CryptographyProvider(`security/cryptography/base.py`) -数据保护横切接口。调用方以 bytes 为边界接入:写入持久化字节前调用 `encrypt`,读取持久化字节后调用 `decrypt`。接口只表达数据保护能力,不绑定 `MemoryUnit` 序列化、不绑定 KV 后端、不决定是否默认启用加密。 +数据保护横切接口。调用方以 bytes 为边界接入:写入持久化字节前调用 `encrypt`,读取持久化字节后调用 `decrypt`。接口只表达数据保护能力,不绑定 `MemoryUnit` 序列化、不绑定 KV 后端、不决定是否启用加密。 | 方法 | 签名 | 语义 | |------|------|------| -| `encrypt` | `(plaintext: bytes, *, context: SecurityContext | None = None, aad: bytes = b"") -> bytes` | 加密明文字节,可结合 scope / purpose / metadata 与 AAD 做租户隔离和完整性保护 | -| `decrypt` | `(ciphertext: bytes, *, context: SecurityContext | None = None, aad: bytes = b"") -> bytes` | 解密密文字节并校验完整性 | +| `encrypt` | `(plaintext: bytes, *, context: CryptoContext, aad: bytes = b"") -> bytes` | 加密明文字节,结合 scope / purpose / object_id / 格式版本与 AAD 做租户隔离和完整性保护 | +| `decrypt` | `(ciphertext: bytes, *, context: CryptoContext, aad: bytes = b"") -> bytes` | 解密密文字节并校验完整性;失败一律抛错,绝不返回原始 bytes | | `health` | `() -> None` | 存活探测;默认返回 `None`,具体实现可覆盖并抛出健康检查异常 | -`SecurityProducer.TOP_NAME` 为 `security`。具体 provider 的实现列表、target 名与 -私有配置参数归 `src/common/AGENTS.md` 与对应 feature 文档记录;本 spec 只固化 -接口、上下文和错误语义。 - -### LockProvider(`lock/lock.py`) - -跨实例互斥横切接口,**本层唯一的异步契约**。只交付互斥原语,不在任何业务路径上加锁; -在哪些临界区取锁、锁多大范围由各消费方自行论证。 +`context` 是**必填 keyword 参数**:AAD 绑定的隔离维度不能靠调用方"忘了传"退化成无绑定。 -| 方法 | 签名 | 语义 | -|------|------|------| -| `build_key` | `(scope: Scope, name: str) -> str` | 拼锁键 `am:lock:v1:{五段 scope}:{name}`;`name` 为空报 `ValidationError` | -| `acquire` | `(scope, name, *, lease_ms=None, wait_timeout_ms=None) -> LockHandle` | 有界等待获取;超时抛 `LockTimeoutError`,`wait_timeout_ms=0` 表示只试一次 | -| `release` | `(handle: LockHandle) -> None` | 按 token 做 CAS 释放;重入时只递减计数 | -| `renew` | `(handle, *, lease_ms=None) -> bool` | 按 token 做 CAS 续期;`False` 表示已失去持有权 | -| `guard` | `(scope, name, **kwargs) -> AsyncContextManager[LockHandle]` | 获取 / 自动续期 / 释放的组合,推荐入口 | -| `health` | `() -> None` | 存活探测;异步,与其余组件的同步 `health()` 不一致 | +密钥一律经 `KeyProvider`(`security/cryptography/key_provider.py`,`TOP_NAME` 为 +`key_provider`)取得,实现不得自己读环境变量或配置文件里的根密钥。 -`LockProducer.TOP_NAME` 为 `lock`,**不设默认实现**——消费方必须显式配置,避免漏配时 -静默退化成不跨实例的单机锁。 +`CryptographyProducer.TOP_NAME` 为 `cryptography`。具体 provider 的实现列表、target 名与 +私有配置参数归 `src/common/AGENTS.md` 与对应 feature 文档记录;本 spec 只固化 +接口、上下文和错误语义,安全侧的装配不变量见 S09。 ## 数据结构 @@ -187,19 +170,34 @@ DashScope Adapter 的 `params.enable_thinking` 由 Adapter 转换为 | `Segment` | type / content / asset_ref / metadata | 内容段 | | `Temporal` | t_event / t_ingest / t_valid / t_invalid | 时间字段 | | `Relation` | id / source_id / target_id / relation / weight / metadata | 关联关系 | -| `Scope` | org / space / user / agent / session | 作用域;非空 `space` 是全局唯一逻辑隔离标识,空值为兼容域且该字段为 keyword-only | +| `Scope` | org / space / user / agent / session | 作用域(frozen value object,`frozen=True`);非空 `space` 是全局唯一逻辑隔离标识,空值为兼容域且该字段为 keyword-only。改维度用 `dataclasses.replace`,不可原地修改(见不变量 10) | | `Context` | scope / max_tokens / extensions | 检索上下文 | | `Entity` | text / type / confidence | 实体 | | `FeatureSet` | keywords / entities / tags | 特征集合 | | `Chunk` | id / text / unit_id / metadata | 切分块 | | `ChatMessage` | role / content | LLM 对话消息 | | `RawPayload` | id / scope / modality / data / uri / metadata / occurred_at | 原始负载 | -| `FilterClause` | field / op / value | 原子过滤谓词;`EQ` / `IN` 正向匹配标量,`CONTAINS` 匹配数组成员,`NE` / `NOT_IN` 分别取反 | +| `FilterClause` | field / op / value | 原子过滤谓词 | | `FilterGroup` | logic / children | AND / OR / NOT 逻辑节点 | | `FilterExpr` | FilterClause \| FilterGroup | 跨 API、检索和存储层的过滤树 | | `matches_memory_unit` | `(MemoryUnit, FilterExpr \| None) -> bool` | retrieval 真源复核和 KV list 共用的 MemoryUnit 字段投影与过滤求值 | -| `AuditEvent` | id / timestamp / actor / target / action / target_id / layer / detail | 审计事件;`actor` 与 `target` 均为 Scope,支持 actor_* 与 target_* 字段过滤 | -| `SecurityContext` | scope / purpose / metadata | 一次加密/解密调用的安全上下文 | +| `AuditEvent` | id / actor / target / action / target_id / layer / decision / occurred_at / detail | 审计事件;`actor` 与 `target` 均为 Scope,支持 actor_* 与 target_* 字段过滤;`detail` 可承载 `acting_user` / `role` / `credential_id` / `auth_method` 等认证审计字段 | + +### 安全类型(`security/types.py`) + +请求身份与加密上下文归安全域,不再住 `type_def/`——`type_def` 是被所有层 import 的 +基础类型,安全类型放进去会让「谁能改身份」的边界消失。字段语义与不变量见 S09。 + +| 类型 | 关键字段 | 语义 | +|------|----------|------| +| `AuthContext` | actor / role / credential_type / credential_id / auth_method / credential_issuer / authenticated_at / expires_at / delegation_id | 认证层产出的可信身份(`frozen=True`);`credential_id` 是不可逆指纹,绝不是明文凭据 | +| `RequestSecurityContext` | auth / request_id / peer / surface / started_at / attributes | 一次请求的显式安全输入;由受控入口构造并逐层传到 PEP,ContextVar 不参与授权 | +| `CryptoContext` | scope / purpose / object_id / format_version / metadata | 一次加解密调用的安全上下文;前四项进 AAD | +| `Credentials` | api_key / headers / peer_address | 认证输入的原始凭据材料(`repr` 不打印敏感字段) | +| `Role` / `Surface` | 见 S09 | 服务端角色注册表与接入形态枚举 | +| `Action` / `DenyReason` | 见 S09 | 封闭安全动作与稳定拒绝原因码 | +| `ResourceDescriptor` / `AuthorizationEnvironment` | target/type/id/attributes;request_id/surface/peer/now/attributes | PDP 的显式资源与环境输入 | +| `Grant` / `Delegation` / `AuthorizationDecision` | 授权记录、委托记录、判定结果 | 授权真源记录与可审计 PDP 输出 | ### 枚举(`type_def/memory.py`) @@ -231,7 +229,8 @@ DashScope Adapter 的 `params.enable_thinking` 由 Adapter 转换为 | `EmbedderProducer` / `ChunkerProducer` / `TokenizerProducer` | `embedder` / `chunker` / `tokenizer` | | `IndexBuilderProducer` / `RecallerProducer` | `constructor` / `recaller` | | `NormalizerProducer` / `FeatureExtractorProducer` / `LlmProducer` / `RerankerProducer` | `normalizer` / `feature_extractor` / `llm` / `reranker` | -| `AuditProducer` / `SecurityProducer` / `LockProducer` | `audit` / `security` / `lock` | +| `AuditProducer` | `audit` | +| 安全域各 Producer(`SecurityRuntimeProducer` / `AuthProducer` / `KeyStoreProducer` / `AuthorizationProducer` / `GrantStoreProducer` / `DelegationStoreProducer` / `RateLimitProducer` / `WorkloadGuardProducer` / `BindingPolicyProducer` / `CryptographyProducer` / `KeyProviderProducer`) | 见 S09 | #### Factory 基类 @@ -292,9 +291,9 @@ def _build(config: ComponentConfig) -> Embedder: - `reset_all()` 清空缓存(隔离多次装配 / 测试隔离) 各 Producer 继承 `Factory`: -- `EmbedderProducer` / `ChunkerProducer` / `TokenizerProducer` / `NormalizerProducer` / `FeatureExtractorProducer` / `LlmProducer` / `RerankerProducer` / `AuditProducer` / `SecurityProducer` +- `EmbedderProducer` / `ChunkerProducer` / `TokenizerProducer` / `NormalizerProducer` / `FeatureExtractorProducer` / `LlmProducer` / `RerankerProducer` / `AuditProducer`,以及 `common/security/` 下的安全域 Producer(见 S09) -## 错误类型(`errors.py` / `security.py` / `lock.py`) +## 错误类型(`errors.py` / `security/cryptography/base.py`) | 异常 | 含义 | |------|------| @@ -304,10 +303,7 @@ def _build(config: ComponentConfig) -> Embedder: | `PolicyError` | 策略错误(未知键/不可变配置) | | `BackendError` | 后端不可用 | | `HealthCheckError` | 健康检查失败 | -| `SecurityError` / `EncryptionError` | 安全横切处理失败的基类 | -| `LockError` | 锁相关异常的基类 | -| `LockTimeoutError` | 有界等待耗尽仍未获得锁 | -| `LockLostError` | 租约续期失败、持有权已失效(由消费方按需抛出) | +| `CryptographyError` | 加密或解密处理失败的基类 | | `InvalidMagicError` | 密文字节不符合当前 provider 期望的信封魔数 | | `CorruptedCiphertextError` | 密文信封结构损坏、版本不支持或长度不完整 | | `AuthenticationFailedError` | AES-GCM tag 校验失败,通常表示 AAD 不匹配或内容被篡改 | @@ -317,13 +313,17 @@ def _build(config: ComponentConfig) -> Embedder: ``` src/common/<组件>/ - base.py | .py # 接口 + Producer(横切组件用 .py,如 security.py / lock.py) + base.py # 接口 + Producer <组件>_impl/ __init__.py # 重导出实现类 .py # 具体实现 + 尾部 @XxxProducer.register("name") ``` -注册由 `common.bootstrap.register_plugins` 统一触发。`security_impl/` 当前注册 `local` SecurityProvider 实现,`lock_impl/` 注册 `redis` 与 `memory` 两个 LockProvider 实现。 +安全能力多一层能力域:`src/common/security/<能力域>/{base.py, <能力域>_impl/}`。 + +注册由 `common.bootstrap.register_plugins` 统一触发(安全域的注册入口是 +`common.security.bootstrap.register_security`)。`cryptography_impl/` 当前注册 `local` +CryptographyProvider 与 `local` KeyProvider。 ## 与其它 spec 的关系 @@ -334,5 +334,5 @@ src/common/<组件>/ | S04-retrieval | 检索层消费 Embedder/Tokenizer/FeatureExtractor/LLM/Reranker | | S05-construction | 构建层消费 Chunker/Embedder/Tokenizer/FeatureExtractor/LLM | | S06-storage | 存储层依赖本层的数据类型定义(Scope/FilterClause 等) | -| S08-config | 插件晚绑定 model/api_key/url 等由 ConfigSource 提供;装配拓扑仍走 Factory | +| S09-security | 约束认证、授权、保护、密码学 capability,配置选择与启动安全不变量 | | architecture.md 全文 | 本层承载全局共享的数据类型与工具 | diff --git a/docs/specs/S09-security.md b/docs/specs/S09-security.md new file mode 100644 index 00000000..850eee9b --- /dev/null +++ b/docs/specs/S09-security.md @@ -0,0 +1,246 @@ +# S09 — 安全横切契约(Security) + +## 元信息 + +| 项 | 值 | +|---|---| +| 关联模块 | `src/common/security/`、`bootstrap/`、`src/api/`、`src/storage/` | +| 最近一次修订日期 | 2026-08-07 | +| 关联特性文档 | `docs/features/common/F04-security-interfaces-and-encryption.md`,`docs/features/common/F07-authentication-kernel.md`,`docs/features/common/F08-authorization-context.md` | + +## 范围 / 边界 + +本规约定义请求认证、主体凭据存储、授权判定、资源保护(限流 / 并发预算 / 绑定策略)、 +静态加密配置与安全运行期装配的不变量。审计完整性由对应审计特性扩展。 + +安全能力统一归属 `src/common/security/`,按能力域分子包: + +| 子包 | 承载 | +|---|---| +| `authentication/` | `Authenticator`、`PrincipalKeyStore` 与三个内置实现 | +| `authorization/` | `Authorizer`(PDP)、`GrantStore`、`DelegationStore`、`scope_rules` | +| `cryptography/` | `CryptographyProvider`、`KeyProvider`、ENC1 本地信封实现 | +| `protection/` | `RateLimiter`、`WorkloadGuard`、`BindingPolicy` | +| `types.py` | `AuthContext`、`RequestSecurityContext`、`CryptoContext`、`Role`、`Surface`、`Credentials`、`Action`、`ResourceDescriptor`、`AuthorizationEnvironment`、`DenyReason` | +| `request_context.py` | `RequestSecurityContext` 的受控构造入口:`new_request_context` / `internal_context` | +| `runtime.py` | `SecurityRuntime`:持有能力引用、启动期健康检查、统一生命周期 | +| `key_source.py` | 外部密钥源的兼容抽象接缝;当前不进入 `KeyProviderProducer` 注册装配链 | + +`audit_integrity/` 由后续 PR 补齐。 + +> 历史状态:这些能力此前平铺在 `src/common/authentication/`、`credential_store/`、 +> `admission/`、`encryption/` 与 `type_def/auth.py`。那是迁移前的目录形态,不再作为 +> 新代码的约束——新增安全能力一律落 `src/common/security/<能力域>/`。 + +## 不变量 + +### 身份与认证 + +1. 请求身份只由认证中间件产生,客户端 payload 中的身份字段不是可信身份来源。 +2. `Authenticator.authenticate` 成功返回 `AuthContext`,失败抛 `AuthenticationError`;不得返回默认身份。 +3. `Scope` 与 `AuthContext` 是 frozen value object,认证后不得原地改写身份或隔离维度。 +4. `Authenticator.mode()` 返回**开放字符串**而非封闭枚举。核心不得按该值分支——需要 + 分支的行为差异必须由 capability 方法显式声明,第三方实现无需改核心即可接入。 + +- **凭据在线复核**:`PrincipalKeyStore.is_revoked` 是可撤销凭据的契约方法; + `ApiKeyAuthenticator` 在认证期校验其已覆盖(第三方缺实现即时失败,不等到首个授权请求 + 500)。PEP 持有 `CredentialStatusRegistry`,按 `(credential_type, credential_issuer)` + 复合键路由到发证 Store,在每次授权前复核 `AuthContext` 未撤销--撤销前缓存的上下文撤销后 + 立即失效。内联认证器由 `Authenticator.bind_instance_name()` 接收 Runtime 派生的稳定 issuer; + 显式具名认证器保留其配置名。有 `credential_id` 但 issuer 未注册时必须 fail-closed; + `AuthContext` 保持纯数据值对象,撤销复核不进值对象。 + +- **请求上下文受控构造**:`RequestSecurityContext` 的 `_origin` 是进程随机密钥签发的 + HMAC-SHA256 来源证明,Canonical JSON 绑定完整 `AuthContext` 及 request id、surface、peer、 + started_at、attributes。PEP 调 `has_valid_origin()` 校验;直接构造或经 `dataclasses.replace` + 改任一安全字段都会失配。该机制只防跨进程伪造,不承诺抵御可调用受控入口的恶意同进程代码; + 不可信插件必须用进程隔离或 capability 边界处理。 + +### 依据 capability 做安全决策 + +5. `Authenticator.requires_loopback_binding()` 默认返回 `True`。只有实现显式声明可远程 + 暴露,surface 才能绑定非 loopback 地址。 +6. `requires_concurrency_guard()` 默认返回 `True`;轻量实现必须显式返回 `False` 才能跳过 + 并发预算。 +7. 持久化、原子写、密钥轮换、分布式限流等能力必须由类型或 capability 显式声明。禁止通过 + `target == "sqlite"`、类名后缀或配置路径推测安全保证。`WorkloadGuard.supports_distributed_budget()` + 是这条的一个实例:进程内实现返回 `False`,多副本部署据此判断实际并发是 N 倍。 + +### 资源保护 + +8. 绑定约束由 `BindingPolicy.check(hosts, *, requires_loopback)` 在实际 socket 绑定前执行, + 不能只存在于某个 CLI `main()`。`requires_loopback` 是 keyword-only,位置传参会让放宽 + 在调用点看不出来。 +9. 限流在 `authenticate` **之前**执行:认证本身就是要保护的资源。 +10. 密码哈希、密钥派生与全量完整性验证等昂贵操作使用独立的全局并发预算。预算耗尽时快速 + 拒绝(429),不得无界排队——排队只是把资源耗尽从 CPU/内存转移到线程和请求队列。 + +### 密码学 + +11. 密码学能力只能通过 `KeyProvider` 获取密钥,不能直接读取环境变量或配置文件中的根密钥。 +12. 信封至少包含 magic、格式版本、algorithm id、**key id 与 key epoch**、nonce、ciphertext 与 + authentication tag。AAD 必须绑定规范化 Scope、存储用途、对象标识和格式版本。 +13. 不提供隐式明文回退:要求加密的数据不是合法信封时拒绝读取;解密失败不得返回原始 bytes; + 是否允许未加密存储由上层显式选择不同的存储适配器表达,同一个加密适配器内部不存在 + `allow_plaintext` 降级开关。 + +### 装配 + +14. YAML 只选择已注册 target 并传递 params,不接受 Python import path 或任意类加载。 +15. 任一安全顶层段存在多个具名实例时必须定义 `default`,否则拒绝启动。 +16. 实现只依赖能力接口,不 import 其他实现目录;注册在装配前统一完成。 +17. `SecurityRuntime` 只持有能力引用、执行启动期健康检查并暴露统一生命周期,不实现认证、 + 授权或密码学算法。能力不健康必须在启动期拒绝,不能等第一个请求打进来才在 500 里暴露。 +18. 运行期共享状态(并发预算、限流桶)通过**具名实例**显式共享,不靠模块级单例。 +19. `SecurityRuntime` 不为后续 PR 预留恒为 `None` 的占位字段——那会诱导消费方写 + `if runtime.authorizer:` 的 fail-open 分支。 +20. 健康检查不泄露 key、token 或主体存在性。 + +### 授权 + +21. ROOT 权限只由可信 `AuthContext.role` 判定,不能由空 actor 或请求参数隐式推导。空 + `Scope()` actor 是「上下文不完整」的信号,PDP 对它直接拒绝。 +22. agent 代操作必须同时满足同 org、明确的委托目标与授权侧委托规则;不得覆盖其他 user + 或 agent 分支。委托关系只来自服务端 `DelegationStore`,请求里带的委托声明不可信。 +23. 授权判定的调用形态演进必须保持 fail-closed 兼容,路由实现不得丢失角色上下文。 +24. **PDP 输入封闭**:`Authorizer.authorize` 固定接收 `AuthContext + ResourceDescriptor + + AuthorizationEnvironment` 三个 keyword-only 参数,**不读 ContextVar**,也不读存储真源。 + 资源的安全 metadata 由 PEP 从真源解析后摊平成 `ResourceDescriptor`,请求 metadata + 不能覆盖它。 +25. **PDP 不抛异常表达拒绝**:返回 `AuthorizationDecision`(`allowed` + 稳定 `reason` code + + `rule`),allow 与 deny 两侧都必须标明是哪条规则做的判定。存储不可用等真实故障仍然 + 抛——不能把故障静默成 deny,PEP 需要区分 403 与 503。 +26. **唯一 PEP**:授权执行点只有 `api.MemoryAPI`,其所有公开 verb 经同一个 `_authorize`。 + 不存在第二条能绕开它的授权入口,`SecurityRuntime` 也不代为转发。 +27. **没有安全上下文就进不了 API**:`security: RequestSecurityContext` 是所有公开 verb 的 + 必填参数。不存在 `auth=None` 分支,不存在空 `Scope()` 自动管理员,业务 payload 不接受 + `actor` / `role` / `acting_user`。 +28. **默认拒绝**:未被 owner 覆盖、Delegation、Grant 或角色闸门覆盖的动作一律拒绝 + (`reason=NOT_COVERED`)。新增 `Action` 在写出对应规则前默认落在拒绝侧。 +29. **委托是显式 allowlist**:可委托动作见 `common.security.types.DELEGATABLE_ACTIONS`, + 不含 `SHARE` 与管理动作——否则一次临时委托可升级成永久 Grant。 +30. **管理面按角色分级**:`MANAGE_PRINCIPAL` / `MANAGE_SPACE` / `MANAGE_POLICY` / + `READ_AUDIT` 要求 ADMIN 及以上,`VERIFY_AUDIT` / `ADMINISTER_SYSTEM` 要求 ROOT。 + ADMIN 的管辖止于本 org;无 org 归属的系统级资源(全局治理策略、跨 org 审计)只有 + ROOT 能碰,且其拒绝原因是 `ROLE_REQUIRED` 而非 `CROSS_ORG`——reason code 是审计与 + 告警的匹配依据,指错方向会把排查引向配置而非权限。 +31. **恒放行实现按 capability 拦截**:`Authorizer.is_test_only()` 为真的实现在生产装配被 + 拒绝启动,要用必须显式打开 `globals.allow_test_only_security`。判据是 capability 而非 + `target == "allow_all"`(不变量 7)。 + +### RequestSecurityContext 的构造 + +32. `RequestSecurityContext` 只能由 `common.security.request_context` 的两个入口构造: + `request_id` 由服务端生成(不接受调用方传入)、`started_at` 取服务端时钟、`surface` + 无默认值必须由适配层显式写入、`peer` 经可信代理规则规范化(无可信代理白名单时只采信 + 传输层地址,不读 `X-Forwarded-For`)、`attributes` 只由系统组件写入。 +33. **进程内调用与外部请求使用同一契约**:进程内直连调用方走 + `internal_context(authenticator)`,authenticator 必填且身份仍由它产出,调用方不能自行 + 声明身份。不允许把传入的 `Scope` 直接包装成已认证 actor,也不允许无参领取 ROOT。 +34. ContextVar(`common.security.types` 的 `set_current` / `get_current` / `reset_current`) + 降级为日志与 trace 的辅助传播:`Authorizer` 与 PEP 均不得依赖其存在,缺失它不改变任何 + 授权结论。 + +## 注册与配置 + +每个能力目录的顶层 `.py` 定义抽象接口和 Producer,`*_impl/` 中的实现通过 +`@Producer.register("target")` 注册,`common.bootstrap.register_plugins()` 在配置解析前统一触发。 + +实现模块必须在配置解析前由 `common.bootstrap.register_plugins()` 或应用自己的注册入口 +import,注册装饰器才会生效。当前核心不自动发现任意外部 Python 包;外部插件应由宿主应用 +在 `Server.build` / `build_kernel` 前显式加载。 + +顶层段名:`security`、`authenticator`、`key_store`、`authorizer`、`grant_store`、 +`delegation_store`、`rate_limiter`、`workload_guard`、`binding_policy`、`cryptography`、 +`key_provider`。 + +```yaml +security: + default: + target: standard + params: + authenticator: default # 必填,无默认实现 + authorizer: default # 省略时引用具名实例 authorizer.default + rate_limiter: default + workload_guard: shared_budget # 具名引用 = 跨 surface 共享同一份预算 + binding_policy: loopback # 省略时按 target 名取默认实现 + cryptography: default # 可选;不配则 SecurityRuntime 不持有密码学能力 +authenticator: + default: + target: api_key + params: + key_store: default + root_api_key: ${ROOT_API_KEY} +key_store: + default: + target: memory +authorizer: + default: + target: standard + params: + grant_store: default # 两个 Store 都无默认实现 + delegation_store: default +grant_store: + default: + target: memory +delegation_store: + default: + target: memory +rate_limiter: + default: + target: token_bucket + params: + capacity: 30 + refill_per_sec: 10 +workload_guard: + shared_budget: + target: semaphore + params: + max_concurrent: 4 +cryptography: + default: + target: local + params: + key_provider: default +key_provider: + default: + target: local + params: + key_env: AGENT_MEMORY_ENCRYPTION_ROOT_KEY + key_file: ~/.agent-memory/security/master.key + key_epoch: 1 +``` + +`security.params.authenticator` 无默认:给认证一个默认会让「忘了配认证」静默变成某种可用 +配置。`authorizer` 的默认是**具名实例** `authorizer.default`(不是匿名新建)——内核装配 +`api.memory_api_impl.build_kernel` 已经建过它并注入了 PEP,Factory 的具名缓存类级共享, +故 `SecurityRuntime` 命中的是同一个实例。健康检查若检查的是另一份持有另一套 +Grant/DelegationStore 的 authorizer,给出的是虚假保证。代价是**装配顺序**: +`SecurityRuntime` 必须在 `build_kernel` 之后建;独立装配(如单测)须在 `security.params` +里显式给出 `authorizer`。 + +其余能力的默认取保守侧,且默认值本身由 capability 决定而非 target 名——认证声明 +`requires_loopback_binding()` 时限流默认 `unlimited`(无远端攻击面),否则默认 `token_bucket`。 + +未配置 `security` 段时回落 DEV 并告警。回落到 DEV 而非拒绝启动是刻意的:它把「无认证」 +从隐式且不可改,变成显式、可切换、且非 loopback 时由 `BindingPolicy` 拒绝启动。 + +## 当前扩展边界 + +- `Authenticator`、`PrincipalKeyStore`、`Authorizer`、`GrantStore`、`DelegationStore`、 + `RateLimiter`、`WorkloadGuard`、`BindingPolicy`、 + `CryptographyProvider`、`KeyProvider` 均可通过 Producer 注册扩展。 +- `GrantStore` 与 `DelegationStore` 是两个独立 Producer:授权记录与委托记录的生命周期、 + 撤销语义与保留期都不同,共用一个后端会让「撤销一次委托」和「回收一条永久授权」走同一 + 条代码路径。 +- `KeyProvider` 是独立 Producer:换 KMS / Vault 不必改加密实现。 +- Server 按 capability 决策绑定和并发保护,不按封闭枚举分支。 +- 认证根装配消费一个最终实例;需要多认证串联时,应注册组合 target,由该 target 通过 + `Producer.dep()` 引用多个具名实例,而不是让 YAML 隐式并行执行。 +- EncryptedKVStore / EncryptedFSStore 只负责存储边界接线,密码学实现归 `common/security/cryptography`。 +- `FsProducer` 已能独立装配 FSStore,但当前 `build_kernel` 主业务链路没有 FSStore 消费点; + 仅写 YAML 不会自动让记忆资产经过 FS 加密,接入前须先定义资产写入/读取消费者和 API 契约。 +- 内置 `LocalKeyProvider` 支持多代轮换:`rotate()` 生成新随机根密钥并推进 epoch,旧 epoch + 根密钥保留在进程内字典供 `unwrap` 解开历史信封(写出一律 v2 信封,v1 只读兼容)。新根 + 密钥**不持久化**--进程重启回到配置声明的初始密钥,轮换后写入的信封在重启后不可读; + 需要跨重启保留轮换状态应换 KMS/Vault,由其管理历史 epoch 验证材料。 diff --git a/evaluation/core/harness.py b/evaluation/core/harness.py index ed3d8178..1f2dff98 100644 --- a/evaluation/core/harness.py +++ b/evaluation/core/harness.py @@ -14,6 +14,10 @@ from typing import Dict, List, Optional from api.memory_api_impl import build_kernel +from common.security import internal_context +from common.security.authentication.authentication_impl.dev_authenticator import ( + DevAuthenticator, +) from common.type_def import Context from config.config import Config @@ -26,6 +30,9 @@ class EvalHarness: def __init__(self, config: Optional[Config] = None) -> None: self._kernel = build_kernel(config=config) self._api = self._kernel.api + # 评测走 MemoryAPI 公共面,安全契约与外部请求相同(F05 §进程内调用): + # 身份由认证能力产出,数据集里的 scope 只表达资源归属。 + self._security = internal_context(DevAuthenticator()) self._key2ids: Dict[str, List[str]] = {} def ingest(self, seeds: List[MemorySeed]) -> None: @@ -34,7 +41,7 @@ def ingest(self, seeds: List[MemorySeed]) -> None: units = self._api.write( seed.content, seed.scope, - identity=seed.scope, + security=self._security, tags=list(seed.tags), metadata=dict(seed.metadata), occurred_at=seed.occurred_at, @@ -46,7 +53,7 @@ def run_query(self, case: QueryCase) -> CaseOutcome: result = self._api.recall( case.text, Context(case.scope), - identity=case.scope, + security=self._security, filters=list(case.filters) or None, as_of=case.as_of, top_k=case.top_k, diff --git a/examples/quickstart.py b/examples/quickstart.py index 7497943f..a5220b3b 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -15,6 +15,10 @@ import os from api import assemble +from common.security import internal_context +from common.security.authentication.authentication_impl.dev_authenticator import ( + DevAuthenticator, +) from common.type_def import Context, Scope from config import Config from construction import EvolveMode @@ -40,7 +44,9 @@ def main() -> None: logger.info("[config] 无用户配置,按内置默认装配(纯内存离线)") api = assemble(config=config) scope = Scope(org="acme", user="alice", agent="assistant", session="s1") - actor = scope # 本人操作自己的 scope + # 身份由认证能力产出,不由脚本声明(F05 §进程内调用):`scope` 只说「操作哪个 + # 范围」,`security` 才说「谁在操作」。进程内直连缺省是 dev 认证(恒 ROOT)。 + security = internal_context(DevAuthenticator()) # 1) write --------------------------------------------------------------- facts = [ @@ -50,20 +56,20 @@ def main() -> None: ] written_ids = [] for f in facts: - units = api.write(f, scope, identity=actor, tags=["demo"]) + units = api.write(f, scope, security=security, tags=["demo"]) written_ids.append(units[0].id) logger.info("[write] %s <%s...>", units[0].id[:8], f[:24]) # 2) recall -------------------------------------------------------------- logger.info("\n[recall] query='咖啡 早上'") - res = api.recall("咖啡 早上", Context(scope), identity=actor, top_k=3, with_trajectory=True) + res = api.recall("咖啡 早上", Context(scope), security=security, top_k=3, with_trajectory=True) for item in res.items: logger.info(" score=%.3f %s %s", item.score, item.unit_id[:8], item.content) logger.info(" trajectory: %s", [(s.stage, s.candidate_count) for s in res.trajectory]) # 3) get(tier 由构建层 Classifier 在写入时判定:含「喜欢」→ semantic) ---- first = written_ids[0] - got = api.get(first, scope, identity=actor) + got = api.get(first, scope, security=security) logger.info( "\n[get] %s tier=%s tags=%s content=<%s>", first[:8], @@ -74,32 +80,32 @@ def main() -> None: # 4) update(SUPERSEDE,记版本链) --------------------------------------- new_unit = api.update( - first, scope, MemoryPatch(content="Alice 改喝拿铁了,要加燕麦奶。"), identity=actor + first, scope, MemoryPatch(content="Alice 改喝拿铁了,要加燕麦奶。"), security=security ) logger.info( "\n[update] %s -> %s supersedes=%s", first[:8], new_unit.id[:8], new_unit.supersedes[:8] ) - chain = api.trace(new_unit.id, scope, identity=actor) + chain = api.trace(new_unit.id, scope, security=security) logger.info(" trace chain: %s", [u.id[:8] for u in chain]) # 4.5) evolve(构建层闭环:抽取低抽象事实 / 升华画像 / 遗忘被取代的旧版) -- q = "咖啡 项目 评审" - before = len(api.recall(q, Context(scope), identity=actor, top_k=20).items) - api.evolve(scope, EvolveMode.EXTRACT, identity=actor) # Extractor:派生事实(记血缘) - api.evolve(scope, EvolveMode.CONSOLIDATE, identity=actor) # Abstractor:升华 CORE 画像 - api.evolve(scope, EvolveMode.ASSOCIATE, identity=actor) # Associator:发现关联 - api.evolve(scope, EvolveMode.FORGET, identity=actor) # 清理 superseded 旧版 - after = len(api.recall(q, Context(scope), identity=actor, top_k=20).items) + before = len(api.recall(q, Context(scope), security=security, top_k=20).items) + api.evolve(scope, EvolveMode.EXTRACT, security=security) # Extractor:派生事实(记血缘) + api.evolve(scope, EvolveMode.CONSOLIDATE, security=security) # Abstractor:升华 CORE 画像 + api.evolve(scope, EvolveMode.ASSOCIATE, security=security) # Associator:发现关联 + api.evolve(scope, EvolveMode.FORGET, security=security) # 清理 superseded 旧版 + after = len(api.recall(q, Context(scope), security=security, top_k=20).items) logger.info( "\n[evolve] 召回命中 %s -> %s(extract 派生 + consolidate 画像入索引)", before, after ) - prof = api.recall("画像综合", Context(scope), identity=actor, top_k=1).items + prof = api.recall("画像综合", Context(scope), security=security, top_k=1).items if prof: logger.info(" consolidate 画像 %s: <%s...>", prof[0].unit_id[:8], prof[0].content[:36]) # 5) admin + audit ------------------------------------------------------- - logger.info("\n[admin] policies: %s", api.admin_all(identity=actor)) - logger.info("[audit] write 事件数: %s", len(api.audit({"action": "write"}, identity=actor))) + logger.info("\n[admin] policies: %s", api.admin_all(security=security)) + logger.info("[audit] write 事件数: %s", len(api.audit({"action": "write"}, security=security))) if __name__ == "__main__": diff --git a/pyproject.toml b/pyproject.toml index 4ea18f31..44d3b30a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,12 @@ mcp = [ "mcp>=1.2", "pyyaml>=6", ] +# 认证能力(src/common/credential_store):API Key 的 Argon2id 哈希。标准库无 Argon2id;缺依赖时在 +# 装配期抛 ValidationError,绝不降级为明文比对。 +# 静态加密所需的 cryptography 已在主依赖里(common.encryption 用)。 +security = [ + "argon2-cffi>=23.1", +] [dependency-groups] dev = [ @@ -66,6 +72,7 @@ include = [ "control*", "ingest*", "retrieval*", + "security*", "storage*", "agent_plugin*", ] diff --git a/src/AGENTS.md b/src/AGENTS.md index 05f3d9b5..d5c26666 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -9,9 +9,9 @@ ``` src/ -├── api/ # 接口层:统一 Core API(write/recall/get/update/delete/evolve/admin),形态无关 -├── common/ # 跨层共享插件(Tokenizer/Chunker/Embedder/FeatureExtractor/LLM/Normalizer/Reranker)+ type_def/ -├── config/ # 配置加载/合并 + ConfigSource 晚绑定(见 S08) +├── api/ # 接口层:统一 Core API(write/batch_write/recall/get/update/delete/evolve/admin),形态无关 +├── common/ # 跨层共享插件、security/(认证/资源保护/密码学)、审计 + type_def/ +├── config/ # 配置加载与校验(待实现) ├── construction/ # 构建层:落盘 + 多形式索引构建 + 自演进闭环 ├── control/ # 编排层:MemoryEngine 跨层编排中枢 + Scheduler/Permission/Policy/Governance/Space ├── ingest/ # 接入层:多模态 → 文本投影 + MemoryUnit,不落盘 @@ -62,11 +62,10 @@ src/ ### common/ — 共享插件 + 类型 -七个无状态插件协议(构建侧与检索侧必须共用同一实例)。`type_def/` 定义跨层数据类型:`MemoryUnit`(原子载体)、`Scope`(隔离模型)、`FilterClause`、`AuditEvent` 等。`errors.py` 统一异常体系。 - -### config/ — 配置层 - -装配配置(`Config` / `defaults` / `AssemblyContext`)与运行时晚绑定来源 `ConfigSource`(默认 yaml_defaults;可换 dict/overlay)。`*.active` 多实例切换与 prompt 晚绑定走 ConfigSource;已知策略键仍走 `PolicyManager`。 +共享插件协议与横切能力均采用 `base.py + *_impl + Producer`,由 YAML 选择已注册 target。 +安全能力(认证、凭据存储、资源保护、密码学)统一归 `common/security/`,其请求身份与 +加密上下文类型住 `security/types.py`;`type_def/` 定义 `MemoryUnit`、`Scope`、 +`AuditEvent` 等跨层类型,`errors.py` 统一异常体系。 ## 架构铁律 diff --git a/src/api/AGENTS.md b/src/api/AGENTS.md index c66750e1..b9973192 100644 --- a/src/api/AGENTS.md +++ b/src/api/AGENTS.md @@ -13,18 +13,18 @@ | `memory_api.py` | MemoryAPI 抽象接口:统一语义定义(write/batch_write/recall/list/get/update/delete/evolve/admin/inspect/trace/audit/grant/revoke/space 管理) | | `memory_api_impl/` | 具体实现目录 | | `memory_api_impl/assembly.py` | 装配入口:`build_kernel(config)` 递归构建 MemoryAPI 实例 | -| `memory_api_impl/local_memory_api.py` | LocalMemoryAPI:委托 Engine/Governor/Scheduler/PermissionManager/SpaceManager + PEP 鉴权 | +| `memory_api_impl/local_memory_api.py` | LocalMemoryAPI:委托 Engine/Governor/Scheduler/SpaceManager + PEP 鉴权(调 `common.security.authorization` 的 Authorizer 作 PDP) | ## 行为铁律 1. **本层不做编排** `MemoryAPI` 只做三件事:鉴权(PEP)、参数装配、委托。编排逻辑(write 路径、recall/list 路径、evolve 调度)全部在 `control/MemoryEngine`,禁止在本层堆业务逻辑。 -2. **identity 不下沉** - 鉴权通过后只透传已鉴权的 target `scope`,`identity` 参数不传入控制层/检索层/构建层/存储层。 +2. **security 不下沉** + 鉴权通过后只透传已鉴权的 target `scope`,`security`(`RequestSecurityContext`)不传入控制层/检索层/构建层/存储层。 3. **recall 参数拆分在本层边界** - `recall(query, context, *, identity, ...)` 中的 `context: Context` 在本层拆开: + `recall(query, context, *, security, ...)` 中的 `context: Context` 在本层拆开: - `context.scope` 作独立轴穿透到 Engine - `context.extensions["max_tokens"]` 由 API 边界解析为 `RetrievalQuery.max_tokens` - 其余 `context.extensions` 写入 `RetrievalQuery.extensions` @@ -32,14 +32,17 @@ 4. **admin_* 不经 Engine** `admin_get/set/all` 直达 `PolicyManager`,不经过 `MemoryEngine`(Engine 中对应方法抛 NotImplementedError)。 -5. **写入同步/异步桥接** - `write` / `batch_write` 分别桥接对应协程入口;batch 在本层逐项归一化、鉴权、space 校验和审计后委托 Engine,默认按输入顺序返回 partial-success outcomes。 +5. **write/write_async 分离** + `write` 是同步桥接(内部 `asyncio.run(write_async)`),供 CLI/脚本使用;`write_async` 直通 Engine 协程,供事件循环形态使用。 + + `batch_write` / `batch_write_async` 同样只保留一套异步实现;每个归一化 item 独立经过 + PEP,随后只把已鉴权的 `BatchWriteItem` 交给 Engine,`security` 不下沉。 6. **space 必须在 API 边界执行策略校验** `scope.require_space=true` 时,具体 target scope 缺少 `space` 的数据面/治理面操作必须在 `LocalMemoryAPI._authorize` 拒绝并记录 deny audit;`Scope()` 根管理面与 org 级 `list_spaces/create_space` 鉴权目标不受此策略影响。 7. **space policy 在 API 边界注入权限上下文** - 已创建 space 的 `principal_path` 由 `SpaceManager.get_policy` 提供,`LocalMemoryAPI._authorize` 在调用 `PermissionManager.check` 前写入 `PermissionContext.metadata["principal_path"]`;调用侧 metadata 不覆盖 space policy。 + 已创建 space 的 `principal_path` 由 `SpaceManager.get_policy` 提供,`LocalMemoryAPI._authorize` 在构造 `ResourceDescriptor` 前写入 `PermissionContext.metadata["principal_path"]`,随后摊平为 descriptor 属性交给 Authorizer;调用侧 metadata 不覆盖 space policy。 8. **list 对实际返回资源逐条鉴权** 请求级 `memory_types` 鉴权通过后,API 必须调用 Engine 的 @@ -54,14 +57,49 @@ ## PEP 鉴权流程 ``` -MemoryAPI.method(scope=target, identity=caller) +MemoryAPI.method(scope=target, security=RequestSecurityContext) → 构造 PermissionContext(write/recall/list 请求条件来自入参;list 实际 unit 与 get/update/delete 来自 Engine 真源元数据) - → PermissionManager.check(actor=identity, target=scope, action=<对应动作>, context=...) - → 通过 → 委托 Engine/Governor/PolicyManager(仅传 scope,不传 identity) - → 拒绝 → 抛 PermissionDeniedError - → 落审计事件(含 identity + action + target_id + 时间) + → 摊平成 ResourceDescriptor(action + resource_type + scope + resource_id + attributes) + → 由 security 派生 AuthorizationEnvironment.from_request(security, now=<服务端时钟>) + → Authorizer.authorize(auth=security.auth, resource=..., environment=...) + → allow → 委托 Engine/Governor/PolicyManager(仅传 scope,不传 security) + → deny → 抛 PermissionDeniedError,并落 deny audit(含 DenyReason code + rule) + → 落审计事件(含 security.actor + action + target_id + 时间) ``` +`security` 是本层**唯一**的安全输入,由调用方(surface 适配层或进程内受控入口)显式 +传入——`Authorizer` 与本层都不读 ContextVar。ContextVar 里仍有一份 `AuthContext`, +但只供日志/trace 关联,缺失它不影响授权结论、存在它也不能替代 `security` +(F05 §RequestSecurityContext)。 + +`security.auth` 携带 `role` 这个 Scope 推不出来的东西(§3.1)。actor 由认证层产出, +业务 payload 不接受 `actor` / `role` / `acting_user`——`Authorizer` 决策第 2 步会校验 +actor 一致性,空 `Scope()` 直接拒(它是「上下文不完整」的信号,不是任何一种权限)。 +代操作不在请求里表达:委托关系来自服务端的 `DelegationStore`,由 +`common.security.authorization` 的 Authorizer 按 `delegation_id` 复核。 + +管理面方法(`admin_*` / 全局 `audit`)除以根 scope 为 target 外,还须携带 +`resource_type`(`admin` / `audit`),使「这是系统级操作」显式可读,而不是从 +「target 恰好是空 scope」反推。ROOT 权限同样只由 `role` 表达。 + +### 没有 `security` 就进不了 API + +`security: RequestSecurityContext` 是所有公开 verb 的**必填 keyword-only 参数**,没有 +`auth=None` 分支、没有「空 `Scope()` 即 platform admin」的旁路——两者都在 PR2 删除。 +非请求场景(`build_kernel` 直连、评测 harness、示例脚本、单测)与外部请求使用**同一 +契约**,通过 `common.security.request_context` 的受控入口取得上下文: + +- `new_request_context(auth, *, surface, peer, attributes)`——给已完成认证的 surface; +- `internal_context(authenticator)`——给进程内直连调用方,authenticator 必填,身份仍由 + 它产出;调用方**不能自行声明身份**,也不存在无参领取 ROOT 的隐式默认。 + +构造规则收在这一处:`request_id` 由服务端生成、`started_at` 取服务端时钟、 +`attributes` 只由系统组件写入(业务 payload 一律不得注入)、`surface` 无默认值必须由 +适配层写入。HTTP / MCP / CLI 三个 surface 经 +`bootstrap.core.auth_middleware.authenticated` 调它,各自传入自己的 `Surface`。新增 +surface 必须沿用同一中间件——它同时承载凭据归一、限流、并发预算与入口审计,绕开它 +等于把请求降级成无认证。 + ## 与其他子目录的边界 **本模块管**: @@ -79,7 +117,7 @@ MemoryAPI.method(scope=target, identity=caller) ## 本地约束 -1. `identity` 为必填 keyword-only 参数,与 `scope` 同为 Scope 类型,强制具名传入防止位置传反。 +1. `security` 为必填 keyword-only 参数,类型是 `RequestSecurityContext`(不是 `Scope`)——与 target `scope` 类型不同,位置传反会在类型层暴露。 2. 所有数据面方法(write/batch_write/recall/list/get/update/delete/evolve)都需要鉴权,治理面(inspect/trace/audit)也需要鉴权。 3. 装配由 `assembly.build_kernel(config)` 完成,递归调用各 Producer.create_from(spec)。 4. 实现类(LocalMemoryAPI)不对外暴露,外部只依赖 `MemoryAPI` 抽象接口。 diff --git a/src/api/memory_api.py b/src/api/memory_api.py index 7937312c..51e7b48a 100644 --- a/src/api/memory_api.py +++ b/src/api/memory_api.py @@ -8,8 +8,9 @@ (任务状态、血缘/审计、跨 scope 授权)直达对应控制算子 (:class:`~control.scheduler.Scheduler` / :class:`~control.governance.Governor` / :class:`~control.permission.PermissionManager`)——只做参数装配与鉴权, -编排逻辑全部在 ``src/control``。调用层(SDK/CLI/MCP 等)只依赖本包即可触达 -全部对外能力,无需 import 内核其他包。 +编排逻辑全部在 ``src/control``;授权判定归 +:class:`~common.security.authorization.Authorizer`(F05 §Authorization)。 +调用层(SDK/CLI/MCP 等)只依赖本包即可触达全部对外能力,无需 import 内核其他包。 """ from __future__ import annotations @@ -18,6 +19,7 @@ from datetime import datetime from typing import Any +from common.security.types import RequestSecurityContext from common.type_def import ( AuditEvent, Context, @@ -29,13 +31,13 @@ ) from construction import EvolveMode from control import ( + BatchWriteItem, + BatchWriteResult, Channel, DeleteMode, DeleteSelector, Grant, JobInfo, - BatchWriteItem, - BatchWriteResult, MemoryListResult, MemoryPatch, SpaceDeleteResult, @@ -53,16 +55,23 @@ class MemoryAPI(ABC): """统一记忆接口(§9 语义,不含 link——关联由构建层 Associator 在演进中维护)。 - **鉴权与审计的执行点(PEP)在本层**:每个涉及租户数据/治理的方法都收 - ``scope``(操作的目标范围 target)与 ``identity``(调用方身份)两个 Scope。 - 本层先构造权限上下文并调用 ``PermissionManager.check(identity, scope, action, - context=...)``,不通过即抛 :class:`~common.errors.PermissionDeniedError` - (适用于下列所有方法,各方法不再重复说明);通过后才委托 - :class:`~control.engine.MemoryEngine`,且仅透传已鉴权的 target ``scope`` - (identity 不下沉,下游信任 target);同时在本层落带 identity 的入口审计事件。 - - ``identity`` 一律为**必填 keyword-only** 参数:它与 target ``scope`` 同为 - Scope 类型,强制具名传入可杜绝二者位置传反导致的越权。 + **鉴权与审计的执行点(PEP)在本层,且本层是唯一的业务 PEP**:每个涉及租户 + 数据/治理的方法都收 ``scope``(操作的目标范围 target)与 ``security`` + (本次请求的安全上下文)。本层把 verb 映射为封闭的 + :class:`~common.security.types.Action`、从真源构造 + :class:`~common.security.types.ResourceDescriptor`、派生 + :class:`~common.security.types.AuthorizationEnvironment`,再调用 + :class:`~common.security.authorization.Authorizer`;不通过即抛 + :class:`~common.errors.PermissionDeniedError`(适用于下列所有方法,各方法不再 + 重复说明);通过后才委托 :class:`~control.engine.MemoryEngine`,且仅透传已鉴权 + 的 target ``scope`` 与业务参数(调用方身份不下沉,下游信任 target);同时在本层 + 落带 actor 与稳定决策标识的入口审计事件。 + + ``security`` 一律为**必填 keyword-only** 参数,且是本层的**唯一显式安全输入**: + 调用方身份只来自 ``security.auth.actor``,业务 payload 中不存在 ``identity`` / + ``actor_*`` / ``role`` / ``acting_user`` 之类的身份声明(F05 + §RequestSecurityContext)。target 仍由业务参数表达,不与 actor 合并——二者 + 同为 Scope 时若合并,「读自己的」和「读别人的」在签名上就分不出来了。 """ @abstractmethod @@ -72,15 +81,15 @@ def write( scope: Scope, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, assets: list[str] | None = None, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, ) -> list[MemoryUnit]: - """同步写入记忆:``scope`` 为写入目标范围、``identity`` 为调用方身份 - (本层据二者鉴权 WRITE 并落入口审计);``content`` 文本/结构投影 + - 可选 ``assets`` 原模态资产引用;阻塞至 hot path 完成(落盘 + 轻量 + """同步写入记忆:``scope`` 为写入目标范围、``security`` 为本次请求的 + 安全上下文(本层据 actor 与 target 鉴权 WRITE 并落入口审计);``content`` + 文本/结构投影 + 可选 ``assets`` 原模态资产引用;阻塞至 hot path 完成(落盘 + 轻量 索引),返回本次插入的全部记忆单元(规约/切分可产生多条);重演进 由 background 通道异步进行。实现上桥接引擎的异步 write(如 ``asyncio.run``),供 CLI/脚本等同步形态使用。""" @@ -92,14 +101,14 @@ async def write_async( scope: Scope, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, assets: list[str] | None = None, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, ) -> list[MemoryUnit]: """异步(协程)写入记忆:语义与 :meth:`write` 一致(同样以 - ``scope``/``identity`` 鉴权),同样返回插入的记忆单元;直通引擎的异步 + ``scope``/``security`` 鉴权),同样返回插入的记忆单元;直通引擎的异步 write,供事件循环/高并发接入形态(HTTP/MCP)非阻塞调用。""" @abstractmethod @@ -109,7 +118,7 @@ def batch_write( scope: Scope | None = None, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, @@ -125,7 +134,7 @@ async def batch_write_async( scope: Scope | None = None, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, @@ -140,7 +149,7 @@ def recall( query: str, context: Context, *, - identity: Scope, + security: RequestSecurityContext, filters: FilterExpr | list[FilterClause] | dict | None = None, as_of: datetime | None = None, top_k: int = 10, @@ -149,9 +158,10 @@ def recall( ) -> RetrievalResult: """混合检索召回。``context`` 携带检索目标范围 ``context.scope`` 与调用级透传配置 ``context.extensions``(**不被内核核心逻辑解释**,值为传输安全的 ``str``), - ``identity`` 为调用方身份(本层据 scope/identity 鉴权 READ)。边界处把 Context - 拆开:scope 作独立轴穿透;``extensions`` 中约定 key ``max_tokens``(自适应披露预算) - 被解析为 int 写入 ``RetrievalQuery`` 由披露阶段消费,其余 ``extensions`` 写入调用级 + ``security`` 为本次请求的安全上下文(本层据 actor 与 ``context.scope`` + 鉴权 READ)。边界处把 Context 拆开:scope 作独立轴穿透;``extensions`` 中 + 约定 key ``max_tokens``(自适应披露预算)被解析为 int 写入 + ``RetrievalQuery`` 由披露阶段消费,其余 ``extensions`` 写入调用级 options、顺 parser 进 ``ParsedQuery`` 供自定义检索模块按约定 key 读取;Context 本身不下沉。``filters`` 在本边界兼容旧 list、单 clause 和 dict DSL,并立即 规范化为支持 AND/OR/NOT 的 :class:`~common.type_def.FilterExpr`, @@ -163,7 +173,7 @@ def list( self, scope: Scope, *, - identity: Scope, + security: RequestSecurityContext, offset: int = 0, limit: int = 100, memory_types: list[str] | None = None, @@ -175,35 +185,40 @@ def list( 语义参考 mem1.0 ``list_memories``:支持 ``offset``/``limit`` 分页与 ``memory_types`` 类型过滤,``extensions`` 透传自定义参数,``filters`` 支持结构化过滤。只返回 ``/memory/`` 真源记录,不包含 - ``/messages/`` 下的 infer 原文缓存。``identity`` 为调用方身份,本层据 - ``scope`` 鉴权 READ 后委托 Engine。返回当前页 items 和分页前匹配总数 count。 + ``/messages/`` 下的 infer 原文缓存。``security`` 为本次请求的安全上下文, + 本层据 ``scope`` 鉴权 READ 后委托 Engine。返回当前页 items 和分页前匹配总数 count。 """ @abstractmethod def get( - self, unit_id: str, scope: Scope, *, identity: Scope, as_of: datetime | None = None + self, + unit_id: str, + scope: Scope, + *, + security: RequestSecurityContext, + as_of: datetime | None = None, ) -> MemoryUnit: - """按 id 读取记忆单元;``scope`` 为目标范围、``identity`` 为调用方身份 - (本层据二者 check READ 后才下发,不读数据即可判权)。``as_of`` 为空时 + """按 id 读取记忆单元;``scope`` 为目标范围、``security`` 为本次请求的安全上下文 + (本层据二者鉴权 READ 后才下发,不读数据即可判权)。``as_of`` 为空时 返回该 id 对应的那一条;非空时沿 ``supersedes`` 版本链回溯,返回 valid 区间含 ``as_of`` 的那一版。不存在时抛 :class:`~common.errors.NotFoundError`。""" @abstractmethod def update( - self, unit_id: str, scope: Scope, patch: MemoryPatch, *, identity: Scope + self, unit_id: str, scope: Scope, patch: MemoryPatch, *, security: RequestSecurityContext ) -> MemoryUnit: - """修正记忆:``scope`` 为目标范围、``identity`` 为调用方身份(本层据二者 + """修正记忆:``scope`` 为目标范围、``security`` 为本次请求的安全上下文(本层据二者 鉴权 UPDATE)。版本语义由 ``patch.mode`` 决定——``SUPERSEDE``(默认、 非破坏式)新建新 id 版本、旧版标记 superseded、新版 ``supersedes`` 指向 旧 id;``OVERWRITE`` 原地覆写沿用同 id、旧内容仅留审计。返回结果记忆 单元(SUPERSEDE 为新 id,OVERWRITE 为原 id)。""" @abstractmethod - def delete(self, selector: DeleteSelector, *, identity: Scope) -> list[str]: + def delete(self, selector: DeleteSelector, *, security: RequestSecurityContext) -> list[str]: """ 删除:按选择器遗忘/归档/降权(非破坏式、可审计、可恢复策略); - ``identity`` 为调用方身份,本层据 ``selector.scope`` 鉴权 DELETE;返回 - 命中的记忆单元 id。 + ``security`` 为本次请求的安全上下文,本层据 ``selector.scope`` 鉴权 + DELETE;返回命中的记忆单元 id。 """ @abstractmethod @@ -213,97 +228,102 @@ def evolve( mode: EvolveMode, channel: Channel = Channel.BACKGROUND, *, - identity: Scope, + security: RequestSecurityContext, ) -> str: """触发演进(extract/associate/consolidate/forget):``scope`` 为演进 - 目标范围、``identity`` 为调用方身份(本层据二者鉴权);返回任务 id,状态 - 用 :meth:`job_status` 查询。索引维护不在此——它随 write/update/delete + 目标范围、``security`` 为本次请求的安全上下文(本层据二者鉴权);返回任务 + id,状态用 :meth:`job_status` 查询。索引维护不在此——它随 write/update/delete 自动跟进。""" @abstractmethod - def job_status(self, job_id: str, *, identity: Scope) -> JobInfo: + def job_status(self, job_id: str, *, security: RequestSecurityContext) -> JobInfo: """ - 查询演进任务状态(委托 Scheduler);``identity`` 为调用方身份,本层 + 查询演进任务状态(委托 Scheduler);``security`` 为本次请求的安全上下文,本层 据其鉴权(仅可查自身/已授权范围的任务)。 """ @abstractmethod - def job_cancel(self, job_id: str, *, identity: Scope) -> None: + def job_cancel(self, job_id: str, *, security: RequestSecurityContext) -> None: """ - 取消尚未完成的演进任务(幂等,委托 Scheduler);``identity`` 为调用 - 方身份,本层据其鉴权。 + 取消尚未完成的演进任务(幂等,委托 Scheduler);``security`` 为本次请求的 + 安全上下文,本层据其鉴权。 """ @abstractmethod - def admin_get(self, key: str, *, identity: Scope) -> str: + def admin_get(self, key: str, *, security: RequestSecurityContext) -> str: """ - admin:读取一项运行时策略的当前值;``identity`` 为调用方身份(本层 - 据其做管理面鉴权)。 + admin:读取一项运行时策略的当前值;本层据 ``security`` 做管理面鉴权 + (``MANAGE_POLICY``)。 """ @abstractmethod - def admin_set(self, key: str, value: str, *, identity: Scope) -> None: + def admin_set(self, key: str, value: str, *, security: RequestSecurityContext) -> None: """ admin:调整一项运行时策略(启停索引、检索/演进开关等;键未知或 - 不可变配置抛 :class:`~common.errors.PolicyError`);``identity`` 为调用方 - 身份,本层据其鉴权并落审计。 + 不可变配置抛 :class:`~common.errors.PolicyError`);本层据 ``security`` 做管理面鉴权 + (``MANAGE_POLICY``)并落审计。 """ @abstractmethod - def admin_all(self, *, identity: Scope) -> dict[str, str]: + def admin_all(self, *, security: RequestSecurityContext) -> dict[str, str]: """ - admin:列出全部运行时策略及当前值;``identity`` 为调用方身份(本层 - 据其做管理面鉴权)。 + admin:列出全部运行时策略及当前值;本层据 ``security`` 做管理面鉴权 + (``MANAGE_POLICY``)。 """ # -- 治理(委托 Governor,架构 §12 的「看」侧) --------------------------- # @abstractmethod - def inspect(self, unit_ids: list[str], scope: Scope, *, identity: Scope) -> list[MemoryUnit]: + def inspect( + self, unit_ids: list[str], scope: Scope, *, security: RequestSecurityContext + ) -> list[MemoryUnit]: """ 检视:读取记忆单元的完整内容与治理字段(含已失效版本)。 - ``scope`` 为目标范围,``identity`` 为调用方身份,本层据二者鉴权。 + ``scope`` 为目标范围,``security`` 为本次请求的安全上下文,本层据二者鉴权。 """ @abstractmethod - def trace(self, unit_id: str, scope: Scope, *, identity: Scope) -> list[MemoryUnit]: + def trace( + self, unit_id: str, scope: Scope, *, security: RequestSecurityContext + ) -> list[MemoryUnit]: """ 血缘回溯:沿 provenance 向上追溯该记忆的演进来源链;``scope`` 为 - 目标范围、``identity`` 为调用方身份,本层据二者鉴权。 + 目标范围、``security`` 为本次请求的安全上下文,本层据二者鉴权。 """ @abstractmethod def audit( - self, filters: dict[str, str], *, identity: Scope, limit: int = 100 + self, filters: dict[str, str], *, security: RequestSecurityContext, limit: int = 100 ) -> list[AuditEvent]: """审计查询:按条件(actor/action/layer/时间段等)检索审计留痕; - ``identity`` 为调用方身份,本层据其做治理鉴权。""" + 本层据 ``security`` 鉴权 ``READ_AUDIT``。""" - # -- 跨 scope 授权(委托 PermissionManager,架构 §3.2) ------------------- # + # -- 跨 scope 授权(写入 Authorizer 读取的 GrantStore) ------------------- # @abstractmethod - def grant(self, grant: Grant, *, identity: Scope) -> None: + def grant(self, grant: Grant, *, security: RequestSecurityContext) -> None: """ - 新增一条跨 scope 授权(共享池等);``identity`` 为调用方身份,本层据 - 其鉴权 SHARE(须有权再授权 ``grant.grantor`` 范围)。 + 新增一条跨 scope 授权(共享池等);本层据 ``security`` 鉴权 SHARE + (须有权再授权 ``grant.grantor`` 范围),通过后写入 Authorizer 判定读取的 + ``GrantStore``(经 ``authorizer.management_grant_store()`` 共享同一真源)。 """ @abstractmethod - def revoke(self, grant: Grant, *, identity: Scope) -> None: + def revoke(self, grant: Grant, *, security: RequestSecurityContext) -> None: """ - 回收一条授权(幂等);``identity`` 为调用方身份,本层据其鉴权。匹配 - 哪条既有授权由具体实现定义。 + 回收一条授权(幂等);本层据 ``security`` 鉴权 ``REVOKE_SHARE``,按 + (grantor, grantee, action) 选择子定位真源记录、按 ``grant_id`` 撤销。 """ # -- Space 管理(委托 SpaceManager) ------------------------------------ # @abstractmethod - def create_space(self, spec: SpaceSpec, *, identity: Scope) -> SpaceInfo: + def create_space(self, spec: SpaceSpec, *, security: RequestSecurityContext) -> SpaceInfo: """创建 space,并写入主体路径、策略、状态与 metadata。""" @abstractmethod - def get_space(self, org: str, space: str, *, identity: Scope) -> SpaceInfo: + def get_space(self, org: str, space: str, *, security: RequestSecurityContext) -> SpaceInfo: """读取单个 space 的基础信息与策略。""" @abstractmethod @@ -311,7 +331,7 @@ def list_spaces( self, org: str, *, - identity: Scope, + security: RequestSecurityContext, status: SpaceStatus | None = None, limit: int = 100, cursor: str | None = None, @@ -320,12 +340,12 @@ def list_spaces( @abstractmethod def update_space( - self, org: str, space: str, patch: SpacePatch, *, identity: Scope + self, org: str, space: str, patch: SpacePatch, *, security: RequestSecurityContext ) -> SpaceInfo: """修改 space display name、metadata、policy 或状态。""" @abstractmethod - def archive_space(self, org: str, space: str, *, identity: Scope) -> SpaceInfo: + def archive_space(self, org: str, space: str, *, security: RequestSecurityContext) -> SpaceInfo: """归档 space,保留读取、导出与审计能力。""" @abstractmethod @@ -334,7 +354,7 @@ def delete_space( org: str, space: str, *, - identity: Scope, + security: RequestSecurityContext, mode: DeleteMode = DeleteMode.PURGE, ) -> SpaceDeleteResult: """删除 space 真源与可重建索引;当前实现只支持 PURGE。""" @@ -345,39 +365,41 @@ def export_space( org: str, space: str, *, - identity: Scope, + security: RequestSecurityContext, include_audit: bool = True, ) -> str: """提交 space 导出,返回 export id。""" @abstractmethod - def space_usage(self, org: str, space: str, *, identity: Scope) -> SpaceUsage: + def space_usage(self, org: str, space: str, *, security: RequestSecurityContext) -> SpaceUsage: """查询 space 级用量。""" @abstractmethod - def get_space_policy(self, org: str, space: str, *, identity: Scope) -> SpacePolicy: + def get_space_policy( + self, org: str, space: str, *, security: RequestSecurityContext + ) -> SpacePolicy: """读取 space 级 policy。""" @abstractmethod def set_space_policy( - self, org: str, space: str, policy: SpacePolicy, *, identity: Scope + self, org: str, space: str, policy: SpacePolicy, *, security: RequestSecurityContext ) -> SpacePolicy: """替换 space 级 policy。""" @abstractmethod def list_space_members( - self, org: str, space: str, *, identity: Scope + self, org: str, space: str, *, security: RequestSecurityContext ) -> list[SpaceMember]: """列出 space 成员。""" @abstractmethod def add_space_member( - self, org: str, space: str, member: SpaceMember, *, identity: Scope + self, org: str, space: str, member: SpaceMember, *, security: RequestSecurityContext ) -> None: """添加或更新 space 成员角色。""" @abstractmethod def remove_space_member( - self, org: str, space: str, member: Scope, *, identity: Scope + self, org: str, space: str, member: Scope, *, security: RequestSecurityContext ) -> None: """移除 space 成员。""" diff --git a/src/api/memory_api_impl/assembly.py b/src/api/memory_api_impl/assembly.py index 7b301a3f..82e30582 100644 --- a/src/api/memory_api_impl/assembly.py +++ b/src/api/memory_api_impl/assembly.py @@ -23,13 +23,15 @@ from dataclasses import dataclass -from common.audit.base import AuditProducer +from common.audit.base import AuditLogger, AuditProducer from common.bootstrap import register_plugins from common.errors import ValidationError from common.factory.factory import Factory from common.log import setup_logging -from common.security import SecurityProducer, SecurityProvider -from common.security.security_impl import SecurityProducer as _SecImpl # noqa: F401 触发自注册 +from common.security.authentication.base import AuthProducer +from common.security.authentication.credential_registry import CredentialStatusRegistry +from common.security.authorization import AuthorizationProducer, Authorizer +from common.security.bootstrap import register_security from config import Config from config.config_source import ConfigSource, ConfigSourceProducer from config.config_source_impl import register_config_sources @@ -39,7 +41,6 @@ from control.bootstrap import register_controllers from control.engine import EngineProducer from control.governance import GovernorProducer -from control.permission import PermissionProducer from control.policy import PolicyProducer from control.scheduler import SchedulerProducer from control.space import SpaceManager, SpaceProducer @@ -66,18 +67,101 @@ class Kernel: api: LocalMemoryAPI kv: KVStore space: SpaceManager | None = None + audit: AuditLogger | None = None # 装配好的审计器;surface 侧记认证失败等入口事件 config_source: ConfigSource | None = None def _register_all() -> None: """组装前按层触发自注册(句柄在接口、注册靠 import 实现;各 bootstrap 幂等)。""" - register_plugins() # common 共享插件 - register_backends() # storage - register_operators() # retrieval - register_ingestors() # ingest + register_plugins() # common 共享插件 + register_security() # common.security(认证/授权/密码学/防护) + register_backends() # storage + register_operators() # retrieval + register_ingestors() # ingest register_constructors() # construction - register_controllers() # control - register_config_sources() # ConfigSource:yaml_defaults / dict / overlay + register_controllers() # control + register_config_sources() # config_source + + +def _build_authorizer(root: ComponentConfig) -> Authorizer: + """装配 Authorizer,并挡住把仅测试实现配进生产的装配。 + + 判据是 :meth:`Authorizer.is_test_only` 这个 capability,不是 ``target == "allow_all"`` + ——第三方注册的恒放行实现同样要被拦住,而核心不认识它的 target 名(S08 不变量 7)。 + 单测要用 allow_all 就显式把 ``globals.allow_test_only_security`` 打开,让「这次装配 + 不做真实授权」在配置里留下痕迹。 + """ + authorizer = AuthorizationProducer.dep(root, default="standard") + if not isinstance(authorizer, Authorizer): + raise ValidationError("authorizer 必须是 Authorizer 实现") + if authorizer.is_test_only() and not root.get("allow_test_only_security", False): + raise ValidationError( + "当前 authorizer 是仅测试实现(恒放行);生产装配拒绝启动。" + "确需在测试中使用时显式配置 globals.allow_test_only_security=true" + ) + return authorizer + + +def _build_credential_registry(root: ComponentConfig) -> CredentialStatusRegistry: + """装配凭据撤销复核注册表(PEP 持有,F05 §认证不变量 6、§决策顺序 1)。 + + 从实际装配的 Authenticator 实例提取 credential_type → KeyStore 映射(P1-1 真源 + 统一):Registry 与 Authenticator 共享同一具名 KeyStore 实例(经 Factory 缓存), + 撤销后 PEP 立即看到。装配时调用所有 KeyStore 的 health() 检查撤销复核 capability + (P1-2 装配健全性):第三方 KeyStore 未实现 is_revoked 时启动期拒绝,而非运行期 + 500。未配 Authenticator 或全是无 KeyStore 的实现(如 dev)时 Registry 空。 + + Round3: 任何已声明 Authenticator 装配失败都拒绝启动(fail-closed),不能静默跳过。 + Round3: 使用 (credential_type, authenticator_name) 复合键,支持平行 Authenticator。 + Round4: 扫描实际装配图,而非仅配置命名空间。从 SecurityRuntime 实例中提取内联 + Authenticator,确保 surface 实际使用的认证器都被注册(P1-2 内联装配支持)。 + """ + from common.security.runtime import SecurityRuntimeProducer + + registry = CredentialStatusRegistry() + registered: set[tuple[str, str]] = set() # 避免重复注册同一 (type, name) + + # 第一步:扫描顶层 authenticator 命名空间(独立声明的 Authenticator) + authenticator_ns = (root.ctx.namespaces or {}).get("authenticator", {}) + for name in authenticator_ns: + authenticator = AuthProducer.build_named(name, root.ctx) + if hasattr(authenticator, "key_store"): + credential_type = authenticator.mode() + key = (credential_type, name) + if key not in registered: + registry.register(credential_type, name, authenticator.key_store) + registered.add(key) + + # Round4 第二步:扫描所有 SecurityRuntime,提取内联 Authenticator + # SecurityRuntime 可能在 params.authenticator 中内联 target,这些 Authenticator + # 不会出现在 authenticator 命名空间,但会被 surface 实际使用。 + security_ns = (root.ctx.namespaces or {}).get("security", {}) + for runtime_name in security_ns: + # Round5: 移除 try/except,任何装配失败都应该传播(fail-closed)。 + # 配置错误(target 不存在、参数错误)必须在启动期暴露,不能静默跳过。 + runtime = SecurityRuntimeProducer.build_named(runtime_name, root.ctx) + authenticator = runtime.authenticator + if hasattr(authenticator, "key_store"): + credential_type = authenticator.mode() + # Round5: 内联 Authenticator 的 _name 通常是空字符串(Factory.dep 传递 name="") + # 如果是空字符串或 "default",说明是匿名内联创建的,用 runtime 名称替换。 + auth_name = getattr(authenticator, "_name", "") + if not auth_name or auth_name == "default": + # 内联 Authenticator,使用 runtime 名称确保唯一 + auth_name = f"runtime:{runtime_name}" + # Round5: 修改 Authenticator 的 _name,让它签发的 AuthContext 携带正确 issuer + if hasattr(authenticator, "_name"): + object.__setattr__(authenticator, "_name", auth_name) + key = (credential_type, auth_name) + if key not in registered: + registry.register(credential_type, auth_name, authenticator.key_store) + registered.add(key) + + # P1-2:装配期健全性检查。Registry.health() 内部调用所有已注册 KeyStore 的 + # health(),确认它们实现了 is_revoked。第三方 Store 漏实现时在此失败,不会等到 + # 首次授权请求才抛 NotImplementedError(F05 §装配不变量「不健康能力启动期拒绝」)。 + registry.health() + return registry def build_kernel( @@ -108,7 +192,6 @@ def build_kernel( setup_logging(root) # 初始化 agent-memory 根 logger(按 globals 的 log_* 配置;幂等) # ConfigSource 须先于 engine/evolver 装配,供 PromptRegistry / 插件晚绑定共享。 - # 顺序:ConfigSource →(强制)EncryptedKV 包装 → LocalMemoryAPI/engine。 config_source = ConfigSourceProducer.dep(root, default="yaml_defaults") if not isinstance(config_source, ConfigSource): raise ValidationError( @@ -116,31 +199,27 @@ def build_kernel( ) ConfigSourceProducer.put("default", config_source) - # 强制 KV 加密:不管配置里 kv_store.default 指向 memory/sqlite/redis, - # 装配出来的 KV 一定是 EncryptedKVStore,security provider 从 security 命名空间取。 - raw_kv = KvProducer.dep(root, default="memory") - if not isinstance(raw_kv, EncryptedKVStore): - security = SecurityProducer.dep(root, default="local") - if not isinstance(security, SecurityProvider): - raise ValidationError( - f"security 命名空间装配结果不是 SecurityProvider: {type(security).__name__}" - ) - raw_kv = EncryptedKVStore(raw=raw_kv, security=security) - KvProducer.put(KV_DEFAULT_NAME, raw_kv) + # audit logger 装配一次、两处共用:API 内部记业务事件,Kernel.audit 暴露给 + # surface 记入口事件(认证失败等发生在 API 之外,拿不到 API 的私有引用)。 + audit_logger = AuditProducer.dep(root, default="sqlite") + authorizer = _build_authorizer(root) api = LocalMemoryAPI( engine=EngineProducer.dep(root, default="in_memory"), - permission=PermissionProducer.dep(root, default="sqlite"), + grant_store=authorizer.management_grant_store(), + authorizer=authorizer, + credential_registry=_build_credential_registry(root), scheduler=SchedulerProducer.dep(root, default="in_process"), policy=PolicyProducer.dep(root, default="dict"), governor=GovernorProducer.dep(root, default="in_memory"), - audit_logger=AuditProducer.dep(root, default="sqlite"), + audit_logger=audit_logger, space=SpaceProducer.dep(root, default="kv"), ) return Kernel( api=api, kv=KvProducer.dep(root, default="memory"), space=api.space_manager, + audit=audit_logger, config_source=config_source, ) diff --git a/src/api/memory_api_impl/local_memory_api.py b/src/api/memory_api_impl/local_memory_api.py index 522b4cbb..551d4ad9 100644 --- a/src/api/memory_api_impl/local_memory_api.py +++ b/src/api/memory_api_impl/local_memory_api.py @@ -1,12 +1,21 @@ """:class:`~api.memory_api.MemoryAPI` 的单进程实现(``LocalMemoryAPI``)+ 装配。 -``LocalMemoryAPI`` 是鉴权与审计的执行点(PEP):每个涉及租户数据/治理的 -方法先构造权限上下文并调用 ``PermissionManager.check(..., context=...)``, -不通过抛 :class:`~common.errors.PermissionDeniedError`,通过后落入口审计并把 -已鉴权的 target scope 透传到引擎/各控制算子(identity 不下沉)。同步方法以 +``LocalMemoryAPI`` 是鉴权与审计的执行点(PEP),且是**唯一的业务 PEP**:每个 +涉及租户数据/治理的方法把 verb 映射为封闭的 +:class:`~common.security.types.Action`、从真源构造 +:class:`~common.security.types.ResourceDescriptor`、从 +:class:`~common.security.types.RequestSecurityContext` 派生 +:class:`~common.security.types.AuthorizationEnvironment`,再调用 +:class:`~common.security.authorization.Authorizer`(PDP)。不通过抛 +:class:`~common.errors.PermissionDeniedError`,通过后落带 actor 与稳定决策标识 +(``rule`` / :class:`~common.security.types.DenyReason`)的入口审计,并把已鉴权的 +target scope 透传到引擎/各控制算子(调用方身份不下沉)。同步方法以 ``asyncio.run`` 桥接引擎的异步协程,供 CLI/脚本使用。各控制算子按其 抽象基类型注入。 +安全输入只有 ``security`` 一个:actor 取自 ``security.auth.actor``,不从 +ContextVar 取、也不从业务 payload 取(F05 §显式上下文优于环境权限)。 + :func:`build_kernel` / :func:`assemble` 把各层具体实现串成一个可直接 调用的内核——是「把整个项目串起来」的落点;生产装配只需在此换成 真实实现。 @@ -24,6 +33,17 @@ from api.memory_api import MemoryAPI from common.audit import AuditLogger from common.errors import NotFoundError, PermissionDeniedError, PolicyError, ValidationError +from common.security.authentication.credential_registry import CredentialStatusRegistry +from common.security.authorization import Authorizer, GrantStore +from common.security.types import ( + Action, + AuthorizationEnvironment, + RequestSecurityContext, + ResourceDescriptor, +) +from common.security.types import ( + Grant as SecurityGrant, +) from common.type_def import ( EXT_MAX_TOKENS, RESERVED_METADATA_KEYS, @@ -45,12 +65,10 @@ from construction import EvolveMode from control.engine import MemoryEngine from control.governance import Governor -from control.permission import PermissionManager from control.policy import PolicyManager from control.scheduler import Scheduler from control.space import SpaceManager from control.types import ( - Action, BatchWriteItem, BatchWriteOutcome, BatchWriteResult, @@ -322,9 +340,7 @@ def _list_routing_clauses( if value: values.add(value) if len(values) == 1: - clauses.append( - FilterClause(canonical_filter_field(field), FilterOp.EQ, values.pop()) - ) + clauses.append(FilterClause(canonical_filter_field(field), FilterOp.EQ, values.pop())) return clauses @@ -357,13 +373,47 @@ def _space_permission_context(resource_type: str, scope: Scope) -> PermissionCon return PermissionContext(resource_type=resource_type, scope=scope) +def _management_permission_context(resource_type: str) -> PermissionContext: + """管理面(系统配置 / 审计查询)的鉴权上下文。 + + 这些操作的鉴权 target 是 ``_ROOT``,但「它是管理操作」这件事此前只能从 + 「target 恰好是空 scope」间接读出来。显式写成 ``resource_type`` 后, + PDP 不必再从数据形状反推语义。 + """ + return PermissionContext(resource_type=resource_type, scope=_ROOT) + + +def _descriptor_attributes(context: PermissionContext | None) -> dict[str, str]: + """把权限上下文摊平成 :class:`ResourceDescriptor` 的属性映射。 + + ``PermissionContext`` 仍是 Engine 侧的契约(``permission_context_for_unit`` / + ``list_with_permission_contexts`` / ``permission_contexts_for_delete`` 都返回它), + 转换点收在 PEP 这一处:Authorizer 只认 descriptor,不认控制层类型。 + + ``metadata`` 先铺、具名字段后覆盖:``memory_type`` 这类字段对已有资源来自真源, + 不能被同名 metadata 项盖掉(F05 §ResourceDescriptor:安全 metadata 来自真源)。 + """ + if context is None: + return {} + attributes = {str(key): str(value) for key, value in context.metadata.items()} + if context.memory_type: + attributes["memory_type"] = context.memory_type + if context.pipeline: + attributes["pipeline"] = context.pipeline + if context.tags: + attributes["tags"] = ",".join(context.tags) + return attributes + + class LocalMemoryAPI(MemoryAPI): """单进程装配下的统一记忆接口实现(鉴权 + 审计 + 委派)。""" def __init__( self, engine: MemoryEngine, - permission: PermissionManager, + grant_store: GrantStore, + authorizer: Authorizer, + credential_registry: CredentialStatusRegistry, scheduler: Scheduler, policy: PolicyManager, governor: Governor, @@ -371,7 +421,20 @@ def __init__( space: SpaceManager, ) -> None: self._engine = engine - self._perm = permission + # 跨主体授权的唯一真源:grant()/revoke() 写 self._grant_store,与 Authorizer + # 判定读取同一实例(装配侧由 authorizer.management_grant_store() 注入)。 + # credential_registry 由 PEP 持有,每次授权前在线复核凭据未撤销。 + # P1-4:路由场景下 Authorizer 可能持有多个 Store,公共 grant/revoke 须写入 + # 全部 Store 以统一真源。_grant_store 保留单 Store 兼容性(主 Store), + # _grant_stores 是完整列表(包含 _grant_store)。 + self._grant_store = grant_store + self._grant_stores = authorizer.management_grant_stores() + if not self._grant_stores: + # 无 Store 的 Authorizer(如 allow_all):_grant_stores 空,grant/revoke 无写目标。 + # _grant_store 仍需非空(旧代码兼容),用外部注入的 grant_store(通常也是空实现)。 + self._grant_stores = [grant_store] if grant_store else [] + self._authorizer = authorizer + self._credential_registry = credential_registry self._scheduler = scheduler self._policy = policy self._governor = governor @@ -406,7 +469,7 @@ def _purge_space_memories(self, scope: Scope) -> list[str]: def _record_audit( self, - identity: Scope, + actor: Scope, action: str, *, target_id: str = "", @@ -419,7 +482,7 @@ def _record_audit( self._audit.record( AuditEvent( id=str(uuid.uuid4()), - actor=identity, + actor=actor, action=action, target_id=target_id, layer="api", @@ -453,20 +516,39 @@ def _apply_space_policy_context( def _authorize( self, - identity: Scope, + security: RequestSecurityContext, target: Scope, action: Action, audit_action: str, target_id: str = "", *, + resource_type: str = "", context: PermissionContext | None = None, - check_permission: bool = True, require_space: bool = True, ) -> dict[str, str]: + """本层唯一的鉴权点:构造 descriptor + environment,调 Authorizer,落审计。 + + ``context`` 是 Engine 侧真源上下文(可为空),在此摊平成 descriptor 属性。 + ``resource_type`` 显式给出时优先——verb 到资源类型的映射归 PEP,不从 + context 的形状反推。 + """ + actor = security.actor + if not security.has_valid_origin(): + # 上下文必须经 new_request_context / internal_context 受控构造:直接构造 + # RequestSecurityContext 即便补齐 request_id/started_at,也未经认证边界。 + # _origin 用 HMAC-SHA256 密码绑定 auth 全字段及完整安全上下文(attributes、 + # surface、peer 等)到进程随机密钥(types.py),只有受控入口能计算正确的 + # 绑定值——攻击者无法通过 replace() 伪造上下文或注入 attributes 提权。 + raise PermissionDeniedError("security context origin binding invalid") + if self._credential_registry.is_revoked(security.auth): + # 凭据已撤销:缓存的 RequestSecurityContext 不会自动失效,PEP 在线复核 + # CredentialStatusRegistry(F05 §认证不变量 6、§决策顺序 1)。Registry 与 + # Authenticator 共享同一具名 Store,撤销后立即生效。 + raise PermissionDeniedError("credential revoked") effective_context = self._apply_space_policy_context(target, context) if _missing_required_space(self._policy, target, require_space): self._record_audit( - identity, + actor, audit_action, target_id=target_id, target_scope=target, @@ -478,22 +560,33 @@ def _authorize( }, ) raise ValidationError("scope.space is required") - if not check_permission: - return { - "permission_check": "disabled", - "permission_reason": "permission check disabled", - **_context_detail(effective_context), - } - if not self._perm.check(identity, target, action, context=effective_context): + resource = ResourceDescriptor( + action=action, + resource_type=resource_type + or (effective_context.resource_type if effective_context else ""), + scope=target, + resource_id=target_id, + attributes=_descriptor_attributes(effective_context), + ) + environment = AuthorizationEnvironment.from_request( + security, now=datetime.now(timezone.utc) + ) + decision = self._authorizer.authorize( + auth=security.auth, resource=resource, environment=environment + ) + if not decision.allowed: + # reason 是稳定 code(DenyReason),rule 标明哪条规则做的判定——两者一起 + # 才能从审计里读出「为什么拒」而不只是「拒了」(F05 §可观测性)。 self._record_audit( - identity, + actor, audit_action, target_id=target_id, target_scope=target, decision="deny", detail={ "permission_check": "enabled", - "permission_reason": f"permission denied for action={action.value}", + "permission_reason": decision.reason.value if decision.reason else "", + "permission_rule": decision.rule, **_context_detail(effective_context), }, ) @@ -501,12 +594,13 @@ def _authorize( return { "permission_check": "enabled", "permission_reason": "permission check passed", + "permission_rule": decision.rule, **_context_detail(effective_context), } def _log( self, - identity: Scope, + security: RequestSecurityContext, action: str, target_id: str = "", *, @@ -515,7 +609,7 @@ def _log( detail: dict[str, str] | None = None, ) -> None: self._record_audit( - identity, + security.actor, action, target_id=target_id, target_scope=target_scope, @@ -531,7 +625,7 @@ def write( scope: Scope, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, assets: list[str] | None = None, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, @@ -542,7 +636,7 @@ def write( content, scope, source, - identity=identity, + security=security, assets=assets, tags=tags, metadata=metadata, @@ -556,7 +650,7 @@ async def write_async( scope: Scope, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, assets: list[str] | None = None, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, @@ -566,7 +660,7 @@ async def write_async( _reject_non_scalar_metadata(metadata) permission_context = _write_permission_context(scope, tags, metadata) auth = self._authorize( - identity, + security, scope, Action.WRITE, "write", @@ -582,7 +676,7 @@ async def write_async( metadata=metadata, occurred_at=occurred_at, ) - self._log(identity, "write", target_scope=scope, detail=auth) + self._log(security, "write", target_scope=scope, detail=auth) return units @staticmethod @@ -678,7 +772,7 @@ def batch_write( scope: Scope | None = None, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, @@ -690,7 +784,7 @@ def batch_write( items, scope, source, - identity=identity, + security=security, tags=tags, metadata=metadata, occurred_at=occurred_at, @@ -705,7 +799,7 @@ async def batch_write_async( scope: Scope | None = None, source: Modality = Modality.TEXT, *, - identity: Scope, + security: RequestSecurityContext, tags: list[str] | None = None, metadata: dict[str, Any] | None = None, occurred_at: datetime | None = None, @@ -763,9 +857,7 @@ async def batch_write_async( ) seen_sequences.add(sequence_key) ready.append((index, item)) - except Exception as exc: - if not isinstance(exc, (ValidationError, PermissionDeniedError, PolicyError)): - raise + except (ValidationError, PermissionDeniedError, PolicyError) as exc: outcomes[index] = self._batch_outcome(index, raw_item, exc) error_scope = ( raw_item.scope @@ -773,7 +865,7 @@ async def batch_write_async( else scope ) self._log( - identity, + security, "write", target_scope=error_scope, decision="error", @@ -797,7 +889,7 @@ async def batch_write_async( permission_context = _write_permission_context(item.scope, item.tags, item.metadata) try: auth = self._authorize( - identity, + security, item.scope, Action.WRITE, "write", @@ -809,7 +901,7 @@ async def batch_write_async( outcomes[index] = self._batch_outcome(index, item, exc) if not isinstance(exc, PermissionDeniedError): self._log( - identity, + security, "write", target_scope=item.scope, decision="error", @@ -842,7 +934,7 @@ async def batch_write_async( engine_outcome.item = item outcomes[index] = engine_outcome self._log( - identity, + security, "write", target_scope=item.scope, decision="allow" if not engine_outcome.error else "error", @@ -853,16 +945,14 @@ async def batch_write_async( }, ) - return BatchWriteResult( - outcomes=[outcomes[index] for index in range(len(items))] - ) + return BatchWriteResult(outcomes=[outcomes[index] for index in range(len(items))]) def recall( self, query: str, context: Context, *, - identity: Scope, + security: RequestSecurityContext, filters: FilterExpr | list[FilterClause] | dict | None = None, as_of: datetime | None = None, top_k: int = 10, @@ -890,7 +980,7 @@ def recall( # 权限上下文与 RetrievalQuery 共用同一规范化后的 FilterExpr(不重复转换)。 permission_context = _recall_permission_context(context, rq.filters) auth = self._authorize( - identity, + security, context.scope, Action.READ, "recall", @@ -902,7 +992,7 @@ def recall( # 策略保护的数据,即可用 A 的钥匙开 B 的门。用户表达式作整体 child 并入外层 AND # (与 lifecycle/时间谓词同一机制),不会被其内部的 OR 稀释。 routing_clauses: list[FilterClause] = [] - for field in self._perm.routing_fields(): + for field in self._authorizer.routing_fields(): routed = permission_context.metadata.get(field, "").strip() if routed: routing_clauses.append( @@ -911,14 +1001,14 @@ def recall( if routing_clauses: rq.filters = and_merge(rq.filters, routing_clauses) result = asyncio.run(self._engine.recall(context.scope, rq)) - self._log(identity, "recall", target_scope=context.scope, detail=auth) + self._log(security, "recall", target_scope=context.scope, detail=auth) return result def list( self, scope: Scope, *, - identity: Scope, + security: RequestSecurityContext, offset: int = 0, limit: int = 100, memory_types: list[str] | None = None, @@ -936,7 +1026,7 @@ def list( auth: dict[str, str] = {} for permission_context in permission_contexts: auth = self._authorize( - identity, + security, scope, Action.READ, "list", @@ -944,7 +1034,7 @@ def list( ) routing_clauses = _list_routing_clauses( permission_contexts, - self._perm.routing_fields(), + self._authorizer.routing_fields(), memory_types, ) effective_filters = and_merge(normalized_filters, routing_clauses) @@ -960,7 +1050,7 @@ def list( ) for permission_context in unit_contexts: auth = self._authorize( - identity, + security, permission_context.scope, Action.READ, "list", @@ -972,7 +1062,7 @@ def list( context.memory_type for context in permission_contexts ) self._log( - identity, + security, "list", target_scope=scope, detail={ @@ -984,10 +1074,15 @@ def list( return result def get( - self, unit_id: str, scope: Scope, *, identity: Scope, as_of: datetime | None = None + self, + unit_id: str, + scope: Scope, + *, + security: RequestSecurityContext, + as_of: datetime | None = None, ) -> MemoryUnit: self._authorize( - identity, + security, scope, Action.READ, "get", @@ -996,7 +1091,7 @@ def get( ) permission_context = asyncio.run(self._engine.permission_context_for_unit(unit_id, scope)) auth = self._authorize( - identity, + security, scope, Action.READ, "get", @@ -1005,7 +1100,7 @@ def get( ) unit = asyncio.run(self._engine.get(unit_id, scope, as_of)) self._log( - identity, + security, "get", unit_id, target_scope=scope, @@ -1014,12 +1109,12 @@ def get( return unit def update( - self, unit_id: str, scope: Scope, patch: MemoryPatch, *, identity: Scope + self, unit_id: str, scope: Scope, patch: MemoryPatch, *, security: RequestSecurityContext ) -> MemoryUnit: _reject_reserved_metadata(patch.metadata) _reject_non_scalar_metadata(patch.metadata) self._authorize( - identity, + security, scope, Action.UPDATE, "update", @@ -1028,7 +1123,7 @@ def update( ) permission_context = asyncio.run(self._engine.permission_context_for_unit(unit_id, scope)) auth = self._authorize( - identity, + security, scope, Action.UPDATE, "update", @@ -1039,7 +1134,7 @@ def update( before = asyncio.run(self._engine.get(unit_id, scope, None)) unit = asyncio.run(self._engine.update(unit_id, scope, patch)) self._log( - identity, + security, "update", unit_id, target_scope=scope, @@ -1047,10 +1142,8 @@ def update( ) return unit - def delete(self, selector: DeleteSelector, *, identity: Scope) -> list[str]: - selector_is_empty = ( - not selector.unit_ids and not selector.tags and selector.before is None - ) + def delete(self, selector: DeleteSelector, *, security: RequestSecurityContext) -> list[str]: + selector_is_empty = not selector.unit_ids and not selector.tags and selector.before is None if selector_is_empty: raise ValidationError("DeleteSelector requires unit_ids, tags, or before") # 按 selector 的目标 scope 鉴权 DELETE;未限定 scope(如纯按 id/标签的 @@ -1059,7 +1152,7 @@ def delete(self, selector: DeleteSelector, *, identity: Scope) -> list[str]: selector_context = _selector_permission_context(selector, target) if selector.scope is not None or not selector.unit_ids: self._authorize( - identity, + security, target, Action.DELETE, "delete", @@ -1068,7 +1161,7 @@ def delete(self, selector: DeleteSelector, *, identity: Scope) -> list[str]: contexts = asyncio.run(self._engine.permission_contexts_for_delete(selector)) if not contexts: auth = self._authorize( - identity, + security, target, Action.DELETE, "delete", @@ -1078,7 +1171,7 @@ def delete(self, selector: DeleteSelector, *, identity: Scope) -> list[str]: auth = {"permission_check": "enabled", "permission_reason": "permission check passed"} for permission_context in contexts: unit_auth = self._authorize( - identity, + security, permission_context.scope, Action.DELETE, "delete", @@ -1088,7 +1181,7 @@ def delete(self, selector: DeleteSelector, *, identity: Scope) -> list[str]: auth.update(unit_auth) deleted = asyncio.run(self._engine.delete(selector)) self._log( - identity, + security, "delete", target_scope=target, detail={**auth, "before_unit_ids": json.dumps(deleted, ensure_ascii=False)}, @@ -1101,108 +1194,184 @@ def evolve( mode: EvolveMode, channel: Channel = Channel.BACKGROUND, *, - identity: Scope, + security: RequestSecurityContext, ) -> str: - auth = self._authorize(identity, scope, Action.WRITE, "evolve") + auth = self._authorize( + security, scope, Action.WRITE, "evolve", resource_type="evolve_request" + ) self._ensure_space_writable(scope) job_id = asyncio.run(self._engine.evolve(scope, mode, channel)) - self._log(identity, "evolve", target_scope=scope, detail={**auth, "job_id": job_id}) + self._log(security, "evolve", target_scope=scope, detail={**auth, "job_id": job_id}) return job_id # -- 任务调度(直达 Scheduler) ----------------------------------------- # - def job_status(self, job_id: str, *, identity: Scope) -> JobInfo: - # 先取任务(含其 scope),再据 identity 对该 scope 的 READ 权放行 + def job_status(self, job_id: str, *, security: RequestSecurityContext) -> JobInfo: + # 先取任务(含其 scope),再据 actor 对该 scope 的 READ 权放行 # (仅可查自身/已授权范围的任务);status 为只读查询,先取后判权 - # 不产生副作用。 + # 不产生副作用。任务 scope 来自 Scheduler 这个真源,不是调用方声明的。 info = self._scheduler.status(job_id) - auth = self._authorize(identity, info.scope, Action.READ, "job_status", job_id) - self._log(identity, "job_status", job_id, target_scope=info.scope, detail=auth) + auth = self._authorize( + security, info.scope, Action.READ, "job_status", job_id, resource_type="job" + ) + self._log(security, "job_status", job_id, target_scope=info.scope, detail=auth) return info - def job_cancel(self, job_id: str, *, identity: Scope) -> None: + def job_cancel(self, job_id: str, *, security: RequestSecurityContext) -> None: # 取消即对该任务范围的写动作,按其 scope 鉴权 WRITE # (与 evolve 触发一致)。 info = self._scheduler.status(job_id) - auth = self._authorize(identity, info.scope, Action.WRITE, "job_cancel", job_id) - self._log(identity, "job_cancel", job_id, target_scope=info.scope, detail=auth) + auth = self._authorize( + security, info.scope, Action.WRITE, "job_cancel", job_id, resource_type="job" + ) + self._log(security, "job_cancel", job_id, target_scope=info.scope, detail=auth) self._scheduler.cancel(job_id) # -- admin(直达 PolicyManager;管理面闸门 = 根 scope 鉴权) ------------- # - def admin_get(self, key: str, *, identity: Scope) -> str: - auth = self._authorize(identity, _ROOT, Action.READ, "admin_get", key) - self._log(identity, "admin_get", key, target_scope=_ROOT, detail=auth) + def admin_get(self, key: str, *, security: RequestSecurityContext) -> str: + auth = self._authorize( + security, + _ROOT, + Action.MANAGE_POLICY, + "admin_get", + key, + context=_management_permission_context("admin"), + ) + self._log(security, "admin_get", key, target_scope=_ROOT, detail=auth) return self._policy.get(key) - def admin_set(self, key: str, value: str, *, identity: Scope) -> None: - auth = self._authorize(identity, _ROOT, Action.WRITE, "admin_set", key) - self._log(identity, "admin_set", key, target_scope=_ROOT, detail=auth) + def admin_set(self, key: str, value: str, *, security: RequestSecurityContext) -> None: + auth = self._authorize( + security, + _ROOT, + Action.MANAGE_POLICY, + "admin_set", + key, + context=_management_permission_context("admin"), + ) + self._log(security, "admin_set", key, target_scope=_ROOT, detail=auth) self._policy.set(key, value) - def admin_all(self, *, identity: Scope) -> dict[str, str]: - auth = self._authorize(identity, _ROOT, Action.READ, "admin_all") - self._log(identity, "admin_all", target_scope=_ROOT, detail=auth) + def admin_all(self, *, security: RequestSecurityContext) -> dict[str, str]: + auth = self._authorize( + security, + _ROOT, + Action.MANAGE_POLICY, + "admin_all", + context=_management_permission_context("admin"), + ) + self._log(security, "admin_all", target_scope=_ROOT, detail=auth) return self._policy.all() # -- 治理(直达 Governor) ---------------------------------------------- # def inspect( - self, unit_ids: list[str], scope: Scope, *, identity: Scope + self, unit_ids: list[str], scope: Scope, *, security: RequestSecurityContext ) -> list[MemoryUnit]: - auth = self._authorize(identity, scope, Action.READ, "inspect") - self._log(identity, "inspect", target_scope=scope, detail=auth) + auth = self._authorize(security, scope, Action.READ, "inspect", resource_type="memory_unit") + self._log(security, "inspect", target_scope=scope, detail=auth) return self._governor.inspect(unit_ids, scope) - def trace(self, unit_id: str, scope: Scope, *, identity: Scope) -> list[MemoryUnit]: - auth = self._authorize(identity, scope, Action.READ, "trace", unit_id) - self._log(identity, "trace", unit_id, target_scope=scope, detail=auth) + def trace( + self, unit_id: str, scope: Scope, *, security: RequestSecurityContext + ) -> list[MemoryUnit]: + auth = self._authorize( + security, scope, Action.READ, "trace", unit_id, resource_type="memory_unit" + ) + self._log(security, "trace", unit_id, target_scope=scope, detail=auth) return self._governor.trace(unit_id, scope) def audit( - self, filters: dict[str, str], *, identity: Scope, limit: int = 100 + self, filters: dict[str, str], *, security: RequestSecurityContext, limit: int = 100 ) -> list[AuditEvent]: # 审计查询跨 scope,按管理面闸门(根 scope READ)鉴权; # 查询本身亦留痕。 - auth = self._authorize(identity, _ROOT, Action.READ, "audit") - self._log(identity, "audit", target_scope=_ROOT, detail=auth) + auth = self._authorize( + security, + _ROOT, + Action.READ_AUDIT, + "audit", + context=_management_permission_context("audit"), + ) + self._log(security, "audit", target_scope=_ROOT, detail=auth) return self._governor.audit(filters, limit) - # -- 跨 scope 授权(直达 PermissionManager) ---------------------------- # + # -- 跨 scope 授权(写入 Authorizer 读取的 GrantStore) ----------------- # - def grant(self, grant: Grant, *, identity: Scope) -> None: - auth = self._authorize(identity, grant.grantor, Action.SHARE, "grant") - self._log(identity, "grant", target_scope=grant.grantor, detail=auth) - self._perm.grant(grant) + def grant(self, grant: Grant, *, security: RequestSecurityContext) -> None: + auth = self._authorize( + security, grant.grantor, Action.SHARE, "grant", resource_type="grant" + ) + self._log(security, "grant", target_scope=grant.grantor, detail=auth) + # 公开签名收 control.types.Grant(选择子,无 id);真源 common.security.types.Grant + # 需 grant_id 与 frozenset 动作,在此摊平。grant_id 服务端生成:撤销/审计按 id 定位。 + # P1-4:路由场景下写入**全部** Store,确保后续请求无论路由到哪个 policy 都能读到授权。 + security_grant = SecurityGrant( + grant_id=uuid.uuid4().hex, + grantor=grant.grantor, + grantee=grant.grantee, + actions=frozenset(Action(a.value) for a in grant.actions), + expires_at=grant.expires_at, + ) + for store in self._grant_stores: + store.add(security_grant) - def revoke(self, grant: Grant, *, identity: Scope) -> None: - auth = self._authorize(identity, grant.grantor, Action.SHARE, "revoke") - self._log(identity, "revoke", target_scope=grant.grantor, detail=auth) - self._perm.revoke(grant) + def revoke(self, grant: Grant, *, security: RequestSecurityContext) -> None: + auth = self._authorize( + security, + grant.grantor, + Action.REVOKE_SHARE, + "revoke", + resource_type="grant", + ) + self._log(security, "revoke", target_scope=grant.grantor, detail=auth) + # 公开 revoke 按 (grantor, grantee, action) 选择子匹配,真源按 id 撤销:用 + # find_active 取候选(已滤撤销/过期),再按精确 grantor+grantee 收窄,逐条按 id + # 撤销。幂等--无匹配或已撤销都不报错。 + # P1-4:路由场景下从**全部** Store 查找并撤销。同一 grant_id 可能在多个 Store + # 中(grant() 写入所有 Store),全部撤销确保无论路由到哪个 policy 都看不到。 + now = datetime.now(timezone.utc) + for action in grant.actions: + # 从所有 Store 收集候选,按 grant_id 去重(同一 grant 可能在多个 Store)。 + candidates_by_id: dict[str, SecurityGrant] = {} + for store in self._grant_stores: + for candidate in store.find_active( + grantee=grant.grantee, + grantor_org=grant.grantor.org, + action=Action(action.value), + now=now, + ): + if candidate.grantor == grant.grantor and candidate.grantee == grant.grantee: + candidates_by_id[candidate.grant_id] = candidate + # 从所有 Store 撤销匹配的 grant_id(幂等--Store 中不存在或已撤销都不报错)。 + for grant_id in candidates_by_id: + for store in self._grant_stores: + store.revoke(grant_id) # -- Space 管理(直达 SpaceManager) ------------------------------------ # - def create_space(self, spec: SpaceSpec, *, identity: Scope) -> SpaceInfo: + def create_space(self, spec: SpaceSpec, *, security: RequestSecurityContext) -> SpaceInfo: target = _space_scope(spec.org, spec.space) target_id = _space_target_id(spec.org, spec.space) auth = self._authorize( - identity, + security, Scope(org=spec.org), - Action.WRITE, + Action.MANAGE_SPACE, "create_space", target_id, context=_space_permission_context("space", target), require_space=False, ) info = self._space.create(spec) - self._log(identity, "create_space", target_id, target_scope=target, detail=auth) + self._log(security, "create_space", target_id, target_scope=target, detail=auth) return info - def get_space(self, org: str, space: str, *, identity: Scope) -> SpaceInfo: + def get_space(self, org: str, space: str, *, security: RequestSecurityContext) -> SpaceInfo: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, Action.READ, "get_space", @@ -1210,21 +1379,21 @@ def get_space(self, org: str, space: str, *, identity: Scope) -> SpaceInfo: context=_space_permission_context("space", target), ) info = self._space.get(org, space) - self._log(identity, "get_space", target_id, target_scope=target, detail=auth) + self._log(security, "get_space", target_id, target_scope=target, detail=auth) return info def list_spaces( self, org: str, *, - identity: Scope, + security: RequestSecurityContext, status: SpaceStatus | None = None, limit: int = 100, cursor: str | None = None, ) -> list[SpaceInfo]: target = Scope(org=org) auth = self._authorize( - identity, + security, target, Action.READ, "list_spaces", @@ -1234,7 +1403,7 @@ def list_spaces( ) spaces = self._space.list(org, status=status, limit=limit, cursor=cursor) self._log( - identity, + security, "list_spaces", org, target_scope=target, @@ -1243,35 +1412,35 @@ def list_spaces( return spaces def update_space( - self, org: str, space: str, patch: SpacePatch, *, identity: Scope + self, org: str, space: str, patch: SpacePatch, *, security: RequestSecurityContext ) -> SpaceInfo: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, - Action.UPDATE, + Action.MANAGE_SPACE, "update_space", target_id, context=_space_permission_context("space", target), ) info = self._space.update(org, space, patch) - self._log(identity, "update_space", target_id, target_scope=target, detail=auth) + self._log(security, "update_space", target_id, target_scope=target, detail=auth) return info - def archive_space(self, org: str, space: str, *, identity: Scope) -> SpaceInfo: + def archive_space(self, org: str, space: str, *, security: RequestSecurityContext) -> SpaceInfo: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, - Action.UPDATE, + Action.MANAGE_SPACE, "archive_space", target_id, context=_space_permission_context("space", target), ) info = self._space.archive(org, space) - self._log(identity, "archive_space", target_id, target_scope=target, detail=auth) + self._log(security, "archive_space", target_id, target_scope=target, detail=auth) return info def delete_space( @@ -1279,7 +1448,7 @@ def delete_space( org: str, space: str, *, - identity: Scope, + security: RequestSecurityContext, mode: DeleteMode = DeleteMode.PURGE, ) -> SpaceDeleteResult: if mode != DeleteMode.PURGE: @@ -1287,9 +1456,9 @@ def delete_space( target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, - Action.DELETE, + Action.MANAGE_SPACE, "delete_space", target_id, context=_space_permission_context("space", target), @@ -1300,7 +1469,7 @@ def delete_space( result.deleted_counts["index"] = result.deleted_counts.get("index", 0) + len(purged) result.deleted_counts["kv"] = result.deleted_counts.get("kv", 0) + len(purged) self._log( - identity, + security, "delete_space", target_id, target_scope=target, @@ -1317,13 +1486,13 @@ def export_space( org: str, space: str, *, - identity: Scope, + security: RequestSecurityContext, include_audit: bool = True, ) -> str: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, Action.READ, "export_space", @@ -1332,7 +1501,7 @@ def export_space( ) export_id = self._space.export(org, space, include_audit=include_audit) self._log( - identity, + security, "export_space", target_id, target_scope=target, @@ -1340,11 +1509,11 @@ def export_space( ) return export_id - def space_usage(self, org: str, space: str, *, identity: Scope) -> SpaceUsage: + def space_usage(self, org: str, space: str, *, security: RequestSecurityContext) -> SpaceUsage: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, Action.READ, "space_usage", @@ -1353,7 +1522,7 @@ def space_usage(self, org: str, space: str, *, identity: Scope) -> SpaceUsage: ) usage = self._space.usage(org, space) self._log( - identity, + security, "space_usage", target_id, target_scope=target, @@ -1366,11 +1535,13 @@ def space_usage(self, org: str, space: str, *, identity: Scope) -> SpaceUsage: ) return usage - def get_space_policy(self, org: str, space: str, *, identity: Scope) -> SpacePolicy: + def get_space_policy( + self, org: str, space: str, *, security: RequestSecurityContext + ) -> SpacePolicy: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, Action.READ, "get_space_policy", @@ -1378,25 +1549,25 @@ def get_space_policy(self, org: str, space: str, *, identity: Scope) -> SpacePol context=_space_permission_context("space_policy", target), ) policy = self._space.get_policy(org, space) - self._log(identity, "get_space_policy", target_id, target_scope=target, detail=auth) + self._log(security, "get_space_policy", target_id, target_scope=target, detail=auth) return policy def set_space_policy( - self, org: str, space: str, policy: SpacePolicy, *, identity: Scope + self, org: str, space: str, policy: SpacePolicy, *, security: RequestSecurityContext ) -> SpacePolicy: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, - Action.UPDATE, + Action.MANAGE_SPACE, "set_space_policy", target_id, context=_space_permission_context("space_policy", target), ) updated = self._space.set_policy(org, space, policy) self._log( - identity, + security, "set_space_policy", target_id, target_scope=target, @@ -1405,12 +1576,12 @@ def set_space_policy( return updated def list_space_members( - self, org: str, space: str, *, identity: Scope + self, org: str, space: str, *, security: RequestSecurityContext ) -> list[SpaceMember]: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, Action.READ, "list_space_members", @@ -1419,7 +1590,7 @@ def list_space_members( ) members = self._space.list_members(org, space) self._log( - identity, + security, "list_space_members", target_id, target_scope=target, @@ -1428,21 +1599,21 @@ def list_space_members( return members def add_space_member( - self, org: str, space: str, member: SpaceMember, *, identity: Scope + self, org: str, space: str, member: SpaceMember, *, security: RequestSecurityContext ) -> None: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, - Action.SHARE, + Action.MANAGE_SPACE, "add_space_member", target_id, context=_space_permission_context("space_member", target), ) self._space.add_member(org, space, member) self._log( - identity, + security, "add_space_member", target_id, target_scope=target, @@ -1450,17 +1621,17 @@ def add_space_member( ) def remove_space_member( - self, org: str, space: str, member: Scope, *, identity: Scope + self, org: str, space: str, member: Scope, *, security: RequestSecurityContext ) -> None: target = _space_scope(org, space) target_id = _space_target_id(org, space) auth = self._authorize( - identity, + security, target, - Action.SHARE, + Action.MANAGE_SPACE, "remove_space_member", target_id, context=_space_permission_context("space_member", target), ) self._space.remove_member(org, space, member) - self._log(identity, "remove_space_member", target_id, target_scope=target, detail=auth) + self._log(security, "remove_space_member", target_id, target_scope=target, detail=auth) diff --git a/src/common/AGENTS.md b/src/common/AGENTS.md index f4579c6d..c9aca038 100644 --- a/src/common/AGENTS.md +++ b/src/common/AGENTS.md @@ -1,6 +1,7 @@ # Agent Memory Common(公共组件层) -**规约文档**:[S07-common.md](../../docs/specs/S07-common.md) +**规约文档**:[S07-common.md](../../docs/specs/S07-common.md);安全横切契约见 +[S09-security.md](../../docs/specs/S09-security.md) > 本文档只记录相对稳定的模块本地规约(职责边界、行为铁律、本地约束)。特性设计与方案取舍记录在 `docs/features/` 下。 @@ -13,10 +14,9 @@ | `base.py` | Plugin 基类:所有共享插件的自描述契约 | | `bootstrap.py` | 统一触发各插件注册(per-layer bootstrap) | | `errors.py` | 自定义异常(ConflictError/NotFoundError/PermissionDeniedError/BackendError 等) | -| `_support.py` | 跨层共用的小工具:配置值布尔归一(`as_bool`)、SSL 配置读取与装配期校验(`SslConfig`/`build_ssl_config`/`require_tls_scheme`/`require_ca_file`/`outbound_verify`/`read_ssl_config`/`reject_url_tls_params`)、scope 命名空间渲染(`SCOPE_DIMS`/`scope_segments`)、后端异常归一(`wrap_backend`);storage、lock 与出站客户端共用,避免各写一份 | | `type_def/` | 核心数据类型定义目录 | | `type_def/memory.py` | MemoryUnit/Relation/Segment/Temporal/ContentLayers 等;MemoryUnit id 在完整 Scope 内唯一;KV key 前缀 `MEMORY_KEY_PREFIX`/`memory_key`(建索引记忆 `/memory/{id}`)。`ContentLayers`(l0/l1) 为分层披露标注,由 LayerAnnotator 对超阈 content 产出 | -| `type_def/scope.py` | Scope:`org/space/user/agent/session` 五维归属;非空 `space` 是全局唯一的逻辑隔离标识且为 keyword-only,旧位置参数保持 `org/user/agent/session` 顺序 | +| `type_def/scope.py` | Scope:`org/space/user/agent/session` 五维归属;非空 `space` 是全局唯一的逻辑隔离标识且为 keyword-only,旧位置参数保持 `org/user/agent/session` 顺序。**frozen value object**(`@dataclass(frozen=True)`):身份/隔离不可变是跨模块安全不变量,改某维用 `dataclasses.replace(scope, org=...)` 返回新值,禁止原地 `scope.x = ...`(抛 `FrozenInstanceError`)。详见 S07 不变量与 F01 决策 16 | | `type_def/filter.py` | FilterClause/FilterGroup/FilterExpr 及 normalize/evaluate;统一 API、检索和存储的树形过滤契约 | | `type_def/memory_filter.py` | MemoryUnit 字段投影与 FilterExpr 公共求值;供 retrieval 真源复核和 KV list 兼容实现共用 | | `type_def/memory_codec.py` | `MemoryUnit` ↔ bytes 编解码(`dumps`/`loads`);当前 `_v=3`,序列化 `layers`({l0,l1}) 与五段 scope,缺失取默认容错老数据,详见 F01-memory-layer / F03-scope-space-isolation | @@ -30,32 +30,32 @@ | `feature_extractor/` | FeatureExtractor 插件目录 | | `llm/` | LLM 插件目录(`echo` / `openai` / `dashscope`) | | `reranker/` | Reranker 插件目录 | +| `security/` | 安全能力的唯一归属地(F05):`types.py`(AuthContext/RequestSecurityContext/CryptoContext/Role/Surface/Credentials/ResourceDescriptor/AuthorizationEnvironment,ContextVar 只作日志-trace 传播)、`request_context.py`(`RequestSecurityContext` 的受控构造入口:`new_request_context` / `internal_context`)、`runtime.py`(SecurityRuntime)、`authentication/`(Authenticator + PrincipalKeyStore + CredentialStatusRegistry,内置 dev/trusted/api_key + memory Argon2id;`PrincipalKeyStore.is_revoked` 供 PEP 在线复核撤销,`CredentialStatusRegistry` 由 PEP 持有按 `(credential_type, credential_issuer)` 路由撤销查询,不放 Authorizer)、`authorization/`(Authorizer + GrantStore + DelegationStore,内置 standard/allow_all + memory/sqlite 存储)、`protection/`(RateLimiter/WorkloadGuard/BindingPolicy,内置 token_bucket/unlimited/semaphore/loopback)、`cryptography/`(CryptographyProvider + KeyProvider(含 `rotate` 轮换契约),内置 `local` ENC1 AES-GCM)。注册入口 `security/bootstrap.py::register_security()` | | `audit/` | AuditLogger 插件目录 | -| `security/` | SecurityProvider 横切接口目录(接口 + `local` ENC1 AES-GCM 实现) | -| `lock/` | LockProvider 横切接口目录:跨实例互斥原语(接口 + `redis` / `memory` 实现)。**common 层唯一的异步契约**,只交付原语、不在业务路径加锁,见 [F06-distributed-lock.md](../../docs/features/common/F06-distributed-lock.md) | ## 行为铁律 1. **插件接口与实现严格分离** 接口模块(`/base.py`)定义抽象契约 + Producer 工厂类,零依赖实现。实现模块(`/_impl/*.py`)具体实现 + 尾部 `@XxxProducer.register("name")` 自注册。消费方只 import 接口层,不触达 `*_impl`。 - 唯一例外是 `LockProvider`:`acquire`/`release`/`guard` 在接口层落实现,只抽象后端原语。重入记账与 guard 组合是契约级行为而非后端细节,下沉会在两个实现里分叉。新增组件不得援引此例外,除非同样能论证「行为属于契约本身」。 2. **工厂随契约(住在接口层)** 每个插件的 Producer 工厂定义在其接口模块(`base.py`)中,与抽象契约同处一地。 3. **注册靠 import 触发** - 实现文件尾部 `@XxxProducer.register("name")` 注册 _build 函数,`*_impl/__init__.py` import 各实现模块触发注册,`bootstrap.py::register_plugins()` 在装配前统一触发。 + 实现文件尾部 `@XxxProducer.register("name")` 注册 _build 函数,`*_impl/__init__.py` import 各实现模块触发注册,`bootstrap.py::register_plugins()` 在装配前统一触发(安全域转交 `security/bootstrap.py::register_security()`)。 -4. **types.py 零依赖其他文件** - `type_def/*.py` 是纯数据定义,不 import 本层其他文件,被全局共享依赖。 +4. **type_def 不依赖能力实现** + `type_def/*.py` 只定义跨层数据与 ContextVar,可在 `type_def` 内部引用基础类型 + (如 `audit.py` 引用 `scope.py`),不得 import security/audit/storage 等能力实现。 + 安全类型住 `security/types.py` 而非 `type_def/`:`type_def` 被所有层 import,身份 + 类型放进去会让「谁能构造/改写身份」的边界消失。 5. **共享插件必须双侧同一** Embedder/Tokenizer/FeatureExtractor 必须在构建侧与检索侧使用同一实现/同一配置,保证同词表/同向量空间。靠配置里「具名 + 引用」显式表达共享:双侧 `dep` 引用同一具名实例 → `build_named` 命中同一缓存键 → 同一实例。 6. **业务 metadata 保留原生类型** `MemoryUnit` / `RawPayload` / `Chunk` / `Relation` 的 metadata 使用 `dict[str, Any]`; - 不在公共类型层统一 string 化。过滤只做严格类型与形态比较,不推测字符串数值的业务 - 含义;`EQ` / `IN` 匹配标量,`CONTAINS` 只做数组成员匹配。 + 不在公共类型层统一 string 化。过滤只做严格类型比较,不推测字符串数值的业务含义。 ## 与其他子目录的边界 @@ -63,7 +63,7 @@ - 共享插件接口定义与注册式工厂 - 核心数据类型(MemoryUnit/Scope/Context/Relation/Chunk/AuditEvent 等) - 工厂注册基础设施(Factory 基类 + `TOP_NAME` 命名空间 + `build`/`build_named`/`dep` 三接口) -- 横切接口(AuditLogger / SecurityProvider / LockProvider) +- 横切接口(Authenticator / PrincipalKeyStore / RateLimiter / WorkloadGuard / BindingPolicy / CryptographyProvider / KeyProvider / AuditLogger) - 错误类型 - 工具函数 @@ -71,31 +71,30 @@ - 具体算子实现(归各层 `*_impl/`) - 存储后端实现 - 业务编排逻辑 -- 鉴权/策略管理 +- 授权的**执行点**(PEP 是 `api/MemoryAPI`)与业务权限语义的编排;授权**判定**(PDP)本身归 `common/security/authorization/` ## 本地约束 -1. 所有插件必须实现 `plugin_type()` 和 `health()`(继承自 `Plugin` 基类)。 +1. 继承 `Plugin` 的模型插件必须实现 `plugin_type()` 和 `health()`;横切能力不继承 + `Plugin`,只实现各自 `base.py` 的契约(例如 Authenticator 有 `health()`,AuditLogger + 没有 `plugin_type()`)。 2. 实现通过 `@XxxProducer.register("name")` 自注册。 3. 新增插件实现:在 `_impl/` 下新建文件 → 实现接口 → 尾部注册 → 在 `__init__.py` 添加 import。 4. 重依赖实现在 `*_impl/__init__.py` 中用 `try/except ImportError` 包裹。 5. 两级命名空间配置驱动装配:每个 Producer 声明全局唯一 `TOP_NAME`(占配置顶层段),其下是若干具名实例(`target` 指定实现名、`params` 传参、`new_instance` 控制是否共享)。`_build(config)` 里用 `XProducer.dep(config, param_name=None, default=...)` 取子依赖(引用名→共享 / 内联 dict→匿名 / 缺省→默认匿名)。 6. LLM 的厂商扩展参数必须由对应 Provider Adapter 注入;构建、检索等内核业务调用点不得硬编码 `extra_body` 等传输层字段。 -7. SecurityProvider、AuditLogger 与 LockProvider 都是横切组件,不继承 `Plugin`、不进入 `PluginType`;实现仍通过独立 Producer 与 `*_impl` 自注册。横切组件的接口文件命名为 `/.py`(不是插件的 `base.py`)。 -8. 出站 HTTP 客户端(LLM / Embedder / Reranker)统一接受 `_ssl_verify` / - `_ssl_ca_cert`(默认关闭),经 `_support.read_outbound_ssl` 读取。开启时须调 - `require_https` 与 `require_ca_file` 在装配期拦截明文 scheme 和缺失证书,并只在此时 - 注入 `http_client`。OpenAI SDK 相关实现必须使用 `openai.DefaultHttpxClient`,不得用 - 裸 `httpx.Client` 覆盖 SDK 的长读取超时、连接池与重定向等默认值。`verify` 取值统一经 - `outbound_verify` 翻译,不在各实现里内联。缺证书回落系统 CA 而非报错,这是与 - storage 侧唯一的差异,详见 - [F05-model-service-ssl.md](../../docs/features/common/F05-model-service-ssl.md)。 -9. SSL 相关的公共件只在 `_support.py` 实现一份:`as_bool` / `SslConfig` / - `build_ssl_config` / `require_tls_scheme` / `require_ca_file` / `outbound_verify` / - `read_ssl_config` / `reject_url_tls_params`。storage 层、lock 与 security 层均从此处 - 引用,新增出站客户端不得再各写一份归一或校验逻辑。同理,scope 命名空间渲染 - (`SCOPE_DIMS` / `scope_segments`)与后端异常归一(`wrap_backend`)也只此一份, - `storage/_support.py` 是再导出而非第二实现。 -10. LockProvider 的契约是异步的,`health()` 随之异步——这是 common 层唯一的异步组件。 - 锁只交付原语,本层不在任何业务路径上加锁;在哪些临界区取锁由各消费方自行论证。 - 锁是基于租约的协调机制而非共识算法,依赖方必须能容忍偶发互斥失效或自备第二道防线。 +7. 横切能力(Authenticator / PrincipalKeyStore / Authorizer / GrantStore / + DelegationStore / RateLimiter / WorkloadGuard / + BindingPolicy / CryptographyProvider / KeyProvider / AuditLogger)不继承 `Plugin`、 + 不进入 `PluginType`;接口统一在能力目录的 `base.py`(安全域为 + `security/<能力域>/`),实现统一在同级 `*_impl/`,YAML 只能选择已经注册的 target + 并传递 params。当前不从 YAML import Python 类,也不自动发现未被应用启动代码 + import 的外部包。 +8. 安全能力一律落 `security/<能力域>/`,不新开顶层目录。核心不得按 target 名或 + `mode()` 字符串分支——需要区分的行为差异由 capability 方法(如 + `requires_loopback_binding()`、`bind_instance_name()`、`is_test_only()`)显式声明,详见 S09。 +9. `RequestSecurityContext` 只能由 `security/request_context.py` 的两个入口构造: + `request_id` 由服务端生成、`started_at` 取服务端时钟、`attributes` 只由系统组件 + 写入、`surface` 无默认值必须由适配层写入。进程内直连调用方走 `internal_context(authenticator)`, + 身份仍由 authenticator 产出——不存在 `auth=None`,也不存在把传入 Scope 直接当成 + 已认证 actor 的旁路(F05 §进程内调用)。 diff --git a/src/common/bootstrap.py b/src/common/bootstrap.py index 62cd570c..0c2aaab2 100644 --- a/src/common/bootstrap.py +++ b/src/common/bootstrap.py @@ -1,7 +1,7 @@ """注册引导:import 各共享组件实现包,触发其 ``@Producer.register`` 自注册。 -工厂句柄定义在各组件的接口模块(多数插件为 ``common..base``,横切组件 -security / lock 为 ``common..``),消费方只依赖接口层;实现的注册 +工厂句柄定义在各组件的接口模块(统一为 ``common..base``), +消费方只依赖接口层;实现的注册 发生在 import 实现模块时,由本函数在装配入口统一触发。与各层 bootstrap 同构。 """ @@ -25,6 +25,5 @@ def register_plugins() -> None: import_module("common.reranker.reranker_impl") import_module("common.llm.llm_impl") import_module("common.audit.audit_impl") - import_module("common.security.security_impl") - import_module("common.lock.lock_impl") + import_module("common.security.bootstrap").register_security() _REGISTERED = True diff --git a/src/common/errors.py b/src/common/errors.py index f5109329..6c1fbc5e 100644 --- a/src/common/errors.py +++ b/src/common/errors.py @@ -48,6 +48,33 @@ def __init__(self, action: str = "", message: str = "") -> None: super().__init__(message or f"permission denied: {action or 'action'}") +class AuthenticationError(AgentMemoryError): + """ + 凭据缺失、格式非法或校验不通过:认证能力(``src/common/authentication``)产出。 + + 与 :class:`PermissionDeniedError` 的区别是「不知道你是谁」(401)对 + 「知道你是谁但不许做」(403)——两者必须可分,否则 HTTP 层无法映射 + 正确状态码,调用方也无法区分「该带凭据」与「该申请授权」。 + + 对外错误消息一律笼统,不区分「主体不存在」与「凭据错误」:区分了就 + 成为主体枚举的侧信道。具体原因写进审计事件的 ``detail``。 + """ + + +class RateLimitedError(AgentMemoryError): + """ + 调用方超出速率上限:资源保护(``src/common/security/protection/``)产出。 + + 与 :class:`AuthenticationError` 必须可分(429 对 401):限流发生在认证 + **之前**,此时还不知道凭据对不对——把它报成 401 会让「你被限流了」和 + 「你的 key 错了」混在一起,运维排障时无法区分,客户端也不知道该重试 + 还是该换凭据。 + + 对外消息同样笼统:不透露桶容量、剩余令牌、已计数的请求数——那些都能 + 用来反推限流参数并贴着阈值发请求。 + """ + + class ValidationError(AgentMemoryError): """ 入参非法或不满足约束:如 ``DeleteSelector`` 未给任何条件、参数越界、 diff --git a/src/common/security/__init__.py b/src/common/security/__init__.py index aa13bce4..27fe6509 100644 --- a/src/common/security/__init__.py +++ b/src/common/security/__init__.py @@ -1,27 +1,34 @@ -"""安全横切接口:加密/解密等数据保护能力。""" +"""安全域:认证、密码学、资源保护与请求安全上下文(F05 Common Security)。 -from .security import ( - AuthenticationFailedError, - CorruptedCiphertextError, - EncryptionError, - InvalidMagicError, - KeyMismatchError, - SecurityContext, - SecurityError, - SecurityProducer, - SecurityProvider, +本包是安全能力的**唯一归属地**。消费方(Bootstrap/Surface、MemoryAPI、Storage +适配器、Audit)只 import 本包的契约与值对象,不反向被 import。 + +各能力子包按 F05 目录组织;``audit_integrity/`` 由 PR3 补齐。本模块只再导出跨能力 +共享的公共类型、``RequestSecurityContext`` 的受控构造入口与 Runtime——各能力的契约从 +其子包取(``common.security.authentication`` 等),避免顶层 ``__init__`` 变成什么都有 +的入口而在装配前意外触发全部 import。 +""" + +from .request_context import internal_context, new_request_context +from .runtime import SecurityRuntime, SecurityRuntimeProducer +from .types import ( + AuthContext, + Credentials, + CryptoContext, + RequestSecurityContext, + Role, + Surface, ) -from .key_source import KeySource __all__ = [ - "AuthenticationFailedError", - "CorruptedCiphertextError", - "EncryptionError", - "InvalidMagicError", - "KeyMismatchError", - "SecurityContext", - "SecurityError", - "SecurityProducer", - "SecurityProvider", - "KeySource", + "AuthContext", + "Credentials", + "CryptoContext", + "RequestSecurityContext", + "Role", + "SecurityRuntime", + "SecurityRuntimeProducer", + "Surface", + "internal_context", + "new_request_context", ] diff --git a/src/common/security/authentication/__init__.py b/src/common/security/authentication/__init__.py new file mode 100644 index 00000000..b44a80c4 --- /dev/null +++ b/src/common/security/authentication/__init__.py @@ -0,0 +1,20 @@ +"""认证能力:契约、主体凭据注册表与内置实现(F05 §Authentication)。""" + +from .base import Authenticator, AuthProducer +from .key_store import ( + KeyStoreProducer, + PrincipalKeyStore, + fingerprint, + generate_api_key, + key_prefix, +) + +__all__ = [ + "AuthProducer", + "Authenticator", + "KeyStoreProducer", + "PrincipalKeyStore", + "fingerprint", + "generate_api_key", + "key_prefix", +] diff --git a/src/common/security/authentication/authentication_impl/__init__.py b/src/common/security/authentication/authentication_impl/__init__.py new file mode 100644 index 00000000..ebb03fce --- /dev/null +++ b/src/common/security/authentication/authentication_impl/__init__.py @@ -0,0 +1,21 @@ +"""authentication_impl 实现集:工厂 AuthProducer / KeyStoreProducer + 各实现。 + +import 各实现模块即触发其 ``@AuthProducer.register(...)`` / +``@KeyStoreProducer.register(...)`` 自注册;本包只对外暴露两个工厂。 + +key_store 先于 authenticator import:后者的 ``_build`` 通过 +``KeyStoreProducer.dep(..., default="memory")`` 引用前者的注册名。注册发生在 +import 期而装配发生在 build 期,顺序其实不影响正确性,但保持依赖方向可读。 +""" + +from importlib import import_module + +from common.security.authentication.base import AuthProducer +from common.security.authentication.key_store import KeyStoreProducer + +import_module(".memory_key_store", __name__) +import_module(".api_key_authenticator", __name__) +import_module(".dev_authenticator", __name__) +import_module(".trusted_authenticator", __name__) + +__all__ = ["AuthProducer", "KeyStoreProducer"] diff --git a/src/common/security/authentication/authentication_impl/api_key_authenticator.py b/src/common/security/authentication/authentication_impl/api_key_authenticator.py new file mode 100644 index 00000000..6b805968 --- /dev/null +++ b/src/common/security/authentication/authentication_impl/api_key_authenticator.py @@ -0,0 +1,127 @@ +"""API_KEY 认证:框架自校验 API Key(F05 §Authentication)。 + +两步:先常时间比对配置声明的 Root API Key,未命中再查主体注册表。 +Root Key **不入注册表**——它是部署级凭据,不属于任何 org。 +""" + +from __future__ import annotations + +import hmac +import logging +from dataclasses import replace +from datetime import datetime, timezone + +from common.errors import AuthenticationError, ValidationError +from common.security.authentication.base import Authenticator, AuthProducer +from common.security.authentication.key_store import ( + KeyStoreProducer, + PrincipalKeyStore, + fingerprint, +) +from common.security.types import AuthContext, Credentials, Role +from common.type_def.scope import Scope + +_LOG = logging.getLogger(__name__) + +_METHOD = "api_key" # 开放字符串而非封闭枚举(F05 拒绝以模式名驱动核心分支) +_ROOT_CREDENTIAL = "root_api_key" + +_FAILED = "authentication failed" + +# Root Key 对应的主体。**不是空 ``Scope()``**:F05 §授权不变量 1 要求 ROOT 由 ``role`` +# 表达,actor 只表达「是谁」。空 actor 在 ``StandardAuthorizer`` 是 deny 而非放行。 +_ROOT_ACTOR = Scope(org="system", user="root") + + +class ApiKeyAuthenticator(Authenticator): + """Root Key 常时间比对 + 主体注册表查询。""" + + def __init__( + self, key_store: PrincipalKeyStore, root_api_key: str = "", name: str = "default" + ) -> None: + self._key_store = key_store + self._root_key = root_api_key + # Root Key 指纹装配期算一次:认证路径上不再碰明文,也避免每请求做一次 + # sha256。指纹不可逆,进 AuthContext 与审计都是安全的。 + self._root_key_fp = fingerprint(root_api_key) if root_api_key else "" + # Round3: 存储具名实例名称,用于 Registry 复合键 + # Round4 P1-4: 具名实例名称用于 credential_issuer,不再覆盖 auth_method + self._name = name + + @property + def key_store(self) -> PrincipalKeyStore: + """本认证器持有的主体注册表(供 PEP 的 CredentialStatusRegistry 注册共享)。""" + return self._key_store + + def authenticate(self, credentials: Credentials) -> AuthContext: + api_key = credentials.api_key + if not api_key: + raise AuthenticationError(_FAILED) + + # Step 1: Root API Key。 + # encode 成 bytes 再比:compare_digest 的 str 版要求两边都是 ASCII-only, + # 攻击者提交的非 ASCII key 会让它抛 TypeError(→ 500 而非 401), + # 且泄露「你提交了非 ASCII」。str.encode 对任何 str 都成功,且 + # compare_digest 对长度不等的输入仍不早退。 + if self._root_key and hmac.compare_digest( + self._root_key.encode("utf-8"), api_key.encode("utf-8") + ): + return AuthContext( + actor=_ROOT_ACTOR, + role=Role.ROOT, + credential_type=_ROOT_CREDENTIAL, + credential_id=self._root_key_fp, + auth_method=_METHOD, # Round4 P1-4: 保留协议标识语义 + credential_issuer=self._name, # Round4 P1-4: 具名实例名称 + authenticated_at=datetime.now(timezone.utc), + ) + + # Step 2: 主体注册表(内部已做常时间比对与 dummy pad)。 + # 先校验 key_store 实现了 is_revoked:第三方 PrincipalKeyStore 漏实现时,在 + # 认证期就失败,而非让 PEP 在首个授权请求才发现 NotImplementedError(500)-- + # F05 §装配不变量「不健康能力启动期拒绝」在认证边界这一侧的落地。 + if type(self._key_store).is_revoked is PrincipalKeyStore.is_revoked: + raise ValidationError( + "api_key 认证要求 key_store 实现 is_revoked 以支持凭据在线撤销复核" + ) + identity = self._key_store.resolve(api_key) + if identity is None: + raise AuthenticationError(_FAILED) + # 注册表只知道「凭据是什么」(credential_type / credential_id),认证方法名 + # 由认证实现补齐——同一个 key_store 可被别的认证实现复用。 + # Round4 P1-4: auth_method 保留协议标识,credential_issuer 携带具名实例名称。 + return replace( + identity, + auth_method=_METHOD, + credential_issuer=self._name, + authenticated_at=datetime.now(timezone.utc), + ) + + def mode(self) -> str: + return _METHOD + + def requires_loopback_binding(self) -> bool: + return False + + def bind_instance_name(self, name: str) -> None: + """为未具名的内联实例补上稳定 issuer,不覆盖显式具名配置。""" + if not self._name or self._name == "default": + self._name = name + + def health(self) -> None: + self._key_store.health() + + +@AuthProducer.register("api_key") +def _build(config): + root_key = str(config.get("root_api_key", "") or "").strip() + if not root_key: + # 引导问题:没有 root key 就没人能签发第一把主体 key。 + # 只警告不阻断——root key 已轮换掉、只留主体 key 的部署是合法的。 + _LOG.warning( + "api_key 认证模式未配置 root_api_key:无法签发首把主体 key。" + "若这是有意的(root key 已轮换),可忽略本警告。" + ) + key_store = KeyStoreProducer.dep(config, "key_store", default="memory") + # Round3: 传递具名实例名称,用于 Registry 复合键和 AuthContext.auth_method + return ApiKeyAuthenticator(key_store=key_store, root_api_key=root_key, name=config.name) diff --git a/src/common/security/authentication/authentication_impl/dev_authenticator.py b/src/common/security/authentication/authentication_impl/dev_authenticator.py new file mode 100644 index 00000000..21bb1e5b --- /dev/null +++ b/src/common/security/authentication/authentication_impl/dev_authenticator.py @@ -0,0 +1,53 @@ +"""DEV 认证:无条件返回 ROOT(F05 §Authentication)。 + +**只用于本地开发。** 配套的 loopback 强制绑定由 +:class:`~common.security.protection.binding_policy.BindingPolicy` 在 socket 绑定前 +执行——本类不知道服务器绑了哪个地址,也不该在一个可被单测 import 的类里 ``sys.exit``。 +它只负责声明 ``requires_loopback_binding()``,由 surface 拿这个 capability 去调策略。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from common.security.authentication.base import Authenticator, AuthProducer +from common.security.types import AuthContext, Credentials, Role +from common.type_def.scope import Scope + +_METHOD = "dev" # 开放字符串而非封闭枚举(F05 拒绝以模式名驱动核心分支) + +# 本地开发主体。**不是空 ``Scope()``**:空 Scope 在旧实现里是 platform-admin 的隐式 +# 形态,F05 §授权不变量 1 把那条线断掉了——ROOT 只由 ``role`` 表达,actor 只表达 +# 「是谁」。给它一个具名主体,审计里也才看得出这条记录出自 dev 认证。 +_DEV_ACTOR = Scope(org="system", user="dev") + + +class DevAuthenticator(Authenticator): + """恒 ROOT,不校验任何凭据。""" + + def authenticate(self, credentials: Credentials) -> AuthContext: + """无条件返回 ROOT 身份。 + + 权限来自 ``role=Role.ROOT``(服务端角色),不来自 actor 的形状。 + """ + return AuthContext( + actor=_DEV_ACTOR, + role=Role.ROOT, + credential_type=_METHOD, + auth_method=_METHOD, + authenticated_at=datetime.now(timezone.utc), + ) + + def mode(self) -> str: + return _METHOD + + def requires_concurrency_guard(self) -> bool: + return False + + def health(self) -> None: + return None + + +@AuthProducer.register("dev") +def _build(config): + return DevAuthenticator() diff --git a/src/common/security/authentication/authentication_impl/memory_key_store.py b/src/common/security/authentication/authentication_impl/memory_key_store.py new file mode 100644 index 00000000..17c6093b --- /dev/null +++ b/src/common/security/authentication/authentication_impl/memory_key_store.py @@ -0,0 +1,277 @@ +"""进程内 :class:`~common.security.authentication.key_store.PrincipalKeyStore`,Argon2id 校验。 + +**已知限制(两条,均在归档文档「已知遗留」列明)**: + +1. **性能**:Argon2id 128 MiB × time_cost=4 的单次 verify 在典型硬件上 + 50~200ms,意味着 API 吞吐上限约 5~20 QPS/核。第一期**不做验证缓存**—— + 缓存会带来撤销延迟(撤销后缓存内 key 仍有效 = 安全漏洞)这个新的安全问题, + 在没有生产流量的阶段不值得引入。高 QPS 场景需要带撤销传播的缓存。 +2. **持久化**:进程重启后所有已签发的 key 失效。生产需要 SQLite 后端。 + +注册名是 ``memory`` 而非 ``argon2``:Argon2 描述的是**哈希算法**(内部细节), +``memory`` 描述的是**存储后端**,与主干 ``sqlite_permission_manager`` / +``in_memory_governor`` 的命名惯例一致。 +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass +from typing import Any + +from common.errors import PermissionDeniedError, ValidationError +from common.security.authentication.key_store import ( + KeyStoreProducer, + PrincipalKeyStore, + fingerprint, + generate_api_key, + key_prefix, +) +from common.security.types import AuthContext, Role +from common.type_def.scope import Scope + +# Argon2id 参数(OWASP 2024+,security.md §2.3.1)。 +# 显式指定全部五项,不用库默认——argon2-cffi 的默认 memory_cost 是 64 MiB, +# 低于 OWASP 推荐,且默认值随版本变化。 +_TIME_COST = 4 +_MEMORY_COST = 131072 # 128 MiB +_PARALLELISM = 2 +_HASH_LEN = 32 +_SALT_LEN = 16 + +_DUMMY_KEY = "dummy-key-for-timing-pad" + +_CREDENTIAL = "api_key" # 本注册表签发的凭据类型;认证方法名由认证实现补齐 + + +@dataclass +class _Record: + """一条主体 key 记录。**不含明文**——明文只在 issue 时返回一次。""" + + key_fp: str + key_hash: str + actor: Scope + role: Role + revoked: bool = False + + +class InMemoryKeyStore(PrincipalKeyStore): + """进程内注册表 + Argon2id 校验。 + + ``hasher`` 与异常类型由 ``_build`` 注入:argon2-cffi 是可选依赖,模块顶层 + import 会让缺依赖的环境连 DEV 模式都起不来(``register_plugins()`` 无差别 + import 全部实现包)。见 ``_build``。 + """ + + def __init__(self, hasher: Any, mismatch_errors: tuple[type[BaseException], ...]) -> None: + self._hasher = hasher + self._mismatch_errors = mismatch_errors + self._records: dict[str, _Record] = {} # key_fp -> record + self._prefix_index: dict[str, list[str]] = {} # key 前缀 -> [key_fp] + # role 按 **principal**(org + user/agent)索引,不含 space、不含 session: + # §3.1 角色是 principal 级(一个 user 是 USER/ADMIN/ROOT,不随 space 或 session + # 变)。含 space 会让「同 principal 不同 space 同 role」变成两条互覆记录; + # 含 session 会让「同一 principal 换个 session 登录」查不到 role。两者都是 + # 把非身份维度塞进了身份索引。 + self._roles: dict[tuple[str, str, str], Role] = {} # (org, user, agent) -> role + # 状态锁:issue 的「检查 role -> hash -> 写 record/role」、revoke 的「标记撤销 + # -> 重算 role」、resolve 的「取候选 -> 确认未撤销」都必须原子(验收复验 P2-role: + # 否则两线程并发 issue 不同 role,都看到 existing=None,最终 _records 同时存在 + # USER/ADMIN 而 _roles 只留最后写入者)。RLock 因 resolve 在锁内调 _verify 之外 + # 不需要重入,但 revoke/get_role 可能被同链路调用,RLock 更稳。 + self._lock = threading.RLock() + # dummy 哈希供 resolve 未命中时 pad 时间。装配期算一次(约 100ms), + # 之后每次 resolve 复用。 + self._dummy_hash: str = hasher.hash(_DUMMY_KEY) + + # -- 内部 ------------------------------------------------------------ # + + @staticmethod + def _role_key(actor: Scope) -> tuple[str, str, str]: + """principal 级 role 索引键:(org, user, agent)。 + + Scope 是可变 dataclass(unhashable),不能直接作 dict key。这里只取身份 + 维度(§3.1:role 是 principal 级),不含 space / session--见 ``_roles`` 注释。 + """ + return (actor.org, actor.user, actor.agent) + + def _verify(self, stored_hash: str, provided: str) -> bool: + """常时间校验。 + + 只捕获 argon2 的校验类异常:未预期的异常(如内存不足)应该炸出来, + 静默 ``return False`` 会把系统性故障伪装成认证失败。 + """ + try: + return bool(self._hasher.verify(stored_hash, provided)) + except self._mismatch_errors: + return False + + # -- 契约 ------------------------------------------------------------ # + + def issue(self, actor: Scope, role: Role) -> str: + if role is Role.ROOT: + # §3.2「明确禁止」:ROOT 只能来自配置声明的 Root API Key。 + raise PermissionDeniedError("issue", message="cannot issue a ROOT key") + if bool(actor.user) == bool(actor.agent): + # §4.1:同一个归属 scope 不应同时设置 user 与 agent;也不能都不设, + # 否则签出的是「整个 org」这种无主体的 key。 + raise ValidationError("principal scope must set exactly one of user / agent") + if not actor.org: + raise ValidationError("principal scope must set org") + + # role 是 principal 的唯一权威状态(§3.1),不是每把 key 的可冲突副本: + # 同 principal 已有不同 role 的有效 key 时拒绝签发(审计验收 P2-role)。 + # 否则 issue 覆盖 _roles 后,resolve(读 record.role)与 get_role(读 _roles) + # 会返回不一致;revoke ADMIN key 后 _roles 仍残留 ADMIN = 撤销后提权残留。 + # 换 role 须先 revoke 该 principal 全部 key,或走专门的 set_role(本期未提供)。 + # + # 并发原子性(验收复验 P2-role):Argon2 hash(~200ms)在锁**外**算,进锁后 + # **重新检查** principal role 再原子提交 record/index/role。否则两线程并发 + # issue 不同 role,都看到 existing=None,最终 _records 同时存在 USER/ADMIN。 + api_key = generate_api_key() + key_fp = fingerprint(api_key) + key_hash = self._hasher.hash(api_key) # 昂贵,锁外算 + role_key = self._role_key(actor) + with self._lock: + existing = self._roles.get(role_key) + if existing is not None and existing is not role: + raise ValidationError( + f"principal 已持有 role={existing.value},签发不同 role={role.value} 前须先 " + f"revoke 其全部 key" + ) + # 检查通过 -> 原子提交三者 + self._records[key_fp] = _Record( + key_fp=key_fp, + key_hash=key_hash, + actor=actor, + role=role, + ) + self._prefix_index.setdefault(key_prefix(api_key), []).append(key_fp) + self._roles[role_key] = role + return api_key + + def resolve(self, api_key: str) -> AuthContext | None: + # 并发契约(验收复验 P2-role):不在锁内跑完整 Argon2(~200ms,会串行化所有 + # 认证)。先锁内取候选快照,锁外 verify,命中后再锁内确认记录未被撤销。 + with self._lock: + candidates = [ + self._records.get(key_fp) + for key_fp in self._prefix_index.get(key_prefix(api_key), ()) + ] + candidates = [r for r in candidates if r is not None and not r.revoked] + verified_any = False + for record in candidates: + verified_any = True + if self._verify(record.key_hash, api_key): + # 命中:锁内确认记录仍存在且未撤销(revoke 可能在这期间发生) + with self._lock: + current = self._records.get(record.key_fp) + if current is None or current.revoked: + continue + return AuthContext( + actor=current.actor, + role=current.role, + credential_type=_CREDENTIAL, + credential_id=record.key_fp, + ) + + # 无候选时补一次 dummy verify,把耗时 pad 到与「有候选」路径同量级。 + # 少了它,「前缀不存在」比「前缀存在但 key 错」快一整个 Argon2 verify + # (~200ms),可用来枚举有效 key 前缀(§2.3.2)。 + # + # 条件是 `not verified_any` 而非无条件:无条件 pad 会让「有候选但 key 错」 + # 跑两次 verify,反而造出一个反向的 2x 时间差--同样是可测量的侧信道。 + # 三条路径(命中 / 有候选未命中 / 无候选)都恰好一次 verify 才是对的。 + if not verified_any: + self._verify(self._dummy_hash, api_key) + return None + + def revoke(self, key_fp: str) -> None: + with self._lock: + record = self._records.get(key_fp) + if record is None: + return # 幂等 + record.revoked = True + # 按剩余有效 key 重算 role(审计验收 P2-role):此前「还有任意有效 key 就保留 + # 当前 _roles」不重算,会残留被撤销 key 的 role。现在取剩余有效 key 的 role-- + # 因 issue 已禁止同 principal 不同 role,剩余 key 的 role 恒与被撤销的一致, + # 但重算使「先 revoke ADMIN 再 revoke USER」等顺序无关。无剩余 key 则清空。 + key = self._role_key(record.actor) + remaining = [ + r + for r in self._records.values() + if not r.revoked and self._role_key(r.actor) == key + ] + if remaining: + self._roles[key] = remaining[0].role + else: + self._roles.pop(key, None) + + def is_revoked(self, credential_id: str) -> bool: + # credential_id 即 issue 时算的 key 指纹;空串(未走可撤销凭据的认证路径) + # 直接返回 False,不查表。 + if not credential_id: + return False + with self._lock: + record = self._records.get(credential_id) + return record is not None and record.revoked + + def get_role(self, actor: Scope) -> Role | None: + with self._lock: + return self._roles.get(self._role_key(actor)) + + def health(self) -> None: + return None + + +# -- 注册到 KeyStoreProducer ------------------------------------------------ # + + +@KeyStoreProducer.register("memory") +def _build(config): + """装配 InMemoryKeyStore。 + + argon2-cffi 的 import 在**这里**而非模块顶层:``register_plugins()`` 会 + 无差别 import 整个 ``authentication_impl`` 包,顶层 import 会让缺依赖的环境连 + DEV 模式都起不来。挪进 builder 后,注册总能成功,只有真正装配本实现时才 + 要求依赖,且失败是装配期的清晰 ``ValidationError``。 + + **绝不回退到明文比对**:加密层也关闭时 key 就是磁盘上的裸明文。fail-closed。 + """ + try: + from argon2 import PasswordHasher + from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + except ImportError as exc: + # 区分两种情况给运维可操作的诊断: + # - argon2 包本身没装 -> 装 extra; + # - 包装了但版本太旧(如 21.3.0 没有 InvalidHashError)-> 升级到 >=23.1。 + # 两者都是 ImportError(子模块存在但缺名字也抛 ImportError),靠 argon2 顶层 + # 能否 import 区分。 + try: + import argon2 # noqa: F401 + except ImportError: + raise ValidationError( + "key_store 'memory' 需要 argon2-cffi:pip install 'JiuwenMemory[security]'。" + "不回退到明文比对--那会让 key 变成磁盘上的裸明文。" + ) from exc + # argon2 顶层能 import 但 from ... import 失败 = 版本过旧(如 21.3.0 + # 无 InvalidHashError)。区分两路径给运维可操作诊断(审计验收 P1-uv.lock)。 + raise ValidationError( + "key_store 'memory' 需要 argon2-cffi>=23.1(当前版本过旧,缺少" + " InvalidHashError):升级 pip install 'JiuwenMemory[security]' --upgrade。" + "不回退到明文比对。" + ) from exc + + hasher = PasswordHasher( + time_cost=_TIME_COST, + memory_cost=_MEMORY_COST, + parallelism=_PARALLELISM, + hash_len=_HASH_LEN, + salt_len=_SALT_LEN, + ) + # VerifyMismatchError 是正常的「key 错」路径;InvalidHashError / + # VerificationError 是哈希损坏或参数不符 → 同样 fail-closed 判为不通过。 + return InMemoryKeyStore( + hasher=hasher, + mismatch_errors=(VerifyMismatchError, InvalidHashError, VerificationError), + ) diff --git a/src/common/security/authentication/authentication_impl/trusted_authenticator.py b/src/common/security/authentication/authentication_impl/trusted_authenticator.py new file mode 100644 index 00000000..53a77846 --- /dev/null +++ b/src/common/security/authentication/authentication_impl/trusted_authenticator.py @@ -0,0 +1,141 @@ +"""TRUSTED 认证:信任上游网关已完成认证(F05 §Authentication)。 + +网关注入身份声明 header,框架据此构造 actor。**关键设计:role 不从 header 读** +——header 说「你是谁」,框架自己查注册表得「你能干什么」。这样即使网关被攻破 +或误配,攻击者也无法通过伪造 ``X-Role: root`` 提权。 + +同理,``X-Delegation-Id`` 只是一个**标识**,不是委托关系本身。header 里出现一个 id +只证明「调用方声称在用这条委托」,证明不了「这条委托存在、未撤销、未过期且覆盖本次 +动作」——那些由 Authorizer 回 ``DelegationStore`` 复核(F05 §从 header 直接产生 +Delegation)。旧的 ``X-Acting-User`` header 已删除:它让网关的一句声明直接成为跨主体 +授权结论,中间没有任何服务端事实。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +from datetime import datetime, timezone + +from common.errors import AuthenticationError, ValidationError +from common.security.authentication.base import Authenticator, AuthProducer +from common.security.authentication.key_store import ( + KeyStoreProducer, + PrincipalKeyStore, + fingerprint, +) +from common.security.types import AuthContext, Credentials +from common.type_def.scope import Scope + +_LOG = logging.getLogger(__name__) + +# header 名硬编码,不做成配置项:没有第二个网关约定的时候,可配置只是多一处 +# 误配可能(配错了就静默认证失败)。gateway_key 是配置项,因为它是部署相关 +# 的秘密,必须能从环境变量注入。 +# +# 键为小写:HTTP header 名大小写不敏感(RFC 9110 §5.1), +# ``credentials_from_headers`` 已把所有键归一为小写。 +_H_ORG = "x-org-id" +_H_TYPE = "x-principal-type" +_H_ID = "x-principal-id" +_H_DELEGATION = "x-delegation-id" +_PRINCIPAL_TYPES = frozenset({"user", "agent"}) + +_METHOD = "trusted" # 开放字符串而非封闭枚举(F05 拒绝以模式名驱动核心分支) +_CREDENTIAL = "gateway" + + +def _credential_id(gateway_key: str, org: str, principal_type: str, principal_id: str) -> str: + """TRUSTED 凭据的不可逆标识:网关凭据指纹 + 主体三元组的 sha256。 + + F05 §认证不变量 5 要求每条凭据有 credential id,供撤销、审计关联与 Delegation + 的 ``bound_credential_id`` 绑定。TRUSTED 的「凭据」是「网关以 ``gateway_key`` + 担保的主体身份」--故标识必须含 ``gateway_key`` 指纹:网关凭据轮换后,同一主体 + 得到不同 credential_id,旧凭据绑定的委托不能迁移到新凭据。单算主体三元组的 + 指纹做不到这点,且 user id 常低熵可枚举。 + """ + material = f"{fingerprint(gateway_key)}:{org}:{principal_type}:{principal_id}" + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + +_FAILED = "authentication failed" + + +class TrustedAuthenticator(Authenticator): + """读网关注入的身份声明,角色查本地注册表。""" + + def __init__(self, key_store: PrincipalKeyStore, gateway_key: str = "") -> None: + self._key_store = key_store + self._gateway_key = gateway_key + + def authenticate(self, credentials: Credentials) -> AuthContext: + headers = credentials.headers + org = str(headers.get(_H_ORG, "")).strip() + principal_type = str(headers.get(_H_TYPE, "")).strip().lower() + principal_id = str(headers.get(_H_ID, "")).strip() + + if not org or principal_type not in _PRINCIPAL_TYPES or not principal_id: + raise AuthenticationError(_FAILED) + + # 网关到框架这一跳的共享密钥(可选):配了就必须对上,防止绕过网关直连。 + # encode 成 bytes 再比:compare_digest 的 str 版对非 ASCII 输入抛 TypeError。 + if self._gateway_key and not hmac.compare_digest( + self._gateway_key.encode("utf-8"), credentials.api_key.encode("utf-8") + ): + raise AuthenticationError(_FAILED) + + # keyword 构造:F03 将给 Scope 加 space 字段,位置参数会错位。 + actor = Scope(org=org, **{principal_type: principal_id}) + + role = self._key_store.get_role(actor) + if role is None: + # 未注册主体一律拒绝,不默认给 USER 放行——fail-closed。 + raise AuthenticationError(_FAILED) + + return AuthContext( + actor=actor, + role=role, + credential_type=_CREDENTIAL, + credential_id=_credential_id(self._gateway_key, org, principal_type, principal_id), + auth_method=_METHOD, + authenticated_at=datetime.now(timezone.utc), + delegation_id=str(headers.get(_H_DELEGATION, "")).strip(), + ) + + def mode(self) -> str: + return _METHOD + + def requires_loopback_binding(self) -> bool: + return False + + def requires_concurrency_guard(self) -> bool: + return False + + def health(self) -> None: + self._key_store.health() + + +def _truthy(value) -> bool: + return str(value).strip().lower() in {"1", "true", "yes", "on"} + + +@AuthProducer.register("trusted") +def _build(config): + gateway_key = str(config.get("gateway_key", "") or "").strip() + if not gateway_key: + # 未配 gateway_key 时,全部身份 header(X-Org-Id / X-Principal-* 等)可被 + # 任意能连到本端口的调用方伪造。默认拒绝启动;确需仅靠网络隔离时,必须 + # 显式 opt-in,让「没有网关密钥」成为一个可见的部署决定而非默认状态。 + if not _truthy(config.get("allow_no_gateway_key", False)): + raise ValidationError( + "trusted 模式必须配置 gateway_key:未配置时身份 header 可被任意调用方" + "伪造。若确需仅靠网络隔离(受信反代/mTLS 已到位),显式设" + " allow_no_gateway_key=true。" + ) + _LOG.warning( + "trusted 模式未配 gateway_key(allow_no_gateway_key=true):信任全部" + "身份 header,仅可用于网络已隔离的部署。" + ) + key_store = KeyStoreProducer.dep(config, "key_store", default="memory") + return TrustedAuthenticator(key_store=key_store, gateway_key=gateway_key) diff --git a/src/common/security/authentication/base.py b/src/common/security/authentication/base.py new file mode 100644 index 00000000..d65c48b5 --- /dev/null +++ b/src/common/security/authentication/base.py @@ -0,0 +1,78 @@ +"""认证能力契约:Authenticator 与注册式 Producer(F05 §Authentication)。 + +把一次请求的凭据材料校验成 :class:`~common.security.types.AuthContext`。 +实现按认证模式区分(dev / trusted / api_key),由配置在装配期选定;运行期 +不再分流——参考 demo 里 ``AuthDispatcher`` 一个类里 if/else 三种模式的写法, +在此拆成三个各自只做一件事的实现。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from common.factory.factory import Factory +from common.security.types import AuthContext, Credentials + + +class AuthProducer(Factory): + """Authenticator 的注册式工厂(与契约同处接口层)。 + + ``target`` 即认证模式名。各实现在 ``authentication_impl`` 下以 + ``@AuthProducer.register("<模式>")`` 自注册——注册发生在 import 实现模块时, + 由 :func:`common.security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "authenticator" + + +class Authenticator(ABC): + """凭据 → 可信身份。""" + + @abstractmethod + def authenticate(self, credentials: Credentials) -> AuthContext: + """校验凭据,返回可信身份;失败抛 :class:`~common.errors.AuthenticationError`。 + + **不返回 None**:认证只有「成功」与「失败」两种结果。返回 None 会诱导 + 调用方写 ``if ctx is None: ctx = default`` 这类 fail-open 分支。 + + 对外错误消息一律笼统(``"authentication failed"``),不区分「凭据缺失」 + 「主体不存在」「凭据错误」——区分即主体枚举侧信道(§2.3.2)。具体原因 + 写进审计事件的 ``detail``,不进异常消息。 + """ + + @abstractmethod + def mode(self) -> str: + """自描述当前认证模式名,供审计与诊断展示。 + + 返回开放字符串而不是封闭枚举:第三方实现无需修改核心即可声明自己的模式名。 + 核心不得按此值分支——需要分支的行为差异由 capability 方法显式声明。 + """ + + def requires_loopback_binding(self) -> bool: + """是否必须只监听 loopback。 + + 未覆写时返回 ``True``(fail closed):第三方认证实现只有显式声明自身具备 + 远程暴露所需的认证保护后,surface 才能绑定非本机地址。 + """ + return True + + def requires_concurrency_guard(self) -> bool: + """认证是否包含需要进程级并发保护的重型校验。 + + 未覆写时返回 ``True``,避免第三方密码校验器在未声明成本模型时绕过并发保护。 + 轻量实现可显式返回 ``False``。 + """ + return True + + def bind_instance_name(self, name: str) -> None: + """把装配期实例名绑定到认证器;不需要实例身份的实现可忽略。 + + 可撤销凭据实现应覆写此方法,把名称写入签发的 ``credential_issuer``,供 + PEP 将凭据路由到同一实例的撤销真源。默认不做任何事,避免要求 dev/trusted + 等不依赖在线撤销的实现维护无意义状态。 + """ + return None + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与 ``ControlOperator`` 同构。""" diff --git a/src/common/security/authentication/credential_registry.py b/src/common/security/authentication/credential_registry.py new file mode 100644 index 00000000..c4a1f8c1 --- /dev/null +++ b/src/common/security/authentication/credential_registry.py @@ -0,0 +1,101 @@ +"""凭据撤销状态注册表(F05 §认证不变量 6、§决策顺序 1)。 + +PEP(PR2 起的 :class:`~api.memory_api_impl.local_memory_api.LocalMemoryAPI`)持有本 +注册表,在每次授权前按 ``AuthContext.credential_type`` 查注册的 +:class:`~common.security.authentication.key_store.PrincipalKeyStore.is_revoked`,使 +撤销前缓存的上下文在撤销后立即失效。 + +**设计要点**: + +- 注册表是**显式装配 capability**,由 PEP / SecurityRuntime 持有,**不放进 Authorizer**-- + Authorizer 保持纯 PDP,不通过闭包访问 KeyStore。 +- 注册表与 Authenticator **共享同一具名 Store 真源**(经 Factory 具名缓存):认证签发 + 与撤销复核读同一份事实,撤销后 PEP 立即看到。 +- ``AuthContext`` 保持纯数据值对象,不在跨层/跨进程边界携带可执行闭包。 +- 未注册的 ``credential_type``(dev/trusted 等不走可撤销凭据的路径)返回 ``False``-- + 它们的撤销语义由各自认证边界另行定义;走可撤销凭据的认证器(如 api_key)必须在装配 + 时注册,且其 Store 必须覆盖 :meth:`PrincipalKeyStore.is_revoked`,否则 :meth:`health` + 在启动期 fail-closed。 + +**Round3 P1-3 修复**: + +- 撤销定位键改为 ``(credential_type, authenticator_name)`` 复合键,支持平行 Authenticator + 实例各自独立的撤销路由。两个平行 ApiKey Authenticator 都返回 ``api_key``,但通过不同 + authenticator 名称区分,第一套签发的凭据撤销后不会误查第二套 Store。 + +**Round4 P1-4 修复**: + +- Registry 路由键改用 ``credential_issuer`` 字段(具名 Authenticator 实例名), + 保留 ``auth_method`` 的协议/认证方法语义("api_key" / "trusted" / "dev")。 +""" + +from __future__ import annotations + +from common.errors import ValidationError +from common.security.types import AuthContext + +from .key_store import PrincipalKeyStore + + +class CredentialStatusRegistry: + """凭据撤销状态的在线复核入口。""" + + def __init__(self) -> None: + # Round3: 改用 (credential_type, authenticator_name) 复合键 + self._stores: dict[tuple[str, str], PrincipalKeyStore] = {} + + def register( + self, credential_type: str, authenticator_name: str, store: PrincipalKeyStore + ) -> None: + """把一种凭据类型 + Authenticator 实例绑定到其发证 Store(供 PEP 复核撤销)。 + + Round3: 使用 (credential_type, authenticator_name) 复合键,支持平行 Authenticator。 + """ + self._stores[(credential_type, authenticator_name)] = store + + def is_revoked(self, auth: AuthContext) -> bool: + """该 AuthContext 的凭据是否已撤销。 + + 无 ``credential_id`` 时返回 ``False``(不可撤销凭据,如匿名访问)。 + 有 ``credential_id`` 但 issuer 未注册时**抛出异常**(fail-closed):未注册可能是 + 装配遗漏、issuer 名称漂移、或第三方 Authenticator 未声明 capability,这些情况 + 必须拒绝而非放行,否则撤销机制失效。 + + Round3: 按 (credential_type, authenticator_name) 复合键查找 Store。 + Round4 P1-4: 使用 credential_issuer 字段而非 auth_method 作为路由键。 + credential_issuer 是 Authenticator 装配时的具名实例名称,区分平行 Authenticator。 + + Round7 P1-3: 未知 issuer 从返回 False(fail-open)改为抛出 ValidationError(fail-closed)。 + + :raises ValidationError: credential_issuer 未注册到 Registry(装配错误或配置漂移) + """ + if not auth.credential_id: + return False # 不可撤销凭据(如匿名) + + # Round4: 使用 credential_issuer(具名实例名称)而非 auth_method(协议标识) + key = (auth.credential_type, auth.credential_issuer) + store = self._stores.get(key) + if store is None: + # Round7 P1-3: fail-closed,未注册的 issuer 必须拒绝 + raise ValidationError( + f"credential_issuer {auth.credential_issuer!r} (type={auth.credential_type}) " + f"未注册到 CredentialStatusRegistry,无法复核撤销状态。" + f"这可能是装配错误、Runtime 名称漂移、或 Authenticator 未声明撤销 capability。" + ) + return store.is_revoked(auth.credential_id) + + def health(self) -> None: + """启动期校验:所有注册 Store 都覆盖了 is_revoked 且自身健康。 + + 未覆盖 is_revoked 的 Store 在认证期也会被 :class:`ApiKeyAuthenticator` 拒绝, + 这里是装配期的额外 fail-closed,避免「注册了一个无法复核撤销的 Store」延迟到 + 运行期才暴露。 + """ + for (credential_type, authenticator_name), store in self._stores.items(): + if type(store).is_revoked is PrincipalKeyStore.is_revoked: + raise ValidationError( + f"credential_status_registry 注册的 {credential_type!r} " + f"(authenticator={authenticator_name!r}) Store " + "未实现 is_revoked,无法在线复核撤销" + ) + store.health() diff --git a/src/common/security/authentication/key_store.py b/src/common/security/authentication/key_store.py new file mode 100644 index 00000000..c51afcdc --- /dev/null +++ b/src/common/security/authentication/key_store.py @@ -0,0 +1,126 @@ +"""主体 API Key 凭据存储契约(security.md §2.3)。 + +只负责「key ↔ 主体身份」的映射:签发、解析、撤销、查角色。不做认证分流 +(那是 :class:`~common.security.authentication.base.Authenticator` 的事),也不管 Root API +Key——它不入注册表,由 api_key authenticator 单独 ``compare_digest`` 比对 +(§2.3.1)。 +""" + +from __future__ import annotations + +import hashlib +import secrets +from abc import ABC, abstractmethod + +from common.factory.factory import Factory +from common.security.types import AuthContext, Role +from common.type_def.scope import Scope + +_PREFIX_LEN = 8 # 前缀索引长度(§2.3.1) +_KEY_BYTES = 32 # 256 bit + + +class KeyStoreProducer(Factory): + """PrincipalKeyStore 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``authentication_impl`` 下以 ``@KeyStoreProducer.register("<后端>")`` + 自注册,由 :func:`common.security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "key_store" + + +def fingerprint(api_key: str) -> str: + """key 的 sha256 十六进制指纹。 + + 三个用途:(1) 注册表的确定性查找键——Argon2 每次 salt 不同,哈希值不能作键; + (2) 撤销的定位键;(3) 未来 OAuth token 的绑定锚(§6.5)。 + + **必须在哈希之前用明文算**——密码哈希不可逆,事后无法补算。 + + 指纹不可逆但**可枚举**(若 key 空间小可暴力),故 key 生成必须高熵, + 见 :func:`generate_api_key`。指纹本身进审计日志是安全的。 + """ + return hashlib.sha256(api_key.encode("utf-8")).hexdigest() + + +def key_prefix(api_key: str) -> str: + """前缀索引键:定位候选记录,避免 resolve 全表扫描(§2.3.1)。 + + 43 字符 URL-safe base64 的前 8 字符约 48 bit,候选列表基本恒为 1。 + 前缀索引本身是**非常时间**的 dict 查找(已知缝隙,§2.3.2),由 resolve + 未命中时的 dummy verify 补偿。 + """ + return api_key[:_PREFIX_LEN] + + +def generate_api_key() -> str: + """生成一把高熵 API Key:43 字符 URL-safe base64,256 bit 熵。 + + 必须用 ``secrets`` 而非 ``random``——后者是可预测的 Mersenne Twister。 + """ + return secrets.token_urlsafe(_KEY_BYTES) + + +class PrincipalKeyStore(ABC): + """主体 API Key 注册表:签发、解析、撤销。""" + + @abstractmethod + def issue(self, actor: Scope, role: Role) -> str: + """为 ``actor`` 签发一把 API Key,返回**一次性明文**。 + + 明文只在此刻返回一次,服务端随后只保存验证材料(密码哈希 + sha256 + 指纹)。 + + ``role`` 不得为 :attr:`~common.security.types.Role.ROOT`——ROOT 只能来自 + 配置声明的 Root API Key(§3.2「明确禁止」自签发 ROOT),传 ROOT 抛 + :class:`~common.errors.PermissionDeniedError`。 + + ``actor`` 必须且只能指定 ``user`` 或 ``agent`` 之一(§4.1),否则抛 + :class:`~common.errors.ValidationError`。 + """ + + @abstractmethod + def resolve(self, api_key: str) -> AuthContext | None: + """按明文 key 反查主体身份;未命中返回 ``None``。 + + **本方法允许返回 None**,与 :meth:`~common.security.authentication.base. + Authenticator.authenticate` 不同:它是「查表未命中」的事实陈述,由调用方翻译成 + ``AuthenticationError``。这不构成 fail-open——调用方拿到 None 唯一能做的 + 就是拒绝。 + + 实现必须满足 §2.3.2:前缀索引定位候选、常时间比对、**未命中时补一次 + dummy verify** 把耗时 pad 到与命中路径同量级,否则「前缀是否存在」成为 + 可测量的侧信道。 + """ + + @abstractmethod + def revoke(self, key_fp: str) -> None: + """按指纹撤销一把 key(幂等)。撤销后 :meth:`resolve` 立即不再命中。""" + + def is_revoked(self, credential_id: str) -> bool: + """凭据是否已撤销(供 PEP 在线复核缓存的 AuthContext,F05 §认证不变量 6)。 + + ``credential_id`` 即 :meth:`resolve` 写进 ``AuthContext`` 的指纹。撤销后返回 + ``True``;未撤销或本注册表不认识该指纹返回 ``False``。 + + 非 abstract:支持撤销的后端(如 :class:`~...memory_key_store.InMemoryKeyStore`) + 覆盖之;不跟踪撤销状态的后端继承本默认实现,在被查询时 fail-closed 抛错, + 而不是静默返回「未撤销」把撤销后凭据放行。``ApiKeyAuthenticator`` 在认证期 + 校验本方法已被覆盖,第三方缺实现会在签发上下文前就失败,而非首个授权请求 + 500。 + """ + raise NotImplementedError(f"{type(self).__name__} 不支持凭据撤销查询") + + @abstractmethod + def get_role(self, actor: Scope) -> Role | None: + """查主体的服务端注册角色;未注册返回 ``None``。 + + TRUSTED 模式据此实现「role 不从 header 读」(§2.2.2 关键设计)——网关说 + 「你是谁」,框架自己查「你能干什么」。这样即使网关被攻破或误配,也无法 + 任意提权。 + """ + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。""" diff --git a/src/common/security/authorization/__init__.py b/src/common/security/authorization/__init__.py new file mode 100644 index 00000000..38ffcfc8 --- /dev/null +++ b/src/common/security/authorization/__init__.py @@ -0,0 +1,22 @@ +"""授权能力:契约、真源与内置实现(F05 §Authorization)。""" + +from .base import AuthorizationDecision, AuthorizationProducer, Authorizer +from .scope_rules import PrincipalPath, scope_covers +from .store import ( + DelegationStore, + DelegationStoreProducer, + GrantStore, + GrantStoreProducer, +) + +__all__ = [ + "AuthorizationDecision", + "AuthorizationProducer", + "Authorizer", + "DelegationStore", + "DelegationStoreProducer", + "GrantStore", + "GrantStoreProducer", + "PrincipalPath", + "scope_covers", +] diff --git a/src/common/security/authorization/authorization_impl/__init__.py b/src/common/security/authorization/authorization_impl/__init__.py new file mode 100644 index 00000000..da909409 --- /dev/null +++ b/src/common/security/authorization/authorization_impl/__init__.py @@ -0,0 +1,19 @@ +"""authorization_impl 实现集:AuthorizationProducer + 各实现。 + +import 各实现模块即触发其 ``@AuthorizationProducer.register(...)`` 自注册;本包只 +对外暴露工厂。Grant/Delegation 的存储实现(``memory_stores``、``sqlite_stores``)也在 +这里 import——Authorizer 的 ``_build`` 通过 ``GrantStoreProducer.dep`` 引用它们的 +注册名,存储模块没被 import 过就等于那些名字不存在。 +""" + +from importlib import import_module + +from common.security.authorization.base import AuthorizationProducer + +import_module(".memory_stores", __name__) +import_module(".sqlite_stores", __name__) +import_module(".allow_all_authorizer", __name__) +import_module(".standard_authorizer", __name__) +import_module(".routing_authorizer", __name__) + +__all__ = ["AuthorizationProducer"] diff --git a/src/common/security/authorization/authorization_impl/allow_all_authorizer.py b/src/common/security/authorization/authorization_impl/allow_all_authorizer.py new file mode 100644 index 00000000..88a0cfd7 --- /dev/null +++ b/src/common/security/authorization/authorization_impl/allow_all_authorizer.py @@ -0,0 +1,40 @@ +"""恒放行 Authorizer —— **仅供测试**(F05 §授权不变量 8)。 + +存在的理由只有一个:单测里那些不关心授权的用例需要一个不需要布置 Grant/Delegation +的 Authorizer。它通过 :meth:`is_test_only` 把「我是测试件」声明成 capability,装配层 +据此在生产模式拒绝启动——而不是靠核心去认 ``target == "allow_all"`` 这个名字 +(S08 不变量 7:禁止按 target 名推测安全保证)。 +""" + +from __future__ import annotations + +from common.security.authorization.base import ( + AuthorizationDecision, + AuthorizationProducer, + Authorizer, +) +from common.security.types import AuthContext, AuthorizationEnvironment, ResourceDescriptor + + +class AllowAllAuthorizer(Authorizer): + """恒放行。恒放行是本实现的**全部**语义——不看角色、不看 scope、不看时效。""" + + def authorize( + self, + *, + auth: AuthContext, + resource: ResourceDescriptor, + environment: AuthorizationEnvironment, + ) -> AuthorizationDecision: + return AuthorizationDecision.allow("allow_all") + + def is_test_only(self) -> bool: + return True + + def health(self) -> None: + return None + + +@AuthorizationProducer.register("allow_all") +def _build(config) -> AllowAllAuthorizer: + return AllowAllAuthorizer() diff --git a/src/common/security/authorization/authorization_impl/memory_stores.py b/src/common/security/authorization/authorization_impl/memory_stores.py new file mode 100644 index 00000000..cf0d4e97 --- /dev/null +++ b/src/common/security/authorization/authorization_impl/memory_stores.py @@ -0,0 +1,143 @@ +"""进程内 :class:`GrantStore` 与 :class:`DelegationStore`。 + +定位与 ``InMemoryKeyStore`` 一致:单进程装配、测试与 DEV 用。**进程重启即全部丢失** +——生产要 SQLite 后端(``sqlite_authorization_store``)。 + +注册名是 ``memory``(描述存储后端),与 ``key_store`` 的 ``memory`` 惯例一致。 + +两个 Store 放同一模块:它们的记录形态、锁策略和撤销语义是对称的,分成两个文件会 +让「Grant 改了撤销语义、Delegation 没跟上」这类分叉更容易发生。它们仍是两个**类**、 +两个 Producer——共享文件不等于共享类型。 +""" + +from __future__ import annotations + +import threading +from datetime import datetime + +from common.security.authorization.store import ( + DelegationStore, + DelegationStoreProducer, + GrantStore, + GrantStoreProducer, +) +from common.security.types import Action, Delegation, Grant +from common.type_def.scope import Scope + + +class InMemoryGrantStore(GrantStore): + """进程内授权表。 + + 按 ``grant_id`` 索引:撤销要按 id 定位,而 grantor/grantee/action 的组合会重复 + (同一对主体可以有多条不同有效期的授权)。 + """ + + def __init__(self) -> None: + self._grants: dict[str, Grant] = {} + self._lock = threading.Lock() + + def add(self, grant: Grant) -> None: + with self._lock: + existing = self._grants.get(grant.grant_id) + if existing is not None and existing.revoked: + # 撤销单调:同 id 的写入不得把 revoked 翻回 False。队列重投或网络重试 + # 会把撤销前的那份创建请求再送一次,无条件覆盖等于给攻击者一条「重放 + # 旧报文即可复活权限」的路径。SQLite 实现的 upsert 不动 revoked_at, + # 语义一致。 + return + self._grants[grant.grant_id] = grant + + def revoke(self, grant_id: str) -> None: + with self._lock: + existing = self._grants.get(grant_id) + if existing is None: + return # 幂等 + # 软撤销:置标记而不是删记录。硬删除会让「这条权限什么时候没的」在审计里 + # 断线,而权限消失的时刻恰恰是事故复盘要问的第一个问题。 + self._grants[grant_id] = Grant( + grant_id=existing.grant_id, + grantor=existing.grantor, + grantee=existing.grantee, + actions=existing.actions, + expires_at=existing.expires_at, + revoked=True, + ) + + def find_active( + self, + *, + grantee: Scope, + grantor_org: str, + action: Action, + now: datetime, + ) -> list[Grant]: + with self._lock: + candidates = list(self._grants.values()) + # 时效与撤销在存储层就滤掉(契约要求)。scope 覆盖不在这里判——那是策略, + # 归 Authorizer;这里只按能索引的维度收窄候选集。 + active = [] + for grant in candidates: + if action not in grant.actions: + continue + if grant.grantor.org != grantor_org or grant.grantee.org != grantee.org: + continue + if grant.is_active(now=now): + active.append(grant) + return active + + def health(self) -> None: + return None + + +class InMemoryDelegationStore(DelegationStore): + """进程内委托表。""" + + def __init__(self) -> None: + self._delegations: dict[str, Delegation] = {} + self._lock = threading.Lock() + + def add(self, delegation: Delegation) -> None: + with self._lock: + existing = self._delegations.get(delegation.delegation_id) + if existing is not None and existing.revoked: + return # 撤销单调,同 InMemoryGrantStore.add + self._delegations[delegation.delegation_id] = delegation + + def revoke(self, delegation_id: str) -> None: + with self._lock: + existing = self._delegations.get(delegation_id) + if existing is None: + return # 幂等 + self._delegations[delegation_id] = Delegation( + delegation_id=existing.delegation_id, + delegator=existing.delegator, + delegate=existing.delegate, + actions=existing.actions, + expires_at=existing.expires_at, + not_before=existing.not_before, + revoked=True, + allowed_spaces=existing.allowed_spaces, + bound_credential_id=existing.bound_credential_id, + bound_session=existing.bound_session, + ) + + def get(self, delegation_id: str) -> Delegation | None: + # 空 id 直接返回 None:``AuthContext.delegation_id`` 的默认值是空串,让它 + # 去查表意味着一条 id 为空的记录能被任何未声明委托的请求命中。 + if not delegation_id: + return None + with self._lock: + return self._delegations.get(delegation_id) + + def health(self) -> None: + return None + + +@GrantStoreProducer.register("memory") +def _build_grant_store(config) -> InMemoryGrantStore: + return InMemoryGrantStore() + + +@DelegationStoreProducer.register("memory") +def _build_delegation_store(config) -> InMemoryDelegationStore: + return InMemoryDelegationStore() diff --git a/src/common/security/authorization/authorization_impl/routing_authorizer.py b/src/common/security/authorization/authorization_impl/routing_authorizer.py new file mode 100644 index 00000000..9eb76f2d --- /dev/null +++ b/src/common/security/authorization/authorization_impl/routing_authorizer.py @@ -0,0 +1,162 @@ +"""按资源属性路由到不同 Authorizer 的组合实现(迁移计划 §5.2「权限路由」)。 + +取代 ``control.permission_impl.routing_permission_manager``。语义一字未改,只是判定 +主体从 ``PermissionManager`` 换成 :class:`~common.security.authorization.Authorizer`、 +路由依据从 ``PermissionContext`` 换成 :class:`ResourceDescriptor`: + +- ``resource_type`` 直接读 descriptor 的同名字段; +- 其余 route_key 读 ``attributes``——PEP 从真源构造 descriptor,所以 ``memory_type`` + 这类值对已有资源是存储里的事实,不是请求里的声明。 + +本实现**不定义任何授权语义**:不额外 deny、不对多个 delegate 求交集。owner-cover、 +角色闸门、Grant、默认拒绝全部由被选中的 delegate 判定。它只回答「这次判定归谁管」。 +""" + +from __future__ import annotations + +from common.errors import ValidationError +from common.security.authorization.base import ( + AuthorizationDecision, + AuthorizationProducer, + Authorizer, +) +from common.security.types import AuthContext, AuthorizationEnvironment, ResourceDescriptor + + +class RoutingAuthorizer(Authorizer): + """统一授权入口下的策略路由。""" + + def __init__( + self, + policies: dict[str, Authorizer], + routes: dict[str, str], + fallback: str, + route_key: str = "memory_type", + ) -> None: + if fallback not in policies: + raise ValidationError( + f"RoutingAuthorizer fallback {fallback!r} 不存在(已定义:{sorted(policies)})" + ) + if policies[fallback].is_test_only(): + # fallback 承接的是**路由值缺失**的请求,而调用方只要不声明类型就能触发。 + # 此时没有路由值可回注为系统谓词、查询范围不受约束,若 fallback 又恒放行, + # 等于把「不声明类型」变成免鉴权后门。这里问的是 capability 而非 target 名 + # (S08 不变量 7):第三方注册的恒放行实现同样要被拦住。 + raise ValidationError( + f"RoutingAuthorizer fallback {fallback!r} 不得是仅测试实现:" + "路由值缺失的请求全部落在 fallback,它必须是最小权限策略" + ) + + # Round4 P1-3: 装配期检查所有 policy 是否共享同一 GrantStore(真源统一) + # 多 Store 顺序写入无原子性:第二个 Store 失败时第一个已提交,造成部分授权。 + # 要么全部 policy 共享同一 Store(通过具名引用),要么拒绝启动。 + all_stores = [] + for policy_name, policy in policies.items(): + stores = policy.management_grant_stores() + if stores: + all_stores.extend((policy_name, id(store)) for store in stores) + + if all_stores: + # 检查所有 policy 的 Store 是否为同一实例(按 id 判断) + unique_store_ids = {store_id for _, store_id in all_stores} + if len(unique_store_ids) > 1: + store_owners = {} + for policy_name, store_id in all_stores: + store_owners.setdefault(store_id, []).append(policy_name) + detail = "; ".join( + f"Store#{i + 1} 被 {', '.join(sorted(owners))} 使用" + for i, owners in enumerate(store_owners.values()) + ) + raise ValidationError( + f"RoutingAuthorizer 的所有 policy 必须共享同一个 GrantStore(真源统一)。" + f"当前配置了 {len(unique_store_ids)} 个不同 Store,grant/revoke 会部分提交。" + f"请在配置中让所有 policy 引用同一具名 grant_store。详情:{detail}" + ) + + self._policies = policies + self._routes = dict(routes) + self._fallback = fallback + self._route_key = route_key + + def routing_fields(self) -> tuple[str, ...]: + # 供 PEP 把路由值回注为系统谓词,绑定「按哪条策略授权」与「能读到哪些数据」。 + return (self._route_key,) + + def health(self) -> None: + for policy in self._policies.values(): + policy.health() + + def management_grant_store(self): + # 管理写透传到 fallback delegate 的 Store:fallback 承接路由值缺失的请求,是 + # 最小权限策略,公共 grant/revoke 写它的真源与其他请求的判定一致。 + # **已弃用**:路由场景应调用 management_grant_stores()(P1-4 真源统一)。 + return self._policies[self._fallback].management_grant_store() + + def management_grant_stores(self): + # P1-4:路由场景下公共 grant 须写入**全部** policy 的 Store,否则「grant 时按 + # fallback 判定、实际访问时路由到别的 policy」就读不到授权——两次判定看不同真源。 + # 去重:多个 policy 可能共享同一 Store(装配期通过具名引用同一实例)。 + stores = [] + seen_ids = set() + for policy in self._policies.values(): + for store in policy.management_grant_stores(): + store_id = id(store) + if store_id not in seen_ids: + stores.append(store) + seen_ids.add(store_id) + return stores + + def authorize( + self, + *, + auth: AuthContext, + resource: ResourceDescriptor, + environment: AuthorizationEnvironment, + ) -> AuthorizationDecision: + policy = self._select(resource) + return policy.authorize(auth=auth, resource=resource, environment=environment) + + def _select(self, resource: ResourceDescriptor) -> Authorizer: + value = _route_value(resource, self._route_key) + # 只接受 routes 里**显式声明**的路由值,未命中一律落 fallback。不同于 Pipeline + # 路由(S03:136 允许「路由值本身是 profile 名则直接使用」)——授权侧若沿用该 + # 兜底,调用方就能直接点名 policy 来挑选审查自己的策略,等于让被审查者选审查员。 + policy_name = self._routes.get(value, self._fallback) if value else self._fallback + if policy_name not in self._policies: + policy_name = self._fallback + return self._policies[policy_name] + + +def _route_value(resource: ResourceDescriptor, route_key: str) -> str: + if route_key == "resource_type": + return resource.resource_type + return str(resource.attributes.get(route_key, "")).strip() + + +@AuthorizationProducer.register("routing") +def _build(config) -> RoutingAuthorizer: + route_key = config.get("route_key", "memory_type") + fallback = str(config.get("fallback", "")).strip() + if not fallback: + raise ValidationError("authorizer.routing params.fallback 必须指向一个具名 authorizer") + if fallback == config.name: + raise ValidationError("authorizer.routing params.fallback 不能指向 routing 自身") + routes_raw = config.get("routes", {}) + if not isinstance(routes_raw, dict): + raise ValidationError("authorizer.routing params.routes 必须是映射") + routes = {str(key): str(value) for key, value in routes_raw.items()} + if any(policy_name == config.name for policy_name in routes.values()): + raise ValidationError("authorizer.routing params.routes 不能指向 routing 自身") + policy_names = set(routes.values()) | {fallback} + policies: dict[str, Authorizer] = {} + for policy_name in policy_names: + policy = AuthorizationProducer.build_named(policy_name, config.ctx) + if not isinstance(policy, Authorizer): + raise ValidationError(f"authorizer.routing 的 {policy_name!r} 必须是 Authorizer") + policies[policy_name] = policy + return RoutingAuthorizer( + policies=policies, + routes=routes, + fallback=fallback, + route_key=route_key, + ) diff --git a/src/common/security/authorization/authorization_impl/sqlite_stores.py b/src/common/security/authorization/authorization_impl/sqlite_stores.py new file mode 100644 index 00000000..29c6655c --- /dev/null +++ b/src/common/security/authorization/authorization_impl/sqlite_stores.py @@ -0,0 +1,317 @@ +"""SQLite 后端的 :class:`GrantStore` 与 :class:`DelegationStore`。 + +从 ``control.permission_impl.sqlite_permission_manager`` 的 grants 表演进而来, +三处变化: + +1. **一条记录一条授权**,不再按 action 拆行。旧表每个 action 一行,撤销要按 + (grantor, grantee, action) 十列匹配;有了 ``grant_id`` 之后按 id 撤销,而 + actions 存成排序后的逗号串。 +2. **`revoked` 与 `expires_at` 在 SQL 里就过滤**(契约要求)。旧实现把过期条件写进 + 了 WHERE,撤销也是;这里保持,但同时保留 ``revoked_at`` 时间戳供审计。 +3. **新增 delegations 表**。旧实现没有委托真源——代操作靠 header 里的 + ``acting_user``,正是 F05 §从 header 直接产生 Delegation 拒绝的形态。 + +不做旧表迁移:grants 的列语义变了(多了 id、actions 合并成一列),且旧表是 +``control`` 的所有物。PR3 删除旧 PermissionManager 时旧表随之废弃。 +""" + +from __future__ import annotations + +import sqlite3 +import threading +from datetime import datetime, timezone +from pathlib import Path + +from common.security.authorization.store import ( + DelegationStore, + DelegationStoreProducer, + GrantStore, + GrantStoreProducer, +) +from common.security.types import Action, Delegation, Grant +from common.type_def.scope import Scope + +_SCHEMA = """ +CREATE TABLE IF NOT EXISTS auth_grants ( + grant_id TEXT PRIMARY KEY, + grantor_org TEXT NOT NULL, + grantor_space TEXT NOT NULL, + grantor_user TEXT NOT NULL, + grantor_agent TEXT NOT NULL, + grantor_session TEXT NOT NULL, + grantee_org TEXT NOT NULL, + grantee_space TEXT NOT NULL, + grantee_user TEXT NOT NULL, + grantee_agent TEXT NOT NULL, + grantee_session TEXT NOT NULL, + actions TEXT NOT NULL, + expires_at TEXT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT NULL +); +CREATE INDEX IF NOT EXISTS idx_auth_grants_lookup +ON auth_grants (grantee_org, grantor_org, revoked_at); + +CREATE TABLE IF NOT EXISTS auth_delegations ( + delegation_id TEXT PRIMARY KEY, + delegator_org TEXT NOT NULL, + delegator_space TEXT NOT NULL, + delegator_user TEXT NOT NULL, + delegator_agent TEXT NOT NULL, + delegator_session TEXT NOT NULL, + delegate_org TEXT NOT NULL, + delegate_space TEXT NOT NULL, + delegate_user TEXT NOT NULL, + delegate_agent TEXT NOT NULL, + delegate_session TEXT NOT NULL, + actions TEXT NOT NULL, + expires_at TEXT NOT NULL, + not_before TEXT NULL, + allowed_spaces TEXT NOT NULL, + bound_credential_id TEXT NOT NULL, + bound_session TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_at TEXT NULL +); +""" + +_SCOPE_DIMENSIONS = ("org", "space", "user", "agent", "session") + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _iso(dt: datetime | None) -> str | None: + return None if dt is None else dt.astimezone(timezone.utc).isoformat() + + +def _parse_dt(raw: str | None) -> datetime | None: + return None if not raw else datetime.fromisoformat(raw) + + +def _scope_values(scope: Scope) -> tuple[str, ...]: + return tuple(getattr(scope, dim) for dim in _SCOPE_DIMENSIONS) + + +def _read_scope(row: sqlite3.Row, prefix: str) -> Scope: + return Scope(**{dim: row[f"{prefix}_{dim}"] for dim in _SCOPE_DIMENSIONS}) + + +def _dump_actions(actions: frozenset[Action]) -> str: + """动作集合序列化成排序后的逗号串。 + + 排序是为了让同一集合有唯一表示——否则同样一条授权在两次写入后长得不一样, + 比对与去重都要先解析。 + """ + return ",".join(sorted(action.value for action in actions)) + + +def _load_actions(raw: str) -> frozenset[Action]: + """反序列化动作集合,**跳过**不认识的成员。 + + 库里出现核心不认识的动作名,只可能是降级部署(新版写入、旧版读取)或数据被改。 + 两种情况都该按「这个动作不存在」处理——认不出就当没有,是 F05 §授权不变量 5 + 「新 Action 默认拒绝」在存储层的对应形态。抛异常反而会让一条脏记录瘫掉整个 + 授权查询。 + """ + result = set() + for item in raw.split(","): + value = item.strip() + if not value: + continue + try: + result.add(Action(value)) + except ValueError: + continue + return frozenset(result) + + +class _SQLiteBacked: + """两个 Store 共享的连接与建表逻辑。 + + 刻意**不是**公开基类:它只承载连接管理这一件与授权语义无关的事。两个 Store 的 + 契约、Producer 和记录形态都各自独立。 + """ + + def __init__(self, db_path: str) -> None: + if db_path != ":memory:": + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(db_path, check_same_thread=False, isolation_level=None) + self._conn.row_factory = sqlite3.Row + self._lock = threading.Lock() + with self._lock: + self._conn.executescript(_SCHEMA) + + def health(self) -> None: + with self._lock: + self._conn.execute("SELECT 1") + + def close(self) -> None: + with self._lock: + self._conn.close() + + +class SQLiteGrantStore(_SQLiteBacked, GrantStore): + """SQLite 后端的授权表。""" + + def add(self, grant: Grant) -> None: + with self._lock: + self._conn.execute( + """ + INSERT INTO auth_grants ( + grant_id, + grantor_org, grantor_space, grantor_user, + grantor_agent, grantor_session, + grantee_org, grantee_space, grantee_user, + grantee_agent, grantee_session, + actions, expires_at, created_at, revoked_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(grant_id) DO UPDATE SET + actions=excluded.actions, + expires_at=excluded.expires_at + WHERE auth_grants.revoked_at IS NULL + """, + ( + grant.grant_id, + *_scope_values(grant.grantor), + *_scope_values(grant.grantee), + _dump_actions(grant.actions), + _iso(grant.expires_at), + _iso(_now()), + _iso(_now()) if grant.revoked else None, + ), + ) + + def revoke(self, grant_id: str) -> None: + with self._lock: + # 只更新未撤销的行:重复撤销不该把撤销时间刷成第二次的,那会让审计里 + # 「权限何时消失」的答案随重试次数漂移。 + self._conn.execute( + "UPDATE auth_grants SET revoked_at=? WHERE grant_id=? AND revoked_at IS NULL", + (_iso(_now()), grant_id), + ) + + def find_active( + self, + *, + grantee: Scope, + grantor_org: str, + action: Action, + now: datetime, + ) -> list[Grant]: + with self._lock: + rows = self._conn.execute( + """ + SELECT * FROM auth_grants + WHERE revoked_at IS NULL + AND (expires_at IS NULL OR expires_at > ?) + AND grantee_org=? + AND grantor_org=? + """, + (_iso(now), grantee.org, grantor_org), + ).fetchall() + # action 在 Python 侧筛:actions 是逗号串,SQL 的 LIKE '%read%' 会把 + # ``read_audit`` 当成 ``read`` 匹配上。按串匹配动作名是典型的子串陷阱。 + grants = [] + for row in rows: + actions = _load_actions(row["actions"]) + if action not in actions: + continue + grants.append( + Grant( + grant_id=row["grant_id"], + grantor=_read_scope(row, "grantor"), + grantee=_read_scope(row, "grantee"), + actions=actions, + expires_at=_parse_dt(row["expires_at"]), + ) + ) + return grants + + +class SQLiteDelegationStore(_SQLiteBacked, DelegationStore): + """SQLite 后端的委托表。""" + + def add(self, delegation: Delegation) -> None: + with self._lock: + self._conn.execute( + """ + INSERT INTO auth_delegations ( + delegation_id, + delegator_org, delegator_space, delegator_user, + delegator_agent, delegator_session, + delegate_org, delegate_space, delegate_user, + delegate_agent, delegate_session, + actions, expires_at, not_before, allowed_spaces, + bound_credential_id, bound_session, created_at, revoked_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(delegation_id) DO UPDATE SET + actions=excluded.actions, + expires_at=excluded.expires_at, + not_before=excluded.not_before, + allowed_spaces=excluded.allowed_spaces + WHERE auth_delegations.revoked_at IS NULL + """, + ( + delegation.delegation_id, + *_scope_values(delegation.delegator), + *_scope_values(delegation.delegate), + _dump_actions(delegation.actions), + _iso(delegation.expires_at), + _iso(delegation.not_before), + ",".join(sorted(delegation.allowed_spaces)), + delegation.bound_credential_id, + delegation.bound_session, + _iso(_now()), + _iso(_now()) if delegation.revoked else None, + ), + ) + + def revoke(self, delegation_id: str) -> None: + with self._lock: + self._conn.execute( + "UPDATE auth_delegations SET revoked_at=? " + "WHERE delegation_id=? AND revoked_at IS NULL", + (_iso(_now()), delegation_id), + ) + + def get(self, delegation_id: str) -> Delegation | None: + if not delegation_id: + # 空 id 不查表:``AuthContext.delegation_id`` 默认空串,让它命中一条 + # id 为空的记录等于给未声明委托的请求发了张通行证。 + return None + with self._lock: + row = self._conn.execute( + "SELECT * FROM auth_delegations WHERE delegation_id=?", + (delegation_id,), + ).fetchone() + if row is None: + return None + spaces = frozenset(s for s in row["allowed_spaces"].split(",") if s) + return Delegation( + delegation_id=row["delegation_id"], + delegator=_read_scope(row, "delegator"), + delegate=_read_scope(row, "delegate"), + actions=_load_actions(row["actions"]), + # ``expires_at`` NOT NULL,故必然解析出值。 + expires_at=_parse_dt(row["expires_at"]), # type: ignore[arg-type] + not_before=_parse_dt(row["not_before"]), + # 撤销状态**随记录一起返回**,由 Authorizer 用本次判定的同一个 now 复核 + # 时效。在这里判「有效与否」会让存储自己取一次 now,与 Grant 的时效判定 + # 错开。 + revoked=row["revoked_at"] is not None, + allowed_spaces=spaces, + bound_credential_id=row["bound_credential_id"], + bound_session=row["bound_session"], + ) + + +@GrantStoreProducer.register("sqlite") +def _build_grant_store(config) -> SQLiteGrantStore: + return SQLiteGrantStore(str(config.get("db_path", ":memory:"))) + + +@DelegationStoreProducer.register("sqlite") +def _build_delegation_store(config) -> SQLiteDelegationStore: + return SQLiteDelegationStore(str(config.get("db_path", ":memory:"))) diff --git a/src/common/security/authorization/authorization_impl/standard_authorizer.py b/src/common/security/authorization/authorization_impl/standard_authorizer.py new file mode 100644 index 00000000..1893d514 --- /dev/null +++ b/src/common/security/authorization/authorization_impl/standard_authorizer.py @@ -0,0 +1,319 @@ +"""标准 Authorizer:按 F05 §Authorization 决策顺序判定。 + +这是**唯一的生产授权实现**。取代 ``control.permission_impl.sqlite_permission_manager`` +里那段与 SQLite 存储混在一起的判定逻辑:策略在这里,记录的存取在 +:mod:`common.security.authorization.store` 后面。分开之后换存储后端不碰策略, +改策略不碰 SQL。 + +决策顺序即代码顺序(F05 §决策顺序): + +1. AuthContext 未过期; +2. actor 与请求上下文一致(由 PEP 保证,这里复核); +3. 系统与管理面角色闸门; +4. actor 是否覆盖 target 的所有者范围; +5. Delegation 是否覆盖本次资源与 Action; +6. 显式 Grant 是否覆盖本次资源与 Action; +7. 默认拒绝。 + +顺序不是任意的:闸门(第 3 步)必须在所有放行规则之前,否则一条 Grant 就能把管理面 +动作放给普通角色;owner(第 4 步)在委托与 Grant 之前,是因为它最常命中且不需查库。 + +第 3 步是**终局**判定——管理面动作走完角色校验就给出结论,不落到第 4-6 步(理由见 +:meth:`StandardAuthorizer._management_plane`)。其余各步只会「拒绝或落到下一步」。 +""" + +from __future__ import annotations + +from datetime import datetime + +from common.errors import ValidationError +from common.security.authorization.base import ( + AuthorizationDecision, + AuthorizationProducer, + Authorizer, +) +from common.security.authorization.scope_rules import PrincipalPath, scope_covers +from common.security.authorization.store import ( + DelegationStore, + DelegationStoreProducer, + GrantStore, + GrantStoreProducer, +) +from common.security.types import ( + MANAGEMENT_ACTIONS, + ROLE_RANK, + Action, + AuthContext, + AuthorizationEnvironment, + Delegation, + DenyReason, + ResourceDescriptor, + Role, +) +from common.type_def.scope import Scope + +# 管理面动作要求的**最低**角色。ADMIN 能管本 org 内的主体与 space;跨 org 的系统级 +# 操作与审计校验要 ROOT。 +_MINIMUM_ROLE: dict[Action, Role] = { + Action.MANAGE_PRINCIPAL: Role.ADMIN, + Action.MANAGE_SPACE: Role.ADMIN, + Action.MANAGE_POLICY: Role.ADMIN, + Action.READ_AUDIT: Role.ADMIN, + Action.VERIFY_AUDIT: Role.ROOT, + Action.ADMINISTER_SYSTEM: Role.ROOT, +} + + +def _principal_path(resource: ResourceDescriptor) -> PrincipalPath: + """本次判定用哪种主体路径。 + + 取自 ``ResourceDescriptor.attributes``——而 descriptor 由 PEP 从 space policy + 这个真源构造(F05 §ResourceDescriptor)。值不合法时回落默认而不是抛:一个写错的 + space policy 不该让请求变成 500,回落到 ``user_agent`` 是两者中更严格的那个 + (agent 维在内层,覆盖面更小)。 + """ + raw = resource.attributes.get("principal_path", "") + try: + return PrincipalPath(raw) + except ValueError: + return PrincipalPath.USER_AGENT + + +def _delegate_is_machine_principal(delegation: Delegation) -> bool: + """被委托方是否是 agent/service 这类**非人主体**(F05 §Delegation)。 + + F05 把 Delegation 定义为「user 对 agent 或 service 的有限期代操作授权」。不校验 + 主体种类的话,user -> user 的委托能通过判定,Delegation 就成了第二套 Grant: + 绕过 SHARE 动作的授权路径、绕过 grant/revoke 的管理面记录,而两者的撤销与治理 + 接口完全不同。``Scope`` 目前只建模了 user 与 agent(service identity 待补),故 + 判据是「delegate 必须带 agent 维」,同时要求 delegator 是 user:委托只能由人发起, + agent 再委托 agent 等于让委托关系自我复制,撤销就追不上了。 + """ + return bool(delegation.delegate.agent) and bool(delegation.delegator.user) + + +class StandardAuthorizer(Authorizer): + """F05 决策顺序的实现。 + + ``grant_store`` 与 ``delegation_store`` 是**必填**依赖,没有 ``None`` 形态。给它们 + 可选形态就等于允许一个「跨主体访问一律拒绝」的降级模式静默存在,而那个模式与 + 「存储配错了」在运行期长得完全一样。 + """ + + def __init__(self, grant_store: GrantStore, delegation_store: DelegationStore) -> None: + self._grants = grant_store + self._delegations = delegation_store + + def health(self) -> None: + self._grants.health() + self._delegations.health() + + def management_grant_store(self) -> GrantStore: + # PEP 的公共 grant/revoke 写这里:与 authorize 第 6 步 find_active 读同一实例, + # 具名 YAML 令本 Authorizer 引用别的 Store 时,公共 grant 也写入同一 Store。 + return self._grants + + def authorize( + self, + *, + auth: AuthContext, + resource: ResourceDescriptor, + environment: AuthorizationEnvironment, + ) -> AuthorizationDecision: + now = environment.now + actor = auth.actor + + # -- 1. 上下文时效 ------------------------------------------------- # + if auth.is_expired(now=now): + return AuthorizationDecision.deny(DenyReason.EXPIRED_CONTEXT, "context_expiry") + + # -- 2. actor 一致性 ------------------------------------------------ # + # actor 为空 Scope 意味着「没填内容的身份」,不是特权形态(F05 §授权不变量 1)。 + # 旧实现把它当 platform admin,那条线在这里彻底断掉。 + if actor == Scope(): + return AuthorizationDecision.deny(DenyReason.CONTEXT_MISMATCH, "empty_actor") + + # -- 3. 角色闸门 ---------------------------------------------------- # + gate = self._management_plane(auth, resource) + if gate is not None: + return gate + + # ROOT 跨 org 全局放行——闸门之后才生效,故 ROOT 也走完了管理面的最低角色校验。 + if auth.role is Role.ROOT: + return AuthorizationDecision.allow("root_role") + + # org 是硬边界,非 ROOT 一律不跨(F05 §Grant:Grant 不跨 org 生效)。放在 + # owner 判定之前:跨 org 的请求没有任何后续规则能救,早拒早给出准确 reason。 + if actor.org != resource.scope.org: + return AuthorizationDecision.deny(DenyReason.CROSS_ORG, "org_boundary") + + path = _principal_path(resource) + + # -- 4. owner 覆盖 -------------------------------------------------- # + if scope_covers(actor, resource.scope, principal_path=path): + return AuthorizationDecision.allow("owner_cover") + + # -- 5. Delegation -------------------------------------------------- # + if auth.delegation_id: + return self._by_delegation(auth, resource, now=now, path=path) + + # -- 6. Grant ------------------------------------------------------- # + if self._by_grant(actor, resource, now=now, path=path): + return AuthorizationDecision.allow("grant") + + # -- 7. 默认拒绝 ---------------------------------------------------- # + return AuthorizationDecision.deny(DenyReason.NOT_COVERED, "default_deny") + + # ------------------------------------------------------------------ # + # 第 3 步:角色闸门 + # ------------------------------------------------------------------ # + + def _management_plane( + self, auth: AuthContext, resource: ResourceDescriptor + ) -> AuthorizationDecision | None: + """管理面动作的**完整**判定;非管理面动作返回 ``None`` 落到下一步。 + + 「这是不是管理操作」由**封闭的 Action** 说了算,不由 ``resource_type`` 或 + 「target 恰好是空 Scope」这类数据形状间接表达——后者是调用方能控制的。 + + 管理面在这里**判完就返回**,不落到 owner / Delegation / Grant: + + - 往下走会**永远拒**。管理别人的主体、别人的 space,目标本就不在 ADMIN + 自己的 scope 内,owner 规则必拒; + - 往下走还会**开一道后门**。若某天 Grant 兜住了这条路径,就等于「能写 Grant + 的人可以自助提权到管理面」。管理面的准入依据只有一个:服务端 role。 + + target 的 org 为空表示**系统级资源**(全局治理策略、跨 org 审计),只有 ROOT + 能碰;带 org 的管理面资源(space、主体)ADMIN 可管,但止于本 org。 + """ + action = resource.action + if action not in MANAGEMENT_ACTIONS: + return None + required = _MINIMUM_ROLE[action] + if ROLE_RANK[auth.role] < ROLE_RANK[required]: + return AuthorizationDecision.deny(DenyReason.ROLE_REQUIRED, "role_gate") + if not resource.scope.org: + # 系统级资源:全局治理策略、跨 org 审计查询没有 org 归属,ADMIN 的本 org + # 管辖覆盖不到它们,只有 ROOT 能碰。显式判一次而不是让它掉进下面那句 + # 「org 不等」——那句会给出 CROSS_ORG,把运维引向「是不是 org 配错了」, + # 而事实是「这个角色不够」。reason code 是审计与告警的匹配依据,得准。 + if auth.role is not Role.ROOT: + return AuthorizationDecision.deny(DenyReason.ROLE_REQUIRED, "role_gate_system") + return AuthorizationDecision.allow("role_gate") + if auth.role is not Role.ROOT and auth.actor.org != resource.scope.org: + # ADMIN 的管辖范围止于本 org(F05 §Role:ADMIN 不可跨 org)。 + return AuthorizationDecision.deny(DenyReason.CROSS_ORG, "role_gate_org") + return AuthorizationDecision.allow("role_gate") + + # ------------------------------------------------------------------ # + # 第 5 步:Delegation + # ------------------------------------------------------------------ # + + def _by_delegation( + self, + auth: AuthContext, + resource: ResourceDescriptor, + *, + now: datetime, + path: PrincipalPath, + ) -> AuthorizationDecision: + """按 ``delegation_id`` 回真源复核(F05 §Delegation)。 + + ``auth`` 里只有一个 id,委托的**内容**一律从 Store 读——认证层产出的 + ``delegation_id`` 证明的是「这个 id 出现在一次已认证的请求里」,不是「这条委托 + 此刻仍然有效且覆盖本次动作」。 + + 带了 delegation_id 就**不再回落**到 Grant:调用方显式声明了「我在代操作」, + 委托不成立时静默改判成「那看看有没有 Grant」,会让一条失效委托的拒绝被另一条 + 规则掩盖,审计里也就看不出委托失效过。 + """ + delegation = self._delegations.get(auth.delegation_id) + if delegation is None or not delegation.is_active(now=now): + # 不存在、已撤销、已过期归同一个 reason:区分它们是委托枚举侧信道。 + return AuthorizationDecision.deny(DenyReason.DELEGATION_INVALID, "delegation_lookup") + if not _delegate_is_machine_principal(delegation): + return AuthorizationDecision.deny(DenyReason.DELEGATION_INVALID, "delegation_principal") + if not delegation.permits(resource.action): + return AuthorizationDecision.deny(DenyReason.DELEGATION_ACTION, "delegation_action") + if not self._delegation_binds(delegation, auth, resource, path=path): + return AuthorizationDecision.deny(DenyReason.DELEGATION_INVALID, "delegation_binding") + return AuthorizationDecision.allow("delegation") + + def _delegation_binds( + self, + delegation: Delegation, + auth: AuthContext, + resource: ResourceDescriptor, + *, + path: PrincipalPath, + ) -> bool: + """委托的绑定条件是否全部成立。 + + 每一条都在回答同一个问题:**这条委托是发给此刻这个调用方、用于此刻这个资源 + 的吗**。少任何一条,一条合法委托就能被别人捡去用。 + """ + actor = auth.actor + if not scope_covers(delegation.delegate, actor, principal_path=path): + # 拿别人的委托 id 来用。 + return False + if not scope_covers(delegation.delegator, resource.scope, principal_path=path): + # 委托方管不着这份资源——委托不能授出委托方自己都没有的范围。 + return False + if delegation.allowed_spaces and resource.scope.space not in delegation.allowed_spaces: + return False + if delegation.bound_credential_id and delegation.bound_credential_id != auth.credential_id: + # 绑定凭据后,换一把 key 的同一个 agent 用不了这条委托,泄露爆炸半径 + # 收敛在单把 key 上。 + return False + return not (delegation.bound_session and delegation.bound_session != actor.session) + + # ------------------------------------------------------------------ # + # 第 6 步:Grant + # ------------------------------------------------------------------ # + + def _by_grant( + self, + actor: Scope, + resource: ResourceDescriptor, + *, + now: datetime, + path: PrincipalPath, + ) -> bool: + """是否存在一条覆盖本次判定的有效 Grant。 + + 两侧都要覆盖:grantee 覆盖 actor(这条授权是给他的),grantor 覆盖 target + (授权方管得着这份资源)。只查一侧就是把「谁被授权」和「授权了什么」拆开, + 任意一半都能被另一半的宽松形状放大。 + """ + grants = self._grants.find_active( + grantee=actor, + grantor_org=resource.scope.org, + action=resource.action, + now=now, + ) + for grant in grants: + if not grant.is_active(now=now): + # Store 契约要求已滤,这里再确认一次:时效判定必须用本次的同一个 now, + # 而 Store 用的是入参 now 还是自己取的,跨实现无法保证。 + continue + if scope_covers(grant.grantee, actor, principal_path=path) and scope_covers( + grant.grantor, resource.scope, principal_path=path + ): + return True + return False + + +@AuthorizationProducer.register("standard") +def _build(config) -> StandardAuthorizer: + """装配标准 Authorizer。 + + 两个 Store 都**无默认实现**:给它们默认会让「忘了配授权存储」静默变成某种可用 + 配置(F05 §装配不变量 6)。要什么后端就在 YAML 里写出来。 + """ + grant_store = GrantStoreProducer.dep(config, "grant_store") + delegation_store = DelegationStoreProducer.dep(config, "delegation_store") + if not isinstance(grant_store, GrantStore): + raise ValidationError("authorizer.standard params.grant_store 必须是 GrantStore") + if not isinstance(delegation_store, DelegationStore): + raise ValidationError("authorizer.standard params.delegation_store 必须是 DelegationStore") + return StandardAuthorizer(grant_store=grant_store, delegation_store=delegation_store) diff --git a/src/common/security/authorization/base.py b/src/common/security/authorization/base.py new file mode 100644 index 00000000..1853e91f --- /dev/null +++ b/src/common/security/authorization/base.py @@ -0,0 +1,150 @@ +"""授权能力契约:Authorizer 与注册式 Producer(F05 §Authorization)。 + +Authorizer 是 **PDP**(决策点):根据可信身份、动作和真实资源属性回答「这次操作 +是否允许」。它不知道 HTTP、不知道 MemoryUnit、不读存储真源——那些是 **PEP** +(``MemoryAPI``)的职责,PEP 把结论整理成 :class:`ResourceDescriptor` 交进来。 + +与被它取代的 ``control.permission.PermissionManager`` 的三点差别: + +1. **输入封闭**。固定为 ``AuthContext + ResourceDescriptor + AuthorizationEnvironment`` + (F05 §Authorization),不再有 ``auth=None`` 这条「没有认证上下文时退回纯 ACL」 + 的兼容线——那条线的实际效果是「谁都不传 auth 就都按无角色处理」。 +2. **不读 ContextVar**(F05 §授权不变量 7)。全部判定依据显式入参:单测不必先布置 + 环境态,判定依据在调用点就能读全。 +3. **输出带原因**。返回 :class:`AuthorizationDecision` 而非 ``bool``:审计要记稳定 + reason code(F05 §可观测性),布尔值到了审计那里只剩「拒了」。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from common.factory.factory import Factory +from common.security.types import ( + AuthContext, + AuthorizationEnvironment, + DenyReason, + ResourceDescriptor, +) + +if TYPE_CHECKING: + from common.security.authorization.store import GrantStore + + +class AuthorizationProducer(Factory): + """Authorizer 的注册式工厂(与契约同处接口层)。 + + ``target`` 即授权实现名。各实现在 ``authorization_impl`` 下以 + ``@AuthorizationProducer.register("<名>")`` 自注册,由 + :func:`common.security.bootstrap.register_security` 统一触发 import。 + """ + + TOP_NAME = "authorizer" + + +@dataclass(frozen=True) +class AuthorizationDecision: + """一次授权判定的结果。 + + ``rule`` 在 allow 与 deny 两侧都必填:只记 deny 的原因,审计里就看不出一次放行 + 是「owner 访问自己的数据」还是「某条快过期的 Grant 兜住了」。两者的运维含义 + 完全不同。 + + ``reason`` 与 ``allowed`` 的一致性在构造期校验:一个 ``allowed=True`` 却带着 + ``CROSS_ORG`` 的决策是矛盾状态,让它构造成功只会把 bug 推迟到读审计的时候。 + """ + + allowed: bool + rule: str # 做出判定的规则标识(owner_cover / grant / role_gate / ...) + reason: DenyReason | None = None + + def __post_init__(self) -> None: + if self.allowed and self.reason is not None: + raise ValueError("allow 决策不得携带 DenyReason") + if not self.allowed and self.reason is None: + raise ValueError("deny 决策必须给出 DenyReason") + if not self.rule: + raise ValueError("授权决策必须标明做出判定的规则") + + @classmethod + def allow(cls, rule: str) -> AuthorizationDecision: + return cls(allowed=True, rule=rule) + + @classmethod + def deny(cls, reason: DenyReason, rule: str) -> AuthorizationDecision: + return cls(allowed=False, rule=rule, reason=reason) + + +class Authorizer(ABC): + """可信身份 + 资源 + 环境 → 允许/拒绝。""" + + @abstractmethod + def authorize( + self, + *, + auth: AuthContext, + resource: ResourceDescriptor, + environment: AuthorizationEnvironment, + ) -> AuthorizationDecision: + """作出授权判定。 + + 三个参数都是 **keyword-only**:它们类型不同但都是「一坨上下文」,位置传参 + 写反了不会报错,只会得到一个语义颠倒却能跑的判定。 + + **不抛异常表达拒绝**:拒绝是正常判定结果,抛异常会让调用方用 try/except 表达 + 控制流,也让「拒绝」和「授权组件坏了」在调用点长得一样。存储不可用等真实故障 + 仍然抛——那时不能把故障静默成 deny,PEP 需要区分 403 与 503。 + """ + + def is_test_only(self) -> bool: + """本实现是否只允许出现在测试装配中(F05 §授权不变量 8)。 + + 默认 ``False``。allow-all 这类恒放行实现返回 ``True``,装配层据此在生产模式 + 拒绝启动。用 capability 而非 ``target == "allow_all"`` 判断,是 S08 不变量 7 + 的要求:第三方注册的恒放行实现同样要能被拦住,而它的 target 名核心不认识。 + """ + return False + + def routing_fields(self) -> tuple[str, ...]: + """本实现据以**选择策略**的 :class:`ResourceDescriptor` 属性名(默认不路由)。 + + 路由型实现按资源的某个属性挑选 delegate,而写查询的那些属性(``memory_type`` + 等)源自请求。若不同时约束查询能触达的数据,调用方就能「用 A 的钥匙开 B 的 + 门」:路由值填宽松策略对应的类型、``filters`` 却指向受严格策略保护的数据。 + PEP 据此把路由值**回注为系统谓词**,使授权依据与数据范围绑定。 + + 与 :meth:`is_test_only` 同为 capability 声明:PEP 问的是「你按什么路由」, + 而不是「你是不是那个叫 routing 的实现」(S08 不变量 7)。 + """ + return () + + def management_grant_store(self) -> GrantStore | None: + """本 Authorizer 判定时实际查询的 GrantStore(供 PEP 的 grant/revoke 共享真源)。 + + 默认 ``None``:不基于 GrantStore 的实现(如 allow_all)没有管理写真源。基于 + GrantStore 的实现(StandardAuthorizer)覆盖之,返回其 ``find_active`` 所读的 + Store。PEP 的公共 grant/revoke 写这里,与 PDP 读取的同一实例--具名 YAML 令 + Authorizer 引用别的 Store 时,公共 grant 也写入同一 Store,不再双真源。 + + **路由场景已弃用本方法**:RoutingAuthorizer 覆盖 :meth:`management_grant_stores` + 返回全部 policy Store,PEP 写入所有 Store 以统一真源(P1-4)。非路由实现仍可 + 只覆盖本方法,PEP 向下兼容。 + """ + return None + + def management_grant_stores(self) -> list[GrantStore]: + """本 Authorizer 判定时可能查询的全部 GrantStore(路由场景真源统一,P1-4)。 + + 默认实现:单 Store 情况返回 ``[management_grant_store()]``(向下兼容),无 Store + 返回空列表。RoutingAuthorizer 覆盖之,返回全部 delegate policy 的 Store 去重后 + 列表。PEP 的公共 grant/revoke 写入返回的**所有** Store,确保路由命中任一 policy + 时都能读到授权——单 Store 时行为不变,路由时统一真源。 + """ + store = self.management_grant_store() + return [store] if store is not None else [] + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与其他安全能力同构。""" diff --git a/src/common/security/authorization/scope_rules.py b/src/common/security/authorization/scope_rules.py new file mode 100644 index 00000000..ddee1115 --- /dev/null +++ b/src/common/security/authorization/scope_rules.py @@ -0,0 +1,79 @@ +"""Scope 覆盖规则(F05 §Authorization 决策顺序第 4 步)。 + +从 ``control.permission_impl.sqlite_permission_manager._owner_scope_covers`` 迁出。 +独立成模块的理由:owner 判定与 Grant 匹配用的是**同一套**覆盖规则(「grantee 覆盖 +actor」且「grantor 覆盖 target」),把它留在某个 Authorizer 实现内部,第二个实现就 +会抄一份,两份迟早分叉——而这条规则分叉的表现形式是越权。 + +与旧实现的一处**行为差异**:不再有「``parent == Scope()`` 即覆盖一切」的通配分支。 +旧实现靠它同时表达两件事——platform admin 与 grant 行的宽松匹配——而 F05 §授权 +不变量 1 要求 ROOT 只由 role 表达。特权判定移到 Authorizer 的角色闸门,这里只剩 +纯粹的「父 scope 是否包含子 scope」。 +""" + +from __future__ import annotations + +from enum import Enum + +from common.type_def.scope import Scope + + +class PrincipalPath(str, Enum): + """主体维度的嵌套顺序。由 space policy 决定,不由请求决定。 + + ``USER_AGENT``:user 是外层,一个 user 名下可有多个 agent(个人助理形态)。 + ``AGENT_USER``:agent 是外层,一个 agent 服务多个 user(平台 bot 形态)。 + + 同名字符串值与 ``control.types.PrincipalPath`` 对齐,便于 PEP 从 space policy + 直接转换。 + """ + + USER_AGENT = "user_agent" + AGENT_USER = "agent_user" + + +def _dimension_order(path: PrincipalPath) -> tuple[str, str, str]: + if path is PrincipalPath.AGENT_USER: + return ("agent", "user", "session") + return ("user", "agent", "session") + + +def scope_covers( + parent: Scope, + child: Scope, + *, + principal_path: PrincipalPath = PrincipalPath.USER_AGENT, +) -> bool: + """``parent`` 是否覆盖 ``child``(即 child 在 parent 的所有者范围内)。 + + 规则,按维度从外到内: + + - ``org`` 与 ``space`` 必须完全相等——两者都是硬边界,不存在「父 org 覆盖子 org」; + - 主体维度按 ``principal_path`` 决定顺序,逐层比较; + - parent 在某维**留空**表示「该维及更内层不限制」,但更内层必须也全空: + ``user=alice, agent="", session="s1"`` 不是一个合法的父范围——它跳过了 agent + 却又限制 session,无法表达成一棵连续的子树。这种形状一律不覆盖,而不是忽略 + 空洞继续比——忽略空洞会让写坏的 Grant 意外扩大覆盖面。 + + ``principal_path`` 是 keyword-only:它改变的是**判定语义本身**,位置传参会让 + 「这次按哪种主体路径判定」在调用点看不出来。 + """ + if parent.org != child.org or parent.space != child.space: + return False + + order = _dimension_order(principal_path) + primary = order[0] + if getattr(parent, primary) != getattr(child, primary): + # 最外层主体维必须精确相等:留空的 parent 覆盖不了具名的 child,那会让 + # 一条「不限 user」的记录横扫整个 space。 + return False + + for index, dim in enumerate(order[1:], start=1): + parent_value = getattr(parent, dim) + if parent_value: + if parent_value != getattr(child, dim): + return False + continue + # parent 在本维留空 → 更内层也必须全空,否则是上面说的「空洞」形状。 + return not any(getattr(parent, later) for later in order[index + 1:]) + return True diff --git a/src/common/security/authorization/store.py b/src/common/security/authorization/store.py new file mode 100644 index 00000000..ff62a115 --- /dev/null +++ b/src/common/security/authorization/store.py @@ -0,0 +1,117 @@ +"""Grant 与 Delegation 的真源契约(F05 §Grant / §Delegation)。 + +两个 Store 刻意**分开**,不合成一个 "PermissionStore": + +- :class:`GrantStore` 是**资源侧**的长期开放(「A 把自己数据的读权限给 B」), + 可以没有过期时间; +- :class:`DelegationStore` 是**身份侧**的有限期代理(「user 授权 agent 代表自己」), + 必须有过期时间、必须能撤销、动作走 allowlist。 + +合成一个类型会让「撤销了代理但分享还在」这类正确行为难以表达,也会让「永久有效」 +这个 Grant 的合法状态泄漏成 Delegation 的合法状态。 + +两者都是 Authorizer 的**只读依赖**(写入走管理面 API)。查询接口按判定需要设计: +Authorizer 问的是「针对这次判定,有没有一条覆盖它的记录」,不是「把 grantee 的所有 +授权列出来让我筛」——后者把过滤逻辑推给调用方,每个调用方都有机会漏一个条件。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from datetime import datetime + +from common.factory.factory import Factory +from common.security.types import Action, Delegation, Grant +from common.type_def.scope import Scope + + +class GrantStoreProducer(Factory): + """GrantStore 的注册式工厂。""" + + TOP_NAME = "grant_store" + + +class DelegationStoreProducer(Factory): + """DelegationStore 的注册式工厂。""" + + TOP_NAME = "delegation_store" + + +class GrantStore(ABC): + """显式长期授权的真源。""" + + @abstractmethod + def add(self, grant: Grant) -> None: + """写入一条授权(按 ``grant_id`` 幂等)。 + + **撤销单调**:若同 ``grant_id`` 的记录已撤销,本次写入必须不生效,不得把 + ``revoked`` 翻回 ``False``。队列重投与网络重试会把撤销前的那份创建请求再送 + 一次,无条件覆盖即一条复活权限的路径。 + """ + + @abstractmethod + def revoke(self, grant_id: str) -> None: + """撤销一条授权(幂等;不存在时静默返回)。 + + 软撤销:记录保留、置撤销标记。硬删除会让「这条权限什么时候没的」在审计里 + 断线,而权限的消失时刻恰恰是事故复盘要问的第一个问题。 + """ + + @abstractmethod + def find_active( + self, + *, + grantee: Scope, + grantor_org: str, + action: Action, + now: datetime, + ) -> list[Grant]: + """取可能覆盖本次判定的**有效**授权(未撤销、未过期、动作匹配)。 + + 按 ``grantor_org`` 而非完整 grantor Scope 过滤:判定时只知道资源归属的 org + 是硬边界,具体哪条 grantor Scope 覆盖 target 要由 scope 覆盖规则算,那是 + Authorizer 的策略而不是存储的查询条件。 + + 实现**必须**在存储层就滤掉已撤销与已过期的记录,不能返回全量让调用方筛—— + 「取回来再过滤」的写法里,漏一个条件就是一个静默的越权。 + """ + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与其他安全能力同构。""" + + +class DelegationStore(ABC): + """代操作授权的真源。 + + 存在的理由是 F05 §从 header 直接产生 Delegation 那条拒绝:网关 header 最多证明 + 「网关声称这是某个 user」,证明不了「该 user 真的授权了这个 agent」。委托必须是 + 服务端事实,由 ``delegation_id`` 回这里复核。 + """ + + @abstractmethod + def add(self, delegation: Delegation) -> None: + """写入一条委托(按 ``delegation_id`` 幂等)。 + + 与 :meth:`GrantStore.add` 同样要求**撤销单调**:已撤销的委托不得被同 id 的 + 重放写回有效。 + """ + + @abstractmethod + def revoke(self, delegation_id: str) -> None: + """撤销一条委托(幂等)。同 :meth:`GrantStore.revoke`,软撤销。""" + + @abstractmethod + def get(self, delegation_id: str) -> Delegation | None: + """按标识取委托;不存在返回 ``None``。 + + **返回原始记录而不是「是否有效」的布尔**:时效判定要用 Authorizer 那一个 + 统一的 ``now``(见 :class:`~common.security.types.AuthorizationEnvironment`), + 存储自己取一次 ``now`` 会和 Grant 的时效判定错开。 + + 不存在与已撤销都由上层归到同一个 reason code——区分二者是委托枚举侧信道。 + """ + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与其他安全能力同构。""" diff --git a/src/common/security/bootstrap.py b/src/common/security/bootstrap.py new file mode 100644 index 00000000..3328fa0e --- /dev/null +++ b/src/common/security/bootstrap.py @@ -0,0 +1,27 @@ +"""安全能力的统一注册入口(F05 §装配不变量 2:注册在装配前统一完成)。 + +各实现包不互相 import;注册顺序由本模块单点管理。import 实现包即触发其 +``@Producer.register(...)`` 自注册,本函数只负责按固定顺序把它们 import 进来。 + +**必须在配置解析之前调用**:``authenticator`` / ``cryptography`` / ``security`` 等顶层 +段名要先进 ``Factory.known_top_names()``,否则解析期会把它们当未知段拒掉。 +""" + +from __future__ import annotations + +from importlib import import_module + +_REGISTERED = False + + +def register_security() -> None: + """import 全部安全实现包,完成自注册(幂等;import 已缓存,重复调用近乎零成本)。""" + global _REGISTERED + if _REGISTERED: + return + import_module("common.security.runtime") # SecurityRuntimeProducer + standard + import_module("common.security.authentication.authentication_impl") + import_module("common.security.authorization.authorization_impl") + import_module("common.security.protection.protection_impl") + import_module("common.security.cryptography.cryptography_impl") + _REGISTERED = True diff --git a/src/common/security/cryptography/__init__.py b/src/common/security/cryptography/__init__.py new file mode 100644 index 00000000..bb88f364 --- /dev/null +++ b/src/common/security/cryptography/__init__.py @@ -0,0 +1,26 @@ +"""Cryptography 能力:字节级加解密契约与密钥提供(F05 §Cryptography)。""" + +from .base import ( + AuthenticationFailedError, + CorruptedCiphertextError, + CryptographyError, + CryptographyProducer, + CryptographyProvider, + InvalidMagicError, + KeyMismatchError, +) +from .key_provider import KeyProvider, KeyProviderProducer, KeyRef, WrappedKey + +__all__ = [ + "AuthenticationFailedError", + "CorruptedCiphertextError", + "CryptographyError", + "CryptographyProducer", + "CryptographyProvider", + "InvalidMagicError", + "KeyMismatchError", + "KeyProvider", + "KeyProviderProducer", + "KeyRef", + "WrappedKey", +] diff --git a/src/common/security/cryptography/base.py b/src/common/security/cryptography/base.py new file mode 100644 index 00000000..832880b0 --- /dev/null +++ b/src/common/security/cryptography/base.py @@ -0,0 +1,80 @@ +"""CryptographyProvider — bytes 边界的加解密契约(F05 §Cryptography)。 + +安全能力不是无状态模型插件,不继承 :class:`common.base.Plugin`,但仍使用 +``Factory`` 提供注册式装配。调用方以**字节**为边界调用本接口:写入持久化字节前 +加密,读取持久化字节后解密。 + +契约边界(F05 §CryptographyProvider):本接口不接收 MemoryUnit、KV key 或文件 +路径等业务对象,也**不决定数据是否应该加密**——那是存储适配器的选择,由上层配 +不同适配器表达,不是本接口内部的开关。 + +密钥一律经 :class:`~common.security.cryptography.key_provider.KeyProvider` 取得, +实现不得自己读环境变量或配置文件里的根密钥。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from common.errors import AgentMemoryError +from common.factory.factory import Factory +from common.security.types import CryptoContext + + +class CryptographyProducer(Factory): + """CryptographyProvider 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``cryptography_impl`` 下以 ``@CryptographyProducer.register("<名>")`` + 自注册,由 :func:`common.security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "cryptography" + + +class CryptographyError(AgentMemoryError): + """加密或解密处理失败。""" + + +class InvalidMagicError(CryptographyError): + """密文字节不符合当前 provider 期望的信封魔数。""" + + +class CorruptedCiphertextError(CryptographyError): + """密文信封结构损坏、版本不支持或长度不完整。""" + + +class AuthenticationFailedError(CryptographyError): + """认证加密 tag 校验失败,通常表示 AAD 不匹配或内容被篡改。""" + + +class KeyMismatchError(CryptographyError): + """包裹的数据密钥无法用对应的密钥材料解开。""" + + +class CryptographyProvider(ABC): + """字节级数据保护能力。""" + + @abstractmethod + def encrypt(self, plaintext: bytes, *, context: CryptoContext, aad: bytes = b"") -> bytes: + """加密明文字节,返回自描述信封。 + + ``context`` **必填**:AAD 必须绑定规范化 Scope、存储用途、对象标识和格式 + 版本(F05 §信封格式),缺了它密文就能被复制到其他租户、对象或存储位置后 + 照样解开。旧接口允许 ``context=None`` 并静默用空 Scope,是这条不变量的缺口。 + + ``aad`` 是调用方追加的附加认证数据,参与完整性保护但不写入密文。 + """ + + @abstractmethod + def decrypt(self, ciphertext: bytes, *, context: CryptoContext, aad: bytes = b"") -> bytes: + """解密密文字节并校验完整性。 + + **不提供明文回退**(F05 §明文策略):入参不是合法信封时抛 + :class:`InvalidMagicError`,解密失败时抛 + :class:`AuthenticationFailedError`,两种情况都不得返回原始 bytes。是否允许 + 未加密存储由上层选用不同存储适配器表达。 + """ + + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则由实现抛出异常。""" + return None diff --git a/src/common/security/cryptography/cryptography_impl/__init__.py b/src/common/security/cryptography/cryptography_impl/__init__.py new file mode 100644 index 00000000..d668cfad --- /dev/null +++ b/src/common/security/cryptography/cryptography_impl/__init__.py @@ -0,0 +1,14 @@ +"""cryptography_impl 实现集:两个 Cryptography 工厂 + 各实现。 + +import 各实现模块即触发其 ``@Producer.register(...)`` 自注册; +本包只对外暴露两个工厂。 +""" + +from importlib import import_module + +from common.security.cryptography.base import CryptographyProducer +from common.security.cryptography.key_provider import KeyProviderProducer + +import_module(".local_envelope", __name__) + +__all__ = ["CryptographyProducer", "KeyProviderProducer"] diff --git a/src/common/security/cryptography/cryptography_impl/local_envelope.py b/src/common/security/cryptography/cryptography_impl/local_envelope.py new file mode 100644 index 00000000..50f4a759 --- /dev/null +++ b/src/common/security/cryptography/cryptography_impl/local_envelope.py @@ -0,0 +1,700 @@ +"""ENC1 本地信封实现:LocalKeyProvider + LocalEnvelopeCryptographyProvider。 + +两个能力装在同一模块,因为它们共享同一套 ENC1 常量与 AES-GCM 原语;对外仍是两个 +独立 Producer 注册(``key_provider.local`` 与 ``cryptography.local``),可各自被具名 +引用与替换。 + +信封结构(F05 §信封格式):: + + root key --HKDF(purpose, org)--> org key --AES-GCM--> 包裹 per-value data key + data key --AES-GCM--> 内容密文 + + header: magic | version | algorithm id | 各段长度 | key id 长度 | key epoch + body: wrapped data key | key nonce | data nonce | key id | 内容密文+tag + +**版本迁移**:写入一律用 v2(带 key id/epoch);读取兼容 v1(无 key id/epoch, +用旧的派生与 AAD 布局)。v1 不再写出,也不会因为解不开而回退明文——不合法信封 +一律拒绝(F05 §明文策略)。 +""" + +from __future__ import annotations + +import binascii +import json +import os +import secrets +import struct +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from common.errors import BackendError, ValidationError +from common.factory.factory import Factory +from common.security.cryptography.base import ( + AuthenticationFailedError, + CorruptedCiphertextError, + CryptographyProducer, + CryptographyProvider, + InvalidMagicError, + KeyMismatchError, +) +from common.security.cryptography.key_provider import ( + KeyProvider, + KeyProviderProducer, + KeyRef, + WrappedKey, +) +from common.security.types import CryptoContext + +try: + from cryptography.exceptions import InvalidTag + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.hkdf import HKDF +except ImportError as import_error: # pragma: no cover - exercised only in minimal installs + _CRYPTO_IMPORT_ERROR: ImportError | None = import_error + InvalidTag = None # type: ignore[assignment] + AESGCM = None # type: ignore[assignment] + HKDF = None # type: ignore[assignment] + hashes = None # type: ignore[assignment] +else: + _CRYPTO_IMPORT_ERROR = None + + +ENVELOPE_MAGIC = b"ENC1" +ENVELOPE_VERSION_V1 = 0x01 # 只读兼容:无 key id / epoch +ENVELOPE_VERSION = 0x02 # 当前写出版本 +LOCAL_ALGORITHM_ID = 0x01 # AES-256-GCM 信封(原 provider_id,语义未变) +NONCE_SIZE = 12 +DATA_KEY_SIZE = 32 +_HEADER_V1 = struct.Struct("!4sBBHHH") +_HEADER = struct.Struct("!4sBBHHHBI") +_MAX_KEY_ID_LEN = 255 # key id 长度字段是 1 字节 +_DEFAULT_KEY_FILE = "~/.agent-memory/security/master.key" +_DEFAULT_KEY_ENV = "AGENT_MEMORY_ENCRYPTION_ROOT_KEY" +_HKDF_SALT = b"agent-memory-security-local-salt-v1" +_KEY_ID_CHARS = 32 # 128 bit 指纹的十六进制长度 + + +@dataclass(frozen=True) +class _Envelope: + version: int + algorithm_id: int + wrapped_data_key: bytes + key_nonce: bytes + data_nonce: bytes + key_id: str + key_epoch: int + encrypted_content: bytes + + +def _private_file_opener(path: str, flags: int) -> int: + return os.open(path, flags, 0o600) + + +# ====================================================================== # +# KeyProvider +# ====================================================================== # + + +class LocalKeyProvider(KeyProvider): + """本地根密钥的 KeyProvider:单机或开发部署。 + + **多代轮换**::meth:`rotate` 生成新随机根密钥并推进 epoch,旧 epoch 的根密钥 + 保留在进程内字典供 :meth:`unwrap` 解开历史信封。新根密钥**不持久化**--进程重启 + 后回到配置声明的初始密钥,轮换后写入的信封在重启后不可读。需要跨重启保留轮换 + 状态的部署应换 KMS/Vault 实现,由其管理历史 epoch 的验证材料。 + """ + + def __init__( + self, + *, + key_file: str = _DEFAULT_KEY_FILE, + key_hex: str = "", + key_b64: str = "", + key_env: str = _DEFAULT_KEY_ENV, + create_key_file: bool = True, + key_epoch: int = 1, + ) -> None: + _ensure_crypto() + if key_epoch < 1: + raise ValidationError("key_epoch must be >= 1") + self._key_file = Path(key_file).expanduser() if key_file else None + self._key_hex = key_hex.strip() + self._key_b64 = key_b64.strip() + self._key_env = key_env.strip() + self._create_key_file_enabled = create_key_file + self._key_epoch = key_epoch + self._root_key: bytes | None = None + self._key_id: str = "" + # 历史 epoch 的根密钥:rotate 时把旧代根密钥保留于此,供 unwrap 解开历史信封。 + self._keys: dict[int, bytes] = {} + + # -- KeyProvider 契约 -------------------------------------------------- # + + def active_key(self) -> KeyRef: + return KeyRef(key_id=self._active_key_id(), epoch=self._key_epoch) + + def wrap(self, data_key: bytes, *, purpose: str, org: str) -> WrappedKey: + if len(data_key) != DATA_KEY_SIZE: + raise ValidationError(f"local data key must be {DATA_KEY_SIZE} bytes") + ref = self.active_key() + wrapping_key = self._derive_wrapping_key(purpose=purpose, org=org) + nonce = secrets.token_bytes(NONCE_SIZE) + ciphertext = _aes_encrypt( + wrapping_key, nonce, data_key, _key_aad(purpose=purpose, org=org, ref=ref) + ) + return WrappedKey(ciphertext=ciphertext, nonce=nonce, ref=ref) + + def unwrap(self, wrapped: WrappedKey, *, purpose: str, org: str) -> bytes: + root_key = self._root_key_for_epoch(wrapped.ref) + if root_key is None: + # 找不到该 epoch 的保留材料即拒,不拿活动密钥试解--试解成功会让 epoch + # 绑定形同虚设,失败则退化成难以诊断的 tag 校验错误。 + raise KeyMismatchError( + "data key was wrapped by a different key generation " + f"(epoch {wrapped.ref.epoch}); no retained key material for it" + ) + wrapping_key = self._derive_wrapping_key(purpose=purpose, org=org, root_key=root_key) + return self._unwrap_with( + wrapping_key, + wrapped, + _key_aad(purpose=purpose, org=org, ref=wrapped.ref), + ) + + def rotate(self) -> KeyRef: + # 保留当前 epoch 的根密钥供历史信封解密,再生成新随机根密钥推进 epoch。 + # 新根密钥**不持久化**(见类 docstring):进程重启回到配置声明的初始密钥, + # 轮换后写入的信封在重启后不可读。需要跨重启保留轮换状态应换 KMS/Vault。 + self._keys[self._key_epoch] = self._load_root_key() + self._root_key = secrets.token_bytes(DATA_KEY_SIZE) + self._key_epoch += 1 + self._key_id = "" # 活动密钥指纹需重算 + return self.active_key() + + def health(self) -> None: + self._load_root_key() + + # -- v1 只读兼容 ------------------------------------------------------- # + + def unwrap_legacy_v1(self, wrapped: WrappedKey, *, org: str) -> bytes: + """解开 v1 信封的数据密钥:无 key id/epoch,用旧派生与旧 AAD。 + + v1 的包裹密钥不含 purpose——用途隔离是本次迁移新增的,旧数据无从追认。 + """ + wrapping_key = self._derive_org_key_v1(org) + return self._unwrap_with(wrapping_key, wrapped, _key_aad_v1(org)) + + # -- 内部 -------------------------------------------------------------- # + + def _unwrap_with(self, wrapping_key: bytes, wrapped: WrappedKey, aad: bytes) -> bytes: + try: + data_key = _aes_decrypt(wrapping_key, wrapped.nonce, wrapped.ciphertext, aad) + except AuthenticationFailedError as exc: + raise KeyMismatchError("wrapped data key cannot be decrypted") from exc + if len(data_key) != DATA_KEY_SIZE: + raise CorruptedCiphertextError("unwrapped data key has invalid length") + return data_key + + def _active_key_id(self) -> str: + """根密钥的不可逆指纹,作 key id 写进信封。 + + 用 HKDF 从根密钥派生而不是直接哈希:派生结果与包裹密钥出自不同 info 标签, + 泄露 key id 不会给暴力破解根密钥提供额外杠杆。 + """ + if not self._key_id: + digest = self._hkdf( + info=b"agent-memory:security:key-id:v1", + length=DATA_KEY_SIZE, + ) + self._key_id = digest.hex()[:_KEY_ID_CHARS] + return self._key_id + + def _derive_wrapping_key( + self, *, purpose: str, org: str, root_key: bytes | None = None + ) -> bytes: + """按 (purpose, org) 派生包裹密钥——用途隔离 + 租户隔离(F05 §密钥隔离)。 + + 长度前缀防歧义:``purpose="a" org="b:c"`` 与 ``purpose="a:b" org="c"`` + 直接拼接会得到同一个 info,从而共用同一把包裹密钥。 + """ + purpose_bytes = purpose.encode("utf-8") + org_bytes = org.encode("utf-8") + info = ( + b"agent-memory:security:kek:v2:" + + len(purpose_bytes).to_bytes(4, "big") + + purpose_bytes + + len(org_bytes).to_bytes(4, "big") + + org_bytes + ) + return self._hkdf(info=info, length=DATA_KEY_SIZE, root_key=root_key) + + def _derive_org_key_v1(self, org_id: str) -> bytes: + """v1 的按 org 派生(无 purpose)。只用于读旧信封。""" + return self._hkdf( + info=b"agent-memory:security:kek:v1:" + org_id.encode("utf-8"), + length=DATA_KEY_SIZE, + ) + + def _hkdf(self, *, info: bytes, length: int, root_key: bytes | None = None) -> bytes: + hkdf_type = HKDF + hashes_module = hashes + if hkdf_type is None or hashes_module is None: + _ensure_crypto() + raise BackendError("cryptography HKDF support is unavailable") + hkdf = hkdf_type( + algorithm=hashes_module.SHA256(), + length=length, + salt=_HKDF_SALT, + info=info, + ) + return hkdf.derive(root_key if root_key is not None else self._load_root_key()) + + def _load_root_key(self) -> bytes: + if self._root_key is None: + self._root_key = self._load_or_create_root_key() + return self._root_key + + def _root_key_for_epoch(self, ref: KeyRef) -> bytes | None: + """取某 epoch 的根密钥:活动 epoch 用 ``_load_root_key``,旧 epoch 用 ``_keys``。 + + ``key_id`` 不在此单独校验--旧 epoch 的 AAD 含 ``ref``(key_id+epoch), + 拿错材料派生的 wrapping_key 会被 AES-GCM 的 AAD 校验拒绝,表现为 + :class:`KeyMismatchError`。 + """ + if ref.epoch == self._key_epoch: + return self._load_root_key() + return self._keys.get(ref.epoch) + + def _load_or_create_root_key(self) -> bytes: + if self._key_hex: + return _decode_hex_key(self._key_hex, source="key_hex") + if self._key_b64: + return _decode_b64_key(self._key_b64, source="key_b64") + + env_value = os.environ.get(self._key_env) if self._key_env else None + if env_value: + return _decode_key_string(env_value, source=f"env {self._key_env}") + + if self._key_file is None: + raise BackendError("local security requires key_hex, key_b64, key_env, or key_file") + if self._key_file.exists(): + _restrict_file_mode(self._key_file) + return _decode_hex_key( + self._key_file.read_text(encoding="ascii").strip(), + source=str(self._key_file), + ) + if not self._create_key_file_enabled: + raise BackendError(f"local security key file does not exist: {self._key_file}") + return self._create_key_file() + + def _create_key_file(self) -> bytes: + key_file = self._key_file + if key_file is None: + raise BackendError("local security key file is not configured") + key = secrets.token_bytes(DATA_KEY_SIZE) + key_file.parent.mkdir(parents=True, exist_ok=True) + try: + with open( + key_file, + "x", + encoding="ascii", + opener=_private_file_opener, + ) as key_stream: + key_stream.write(f"{key.hex()}\n") + except FileExistsError: + _restrict_file_mode(key_file) + return _decode_hex_key( + key_file.read_text(encoding="ascii").strip(), + source=str(key_file), + ) + except Exception: + key_file.unlink(missing_ok=True) + raise + _restrict_file_mode(key_file) + return key + + +# ====================================================================== # +# CryptographyProvider +# ====================================================================== # + + +class LocalEnvelopeCryptographyProvider(CryptographyProvider): + """ENC1 AES-256-GCM 信封,密钥经 KeyProvider 取得。 + + **无明文回退**(F05 §明文策略):入参不是合法信封即拒绝读取,解密失败不返回 + 原始 bytes。是否允许未加密存储由上层选用不同的存储适配器表达,不在本类里开关。 + """ + + def __init__(self, key_provider: KeyProvider) -> None: + _ensure_crypto() + self._key_provider = key_provider + + def encrypt(self, plaintext: bytes, *, context: CryptoContext, aad: bytes = b"") -> bytes: + data_key = secrets.token_bytes(DATA_KEY_SIZE) + data_nonce = secrets.token_bytes(NONCE_SIZE) + wrapped = self._key_provider.wrap(data_key, purpose=context.purpose, org=context.scope.org) + key_id_bytes = wrapped.ref.key_id.encode("utf-8") + if len(key_id_bytes) > _MAX_KEY_ID_LEN: + raise ValidationError( + f"key id is too long for the ENC1 envelope ({len(key_id_bytes)} bytes)" + ) + encrypted_content = _aes_encrypt( + data_key, + data_nonce, + plaintext, + _content_aad(context, aad, ref=wrapped.ref), + ) + return _build_envelope( + wrapped=wrapped, + key_id_bytes=key_id_bytes, + data_nonce=data_nonce, + encrypted_content=encrypted_content, + ) + + def decrypt(self, ciphertext: bytes, *, context: CryptoContext, aad: bytes = b"") -> bytes: + if not ciphertext.startswith(ENVELOPE_MAGIC): + raise InvalidMagicError("ciphertext is not an ENC1 envelope") + + envelope = _parse_envelope(ciphertext) + _validate_local_envelope(envelope) + wrapped = WrappedKey( + ciphertext=envelope.wrapped_data_key, + nonce=envelope.key_nonce, + ref=KeyRef(key_id=envelope.key_id, epoch=envelope.key_epoch), + ) + + if envelope.version == ENVELOPE_VERSION_V1: + data_key = self._unwrap_v1(wrapped, org=context.scope.org) + content_aad = _content_aad_v1(context, aad) + else: + data_key = self._key_provider.unwrap( + wrapped, purpose=context.purpose, org=context.scope.org + ) + content_aad = _content_aad(context, aad, ref=wrapped.ref) + + return _aes_decrypt(data_key, envelope.data_nonce, envelope.encrypted_content, content_aad) + + def health(self) -> None: + self._key_provider.health() + + def _unwrap_v1(self, wrapped: WrappedKey, *, org: str) -> bytes: + """v1 信封的数据密钥解包,只有声明支持的 KeyProvider 能做。""" + legacy = getattr(self._key_provider, "unwrap_legacy_v1", None) + if legacy is None: + raise CorruptedCiphertextError( + "ENC1 v1 envelope requires a key provider with v1 read compatibility" + ) + return legacy(wrapped, org=org) + + +# ====================================================================== # +# 信封编解码 +# ====================================================================== # + + +def _ensure_crypto() -> None: + if _CRYPTO_IMPORT_ERROR is not None: + raise BackendError( + "security.local requires the 'cryptography' package; install project dependencies" + ) from _CRYPTO_IMPORT_ERROR + + +def _build_envelope( + *, + wrapped: WrappedKey, + key_id_bytes: bytes, + data_nonce: bytes, + encrypted_content: bytes, +) -> bytes: + header = _HEADER.pack( + ENVELOPE_MAGIC, + ENVELOPE_VERSION, + LOCAL_ALGORITHM_ID, + len(wrapped.ciphertext), + len(wrapped.nonce), + len(data_nonce), + len(key_id_bytes), + wrapped.ref.epoch, + ) + return ( + header + wrapped.ciphertext + wrapped.nonce + data_nonce + key_id_bytes + encrypted_content + ) + + +def _parse_envelope(ciphertext: bytes) -> _Envelope: + # 版本字节的位置在 v1/v2 一致(紧跟 magic),先读版本再选布局。 + if len(ciphertext) < _HEADER_V1.size: + raise CorruptedCiphertextError("ENC1 envelope too short") + version = ciphertext[len(ENVELOPE_MAGIC)] + if version == ENVELOPE_VERSION_V1: + return _parse_envelope_v1(ciphertext) + if version != ENVELOPE_VERSION: + raise CorruptedCiphertextError(f"unsupported ENC1 version: {version}") + + if len(ciphertext) < _HEADER.size: + raise CorruptedCiphertextError("ENC1 envelope too short") + ( + magic, + _version, + algorithm_id, + key_len, + key_nonce_len, + data_nonce_len, + key_id_len, + key_epoch, + ) = _HEADER.unpack(ciphertext[: _HEADER.size]) + if magic != ENVELOPE_MAGIC: + raise InvalidMagicError("ciphertext is not an ENC1 envelope") + + offset = _HEADER.size + body_len = key_len + key_nonce_len + data_nonce_len + key_id_len + if len(ciphertext) < offset + body_len: + raise CorruptedCiphertextError("ENC1 envelope length is incomplete") + + wrapped_data_key, offset = _take(ciphertext, offset, key_len) + key_nonce, offset = _take(ciphertext, offset, key_nonce_len) + data_nonce, offset = _take(ciphertext, offset, data_nonce_len) + key_id_raw, offset = _take(ciphertext, offset, key_id_len) + encrypted_content = ciphertext[offset:] + if not encrypted_content: + raise CorruptedCiphertextError("ENC1 envelope has no encrypted content") + try: + key_id = key_id_raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise CorruptedCiphertextError("ENC1 envelope key id is not valid UTF-8") from exc + + return _Envelope( + version=ENVELOPE_VERSION, + algorithm_id=algorithm_id, + wrapped_data_key=wrapped_data_key, + key_nonce=key_nonce, + data_nonce=data_nonce, + key_id=key_id, + key_epoch=key_epoch, + encrypted_content=encrypted_content, + ) + + +def _parse_envelope_v1(ciphertext: bytes) -> _Envelope: + magic, _version, algorithm_id, key_len, key_nonce_len, data_nonce_len = _HEADER_V1.unpack( + ciphertext[: _HEADER_V1.size] + ) + if magic != ENVELOPE_MAGIC: + raise InvalidMagicError("ciphertext is not an ENC1 envelope") + + offset = _HEADER_V1.size + body_len = key_len + key_nonce_len + data_nonce_len + if len(ciphertext) < offset + body_len: + raise CorruptedCiphertextError("ENC1 envelope length is incomplete") + + wrapped_data_key, offset = _take(ciphertext, offset, key_len) + key_nonce, offset = _take(ciphertext, offset, key_nonce_len) + data_nonce, offset = _take(ciphertext, offset, data_nonce_len) + encrypted_content = ciphertext[offset:] + if not encrypted_content: + raise CorruptedCiphertextError("ENC1 envelope has no encrypted content") + + return _Envelope( + version=ENVELOPE_VERSION_V1, + algorithm_id=algorithm_id, + wrapped_data_key=wrapped_data_key, + key_nonce=key_nonce, + data_nonce=data_nonce, + key_id="", # v1 无 key id + key_epoch=0, # 0 = 未声明,与合法 epoch(>=1)不会混淆 + encrypted_content=encrypted_content, + ) + + +def _take(buffer: bytes, offset: int, length: int) -> tuple[bytes, int]: + end = offset + length + return buffer[offset:end], end + + +def _validate_local_envelope(envelope: _Envelope) -> None: + if envelope.algorithm_id != LOCAL_ALGORITHM_ID: + raise CorruptedCiphertextError(f"unsupported algorithm id: {envelope.algorithm_id}") + if len(envelope.key_nonce) != NONCE_SIZE: + raise CorruptedCiphertextError("wrapped data key nonce has invalid length") + if len(envelope.data_nonce) != NONCE_SIZE: + raise CorruptedCiphertextError("content nonce has invalid length") + if len(envelope.wrapped_data_key) < 16: + raise CorruptedCiphertextError("wrapped data key is too short") + if len(envelope.encrypted_content) < 16: + raise CorruptedCiphertextError("encrypted content is too short") + if envelope.version == ENVELOPE_VERSION and not envelope.key_id: + raise CorruptedCiphertextError("ENC1 v2 envelope is missing the key id") + + +# ====================================================================== # +# AES-GCM 原语 +# ====================================================================== # + + +def _aes_encrypt(key: bytes, nonce: bytes, plaintext: bytes, aad: bytes) -> bytes: + aesgcm_type = AESGCM + if aesgcm_type is None: + _ensure_crypto() + raise BackendError("cryptography AES-GCM support is unavailable") + try: + return aesgcm_type(key).encrypt(nonce, plaintext, aad) + except Exception as exc: + raise BackendError("AES-GCM encryption failed") from exc + + +def _aes_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, aad: bytes) -> bytes: + aesgcm_type = AESGCM + if aesgcm_type is None: + _ensure_crypto() + raise BackendError("cryptography AES-GCM support is unavailable") + try: + return aesgcm_type(key).decrypt(nonce, ciphertext, aad) + except Exception as exc: + if _is_invalid_tag(exc): + raise AuthenticationFailedError("AES-GCM authentication failed") from exc + raise BackendError("AES-GCM decryption failed") from exc + + +def _is_invalid_tag(exc: Exception) -> bool: + return InvalidTag is not None and isinstance(exc, InvalidTag) + + +# ====================================================================== # +# AAD 构造 +# ====================================================================== # + + +def _content_aad(context: CryptoContext, aad: bytes, *, ref: KeyRef) -> bytes: + """v2 内容 AAD:绑定 Scope、用途、对象 id、格式版本与 key ref(F05 §信封格式)。 + + key ref 一并进 AAD,使信封头里的 key id/epoch 不能被替换成另一代——只写进头部 + 而不参与认证的字段是可篡改的。 + """ + payload = _context_payload_v1(context) + payload["object_id"] = context.object_id + payload["format_version"] = context.format_version + payload["key_id"] = ref.key_id + payload["key_epoch"] = ref.epoch + return _pack_aad(b"AMSEC-AAD2", payload, aad) + + +def _content_aad_v1(context: CryptoContext, aad: bytes) -> bytes: + """v1 内容 AAD:只绑定 Scope、用途与 metadata。只用于读旧信封。""" + return _pack_aad(b"AMSEC-AAD1", _context_payload_v1(context), aad) + + +def _pack_aad(tag: bytes, payload: dict[str, Any], aad: bytes) -> bytes: + payload_bytes = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + return tag + len(payload_bytes).to_bytes(4, "big") + payload_bytes + aad + + +def _context_payload_v1(context: CryptoContext) -> dict[str, Any]: + scope = context.scope + return { + "scope": { + "org": scope.org, + "space": str(scope.space), + "user": scope.user, + "agent": scope.agent, + "session": scope.session, + }, + "purpose": context.purpose, + "metadata": {str(k): str(v) for k, v in sorted(context.metadata.items())}, + } + + +def _key_aad(*, purpose: str, org: str, ref: KeyRef) -> bytes: + """v2 包裹层 AAD:绑定用途、租户与 key ref。""" + payload = {"purpose": purpose, "org": org, "key_id": ref.key_id, "key_epoch": ref.epoch} + return _pack_aad(b"agent-memory:security:data-key:v2:", payload, b"") + + +def _key_aad_v1(org_id: str) -> bytes: + """v1 包裹层 AAD:只绑定租户。只用于读旧信封。""" + return b"agent-memory:security:data-key:v1:" + org_id.encode("utf-8") + + +# ====================================================================== # +# 根密钥解码 +# ====================================================================== # + + +def _decode_key_string(value: str, *, source: str) -> bytes: + raw = value.strip() + if raw.startswith("hex:"): + return _decode_hex_key(raw[4:], source=source) + if raw.startswith("base64:"): + return _decode_b64_key(raw[7:], source=source) + return _decode_hex_key(raw, source=source) + + +def _decode_hex_key(value: str, *, source: str) -> bytes: + try: + key = bytes.fromhex(value.strip()) + except ValueError as exc: + raise ValidationError(f"invalid hex encryption root key from {source}") from exc + return _validate_root_key(key, source=source) + + +def _decode_b64_key(value: str, *, source: str) -> bytes: + try: + key = binascii.a2b_base64(value.strip(), strict_mode=True) + except binascii.Error as exc: + raise ValidationError(f"invalid base64 encryption root key from {source}") from exc + return _validate_root_key(key, source=source) + + +def _validate_root_key(key: bytes, *, source: str) -> bytes: + if len(key) != DATA_KEY_SIZE: + raise ValidationError(f"encryption root key from {source} must be {DATA_KEY_SIZE} bytes") + return key + + +def _restrict_file_mode(path: Path) -> None: + try: + os.chmod(path, 0o600) + except OSError as exc: + raise BackendError(f"failed to set key file permissions: {path}") from exc + + +def _as_bool(value: Any, *, default: bool) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + return bool(value) + + +# ====================================================================== # +# 注册 +# ====================================================================== # + + +@KeyProviderProducer.register("local") +def _build_key_provider(config): + return LocalKeyProvider( + key_file=Factory.cfg_get(config, "key_file", _DEFAULT_KEY_FILE), + key_hex=Factory.cfg_get(config, "key_hex", ""), + key_b64=Factory.cfg_get(config, "key_b64", ""), + key_env=Factory.cfg_get(config, "key_env", _DEFAULT_KEY_ENV), + create_key_file=_as_bool( + Factory.cfg_get(config, "create_key_file", True), + default=True, + ), + key_epoch=int(Factory.cfg_get(config, "key_epoch", 1)), + ) + + +@CryptographyProducer.register("local") +def _build(config): + return LocalEnvelopeCryptographyProvider( + KeyProviderProducer.dep(config, "key_provider", default="local") + ) diff --git a/src/common/security/cryptography/key_provider.py b/src/common/security/cryptography/key_provider.py new file mode 100644 index 00000000..bc9b26ba --- /dev/null +++ b/src/common/security/cryptography/key_provider.py @@ -0,0 +1,97 @@ +"""密钥提供与轮换契约(F05 §Cryptography §KeyProvider)。 + +密码学能力**只能通过本接口获取密钥**,不得自己读环境变量或配置文件里的根密钥 +——否则「密钥从哪来」这条最敏感的路径会散落在每个 provider 里,KMS/Vault/HSM +也就无从接入。 + +用途隔离(F05 §密钥隔离)由 :meth:`KeyProvider.wrap` 的 ``purpose`` 参数承担: +加密、审计完整性、token 签名各自派生独立子密钥。API Key 与 Encryption Root Key +永远不是同一密钥体系——前者归 +:mod:`common.security.authentication.key_store`,两边不共享任何材料。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from common.factory.factory import Factory + + +class KeyProviderProducer(Factory): + """KeyProvider 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``cryptography_impl`` 下以 ``@KeyProviderProducer.register("<后端>")`` + 自注册,由 :func:`common.security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "key_provider" + + +@dataclass(frozen=True) +class KeyRef: + """一把密钥的标识:id + epoch(F05 §信封格式)。 + + ``key_id`` 标识密钥体系中的哪一把,``epoch`` 标识它的第几代。两者都进信封头 + 与 AAD:只有 id 没有 epoch 时,轮换后的密文可以被替换成同一把 key 的旧代密文 + 而不被察觉。 + + ``key_id`` 必须是**不可逆标识**(如密钥材料的 KDF 派生指纹),不得包含密钥 + 材料本身——它会明文写进信封,随数据一起落盘。 + """ + + key_id: str + epoch: int = 1 + + +@dataclass(frozen=True) +class WrappedKey: + """被包裹的数据密钥及其解开所需的全部非敏感元数据。""" + + ciphertext: bytes + nonce: bytes + ref: KeyRef + + +class KeyProvider(ABC): + """密钥的提供方:包裹/解开数据密钥,并声明当前活动密钥。""" + + @abstractmethod + def active_key(self) -> KeyRef: + """当前用于**加密**的密钥标识。解密按密文自带的 ref 走,不用这个。""" + + @abstractmethod + def rotate(self) -> KeyRef: + """轮换到新一代活动密钥,返回新的 :class:`KeyRef`(F05 §KeyProvider)。 + + 轮换后 :meth:`active_key` 与新 :meth:`wrap` 都落到新 epoch;旧 epoch 密文仍按 + 自带 ref 解开,前提是实现保留了历史 epoch 的验证材料。能否安全轮换是 + KeyProvider 的契约能力,而不只是 ``active_key`` 之外的一个可选项--只有 epoch + 字段、没有轮换入口的实现不满足 F05。实现必须真正推进 epoch(或更换 key_id), + 不得永远抛错冒充 fail-closed。 + """ + + @abstractmethod + def wrap(self, data_key: bytes, *, purpose: str, org: str) -> WrappedKey: + """用当前活动密钥包裹一把数据密钥。 + + ``purpose`` 与 ``org`` 参与密钥派生与包裹层 AAD:前者实现用途隔离(F05 + §密钥隔离),后者实现租户隔离——两者不同的调用绝不能解开彼此的数据密钥。 + """ + + @abstractmethod + def unwrap(self, wrapped: WrappedKey, *, purpose: str, org: str) -> bytes: + """解开数据密钥;失败抛 + :class:`~common.security.cryptography.base.KeyMismatchError`。 + + 必须按 ``wrapped.ref`` 选取密钥材料,而不是无条件用活动密钥——轮换后旧 + 数据仍要可读,前提是实现保留了对应 epoch 的验证材料。找不到对应材料时 + **拒绝**,不得回退到活动密钥试解(那会让 epoch 绑定形同虚设)。 + """ + + @abstractmethod + def health(self) -> None: + """存活探测:密钥可用时返回 ``None``,否则抛出异常。 + + 不得在异常消息里泄露密钥材料、密钥文件内容或 KMS 凭据(F05 §装配不变量 8)。 + """ diff --git a/src/common/security/protection/__init__.py b/src/common/security/protection/__init__.py new file mode 100644 index 00000000..dd68f853 --- /dev/null +++ b/src/common/security/protection/__init__.py @@ -0,0 +1,14 @@ +"""Protection 能力:入口限流、昂贵操作预算、绑定策略(F05 §Protection)。""" + +from .binding_policy import BindingPolicy, BindingPolicyProducer +from .rate_limit import RateLimiter, RateLimitProducer +from .workload_guard import WorkloadGuard, WorkloadGuardProducer + +__all__ = [ + "BindingPolicy", + "BindingPolicyProducer", + "RateLimitProducer", + "RateLimiter", + "WorkloadGuard", + "WorkloadGuardProducer", +] diff --git a/src/common/security/protection/binding_policy.py b/src/common/security/protection/binding_policy.py new file mode 100644 index 00000000..b833e141 --- /dev/null +++ b/src/common/security/protection/binding_policy.py @@ -0,0 +1,49 @@ +"""绑定地址策略契约(F05 §Protection §BindingPolicy)。 + +无认证开发模式恒返回 ROOT 身份,绑到非 loopback 就是把全权限暴露给整个网络。 +F05 要求这条约束由**统一 Server lifecycle 在实际 socket 绑定前执行**,不能只 +存在于某个 CLI ``main()``——故本模块给出能力接口与 Producer,由 Server 在真正 +``bind()`` 之前调用,而不是让每个 surface 的入口各自记得调。 + +策略**抛异常,不 ``sys.exit``**:exit 语义留在真正的进程入口,这样策略可被单测 +直接断言,而不会让测试进程退出。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Sequence + +from common.factory.factory import Factory + + +class BindingPolicyProducer(Factory): + """BindingPolicy 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``protection_impl`` 下以 ``@BindingPolicyProducer.register("<策略>")`` + 自注册,由 :func:`common.security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "binding_policy" + + +class BindingPolicy(ABC): + """监听地址准入:在 socket 真正绑定前裁决。""" + + @abstractmethod + def check(self, hosts: str | Sequence[str] | None, *, requires_loopback: bool) -> None: + """校验监听地址;不合规抛 :class:`~common.errors.ValidationError`。 + + ``requires_loopback`` 来自 + :meth:`~common.security.authentication.base.Authenticator.requires_loopback_binding` + ——**认证能力自己声明**是否具备远程暴露所需的保护,策略据此裁决。这样第三方 + 认证实现无需修改本模块即可参与判断,也不必按 target 名(``dev`` / ``api_key``) + 推断安全保证。 + + 返回 ``None`` 即通过。不返回 bool:绑定裁决只有「放行」与「拒绝启动」两种 + 结果,返回 bool 会诱导调用方写 ``if not ok: log.warning(...)`` 这类 fail-open。 + """ + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与其他安全组件同构。""" diff --git a/src/common/security/protection/protection_impl/__init__.py b/src/common/security/protection/protection_impl/__init__.py new file mode 100644 index 00000000..bd962a0c --- /dev/null +++ b/src/common/security/protection/protection_impl/__init__.py @@ -0,0 +1,18 @@ +"""protection_impl 实现集:三个 Protection 工厂 + 各实现。 + +import 各实现模块即触发其 ``@Producer.register(...)`` 自注册; +本包只对外暴露三个工厂。 +""" + +from importlib import import_module + +from common.security.protection.binding_policy import BindingPolicyProducer +from common.security.protection.rate_limit import RateLimitProducer +from common.security.protection.workload_guard import WorkloadGuardProducer + +import_module(".token_bucket_limiter", __name__) +import_module(".unlimited_limiter", __name__) +import_module(".semaphore_guard", __name__) +import_module(".loopback_binding", __name__) + +__all__ = ["BindingPolicyProducer", "RateLimitProducer", "WorkloadGuardProducer"] diff --git a/src/common/security/protection/protection_impl/loopback_binding.py b/src/common/security/protection/protection_impl/loopback_binding.py new file mode 100644 index 00000000..409c5c5d --- /dev/null +++ b/src/common/security/protection/protection_impl/loopback_binding.py @@ -0,0 +1,74 @@ +"""loopback 绑定策略:要求 loopback 的认证能力只允许绑定 localhost。 + +判断依据是认证能力自报的 ``requires_loopback_binding()``,不是 target 名—— +第三方认证实现只要声明自己具备远程暴露所需的保护,无需改本模块即可放行。 +""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path +from typing import Sequence + +from common.errors import ValidationError +from common.security.protection.binding_policy import BindingPolicy, BindingPolicyProducer + +_LOG = logging.getLogger(__name__) + +_LOOPBACK = frozenset({"127.0.0.1", "localhost", "::1"}) +# 「绑定所有网卡」的各种写法。空串在 socket 语义里等价于 0.0.0.0—— +# 这是容器化场景下最危险的情况:以为只是没配,实际暴露给了整个网络。 +_WILDCARD = frozenset({"0.0.0.0", "::", "*", ""}) + + +def _in_container() -> bool: + return Path("/.dockerenv").exists() or bool(os.environ.get("KUBERNETES_SERVICE_HOST")) + + +class LoopbackBindingPolicy(BindingPolicy): + """要求 loopback 的认证能力只允许绑定 localhost。""" + + def check(self, hosts: str | Sequence[str] | None, *, requires_loopback: bool) -> None: + if not requires_loopback: + return + + if hosts is None: + candidates: list[str] = [""] + elif isinstance(hosts, str): + candidates = [hosts] + else: + candidates = [str(h) for h in hosts] or [""] + + # 多网卡时**任一** host 危险即拒绝。 + for host in candidates: + normalized = host.strip().strip("[]").lower() + if normalized in _WILDCARD: + raise ValidationError( + f"当前认证能力要求 loopback 绑定,禁止绑定 {host!r}(等价于所有网卡):" + "该能力未声明具备远程暴露所需的认证保护,绑到非 localhost 等于把全权限" + "暴露给整个网络。请改绑 127.0.0.1,或配置 authenticator.default.target " + "为 api_key / trusted。" + ) + if normalized not in _LOOPBACK: + raise ValidationError( + f"当前认证能力要求 loopback 绑定,只允许绑定 localhost,得到 {host!r}。" + "请改绑 127.0.0.1,或配置 authenticator.default.target 为 api_key / trusted。" + ) + + if _in_container(): + # 容器里绑 127.0.0.1 本身是合法的,是否真的暴露取决于 port mapping / + # Service,框架无法检查——故只警告不拒绝。 + _LOG.warning( + "检测到容器环境且认证能力要求 loopback:即使绑定 127.0.0.1,是否对外暴露仍" + "取决于 port mapping / Service 配置,框架无法检查。生产部署请使用 api_key " + "或 trusted 认证。" + ) + + def health(self) -> None: + return None + + +@BindingPolicyProducer.register("loopback") +def _build(config): + return LoopbackBindingPolicy() diff --git a/src/common/security/protection/protection_impl/semaphore_guard.py b/src/common/security/protection/protection_impl/semaphore_guard.py new file mode 100644 index 00000000..0e24506d --- /dev/null +++ b/src/common/security/protection/protection_impl/semaphore_guard.py @@ -0,0 +1,67 @@ +"""进程内信号量预算:昂贵安全操作的并发上限(F05 §Protection §WorkloadGuard)。 + +``BoundedSemaphore`` 非阻塞 acquire:有空槽立刻占用,耗尽即返回 ``False`` 让调用方 +翻译成 429。不排队——排队会把资源耗尽从 CPU/内存转移到线程与请求队列。 + +**已知限制**:进程内计数,多副本部署 N 个副本 = N 倍实际并发。故 +:meth:`supports_distributed_budget` 继承默认的 ``False``,装配期据此判断能否宣称 +集群级预算,而不是靠 target 名推断。 +""" + +from __future__ import annotations + +import logging +import threading + +from common.errors import ValidationError +from common.security.protection.workload_guard import WorkloadGuard, WorkloadGuardProducer + +_LOG = logging.getLogger(__name__) + +# Argon2id 单次 verify 内存 128 MiB。默认按「给安全操作留 ~512 MiB」预算:4 个并发 +# 同时最多吃 512 MiB,留出业务内存。可按机器内存调(params.max_concurrent)。 +_DEFAULT_MAX_CONCURRENT = 4 + + +class SemaphoreWorkloadGuard(WorkloadGuard): + """进程内并发预算,基于 ``threading.BoundedSemaphore``。""" + + def __init__(self, max_concurrent: int) -> None: + if max_concurrent < 1: + raise ValueError(f"max_concurrent 须 >= 1,得到 {max_concurrent}") + self._max = max_concurrent + self._sem = threading.BoundedSemaphore(max_concurrent) + + def acquire(self) -> bool: + return self._sem.acquire(blocking=False) + + def release(self) -> None: + try: + self._sem.release() + except ValueError: + # release 过多次(acquire 失败后误 release):不抛,但记一笔—— + # BoundedSemaphore 超过初始值会 ValueError,吞掉会让计数器永久偏。 + _LOG.error("WorkloadGuard release 越界(acquire 未成功即 release?)", exc_info=True) + + @property + def max_concurrent(self) -> int: + return self._max + + def health(self) -> None: + return None + + +@WorkloadGuardProducer.register("semaphore") +def _build(config): + """装配 SemaphoreWorkloadGuard;参数非法在**装配期**报错。 + + ``max_concurrent=0`` 会让服务拒绝一切昂贵操作(认证全挂),必须在启动时炸, + 不能等到第一个请求进来才暴露。 + """ + max_concurrent = int(config.get("max_concurrent", _DEFAULT_MAX_CONCURRENT)) + if max_concurrent < 1: + raise ValidationError( + f"workload_guard 'semaphore' 的 max_concurrent 须 >= 1,得到 {max_concurrent}。" + "为 0 时所有昂贵安全操作(密码哈希、完整性验证)都会被拒绝。" + ) + return SemaphoreWorkloadGuard(max_concurrent) diff --git a/src/common/security/protection/protection_impl/token_bucket_limiter.py b/src/common/security/protection/protection_impl/token_bucket_limiter.py new file mode 100644 index 00000000..f6d45746 --- /dev/null +++ b/src/common/security/protection/protection_impl/token_bucket_limiter.py @@ -0,0 +1,123 @@ +"""进程内令牌桶限流,按调用方地址分桶(F05 §Protection §入口限流)。 + +两个参数分别管两件事:``capacity`` 是**突发**额度(桶满时能一口气放多少个), +``refill_per_sec`` 是**持续**速率(长期平均每秒放多少个)。交互式客户端天然 +是「短突发 + 长空闲」,所以默认给一个偏大的桶配一个偏小的补充速率。 + +**桶表是 LRU 有界的**:桶按 peer 建,peer 由远端决定,无界字典会让这个 +「防资源耗尽」的组件自己变成资源耗尽的入口。超出 ``max_tracked`` 时淘汰最久 +未活跃的那个——它最可能已经补满,淘汰等于重建成满桶,不丢有效状态。 + +**已知限制**(两条,都不是本实现能解决的): + +1. **多副本各算各的**:进程内计数,N 个副本 = N 倍实际额度。故 + ``supports_distributed_quota()`` 保持默认的 ``False``——真正的多副本限流要 + Redis 之类的共享计数器,届时在 ``protection_impl`` 下新增一个实现并覆写该 + capability,中间件不用改。 +2. **按地址分桶挡不住僵尸网络**:来源足够分散时每个 IP 都拿到一个新满桶。 + 能收敛这种攻击的是对昂贵校验本身的全局并发预算(见 + :mod:`common.security.protection.workload_guard`),那是与限流互补的另一层。 +""" + +from __future__ import annotations + +import threading +import time +from collections import OrderedDict +from dataclasses import dataclass + +from common.errors import ValidationError +from common.security.protection.rate_limit import RateLimiter, RateLimitProducer + +# 默认值面向「交互式使用不该被限流,脚本化枚举必须被限流」这条线: +# 30 个突发够任何人工操作和常规客户端启动时的几次探测;持续 5 QPS 远低于 +# Argon2 verify 打满一个核所需的速率。 +_DEFAULT_CAPACITY = 30 +_DEFAULT_REFILL_PER_SEC = 5.0 +_DEFAULT_MAX_TRACKED = 10_000 + + +@dataclass +class _Bucket: + """一个 peer 的桶。``last`` 是 ``time.monotonic()`` 读数,不是墙上时间。""" + + tokens: float + last: float + + +class TokenBucketLimiter(RateLimiter): + """按 peer 分桶的令牌桶;LRU 有界,并发安全。""" + + def __init__(self, capacity: int, refill_per_sec: float, max_tracked: int) -> None: + self._capacity = float(capacity) + self._refill = refill_per_sec + self._max_tracked = max_tracked + self._buckets: OrderedDict[str, _Bucket] = OrderedDict() + # 一把全局锁,不做分桶锁:临界区只有几次浮点运算,而其后紧跟的 + # Argon2 verify 是 50~200ms——锁竞争在这个量级下不值得优化。 + self._lock = threading.Lock() + + def allow(self, peer: str) -> bool: + if not peer: + # 无网络对端(进程内直连 / MCP stdio)。没有远端就没有可收敛的 + # 攻击面,限流只会把本地 CLI 卡住。 + return True + + now = time.monotonic() + with self._lock: + bucket = self._buckets.get(peer) + if bucket is None: + bucket = _Bucket(tokens=self._capacity, last=now) + self._buckets[peer] = bucket + if len(self._buckets) > self._max_tracked: + # 只可能超出 1 个(每次调用最多插一个),故一次淘汰即可。 + # 刚插入的在末尾,不会被 last=False 弹掉。 + self._buckets.popitem(last=False) + else: + self._buckets.move_to_end(peer) # 维护 LRU 次序 + refilled = bucket.tokens + (now - bucket.last) * self._refill + bucket.tokens = min(self._capacity, refilled) + bucket.last = now + + if bucket.tokens < 1.0: + return False + bucket.tokens -= 1.0 + return True + + def health(self) -> None: + return None + + +@RateLimitProducer.register("token_bucket") +def _build(config): + """装配 TokenBucketLimiter;参数非法在**装配期**报错。 + + 参数错了要在启动时炸,不能等到运行期:``capacity=0`` 会让服务拒绝一切 + 请求,``refill_per_sec=0`` 会让桶空了再也补不回来——两者都是「配置写错 + 等于服务下线」,而运行期才暴露就是一次生产事故。要关闭限流请显式配 + ``target: unlimited``,不要靠把参数写成 0。 + """ + capacity = int(config.get("capacity", _DEFAULT_CAPACITY)) + refill_per_sec = float(config.get("refill_per_sec", _DEFAULT_REFILL_PER_SEC)) + max_tracked = int(config.get("max_tracked", _DEFAULT_MAX_TRACKED)) + + if capacity < 1: + raise ValidationError( + f"rate_limiter 'token_bucket' 的 capacity 须 >= 1,得到 {capacity}。" + "要关闭限流请配 target: unlimited。" + ) + if refill_per_sec <= 0: + raise ValidationError( + f"rate_limiter 'token_bucket' 的 refill_per_sec 须 > 0,得到 {refill_per_sec}。" + "为 0 时桶一旦耗尽就永不恢复,等于把调用方永久拉黑。" + ) + if max_tracked < 1: + raise ValidationError( + f"rate_limiter 'token_bucket' 的 max_tracked 须 >= 1,得到 {max_tracked}" + ) + + return TokenBucketLimiter( + capacity=capacity, + refill_per_sec=refill_per_sec, + max_tracked=max_tracked, + ) diff --git a/src/common/security/protection/protection_impl/unlimited_limiter.py b/src/common/security/protection/protection_impl/unlimited_limiter.py new file mode 100644 index 00000000..d7a3883b --- /dev/null +++ b/src/common/security/protection/protection_impl/unlimited_limiter.py @@ -0,0 +1,27 @@ +"""显式关闭限流的实现:恒放行(F05 §Protection §入口限流)。 + +存在的理由是 TRUSTED 模式的真实部署形态——网关已在边缘做了限流,框架再做 +一层只会把「网关的单个出口 IP」当成一个 peer,从而把全部正常流量误伤成 429。 +这种部署需要一个**写在配置里、看得见**的关闭方式,而不是把 ``capacity`` 写成 +某个反着读的魔法值(``capacity: 0`` 是「一个令牌都不给」还是「不限流」? +配置文件里读不出来,而读不出来的配置就是会被写错的配置)。 +""" + +from __future__ import annotations + +from common.security.protection.rate_limit import RateLimiter, RateLimitProducer + + +class NoRateLimit(RateLimiter): + """恒放行。""" + + def allow(self, peer: str) -> bool: + return True + + def health(self) -> None: + return None + + +@RateLimitProducer.register("unlimited") +def _build(config): + return NoRateLimit() diff --git a/src/common/security/protection/rate_limit.py b/src/common/security/protection/rate_limit.py new file mode 100644 index 00000000..f2c56d69 --- /dev/null +++ b/src/common/security/protection/rate_limit.py @@ -0,0 +1,67 @@ +"""入口限流契约:认证前按调用方地址执行低成本准入(F05 §Protection §入口限流)。 + +限流挂在**认证之前**:认证本身就是要保护的资源。API_KEY 模式下每次 +``authenticate`` 都跑一次 Argon2id verify(128 MiB × time_cost=4,约 +50~200ms),无限制地触发它能把进程的 CPU 与内存同时打满。 + +**限流维度是调用方地址,不是 key 指纹。** 按 ``key_fp`` 分桶防的是「单个合法 +key 打爆配额」(配额公平),不是「攻击者打爆 CPU」——攻击者每次换一把随机 key +就换一个新桶,对枚举与耗尽两种攻击都不生效。真正能收敛攻击的是来源地址。 +按 key 的配额公平是独立需求,本期不做。 + +抽象与实现分离的理由:分布式部署下进程内桶各算各的(N 个副本 = N 倍额度), +真正的多副本限流要 Redis 之类的共享计数器。契约留在这里,届时新增一个实现 +即可,无需改中间件。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from common.factory.factory import Factory + + +class RateLimitProducer(Factory): + """RateLimiter 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``protection_impl`` 下以 ``@RateLimitProducer.register("<后端>")`` + 自注册,由 :func:`common.security.bootstrap.register_security` 统一触发。 + """ + + TOP_NAME = "rate_limiter" + + +class RateLimiter(ABC): + """请求准入:一次调用消耗一个额度。""" + + @abstractmethod + def allow(self, peer: str) -> bool: + """``peer`` 还有额度则消耗一个并返回 ``True``,否则返回 ``False``。 + + **返回 bool 而非抛异常**:限流是「事实陈述」,翻译成 HTTP 状态码是 + 调用方(``auth_middleware``)的事。这与 + :meth:`~common.security.authentication.key_store.PrincipalKeyStore.resolve` + 返回 ``None`` 同理,且不构成 fail-open——调用方拿到 ``False`` 唯一能做的 + 就是拒绝。 + + 实现必须是**并发安全**的:``ThreadingHTTPServer`` 每请求一线程, + 「读余量 → 减一 → 写回」在 GIL 下不是原子的,两个线程能同时看到 + 最后一个令牌。 + + ``peer`` 为空串(进程内直连 / MCP stdio,无网络对端)时应放行: + 没有远端就没有可收敛的攻击面,限流反而会把本地 CLI 卡住。 + """ + + def supports_distributed_quota(self) -> bool: + """本实现是否提供**跨副本共享**的配额(F05 §Protection §分布式部署)。 + + 默认 ``False``:进程内计数只是单实例保护,N 个副本 = N 倍实际额度。 + 默认值取「不宣称」而非「宣称」,是为了让「进程内限流被当成集群配额」 + 这类误判必须由实现显式承担——共享计数器后端(Redis 等)覆写返回 + ``True``,装配期据此校验 capability,而不是靠 target 名推断。 + """ + return False + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与其他安全组件同构。""" diff --git a/src/common/security/protection/workload_guard.py b/src/common/security/protection/workload_guard.py new file mode 100644 index 00000000..a5e9c6c8 --- /dev/null +++ b/src/common/security/protection/workload_guard.py @@ -0,0 +1,73 @@ +"""昂贵安全操作的全局并发预算契约(F05 §Protection §WorkloadGuard)。 + +入口限流限的是「单地址的请求速率」,限不住「同时在跑的昂贵校验数」——后者才是 +CPU/内存耗尽攻击的真正向量:单 IP 30 个并发错误 key = 30 × 128 MiB Argon2 同时 +驻留。本契约是在昂贵操作**之前** acquire、耗尽即快速拒绝的全局预算,是入口限流 +之上的第一层。 + +**预算是能力级的,不是 Argon2 专用的**:密码哈希、密钥派生、全量完整性验证共用 +同一份预算。原 ``Argon2Guard`` 只覆盖密码哈希一项,PR3 的审计全量验证同样昂贵, +届时无处挂靠。 + +**耗尽时快速拒绝,绝不排队**:排队会让线程无界堆积,把资源耗尽从 CPU/内存转移 +到线程和请求队列——攻击者用慢请求占满队列就能把正常请求一并堵死。 +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from common.factory.factory import Factory + + +class WorkloadGuardProducer(Factory): + """WorkloadGuard 的注册式工厂(与契约同处接口层)。 + + 各实现在 ``protection_impl`` 下以 ``@WorkloadGuardProducer.register("<后端>")`` + 自注册,由 :func:`common.security.bootstrap.register_security` 统一触发。 + + 进程级共享通过**具名实例**表达(``build_named`` 的实例缓存),不用模块级单例: + 单例把「谁持有预算」藏进模块状态,同进程多 Server / 热重载时只能靠比对参数 + 抛冲突错来兜底;具名实例让共享成为一条可在配置里读出来的显式引用。 + """ + + TOP_NAME = "workload_guard" + + +class WorkloadGuard(ABC): + """昂贵安全操作的并发预算:一次操作占用一个槽位。""" + + @abstractmethod + def acquire(self) -> bool: + """有空槽则占用并返回 ``True``,否则立即返回 ``False``。 + + **非阻塞**:实现不得在此排队等待(见模块 docstring)。 + + **返回 bool 而非抛异常**:预算耗尽是「事实陈述」,翻译成 429 是调用方的事。 + 不构成 fail-open——调用方拿到 ``False`` 唯一能做的就是拒绝。 + + 成功 acquire 后必须在 ``finally`` 中 :meth:`release`,否则槽位永久泄漏, + 预算耗尽后服务再也不接受任何昂贵操作。 + """ + + @abstractmethod + def release(self) -> None: + """归还一个槽位。只有 :meth:`acquire` 返回 ``True`` 后才可调用。""" + + @property + @abstractmethod + def max_concurrent(self) -> int: + """预算上限,供诊断与启动期日志展示。""" + + def supports_distributed_budget(self) -> bool: + """本实现的预算是否**跨副本共享**(F05 §Protection §分布式部署)。 + + 默认 ``False``:进程内信号量只约束本副本,N 个副本 = N 倍实际并发。 + 与 :meth:`~common.security.protection.rate_limit.RateLimiter. + supports_distributed_quota` 同理,默认取「不宣称」,由共享后端实现显式覆写。 + """ + return False + + @abstractmethod + def health(self) -> None: + """存活探测:健康时返回 ``None``,否则抛出异常。与其他安全组件同构。""" diff --git a/src/common/security/request_context.py b/src/common/security/request_context.py new file mode 100644 index 00000000..baafe204 --- /dev/null +++ b/src/common/security/request_context.py @@ -0,0 +1,84 @@ +"""``RequestSecurityContext`` 的受控构造入口(F05 §RequestSecurityContext、§进程内调用)。 + +构造点收在这里,不散在各 surface:``request_id`` 由服务端生成、``started_at`` 取服务端 +时钟、``attributes`` 只由系统组件写入——这三条不变量只有一处实现,新增一个接入形态时 +不会各自发明一套(迁移计划 §5.2 第 7 项)。 + +两个入口对应两类调用方: + +- :func:`new_request_context` 给**已完成认证**的 surface 用(HTTP / MCP / CLI 经 + ``bootstrap.core.auth_middleware`` 调它); +- :func:`internal_context` 给**进程内直连**的调用方用(示例脚本、评测 harness、 + 嵌入式插件)——它们没有网络对端,但契约与外部请求完全相同(F05 §进程内调用)。 +""" + +from __future__ import annotations + +import uuid +from collections.abc import Mapping +from datetime import datetime, timezone + +from common.security.types import ( + AuthContext, + Credentials, + RequestSecurityContext, + Surface, + _bind_origin, +) + + +def new_request_context( + auth: AuthContext, + *, + surface: Surface, + peer: str = "", + attributes: Mapping[str, str] | None = None, +) -> RequestSecurityContext: + """把一个**已认证**的 :class:`AuthContext` 包成本次请求的安全上下文。 + + ``request_id`` 在这里生成,**不接受调用方传入**:它进审计与 ``AuthorizationEnvironment``, + 能被调用方指定就等于让调用方给自己的行为贴任意标签、或与他人的记录撞号。 + + ``surface`` 无默认值,必须由适配层显式写入——缺省成 ``INTERNAL`` 会让一个漏传的 + HTTP 请求在审计里看起来像进程内调用。 + + ``attributes`` 只允许系统组件写入(可信代理链、mTLS 主体一类)。业务 payload 一律 + 不得注入:它参与授权环境,可注入就等于把授权输入交给了调用方。 + + Round3: _origin 绑定完整 RequestSecurityContext 安全字段,防止 replace(attributes=...) 提权。 + """ + # 先构造上下文(_origin 用占位符) + context = RequestSecurityContext( + auth=auth, + request_id=uuid.uuid4().hex, + peer=peer, + surface=surface, + started_at=datetime.now(timezone.utc), + attributes=attributes or {}, + _origin="", # 占位符 + ) + # Round3: 用完整上下文计算绑定值,然后替换 _origin + # 使用 object.__setattr__ 绕过 frozen dataclass 限制 + object.__setattr__(context, "_origin", _bind_origin(auth, context)) + return context + + +def internal_context(authenticator) -> RequestSecurityContext: + """进程内直连调用方的受控上下文入口(F05 §进程内调用)。 + + 身份仍**由 authenticator 产出**,不由调用方声明--这正是 F05 拒绝 ``auth=None`` + 与「传入 Scope 直接当已认证 actor」的那条线(迁移计划 §5.3)。调用方要操作哪个 + Scope,照旧走业务参数;它决定不了自己是谁。 + + ``authenticator`` **必填**:无参领取 ROOT 已不再允许。进程内直连确实信任内核装配 + 方,但这份信任必须是一次显式传入(如 ``internal_context(DevAuthenticator())``), + 让「这里拿到了 ROOT」在调用点看得见,而不是像旧的 ``authenticator=None`` 默认那样 + 谁都不写就悄悄得到一个超管身份。 + + **不可用于有网络对端的场景。** 网络接入必须走 ``auth_middleware.authenticated``: + 那里有真实凭据校验、限流、并发预算和入口审计,这里一样都没有。 + """ + return new_request_context( + authenticator.authenticate(Credentials()), + surface=Surface.INTERNAL, + ) diff --git a/src/common/security/runtime.py b/src/common/security/runtime.py new file mode 100644 index 00000000..3072740f --- /dev/null +++ b/src/common/security/runtime.py @@ -0,0 +1,212 @@ +"""SecurityRuntime — 一次装配得到的安全能力集合(F05 §SecurityRuntime)。 + +Runtime **只做三件事**:持有能力引用、执行启动期健康检查、暴露统一生命周期。 +它不实现认证、限流、密码学或授权算法——所有判断都在各能力自己的实现里。图示:: + + SecurityRuntime + ├── authenticator + ├── authorizer + ├── cryptography_provider + ├── rate_limiter + ├── workload_guard + └── binding_policy + +(``audit_integrity_provider`` 是 F05 目标态成员,由 PR3 补齐。不预留占位字段: +一个恒为 ``None`` 的字段会诱导消费方写 ``if runtime.x:`` 这类 fail-open 分支。) + +**运行期共享状态**(撤销缓存、分布式限流连接、key 缓存)通过 Factory 的**具名实例** +显式共享,不靠模块级单例——谁与谁共享哪个后端,从配置里就能读出来。 + +不同接入形态(HTTP / MCP / CLI)消费**同一个** Runtime 实例。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any + +from common.errors import ValidationError +from common.factory.factory import Factory +from common.security.authentication.base import Authenticator, AuthProducer +from common.security.authorization.base import AuthorizationProducer, Authorizer +from common.security.cryptography.base import CryptographyProducer, CryptographyProvider +from common.security.protection.binding_policy import BindingPolicy, BindingPolicyProducer +from common.security.protection.rate_limit import RateLimiter, RateLimitProducer +from common.security.protection.workload_guard import WorkloadGuard, WorkloadGuardProducer + +_LOG = logging.getLogger(__name__) + + +class SecurityRuntimeProducer(Factory): + """SecurityRuntime 的注册式工厂。 + + 配置顶层段为 ``security``,其具名实例只**组合**其他能力的具名实例:: + + security: + default: + target: standard + params: + authenticator: primary_auth + authorizer: primary_authorizer + cryptography: primary_crypto + rate_limiter: ingress_limit + workload_guard: security_budget + binding_policy: server_binding + """ + + TOP_NAME = "security" + + +@dataclass(frozen=True) +class SecurityRuntime: + """已装配的安全能力集合。 + + ``cryptography_provider`` 可以是 ``None``:是否加密持久化数据由存储适配器的选型 + 表达(F05 §明文策略),未启用存储加密的部署本就不需要这项能力。其余五项**必须 + 非 None**——它们是每个请求都要经过的路径,缺一项就意味着某条边界没人把守。 + + ``authorizer`` 在这里只是**装配与健康检查**的归口。真正调用它的是 ``MemoryAPI`` + 这个唯一 PEP,且由内核装配注入(见 ``api.memory_api_impl.assembly``)——Runtime + 不代为转发,避免出现第二条能绕开 PEP 的授权入口。 + """ + + authenticator: Authenticator + authorizer: Authorizer + rate_limiter: RateLimiter + workload_guard: WorkloadGuard + binding_policy: BindingPolicy + cryptography_provider: CryptographyProvider | None = None + + def health(self) -> None: + """启动期健康检查:任一能力不健康即抛出,不返回 bool。 + + 返回 ``bool`` 会诱导调用方写 ``if not runtime.health(): log.warning(...)`` + 然后继续启动。健康检查失败必须拒绝启动(F05 §默认拒绝)。 + + 异常消息只带**能力名**——能力名来自配置、不是秘密;具体原因由各实现自己 + 决定暴露多少(F05 §装配不变量 8:不得泄露 key、token 或主体是否存在)。 + """ + for name, capability in self._capabilities(): + try: + capability.health() + except Exception as exc: + raise ValidationError(f"security capability {name!r} is unhealthy") from exc + + def close(self) -> None: + """关闭持有连接的能力。没有 ``close`` 的能力跳过。 + + 逐个捕获并记录而不是让第一个失败中断后续——关闭路径上放弃剩余能力会漏掉 + 连接与文件句柄。 + """ + for name, capability in self._capabilities(): + closer = getattr(capability, "close", None) + if closer is None: + continue + try: + closer() + except Exception: + _LOG.error("关闭安全能力 %r 失败", name, exc_info=True) + + def _capabilities(self) -> list[tuple[str, Any]]: + pairs = [ + ("authenticator", self.authenticator), + ("authorizer", self.authorizer), + ("rate_limiter", self.rate_limiter), + ("workload_guard", self.workload_guard), + ("binding_policy", self.binding_policy), + ] + if self.cryptography_provider is not None: + pairs.append(("cryptography", self.cryptography_provider)) + return pairs + + +@SecurityRuntimeProducer.register("standard") +def _build(config) -> SecurityRuntime: + """组合具名安全能力。 + + ``authenticator`` **必填且无默认**:没有默认值,缺失就抛 ValidationError。给它 + 一个默认会让「忘了配认证」静默变成某种可用配置——F05 §装配不变量 6 拒绝的正是 + 这种隐式选择。开发部署要 dev 认证,就在配置里写出来。 + + ``authorizer`` 的默认是 ``standard``(唯一的生产实现),不是 ``allow_all``:默认 + 必须落在**做真实判定**的那一侧。恒放行实现声明 ``is_test_only()``,在这里被拒, + 要用得显式打开 ``allow_test_only_security``——判据是 capability 而非 target 名 + (S08 不变量 7),第三方注册的恒放行实现同样拦得住。 + + 其余四项的默认取**保守侧**:``token_bucket`` 而非 ``unlimited``、有限并发预算而非 + 无限、强制 loopback 校验而非放行。没配等于没读过文档,此时给出的默认必须是拦住 + 请求的那个,不是放行的那个。 + + 唯一的例外是 ``rate_limiter``:只监听 loopback 的部署没有远端攻击面,默认限流只会 + 卡住本地压测与调试脚本。这个分岔由 ``Authenticator.requires_loopback_binding()`` + 这个 **capability** 决定,不看 target 名(F05 §依据 capability 做安全决策)。 + """ + authenticator = AuthProducer.dep(config, "authenticator") + + # 内联 Authenticator 没有顶层具名实例可作 issuer;由 Runtime 名派生稳定标识。 + # 通过公开装配契约绑定,避免 Runtime 依赖某个具体实现的内部字段。 + runtime_name = getattr(config, "name", "") + if runtime_name: + authenticator.bind_instance_name(f"runtime:{runtime_name}") + + rate_limiter_default = ( + "unlimited" if authenticator.requires_loopback_binding() else "token_bucket" + ) + return SecurityRuntime( + authenticator=authenticator, + authorizer=_authorizer(config), + rate_limiter=RateLimitProducer.dep(config, "rate_limiter", default=rate_limiter_default), + workload_guard=WorkloadGuardProducer.dep(config, "workload_guard", default="semaphore"), + binding_policy=BindingPolicyProducer.dep(config, "binding_policy", default="loopback"), + cryptography_provider=_optional_cryptography(config), + ) + + +def _authorizer(config) -> Authorizer: + """取 Authorizer 引用,并挡住把仅测试实现配进生产的装配。 + + 默认引用**具名实例** ``default`` 而不是匿名新建一个 ``standard``:内核装配 + (``api.memory_api_impl.assembly``)已经建过 ``authorizer.default`` 并注入了 + PEP,Factory 的具名缓存是类级共享的,故这里 ``build_named`` 命中的是**同一个 + 实例**。若改成匿名新建,Runtime 健康检查的就是另一份持有另一套 Grant/Delegation + 存储的 authorizer——那比不检查更糟,它给出的是虚假保证。 + + 共享具名实例的代价是**依赖装配顺序**:Runtime 必须在内核之后建。顺序反了会落到 + 下面那句重抛——原始错误说的是「配置里没有 authorizer.default」,指向配置文件; + 真正的原因是 PEP 还没装配,两者要修的地方不同。 + + 判据是 ``is_test_only()`` 这个 capability,不是 ``target == "allow_all"``: + 第三方注册的恒放行实现同样要被拦住,而核心不认识它的 target 名(S08 不变量 7)。 + """ + configured = Factory.cfg_get(config, "authorizer") + if configured is None: + try: + authorizer = AuthorizationProducer.build_named("default", config.ctx) + except ValidationError as exc: + raise ValidationError( + "SecurityRuntime 取不到 authorizer.default:它由内核装配建立," + "故 SecurityRuntime 必须在 build_kernel 之后装配。" + "独立装配(如单测)请在 security params 里显式给出 authorizer。" + ) from exc + else: + authorizer = AuthorizationProducer.dep(config, "authorizer") + if not isinstance(authorizer, Authorizer): + raise ValidationError("security params.authorizer 必须是 Authorizer 实现") + if authorizer.is_test_only() and not Factory.cfg_get(config, "allow_test_only_security"): + raise ValidationError( + "当前 authorizer 是仅测试实现(恒放行);生产装配拒绝启动。" + "确需在测试中使用时显式配置 globals.allow_test_only_security=true" + ) + return authorizer + + +def _optional_cryptography(config) -> CryptographyProvider | None: + """只在显式配置了 ``cryptography`` 时装配——没有默认实现。 + + 默认装一个加密 provider 会凭空造出一把没人管理生命周期的根密钥;不配就是不用, + 要用就得把 KeyProvider 一起配出来。 + """ + if Factory.cfg_get(config, "cryptography") is None: + return None + return CryptographyProducer.dep(config, "cryptography") diff --git a/src/common/security/security.py b/src/common/security/security.py deleted file mode 100644 index 0f3a0af7..00000000 --- a/src/common/security/security.py +++ /dev/null @@ -1,94 +0,0 @@ -"""SecurityProvider — 数据保护横切接口。 - -安全能力不是无状态模型插件,不继承 :class:`common.base.Plugin`,但仍使用 -``Factory`` 提供注册式装配。调用方以字节为边界调用本接口: -写入持久化字节前加密,读取持久化字节后解密; -是否启用加密由具体实现与配置决定。 -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass, field - -from ..errors import AgentMemoryError -from ..factory.factory import Factory -from ..type_def import Scope - - -@dataclass(frozen=True) -class SecurityContext: - """一次安全处理的上下文。 - - ``scope`` 用于表达租户/主体隔离边界,``purpose`` 用于区分调用场景 - (如 ``"memory_unit_content"``),``metadata`` 透传实现所需的非敏感标签。 - """ - - scope: Scope = field(default_factory=Scope) - purpose: str = "" - metadata: dict[str, str] = field(default_factory=dict) - - -class SecurityProducer(Factory): - """SecurityProvider 的注册式工厂。 - - 各实现在 ``security_impl`` 下以 ``@SecurityProducer.register("<名>")`` 自注册。 - 当前 ``security_impl`` 注册 ``local`` ENC1 AES-GCM 实现。 - """ - - TOP_NAME = "security" - - -class SecurityError(AgentMemoryError): - """所有安全横切处理异常的基类。""" - - -class EncryptionError(SecurityError): - """加密或解密处理失败。""" - - -class InvalidMagicError(EncryptionError): - """密文字节不符合当前 provider 期望的信封魔数。""" - - -class CorruptedCiphertextError(EncryptionError): - """密文信封结构损坏、版本不支持或长度不完整。""" - - -class AuthenticationFailedError(EncryptionError): - """认证加密 tag 校验失败,通常表示 AAD 不匹配或内容被篡改。""" - - -class KeyMismatchError(EncryptionError): - """包裹的数据密钥无法用当前租户密钥解开。""" - - -class SecurityProvider(ABC): - """字节级数据保护能力。""" - - @abstractmethod - def encrypt( - self, - plaintext: bytes, - *, - context: SecurityContext | None = None, - aad: bytes = b"", - ) -> bytes: - """加密明文字节。 - - ``aad`` 是附加认证数据,具体实现可用于完整性保护但不写入密文。 - """ - - @abstractmethod - def decrypt( - self, - ciphertext: bytes, - *, - context: SecurityContext | None = None, - aad: bytes = b"", - ) -> bytes: - """解密密文字节并校验完整性。""" - - def health(self) -> None: - """存活探测:健康时返回 ``None``,否则由实现抛出异常。""" - return None diff --git a/src/common/security/security_impl/__init__.py b/src/common/security/security_impl/__init__.py deleted file mode 100644 index d95e9ad8..00000000 --- a/src/common/security/security_impl/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""security_impl 实现集:工厂 SecurityProducer + 各实现。 - -import 各实现模块即触发其 ``@SecurityProducer.register(...)`` 自注册; -本包只对外暴露工厂 SecurityProducer。 -""" - -from importlib import import_module - -from common.security.security import SecurityProducer - -import_module(".local_envelope_security_provider", __name__) - -__all__ = ["SecurityProducer"] diff --git a/src/common/security/security_impl/local_envelope_security_provider.py b/src/common/security/security_impl/local_envelope_security_provider.py deleted file mode 100644 index eafe52c4..00000000 --- a/src/common/security/security_impl/local_envelope_security_provider.py +++ /dev/null @@ -1,442 +0,0 @@ -"""Local ENC1 SecurityProvider implementation. - -This module keeps the cryptographic implementation in common.security, away from -storage decorators. It uses envelope encryption: - -root key -> HKDF(org) -> org key -> AES-GCM wraps per-value data key -data key -> AES-GCM encrypts the value bytes -""" - -from __future__ import annotations - -import binascii -import json -import os -import secrets -import struct -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -from common._support import as_bool -from common.errors import BackendError, ValidationError -from common.factory.factory import Factory -from common.security import ( - AuthenticationFailedError, - CorruptedCiphertextError, - InvalidMagicError, - KeyMismatchError, - SecurityContext, - SecurityProducer, - SecurityProvider, -) - -try: - from cryptography.exceptions import InvalidTag - from cryptography.hazmat.primitives import hashes - from cryptography.hazmat.primitives.ciphers.aead import AESGCM - from cryptography.hazmat.primitives.kdf.hkdf import HKDF -except ImportError as import_error: # pragma: no cover - exercised only in minimal installs - _CRYPTO_IMPORT_ERROR: ImportError | None = import_error - InvalidTag = None # type: ignore[assignment] - AESGCM = None # type: ignore[assignment] - HKDF = None # type: ignore[assignment] - hashes = None # type: ignore[assignment] -else: - _CRYPTO_IMPORT_ERROR = None - - -ENVELOPE_MAGIC = b"ENC1" -ENVELOPE_VERSION = 0x01 -LOCAL_PROVIDER_ID = 0x01 -NONCE_SIZE = 12 -DATA_KEY_SIZE = 32 -_HEADER = struct.Struct("!4sBBHHH") -_DEFAULT_KEY_FILE = "~/.agent-memory/security/master.key" -_DEFAULT_KEY_ENV = "AGENT_MEMORY_ENCRYPTION_ROOT_KEY" -_HKDF_SALT = b"agent-memory-security-local-salt-v1" - - -@dataclass(frozen=True) -class _Envelope: - provider_id: int - encrypted_data_key: bytes - key_nonce: bytes - data_nonce: bytes - encrypted_content: bytes - - -def _private_file_opener(path: str, flags: int) -> int: - return os.open(path, flags, 0o600) - - -class LocalKeyProvider: - """Local root-key provider for single-node or development deployments.""" - - provider_id = LOCAL_PROVIDER_ID - - def __init__( - self, - *, - key_file: str = _DEFAULT_KEY_FILE, - key_hex: str = "", - key_b64: str = "", - key_env: str = _DEFAULT_KEY_ENV, - create_key_file: bool = True, - ) -> None: - _ensure_crypto() - self._key_file = Path(key_file).expanduser() if key_file else None - self._key_hex = key_hex.strip() - self._key_b64 = key_b64.strip() - self._key_env = key_env.strip() - self._create_key_file_enabled = create_key_file - self._root_key: bytes | None = None - - def get_encryption_root_key(self) -> bytes: - if self._root_key is None: - self._root_key = self._load_or_create_root_key() - return self._root_key - - def derive_org_key(self, org_id: str) -> bytes: - root_key = self.get_encryption_root_key() - hkdf_type = HKDF - hashes_module = hashes - if hkdf_type is None or hashes_module is None: - _ensure_crypto() - raise BackendError("cryptography HKDF support is unavailable") - hkdf = hkdf_type( - algorithm=hashes_module.SHA256(), - length=DATA_KEY_SIZE, - salt=_HKDF_SALT, - info=b"agent-memory:security:kek:v1:" + org_id.encode("utf-8"), - ) - return hkdf.derive(root_key) - - def encrypt_key(self, plaintext: bytes, org_id: str) -> tuple[bytes, bytes]: - if len(plaintext) != DATA_KEY_SIZE: - raise ValidationError("local security data key must be 32 bytes") - org_key = self.derive_org_key(org_id) - nonce = secrets.token_bytes(NONCE_SIZE) - return _aes_encrypt(org_key, nonce, plaintext, _key_aad(org_id)), nonce - - def decrypt_key(self, ciphertext: bytes, nonce: bytes, org_id: str) -> bytes: - org_key = self.derive_org_key(org_id) - try: - data_key = _aes_decrypt(org_key, nonce, ciphertext, _key_aad(org_id)) - except AuthenticationFailedError as exc: - raise KeyMismatchError("encrypted data key cannot be decrypted") from exc - if len(data_key) != DATA_KEY_SIZE: - raise CorruptedCiphertextError("decrypted data key has invalid length") - return data_key - - def _load_or_create_root_key(self) -> bytes: - if self._key_hex: - return _decode_hex_key(self._key_hex, source="key_hex") - if self._key_b64: - return _decode_b64_key(self._key_b64, source="key_b64") - - env_value = os.environ.get(self._key_env) if self._key_env else None - if env_value: - return _decode_key_string(env_value, source=f"env {self._key_env}") - - if self._key_file is None: - raise BackendError("local security requires key_hex, key_b64, key_env, or key_file") - if self._key_file.exists(): - _restrict_file_mode(self._key_file) - return _decode_hex_key( - self._key_file.read_text(encoding="ascii").strip(), - source=str(self._key_file), - ) - if not self._create_key_file_enabled: - raise BackendError(f"local security key file does not exist: {self._key_file}") - return self._create_key_file() - - def _create_key_file(self) -> bytes: - key_file = self._key_file - if key_file is None: - raise BackendError("local security key file is not configured") - key = secrets.token_bytes(DATA_KEY_SIZE) - key_file.parent.mkdir(parents=True, exist_ok=True) - try: - with open( - key_file, - "x", - encoding="ascii", - opener=_private_file_opener, - ) as key_stream: - key_stream.write(f"{key.hex()}\n") - except FileExistsError: - _restrict_file_mode(key_file) - return _decode_hex_key( - key_file.read_text(encoding="ascii").strip(), - source=str(key_file), - ) - except Exception: - key_file.unlink(missing_ok=True) - raise - _restrict_file_mode(key_file) - return key - - -class LocalEnvelopeSecurityProvider(SecurityProvider): - """ENC1 AES-256-GCM provider using a local encryption root key.""" - - def __init__( - self, - key_provider: LocalKeyProvider, - *, - allow_plaintext: bool = True, - ) -> None: - _ensure_crypto() - self._key_provider = key_provider - self._allow_plaintext = allow_plaintext - - def encrypt( - self, - plaintext: bytes, - *, - context: SecurityContext | None = None, - aad: bytes = b"", - ) -> bytes: - data_key = secrets.token_bytes(DATA_KEY_SIZE) - data_nonce = secrets.token_bytes(NONCE_SIZE) - org_id = _org_id(context) - encrypted_content = _aes_encrypt( - data_key, - data_nonce, - plaintext, - _effective_aad(context, aad), - ) - encrypted_data_key, key_nonce = self._key_provider.encrypt_key(data_key, org_id) - return _build_envelope( - LOCAL_PROVIDER_ID, - encrypted_data_key, - key_nonce, - data_nonce, - encrypted_content, - ) - - def decrypt( - self, - ciphertext: bytes, - *, - context: SecurityContext | None = None, - aad: bytes = b"", - ) -> bytes: - if not ciphertext.startswith(ENVELOPE_MAGIC): - if self._allow_plaintext: - return ciphertext - raise InvalidMagicError("ciphertext is not an ENC1 envelope") - - envelope = _parse_envelope(ciphertext) - _validate_local_envelope(envelope) - data_key = self._key_provider.decrypt_key( - envelope.encrypted_data_key, - envelope.key_nonce, - _org_id(context), - ) - return _aes_decrypt( - data_key, - envelope.data_nonce, - envelope.encrypted_content, - _effective_aad(context, aad), - ) - - def health(self) -> None: - self._key_provider.get_encryption_root_key() - - -def _ensure_crypto() -> None: - if _CRYPTO_IMPORT_ERROR is not None: - raise BackendError( - "security.local requires the 'cryptography' package; install project dependencies" - ) from _CRYPTO_IMPORT_ERROR - - -def _build_envelope( - provider_id: int, - encrypted_data_key: bytes, - key_nonce: bytes, - data_nonce: bytes, - encrypted_content: bytes, -) -> bytes: - header = _HEADER.pack( - ENVELOPE_MAGIC, - ENVELOPE_VERSION, - provider_id, - len(encrypted_data_key), - len(key_nonce), - len(data_nonce), - ) - return header + encrypted_data_key + key_nonce + data_nonce + encrypted_content - - -def _parse_envelope(ciphertext: bytes) -> _Envelope: - if len(ciphertext) < _HEADER.size: - raise CorruptedCiphertextError("ENC1 envelope too short") - magic, version, provider_id, key_len, key_nonce_len, data_nonce_len = _HEADER.unpack( - ciphertext[: _HEADER.size] - ) - if magic != ENVELOPE_MAGIC: - raise InvalidMagicError("ciphertext is not an ENC1 envelope") - if version != ENVELOPE_VERSION: - raise CorruptedCiphertextError(f"unsupported ENC1 version: {version}") - - offset = _HEADER.size - body_len = key_len + key_nonce_len + data_nonce_len - if len(ciphertext) < offset + body_len: - raise CorruptedCiphertextError("ENC1 envelope length is incomplete") - - encrypted_key = ciphertext[offset: offset + key_len] - offset += key_len - key_nonce = ciphertext[offset: offset + key_nonce_len] - offset += key_nonce_len - data_nonce = ciphertext[offset: offset + data_nonce_len] - offset += data_nonce_len - encrypted_content = ciphertext[offset:] - if not encrypted_content: - raise CorruptedCiphertextError("ENC1 envelope has no encrypted content") - return _Envelope( - provider_id=provider_id, - encrypted_data_key=encrypted_key, - key_nonce=key_nonce, - data_nonce=data_nonce, - encrypted_content=encrypted_content, - ) - - -def _validate_local_envelope(envelope: _Envelope) -> None: - if envelope.provider_id != LOCAL_PROVIDER_ID: - raise CorruptedCiphertextError(f"unsupported security provider id: {envelope.provider_id}") - if len(envelope.key_nonce) != NONCE_SIZE: - raise CorruptedCiphertextError("encrypted data key nonce has invalid length") - if len(envelope.data_nonce) != NONCE_SIZE: - raise CorruptedCiphertextError("content nonce has invalid length") - if len(envelope.encrypted_data_key) < 16: - raise CorruptedCiphertextError("encrypted data key is too short") - if len(envelope.encrypted_content) < 16: - raise CorruptedCiphertextError("encrypted content is too short") - - -def _aes_encrypt(key: bytes, nonce: bytes, plaintext: bytes, aad: bytes) -> bytes: - aesgcm_type = AESGCM - if aesgcm_type is None: - _ensure_crypto() - raise BackendError("cryptography AES-GCM support is unavailable") - try: - return aesgcm_type(key).encrypt(nonce, plaintext, aad) - except Exception as exc: - raise BackendError("AES-GCM encryption failed") from exc - - -def _aes_decrypt(key: bytes, nonce: bytes, ciphertext: bytes, aad: bytes) -> bytes: - aesgcm_type = AESGCM - if aesgcm_type is None: - _ensure_crypto() - raise BackendError("cryptography AES-GCM support is unavailable") - try: - return aesgcm_type(key).decrypt(nonce, ciphertext, aad) - except Exception as exc: - if _is_invalid_tag(exc): - raise AuthenticationFailedError("AES-GCM authentication failed") from exc - raise BackendError("AES-GCM decryption failed") from exc - - -def _is_invalid_tag(exc: Exception) -> bool: - return InvalidTag is not None and isinstance(exc, InvalidTag) - - -def _effective_aad(context: SecurityContext | None, aad: bytes) -> bytes: - context_bytes = json.dumps( - _context_payload(context), - sort_keys=True, - separators=(",", ":"), - ).encode("utf-8") - return b"AMSEC-AAD1" + len(context_bytes).to_bytes(4, "big") + context_bytes + aad - - -def _context_payload(context: SecurityContext | None) -> dict[str, Any]: - scope = context.scope if context is not None else None - metadata = context.metadata if context is not None else {} - return { - "scope": { - "org": getattr(scope, "org", ""), - "space": str(getattr(scope, "space", "")), - "user": getattr(scope, "user", ""), - "agent": getattr(scope, "agent", ""), - "session": getattr(scope, "session", ""), - }, - "purpose": context.purpose if context is not None else "", - "metadata": {str(key): str(value) for key, value in sorted(metadata.items())}, - } - - -def _org_id(context: SecurityContext | None) -> str: - if context is None: - return "" - return context.scope.org - - -def _key_aad(org_id: str) -> bytes: - return b"agent-memory:security:data-key:v1:" + org_id.encode("utf-8") - - -def _decode_key_string(value: str, *, source: str) -> bytes: - raw = value.strip() - if raw.startswith("hex:"): - return _decode_hex_key(raw[4:], source=source) - if raw.startswith("base64:"): - return _decode_b64_key(raw[7:], source=source) - return _decode_hex_key(raw, source=source) - - -def _decode_hex_key(value: str, *, source: str) -> bytes: - try: - key = bytes.fromhex(value.strip()) - except ValueError as exc: - raise ValidationError(f"invalid hex encryption root key from {source}") from exc - return _validate_root_key(key, source=source) - - -def _decode_b64_key(value: str, *, source: str) -> bytes: - try: - key = binascii.a2b_base64(value.strip(), strict_mode=True) - except binascii.Error as exc: - raise ValidationError(f"invalid base64 encryption root key from {source}") from exc - return _validate_root_key(key, source=source) - - -def _validate_root_key(key: bytes, *, source: str) -> bytes: - if len(key) != DATA_KEY_SIZE: - raise ValidationError( - f"encryption root key from {source} must be {DATA_KEY_SIZE} bytes" - ) - return key - - -def _restrict_file_mode(path: Path) -> None: - try: - os.chmod(path, 0o600) - except OSError as exc: - raise BackendError(f"failed to set key file permissions: {path}") from exc - - - -@SecurityProducer.register("local") -def _build(config): - return LocalEnvelopeSecurityProvider( - LocalKeyProvider( - key_file=Factory.cfg_get(config, "key_file", _DEFAULT_KEY_FILE), - key_hex=Factory.cfg_get(config, "key_hex", ""), - key_b64=Factory.cfg_get(config, "key_b64", ""), - key_env=Factory.cfg_get(config, "key_env", _DEFAULT_KEY_ENV), - create_key_file=as_bool( - Factory.cfg_get(config, "create_key_file", True), - default=True, - ), - ), - allow_plaintext=as_bool( - Factory.cfg_get(config, "allow_plaintext", True), - default=True, - ), - ) diff --git a/src/common/security/types.py b/src/common/security/types.py new file mode 100644 index 00000000..68483cd6 --- /dev/null +++ b/src/common/security/types.py @@ -0,0 +1,576 @@ +"""安全域公共值对象(F05「公共安全类型」)。 + +本模块只放**协议无关、跨能力共享**的值对象与身份传播原语:认证输入 +(:class:`Credentials`)、认证产出(:class:`AuthContext`)、请求安全上下文 +(:class:`RequestSecurityContext`)、授权输入(:class:`Action`、 +:class:`ResourceDescriptor`、:class:`AuthorizationEnvironment`、:class:`Grant`、 +:class:`Delegation`)、密码学调用上下文(:class:`CryptoContext`)。 + +不放什么(F05 §公共安全类型): + +- 协议 payload 类型(HTTP/MCP 各自的请求体留在各 surface); +- 存储业务对象(MemoryUnit / KV entry 等留在 ``common.type_def``); +- 授权**策略**与存储实现——类型在这里,判定归 ``security/authorization/``。 + +与 :class:`~common.type_def.scope.Scope` 的职责分工没变:Scope 表达**资源归属**, +本模块表达**谁在操作、以什么凭据、在哪次请求里**。核心不变量(F05 §显式上下文 +优于环境权限):身份来自本模块的值对象,不来自 URI、请求体参数或未经校验的 +HTTP header。 +""" + +from __future__ import annotations + +import hashlib +import hmac +import os +from collections.abc import Mapping +from contextvars import ContextVar, Token +from dataclasses import dataclass, field +from datetime import datetime +from enum import Enum +from types import MappingProxyType + +from common.type_def.scope import Scope + +# ====================================================================== # +# 角色 +# ====================================================================== # + + +class Role(str, Enum): + """三级角色(F05 §认证不变量 2:role 只能来自服务端注册表或已验证 claim)。 + + 继承 ``str`` 使其可直接进 ``AuditEvent.detail``(``dict[str, str]``) + 与 JSON 序列化,无需额外转换。 + """ + + USER = "user" # 普通主体:只能在自己 scope 内操作 + ADMIN = "admin" # 管理员:可管理本 org 内主体,不可跨 org + ROOT = "root" # 超级管理员:跨 org 全局 + + +ROLE_RANK: dict[Role, int] = {Role.USER: 0, Role.ADMIN: 1, Role.ROOT: 2} +"""角色偏序,用于降级检测:签发方不得签出高于自身的角色。""" + + +# ====================================================================== # +# Surface 标识 +# ====================================================================== # + + +class Surface(str, Enum): + """请求接入形态。由适配层写入 :class:`RequestSecurityContext`,业务 payload 不可声明。""" + + HTTP = "http" + MCP = "mcp" + CLI = "cli" + SDK = "sdk" + INTERNAL = "internal" # 进程内任务 / 后台 job + + +# ====================================================================== # +# 认证输入 +# ====================================================================== # + + +@dataclass(frozen=True) +class Credentials: + """一次认证所需的**原始凭据材料**(F05 §Credentials)。 + + 只保存协议无关且已规范化的数据。HTTP/MCP/CLI 的协议解析留在各自 surface, + 交到认证能力手上的必须已经是本结构。 + + 必须满足的约束(F05): + + - 不包含目标资源 Scope——那是授权的输入,不是认证的; + - 不接受 role / acting_user 等授权结果——认证只回答「你是谁」; + - ``headers`` 的键在进入认证能力前已归一为小写(RFC 9110 §5.1 大小写不敏感); + - 敏感值不出现在 repr、错误消息或审计 detail 中——故 ``repr=False``。 + """ + + api_key: str = field(default="", repr=False) + headers: Mapping[str, str] = field(default_factory=dict, repr=False) + peer_address: str = "" + + +# ====================================================================== # +# 认证产出 +# ====================================================================== # + + +@dataclass(frozen=True) +class AuthContext: + """认证完成后得到的**可信身份**(F05 §AuthContext)。 + + 不是客户端提交的数据结构:API Key、受信网关、OAuth 等不同认证路径最终都归一 + 为本结构。任何 handler、业务参数或 LLM tool_call 都不得覆盖其中字段——故 + ``frozen=True``。 + + ``actor`` **无默认值**,必须显式传入。给它默认值会让「忘了传 actor」静默产出 + 空 ``Scope()``。 + + **ROOT 由 ``role`` 表达,不由 actor 的形状表达**(F05 §授权不变量 1)。旧实现的 + ``actor == Scope()`` 隐式 platform-admin 兼容线已删除:空 actor 在 + ``StandardAuthorizer`` 是 **deny**,各认证实现的 ROOT 身份都带具名 actor。 + + ``delegation_id`` 只携带**已经服务端验证过的**委托标识;Authorizer 拿它回 + ``DelegationStore`` 复核。这里刻意**没有** ``acting_user`` 之类的字段:一个 user 名 + 只能表达「调用方声称在代谁操作」,表达不了「那个 user 真的授权过」(F05 + §从 header 直接产生 Delegation)。 + + Round4 P1-4: 新增 ``credential_issuer`` 字段,用于 Registry 撤销路由。 + ``auth_method`` 保留协议/认证方法语义("api_key" / "trusted" / "dev"), + ``credential_issuer`` 携带具名实例名称("primary_auth" / "partner_auth")。 + """ + + actor: Scope # 已认证的操作执行者 + role: Role = Role.USER # 服务端角色注册表的产物,不来自请求 + credential_type: str = "" # 本次使用的凭据类型(api_key / gateway / dev) + credential_id: str = "" # 凭据的不可逆标识(指纹),供撤销与审计;绝不是明文 + auth_method: str = "" # 认证实现声明的方法标识(dev / trusted / api_key / ...) + credential_issuer: str = "" # Round4: 凭据签发者标识(具名 Authenticator 实例名) + authenticated_at: datetime | None = None # 服务端完成认证的时间 + expires_at: datetime | None = None # 本次上下文的失效时间;None = 不随上下文过期 + delegation_id: str = "" # 已验证的委托标识;由 DelegationStore 复核 + + def is_expired(self, *, now: datetime | None = None) -> bool: + """认证上下文是否已过期。``expires_at`` 为 None 表示不随上下文过期。""" + if self.expires_at is None: + return False + reference = now if now is not None else datetime.now(tz=self.expires_at.tzinfo) + return reference >= self.expires_at + + +# ====================================================================== # +# 请求安全上下文 +# ====================================================================== # + +_EMPTY_ATTRIBUTES: Mapping[str, str] = MappingProxyType({}) + + +def _empty_attributes() -> Mapping[str, str]: + """只读空映射的工厂。 + + 不能写成 ``field(default=_EMPTY_ATTRIBUTES)``:dataclass 以 + ``default.__class__.__hash__ is None`` 判定「可变默认值」,而 ``mappingproxy`` + 在 Python 3.11 正是不可哈希的,import 阶段就会抛 + ``ValueError: mutable default ... use default_factory``(3.12 给它补了 + ``__hash__``,所以该阻断只在 3.11 暴露,而 3.11 是本项目的目标下限)。 + 工厂每次都返回同一个只读常量,语义与 default 完全一致。 + """ + return _EMPTY_ATTRIBUTES + + +# RequestSecurityContext 的受控来源证明:绑定 auth 安全字段的 HMAC。 +# 只有受控构造入口(new_request_context / internal_context)构造时计算并写入 _origin; +# PEP 校验 _origin == _bind_origin(security.auth)。dataclasses.replace 换 auth 但复制 +# 旧 _origin -> HMAC 不匹配新 auth -> 拒。直接构造不传 _origin -> 空串 -> 拒。 +# _ORIGIN_KEY 进程随机:进程外/跨进程无法伪造 HMAC。 +_ORIGIN_KEY = os.urandom(32) + + +def _bind_origin(auth: AuthContext, context: "RequestSecurityContext | None" = None) -> str: + """计算 auth 及完整安全上下文的来源绑定 token(HMAC-SHA256)。 + + 绑定 actor 五维 + role + credential 字段 + auth 字段,以及完整 RequestSecurityContext + 的安全字段(attributes、surface、peer 等)。replace 换掉其中任一字段后,旧 _origin + 与新上下文不匹配,PEP 拒。 + + **威胁边界**:此 HMAC 方案仅防止跨进程伪造(_ORIGIN_KEY 是进程内随机密钥)。 + 同进程代码可以调用 :func:`new_request_context` 构造任意 AuthContext 并获得有效签名, + 因此**无法防御恶意同进程组件**(业务插件、agent adapter、被注入的第三方代码)。 + 若同进程代码不可信,需要进程隔离或 capability-based 设计。 + + Round3: 绑定完整 RequestSecurityContext 安全字段,防止通过 replace(attributes=...) + 注入服务端安全 attributes 提权。 + + Round4: 使用 NUL 分隔符明确边界,避免 attributes 序列化结构碰撞。 + Round4 P1-4: 绑定 credential_issuer 字段。 + + Round5: 在每对 k-v 之后也添加额外 NUL 分隔符,防止通过 value 中嵌入 NUL 字符绕过。 + + Round7 P1-1: 改用 Canonical JSON 序列化,彻底消除手写分隔符的结构歧义。 + JSON 的结构化编码天然防止 {"a": "b\\0\\0c\\0d"} 和 {"a": "b", "c": "d"} 碰撞。 + """ + import json + + # 构造结构化签名材料 + payload = { + "auth": { + "actor": { + "org": auth.actor.org, + "space": auth.actor.space, + "user": auth.actor.user, + "agent": auth.actor.agent, + "session": auth.actor.session, + }, + "role": auth.role.value, + "credential_type": auth.credential_type, + "credential_id": auth.credential_id, + "auth_method": auth.auth_method, + "credential_issuer": auth.credential_issuer, + "authenticated_at": auth.authenticated_at.isoformat() if auth.authenticated_at else "", + "delegation_id": auth.delegation_id, + } + } + + # Round3: 绑定完整安全上下文字段(若提供) + if context is not None: + payload["context"] = { + "surface": context.surface.value if context.surface else "", + "peer": context.peer or "", + "request_id": context.request_id, + "started_at": context.started_at.isoformat() if context.started_at else "", + "attributes": dict(sorted(context.attributes.items())), + } + + # Canonical JSON: sort_keys 保证顺序,separators 消除空格,ensure_ascii=False 保留 Unicode + material = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + + return hmac.new(_ORIGIN_KEY, material.encode("utf-8"), hashlib.sha256).hexdigest() + + +@dataclass(frozen=True) +class RequestSecurityContext: + """一次请求内供 API 安全边界使用的完整上下文(F05 §RequestSecurityContext)。 + + ``MemoryAPI`` 公开方法的**唯一显式安全输入**——业务 payload 中不存在 + ``identity`` / ``actor_*`` / ``role`` / ``acting_user`` 身份声明。由 + ``bootstrap.core.auth_middleware.authenticated`` 在认证后构造,经参数一路传到 + PEP;不经 ContextVar(那条通道已降级为日志辅助,见 :func:`set_current`)。 + + ``attributes`` 只允许系统组件写入:它参与 ``AuthorizationEnvironment``,能从业务 + payload 任意注入就等于把授权输入交给了调用方。构造时统一冻结成只读映射,让这条 + 约束在类型层面成立而不只是文档约定。 + """ + + auth: AuthContext + request_id: str = "" # 服务端生成或严格验证后的请求标识 + peer: str = "" # 规范化后的连接来源 + surface: Surface = Surface.INTERNAL + started_at: datetime | None = None + attributes: Mapping[str, str] = field(default_factory=_empty_attributes) + _origin: str = field(default="", repr=False, compare=False) + + def __post_init__(self) -> None: + if not isinstance(self.attributes, MappingProxyType): + object.__setattr__( + self, + "attributes", + MappingProxyType({str(k): str(v) for k, v in self.attributes.items()}), + ) + + @property + def actor(self) -> Scope: + """已认证主体。授权的 actor 只能来自这里,不来自业务参数。""" + return self.auth.actor + + def has_valid_origin(self) -> bool: + """上下文安全字段是否仍与受控构造入口签发的来源证明一致。""" + return hmac.compare_digest(self._origin, _bind_origin(self.auth, self)) + + +# ====================================================================== # +# 授权:动作 +# ====================================================================== # + + +class Action(str, Enum): + """封闭的安全动作集合(F05 §Action)。 + + **封闭**是这个类型的全部意义:授权策略按成员穷举,新增成员默认不属于任何角色、 + Grant 或 Delegation,必须显式配置后才能放行(F05 §授权不变量 5)。用开放字符串 + 表达动作会让「拼错的动作名」和「未配置的新动作」在策略里长得一样,而前者应该 + 是错误、后者应该是拒绝。 + + 与 ``Authenticator.mode()`` 的开放字符串刚好相反:模式影响的是**如何认证**, + 第三方实现要能自带;动作影响的是**准许什么**,第三方不能自行扩张。 + """ + + # -- 数据动作 -------------------------------------------------------- # + READ = "read" # 读取/检索 + WRITE = "write" # 写入新记忆 + UPDATE = "update" # 修正已有记忆 + DELETE = "delete" # 遗忘/降权/归档 + + # -- 分享动作 -------------------------------------------------------- # + SHARE = "share" # 再授权给其他 scope + REVOKE_SHARE = "revoke_share" # 回收已授出的分享 + + # -- 管理动作 -------------------------------------------------------- # + MANAGE_PRINCIPAL = "manage_principal" # 主体注册表:签发/撤销/改角色 + MANAGE_SPACE = "manage_space" # space 生命周期与策略 + MANAGE_POLICY = "manage_policy" # 治理策略 + + # -- 审计动作 -------------------------------------------------------- # + READ_AUDIT = "read_audit" # 查询审计事件 + VERIFY_AUDIT = "verify_audit" # 校验审计链完整性 + + # -- 系统动作 -------------------------------------------------------- # + ADMINISTER_SYSTEM = "administer_system" # 跨 org 的系统级操作 + + +MANAGEMENT_ACTIONS: frozenset[Action] = frozenset( + { + Action.MANAGE_PRINCIPAL, + Action.MANAGE_SPACE, + Action.MANAGE_POLICY, + Action.READ_AUDIT, + Action.VERIFY_AUDIT, + Action.ADMINISTER_SYSTEM, + } +) +"""管理面动作:需要角色闸门,且默认不可委托(F05 §授权不变量 4)。""" + +DELEGATABLE_ACTIONS: frozenset[Action] = frozenset( + {Action.READ, Action.WRITE, Action.UPDATE, Action.DELETE} +) +"""允许出现在 Delegation allowlist 中的动作。 + +刻意用**白名单**而非「管理动作取反」:取反写法下,新增的动作会自动变得可委托, +而 F05 §授权不变量 5 要求新 Action 默认拒绝。``SHARE`` / ``REVOKE_SHARE`` 不在列内 +——让被委托方能再授权,等于让委托关系自我复制,撤销就追不上了。 +""" + + +# ====================================================================== # +# 授权:拒绝原因 +# ====================================================================== # + + +class DenyReason(str, Enum): + """稳定的拒绝原因码(F05 §可观测性:授权决策记录稳定 reason code)。 + + 稳定指:值是审计与告警的匹配依据,改名等于破坏下游。文案可以变,值不可以。 + + 刻意**不**细分到「资源不存在」与「无权访问」——那是资源枚举侧信道。二者都归 + ``NOT_COVERED``。 + """ + + EXPIRED_CONTEXT = "expired_context" # AuthContext 已过期 + CONTEXT_MISMATCH = "context_mismatch" # actor 与请求安全上下文不一致 + ROLE_REQUIRED = "role_required" # 未通过角色闸门 + CROSS_ORG = "cross_org" # 跨 org 硬边界 + CROSS_SPACE = "cross_space" # 跨 space 硬边界 + NOT_COVERED = "not_covered" # actor 不覆盖 target,且无有效 Grant/Delegation + DELEGATION_INVALID = "delegation_invalid" # 委托不存在/已撤销/已过期/绑定不符 + DELEGATION_ACTION = "delegation_action" # 动作不在委托 allowlist 内 + GRANT_INVALID = "grant_invalid" # 授权不存在/已撤销/已过期 + DEFAULT_DENY = "default_deny" # 兜底:没有任何规则放行 + + +# ====================================================================== # +# 授权:资源描述 +# ====================================================================== # + + +@dataclass(frozen=True) +class ResourceDescriptor: + """一次授权判定的**目标资源**(F05 §ResourceDescriptor)。 + + 由 ``MemoryAPI``(唯一业务 PEP)构造。核心约束:**已有资源的安全 metadata 必须 + 来自真源**——请求只能提交筛选意图,不能声明决定授权结果的资源属性。若允许请求 + 自述「这条记忆属于我」,授权就退化成了信任调用方。 + + ``scope`` 是资源的真实归属,不是请求里写的那个;对已存在的 unit,它必须由 + API/Engine 从存储真源读出。 + """ + + action: Action + resource_type: str # write_input / query / memory_unit / admin / job ... + scope: Scope # 资源真实归属(真源) + resource_id: str = "" # 已存在资源的 id;新建操作为空 + attributes: Mapping[str, str] = field(default_factory=_empty_attributes) + + def __post_init__(self) -> None: + if not isinstance(self.attributes, MappingProxyType): + object.__setattr__( + self, + "attributes", + MappingProxyType({str(k): str(v) for k, v in self.attributes.items()}), + ) + + +# ====================================================================== # +# 授权:环境 +# ====================================================================== # + + +@dataclass(frozen=True) +class AuthorizationEnvironment: + """授权判定的**环境输入**(F05 §Authorization)。 + + 只包含服务端产生的时间、部署策略和请求安全属性。**不包含**调用方声明的 role + 或资源安全 metadata——那两样一旦可由请求提供,授权就成了自证。 + + ``now`` 显式传入而非在 Authorizer 内部取:过期判定要可测试,也要保证同一次判定 + 里所有时效检查用同一个时刻(Grant 与 Delegation 分别取一次 ``now`` 会出现一个 + 刚好过期、另一个还没过期的裂缝)。 + """ + + now: datetime + surface: Surface = Surface.INTERNAL + request_id: str = "" + peer: str = "" + attributes: Mapping[str, str] = field(default_factory=_empty_attributes) + + def __post_init__(self) -> None: + if not isinstance(self.attributes, MappingProxyType): + object.__setattr__( + self, + "attributes", + MappingProxyType({str(k): str(v) for k, v in self.attributes.items()}), + ) + + @classmethod + def from_request( + cls, security: RequestSecurityContext, *, now: datetime + ) -> AuthorizationEnvironment: + """从请求安全上下文派生环境。 + + 只取 surface/request_id/peer/attributes 四项——它们都由服务端组件写入。 + ``auth`` 不进环境:身份是 Authorizer 的独立入参,混进环境会让「谁在操作」 + 和「在什么条件下操作」两件事在策略里纠缠。 + """ + return cls( + now=now, + surface=security.surface, + request_id=security.request_id, + peer=security.peer, + attributes=security.attributes, + ) + + +# ====================================================================== # +# 授权:长期授权与代操作 +# ====================================================================== # + + +@dataclass(frozen=True) +class Grant: + """主体之间的**显式长期授权**(F05 §Grant)。 + + 与 :class:`Delegation` 的分工:Grant 是「A 把自己资源的某些动作开放给 B」, + 是资源侧的长期开放;Delegation 是「user 授权 agent 代表自己行事」,是身份侧的 + 有限期代理。两者的撤销语义、时效要求和可委托动作都不同,合成一个类型会让 + 「撤销了代理,分享还在」这类正确行为难以表达。 + + ``grant_id`` 是服务端生成的审计标识:撤销、审计与告警都按它定位。 + """ + + grant_id: str + grantor: Scope + grantee: Scope + actions: frozenset[Action] + expires_at: datetime | None = None # None = 长期有效 + revoked: bool = False + + def is_active(self, *, now: datetime) -> bool: + """当前是否有效(未撤销且未过期)。""" + if self.revoked: + return False + return self.expires_at is None or now < self.expires_at + + +@dataclass(frozen=True) +class Delegation: + """user 对 agent/service 的**可撤销、有限期代操作授权**(F05 §Delegation)。 + + 存在的理由是 F05 §从 header 直接产生 Delegation 那条拒绝:网关 header 最多证明 + 「网关声称这是某个 user」,不能证明「该 user 真的授权了这个 agent」。委托必须是 + 服务端事实,由 ``delegation_id`` 回真源复核。 + + ``expires_at`` **无默认值**:代操作授权必须有限期。给它一个「None = 永久」的默认, + 等于让忘记设置过期时间静默产出一个永久代理。 + + ``bound_credential_id`` 把委托绑到具体凭据:换一把 key 的同一个 agent 用不了这条 + 委托,凭据泄露的爆炸半径就收敛在单把 key 上。 + """ + + delegation_id: str + delegator: Scope # 委托方(user) + delegate: Scope # 被委托方(agent / service) + actions: frozenset[Action] # 动作 allowlist + expires_at: datetime + not_before: datetime | None = None + revoked: bool = False + allowed_spaces: frozenset[str] = frozenset() # 空 = 不额外限制 space + bound_credential_id: str = "" # 绑定凭据;空 = 不绑定 + bound_session: str = "" # 绑定会话;空 = 不绑定 + + def is_active(self, *, now: datetime) -> bool: + """当前是否在有效期内且未撤销。""" + if self.revoked: + return False + if self.not_before is not None and now < self.not_before: + return False + return now < self.expires_at + + def permits(self, action: Action) -> bool: + """动作是否在 allowlist 内**且**本身可委托。 + + 两个条件缺一不可:allowlist 由数据决定、``DELEGATABLE_ACTIONS`` 由策略决定。 + 只查 allowlist 会让一条写坏或被篡改的委托记录直接拿到管理动作。 + """ + return action in DELEGATABLE_ACTIONS and action in self.actions + + +# ====================================================================== # +# 密码学调用上下文 +# ====================================================================== # + + +@dataclass(frozen=True) +class CryptoContext: + """一次加解密调用的安全上下文(F05 §CryptoContext)。 + + 由**存储适配器**构造:只有它同时掌握真实对象 id 与存储用途。业务请求不能直接 + 控制 AAD——否则攻击者可以让两个不同对象共用同一 AAD,密文就能跨对象复制。 + + ``format_version`` 进 AAD,使信封格式升级不会被降级重放。 + """ + + scope: Scope + purpose: str # 存储用途(memory_unit / raw_message / fs_object / kv_value ...) + object_id: str = "" # 对象标识(KV key / FS ref) + format_version: int = 1 # AAD 载荷格式版本 + metadata: Mapping[str, str] = field(default_factory=dict) + + +# ====================================================================== # +# 请求内身份传播(辅助通道) +# ====================================================================== # + +_CURRENT: ContextVar[AuthContext | None] = ContextVar("auth_context", default=None) + + +def set_current(ctx: AuthContext) -> Token[AuthContext | None]: + """在请求入口设置当前认证上下文;返回的 token 必须在请求结束时交给 reset。 + + **定位(F05 §显式上下文优于环境权限)**:ContextVar 已降级为日志/trace 的辅助 + 传播通道(迁移计划 §5.2 第 10 项)。安全语义全部走显式参数—— + ``auth_middleware.authenticated`` 产出 :class:`RequestSecurityContext`,surface + 显式传给 ``dispatch``、``dispatch`` 传给 ``MemoryAPI``、``MemoryAPI`` 传给 + ``Authorizer``。**没有任何授权路径读这里**:新增消费方前请先确认,你要的不是 + 「把 security 参数一路传下去」。 + """ + return _CURRENT.set(ctx) + + +def reset_current(token: Token[AuthContext | None]) -> None: + """请求结束时还原上下文。 + + 必须在 ``finally`` 中调用:``ThreadingHTTPServer`` 每请求一线程,线程可能 + 被复用,漏 reset 会让下一个请求继承上一个请求的身份。授权已不读它,故这不再是 + 越权路径,但漏 reset 会让日志把两个请求归到同一主体名下。 + """ + _CURRENT.reset(token) + + +def get_current() -> AuthContext | None: + """取当前认证上下文;未认证返回 ``None``。**只用于日志/trace**。 + + 刻意不返回默认 ``AuthContext``:那是 fail-open,会让「中间件漏挂」在读取侧 + 看起来像「有个匿名身份」。返回 ``None`` 迫使调用方显式处理缺失。 + """ + return _CURRENT.get() diff --git a/src/common/type_def/audit.py b/src/common/type_def/audit.py index 2646640b..4c62b2eb 100644 --- a/src/common/type_def/audit.py +++ b/src/common/type_def/audit.py @@ -27,5 +27,10 @@ class AuditEvent: default_factory=dict ) # 附加明细(字符串化扩展字段;不放敏感 scope) target: Scope = field(default_factory=Scope) # 操作目标 scope;无具体目标时为空 - # 常见约定:permission_check、permission_reason、job_id、 + # 常见约定:permission_check、permission_reason、permission_rule、job_id、 # before_unit_id / after_unit_id、before_unit_ids / after_unit_ids + # 安全层(src/common/security)另加:role、key_fp、auth_mode。 + # security.md §7.2 要求审计记录这些,但它们是**认证元数据**,与本结构 + # 承载的「谁对什么做了什么」不同层;塞 detail 而非提升为一等字段,是因为 + # 改本结构要同时动 common / control / 两个 AuditLogger 实现 + + # handler._event_view。若这些键稳定使用,第二期应提升为一等字段。 diff --git a/src/common/type_def/scope.py b/src/common/type_def/scope.py index 96262f1f..3522713a 100644 --- a/src/common/type_def/scope.py +++ b/src/common/type_def/scope.py @@ -2,6 +2,10 @@ ``org > space > user/agent > session`` 五维归属,统一支撑隔离(多租户、 单 Agent 私有)与共享(跨 Agent 共享池);检索/写入默认在 scope 内。 + +**frozen=True(验收第三次 P2-1)**:Scope 是身份/隔离的值对象,可变性是安全 +缺陷--签发 key 后改原 actor 的 org,会让已签发 key 的身份跟着变(越权)。改某维 +用 ``dataclasses.replace(scope, org=...)`` 返回新值,不原地修改。 """ from __future__ import annotations @@ -9,7 +13,7 @@ from dataclasses import dataclass, field -@dataclass +@dataclass(frozen=True) class Scope: org: str = "" # 组织/租户 space: str = field(default="", kw_only=True) # 全局唯一的逻辑隔离空间标识 diff --git a/src/config/defaults.py b/src/config/defaults.py index f95dc085..2d4a4307 100644 --- a/src/config/defaults.py +++ b/src/config/defaults.py @@ -43,9 +43,9 @@ def default_config_dict() -> dict[str, Any]: "prompts": _PROMPTS_DEFAULT, # -- 存储(有状态,必须对象共享)-------------------------------------- # "kv_store": {_D: "memory"}, - # 安全 provider:默认用 local 信封加密(AES-256-GCM)。 - # 生产装配覆盖为同事实现的自注册 target(@SecurityProducer.register("xxx"))。 - "security": {_D: "local"}, + # 密码学 provider:默认用 local 信封加密(AES-256-GCM)。 + # 生产装配可覆盖为自注册 target(@CryptographyProducer.register("xxx"))。 + "cryptography": {_D: "local"}, "vector_store": { _D: "memory", # L0/L1 分表(与构建侧同命名 layers_l0/l1;同后端不同 collection) @@ -234,6 +234,17 @@ def default_config_dict() -> dict[str, Any]: "policy": {_D: "dict"}, "governor": {_D: {"target": "in_memory", "params": {"audit": _D, "kv_store": _D}}}, "permission": {_D: {"target": "sqlite", "params": {"db_path": ":memory:"}}}, + # 授权(F05 §Authorization / 迁移计划 §7.1):策略在 authorizer,记录的存取在 + # grant_store / delegation_store。两个 Store 在 authorizer.standard 的 _build + # 里**无默认**,故必须在这里显式具名——「忘了配授权存储」要在装配期就报错。 + "grant_store": {_D: "memory"}, + "delegation_store": {_D: "memory"}, + "authorizer": { + _D: { + "target": "standard", + "params": {"grant_store": _D, "delegation_store": _D}, + } + }, "space": {_D: {"target": "kv", "params": {"kv_store": _D}}}, # 可插拔配置来源:默认装配快照;产品可覆盖为 dict/overlay/自研 target "config_source": {_D: "yaml_defaults"}, @@ -243,7 +254,7 @@ def default_config_dict() -> dict[str, Any]: # 根组件(LocalMemoryAPI)对各顶层组件的引用——全部指向各命名空间下的 default 实例。 ROOT_PARAMS: dict[str, str] = { "engine": _D, - "permission": _D, + "authorizer": _D, "scheduler": _D, "policy": _D, "governor": _D, diff --git a/src/control/AGENTS.md b/src/control/AGENTS.md index 63a249ac..b83397f2 100644 --- a/src/control/AGENTS.md +++ b/src/control/AGENTS.md @@ -13,7 +13,7 @@ | 文件 | 职责 | |---|---| | `base.py` | `ControlOperator` 抽象基类 + `ControlOperatorType` 枚举;所有算子的自描述契约 | -| `types.py` | 控制层数据类型(Action/Grant/Channel/JobInfo/MemoryPatch/DeleteSelector/BatchWrite* 等),被本层所有文件及上游 `api/` 依赖 | +| `types.py` | 控制层数据类型(Action/Grant/Channel/JobInfo/MemoryPatch/DeleteSelector/BatchWriteItem/BatchWriteResult 等),被本层所有文件及上游 `api/` 依赖 | | `engine.py` | `MemoryEngine` 抽象接口——跨层编排中枢,异步协程 | | `pipeline.py` | `MemoryPipeline` 抽象接口——按记忆类型选择构建/查询 profile | | `lifecycle.py` | `LifecycleManager` 接口——状态流转(transition)与到期清扫(sweep) | @@ -41,7 +41,9 @@ 1. **引擎不实现具体算法能力**:`MemoryEngine` 只编排,Ingestor/构建算子/Retriever/Store 全部由装配注入。Engine 可通过注入的 `KVStore` 完成接口语义要求的真源落盘/点读/删除,但禁止绕过 Store 抽象绑定具体后端或在 engine 内调用 LLM。 2. **引擎方法一律异步协程**:同步调用由 `api/` 层自行桥接(`asyncio.run`),engine 内不做同步阻塞。 -3. **鉴权不在本层执行**:`PermissionManager.check` 由 `api/MemoryAPI` 在入口调用,engine 信任传入的 scope 已鉴权。Engine 提供 `permission_context_for_unit`、`list_with_permission_contexts` 和 `permission_contexts_for_delete`,供 API 使用真源 metadata 做类型化鉴权;list 的 items、count 与 contexts 必须来自同一次 KV 列表查询。禁止在 engine 内部重复 check。 +3. **鉴权不在本层执行**:授权判定由 `api/MemoryAPI` 这个唯一 PEP 在入口调用 `common.security.authorization` 的 `Authorizer`(PDP),engine 信任传入的 scope 已鉴权。Engine 提供 `permission_context_for_unit`、`list_with_permission_contexts` 和 `permission_contexts_for_delete`,供 API 使用真源 metadata 做类型化鉴权;list 的 items、count 与 contexts 必须来自同一次 KV 列表查询。禁止在 engine 内部重复鉴权。 + `batch_write` 接收的每个 `BatchWriteItem` 同样已由 API 按最终 scope 独立鉴权,Engine + 只负责保序执行与逐项结果归集,不接收 `RequestSecurityContext`。 4. **LifecycleManager 只做 Scope 内非破坏式标记**:`transition` / `supersede` 必须接收完整 Scope,只标记该 Scope 下的目标 id,绝不物理删除。物理删除(purge)走 engine 的 `delete` 路径 + `DeleteMode.PURGE`。 5. **接口与实现严格分离**:顶层 `.py` 是纯抽象,不 import `*_impl/`。`*_impl/` 通过 producer 工厂被外部装配消费,不被顶层接口引用。 6. **Pipeline 只做 profile 选择**:`MemoryPipeline` 选择一组已装配的 `IndexBuilder` / `Evolver` / `Retriever` / `Classifier` 绑定,不实现抽取、巩固、索引、检索算法,不让 construction/retrieval 反向依赖 control。 @@ -49,11 +51,11 @@ 8. **权限路由与数据范围绑定**:RoutingPermissionManager 只按 PermissionContext 选择 delegate;API 必须把授权所依据的路由字段回注为系统过滤谓词。未知路由值和直接 policy 名落最小权限 fallback,fallback 不得配置为 allow_all。 -9. **space 是权限硬边界**:`PermissionManager.check` 先按 `org + space` 判断 owner-cover;同 org 跨 space 默认拒绝,只有 `Scope()` 或显式 grant 可跨 space。owner-cover 的主体路径由 `PermissionContext.metadata["principal_path"]` 选择(默认 `user_agent`,可选 `agent_user`)。 +9. **space 是权限硬边界**:`StandardAuthorizer` 先按 `org + space` 判断 owner-cover;同 org 跨 space 默认拒绝,只有 ROOT 角色或显式 Grant 可跨 space。owner-cover 的主体路径由 `PermissionContext.metadata["principal_path"]` 选择(默认 `user_agent`,可选 `agent_user`),经 API 摊平为 `ResourceDescriptor.attributes` 传入。 10. **space policy 是主体路径来源**:`LocalMemoryAPI` 在鉴权前读取目标 space policy,并用其中的 `principal_path` 覆盖 `PermissionContext.metadata["principal_path"]`;调用级 metadata 不能临时改变已有 space 的主体路径。 11. **Space id 全局唯一**:`KVSpaceManager` 在根 Scope 维护全局 Space 注册键;不同 org 创建同一非空 Space id 必须报 `ConflictError`。 12. **治理读取按已鉴权 Scope 定位**:Governor 的 `inspect` / `trace` 必须接收 API 已鉴权 target Scope,不得仅按 unit id 跨 Scope 扫描。 -13. **批量写入保序且不鉴权**:Engine 的 `batch_write` 只接收 API 已前置校验的归一化 item,按输入顺序复用 `write`;不得在 Engine 内并发提交或重复执行 `PermissionManager.check`。 +13. **授权判定归 `common.security.authorization`,本层不再持有安全所有权**:PDP 是 `Authorizer.authorize(auth, resource, environment)`,输入固定为 `AuthContext + ResourceDescriptor + AuthorizationEnvironment`,**不读 ContextVar**。`auth.actor != resource` 所依据的调用方即拒(fail-closed),空 `Scope()` actor 直接拒(它是「上下文不完整」的信号,不是 platform admin);ROOT 只按 `role` 判定;管理面资源(`resource_type` 为 admin/audit,或 space 的写/删)要求 ADMIN 及以上,其中无 org 归属的系统级资源要求 ROOT。代操作不在请求里表达:委托关系必须来自服务端的 `DelegationStore`,由 Authorizer 按 `delegation_id` 复核,可委托动作的 allowlist 见 `common.security.types.DELEGATABLE_ACTIONS`(不含 SHARE 与管理动作——否则临时委托可升级成永久 Grant,审计 P1-1)。API 的 grant/revoke 已改写 Authorizer 读取的 `GrantStore`(经 `management_grant_store()` 共享同一真源),不再经 `PermissionManager`;`PermissionManager` 仅作 PR3 待删除的遗留(迁移计划 §6.2 第 10 项)。 ## 双通道调度机制 diff --git a/src/control/permission.py b/src/control/permission.py index 72c36f2c..27f31686 100644 --- a/src/control/permission.py +++ b/src/control/permission.py @@ -10,6 +10,7 @@ from common.factory.factory import Factory from common.type_def import Scope +from common.security.types import AuthContext from .base import ControlOperator from .types import Action, Grant, PermissionContext @@ -43,8 +44,23 @@ def check( target: Scope, action: Action, context: PermissionContext | None = None, + *, + auth: AuthContext | None = None, ) -> bool: - """校验 ``actor`` 是否可对 ``target`` scope 执行 ``action``。""" + """校验 ``actor`` 是否可对 ``target`` scope 执行 ``action``。 + + ``auth`` 是认证层产出的可信上下文(由 PEP 从 ContextVar 取出后透传), + 携带 ``role``(§3.1 三级角色)这个 ``actor`` 推不出来的判定依据。 + + 代操作(原 ``acting_user``)已不在这里判:委托关系必须来自服务端记录,由 + ``common.security.authorization`` 的 Authorizer 按 ``delegation_id`` 回 + ``DelegationStore`` 复核(F05 §从 header 直接产生 Delegation)。 + + ``auth`` 为 ``None`` 时行为退回纯 ACL——即认证接入前的语义。这条兼容线 + 承载后台 job、单测与 ``build_kernel`` 直连等非请求场景:它们没有认证上下文, + 不该因此被拒。实现**不得**自行去读 ContextVar:PDP 应当是其入参的纯函数, + 否则单测要先布置环境态才能跑,判定依据也不再显式可见。 + """ def routing_fields(self) -> tuple[str, ...]: """本实现据以**选择策略**的 :class:`PermissionContext` 字段名(默认不路由)。 diff --git a/src/control/permission_impl/allow_all_permission_manager.py b/src/control/permission_impl/allow_all_permission_manager.py index 5338b733..eca3e458 100644 --- a/src/control/permission_impl/allow_all_permission_manager.py +++ b/src/control/permission_impl/allow_all_permission_manager.py @@ -9,6 +9,7 @@ from typing import List from common.type_def import Scope +from common.security.types import AuthContext from control.base import ControlOperatorType from control.permission import PermissionManager, PermissionProducer from control.types import Action, Grant, PermissionContext @@ -42,7 +43,11 @@ def check( target: Scope, action: Action, context: PermissionContext | None = None, + *, + auth: AuthContext | None = None, ) -> bool: + # 恒放行是本实现的**全部**语义,``auth`` 一并忽略:它是 dev-only 的装配件, + # 掺进角色闸门只会让「allow_all 就是不鉴权」这个前提变得需要逐条确认。 return True diff --git a/src/control/permission_impl/routing_permission_manager.py b/src/control/permission_impl/routing_permission_manager.py index 877b2f43..0045f863 100644 --- a/src/control/permission_impl/routing_permission_manager.py +++ b/src/control/permission_impl/routing_permission_manager.py @@ -4,6 +4,7 @@ from common.errors import ValidationError from common.type_def import Scope +from common.security.types import AuthContext from control.base import ControlOperatorType from control.permission import PermissionManager, PermissionProducer from control.types import Action, Grant, PermissionContext @@ -85,14 +86,18 @@ def check( target: Scope, action: Action, context: PermissionContext | None = None, + *, + auth: AuthContext | None = None, ) -> bool: # S03「PermissionManager」约束「routing 不改变授权语义,只选择 delegate」—— # 此处只做选择并委托: # 不额外 deny、不对多个 policy 求交集,root / owner-cover / Grant 等基础规则 # 全部由被选中的 delegate 按 S03 的 check 规则判定。路由值未解析时按 _select # 落到 fallback(S03 示例即如此定义),不在路由层加码。 + # ``auth`` 同理**原样透传**:在这里吞掉它,角色闸门与代操作委托会在路由型 + # 部署下静默失效——一个只在某种装配形态下出现、且没有任何症状的授权漏洞。 policy = self._select(context) - return policy.check(actor, target, action, context=context) + return policy.check(actor, target, action, context=context, auth=auth) def _select(self, context: PermissionContext | None) -> PermissionManager: value = _context_value(context, self._route_key) @@ -134,9 +139,7 @@ def _build(config): route_key = config.get("route_key", "memory_type") fallback = str(config.get("fallback", "")).strip() if not fallback: - raise ValidationError( - "permission.routing params.fallback 必须指向一个具名 permission" - ) + raise ValidationError("permission.routing params.fallback 必须指向一个具名 permission") if fallback == config.name: raise ValidationError("permission.routing params.fallback 不能指向 routing 自身") routes_raw = config.get("routes", {}) diff --git a/src/control/permission_impl/sqlite_permission_manager.py b/src/control/permission_impl/sqlite_permission_manager.py index 957c15d4..4c584b68 100644 --- a/src/control/permission_impl/sqlite_permission_manager.py +++ b/src/control/permission_impl/sqlite_permission_manager.py @@ -2,11 +2,19 @@ 第一期真实 ACL 实现: -- `Scope()` 视为 platform admin,全局放行; +- `Scope()` 视为 platform admin,全局放行(**仅在无认证上下文时**,见下); - owner 访问自己的 scope(含 agent/session 子 scope)默认放行; - 跨 org 默认拒绝,同 org 跨 space 默认拒绝; - grant 持久化到 SQLite,按 action 单行存储; - revoke 采用软撤销(`revoked_at`)。 + +传入 ``auth``(认证层产出的 ``AuthContext``)时另加三条,判定顺序即代码顺序: + +1. ``auth.actor`` 与 ``actor`` 不一致 → 拒; +2. 管理面资源(``resource_type`` 为 admin/audit,或 space 的写/删)要求 ROOT; +3. ROOT 按 **role** 判定(§3.5)。 + +此时空 `Scope()` **不再**自动等于 platform admin:特权必须来自认证层的显式结论。 """ from __future__ import annotations @@ -17,6 +25,7 @@ from pathlib import Path from common.type_def import Scope +from common.security.types import AuthContext, Role from control.base import ControlOperatorType from control.permission import PermissionManager, PermissionProducer from control.types import Action, Grant, PermissionContext @@ -106,12 +115,54 @@ def _owner_scope_covers( if parent_value != child_value: return False continue - if any(getattr(parent, later) for later in order[index + 1:]): + later_start = index + 1 + if any(getattr(parent, later) for later in order[later_start:]): return False return True return True +_MANAGEMENT_RESOURCES = frozenset({"admin", "audit"}) +_SPACE_LIFECYCLE_ACTIONS = frozenset({Action.WRITE, Action.DELETE}) + + +def _management_plane_denies( + auth: AuthContext | None, + action: Action, + context: PermissionContext | None, +) -> bool: + """管理面(§3.2 的 ROOT 行)要求 ROOT,非 ROOT 一律拒。 + + 「这是不是管理操作」由 ``PermissionContext.resource_type`` 说了算,而不是由 + 「target 恰好是空 Scope」间接表达。后者是**靠数据形状表达语义**:把 target + 填成自己的 scope 就绕过去了,而调用方是能控制 target 的。 + + ``grant`` / ``revoke`` **不在**这张表里。§3.2 那行说的是「**跨租户**修改权限」, + 而跨 org 的 grant 今天已被 ``actor.org != target.org`` 挡住;对自己 scope 发 + grant 是 Grant 模型的主用途,把它闸进 ROOT 会废掉正常共享。 + + ``auth`` 为 ``None`` 时不闸——那是没有认证上下文的场景(后台 job / 单测 / + ``build_kernel`` 直连),此时无从判定角色,沿用旧的 ACL 判定。 + """ + if auth is None or context is None: + return False + if auth.role is Role.ROOT: + return False + if context.resource_type in _MANAGEMENT_RESOURCES: + return True + # 创建/删除租户属 ROOT(§3.2)。同为 resource_type="space" 的 get/update/archive + # 走 READ/UPDATE,不在此列——§3.2 只点名了「创建/删除」,读 space 元数据若也要 + # ROOT,普通用户连自己所在 space 的名字都拿不到。 + return context.resource_type == "space" and action in _SPACE_LIFECYCLE_ACTIONS + + +# agent 代 user 操作的判定路径已删除。它依赖网关 header 送来的 ``acting_user``,而 +# header 只能证明网关声称某个 user,证明不了该 user 真的授权了这个 agent(F05 +# §从 header 直接产生 Delegation)。代操作现在走 ``DelegationStore`` 里的服务端记录, +# 由 ``StandardAuthorizer`` 按 ``delegation_id`` 复核;可委托动作的 allowlist 迁到 +# ``common.security.types.DELEGATABLE_ACTIONS``。 + + def _row_scope(row: sqlite3.Row | tuple, prefix: str) -> Scope: if isinstance(row, sqlite3.Row): return Scope( @@ -234,8 +285,31 @@ def check( target: Scope, action: Action, context: PermissionContext | None = None, + *, + auth: AuthContext | None = None, ) -> bool: - if actor == Scope(): + if auth is not None and auth.actor != actor: + # 两个身份来源不一致:要么是接线错误,要么是拿 A 的凭据去问 B 的权限。 + # 两种都拒(fail-closed,铁律 #3)。返回 False 而非抛异常——check 的契约 + # 是给出布尔判定,异常留给 PEP 去翻译成 403。 + return False + + if _management_plane_denies(auth, action, context): + return False + + if auth is not None: + if auth.role is Role.ROOT: + return True + if actor == Scope(): + # 有认证上下文、role 又不是 ROOT:空 actor 只是一个**没填内容的 + # 身份**,不是特权形态。这里必须显式拒,否则它会命中下方 + # `_owner_scope_covers` 顶部的「parent 为空即覆盖一切」通配分支—— + # 那个分支是给 grant 行匹配用的,不该被 actor 借道。 + return False + elif actor == Scope(): + # 无认证上下文时保留旧的 platform-admin 规则。有认证上下文时**不**保留: + # 见下方 _management_plane_denies 上面的说明,特权必须来自认证层的显式 + # 结论,不能来自「actor 恰好是空 Scope」这个数据形状的巧合。 return True if _owner_scope_covers(actor, target, context): diff --git a/src/control/space_impl/kv_space_manager.py b/src/control/space_impl/kv_space_manager.py index 8df8eb60..7d054ec8 100644 --- a/src/control/space_impl/kv_space_manager.py +++ b/src/control/space_impl/kv_space_manager.py @@ -181,8 +181,8 @@ def _normalize_member(org: str, space: str, member: SpaceMember) -> SpaceMember: raise ValidationError("member scope org must match target space org") if scope.space and scope.space != space: raise ValidationError("member scope space must match target space") - scope.org = org - scope.space = space + # Scope 是 frozen 值对象(验收第三次 P2-1):用 replace 返回新值,不原地修改。 + scope = replace(scope, org=org, space=space) created_at = member.created_at or _now() return SpaceMember( scope=scope, @@ -198,9 +198,7 @@ def _normalize_member_scope(org: str, space: str, member: Scope) -> Scope: raise ValidationError("member scope org must match target space org") if scope.space and scope.space != space: raise ValidationError("member scope space must match target space") - scope.org = org - scope.space = space - return scope + return replace(scope, org=org, space=space) class KVSpaceManager(SpaceManager): @@ -286,7 +284,8 @@ def list( continue spaces[(info.org, info.space)] = info ordered = [spaces[key] for key in sorted(spaces)] - return ordered[offset:offset + limit] + page_end = offset + limit + return ordered[offset:page_end] def update(self, org: str, space: str, patch: SpacePatch) -> SpaceInfo: info = self.get(org, space) diff --git a/src/storage/AGENTS.md b/src/storage/AGENTS.md index 9d427301..c9c8b2c7 100644 --- a/src/storage/AGENTS.md +++ b/src/storage/AGENTS.md @@ -18,14 +18,12 @@ | `fulltext.py` | FulltextStore 接口:全文倒排索引存储,统一 CRUD + 关键词检索(BM25) | | `fusion.py` | FusionStore 接口:融合存储(向量+倒排+正排一体) | | `fs.py` | FSStore 接口:文件系统存储(原始负载/二进制资产) | -| `_support.py` | 后端实现共用:异常归一(`wrap_backend`)、scope 派生(`scope_dims`/`scope_segments`)、SSL 配置读取(`read_ssl_config`);`SslConfig` 与 scheme 校验复用 `common._support` | -| `_pg.py` | PostgreSQL 后端共享的惰性连接池、schema 工具与 FilterExpr SQL 编译 | -| `kv_impl/` | KVStore 实现目录(memory / sqlite / redis / encrypted / postgres)及共用的 `memory_list.py` 兼容逻辑 | -| `vector_impl/` | VectorStore 实现目录(memory / milvus / pgvector) | +| `kv_impl/` | KVStore 实现目录(memory / sqlite / redis / encrypted)及共用的 `memory_list.py` 兼容逻辑 | +| `vector_impl/` | VectorStore 实现目录(memory) | | `graph_impl/` | GraphStore 实现目录(memory) | | `fulltext_impl/` | FulltextStore 实现目录(memory) | | `fusion_impl/` | FusionStore 实现目录(memory) | -| `fs_impl/` | FSStore 实现目录(local) | +| `fs_impl/` | FSStore 实现目录(local / encrypted) | | `bootstrap.py` | 统一触发所有存储后端注册 | ## 统一 CRUD 动词 @@ -37,7 +35,7 @@ | `update` | 改:修改已有记录(id 不存在时抛 NotFoundError) | | `get` | 查:按 id 点查(点查单条不存在时抛 NotFoundError;批量查缺失的 id 省略) | -检索型存储额外提供 `search` 查询;kv 提供 MemoryUnit 专用 `list` 和通用 `mget` / +检索型存储额外提供 `search` 查询;kv 提供 MemoryUnit 专用 `list` 和通用 `exists` / `scan` / `scopes`;fs 提供 `stat`。 ## 行为铁律 @@ -64,21 +62,37 @@ 7. **后端不可用统一抛 BackendError** 连接失败/超时/服务不可用等非预期失败统一抛 `BackendError`(不抛泛化的 Exception)。 -8. **EncryptedKVStore 只做装饰,不做算法** - `encrypted` KV target 必须显式包装一个 raw KVStore,并调用 `common.security.SecurityProvider` - 做 value 加解密;`list` 必须在解密后执行 MemoryUnit 过滤,不能把过滤下推到密文 raw KV。 - 真实加密算法不放在 storage 层。 - -9. **过滤保持 metadata 形态语义** - `EQ` / `IN` 的正向匹配只命中标量,`CONTAINS` 只命中数组成员;`NE` / `NOT_IN` - 分别是前两者的逻辑否定;范围算子只作用于标量。后端原生字段若不区分单值与数组, - 必须写入内部派生标记恢复该语义,不得把 `EQ` 与 `CONTAINS` 编译成无差别查询, - 也不得让数组字段被范围谓词按「任一成员命中」选中。 - -10. **SSL 开启后不得静默降级** - `ssl_verify=true` 意味着实际必须校验服务端证书。缺 `ssl_ca_cert`、连接串仍为明文 - scheme、或连接串自带会覆盖本设置的 TLS 参数,一律在**装配阶段**报错,不得放行到 - 运行期——调用方以为受保护而实际未校验,比明文更危险。 +8. **加密装饰器只做装饰,不做算法** + `encrypted` KV / FS target 必须显式包装一个 raw Store,并调用 + `common.security.cryptography.CryptographyProvider` 做加解密;KV 的 `list` 必须在 + 解密后执行 MemoryUnit 过滤,不能把过滤下推到密文 raw KV。真实加密算法不放在 storage 层。 + +9. **没有明文回落** + 不是合法信封就拒绝读取,解密失败一律抛错,绝不返回原始 bytes。加密适配器内部 + 不存在 `allow_plaintext` 这类降级开关——是否允许未加密存储,由上层选 `encrypted` + 还是 raw target 显式表达(F05 §明文策略)。一个能读明文的"加密"存储,让「以为 + 加密了」的部署实际裸奔,而调用方看不出任何区别。 + +## 加密装饰器(第③道防线的接线) + +`kv_impl/encrypted_kv_store.py` 与 `fs_impl/encrypted_fs_store.py` 是**装饰器**: +包住任意一个同类 Store,写前加密、读后解密,对上仍是一个普通 `KVStore` / `FSStore`。 + +- **依赖方向是 `storage → common.security.cryptography`(单向)**。密码学一行都不在 + storage 里,全在 `common.security.cryptography.cryptography_impl`;这两个文件只构造 + `CryptoContext` / AAD 并转发。反向依赖不存在,cryptography 不认识 Store。 +- **KV 只加密 `value`**。`key` 明文是必须的(加密它就没法 `list(prefix=...)`、 + 没法点查);`ttl` 明文是必须的(它是后端的原生能力,加密它等于放弃过期功能)。 +- **FS 加密整个文件内容**,`ref` 与 scope 保持明文(路径要能寻址),`ref` 进 AAD。 + 代价是 `get` 必须读全文件到内存才能解密(AES-GCM 整块认证的直接后果),且 + `FileStat.size` 返回的是密文长度(修正需先解密才知道明文长度,代价荒谬)。 +- **AAD 绑满五维 scope + 定位信息**(KV 是 `key`,FS 是 `ref`)。存储层的 scope + 隔离是访问控制、可以被绕过(直接写底层、备份恢复串了);AAD 是密码学的,绕不过。 +- **`cryptography` 缺失时在装配期抛 `BackendError`,绝不回落明文存储**——回落 + 会让「以为加密了」的部署实际裸奔,比不加密更危险。 +- 默认关闭:不配 `target: encrypted` 就没有任何加密行为,现有部署零影响。 +- `FsProducer` 可独立装配 encrypted FSStore,但当前 `build_kernel` 业务主链路没有 + FSStore 消费点;仅写 YAML 不会自动让记忆资产经过 FS 加密,接入前必须先定义资产 API。 ## 与其他子目录的边界 @@ -88,12 +102,14 @@ - 文件系统存储(FSStore) - 统一 CRUD 动词 - scope 原生隔离 +- 静态加密的**接线**(两个装饰器 + 它们的注册),密码学本身归 `common/security/cryptography/` **不管**: - 鉴权(归 `api`) - 检索编排(归 `retrieval`) - 索引构建逻辑(归 `construction`) - 具体后端选型决策(由装配层配置) +- 信封格式、密钥派生与包装、AES-GCM/HKDF、根密钥获取(归 `common/security/cryptography/`) ## 本地约束 @@ -103,12 +119,5 @@ 4. KVStore 的 `ttl` 单位为秒(float),`0` 表示永不过期。 5. GraphStore 的 `seed_ids` 用于图召回时定位入口节点,匹配语义由后端定义(允许实现差异)。 6. FusionStore 的 `FusionRecord` 可部分字段为 None(如只写向量不写文本)。 -7. `EncryptedKVStore` 的 `raw_kv_store` 不能指向自身;未配置 raw 依赖时必须在装配阶段报错。 -8. `KVStore.mget` 是 `get` 的批量互补:返回与 `keys` 下标一一对应的 `list[bytes]`、任一 key 缺失即抛 `NotFoundError`(与 `get` 一致,不静默省略)、**不去重**、支持重复 key(各下标独立返回,语义同 Redis `MGET`,重复 key 去重由调用方如 `UnitReader.load` 负责,不下沉到本接口);`encrypted` 的 `mget` 委托 raw 取密文(raw 缺失即抛 `NotFoundError`)后须逐项解密(AAD 绑 key,不可批量统一解密)。 -9. 接外部后端的实现统一接受 `ssl_verify` / `ssl_ca_cert`(默认关闭),经 `_support.read_ssl_config` - 读取后由各 builder 自行翻译为客户端参数:redis `ssl_ca_certs`、elasticsearch `ca_certs`、 - postgres/pgvector `sslrootcert`(配 `sslmode=verify-full`)、milvus `server_pem_path`(配 - `secure=True`)。不做跨后端的 TLS 参数抽象层——各客户端语义切分不同,详见 - [F04-storage-ssl.md](../../docs/features/storage/F04-storage-ssl.md)。 - `SslConfig`、归一(`build_ssl_config`)与 scheme 校验(`require_tls_scheme`)住在 - `common._support`,与出站客户端共用;storage 侧只保留缺证书即报错这条自有策略。 +7. `EncryptedKVStore` 的 `raw_kv_store` / `EncryptedFSStore` 的 `inner` 不能指向自身; + 未配置该依赖时必须在装配阶段报错(给默认值只会把数据写到调用方没预期的地方)。 diff --git a/src/storage/fs_impl/__init__.py b/src/storage/fs_impl/__init__.py index 4678003f..4ffc0c56 100644 --- a/src/storage/fs_impl/__init__.py +++ b/src/storage/fs_impl/__init__.py @@ -9,5 +9,6 @@ import_module(".in_memory_fs_store", __name__) import_module(".local_fs", __name__) +import_module(".encrypted_fs_store", __name__) __all__ = ["FsProducer"] diff --git a/src/storage/fs_impl/encrypted_fs_store.py b/src/storage/fs_impl/encrypted_fs_store.py new file mode 100644 index 00000000..67a499ce --- /dev/null +++ b/src/storage/fs_impl/encrypted_fs_store.py @@ -0,0 +1,238 @@ +"""EncryptedFSStore — FSStore 加密装饰器。 + +与 ``EncryptedKVStore`` 同构:不含任何加解密算法,只在 FS 边界统一构造 +``CryptoContext`` / AAD,并委托注入的 ``CryptographyProvider``。真实算法位于 +``common.security.cryptography.cryptography_impl``。 + +文件内容整体加密成一个信封再落盘,``ref`` / ``scope`` 保持明文(路径要能寻址), +``ref`` 进 AAD。 + +**已知代价(两条,都是 AES-GCM 整块认证的直接后果)**: + +1. ``get`` 必须**读全文件到内存**再整体解密——没有跨块认证绑定就不能流式部分 + 解密。大文件(视频、模型权重)会吃内存。第一期不做 chunked encryption: + chunk 间无绑定,可被重排/截断,不适合作默认方案。 +2. :attr:`~storage.types.FileStat.size` 返回的是**密文长度**,比明文长(信封头 + + 包装后的数据密钥 + 两个 nonce + 两个 16B 的 GCM tag)。不修正——修正需要先 + 解密才能知道明文长度,代价荒谬。调用方拿它去分配缓冲区只会偏大,不影响正确性。 + +**不存在明文回退**(F05 §明文策略):不是合法信封的内容一律拒绝读取。是否允许 +未加密存储由配置选用不同的 FSStore 适配器表达,不由本装饰器或 provider 的开关表达。 +""" + +from __future__ import annotations + +import io +import json +from typing import Any, BinaryIO + +from common.errors import BackendError, ValidationError +from common.security.cryptography import CryptographyProducer, CryptographyProvider +from common.security.types import CryptoContext +from common.type_def import Scope +from storage.base import StoreType +from storage.fs import FsProducer, FSStore +from storage.types import FileStat + +_AAD_VERSION = 1 +_PURPOSE_FS_OBJECT = "fs_object" + +# 单文件明文大小硬上限。AES-GCM 整块认证要求把整个明文读入内存再加密(见模块 +# docstring 的已知代价),无上限意味着一个超大输入能把进程内存吃满。64 MiB 覆盖 +# 文本/图片/中等模型分片等记忆资产;视频/原始模型权重本就该走专用对象存储而非 +# memory 系统。chunked 加密(第一期不做)落地后可放宽。 +_DEFAULT_MAX_PLAINTEXT_BYTES = 64 * 1024 * 1024 + +# 密文上限的默认安全余量(加在明文上限上)。CryptographyProvider 的 ABC 不暴露密文 +# overhead,故不硬编码某个 provider 的精确值--用宽松余量覆盖 ENC1 信封固定开销 +# (header + 加密 data key + nonce + GCM tag ≈ 100),宁可拒偏大也不读入超大密文。 +# 需要精确控制时显式配 max_ciphertext_bytes(验收复验 P2-FS)。 +_DEFAULT_CIPHERTEXT_OVERHEAD = 4 * 1024 # 4 KiB,远大于 ~100 字节信封开销 + + +def _read_bounded_stream(stream: BinaryIO, limit: int, *, ref: str) -> bytes: + """循环有界读取:反复 ``read`` 直到 EOF 或累计达到 ``limit + 1``。 + + BinaryIO.read(n) 允许短读(返回 < n 字节而未 EOF)。单次 read 会把第一段当完整 + 内容,造成静默截断(验收复验 P2-FS 问题 1)。循环读取并在累计超过 limit 时 + 拒绝,才真正守住边界。多读 1 字节用于判定超限。 + + 用 ``bytearray`` 累积而非 ``list[bytes]`` + ``join``(验收第三次 P2-2):恶意 + 1-byte 短读会让 list 长出百万级元素 + join 拼接元数据,8 MiB 内容能放大到 ~700 MiB。 + bytearray.extend 是单个连续缓冲区,内存与内容字节数成正比,不随分片数放大。 + """ + buffer = bytearray() + while len(buffer) <= limit: + chunk = stream.read(limit + 1 - len(buffer)) + if not chunk: + break + buffer.extend(chunk) + if len(buffer) > limit: + raise ValidationError(f"fs encrypted: 内容超过单文件上限 {limit}B(ref={ref!r})") + return bytes(buffer) + + +def _scope_payload(scope: Scope) -> dict[str, str]: + return { + "org": scope.org, + "space": str(getattr(scope, "space", "")), + "user": scope.user, + "agent": scope.agent, + "session": scope.session, + } + + +def _aad(scope: Scope, ref: str) -> bytes: + payload = { + "version": _AAD_VERSION, + "scope": _scope_payload(scope), + "ref": ref, + "purpose": _PURPOSE_FS_OBJECT, + } + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def _crypto_context(scope: Scope, ref: str) -> CryptoContext: + """对象标识与格式版本走专有字段,理由同 ``EncryptedKVStore._crypto_context``。""" + return CryptoContext( + scope=scope, + purpose=_PURPOSE_FS_OBJECT, + object_id=ref, + format_version=_AAD_VERSION, + ) + + +class EncryptedFSStore(FSStore): + """对任意 FSStore 做透明加解密的装饰器。""" + + def __init__( + self, + inner: FSStore, + encryption: CryptographyProvider, + *, + max_plaintext_bytes: int, + max_ciphertext_bytes: int = 0, + ) -> None: + self._inner = inner + self._encryption = encryption + self._max_plaintext_bytes = max_plaintext_bytes + # 密文上限:默认按明文上限 + 安全余量(覆盖 ENC1 信封固定开销 + 一点 buffer), + # 可显式配置覆盖。不硬编码某个 provider 的精确开销--CryptographyProvider 的 ABC + # 不暴露 ciphertext bound,硬编码 128 会随 provider 实现变化失准(验收复验 P2-FS)。 + self._max_ciphertext_bytes = ( + max_ciphertext_bytes or max_plaintext_bytes + _DEFAULT_CIPHERTEXT_OVERHEAD + ) + + def store_type(self) -> StoreType: + return StoreType.FS + + def health(self) -> None: + self._inner.health() + self._encryption.health() + + # -- 写:永远加密 ---------------------------------------------------- # + + def insert(self, scope: Scope, key: str, data: BinaryIO) -> str: + plaintext = self._read_bounded(data, ref=key) + return self._inner.insert(scope, key, io.BytesIO(self._encrypt(scope, key, plaintext))) + + def update(self, scope: Scope, ref: str, data: BinaryIO) -> str: + plaintext = self._read_bounded(data, ref=ref) + return self._inner.update(scope, ref, io.BytesIO(self._encrypt(scope, ref, plaintext))) + + # -- 读:解密 -------------------------------------------------------- # + + def get(self, scope: Scope, ref: str) -> BinaryIO: + # stat 只作快速早拒(避免无谓打开超大对象);它不是唯一边界--stat 与随后 get + # 之间内容可能变化(TOCTOU),故真正读取仍用有界循环(验收复验 P2-FS)。 + stat = self._inner.stat(scope, ref) + if stat.size > self._max_ciphertext_bytes: + raise ValidationError( + f"fs encrypted: 密文 {stat.size}B 超过单文件上限 " + f"{self._max_ciphertext_bytes}B(ref={ref!r})" + ) + with self._inner.get(scope, ref) as fh: + stored = _read_bounded_stream(fh, self._max_ciphertext_bytes, ref=ref) + plaintext = self._decrypt(scope, ref, stored) + # 解密后复核明文上限:密文长度通过不代表明文通过(密文可被替换成另一个合法但 + # 解压后超大的信封,或 stat/get 不一致时绕过了上面的早拒)。 + if len(plaintext) > self._max_plaintext_bytes: + raise ValidationError( + f"fs encrypted: 解密后明文 {len(plaintext)}B 超过单文件上限 " + f"{self._max_plaintext_bytes}B(ref={ref!r})" + ) + return io.BytesIO(plaintext) + + # -- 不涉加解密的纯转发 ---------------------------------------------- # + + def delete(self, scope: Scope, ref: str) -> None: + self._inner.delete(scope, ref) + + def stat(self, scope: Scope, ref: str) -> FileStat: + # size 是密文长度,见模块 docstring。 + return self._inner.stat(scope, ref) + + # -- 内部 ------------------------------------------------------------ # + + def _read_bounded(self, data: BinaryIO, *, ref: str) -> bytes: + """有界读取明文:循环 read 直到 EOF 或累计达到 limit+1。 + + 验收复验 P2-FS:单次 ``read(limit+1)`` 不等于「读到 EOF 或上限」--BinaryIO + 允许短读(返回 < n 字节而未 EOF),单次调用会把第一段当完整文件,造成静默 + 数据截断。循环读取并在超限时拒绝,才能真正守住边界。 + """ + return _read_bounded_stream(data, self._max_plaintext_bytes, ref=ref) + + def _encrypt(self, scope: Scope, ref: str, plaintext: bytes) -> bytes: + try: + return self._encryption.encrypt( + plaintext, + context=_crypto_context(scope, ref), + aad=_aad(scope, ref), + ) + except Exception as exc: + raise BackendError(f"fs encryption failed: ref={ref!r}") from exc + + def _decrypt(self, scope: Scope, ref: str, ciphertext: bytes) -> bytes: + try: + return self._encryption.decrypt( + ciphertext, + context=_crypto_context(scope, ref), + aad=_aad(scope, ref), + ) + except Exception as exc: + raise BackendError(f"fs decryption failed: ref={ref!r}") from exc + + +def _inner_store(config: Any) -> FSStore: + """取被包住的 Store。无默认值——加密装饰器必须显式指明包住哪个 Store, + 猜一个默认后端只会把数据写到调用方没预期的地方(理由同 EncryptedKVStore)。 + """ + inner = config.params.get("inner") + if inner is None: + raise ValidationError("fs_store.encrypted params.inner 必须配置") + if isinstance(inner, str) and inner == config.name: + raise ValidationError("fs_store.encrypted params.inner 不能指向自身") + return FsProducer.dep(config, "inner") + + +@FsProducer.register("encrypted") +def _build(config): + max_plaintext_bytes = int( + config.params.get("max_plaintext_bytes", _DEFAULT_MAX_PLAINTEXT_BYTES) + ) + if max_plaintext_bytes < 1: + raise ValidationError( + f"fs_store.encrypted params.max_plaintext_bytes 须 >= 1,得到 {max_plaintext_bytes}" + ) + max_ciphertext_bytes = int(config.params.get("max_ciphertext_bytes", 0)) + if max_ciphertext_bytes < 0: + raise ValidationError( + f"fs_store.encrypted params.max_ciphertext_bytes 须 >= 0,得到 {max_ciphertext_bytes}" + ) + return EncryptedFSStore( + inner=_inner_store(config), + encryption=CryptographyProducer.dep(config), + max_plaintext_bytes=max_plaintext_bytes, + max_ciphertext_bytes=max_ciphertext_bytes, + ) diff --git a/src/storage/kv_impl/encrypted_kv_store.py b/src/storage/kv_impl/encrypted_kv_store.py index 51a9475a..36f1bf40 100644 --- a/src/storage/kv_impl/encrypted_kv_store.py +++ b/src/storage/kv_impl/encrypted_kv_store.py @@ -1,9 +1,9 @@ """EncryptedKVStore — KVStore 加密装饰器。 该实现不包含具体加解密算法,只在 KV 边界统一构造 -``SecurityContext`` / AAD,并委托注入的 ``SecurityProvider``。真实算法位于 -``common.security.security_impl``;本类只负责把所有 KV value 的写前加密、读后解密 -收敛到同一个存储装饰器。 +``CryptoContext`` / AAD,并委托注入的 ``CryptographyProvider``。真实算法位于 +``common.security.cryptography.cryptography_impl``;本类只负责把所有 KV value 的 +写前加密、读后解密收敛到同一个存储装饰器。 """ from __future__ import annotations @@ -12,7 +12,8 @@ from typing import Any from common.errors import BackendError, ValidationError -from common.security import SecurityContext, SecurityProducer, SecurityProvider +from common.security.cryptography import CryptographyProducer, CryptographyProvider +from common.security.types import CryptoContext from common.type_def import MEMORY_KEY_PREFIX, MESSAGES_KEY_PREFIX, FilterExpr, Scope from storage.base import StoreType from storage.kv import KvProducer, KVStore @@ -54,30 +55,33 @@ def _aad(scope: Scope, key: str, purpose: str) -> bytes: return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") -def _security_context(scope: Scope, key: str, purpose: str) -> SecurityContext: - return SecurityContext( +def _crypto_context(scope: Scope, key: str, purpose: str) -> CryptoContext: + """对象标识与格式版本走 :class:`CryptoContext` 的专有字段,不再塞 metadata。 + + 它们是 F05 §信封格式要求 AAD 必须绑定的项,由类型显式承载才能保证每个调用点 + 都带上——放 metadata 里漏掉一个不会有任何提示。 + """ + return CryptoContext( scope=scope, purpose=purpose, - metadata={ - "key": key, - "aad_version": str(_AAD_VERSION), - }, + object_id=key, + format_version=_AAD_VERSION, ) class EncryptedKVStore(KVStore): """对任意 KVStore 做透明加解密的装饰器。""" - def __init__(self, raw: KVStore, security: SecurityProvider) -> None: + def __init__(self, raw: KVStore, encryption: CryptographyProvider) -> None: self._raw = raw - self._security = security + self._encryption = encryption def store_type(self) -> StoreType: return StoreType.KV def health(self) -> None: self._raw.health() - self._security.health() + self._encryption.health() def insert(self, scope: Scope, key: str, value: bytes, ttl: float = 0.0) -> None: self._raw.insert(scope, key, self._encrypt(scope, key, value), ttl=ttl) @@ -133,25 +137,21 @@ def scopes(self) -> list[Scope]: def _encrypt(self, scope: Scope, key: str, plaintext: bytes) -> bytes: purpose = _purpose_for_key(key) - context = _security_context(scope, key, purpose) + context = _crypto_context(scope, key, purpose) aad = _aad(scope, key, purpose) try: - return self._security.encrypt(plaintext, context=context, aad=aad) + return self._encryption.encrypt(plaintext, context=context, aad=aad) except Exception as exc: - raise BackendError( - f"kv encryption failed: key={key!r} purpose={purpose!r}" - ) from exc + raise BackendError(f"kv encryption failed: key={key!r} purpose={purpose!r}") from exc def _decrypt(self, scope: Scope, key: str, ciphertext: bytes) -> bytes: purpose = _purpose_for_key(key) - context = _security_context(scope, key, purpose) + context = _crypto_context(scope, key, purpose) aad = _aad(scope, key, purpose) try: - return self._security.decrypt(ciphertext, context=context, aad=aad) + return self._encryption.decrypt(ciphertext, context=context, aad=aad) except Exception as exc: - raise BackendError( - f"kv decryption failed: key={key!r} purpose={purpose!r}" - ) from exc + raise BackendError(f"kv decryption failed: key={key!r} purpose={purpose!r}") from exc def _raw_kv_store(config: Any) -> KVStore: @@ -167,5 +167,5 @@ def _raw_kv_store(config: Any) -> KVStore: def _build(config): return EncryptedKVStore( raw=_raw_kv_store(config), - security=SecurityProducer.dep(config), + encryption=CryptographyProducer.dep(config), ) diff --git a/src/storage/kv_impl/in_memory_kv_store.py b/src/storage/kv_impl/in_memory_kv_store.py index 315f5b28..9b1ffccc 100644 --- a/src/storage/kv_impl/in_memory_kv_store.py +++ b/src/storage/kv_impl/in_memory_kv_store.py @@ -30,9 +30,7 @@ class InMemoryKVStore(KVStore): """纯内存键值存储:``{scope: {key: (value, expires_at)}}``,按 scope 隔离。""" def __init__(self) -> None: - self._data: dict[_ScopeKey, dict[str, tuple[bytes, float | None]]] = ( - defaultdict(dict) - ) + self._data: dict[_ScopeKey, dict[str, tuple[bytes, float | None]]] = defaultdict(dict) def store_type(self) -> StoreType: return StoreType.KV @@ -117,8 +115,7 @@ def list( def scopes(self) -> list[Scope]: return [ - Scope(org=k[0], space=k[1], user=k[2], agent=k[3], session=k[4]) - for k in self._data + Scope(org=k[0], space=k[1], user=k[2], agent=k[3], session=k[4]) for k in self._data ] diff --git a/src/storage/kv_impl/memory_list.py b/src/storage/kv_impl/memory_list.py index f0a2f8a7..a68b6b43 100644 --- a/src/storage/kv_impl/memory_list.py +++ b/src/storage/kv_impl/memory_list.py @@ -47,7 +47,8 @@ def list_memory_entries( matches.append((key, raw, unit)) matches.sort(key=lambda item: _sort_key(item[2]), reverse=True) count = len(matches) - page = matches[offset:offset + limit] + page_end = offset + limit + page = matches[offset:page_end] return KVMemoryListResult( entries=[(key, raw) for key, raw, _ in page], count=count, diff --git a/src/storage/kv_impl/redis_kv.py b/src/storage/kv_impl/redis_kv.py index 56e038c7..90b58a8e 100644 --- a/src/storage/kv_impl/redis_kv.py +++ b/src/storage/kv_impl/redis_kv.py @@ -88,9 +88,7 @@ def client(self) -> Any: try: import redis except ImportError as exc: # 依赖缺失归一为后端不可用 - raise BackendError( - "redis client not installed (pip install redis)" - ) from exc + raise BackendError("redis client not installed (pip install redis)") from exc with wrap_backend("redis connect"): if url: # url 里的 query 参数优先级高于此处 kwargs(redis-py 解析顺序所致)。 @@ -111,11 +109,9 @@ def _px(ttl: float) -> int | None: return int(ttl * 1000) if ttl and ttl > 0 else None def store_type(self) -> StoreType: - """返回存储类型 ``KV``。""" return StoreType.KV def health(self) -> None: - """对 Redis 执行 ``PING``;失败抛 :class:`HealthCheckError`。""" try: ok = self.client.ping() except Exception as exc: @@ -124,7 +120,6 @@ def health(self) -> None: raise HealthCheckError("redis ping returned falsy") def insert(self, scope: Scope, key: str, value: bytes, ttl: float = 0.0) -> None: - """在 ``scope`` 下新建 ``key``;已存在时报冲突。""" nk = self._namespaced(scope, key) with wrap_backend(f"redis insert {key!r}"): ok = self.client.set(nk, value, nx=True, px=self._px(ttl)) @@ -132,7 +127,6 @@ def insert(self, scope: Scope, key: str, value: bytes, ttl: float = 0.0) -> None raise ConflictError(entity="key", key=key) def update(self, scope: Scope, key: str, value: bytes, ttl: float = 0.0) -> None: - """覆写 ``scope`` 下已有 ``key``;不存在时报缺失。""" nk = self._namespaced(scope, key) with wrap_backend(f"redis update {key!r}"): ok = self.client.set(nk, value, xx=True, px=self._px(ttl)) @@ -140,12 +134,10 @@ def update(self, scope: Scope, key: str, value: bytes, ttl: float = 0.0) -> None raise NotFoundError(entity="key", key=key) def delete(self, scope: Scope, key: str) -> None: - """删除 ``scope`` 下的 ``key``(幂等)。""" with wrap_backend(f"redis delete {key!r}"): self.client.delete(self._namespaced(scope, key)) # 幂等 def get(self, scope: Scope, key: str) -> bytes: - """读取 ``scope`` 下 ``key`` 的值;不存在时报缺失。""" with wrap_backend(f"redis get {key!r}"): value = self.client.get(self._namespaced(scope, key)) if value is None: @@ -169,22 +161,21 @@ def mget(self, scope: Scope, keys: list[str]) -> list[bytes]: return out def exists(self, scope: Scope, key: str) -> bool: - """返回 ``scope`` 下 ``key`` 是否存在。""" with wrap_backend(f"redis exists {key!r}"): return self.client.exists(self._namespaced(scope, key)) > 0 def scan(self, scope: Scope, prefix: str = "") -> list[tuple[str, bytes]]: - """扫描 ``scope`` 下全部 ``(key, value)``(可选 ``prefix`` 过滤)。""" ns = ":".join(scope_segments(scope)) + ":" # 该 scope 的命名空间前缀 with wrap_backend(f"redis scan {prefix!r}"): keys = list(self.client.scan_iter(match=f"{ns}{prefix}*")) values = self.client.mget(keys) if keys else [] out: list[tuple[str, bytes]] = [] + prefix_len = len(ns) for raw, value in zip(keys, values): if value is None: # scan 与 mget 之间过期/删除 continue k = raw.decode("utf-8") if isinstance(raw, bytes) else raw - out.append((k[len(ns):], value)) # 去掉命名空间前缀还原逻辑 key + out.append((k[prefix_len:], value)) # 去掉命名空间前缀还原逻辑 key return out def list( @@ -197,7 +188,6 @@ def list( filters: FilterExpr | None = None, extensions: dict[str, str] | None = None, ) -> KVMemoryListResult: - """按记忆列表协议分页枚举 ``scope`` 下条目。""" return list_memory_entries( self.scan(scope, MEMORY_KEY_PREFIX), offset=offset, @@ -208,7 +198,6 @@ def list( ) def scopes(self) -> list[Scope]: - """枚举本存储中已用过的全部 scope(命名空间)。""" seen: set[tuple[str, str, str, str, str]] = set() with wrap_backend("redis scopes"): for raw in self.client.scan_iter(match="*"): @@ -247,9 +236,7 @@ def _build(config): ssl = read_ssl_config(config, backend="redis KV") options: dict[str, Any] = {} if ssl.verify: - require_tls_scheme( - url, expected="rediss", component="redis KV", param="params.url" - ) + require_tls_scheme(url, expected="rediss", component="redis KV", param="params.url") reject_url_tls_params(url, backend="redis KV", param="url") options["ssl_ca_certs"] = ssl.ca_cert return RedisKVStore( diff --git a/src/storage/kv_impl/sqlite_kv_store.py b/src/storage/kv_impl/sqlite_kv_store.py index 91d9b86c..f63b8089 100644 --- a/src/storage/kv_impl/sqlite_kv_store.py +++ b/src/storage/kv_impl/sqlite_kv_store.py @@ -64,9 +64,7 @@ def _expiry(ttl: float) -> float | None: return time.time() + ttl if ttl else None def _migrate_schema(self) -> None: - columns = { - row[1] for row in self._conn.execute("PRAGMA table_info(kv)").fetchall() - } + columns = {row[1] for row in self._conn.execute("PRAGMA table_info(kv)").fetchall()} if not columns or "space" in columns: return self._conn.execute("ALTER TABLE kv RENAME TO kv_legacy") diff --git a/tests/conftest.py b/tests/conftest.py index 1b7cbff4..7f81f488 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,7 +13,10 @@ KeywordFeatureExtractor, ) from common.reranker.reranker_impl.overlap_reranker import OverlapReranker +from common.security.request_context import new_request_context +from common.security.types import AuthContext, RequestSecurityContext, Role, Surface from common.tokenizer.tokenizer_impl.whitespace_tokenizer import WhitespaceTokenizer +from common.type_def import memory_key from common.type_def.memory import ( LifecycleState, MemoryTier, @@ -22,7 +25,6 @@ Segment, Temporal, ) -from common.type_def import memory_key from common.type_def.memory_codec import dumps from common.type_def.scope import Scope from retrieval.discloser_impl.truncating_discloser import TruncatingDiscloser @@ -39,6 +41,35 @@ DEFAULT_SCOPE = Scope(org="acme", user="u1", agent="a1", session="s1") +#: 具名 ROOT 主体。管理面测试用它,**不用**空 ``Scope()``——空 actor 现在是 +#: 「上下文不完整」的信号,PDP 对它直接拒(S08 不变量 21)。 +ROOT_ACTOR = Scope(org="system", user="root") + + +def sec( + actor: Scope, + *, + role: Role = Role.USER, + surface: Surface = Surface.SDK, +) -> RequestSecurityContext: + """把一个 actor Scope 包成测试用的 ``RequestSecurityContext``。 + + 测试要表达的几乎总是「谁在调用」,而 ``RequestSecurityContext`` 还带着 + request_id、started_at、surface 这些由服务端产生的字段。走 + ``new_request_context`` 而不是直接构造,是为了让测试和生产路径用同一个 + 受控入口——那三个字段的产生规则只有一处实现(S08 不变量 32)。 + + ``role`` 默认 ``USER``:管理面权限必须由用例显式声明 + ``role=Role.ROOT`` 才拿得到,默认给 ROOT 会让「谁能碰管理面」这条断言 + 在所有用例里静默失效。 + """ + return new_request_context(AuthContext(actor=actor, role=role), surface=surface) + + +def root_sec(actor: Scope = ROOT_ACTOR) -> RequestSecurityContext: + """管理面测试用的 ROOT 安全上下文。ROOT 由 ``role`` 表达,不由 actor 形状表达。""" + return sec(actor, role=Role.ROOT) + @dataclass class RetrievalWorld: diff --git a/tests/integration/test_identity_forgery_rejected.py b/tests/integration/test_identity_forgery_rejected.py new file mode 100644 index 00000000..830e785b --- /dev/null +++ b/tests/integration/test_identity_forgery_rejected.py @@ -0,0 +1,221 @@ +"""身份伪造必须被拒——第一期唯一改变系统安全性的回归防线。 + +改动前的行为(已复现):``handler._actor_scope`` 从 payload 读 +``actor_tenant_id`` / ``actor_scope``,任何调用方声明 ``actor_scope: "alice"`` +即可读到 alice 的记忆;声明空值即可拿到空 ``Scope()``,命中 +``SQLitePermissionManager.check`` 的 platform-admin 全局放行。 + +改动后:身份只来自认证层产出的 ``AuthContext``(security.md §9 铁律 #1), +payload 里出现身份声明字段一律 400。F05 迁移后它进一步显式化——身份由 +``RequestSecurityContext`` 作为 ``dispatch`` 的参数传入,ContextVar 只剩 +日志/trace 用途,故本文件的 ``set_current`` 布置一并换成显式传参。 + +本文件测的是**跨 bootstrap 与 src 的完整链路**(认证中间件 → dispatch → +Authorizer),故落 integration 而非 unit。 +""" + +from __future__ import annotations + +import os +import sys + +import pytest + +# bootstrap/core 是 flat import root(server.py / handler.py / profiles.py), +# 不是包;与 http_server/cli surface 用同样的方式接进来。 +_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_CORE_DIR = os.path.join(_ROOT, "bootstrap", "core") +if _CORE_DIR not in sys.path: + sys.path.append(_CORE_DIR) + +from common.bootstrap import register_plugins # noqa: E402 +from common.errors import AuthenticationError # noqa: E402 +from common.security.authentication.key_store import KeyStoreProducer # noqa: E402 +from common.security.types import Role # noqa: E402 +from common.type_def.scope import Scope # noqa: E402 +from config.context import AssemblyContext # noqa: E402 +from tests.conftest import sec # noqa: E402 + +pytestmark = pytest.mark.integration + +_ALICE = Scope(org="acme", user="alice") +_MALLORY = Scope(org="acme", user="mallory") + + +@pytest.fixture(scope="module") +def srv(): + """一个装配好的进程内 Server(OFFLINE profile,纯内存栈)。""" + import server + from profiles import OFFLINE, load_config + + return server.build(load_config([OFFLINE])) + + +@pytest.fixture +def api_key_srv(): + """API Key Runtime 与内核撤销注册表共享同一装配图。""" + import server + from profiles import OFFLINE, load_config + + return server.build( + load_config( + [ + OFFLINE, + { + "memory_api": { + "security": { + "default": { + "target": "standard", + "params": { + "authenticator": { + "target": "api_key", + "params": { + "root_api_key": "", + "key_store": {"target": "memory"}, + }, + } + }, + } + } + } + }, + ] + ) + ) + + +def _dispatch(srv, verb, payload, security=None): + from handler import dispatch + + return dispatch(srv, verb, payload, security) + + +# -- 核心:payload 不再能声明身份 ------------------------------------------- # + + +def test_claimed_identity_in_payload_is_rejected(srv) -> None: + """曾经的越权路径:mallory 声明 ``actor_scope: alice`` 读到了 alice 的数据。 + + 现在这类字段一律 400——**静默忽略是不够的**:运维会以为 + 「我传了 actor_scope」仍然生效,写出错误的安全认知。 + """ + for forged in ( + {"actor_scope": "alice"}, + {"actor_tenant_id": "acme", "actor_scope": "alice"}, + {"actor_tenant_id": " "}, # 曾经命中 platform-admin 全局放行 + {"actor_agent": "bot"}, + {"actor_session": "s1"}, + ): + payload = {"tenant_id": "acme", "scope": "alice", "item_id": "x", **forged} + status, body = _dispatch(srv, "get", payload, sec(_MALLORY)) + assert status == 400, f"{forged} → {status} {body}" + assert body["error"] == "ValidationError" + + +def test_identity_comes_from_context_not_payload(srv) -> None: + """同一个 payload,安全上下文不同 → 授权结果不同。 + + 这条直接钉死「身份来自上下文」:payload 一字未改,只换了 security, + alice 能读、mallory 不能。 + """ + status, body = _dispatch( + srv, + "add", + {"tenant_id": "acme", "scope": "alice", "content": "alice salary 999"}, + sec(_ALICE), + ) + assert status == 200, body + item_id = body["item_id"] + + payload = {"tenant_id": "acme", "scope": "alice", "item_id": item_id} + + assert _dispatch(srv, "get", payload, sec(_ALICE))[0] == 200 + + status, body = _dispatch(srv, "get", payload, sec(_MALLORY)) + assert status == 403, body + + +def test_no_context_fails_closed(srv) -> None: + """中间件漏挂时必须 401,绝不回退到 payload 或默认身份。 + + 这是 fail-closed 的落点:一个装配错误应该让所有请求失败, + 而不是让所有请求以未知身份成功。 + """ + status, body = _dispatch(srv, "get", {"tenant_id": "acme", "scope": "alice", "item_id": "x"}) + assert status == 401, body + assert body["error"] == "AuthenticationError" + + +# -- 认证与授权确实串起来了 --------------------------------------------------- # + + +def test_api_key_binds_identity_end_to_end(api_key_srv) -> None: + """用 A 主体的 key 去读 B 主体的数据 → 403(不是 200,也不是 401)。 + + 401 说明认证没过(key 无效),403 说明认证过了但授权拒了。 + 这条要的是后者——证明 key → AuthContext → RequestSecurityContext → Authorizer + 整条链通了。``authenticated`` yield 的正是要传给 dispatch 的那个上下文。 + """ + from common.security.types import Credentials + + auth = api_key_srv.security.authenticator + store = auth.key_store + alice_key = store.issue(_ALICE, Role.USER) + mallory_key = store.issue(_MALLORY, Role.USER) + + from auth_middleware import authenticated + + with authenticated(auth, Credentials(api_key=alice_key)) as security: + status, body = _dispatch( + api_key_srv, + "add", + {"tenant_id": "acme", "scope": "alice", "content": "key-bound secret"}, + security, + ) + assert status == 200, body + item_id = body["item_id"] + + payload = {"tenant_id": "acme", "scope": "alice", "item_id": item_id} + + with authenticated(auth, Credentials(api_key=alice_key)) as security: + assert _dispatch(api_key_srv, "get", payload, security)[0] == 200 + + with authenticated(auth, Credentials(api_key=mallory_key)) as security: + assert _dispatch(api_key_srv, "get", payload, security)[0] == 403 + + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="not-a-real-key")): + pass # pragma: no cover - authenticate 在进入 with 体之前就抛了 + + +def test_context_is_reset_after_failed_authentication(srv) -> None: + """认证失败后不得留下任何可被下一个请求继承的身份。 + + `ThreadingHTTPServer` 每请求一线程但线程可能被复用,这是最严重的一类越权。 + 身份改为显式传参后这条更强了:没有 ``authenticated`` 就没有 security 可传, + dispatch 只能 401——不存在「残留态」这个概念。ContextVar 的 reset 仍在 + ``authenticated`` 的 ``finally`` 里,只是不再是授权的依据。 + """ + from auth_middleware import authenticated + + from common.security.authentication.authentication_impl.api_key_authenticator import ( + ApiKeyAuthenticator, + ) + from common.security.types import Credentials + + register_plugins() + store = KeyStoreProducer.build("memory", {}, AssemblyContext()) + alice_key = store.issue(_ALICE, Role.USER) + auth = ApiKeyAuthenticator(key_store=store, root_api_key="") + + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="wrong")): + pass # pragma: no cover + + # 失败之后仍应是「无身份」,而不是残留上一次的 + assert _dispatch(srv, "get", {"tenant_id": "acme", "scope": "alice", "item_id": "x"})[0] == 401 + + with authenticated(auth, Credentials(api_key=alice_key)) as security: + assert security.auth.actor == _ALICE + + assert _dispatch(srv, "get", {"tenant_id": "acme", "scope": "alice", "item_id": "x"})[0] == 401 diff --git a/tests/unit/api/test_authorization_with_auth_context.py b/tests/unit/api/test_authorization_with_auth_context.py new file mode 100644 index 00000000..4bd91725 --- /dev/null +++ b/tests/unit/api/test_authorization_with_auth_context.py @@ -0,0 +1,108 @@ +"""安全上下文经 PEP 抵达 PDP(F05 §Authorization / §PEP-PDP 分离)。 + +`tests/unit/common/security/authorization/` 覆盖的是 PDP 自身的判定规则;本文件覆盖 +**接线**:`LocalMemoryAPI._authorize` 是否真的把调用方传进来的 `RequestSecurityContext` +里的 `AuthContext` 交给 `Authorizer.authorize`。两者缺一不可——PDP 判得再对,PEP 不传 +就等于没做。 + +F05 迁移后身份不再经 ContextVar 注入,`security` 是 API 的显式参数,所以本文件原先的 +`_as()` 布置全部消失:角色写在 `sec(..., role=...)` 里,判定依据在调用点就读得全。 + +一并删除的还有 `test_identity_must_match_the_authenticated_actor`——它钉的是「`identity` +参数与 ContextVar 里的 `auth` 不一致要拒」。迁移后只剩 `security` 一个身份入口, +「两者不一致」这个状态构造不出来,规则也就无从违反。 +""" + +from __future__ import annotations + +import pytest + +from api.memory_api_impl import build_kernel +from common.errors import PermissionDeniedError +from common.security.types import Role +from common.type_def import Scope +from control.types import Action, Grant +from tests.conftest import root_sec, sec + +pytestmark = pytest.mark.unit + +_ALICE = Scope(org="acme", user="alice") +_BOB = Scope(org="acme", user="bob") + + +@pytest.fixture() +def api(): + return build_kernel().api + + +def test_promoted_root_reaches_admin_plane(api) -> None: + """一个绑了具体 org/user 的 ROOT 能用管理面。 + + 接线前它做不到:`_authorize` 只传 `identity`,PDP 看到的是个普通 alice, + 而管理面的鉴权 target 是空 `Scope()`——跨 org 直接拒。F05 明写两种 ROOT + 「在运行时权限检查中等价」,这条就是那句话的可执行形式。 + """ + api.admin_set("rerank.enabled", "false", security=sec(_ALICE, role=Role.ROOT)) + assert api.admin_get("rerank.enabled", security=sec(_ALICE, role=Role.ROOT)) == "false" + + +def test_plain_user_cannot_reach_admin_plane(api) -> None: + with pytest.raises(PermissionDeniedError): + api.admin_set("rerank.enabled", "false", security=sec(_ALICE)) + + +def test_admin_role_is_not_enough_for_admin_plane(api) -> None: + """ADMIN 够不到 `ADMINISTER_SYSTEM`——那条动作的 `_MINIMUM_ROLE` 是 ROOT。 + + 这条断言的是**当前**的最小角色表,不是终局设计。若哪天把系统级管理面下放给 + ADMIN,改动会撞在这里,那正是它存在的意义。 + """ + with pytest.raises(PermissionDeniedError): + api.admin_set("rerank.enabled", "false", security=sec(_ALICE, role=Role.ADMIN)) + + +def test_agent_cannot_reach_a_user_scope(api) -> None: + """agent 够不到 user 的 scope——代操作不再由认证产物直接表达。 + + 这里原有三条用例,钉的是 ``AuthContext.acting_user`` 触发的端到端代操作:agent + 带着一个 user 名就能读写那个 user 的 scope。该字段与判定路径已删除,因为 header + 里的一个 user 名证明不了那个 user 真的授权过(F05 §从 header 直接产生 Delegation)。 + + 委托的端到端形态由 ``DelegationStore`` 复核 ``delegation_id`` 重建,认证产物里 + 的一个名字不再是依据。此刻的正确行为就是拒。 + """ + agent = Scope(org="acme", agent="assistant") + + with pytest.raises(PermissionDeniedError): + api.write("代 alice 记下的内容", _ALICE, security=sec(agent)) + with pytest.raises(PermissionDeniedError): + api.write("越权写 bob", _BOB, security=sec(agent)) + + +def test_agent_cannot_grant_on_another_principals_behalf(api) -> None: + """agent 对 alice 的 scope 发 SHARE 应 403,且不产生任何授权记录。 + + 否则 eve 会凭空拿到 alice 的长期读权限。原用例走的是「持 alice 委托的 agent」, + 委托来源换成 DelegationStore 之后这条断言仍然成立,且理由更简单:agent 根本够不到 + alice 的 scope。 + """ + agent = Scope(org="acme", agent="assistant") + eve = Scope(org="acme", user="eve") + grant = Grant(grantor=_ALICE, grantee=eve, actions=[Action.READ]) + + with pytest.raises(PermissionDeniedError): + api.grant(grant, security=sec(agent)) + + # grant 未执行:eve 拿不到 alice 的任何权限 + with pytest.raises(PermissionDeniedError): + api.get("anything", _ALICE, security=sec(eve)) + + +def test_named_root_reaches_admin_plane_without_a_surface(api) -> None: + """直接调 `build_kernel` 的路径(脚本、后台 job、examples)照样走同一套判定。 + + 它们不经过任何 surface 的认证中间件,但仍要自己给出 `RequestSecurityContext`: + ROOT 由 `role` 表达,不由空 `Scope()` 这个形状表达——空 actor 现在是 + 「上下文不完整」的信号,PDP 对它直接拒。 + """ + assert api.admin_get("rerank.enabled", security=root_sec()) is not None diff --git a/tests/unit/api/test_batch_handler.py b/tests/unit/api/test_batch_handler.py index 1f206007..32a73e30 100644 --- a/tests/unit/api/test_batch_handler.py +++ b/tests/unit/api/test_batch_handler.py @@ -9,6 +9,7 @@ from api.memory_api_impl import build_kernel from bootstrap.core import handler from control import BatchWriteItem, BatchWriteOutcome, BatchWriteResult +from tests.conftest import sec pytestmark = pytest.mark.unit @@ -41,6 +42,7 @@ def __init__(self) -> None: def test_batch_add_maps_defaults_item_scope_and_actor() -> None: srv = _Server() + security = sec(handler.Scope(org="acme", space="product", user="writer")) status, body = handler.dispatch( srv, @@ -54,7 +56,6 @@ def test_batch_add_maps_defaults_item_scope_and_actor() -> None: "stream_id": "session-1", "occurred_at": "2026-08-05T10:00:00+00:00", }, - "actor_scope": "writer", "items": [ {"content": "first", "sequence": 1}, { @@ -66,13 +67,14 @@ def test_batch_add_maps_defaults_item_scope_and_actor() -> None: }, ], }, + security, ) assert status == 200, body assert body["ok"] is True assert [outcome["input"]["content"] for outcome in body["outcomes"]] == ["first", "second"] call = srv.api.calls[0] - assert call["identity"] == handler.Scope(org="acme", space="product", user="writer") + assert call["security"] is security assert call["items"][0].scope == handler.Scope(org="acme", space="product", user="alice") assert call["items"][1].scope == handler.Scope(org="acme", space="product", user="bob") assert call["items"][1].source == handler.Modality.CODE @@ -91,6 +93,7 @@ def test_batch_add_null_item_tenant_inherits_default_scope() -> None: "defaults": {"tenant_id": "acme", "scope": "alice"}, "items": [{"content": "remember", "target_scope": {"tenant_id": None}}], }, + sec(handler.Scope(org="acme", user="alice")), ) assert status == 200, body @@ -102,6 +105,7 @@ def test_batch_add_returns_structured_outcome_for_malformed_item() -> None: _Server(), "batch_add", {"defaults": {"tenant_id": "acme"}, "items": ["invalid"]}, + sec(handler.Scope(org="acme", user="alice")), ) assert status == 200, body @@ -121,6 +125,7 @@ def __init__(self) -> None: "defaults": {"tenant_id": "acme", "scope": "alice"}, "items": ["invalid", {"content": "valid"}], }, + sec(handler.Scope(org="acme", user="alice")), ) assert status == 200, body @@ -147,6 +152,7 @@ def __init__(self) -> None: "defaults": {"tenant_id": "acme", "scope": "alice"}, "items": [item, {"content": "valid"}], }, + sec(handler.Scope(org="acme", user="alice")), ) assert status == 200, body @@ -162,6 +168,7 @@ def test_batch_add_invalid_default_occurred_at_returns_validation_error() -> Non "defaults": {"tenant_id": "acme", "occurred_at": "not-a-datetime"}, "items": [{"content": "remember"}], }, + sec(handler.Scope(org="acme", user="alice")), ) assert status == 400 diff --git a/tests/unit/api/test_batch_write.py b/tests/unit/api/test_batch_write.py index e7e69304..c3c81f74 100644 --- a/tests/unit/api/test_batch_write.py +++ b/tests/unit/api/test_batch_write.py @@ -8,6 +8,7 @@ from common.type_def import Modality, Scope from config import Config from control import BatchWriteItem +from tests.conftest import sec pytestmark = pytest.mark.unit @@ -28,7 +29,7 @@ def test_batch_write_normalizes_defaults_and_preserves_input_order() -> None: ), ], scope, - identity=scope, + security=sec(scope), tags=["shared"], metadata={"project": "batch", "priority": 1}, stream_id="import-1", @@ -52,7 +53,7 @@ def test_batch_write_collects_item_validation_errors_and_continues() -> None: BatchWriteItem(content="valid"), ], scope, - identity=scope, + security=sec(scope), ) assert result.outcomes[0].error_type == "ValidationError" @@ -70,7 +71,7 @@ def test_batch_write_fail_fast_marks_remaining_items_skipped() -> None: BatchWriteItem(content="not-written"), ], scope, - identity=scope, + security=sec(scope), continue_on_error=False, ) @@ -85,7 +86,7 @@ def test_batch_write_rejects_duplicate_sequence_within_scope_and_stream() -> Non result = api.batch_write( [BatchWriteItem(content="first", sequence=1), BatchWriteItem(content="second", sequence=1)], scope, - identity=scope, + security=sec(scope), stream_id="import-1", ) @@ -103,7 +104,7 @@ def test_batch_write_authorizes_each_item_without_blocking_later_owner_item() -> BatchWriteItem(content="denied", scope=owner), BatchWriteItem(content="allowed", scope=reader), ], - identity=reader, + security=sec(reader), ) assert result.outcomes[0].error_type == "PermissionDeniedError" @@ -115,7 +116,7 @@ def test_batch_write_async_matches_synchronous_result_shape() -> None: scope = Scope(org="acme", user="alice") result = asyncio.run( - api.batch_write_async([BatchWriteItem(content="async")], scope, identity=scope) + api.batch_write_async([BatchWriteItem(content="async")], scope, security=sec(scope)) ) assert len(result.outcomes) == 1 diff --git a/tests/unit/api/test_build_kernel_config.py b/tests/unit/api/test_build_kernel_config.py index 966a2b7b..955ff08d 100644 --- a/tests/unit/api/test_build_kernel_config.py +++ b/tests/unit/api/test_build_kernel_config.py @@ -2,7 +2,9 @@ 验证:默认(无 config)走离线进程内缺省;config 覆盖某具名实例的 target 时改用该实现;未注册的 target 在 build 阶段报错;顶层段名拼错在解析期报错;具名实例经 ``build_named`` 共享单例。 -以控制层 permission + 存储层 vector_store 作可观测点。 +以安全层 authorizer + 存储层 vector_store 作可观测点--授权判定与 grant/revoke 真源都已 +迁到 ``common.security.authorization``(Authorizer 经 ``management_grant_store()`` 向 PEP +共享 ``GrantStore``),``permission`` 段不再被 PEP 引用,覆盖它观测不到判定或授权变化。 """ from __future__ import annotations @@ -14,14 +16,23 @@ from common.audit.base import AuditProducer from common.errors import PermissionDeniedError, ValidationError from common.factory.factory import Factory +from common.security.authorization.base import ( + AuthorizationDecision, + AuthorizationProducer, + Authorizer, +) +from common.security.types import ( + AuthContext, + AuthorizationEnvironment, + DenyReason, + ResourceDescriptor, +) from common.type_def import Context, Scope from config import Config from config.context import AssemblyContext from config.defaults import default_config_dict -from control.base import ControlOperatorType -from control.permission import PermissionManager, PermissionProducer -from control.types import Action, Grant, PermissionContext from storage.vector import VectorProducer +from tests.conftest import root_sec, sec SCOPE = Scope(org="o", user="u") @@ -37,50 +48,46 @@ def _build_counting_vector(config): return store -class _DenyAllPermission(PermissionManager): - """测试用:check 恒拒绝。""" +class _DenyAllAuthorizer(Authorizer): + """测试用:恒拒绝。 - def operator_type(self) -> ControlOperatorType: - return ControlOperatorType.PERMISSION + 刻意**不**声明 ``is_test_only``——那个 capability 的含义是「恒放行、生产装配必须 + 拒绝启动」。恒拒绝没有这个风险,若把它也标成 test-only,本用例就得额外打开 + ``allow_test_only_security``,反而弱化了那道闸门在别处的可信度。 + """ + + def authorize( + self, + *, + auth: AuthContext, + resource: ResourceDescriptor, + environment: AuthorizationEnvironment, + ) -> AuthorizationDecision: + return AuthorizationDecision.deny(DenyReason.DEFAULT_DENY, "deny_all_test") def health(self) -> None: return None - def grant(self, grant: Grant) -> None: # pragma: no cover - 测试不触发 - ... - - def revoke(self, grant: Grant) -> None: # pragma: no cover - ... - - def check( - self, - actor: Scope, - target: Scope, - action: Action, - context: PermissionContext | None = None, - ) -> bool: - return False - -@PermissionProducer.register("deny_all_test") -def _build_deny(config) -> _DenyAllPermission: - return _DenyAllPermission() +@AuthorizationProducer.register("deny_all_test") +def _build_deny(config) -> _DenyAllAuthorizer: + return _DenyAllAuthorizer() def test_default_assembly_allows_write() -> None: """无 config:内置默认 owner-only sqlite ACL,owner 写入放行、可召回。""" api = assemble() - units = api.write("hello", SCOPE, identity=SCOPE) - assert units and api.recall("hello", Context(SCOPE), identity=SCOPE).items + units = api.write("hello", SCOPE, security=sec(SCOPE)) + assert units and api.recall("hello", Context(SCOPE), security=sec(SCOPE)).items def test_default_audit_config_uses_in_memory_sqlite() -> None: audit_config = default_config_dict()["audit"]["default"] api = assemble() - api.write("audit default smoke", SCOPE, identity=SCOPE) + api.write("audit default smoke", SCOPE, security=sec(SCOPE)) assert audit_config == {"target": "sqlite", "params": {"db_path": ":memory:"}} - events = api.audit({"action": "write"}, identity=Scope()) + events = api.audit({"action": "write"}, security=root_sec()) assert any(event.action == "write" for event in events) @@ -103,12 +110,12 @@ def capture_dep(_cls, config, param_name=None, default=None): assert set(seen_defaults) == {"sqlite"} -def test_config_overrides_control_operator() -> None: - """覆盖 permission.default=deny_all_test → 合并到默认之上,写入被拒。""" - cfg = Config.from_dict({"permission": {"default": "deny_all_test"}}) +def test_config_overrides_security_component() -> None: + """覆盖 authorizer.default=deny_all_test → 合并到默认之上,写入被拒。""" + cfg = Config.from_dict({"authorizer": {"default": "deny_all_test"}}) api = assemble(config=cfg) with pytest.raises(PermissionDeniedError): - api.write("hello", SCOPE, identity=SCOPE) + api.write("hello", SCOPE, security=sec(SCOPE)) def test_unknown_operator_target_raises() -> None: diff --git a/tests/unit/api/test_dispatch_management_compat.py b/tests/unit/api/test_dispatch_management_compat.py index 25588979..a28ca309 100644 --- a/tests/unit/api/test_dispatch_management_compat.py +++ b/tests/unit/api/test_dispatch_management_compat.py @@ -1,3 +1,16 @@ +"""管理面 verb 的 dispatch 兼容性。 + +原先本文件不带任何认证上下文直接 ``srv.dispatch(...)``,靠 ``_actor_scope`` +从 payload 里凑出身份。身份改由认证上下文提供后(security.md §9 铁律 #1), +每条用例都必须显式声明「谁在发这个请求」——这正是要的效果: +**签名上就不给「不指定身份也能调」留位置**。 + +身份的传递方式在 F05 迁移中又变了一次:原先靠 ContextVar(``set_current``)注入, +现在 ``dispatch`` 的第三参就是 ``RequestSecurityContext``,ContextVar 只剩日志/trace +用途。用例因此直接把 ``sec(...)`` 传进去——**不传即 401**,这条由 +``test_dispatch_admin_without_credentials_is_unauthenticated_not_forbidden`` 钉住。 +""" + from __future__ import annotations import importlib @@ -7,7 +20,9 @@ import pytest from common.type_def import Segment +from common.type_def.scope import Scope from control import MemoryListResult, PrincipalPath, SpaceInfo, SpaceStatus +from tests.conftest import sec pytestmark = pytest.mark.unit @@ -26,23 +41,30 @@ load_config = profiles.load_config Server = server.Server +_OWNER = Scope(org="acme", user="owner") + def test_dispatch_admin_requires_platform_admin_under_default_kernel() -> None: srv = Server.build(load_config([OFFLINE])) - status, body = srv.dispatch("admin", {"tenant_id": "acme", "scope": "alice"}) + status, body = srv.dispatch( + "admin", {"tenant_id": "acme", "scope": "alice"}, sec(Scope(org="acme", user="alice")) + ) assert status == 403 assert body["error"] == "PermissionDeniedError" -def test_dispatch_admin_rejects_missing_identity_fields() -> None: - srv = Server.build(load_config([OFFLINE])) - payload = {} +def test_dispatch_admin_without_credentials_is_unauthenticated_not_forbidden() -> None: + """无凭据是 401 而不是 403。 - status, body = srv.dispatch("admin", payload) + 401「不知道你是谁」与 403「知道你是谁但不许」是两件事;旧实现把前者伪装 + 成后者(payload 凑出的身份恰好没权限),掩盖了「认证层根本不存在」。 + """ + srv = Server.build(load_config([OFFLINE])) + status, body = srv.dispatch("admin", {}) - assert status == 403 - assert body["error"] == "PermissionDeniedError" + assert status == 401 + assert body["error"] == "AuthenticationError" def test_dispatch_revoke_supports_scope_owner() -> None: @@ -51,12 +73,31 @@ def test_dispatch_revoke_supports_scope_owner() -> None: status, body = srv.dispatch( "revoke", {"tenant_id": "acme", "scope": "owner", "grantee": "reader"}, + sec(_OWNER), ) assert status == 200 assert body["grantee"]["user"] == "reader" +def test_dispatch_revoke_denied_for_non_owner() -> None: + """撤销别人 scope 下的授权 → 403。 + + 旧用例靠 payload 里塞 ``actor_scope: outsider`` 制造这个非属主身份; + 现在身份来自上下文,构造方式变了,**要断言的行为没变**。 + """ + srv = Server.build(load_config([OFFLINE])) + + status, body = srv.dispatch( + "revoke", + {"tenant_id": "acme", "scope": "owner", "grantee": "reader"}, + sec(Scope(org="acme", user="outsider")), + ) + + assert status == 403 + assert body["error"] == "PermissionDeniedError" + + @pytest.mark.parametrize( "actor_override", [ @@ -64,9 +105,10 @@ def test_dispatch_revoke_supports_scope_owner() -> None: {"actor_tenant_id": "acme", "actor_scope": "outsider"}, ], ) -def test_dispatch_revoke_rejects_non_owner_actor_overrides( +def test_dispatch_revoke_rejects_payload_identity_claims( actor_override: dict[str, str], ) -> None: + """payload 里的身份声明一律 400——包括曾经能命中全局放行的空 ``actor_tenant_id``。""" srv = Server.build(load_config([OFFLINE])) status, body = srv.dispatch( @@ -77,10 +119,12 @@ def test_dispatch_revoke_rejects_non_owner_actor_overrides( "grantee": "reader", **actor_override, }, + sec(_OWNER), ) - assert status == 403 - assert body["error"] == "PermissionDeniedError" + assert status == 400 + assert body["error"] == "ValidationError" + assert "identity must come from credentials" in body["message"] def test_dispatch_audit_forwards_structured_filters() -> None: @@ -88,7 +132,7 @@ class _Api: def __init__(self) -> None: self.filters = None - def audit(self, filters, *, identity, limit=100): + def audit(self, filters, *, security, limit=100): self.filters = filters return [ handler.AuditEvent( @@ -114,6 +158,7 @@ def __init__(self) -> None: "actor_user": "owner", "target_space": "coding", }, + sec(_OWNER), ) assert status == 200 @@ -128,18 +173,46 @@ def __init__(self) -> None: assert body["events"][0]["target"]["space"] == "coding" +def test_dispatch_audit_keeps_actor_agent_as_query_filter() -> None: + """``audit`` 的 ``actor_agent`` / ``actor_session`` 是**查询谓词**不是身份声明。 + + 与身份声明字段同名但语义不同(筛「历史事件的操作者是谁」),故对该 verb 放行。 + """ + + class _Api: + def __init__(self) -> None: + self.filters = None + + def audit(self, filters, *, security, limit=100): + self.filters = filters + return [] + + class _Srv: + def __init__(self) -> None: + self.api = _Api() + + srv = _Srv() + + status, _ = handler.dispatch( + srv, "audit", {"actor_agent": "bot", "actor_session": "s1"}, sec(_OWNER) + ) + + assert status == 200 + assert srv.api.filters == {"actor_agent": "bot", "actor_session": "s1"} + + @pytest.mark.parametrize("limit", ["not-a-number", "", [], -1, 0]) def test_dispatch_audit_rejects_invalid_limit(limit) -> None: class _Api: @staticmethod - def audit(filters, *, identity, limit=100): + def audit(filters, *, security, limit=100): raise AssertionError("audit should not be called with an invalid limit") class _Srv: def __init__(self) -> None: self.api = _Api() - status, body = handler.dispatch(_Srv(), "audit", {"limit": limit}) + status, body = handler.dispatch(_Srv(), "audit", {"limit": limit}, sec(_OWNER)) assert status == 400 assert body["error"] == "ValidationError" @@ -154,16 +227,18 @@ def list( self, scope, *, - identity, + security, offset=0, limit=100, memory_types=None, extensions=None, filters=None, ): + # 记 ``security.auth.actor`` 而非 security 本身:本用例断言的是「谁在调用」, + # 而 RequestSecurityContext 还带 request_id / started_at 这类不可预期的字段。 self.call = { "scope": scope, - "identity": identity, + "actor": security.auth.actor, "offset": offset, "limit": limit, "memory_types": memory_types, @@ -187,25 +262,26 @@ def __init__(self) -> None: self.api = _Api() srv = _Srv() + # 身份从 security 参数来,不从 payload 的 actor_scope 来——后者现在会被拒。 status, body = handler.dispatch( srv, "list", { "tenant_id": "acme", "scope": "owner", - "actor_scope": "reader", "offset": "2", "limit": "5", "memory_types": "coding,episodic", - "extensions": {"vendor_mode": 3}, + "extensions": {"vendor_mode": "3"}, "filter": {"metadata.project": "alpha"}, }, + sec(handler.Scope(org="acme", user="reader")), ) assert status == 200 assert srv.api.call == { "scope": handler.Scope(org="acme", user="owner"), - "identity": handler.Scope(org="acme", user="reader"), + "actor": handler.Scope(org="acme", user="reader"), "offset": 2, "limit": 5, "memory_types": ["coding", "episodic"], @@ -243,6 +319,7 @@ class _Srv: _Srv(), "list", {"tenant_id": "acme", "scope": "owner", **payload}, + sec(handler.Scope(org="acme", user="reader")), ) assert status == 400 @@ -254,8 +331,8 @@ class _Api: def __init__(self) -> None: self.call = None - def create_space(self, spec, *, identity): - self.call = {"spec": spec, "identity": identity} + def create_space(self, spec, *, security): + self.call = {"spec": spec, "actor": security.auth.actor} return SpaceInfo( org=spec.org, space=spec.space, @@ -271,23 +348,25 @@ def __init__(self) -> None: self.api = _Api() srv = _Srv() + # 上游原版在 payload 里塞 ``actor_space: ""`` / ``actor_scope: ""`` 来把 identity + # 压成 ``Scope(org="acme")``;这两个字段现在会被拒。要断言的东西没变, + # 换成从 security 参数给同一个身份。 status, body = handler.dispatch( srv, "create_space", { "tenant_id": "acme", "space": "coding", - "actor_space": "", - "actor_scope": "", "display_name": "Coding", "principal_path": "agent_user", "policy": {"pipeline_profiles": {"coding": "coding"}}, "metadata": {"env": "prod"}, }, + sec(handler.Scope(org="acme")), ) assert status == 200 - assert srv.api.call["identity"] == handler.Scope(org="acme") + assert srv.api.call["actor"] == handler.Scope(org="acme") assert srv.api.call["spec"].org == "acme" assert srv.api.call["spec"].space == "coding" assert srv.api.call["spec"].principal_path == PrincipalPath.AGENT_USER diff --git a/tests/unit/api/test_grant_revoke_truth_source.py b/tests/unit/api/test_grant_revoke_truth_source.py new file mode 100644 index 00000000..5a734816 --- /dev/null +++ b/tests/unit/api/test_grant_revoke_truth_source.py @@ -0,0 +1,92 @@ +"""公共 grant/revoke 真源与上下文受控构造经 PEP 的端到端契约(F05 §Authorization)。 + +P1-1:``LocalMemoryAPI.grant`` / ``revoke`` 写入 Authorizer 实际查询的 ``GrantStore`` + (经 ``authorizer.management_grant_store()`` 共享),具名 YAML 令 Authorizer 引用 + 别的 Store 时公共 grant 也写入同一实例,不双真源。 +P1-2:绕过受控构造器、直接拼出的 ``RequestSecurityContext``(``_origin`` 未受控)不得 + 进入授权--补齐 request_id/started_at 也不行。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from api.memory_api_impl import build_kernel +from common.errors import PermissionDeniedError, ValidationError +from common.security.types import AuthContext, RequestSecurityContext +from common.type_def import Scope +from config import Config +from control.types import Action as ControlAction +from control.types import Grant as ControlGrant +from tests.conftest import sec + +pytestmark = pytest.mark.unit + +OWNER = Scope(org="acme", user="owner") +READER = Scope(org="acme", user="reader") + + +def test_public_grant_drives_the_authorizer_truth_source() -> None: + """公开 grant 成功后,PDP 立即看到授权(P1-1)。""" + api = build_kernel().api + unit = api.write("shared", OWNER, security=sec(OWNER))[0] + api.grant( + ControlGrant(grantor=OWNER, grantee=READER, actions=[ControlAction.READ]), + security=sec(OWNER), + ) + assert api.get(unit.id, OWNER, security=sec(READER)).id == unit.id + + +def test_public_revoke_drives_the_authorizer_truth_source() -> None: + """公开 revoke 撤掉 PDP 正在读的记录(P1-1)。""" + api = build_kernel().api + unit = api.write("shared", OWNER, security=sec(OWNER))[0] + api.grant( + ControlGrant(grantor=OWNER, grantee=READER, actions=[ControlAction.READ]), + security=sec(OWNER), + ) + api.revoke( + ControlGrant(grantor=OWNER, grantee=READER, actions=[ControlAction.READ]), + security=sec(OWNER), + ) + with pytest.raises(PermissionDeniedError): + api.get(unit.id, OWNER, security=sec(READER)) + + +def test_yaml_named_authorizer_store_is_also_the_grant_truth_source() -> None: + """Authorizer 改用具名 Store 后,公共 grant 仍写入同一实例(P1-1,非双真源)。""" + config = Config.from_dict( + { + "grant_store": {"pdp_grants": {"target": "memory"}}, + "authorizer": { + "default": { + "target": "standard", + "params": { + "grant_store": "pdp_grants", + "delegation_store": "default", + }, + } + }, + } + ) + api = build_kernel(config=config).api + unit = api.write("named-store", OWNER, security=sec(OWNER))[0] + api.grant( + ControlGrant(grantor=OWNER, grantee=READER, actions=[ControlAction.READ]), + security=sec(OWNER), + ) + assert api.get(unit.id, OWNER, security=sec(READER)).id == unit.id + + +def test_structurally_complete_but_forged_context_is_rejected() -> None: + """补齐 request_id/started_at 仍非受控来源,PEP 拒(P1-2)。""" + api = build_kernel().api + forged = RequestSecurityContext( + auth=AuthContext(actor=OWNER), + request_id="attacker-chosen-id", + started_at=datetime.now(timezone.utc), + ) + with pytest.raises(PermissionDeniedError): + api.write("forged-context", OWNER, security=forged) diff --git a/tests/unit/api/test_handler_identity_split.py b/tests/unit/api/test_handler_identity_split.py index f8c21a95..708abc72 100644 --- a/tests/unit/api/test_handler_identity_split.py +++ b/tests/unit/api/test_handler_identity_split.py @@ -1,3 +1,14 @@ +"""handler 的「身份 / 目标」分离——身份来自安全上下文,目标来自 payload。 + +本文件原先测的是 ``_actor_scope(payload)``:身份从 ``actor_tenant_id`` / +``actor_scope`` 读、缺省回落成目标 scope。那正是 security.md §9 铁律 #1 要堵的 +洞(见 ``tests/integration/test_identity_forgery_rejected.py``),故断言随实现 +一并改写。 + +与集成测试的分工:那边验端到端的授权结果(200/403),这边用 recording API +验**传给 API 边界的 actor 到底是哪个值**——集成测试看不到这一层。 +""" + from __future__ import annotations import importlib @@ -8,6 +19,8 @@ import pytest from common.type_def import Segment +from common.type_def.scope import Scope +from tests.conftest import sec pytestmark = pytest.mark.unit @@ -21,6 +34,8 @@ handler = importlib.import_module("handler") +_ALICE = Scope(org="acme", user="alice") + class _RecordingApi: def __init__(self) -> None: @@ -33,20 +48,20 @@ def write( scope, modality, *, - identity, + security, tags=None, assets=None, metadata=None, ): - self.write_calls.append({"scope": scope, "identity": identity}) + self.write_calls.append({"scope": scope, "actor": security.auth.actor}) return [handler.MemoryUnit(id="unit-1", scope=scope, segments=[Segment(content=content)])] - def recall(self, query, context, *, identity, filters=None, **options): + def recall(self, query, context, *, security, filters=None, **options): self.recall_calls.append( { "query": query, "context": context, - "identity": identity, + "actor": security.auth.actor, "filters": filters, "options": options, } @@ -61,40 +76,83 @@ def __init__(self) -> None: def _dispatch_add(payload: dict) -> dict: srv = _RecordingServer() - status, body = handler.dispatch(srv, "add", {"content": "hello", **payload}) + status, body = handler.dispatch(srv, "add", {"content": "hello", **payload}, sec(_ALICE)) assert status == 200, body return srv.api.write_calls[0] -def test_actor_scope_and_target_scope_match_when_actor_fields_are_omitted() -> None: - call = _dispatch_add({"tenant_id": "acme", "space": "product", "scope": "alice"}) +def test_identity_comes_from_context_target_from_payload() -> None: + """同一次请求里两者可以不同:alice 往 owner 的 scope 写。 - assert call["identity"] == call["scope"] - assert call["identity"].org == "acme" - assert call["identity"].space == "product" - assert call["identity"].user == "alice" + 能不能写由 Authorizer 判(这里的 API 是 recording stub,不判); + handler 的职责只是**把两个值从各自的来源取对**。 + """ + call = _dispatch_add({"tenant_id": "acme", "space": "product", "scope": "owner"}) + assert call["actor"] == _ALICE + assert call["scope"] == Scope(org="acme", space="product", user="owner") -def test_actor_scope_uses_default_scope_when_identity_fields_are_omitted() -> None: + +def test_identity_is_never_derived_from_target_scope() -> None: + """payload 完全不给身份线索时,actor 仍是安全上下文里的那个。 + + 旧实现在这种情况下让 identity 回落成 target scope——等于「谁访问谁就是主人」。 + """ call = _dispatch_add({}) - assert call["identity"] == handler.Scope(org="default", user="") - assert call["scope"] == handler.Scope(org="default", user="") + assert call["actor"] == _ALICE + assert call["scope"] == Scope(org="default", user="") + +def test_payload_identity_claims_are_rejected_not_ignored() -> None: + """``actor_scope`` 这类字段一律 400。 -def test_actor_scope_override_inherits_target_tenant_when_actor_tenant_not_provided() -> None: - call = _dispatch_add( - { - "tenant_id": "acme", - "space_id": "product", - "scope": "owner", - "actor_scope": "auditor", - } + 静默忽略会让调用方以为它仍然生效,写出错误的安全认知。 + """ + srv = _RecordingServer() + status, body = handler.dispatch( + srv, + "add", + {"content": "hello", "tenant_id": "acme", "scope": "owner", "actor_scope": "auditor"}, + sec(_ALICE), ) - assert call["identity"] == handler.Scope(org="acme", space="product", user="auditor") - assert call["scope"] == handler.Scope(org="acme", space="product", user="owner") + assert status == 400, body + assert body["error"] == "ValidationError" + assert "identity must come from credentials" in body["message"] + assert srv.api.write_calls == [] # 拒在进 API 之前 + + +def test_space_dimension_identity_claims_are_rejected() -> None: + """``actor_space`` / ``actor_space_id`` 与其余四维同等对待。 + + space 是 ``Scope`` 五维化时新加的维度,声明字段每多一维、可冒充的主体就多一维; + 禁止列表若漏了它,伪造面就跟着 ``Scope`` 一起长回来。 + + (这条取代了原先断言 ``actor_space`` **能**覆盖 identity 的用例——那个行为 + 正是 §9 铁律 #1 要堵的洞。) + """ + srv = _RecordingServer() + for key in ("actor_space", "actor_space_id"): + status, body = handler.dispatch( + srv, "add", {"content": "hello", "tenant_id": "acme", key: "product"}, sec(_ALICE) + ) + + assert status == 400, body + assert body["error"] == "ValidationError" + assert key in body["message"] + assert srv.api.write_calls == [] + + +def test_missing_context_fails_closed() -> None: + """中间件漏挂 → 401,绝不以某个默认身份跑完。""" + srv = _RecordingServer() + status, body = handler.dispatch(srv, "add", {"content": "hello", "tenant_id": "acme"}) + + assert status == 401, body + assert body["error"] == "AuthenticationError" + assert srv.api.write_calls == [] def test_search_forwards_filter_dsl_to_api_boundary() -> None: @@ -110,22 +168,8 @@ def test_search_forwards_filter_dsl_to_api_boundary() -> None: srv, "search", {"query": "pytest", "tenant_id": "acme", "scope": "alice", "filters": filters}, + sec(_ALICE), ) assert status == 200, body assert srv.api.recall_calls[0]["filters"] == filters - - -def test_actor_space_override_can_differ_from_target_space() -> None: - call = _dispatch_add( - { - "tenant_id": "acme", - "space": "product", - "scope": "owner", - "actor_space": "coding", - "actor_scope": "reader", - } - ) - - assert call["identity"] == handler.Scope(org="acme", space="coding", user="reader") - assert call["scope"] == handler.Scope(org="acme", space="product", user="owner") diff --git a/tests/unit/api/test_memory_api_list.py b/tests/unit/api/test_memory_api_list.py index 2b64790b..eaf3ba0f 100644 --- a/tests/unit/api/test_memory_api_list.py +++ b/tests/unit/api/test_memory_api_list.py @@ -15,24 +15,29 @@ ) from common.type_def.memory_codec import dumps from config import Config +from tests.conftest import sec pytestmark = pytest.mark.unit def _routing_config() -> Config: + """配 ``authorizer`` 段:判定已收敛到 PDP,``permission`` 只剩 grant/revoke 记录通道。""" return Config.from_dict( { - "permission": { + "authorizer": { "default": { "target": "routing", "params": { "route_key": "memory_type", "fallback": "strict", - "routes": {"coding": "strict", "episodic": "standard"}, + "routes": {"coding": "strict", "episodic": "lenient"}, }, }, - "standard": "allow_all", - "strict": "sqlite", + "lenient": "allow_all", + "strict": { + "target": "standard", + "params": {"grant_store": "default", "delegation_store": "default"}, + }, } } ) @@ -45,25 +50,25 @@ def test_memory_api_list_supports_pagination_and_memory_type_filter() -> None: episodic = api.write( "alice joined the sprint planning", scope, - identity=scope, + security=sec(scope), metadata={"memory_type": "episodic"}, )[0] coding = api.write( "repo uses pytest for unit tests", scope, - identity=scope, + security=sec(scope), metadata={"memory_type": "coding"}, )[0] semantic = api.write( "alice prefers concise summaries", scope, - identity=scope, + security=sec(scope), metadata={"memory_type": "semantic"}, )[0] - coding_result = api.list(scope, identity=scope, memory_types=["coding"]) - all_result = api.list(scope, identity=scope) - second_page = api.list(scope, identity=scope, offset=1, limit=1) + coding_result = api.list(scope, security=sec(scope), memory_types=["coding"]) + all_result = api.list(scope, security=sec(scope)) + second_page = api.list(scope, security=sec(scope), offset=1, limit=1) assert [unit.id for unit in coding_result.items] == [coding.id] assert coding_result.count == 1 @@ -79,7 +84,7 @@ def test_memory_api_list_is_scope_bound_and_ignores_message_prefix_records() -> owner = Scope(org="acme", user="owner") other = Scope(org="acme", user="other") - visible = api.write("visible indexed memory", owner, identity=owner)[0] + visible = api.write("visible indexed memory", owner, security=sec(owner))[0] hidden = MemoryUnit( id="raw-message", scope=owner, @@ -87,9 +92,9 @@ def test_memory_api_list_is_scope_bound_and_ignores_message_prefix_records() -> temporal=Temporal(t_ingest=visible.temporal.t_ingest), ) kernel.kv.insert(owner, messages_key(hidden.id), dumps(hidden)) - api.write("other tenant memory", other, identity=other) + api.write("other tenant memory", other, security=sec(other)) - listed = api.list(owner, identity=owner) + listed = api.list(owner, security=sec(owner)) assert [unit.id for unit in listed.items] == [visible.id] assert listed.count == 1 @@ -102,25 +107,25 @@ def test_memory_api_list_filters_before_pagination_and_preserves_total_count() - first = api.write( "first alpha memory", scope, - identity=scope, + security=sec(scope), metadata={"memory_type": "coding", "project": "alpha", "priority": 1}, )[0] second = api.write( "second alpha memory", scope, - identity=scope, + security=sec(scope), metadata={"memory_type": "coding", "project": "alpha", "priority": 2}, )[0] api.write( "beta memory", scope, - identity=scope, + security=sec(scope), metadata={"memory_type": "coding", "project": "beta", "priority": 3}, ) result = api.list( scope, - identity=scope, + security=sec(scope), offset=1, limit=1, memory_types=["coding"], @@ -144,7 +149,7 @@ def test_memory_api_list_copies_extensions_and_forwards_normalized_filters() -> api.write( "alpha memory", scope, - identity=scope, + security=sec(scope), metadata={"project": "alpha"}, ) extensions = {"vendor_mode": 7} @@ -160,7 +165,7 @@ def recording_list(target_scope, **kwargs): result = api.list( scope, - identity=scope, + security=sec(scope), extensions=extensions, filters=filters, ) @@ -178,9 +183,9 @@ def test_memory_api_list_rejects_invalid_extensions_and_scope_filter() -> None: scope = Scope(org="acme", user="owner") with pytest.raises(ValidationError): - api.list(scope, identity=scope, extensions=["invalid"]) + api.list(scope, security=sec(scope), extensions=["invalid"]) with pytest.raises(ValidationError): - api.list(scope, identity=scope, filters={"space": "other"}) + api.list(scope, security=sec(scope), filters={"space": "other"}) def test_memory_api_list_validates_pagination() -> None: @@ -188,9 +193,9 @@ def test_memory_api_list_validates_pagination() -> None: scope = Scope(org="acme", user="owner") with pytest.raises(ValidationError): - api.list(scope, identity=scope, offset=-1) + api.list(scope, security=sec(scope), offset=-1) with pytest.raises(ValidationError): - api.list(scope, identity=scope, limit=0) + api.list(scope, security=sec(scope), limit=0) def test_memory_api_list_permission_routes_by_memory_type() -> None: @@ -198,11 +203,11 @@ def test_memory_api_list_permission_routes_by_memory_type() -> None: owner = Scope(org="acme", user="owner") reader = Scope(org="acme", user="reader") - api.list(owner, identity=reader, memory_types=["episodic"]) + api.list(owner, security=sec(reader), memory_types=["episodic"]) with pytest.raises(PermissionDeniedError): - api.list(owner, identity=reader, memory_types=["coding"]) + api.list(owner, security=sec(reader), memory_types=["coding"]) with pytest.raises(PermissionDeniedError): - api.list(owner, identity=reader, memory_types=["episodic", "coding"]) + api.list(owner, security=sec(reader), memory_types=["episodic", "coding"]) def test_memory_api_unfiltered_list_uses_strict_fallback() -> None: @@ -212,12 +217,12 @@ def test_memory_api_unfiltered_list_uses_strict_fallback() -> None: api.write( "private coding memory", owner, - identity=owner, + security=sec(owner), metadata={"memory_type": "coding"}, ) with pytest.raises(PermissionDeniedError): - api.list(owner, identity=reader) + api.list(owner, security=sec(reader)) def test_memory_api_list_binds_extension_permission_route_to_filter() -> None: @@ -227,19 +232,19 @@ def test_memory_api_list_binds_extension_permission_route_to_filter() -> None: episodic = api.write( "shareable episodic memory", owner, - identity=owner, + security=sec(owner), metadata={"memory_type": "episodic"}, )[0] api.write( "private coding memory", owner, - identity=owner, + security=sec(owner), metadata={"memory_type": "coding"}, ) result = api.list( owner, - identity=reader, + security=sec(reader), extensions={"memory_type": "episodic"}, ) diff --git a/tests/unit/api/test_permission_audit.py b/tests/unit/api/test_permission_audit.py index 98f4e913..c71ddd4e 100644 --- a/tests/unit/api/test_permission_audit.py +++ b/tests/unit/api/test_permission_audit.py @@ -7,6 +7,7 @@ from common.type_def import Scope from config import Config from construction import EvolveMode +from tests.conftest import root_sec, sec pytestmark = pytest.mark.unit @@ -19,11 +20,11 @@ def test_permission_denial_is_audited() -> None: target = Scope(org="acme", user="owner") with pytest.raises(PermissionDeniedError): - api.get("missing", target, identity=actor) + api.get("missing", target, security=sec(actor)) denied = [ event - for event in api.audit({"action": "get"}, identity=Scope(), limit=10) + for event in api.audit({"action": "get"}, security=root_sec(), limit=10) if event.detail.get("decision") == "deny" ] assert denied @@ -36,22 +37,20 @@ def test_root_identity_can_use_admin_interfaces_with_sqlite_permission() -> None cfg = Config.from_dict({"permission": {"default": "sqlite"}}) kernel = build_kernel(config=cfg) api = kernel.api - root = Scope() - assert api.admin_get("rerank.enabled", identity=root) == "true" - api.admin_set("rerank.enabled", "false", identity=root) - assert api.admin_get("rerank.enabled", identity=root) == "false" + assert api.admin_get("rerank.enabled", security=root_sec()) == "true" + api.admin_set("rerank.enabled", "false", security=root_sec()) + assert api.admin_get("rerank.enabled", security=root_sec()) == "false" def test_audit_event_view_includes_actor_decision_and_detail_fields() -> None: cfg = Config.from_dict({"permission": {"default": "sqlite"}}) kernel = build_kernel(config=cfg) api = kernel.api - root = Scope() scope = Scope(org="acme", user="owner") - api.write("audit event view", scope, identity=scope) - events = api.audit({"action": "write"}, identity=root, limit=10) + api.write("audit event view", scope, security=sec(scope)) + events = api.audit({"action": "write"}, security=root_sec(), limit=10) write_event = next(event for event in events if event.action == "write") assert write_event.actor == scope @@ -62,11 +61,10 @@ def test_audit_event_view_includes_actor_decision_and_detail_fields() -> None: def test_evolve_audit_records_job_id_not_unit_id() -> None: cfg = Config.from_dict({"permission": {"default": "sqlite"}}) api = build_kernel(config=cfg).api - root = Scope() scope = Scope(org="acme", user="owner") - job_id = api.evolve(scope, EvolveMode.EXTRACT, identity=scope) - events = api.audit({"action": "evolve"}, identity=root, limit=10) + job_id = api.evolve(scope, EvolveMode.EXTRACT, security=sec(scope)) + events = api.audit({"action": "evolve"}, security=root_sec(), limit=10) evolve_event = next(event for event in events if event.action == "evolve") assert evolve_event.detail["job_id"] == job_id @@ -87,13 +85,12 @@ def test_configured_sqlite_audit_persists_through_api_audit(tmp_path) -> None: } ) scope = Scope(org="acme", user="owner") - root = Scope() first = build_kernel(config=cfg).api - first.write("persisted audit event", scope, identity=scope) + first.write("persisted audit event", scope, security=sec(scope)) second = build_kernel(config=cfg).api - events = second.audit({"action": "write"}, identity=root, limit=10) + events = second.audit({"action": "write"}, security=root_sec(), limit=10) assert any(event.action == "write" and event.actor == scope for event in events) @@ -111,11 +108,10 @@ def test_configured_sqlite_audit_memory_database_is_queryable() -> None: } ) scope = Scope(org="acme", user="owner") - root = Scope() api = build_kernel(config=cfg).api - api.write("in-memory sqlite audit event", scope, identity=scope) - events = api.audit({"action": "write"}, identity=root, limit=10) + api.write("in-memory sqlite audit event", scope, security=sec(scope)) + events = api.audit({"action": "write"}, security=root_sec(), limit=10) assert any(event.action == "write" and event.actor == scope for event in events) @@ -143,11 +139,11 @@ def test_require_space_policy_rejects_empty_space_and_audits_denial() -> None: scope = Scope(org="acme", user="owner") with pytest.raises(ValidationError): - api.write("missing space", scope, identity=scope) + api.write("missing space", scope, security=sec(scope)) denied = [ event - for event in api.audit({"action": "write"}, identity=Scope(), limit=10) + for event in api.audit({"action": "write"}, security=root_sec(), limit=10) if event.decision == "deny" ] assert denied diff --git a/tests/unit/api/test_recall_context.py b/tests/unit/api/test_recall_context.py index 91b8cdcf..aaead5a0 100644 --- a/tests/unit/api/test_recall_context.py +++ b/tests/unit/api/test_recall_context.py @@ -8,6 +8,7 @@ from common.type_def import EXT_MAX_TOKENS, Context, Modality, Scope from config import Config from retrieval.types import DisclosureLevel +from tests.conftest import sec # discloser 用结构化披露(自适应分级) _CONFIG = {"discloser": {"default": "structured"}} @@ -25,12 +26,12 @@ def _api(): def test_context_max_tokens_reaches_adaptive_disclosure() -> None: api = _api() - api.write(_TEXT, _SCOPE, source=Modality.TEXT, identity=_ACTOR) + api.write(_TEXT, _SCOPE, source=Modality.TEXT, security=sec(_ACTOR)) res = api.recall( "coffee", Context(_SCOPE, extensions={EXT_MAX_TOKENS: "300"}), - identity=_ACTOR, + security=sec(_ACTOR), disclosure=DisclosureLevel.ADAPTIVE, with_trajectory=True, ) @@ -41,12 +42,12 @@ def test_context_max_tokens_reaches_adaptive_disclosure() -> None: def test_context_without_max_tokens_uses_default() -> None: api = _api() - api.write(_TEXT, _SCOPE, source=Modality.TEXT, identity=_ACTOR) + api.write(_TEXT, _SCOPE, source=Modality.TEXT, security=sec(_ACTOR)) res = api.recall( "coffee", Context(_SCOPE), # 不给预算 → max_tokens=None - identity=_ACTOR, + security=sec(_ACTOR), disclosure=DisclosureLevel.ADAPTIVE, with_trajectory=True, ) diff --git a/tests/unit/api/test_space_api.py b/tests/unit/api/test_space_api.py index cead2c76..f8a1ba78 100644 --- a/tests/unit/api/test_space_api.py +++ b/tests/unit/api/test_space_api.py @@ -4,9 +4,11 @@ from api.memory_api_impl import build_kernel from common.errors import NotFoundError, PermissionDeniedError, ValidationError +from common.security.types import Role from common.type_def import Scope from config import Config from control import PrincipalPath, SpaceMember, SpacePolicy, SpaceSpec, SpaceStatus +from tests.conftest import root_sec, sec pytestmark = pytest.mark.unit @@ -31,6 +33,16 @@ def _cloud_kernel(): ) +def _admin(actor: Scope): + """space 管理面(``MANAGE_SPACE``)的最低角色是 ADMIN。 + + 原先「管理员」是靠 actor 的 scope 形状表达的(``Scope(org="acme")`` 这种少填几维 + 的形状恰好覆盖得更宽);现在角色是 ``AuthContext`` 里的独立字段,形状只决定 + owner 覆盖范围、够不到管理面。两者都要给对,用例才测到真实的准入路径。 + """ + return sec(actor, role=Role.ADMIN) + + def test_memory_api_space_lifecycle_usage_members_and_delete() -> None: kernel = _cloud_kernel() api = kernel.api @@ -40,33 +52,46 @@ def test_memory_api_space_lifecycle_usage_members_and_delete() -> None: info = api.create_space( SpaceSpec(org="acme", space="coding", display_name="Coding"), - identity=org_admin, + security=_admin(org_admin), ) assert info.status == SpaceStatus.ACTIVE - assert api.get_space("acme", "coding", identity=space_admin).display_name == "Coding" - assert [space.space for space in api.list_spaces("acme", identity=org_admin)] == ["coding"] + assert api.get_space("acme", "coding", security=sec(space_admin)).display_name == "Coding" + assert [space.space for space in api.list_spaces("acme", security=sec(org_admin))] == ["coding"] api.add_space_member( "acme", "coding", SpaceMember(scope=Scope(user="alice"), role="admin"), - identity=space_admin, + security=_admin(space_admin), ) - assert api.list_space_members("acme", "coding", identity=space_admin)[0].scope == unit_scope + members = api.list_space_members("acme", "coding", security=sec(space_admin)) + assert members[0].scope == unit_scope - unit = api.write("space scoped memory", unit_scope, identity=unit_scope)[0] - usage = api.space_usage("acme", "coding", identity=space_admin) + unit = api.write("space scoped memory", unit_scope, security=sec(unit_scope))[0] + usage = api.space_usage("acme", "coding", security=sec(space_admin)) assert usage.memory_count == 1 assert usage.storage_bytes > 0 - assert api.export_space("acme", "coding", identity=space_admin) + assert api.export_space("acme", "coding", security=sec(space_admin)) - result = api.delete_space("acme", "coding", identity=space_admin) + result = api.delete_space("acme", "coding", security=_admin(space_admin)) assert result.deleted_counts["memory"] == 1 assert result.deleted_counts["index"] == 1 - events = api.audit({"target_space": "coding"}, identity=Scope(), limit=100) + events = api.audit({"target_space": "coding"}, security=root_sec(), limit=100) assert unit.id in events[-1].detail.get("deleted_memory_ids", "") with pytest.raises(NotFoundError): - api.get_space("acme", "coding", identity=space_admin) + api.get_space("acme", "coding", security=sec(space_admin)) + + +def test_space_management_requires_admin_role() -> None: + """USER 角色够不到 space 管理面,哪怕它的 scope 覆盖那个 space。 + + 这条是上面那个 ``_admin`` 的反面:owner 覆盖只管数据面,管理面的准入依据是 + 服务端签发的 role,调用方改不了自己的 scope 就提权。 + """ + api = _cloud_kernel().api + + with pytest.raises(PermissionDeniedError): + api.create_space(SpaceSpec(org="acme", space="coding"), security=sec(Scope(org="acme"))) def test_memory_api_rejects_writes_after_space_archive() -> None: @@ -75,11 +100,11 @@ def test_memory_api_rejects_writes_after_space_archive() -> None: space_admin = Scope(org="acme", space="coding") unit_scope = Scope(org="acme", space="coding", user="alice") - api.create_space(SpaceSpec(org="acme", space="coding"), identity=org_admin) - api.archive_space("acme", "coding", identity=space_admin) + api.create_space(SpaceSpec(org="acme", space="coding"), security=_admin(org_admin)) + api.archive_space("acme", "coding", security=_admin(space_admin)) with pytest.raises(ValidationError): - api.write("blocked after archive", unit_scope, identity=unit_scope) + api.write("blocked after archive", unit_scope, security=sec(unit_scope)) def test_space_policy_principal_path_drives_api_authorization() -> None: @@ -94,19 +119,19 @@ def test_space_policy_principal_path_drives_api_authorization() -> None: principal_path=PrincipalPath.AGENT_USER, policy=SpacePolicy(principal_path=PrincipalPath.AGENT_USER), ), - identity=org_admin, + security=_admin(org_admin), ) api.write( "agent owns user memory in this space", target, - identity=Scope(org="acme", space="coding", agent="agent-a"), + security=sec(Scope(org="acme", space="coding", agent="agent-a")), ) with pytest.raises(PermissionDeniedError): api.write( "user is not the parent in this space", target, - identity=Scope(org="acme", space="coding", user="alice"), + security=sec(Scope(org="acme", space="coding", user="alice")), ) @@ -115,4 +140,4 @@ def test_in_memory_engine_rejects_non_empty_space() -> None: scope = Scope(org="acme", space="cloud-space", user="alice") with pytest.raises(ValidationError, match="InMemoryEngine"): - api.write("cloud scoped memory", scope, identity=scope) + api.write("cloud scoped memory", scope, security=sec(scope)) diff --git a/tests/unit/api/test_write_reserved_metadata.py b/tests/unit/api/test_write_reserved_metadata.py index c7a7e39d..c20dd55b 100644 --- a/tests/unit/api/test_write_reserved_metadata.py +++ b/tests/unit/api/test_write_reserved_metadata.py @@ -13,6 +13,7 @@ from common.type_def import RESERVED_METADATA_KEYS, Modality, Scope from config import Config from control.types import MemoryPatch +from tests.conftest import sec pytestmark = pytest.mark.unit @@ -30,7 +31,9 @@ def test_write_rejects_reserved_metadata_key(key: str) -> None: api = _api() with pytest.raises(ValidationError): - api.write(_TEXT, _SCOPE, source=Modality.TEXT, identity=_ACTOR, metadata={key: "custom"}) + api.write( + _TEXT, _SCOPE, source=Modality.TEXT, security=sec(_ACTOR), metadata={key: "custom"} + ) def test_write_allows_normal_metadata() -> None: @@ -40,7 +43,7 @@ def test_write_allows_normal_metadata() -> None: _TEXT, _SCOPE, source=Modality.TEXT, - identity=_ACTOR, + security=sec(_ACTOR), metadata={"memory_type": "coding", "project": "alpha"}, ) @@ -49,10 +52,12 @@ def test_write_allows_normal_metadata() -> None: def test_update_rejects_reserved_metadata_key() -> None: api = _api() - unit = api.write(_TEXT, _SCOPE, source=Modality.TEXT, identity=_ACTOR)[0] + unit = api.write(_TEXT, _SCOPE, source=Modality.TEXT, security=sec(_ACTOR))[0] with pytest.raises(ValidationError): - api.update(unit.id, _SCOPE, MemoryPatch(metadata={"lifecycle": "custom"}), identity=_ACTOR) + api.update( + unit.id, _SCOPE, MemoryPatch(metadata={"lifecycle": "custom"}), security=sec(_ACTOR) + ) def test_write_preserves_scalar_types_end_to_end() -> None: @@ -68,7 +73,7 @@ def test_write_preserves_scalar_types_end_to_end() -> None: _TEXT, _SCOPE, source=Modality.TEXT, - identity=_ACTOR, + security=sec(_ACTOR), metadata={"priority": 8, "score": 9.5, "archived": False, "project": "alpha"}, ) @@ -84,7 +89,7 @@ def test_write_switch_accepts_native_bool() -> None: api = _api() units = api.write( - _TEXT, _SCOPE, source=Modality.TEXT, identity=_ACTOR, metadata={"procedural": True} + _TEXT, _SCOPE, source=Modality.TEXT, security=sec(_ACTOR), metadata={"procedural": True} ) assert units # 开关被识别、写入成功;未被识别时走的是另一条落库路径 @@ -96,7 +101,7 @@ def test_write_rejects_non_scalar_metadata(value) -> None: api = _api() with pytest.raises(ValidationError): - api.write(_TEXT, _SCOPE, source=Modality.TEXT, identity=_ACTOR, metadata={"x": value}) + api.write(_TEXT, _SCOPE, source=Modality.TEXT, security=sec(_ACTOR), metadata={"x": value}) def test_write_allows_string_array_metadata() -> None: @@ -104,7 +109,7 @@ def test_write_allows_string_array_metadata() -> None: api = _api() units = api.write( - _TEXT, _SCOPE, source=Modality.TEXT, identity=_ACTOR, metadata={"langs": ["py", "go"]} + _TEXT, _SCOPE, source=Modality.TEXT, security=sec(_ACTOR), metadata={"langs": ["py", "go"]} ) assert units[0].metadata["langs"] == ["py", "go"] diff --git a/tests/unit/bootstrap/test_auth_middleware.py b/tests/unit/bootstrap/test_auth_middleware.py new file mode 100644 index 00000000..4fa1e239 --- /dev/null +++ b/tests/unit/bootstrap/test_auth_middleware.py @@ -0,0 +1,532 @@ +"""bootstrap/core/auth_middleware:凭据归一 + RequestSecurityContext 构造 + 清理。 + +中间件本身不含认证策略(模式由配置在装配期选定),故这里测三件事: +**凭据材料被正确归一**、**产出的 RequestSecurityContext 各字段由服务端决定**,以及 +**辅助 ContextVar 一定被清理**。最后一项的测法是验证行为后果(``get_current()`` +是否干净),不是验证 ``reset_current`` 被调用过。 + +``authenticated`` yield 的是 :class:`RequestSecurityContext` 而非 ``AuthContext`` +(迁移计划 §5.2 第 7 项)——它是 ``MemoryAPI`` 的唯一显式安全输入,由调用方显式传给 +``dispatch``。ContextVar 里仍放 ``AuthContext``,但只供日志/trace 关联。 +""" + +# ruff: noqa: E402 + +from __future__ import annotations + +import os +import sys + +import pytest + +# bootstrap/core 是 flat import root(不是包),与各 surface 用同样的方式接进来。 +_CORE_DIR = os.path.abspath( + os.path.join(os.path.dirname(__file__), "..", "..", "..", "bootstrap", "core") +) +if _CORE_DIR not in sys.path: + sys.path.append(_CORE_DIR) + +from auth_middleware import authenticated, credentials_from_headers # noqa: E402 + +from common.bootstrap import register_plugins # noqa: E402 +from common.errors import AuthenticationError, RateLimitedError # noqa: E402 +from common.security.authentication.authentication_impl.api_key_authenticator import ( + ApiKeyAuthenticator, # noqa: E402 +) +from common.security.authentication.authentication_impl.dev_authenticator import ( + DevAuthenticator, # noqa: E402 +) +from common.security.authentication.authentication_impl.trusted_authenticator import ( + TrustedAuthenticator, # noqa: E402 +) +from common.security.authentication.key_store import KeyStoreProducer # noqa: E402 +from common.security.protection.protection_impl.semaphore_guard import ( + SemaphoreWorkloadGuard, # noqa: E402 +) +from common.security.types import Credentials, Role, Surface, get_current # noqa: E402 +from common.type_def.scope import Scope # noqa: E402 +from config.context import AssemblyContext # noqa: E402 + +pytestmark = pytest.mark.unit + +_ALICE = Scope(org="acme", user="alice") + + +@pytest.fixture(scope="module") +def key_store(): + register_plugins() + return KeyStoreProducer.build("memory", {}, AssemblyContext()) + + +@pytest.fixture(scope="module") +def alice_key(key_store) -> str: + return key_store.issue(_ALICE, Role.USER) + + +# -- 凭据归一 ---------------------------------------------------------------- # + + +def test_bearer_scheme_is_case_insensitive() -> None: + """RFC 9110 §11.1:auth-scheme 大小写不敏感。三种写法必须取到同一个 key。""" + for raw in ("Bearer k123", "bearer k123", "BEARER k123"): + assert credentials_from_headers({"Authorization": raw}).api_key == "k123" + + +def test_header_names_are_normalized_to_lowercase() -> None: + """header 名大小写不敏感(RFC 9110 §5.1)——TRUSTED 实现按小写常量查。 + + 归一放在这里而不是各 authenticator 里,是为了让「查 header」只有一种写法。 + """ + creds = credentials_from_headers({"X-ORG-Id": "acme", "x-Principal-TYPE": "user"}) + assert creds.headers == {"x-org-id": "acme", "x-principal-type": "user"} + + +def test_x_api_key_is_the_fallback_not_the_override(alice_key) -> None: + """Authorization 优先;它缺失或非 Bearer 时才回落 X-Api-Key。 + + 顺序反过来会让「同时带两个 header」的请求用哪个 key 取决于实现细节。 + """ + both = credentials_from_headers({"Authorization": "Bearer from-bearer", "X-Api-Key": "from-x"}) + assert both.api_key == "from-bearer" + + only_x = credentials_from_headers({"X-Api-Key": "from-x"}) + assert only_x.api_key == "from-x" + + # Basic 不是 Bearer,不该被当成 api_key 提取,此时回落 X-Api-Key。 + basic = credentials_from_headers({"Authorization": "Basic dXNlcjpwdw==", "X-Api-Key": "from-x"}) + assert basic.api_key == "from-x" + + +def test_missing_credentials_yield_empty_key_not_none() -> None: + """无凭据是空串而非 None——authenticator 侧不必再写 None 判断。""" + creds = credentials_from_headers({}) + assert creds.api_key == "" + assert creds.headers == {} + assert creds.peer_address == "" + + +def test_peer_address_is_carried_through() -> None: + """审计要记调用方地址(未来还要给速率限制用)。""" + assert credentials_from_headers({}, "10.0.0.7").peer_address == "10.0.0.7" + + +def test_surrounding_whitespace_is_stripped() -> None: + assert credentials_from_headers({"Authorization": "Bearer k123 "}).api_key == "k123" + assert credentials_from_headers({"X-Api-Key": " k123 "}).api_key == "k123" + + +# -- ContextVar 生命周期 ------------------------------------------------------ # + + +def test_context_is_set_inside_and_cleared_outside() -> None: + assert get_current() is None + with authenticated(DevAuthenticator(), Credentials()) as security: + # ContextVar 里是 AuthContext(日志/trace 用),yield 出来的是完整的 + # RequestSecurityContext(授权用)——两者不是同一个对象。 + assert get_current() is security.auth + assert security.auth.role is Role.ROOT + assert get_current() is None + + +def test_context_is_cleared_when_body_raises() -> None: + """with 体内抛异常同样要清理——否则一次 500 就污染整条线程。""" + with pytest.raises(RuntimeError): + with authenticated(DevAuthenticator(), Credentials()): + raise RuntimeError("boom") + assert get_current() is None + + +def test_failed_authentication_leaves_no_context(key_store) -> None: + """认证失败后必须仍是「无身份」,不能残留上一次的。""" + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="not-a-real-key")): + pass # pragma: no cover - authenticate 在进入 with 体之前就抛了 + assert get_current() is None + + +def test_consecutive_requests_do_not_inherit_identity(key_store, alice_key) -> None: + """池化线程上连续两个请求:第二个必须看不到第一个的身份。 + + 这是漏 reset 的真实后果,也是本中间件存在的主要理由。 + """ + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + + with authenticated(auth, Credentials(api_key=alice_key)) as security: + assert security.actor == _ALICE + assert get_current() is None + + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="wrong")): + pass # pragma: no cover + assert get_current() is None + + +# -- 归一后的 header 确实能被 TRUSTED 消费 ------------------------------------- # + + +def test_normalized_headers_authenticate_under_trusted(key_store) -> None: + """端到端一小段:大小写混乱的网关 header 经归一后仍认得出主体。 + + 单独测归一、单独测 TRUSTED 都会漏掉「两边约定不一致」这个真实故障。 + """ + auth = TrustedAuthenticator(key_store=key_store, gateway_key="") + creds = credentials_from_headers( + {"X-Org-ID": "acme", "X-Principal-Type": "User", "X-PRINCIPAL-ID": "alice"} + ) + with authenticated(auth, creds) as security: + assert security.actor == _ALICE + assert security.auth.role is Role.USER + + +# -- RequestSecurityContext 的构造(迁移计划 §5.2 第 7 项)--------------------- # + + +def test_request_id_is_server_generated_and_unique() -> None: + """request_id 由服务端生成且每次不同,调用方不能伪造审计关联标识。""" + seen = set() + for _ in range(3): + with authenticated(DevAuthenticator(), Credentials()) as security: + assert security.request_id + seen.add(security.request_id) + assert len(seen) == 3 + + +def test_surface_comes_from_the_adapter_not_the_caller() -> None: + """surface 由适配层写入;缺省 INTERNAL 对应进程内装配。""" + with authenticated(DevAuthenticator(), Credentials(), surface=Surface.HTTP) as security: + assert security.surface is Surface.HTTP + with authenticated(DevAuthenticator(), Credentials()) as security: + assert security.surface is Surface.INTERNAL + + +def test_peer_is_the_transport_address_not_a_forwarded_header() -> None: + """不采信未经可信代理校验的转发头,避免伪造限流分桶与审计来源。""" + creds = credentials_from_headers( + {"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "5.6.7.8"}, "10.0.0.7" + ) + with authenticated(DevAuthenticator(), creds) as security: + assert security.peer == "10.0.0.7" + + +def test_attributes_are_empty_and_read_only() -> None: + """本层没有可信系统属性,业务 payload 不能向只读 attributes 注入值。""" + with authenticated(DevAuthenticator(), Credentials()) as security: + assert dict(security.attributes) == {} + with pytest.raises(TypeError): + security.attributes["injected"] = "root" # type: ignore[index] + + +def test_started_at_is_server_clock() -> None: + """授权的时效判定用它派生的 now,必须是服务端时钟且带时区。""" + with authenticated(DevAuthenticator(), Credentials()) as security: + assert security.started_at is not None + assert security.started_at.tzinfo is not None + + +# -- 限流(§8.1) ------------------------------------------------------------- # + + +class _CountingAuth(DevAuthenticator): + """记下 authenticate 被调了几次——限流是否真的挡在认证之前,只能这样测。""" + + def __init__(self) -> None: + self.calls = 0 + + def authenticate(self, credentials): + self.calls += 1 + return super().authenticate(credentials) + + +class _Blocked: + """恒拒绝的限流器。""" + + @staticmethod + def allow(peer): + return False + + @staticmethod + def health() -> None: + return None + + +class _Open: + @staticmethod + def allow(peer): + return True + + @staticmethod + def health() -> None: + return None + + +def _peer() -> Credentials: + """带对端地址的凭据:限流按 peer 建桶,没有 peer 就不限流。""" + return Credentials(peer_address="10.0.0.7") + + +def test_rate_limit_runs_before_authentication() -> None: + """这是限流存在的全部理由:被限流的请求**不能**触发 Argon2 verify。 + + 放在认证之后限流,等于「先让攻击者把 CPU 用掉,再告诉他超限了」—— + §8.1 要防的资源耗尽就完全没防住。 + """ + auth = _CountingAuth() + with pytest.raises(RateLimitedError): + with authenticated(auth, _peer(), None, _Blocked()): + pass # pragma: no cover - 限流在进入 with 体之前就抛了 + assert auth.calls == 0 + + +def test_rate_limited_is_not_an_authentication_error() -> None: + """429 与 401 必须可分:一个该稍后重试,一个该换凭据。""" + with pytest.raises(RateLimitedError) as exc: + with authenticated(DevAuthenticator(), _peer(), None, _Blocked()): + pass # pragma: no cover + assert not isinstance(exc.value, AuthenticationError) + + +def test_rate_limited_leaves_no_context() -> None: + with pytest.raises(RateLimitedError): + with authenticated(DevAuthenticator(), _peer(), None, _Blocked()): + pass # pragma: no cover + assert get_current() is None + + +def test_no_limiter_means_no_limiting() -> None: + """``limiter=None`` 是进程内直连 / MCP stdio 的形态,行为与一期一致。""" + auth = _CountingAuth() + for _ in range(5): + with authenticated(auth, Credentials()): + pass + assert auth.calls == 5 + + +def test_passing_limiter_does_not_change_the_allowed_path() -> None: + with authenticated(DevAuthenticator(), _peer(), None, _Open()) as security: + assert security.auth.role is Role.ROOT + assert get_current() is None + + +def test_rate_limit_denial_is_audited_distinctly() -> None: + """审计里限流与认证失败要分得开,否则运维看到一堆 deny 不知道该调哪个。""" + recorded = [] + + class _Recorder: + @staticmethod + def record(event): + recorded.append(event) + + with pytest.raises(RateLimitedError): + with authenticated( + DevAuthenticator(), Credentials(peer_address="10.0.0.7"), _Recorder(), _Blocked() + ): + pass # pragma: no cover + + assert len(recorded) == 1 + assert recorded[0].action == "rate_limit" + assert recorded[0].decision == "deny" + assert recorded[0].actor == Scope() # 身份未知,不可用调用方声明的值填充 + assert recorded[0].detail["peer"] == "10.0.0.7" + + +def test_rate_limit_audit_carries_no_bucket_state() -> None: + """不记桶余量:那能用来反推限流参数,然后贴着阈值发请求。""" + recorded = [] + + class _Recorder: + @staticmethod + def record(event): + recorded.append(event) + + with pytest.raises(RateLimitedError): + with authenticated( + DevAuthenticator(), + Credentials(api_key="secret-key", peer_address="10.0.0.7"), + _Recorder(), + _Blocked(), + ): + pass # pragma: no cover + + detail = recorded[0].detail + assert set(detail) == {"mode", "peer"} + assert "secret-key" not in str(detail) # §7.5:凭据不进审计 + + +def test_audit_backend_failure_does_not_mask_429() -> None: + """审计写失败不该把 429 变成 500——与 401 同样的取舍。""" + + class _Exploding: + @staticmethod + def record(event): + raise RuntimeError("audit backend down") + + with pytest.raises(RateLimitedError): + with authenticated( + DevAuthenticator(), Credentials(peer_address="10.0.0.7"), _Exploding(), _Blocked() + ): + pass # pragma: no cover + + +def test_audit_backend_failure_does_not_mask_401(key_store) -> None: + """审计写失败不该把 401 变成 500——认证结论优先于可观测性。""" + + class _Exploding: + @staticmethod + def record(event): + raise RuntimeError("audit backend down") + + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + with pytest.raises(AuthenticationError): + with authenticated(auth, Credentials(api_key="wrong"), _Exploding()): + pass # pragma: no cover + assert get_current() is None + + +# -- 昂贵操作的并发预算(审计 P1-3;F05 §Protection §WorkloadGuard)---------- # + + +def test_workload_guard_release_on_success() -> None: + """guard 在认证成功后必须释放,否则槽位泄漏把后续请求也堵死。""" + guard = SemaphoreWorkloadGuard(1) + auth = _CountingAuth() + with authenticated(auth, Credentials(), None, None, workload_guard=guard): + pass + # 认证后槽位已释放,能再 acquire + assert guard.acquire() is True + guard.release() + assert auth.calls == 1 + + +def test_workload_guard_release_on_auth_failure() -> None: + """认证失败也要释放(finally)。""" + guard = SemaphoreWorkloadGuard(1) + + # 用一个恒失败的 auth:走认证失败路径,验证 guard 在 finally 释放 + class _Fail: + mode = DevAuthenticator().mode + + @staticmethod + def authenticate(credentials): + raise AuthenticationError("nope") + + with pytest.raises(AuthenticationError): + with authenticated(_Fail(), Credentials(), None, None, workload_guard=guard): + pass # pragma: no cover + assert guard.acquire() is True + guard.release() + + +def test_workload_guard_blocks_when_slots_exhausted() -> None: + """耗尽并发槽返回 429,不进入 authenticate。""" + guard = SemaphoreWorkloadGuard(1) + # 占满唯一槽位 + assert guard.acquire() is True + auth = _CountingAuth() + with pytest.raises(RateLimitedError): + with authenticated(auth, Credentials(), None, None, workload_guard=guard): + pass # pragma: no cover + assert auth.calls == 0 + guard.release() + + +def test_workload_guard_released_on_rate_limit_before_it() -> None: + """IP 桶先挡住时 guard 不该 acquire(两层独立)。""" + guard = SemaphoreWorkloadGuard(1) + with pytest.raises(RateLimitedError): + with authenticated(DevAuthenticator(), _peer(), None, _Blocked(), workload_guard=guard): + pass # pragma: no cover + # guard 没被占 + assert guard.acquire() is True + guard.release() + + +def test_workload_guard_none_means_unlimited() -> None: + """None 表示不限(DEV / 进程内直连),与一期行为一致。""" + auth = _CountingAuth() + for _ in range(10): + with authenticated(auth, Credentials()): + pass + assert auth.calls == 10 + + +def test_workload_guard_rejects_zero_max_concurrent() -> None: + """max_concurrent=0 是非法,装配期炸,不用 or 吞成默认(审计验收 P2-guard)。""" + with pytest.raises(ValueError): + SemaphoreWorkloadGuard(0) + + +def test_workload_guard_concurrency_is_actually_bounded() -> None: + """真实并发测试:同时进入 authenticate 的数 <= max_concurrent。 + + 复验 P3:此前 gate.set() 没等前两个确定占住槽,调度型竞态导致偶发 + ``assert 1 == 2``。改为 Barrier 明确同步 happens-before: + 1) 先启 2 线程,等它们都进 _Blocking.authenticate(占住两个槽); + 2) 再启 2 线程,它们应被 guard 挡(acquire 失败 -> 429); + 3) 最后 set gate 释放前两个。 + """ + import threading + + guard = SemaphoreWorkloadGuard(2) + in_flight = 0 + peak = 0 + lock = threading.Lock() + gate = threading.Event() + # 前 2 个线程进入 authenticate 后用它通知主线程「已占住槽」 + holders_inside = threading.Barrier(2) + holders_ready = threading.Event() + + class _Blocking: + mode = DevAuthenticator().mode + + @staticmethod + def authenticate(credentials): + nonlocal in_flight, peak + with lock: + in_flight += 1 + peak = max(peak, in_flight) + # 通知主线程:我已占住槽。用 Barrier 让 2 个 holder 都到齐再统一放行。 + try: + holders_inside.wait(timeout=2) + except threading.BrokenBarrierError: + pass + holders_ready.set() + gate.wait(timeout=3) + with lock: + in_flight -= 1 + return DevAuthenticator().authenticate(credentials) + + def fire(i, results): + try: + with authenticated(_Blocking(), Credentials(), None, None, workload_guard=guard): + results.append(i) + except RateLimitedError: + results.append(f"blocked-{i}") + + # 阶段 1:先启 2 个占槽线程,等它们都进入 authenticate + holder_results = [] + holders = [threading.Thread(target=fire, args=(i, holder_results)) for i in range(2)] + for t in holders: + t.start() + # 等两个 holder 都进 authenticate(Barrier 到齐 -> holders_ready set) + assert holders_ready.wait(timeout=3), "holders 未在限时内占住槽" + # 此时两个槽被占 + + # 阶段 2:再启 2 个线程,应被 guard 挡(429) + blocked_results = [] + seekers = [threading.Thread(target=fire, args=(i, blocked_results)) for i in (2, 3)] + for t in seekers: + t.start() + for t in seekers: + t.join(timeout=2) + + # 阶段 3:放行前两个 + gate.set() + for t in holders: + t.join(timeout=2) + + blocked = [x for x in blocked_results if isinstance(x, str)] + accepted = [x for x in blocked_results if isinstance(x, int)] + assert accepted == [], "槽位已满时 seeker 不应进入 authenticate" + assert len(blocked) == 2, f"应有 2 个被挡,得到 {blocked_results}" + assert peak == 2 diff --git a/tests/unit/bootstrap/test_http_body_limits.py b/tests/unit/bootstrap/test_http_body_limits.py new file mode 100644 index 00000000..b2caddec --- /dev/null +++ b/tests/unit/bootstrap/test_http_body_limits.py @@ -0,0 +1,226 @@ +"""HTTP surface 的两阶段准入与并发上限(审计验收 P1-HTTP / P2-4)。 + +`_parse_content_length` 只校验 header 不读 body(第一阶段);`_read_body` 在 +认证通过后按已校验长度读(第三阶段)。中间的 limiter/认证在 body 之前,慢连接 +在读 body 前就被挡住。集成层起真实 HTTP server,端到端验证 413/400/503/正常, +并验证慢上传在占满全局连接额度后不能继续创建处理线程。 +""" + +from __future__ import annotations + +# These tests intentionally exercise the HTTP adapter's private admission primitives. +# pylint: disable=protected-access +import importlib +import io +import json +import os +import socket +import sys +import threading +import time + +import pytest + +pytestmark = pytest.mark.unit + +_BOOT_DIR = "bootstrap/http_server" +_CORE_DIR = os.path.join("bootstrap", "core") +for _p in (_BOOT_DIR, _CORE_DIR, "src"): + if _p not in sys.path: + sys.path.append(_p) + +_mod = importlib.import_module("bootstrap.http_server.__main__") # noqa: E402 +_parse_content_length = _mod._parse_content_length +_read_body = _mod._read_body +_MAX = _mod._MAX_BODY_BYTES +BoundedServer = _mod._BoundedThreadingHTTPServer + + +class _Headers: + def __init__(self, length: str | None): + self._d = {} if length is None else {"Content-Length": length} + + def get(self, key, default=None): + return self._d.get(key, default) + + +def test_parse_length_rejects_negative() -> None: + assert _parse_content_length(_Headers("-1"))[0] == 400 + + +def test_parse_length_rejects_non_numeric() -> None: + assert _parse_content_length(_Headers("abc"))[0] == 400 + + +def test_parse_length_rejects_oversized() -> None: + assert _parse_content_length(_Headers(str(_MAX + 1)))[0] == 413 + + +def test_parse_length_accepts_at_limit() -> None: + status, length = _parse_content_length(_Headers(str(_MAX))) + assert status == 200 + assert length == _MAX + + +def test_parse_length_zero_or_missing() -> None: + assert _parse_content_length(_Headers("0")) == (200, 0) + assert _parse_content_length(_Headers(None)) == (200, 0) + + +def test_read_body_returns_exact_bytes() -> None: + data = b"payload" + assert _read_body(io.BytesIO(data), len(data)) == data + assert _read_body(io.BytesIO(b""), 0) == b"" + + +# -- 集成:真实 HTTP server ------------------------------------------------- # + + +def _start_server(): + profiles = importlib.import_module("profiles") + http_mod = importlib.import_module("bootstrap.http_server.__main__") + srv = http_mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + httpd = http_mod._BoundedThreadingHTTPServer(("127.0.0.1", 0), srv._handler_cls()) + httpd.daemon_threads = True + port = httpd.server_address[1] + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + return httpd, port + + +def test_public_serve_rejects_non_loopback_before_socket_bind(monkeypatch) -> None: + """直接调用公开 serve() 也必须执行 DEV 绑定 guard。""" + profiles = importlib.import_module("profiles") + http_mod = importlib.import_module("bootstrap.http_server.__main__") + srv = http_mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + + def unexpected_bind(*_args, **_kwargs): + raise AssertionError("guard 必须在构造监听 socket 前执行") + + monkeypatch.setattr(http_mod, "_BoundedThreadingHTTPServer", unexpected_bind) + with pytest.raises(importlib.import_module("common.errors").ValidationError): + srv.serve("0.0.0.0", 0) + + +def test_public_serve_allows_real_loopback_bind(monkeypatch) -> None: + """安全地址仍走真实 socket bind;仅跳过阻塞的 serve_forever。""" + profiles = importlib.import_module("profiles") + http_mod = importlib.import_module("bootstrap.http_server.__main__") + srv = http_mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + monkeypatch.setattr(http_mod._BoundedThreadingHTTPServer, "serve_forever", lambda _self: None) + + srv.serve("127.0.0.1", 0) + + +def _post(port: int, body: bytes, content_length: str | None = None) -> tuple[int, dict]: + s = socket.create_connection(("127.0.0.1", port), timeout=5) + try: + headers = "POST /v1/list HTTP/1.1\r\nHost: 127.0.0.1\r\n" + if content_length is not None: + headers += f"Content-Length: {content_length}\r\n" + else: + headers += f"Content-Length: {len(body)}\r\n" + headers += "Content-Type: application/json\r\n\r\n" + s.sendall(headers.encode() + body) + data = s.recv(65536) + finally: + s.close() + head, _, payload = data.partition(b"\r\n\r\n") + status_line = head.split(b"\r\n", 1)[0] + code = int(status_line.split()[1]) + try: + return code, json.loads(payload) if payload else {} + except ValueError: + return code, {"raw": payload} + + +def test_http_rejects_oversized_body() -> None: + httpd, port = _start_server() + try: + code, _ = _post(port, b"x" * 10, content_length=str(_MAX + 1)) + assert code == 413 + finally: + httpd.shutdown() + + +def test_http_rejects_invalid_length() -> None: + httpd, port = _start_server() + try: + code, _ = _post(port, b"", content_length="-1") + assert code == 400 + finally: + httpd.shutdown() + + +def test_http_accepts_normal_request() -> None: + httpd, port = _start_server() + try: + code, _ = _post(port, json.dumps({"scope": {"org": "acme", "user": "alice"}}).encode()) + assert code == 200 + finally: + httpd.shutdown() + + +def test_http_concurrency_limit_rejects_excess() -> None: + """审计验收 P1-HTTP:占满全局连接额度后,多余连接快速被拒(503)。 + + 用一个故意阻塞的 handler 钉住连接槽,开满 _MAX_CONCURRENT_REQUESTS + N 个, + 断言多余的被 503 拒而非无限创建线程。 + """ + profiles = importlib.import_module("profiles") + http_mod = importlib.import_module("bootstrap.http_server.__main__") + srv = http_mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + handler = srv._handler_cls() + + # 用小额度 server 避免开几百连接 + class _TinyServer(http_mod._BoundedThreadingHTTPServer): + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self._slots = threading.BoundedSemaphore(2) + + httpd = _TinyServer(("127.0.0.1", 0), handler) + httpd.daemon_threads = True + port = httpd.server_address[1] + gate = threading.Event() + + # 用阻塞的 do_POST 占住槽 + class _Block(handler): + def handle_blocked_post(self): + gate.wait(timeout=3) + self.send_response(200) + self.end_headers() + + # 替换 handler 为阻塞版 + setattr(_Block, "do_POST", _Block.handle_blocked_post) + setattr(httpd, "RequestHandlerClass", _Block) + t = threading.Thread(target=httpd.serve_forever, daemon=True) + t.start() + + results = [] + + def fire(): + try: + s = socket.create_connection(("127.0.0.1", port), timeout=3) + s.sendall(b"POST /v1/x HTTP/1.1\r\nHost: x\r\nContent-Length: 0\r\n\r\n") + data = s.recv(256) + results.append(int(data.split()[1])) + s.close() + except OSError: + results.append(-1) + + # 开 5 个连接,额度 2,预期 2 个 200、3 个 503(或被拒) + threads = [threading.Thread(target=fire) for _ in range(5)] + for th in threads: + th.start() + time.sleep(0.05) # 错开让前两个先进 + time.sleep(0.5) + gate.set() # 放行阻塞的 + for th in threads: + th.join(timeout=3) + + httpd.shutdown() + accepted = results.count(200) + rejected = sum(1 for r in results if r in (503, -1)) + # 最多 2 个被处理,其余被拒 + assert accepted <= 2 + assert accepted + rejected == 5 diff --git a/tests/unit/bootstrap/test_http_slow_upload.py b/tests/unit/bootstrap/test_http_slow_upload.py new file mode 100644 index 00000000..a933837f --- /dev/null +++ b/tests/unit/bootstrap/test_http_slow_upload.py @@ -0,0 +1,57 @@ +"""HTTP 慢上传测试(审计验收 P1-HTTP)。 + +慢上传客户端只发 header + 部分 body,验证两阶段准入下 server 不会在读 body 阶段 +无限阻塞、不崩溃、连接最终被处理或超时关闭。 +""" + +from __future__ import annotations + +# These tests intentionally exercise the HTTP adapter's private server helpers. +# pylint: disable=protected-access +import importlib +import socket +import sys +import threading + +import pytest + +pytestmark = pytest.mark.unit + +for _p in ("bootstrap/http_server", "bootstrap/core", "src"): + if _p not in sys.path: + sys.path.append(_p) + +_mod = importlib.import_module("bootstrap.http_server.__main__") # noqa: E402 + + +def _start_server(): + profiles = importlib.import_module("profiles") + srv = _mod.HttpServer.build(profiles.load_config([profiles.OFFLINE])) + httpd = _mod._BoundedThreadingHTTPServer(("127.0.0.1", 0), srv._handler_cls()) + httpd.daemon_threads = True + port = httpd.server_address[1] + threading.Thread(target=httpd.serve_forever, daemon=True).start() + return httpd, port + + +def test_slow_upload_does_not_crash_server() -> None: + """慢上传:发 header + 部分 body 后停住,server 不崩,连接最终超时/关闭。""" + httpd, port = _start_server() + try: + s = socket.create_connection(("127.0.0.1", port), timeout=5) + # 声明 100 字节 body,只发部分 + head = ( + b"POST /v1/list HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n" + b"Content-Type: application/json\r\n\r\n" + ) + s.sendall(head + b'{"scope":') + s.settimeout(3) + try: + data = s.recv(4096) + except socket.timeout: + data = b"" + s.close() + # 关键:server 没崩,连接要么返回响应要么超时关闭(不无限挂住线程) + assert data == b"" or b"HTTP" in data + finally: + httpd.shutdown() diff --git a/tests/unit/bootstrap/test_server_security_config.py b/tests/unit/bootstrap/test_server_security_config.py new file mode 100644 index 00000000..f05f55de --- /dev/null +++ b/tests/unit/bootstrap/test_server_security_config.py @@ -0,0 +1,231 @@ +"""Server 安全装配的 fail-closed 选择规则。 + +装配面从三个独立的 ``_build_authenticator`` / ``_build_rate_limiter`` / +``_build_argon2_guard`` 收敛成一个 ``build_security_runtime``(返回 ``SecurityRuntime``)后, +这里测的仍是同三件事:**多实例无 default 拒绝启动**、**能力默认取保守侧**、 +**分岔由 capability 决定而非认证 target 名**。 +""" + +from __future__ import annotations + +import importlib +import os +import sys + +import pytest + +_CORE_DIR = os.path.join("bootstrap", "core") +for _path in (_CORE_DIR, "src"): + if _path not in sys.path: + sys.path.append(_path) + +server = importlib.import_module("server") # noqa: E402 +Config = importlib.import_module("config").Config # noqa: E402 +ValidationError = importlib.import_module("common.errors").ValidationError # noqa: E402 +register_plugins = importlib.import_module("common.bootstrap").register_plugins # noqa: E402 +Factory = importlib.import_module("common.factory.factory").Factory # noqa: E402 +AssemblyContext = importlib.import_module("config.context").AssemblyContext # noqa: E402 +_authz = importlib.import_module("common.security.authorization") # noqa: E402 +AuthorizationProducer = _authz.AuthorizationProducer # noqa: E402 +_auth = importlib.import_module("common.security.authentication") # noqa: E402 +Authenticator = _auth.Authenticator # noqa: E402 +AuthProducer = _auth.AuthProducer # noqa: E402 + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True) +def _fresh_instances(): + """每个用例独立装配:具名实例缓存会让同名 ``security.only`` 跨用例复用。 + + 清空后预置 ``authorizer.default``:生产路径上它由 ``build_kernel`` 建立, + ``build_security_runtime`` 按具名引用命中的是**同一个实例**。 + 这里摆上那一步的产物而不建整个内核,也不给各用例塞内联 authorizer——内联会新建 + 另一份,等于让用例悄悄绕过它依赖的那条共享契约。 + """ + Factory.reset_all() + register_plugins() + AuthorizationProducer.put( + "default", + AuthorizationProducer.build( + "standard", + {"grant_store": {"target": "memory"}, "delegation_store": {"target": "memory"}}, + AssemblyContext(), + ), + ) + yield + Factory.reset_all() + + +class _CustomAuthenticator(Authenticator): + """第三方认证实现:核心不认识它的 target 名,只读它自报的 capability。""" + + def authenticate(self, credentials): + raise NotImplementedError + + def mode(self) -> str: + return "custom_remote" + + def requires_loopback_binding(self) -> bool: + return False + + def requires_concurrency_guard(self) -> bool: + return False + + def health(self) -> None: + return None + + +def _config(data: dict) -> object: + register_plugins() + return Config.from_dict(data) + + +# -- 歧义配置拒绝启动(F05 §装配不变量 3)------------------------------------ # + + +def test_multiple_security_instances_without_default_are_rejected() -> None: + config = _config( + { + "security": { + "local": {"target": "standard", "params": {"authenticator": {"target": "dev"}}}, + "production": { + "target": "standard", + "params": {"authenticator": {"target": "dev"}}, + }, + } + } + ) + + with pytest.raises(ValidationError, match="多个具名实例"): + server.build_security_runtime(config) + + +def test_default_wins_when_multiple_security_instances_exist() -> None: + config = _config( + { + "security": { + "other": { + "target": "standard", + "params": { + "authenticator": { + "target": "api_key", + "params": {"root_api_key": "root-key-for-tests"}, + } + }, + }, + "default": { + "target": "standard", + "params": {"authenticator": {"target": "dev"}}, + }, + } + } + ) + + assert server.build_security_runtime(config).authenticator.mode() == "dev" + + +def test_single_unnamed_instance_is_used_without_default() -> None: + """只有一个实例时不存在歧义,不强求叫 ``default``。""" + config = _config( + { + "security": {"only": {"target": "standard", "params": {"authenticator": "dev_auth"}}}, + "authenticator": {"dev_auth": {"target": "dev"}}, + } + ) + + assert server.build_security_runtime(config).authenticator.mode() == "dev" + + +# -- 无 security 段回落 DEV(显式、可切换,非隐式不可改)--------------------- # + + +def test_missing_security_section_falls_back_to_dev_with_a_warning(caplog) -> None: + """回落不是静默的:日志必须说明「现在没有认证」以及怎么改。""" + config = _config({}) + + with caplog.at_level("WARNING"): + runtime = server.build_security_runtime(config) + + assert runtime.authenticator.mode() == "dev" + assert any("security" in r.message for r in caplog.records) + + +def test_dev_fallback_still_enforces_loopback_binding() -> None: + """回落 DEV 不等于放开绑定:非 loopback 由 binding_policy 在绑定前拒绝。""" + runtime = server.build_security_runtime(_config({})) + + runtime.binding_policy.check( + "127.0.0.1", requires_loopback=runtime.authenticator.requires_loopback_binding() + ) + with pytest.raises(ValidationError): + runtime.binding_policy.check( + "0.0.0.0", requires_loopback=runtime.authenticator.requires_loopback_binding() + ) + + +# -- 分岔由 capability 决定,不看 target 名(F05 §依据 capability 做安全决策)- # + + +def test_custom_authenticator_does_not_require_a_target_name_branch() -> None: + """核心不认识 ``custom_remote_test``,仍能按它自报的 capability 装配出正确默认。""" + AuthProducer.register("custom_remote_test")(lambda _config: _CustomAuthenticator()) + try: + config = _config( + { + "security": { + "only": { + "target": "standard", + "params": {"authenticator": {"target": "custom_remote_test"}}, + } + } + } + ) + runtime = server.build_security_runtime(config) + + assert runtime.authenticator.mode() == "custom_remote" + # 声明可远程暴露 -> 默认限流;声明不需要预算 -> Server 不把预算传给中间件。 + assert type(runtime.rate_limiter).__name__ == "TokenBucketLimiter" + assert server.Server(config, None, runtime).workload_guard is None + finally: + # Producer 注册表没有运行期卸载语义;测试只需恢复本次临时注册。 + vars(AuthProducer)["_registry"].pop("custom_remote_test", None) + + +def test_loopback_only_authenticator_defaults_to_no_rate_limit() -> None: + """dev 声明 requires_loopback_binding:无远端攻击面,默认限流只会卡住本地调试。""" + runtime = server.build_security_runtime(_config({})) + + assert runtime.authenticator.requires_loopback_binding() is True + assert type(runtime.rate_limiter).__name__ == "NoRateLimit" + + +def test_workload_guard_is_withheld_when_the_authenticator_declares_no_need() -> None: + """预算是否传给中间件由认证实现的成本模型决定,不由 Server 猜。 + + api_key 每次 authenticate 跑一次 Argon2id verify,未声明豁免(基类默认 True) + -> 拿得到预算;dev 显式声明无重型校验 -> 拿不到。 + """ + api_key_config = _config( + { + "security": { + "default": { + "target": "standard", + "params": { + "authenticator": { + "target": "api_key", + "params": {"root_api_key": "root-key-for-tests"}, + } + }, + } + } + } + ) + expensive = server.build_security_runtime(api_key_config) + assert expensive.authenticator.requires_concurrency_guard() is True + assert server.Server(api_key_config, None, expensive).workload_guard is not None + + dev_config = _config({}) + cheap = server.build_security_runtime(dev_config) + assert cheap.authenticator.requires_concurrency_guard() is False + assert server.Server(dev_config, None, cheap).workload_guard is None diff --git a/tests/unit/common/security/authentication/test_authentication_impl.py b/tests/unit/common/security/authentication/test_authentication_impl.py new file mode 100644 index 00000000..b741648a --- /dev/null +++ b/tests/unit/common/security/authentication/test_authentication_impl.py @@ -0,0 +1,343 @@ +"""common.security.authentication.authentication_impl: 三个实现的正反路径与错误消息一致性。""" + +from __future__ import annotations + +import pytest + +from common.bootstrap import register_plugins +from common.errors import AuthenticationError, ValidationError +from common.security.authentication.authentication_impl.api_key_authenticator import ( + ApiKeyAuthenticator, +) +from common.security.authentication.authentication_impl.dev_authenticator import ( + DevAuthenticator, +) +from common.security.authentication.authentication_impl.trusted_authenticator import ( + TrustedAuthenticator, +) +from common.security.authentication.base import AuthProducer +from common.security.authentication.key_store import KeyStoreProducer, PrincipalKeyStore +from common.security.types import AuthContext, Credentials, Role +from common.type_def.scope import Scope +from config.context import AssemblyContext + +pytestmark = pytest.mark.unit + +_ROOT_KEY = "root-key-for-tests" + + +@pytest.fixture(scope="module") +def key_store() -> PrincipalKeyStore: + register_plugins() + return KeyStoreProducer.build("memory", {}, AssemblyContext()) + + +@pytest.fixture(scope="module") +def alice_key(key_store) -> str: + return key_store.issue(Scope(org="acme", user="alice"), Role.USER) + + +@pytest.fixture(scope="module") +def agent_key(key_store) -> str: + return key_store.issue(Scope(org="acme", agent="assistant"), Role.USER) + + +# -- DevAuthenticator ------------------------------------------------------- # + + +def test_dev_root_is_a_named_principal_not_an_empty_scope() -> None: + """ROOT 由 ``role`` 表达,不由 actor 的形状表达(F05 §授权不变量 1)。 + + 旧行为是「空 ``Scope()`` 即管理员」:授权侧只要漏判一次 actor 就等于放行,且审计 + 里所有 ROOT 动作都记成同一个无名主体、追不到人。现在 dev 是具名的 + ``system/dev``,权限完全来自 ``role is Role.ROOT``。 + """ + ctx = DevAuthenticator().authenticate(Credentials()) + assert ctx.actor == Scope(org="system", user="dev") + assert ctx.role is Role.ROOT + + +def test_dev_ignores_all_credentials() -> None: + dev = DevAuthenticator() + assert dev.authenticate(Credentials(api_key="anything")).role is Role.ROOT + assert dev.mode() == "dev" + assert dev.health() is None + + +# -- TrustedAuthenticator --------------------------------------------------- # + + +def _gateway_headers(**overrides) -> dict[str, str]: + headers = { + "x-org-id": "acme", + "x-principal-type": "user", + "x-principal-id": "alice", + } + headers.update(overrides) + return headers + + +def test_trusted_accepts_registered_principal(key_store, alice_key) -> None: + auth = TrustedAuthenticator(key_store=key_store) + ctx = auth.authenticate(Credentials(headers=_gateway_headers())) + assert ctx.actor == Scope(org="acme", user="alice") + assert ctx.role is Role.USER + assert auth.mode() == "trusted" + + +def test_trusted_ignores_role_header(key_store, alice_key) -> None: + """§2.2.2 关键设计:header 说「你是谁」,框架自己查「你能干什么」。 + + 这条防的是网关被攻破或误配时的任意提权。 + """ + auth = TrustedAuthenticator(key_store=key_store) + ctx = auth.authenticate( + Credentials(headers=_gateway_headers(**{"x-role": "root", "x-principal-role": "root"})) + ) + assert ctx.role is Role.USER + + +def test_trusted_rejects_unregistered_principal(key_store) -> None: + """未注册主体一律拒绝,不默认给 USER 放行。""" + auth = TrustedAuthenticator(key_store=key_store) + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(headers=_gateway_headers(**{"x-principal-id": "nobody"}))) + + +@pytest.mark.parametrize( + "overrides", + [ + {"x-principal-type": "admin"}, # 非 user/agent + {"x-principal-type": ""}, + {"x-org-id": ""}, + {"x-principal-id": ""}, + {"x-org-id": " "}, # 只有空白 + ], +) +def test_trusted_rejects_malformed_headers(key_store, overrides) -> None: + auth = TrustedAuthenticator(key_store=key_store) + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(headers=_gateway_headers(**overrides))) + + +def test_trusted_requires_gateway_key_when_configured(key_store, alice_key) -> None: + auth = TrustedAuthenticator(key_store=key_store, gateway_key="shared-secret") + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(headers=_gateway_headers())) # 没带 + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key="wrong", headers=_gateway_headers())) + ctx = auth.authenticate(Credentials(api_key="shared-secret", headers=_gateway_headers())) + assert ctx.actor == Scope(org="acme", user="alice") + + +def test_trusted_gateway_key_survives_non_ascii(key_store, alice_key) -> None: + """compare_digest 的 str 版对非 ASCII 抛 TypeError → 500 而非 401。""" + auth = TrustedAuthenticator(key_store=key_store, gateway_key="shared-secret") + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key="密钥", headers=_gateway_headers())) + + +def test_trusted_does_not_accept_acting_user_header(key_store, agent_key) -> None: + """``X-Acting-User`` 不再产生任何跨主体授权(F05 §从 header 直接产生 Delegation)。 + + 这个 header 曾直接写进 ``AuthContext.acting_user``,旧 PermissionManager 据此放行 + agent 代 user 的读写。网关的一句声明就成了跨主体授权结论,中间没有服务端事实。 + """ + auth = TrustedAuthenticator(key_store=key_store) + headers = _gateway_headers( + **{ + "x-principal-type": "agent", + "x-principal-id": "assistant", + "x-acting-user": "alice", + } + ) + ctx = auth.authenticate(Credentials(headers=headers)) + assert ctx.actor == Scope(org="acme", agent="assistant") + assert not hasattr(ctx, "acting_user") + assert ctx.delegation_id == "" + + +def test_trusted_carries_delegation_id_without_validating_it(key_store, agent_key) -> None: + """``X-Delegation-Id`` 只是原样带过来的**标识**,认证层不作任何有效性判断。 + + 有效性(存在、未撤销、未过期、覆盖本次动作)由 Authorizer 回 DelegationStore 复核。 + 认证层这里放行一个查无此据的 id 是对的——伪造 id 的拒绝发生在授权层,且与 + 「已撤销」「已过期」共用同一个 reason,不构成委托枚举侧信道。 + """ + auth = TrustedAuthenticator(key_store=key_store) + headers = _gateway_headers( + **{ + "x-principal-type": "agent", + "x-principal-id": "assistant", + "x-delegation-id": " d-42 ", + } + ) + ctx = auth.authenticate(Credentials(headers=headers)) + assert ctx.delegation_id == "d-42" + + +# -- ApiKeyAuthenticator ---------------------------------------------------- # + + +def test_api_key_root_is_a_named_principal(key_store) -> None: + """root key 换到的同样是具名主体 + ROOT 角色,不是空 Scope 管理员。 + + ``StandardAuthorizer`` 第 2 步对 ``actor == Scope()`` 直接 deny:空 actor 现在 + 是「上下文不完整」的信号,不再是任何一种权限。 + """ + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + ctx = auth.authenticate(Credentials(api_key=_ROOT_KEY)) + assert ctx.actor == Scope(org="system", user="root") + assert ctx.role is Role.ROOT + assert auth.mode() == "api_key" + + +def test_api_key_resolves_principal(key_store, alice_key) -> None: + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + ctx = auth.authenticate(Credentials(api_key=alice_key)) + assert ctx.actor == Scope(org="acme", user="alice") + assert ctx.role is Role.USER + + +@pytest.mark.parametrize("bad", ["", "wrong-key", "密钥非ascii", "a" * 500]) +def test_api_key_rejects_bad_keys(key_store, bad) -> None: + """非 ASCII 必须走 AuthenticationError(401),不能是 TypeError(500)。""" + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key=bad)) + + +def test_api_key_works_without_root_key(key_store, alice_key) -> None: + """root key 已轮换掉、只留主体 key 的部署是合法的。""" + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key="") + assert auth.authenticate(Credentials(api_key=alice_key)).role is Role.USER + with pytest.raises(AuthenticationError): + auth.authenticate(Credentials(api_key=_ROOT_KEY)) + + +class _StoreWithoutRevocation(PrincipalKeyStore): + """可插拔 Store 漏实现 is_revoked 的最小桩(继承默认 NotImplementedError)。""" + + def issue(self, actor: Scope, role: Role) -> str: + raise NotImplementedError + + def resolve(self, api_key: str) -> AuthContext | None: + return None + + def revoke(self, key_fp: str) -> None: + return None + + def get_role(self, actor: Scope) -> Role | None: + return Role.USER + + def health(self) -> None: + return None + + +def test_api_key_rejects_store_without_revocation_query() -> None: + """可插拔 KeyStore 缺 is_revoked 时,认证期就拒绝(P1-3)。 + + 不让 PEP 在首个授权请求才发现 NotImplementedError(500)--F05 §装配不变量 + 「不健康能力启动期拒绝」在认证边界的落地。 + """ + auth = ApiKeyAuthenticator(_StoreWithoutRevocation()) + with pytest.raises(ValidationError): + auth.authenticate(Credentials(api_key="any-key")) + + +def test_api_key_store_is_revoked_drives_revocation(key_store) -> None: + """InMemoryKeyStore 覆盖 is_revoked:撤销前 False、撤销后 True(P1-3 在线复核基础)。""" + fresh = key_store.issue(Scope(org="acme", user="carol"), Role.USER) + auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + ctx = auth.authenticate(Credentials(api_key=fresh)) + assert key_store.is_revoked(ctx.credential_id) is False + key_store.revoke(ctx.credential_id) + assert key_store.is_revoked(ctx.credential_id) is True + + +def test_trusted_credential_id_changes_with_gateway_key(key_store) -> None: + """网关凭据轮换后,同主体得到不同 credential_id(P2-1)。 + + credential_id 含 gateway_key 指纹:旧凭据绑定的委托不能迁移到新凭据。 + """ + headers = _gateway_headers() + before = TrustedAuthenticator(key_store=key_store, gateway_key="gw-v1").authenticate( + Credentials(api_key="gw-v1", headers=headers) + ) + after = TrustedAuthenticator(key_store=key_store, gateway_key="gw-v2").authenticate( + Credentials(api_key="gw-v2", headers=headers) + ) + assert before.credential_id + assert before.credential_id != after.credential_id + + +# -- 跨实现的一致性 ---------------------------------------------------------- # + + +def test_all_failures_share_one_message(key_store) -> None: + """错误消息若区分「主体不存在」与「凭据错误」,就成了主体枚举侧信道。 + + 这条是防止后续维护者「好心」加详细错误消息的护栏。 + """ + api_key_auth = ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY) + trusted_auth = TrustedAuthenticator(key_store=key_store, gateway_key="s") + + messages = set() + for auth, creds in ( + (api_key_auth, Credentials()), # 凭据缺失 + (api_key_auth, Credentials(api_key="wrong")), # 凭据错误 + (trusted_auth, Credentials(headers={})), # 声明缺失 + (trusted_auth, Credentials(headers=_gateway_headers())), # 网关密钥缺失 + ( + trusted_auth, + Credentials(api_key="s", headers=_gateway_headers(**{"x-principal-id": "ghost"})), + ), # 主体不存在 + ): + with pytest.raises(AuthenticationError) as exc: + auth.authenticate(creds) + messages.add(str(exc.value)) + + assert messages == {"authentication failed"} + + +def test_authenticate_never_returns_none(key_store, alice_key) -> None: + """认证只有成功与失败两种结果——返回 None 会诱导 fail-open 分支。""" + for auth, creds in ( + (DevAuthenticator(), Credentials()), + (TrustedAuthenticator(key_store=key_store), Credentials(headers=_gateway_headers())), + ( + ApiKeyAuthenticator(key_store=key_store, root_api_key=_ROOT_KEY), + Credentials(api_key=alice_key), + ), + ): + assert isinstance(auth.authenticate(creds), AuthContext) + + +def test_producer_builds_each_mode() -> None: + register_plugins() + ctx = AssemblyContext() + assert AuthProducer.build("dev", {}, ctx).mode() == "dev" + assert ( + AuthProducer.build("trusted", {"allow_no_gateway_key": True}, ctx).mode() + == "trusted" + ) + assert ( + AuthProducer.build("api_key", {"root_api_key": _ROOT_KEY}, ctx).mode() == "api_key" + ) + + +def test_trusted_build_requires_gateway_key_by_default() -> None: + """审计 P1-2:未配 gateway_key 时默认拒绝装配,fail-closed。 + + 未配置时全部身份 header 可被任意调用方伪造;让它默认启动等于把信任边界 + 留给「配没配网关」这个隐含假设。显式 opt-in 才放行。 + """ + register_plugins() + ctx = AssemblyContext() + with pytest.raises(ValidationError): + AuthProducer.build("trusted", {}, ctx) + # 显式 opt-in 后可装配 + built = AuthProducer.build("trusted", {"allow_no_gateway_key": True}, ctx) + assert built.mode() == "trusted" + # 配了 gateway_key 自然可装配 + assert AuthProducer.build("trusted", {"gateway_key": "k"}, ctx).mode() == "trusted" diff --git a/tests/unit/common/security/authentication/test_authenticator.py b/tests/unit/common/security/authentication/test_authenticator.py new file mode 100644 index 00000000..893ae9ae --- /dev/null +++ b/tests/unit/common/security/authentication/test_authenticator.py @@ -0,0 +1,125 @@ +"""common.security.authentication.base / key_store: 抽象契约与工厂注册。""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from common.bootstrap import register_plugins +from common.factory.factory import Factory +from common.security.authentication.base import Authenticator, AuthProducer +from common.security.authentication.key_store import KeyStoreProducer, PrincipalKeyStore +from common.security.types import Credentials +from config.context import AssemblyContext + +pytestmark = pytest.mark.unit + + +def test_registration_is_idempotent() -> None: + register_plugins() + first = AuthProducer.known() + register_plugins() + assert AuthProducer.known() == first + + +def test_all_three_modes_registered() -> None: + register_plugins() + assert AuthProducer.known() == ["api_key", "dev", "trusted"] + assert KeyStoreProducer.known() == ["memory"] + + +def test_top_names_enter_config_validation() -> None: + """顶层段名要进 Factory.known_top_names(),否则配置解析期会拒掉这两段。""" + register_plugins() + tops = Factory.known_top_names() + assert "authenticator" in tops + assert "key_store" in tops + + +def test_abstract_contract_cannot_be_partially_implemented() -> None: + class Incomplete(Authenticator): + def authenticate(self, credentials: Credentials): # 缺 mode / health + raise NotImplementedError + + with pytest.raises(TypeError): + Incomplete() # type: ignore[abstract] + + +def test_capability_declarations_default_to_fail_closed() -> None: + """未覆写的 capability 取保守侧:第三方实现不声明就不享受放宽。 + + ``requires_loopback_binding`` 默认 True——没声明具备远程暴露保护的实现不许 + 绑非本机地址;``requires_concurrency_guard`` 默认 True——没声明成本模型的 + 校验器不许绕过并发预算。两处默认反过来都是 fail-open。 + """ + + class Minimal(Authenticator): + def authenticate(self, credentials: Credentials): + raise NotImplementedError + + def mode(self) -> str: + return "minimal" + + def health(self) -> None: + return None + + minimal = Minimal() + assert minimal.requires_loopback_binding() is True + assert minimal.requires_concurrency_guard() is True + + +def test_key_store_abstract_contract() -> None: + class Incomplete(PrincipalKeyStore): + def issue(self, actor, role): + raise NotImplementedError + + with pytest.raises(TypeError): + Incomplete() # type: ignore[abstract] + + +def test_credentials_is_frozen() -> None: + creds = Credentials(api_key="k") + with pytest.raises(FrozenInstanceError): + creds.api_key = "other" # type: ignore[misc] + + +def test_credentials_defaults_are_empty() -> None: + creds = Credentials() + assert creds.api_key == "" + assert creds.headers == {} + assert creds.peer_address == "" + + +def test_credentials_repr_hides_secrets() -> None: + """凭据会进日志与异常回溯:明文 key 不能出现在 repr 里(F05 §Credentials)。""" + assert "super-secret" not in repr(Credentials(api_key="super-secret")) + + +def test_mode_is_an_open_string_not_a_closed_enum() -> None: + """F05 拒绝以封闭枚举驱动核心分支:第三方实现不改核心即可声明自己的模式名。""" + register_plugins() + mode = AuthProducer.build("dev", {}, AssemblyContext()).mode() + assert isinstance(mode, str) + assert mode == "dev" + + +def test_interface_module_does_not_import_impl() -> None: + """顶层 .py 是纯抽象,不 import *_impl/(与 control 同规)。 + + 检查 AST 的 import 节点,不是文本匹配——docstring 里提到实现包名是正常的。 + """ + import ast + + import common.security.authentication.base as auth_mod + import common.security.authentication.key_store as ks_mod + + for mod in (auth_mod, ks_mod): + tree = ast.parse(open(mod.__file__, encoding="utf-8").read()) + imported: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported += [a.name for a in node.names] + elif isinstance(node, ast.ImportFrom) and node.module: + imported.append(node.module) + assert not [name for name in imported if "_impl" in name], mod.__name__ diff --git a/tests/unit/common/security/authentication/test_credential_registry.py b/tests/unit/common/security/authentication/test_credential_registry.py new file mode 100644 index 00000000..23edde13 --- /dev/null +++ b/tests/unit/common/security/authentication/test_credential_registry.py @@ -0,0 +1,112 @@ +"""CredentialStatusRegistry:PEP 持有的凭据撤销在线复核入口(F05 §认证不变量 6)。""" + +from __future__ import annotations + +import pytest + +from common.errors import ValidationError +from common.security.authentication.credential_registry import CredentialStatusRegistry +from common.security.authentication.key_store import PrincipalKeyStore +from common.security.types import AuthContext, Role +from common.type_def.scope import Scope + +pytestmark = pytest.mark.unit + + +class _StubStore(PrincipalKeyStore): + """覆盖 is_revoked 的可撤销 Store 桩。""" + + def __init__(self, revoked_ids: set[str] | None = None) -> None: + self._revoked = revoked_ids or set() + + def issue(self, actor: Scope, role: Role) -> str: + raise NotImplementedError + + def resolve(self, api_key: str) -> AuthContext | None: + raise NotImplementedError + + def revoke(self, key_fp: str) -> None: + self._revoked.add(key_fp) + + def get_role(self, actor: Scope) -> Role | None: + return Role.USER + + def is_revoked(self, credential_id: str) -> bool: + return credential_id in self._revoked + + def health(self) -> None: + return None + + +class _StoreWithoutRevocation(PrincipalKeyStore): + """漏实现 is_revoked 的桩(继承默认 NotImplementedError)。""" + + def issue(self, actor: Scope, role: Role) -> str: + raise NotImplementedError + + def resolve(self, api_key: str) -> AuthContext | None: + return None + + def revoke(self, key_fp: str) -> None: + return None + + def get_role(self, actor: Scope) -> Role | None: + return Role.USER + + def health(self) -> None: + return None + + +def _auth( + *, + credential_type: str, + credential_id: str, + auth_method: str = "default", + credential_issuer: str = "default", +) -> AuthContext: + """Round4 P1-4: 添加 credential_issuer 参数,默认值与 auth_method 一致。""" + return AuthContext( + actor=Scope(org="acme", user="alice"), + credential_type=credential_type, + credential_id=credential_id, + auth_method=auth_method, + credential_issuer=credential_issuer, + ) + + +def test_is_revoked_routes_to_the_registered_store() -> None: + reg = CredentialStatusRegistry() + # Round3: register 需要 (credential_type, authenticator_name, store) + reg.register("api_key", "default", _StubStore({"revoked-1"})) + assert reg.is_revoked(_auth(credential_type="api_key", credential_id="revoked-1")) is True + assert reg.is_revoked(_auth(credential_type="api_key", credential_id="active-1")) is False + + +def test_unregistered_credential_type_is_not_revoked() -> None: + """Round7 P1-3: 未注册的 issuer 必须 fail-closed(抛出 ValidationError)。""" + reg = CredentialStatusRegistry() + with pytest.raises(ValidationError, match="未注册到 CredentialStatusRegistry"): + reg.is_revoked(_auth(credential_type="dev", credential_id="dev-1")) + + +def test_empty_credential_id_is_not_revoked() -> None: + reg = CredentialStatusRegistry() + # Round3: register 需要 (credential_type, authenticator_name, store) + reg.register("api_key", "default", _StubStore()) + assert reg.is_revoked(_auth(credential_type="api_key", credential_id="")) is False + + +def test_health_rejects_store_without_is_revoked_override() -> None: + """注册了未覆盖 is_revoked 的 Store,启动期 fail-closed(P1-3)。""" + reg = CredentialStatusRegistry() + # Round3: register 需要 (credential_type, authenticator_name, store) + reg.register("api_key", "default", _StoreWithoutRevocation()) + with pytest.raises(ValidationError): + reg.health() + + +def test_health_passes_when_store_overrides_is_revoked() -> None: + reg = CredentialStatusRegistry() + # Round3: register 需要 (credential_type, authenticator_name, store) + reg.register("api_key", "default", _StubStore()) + assert reg.health() is None diff --git a/tests/unit/common/security/authentication/test_key_store.py b/tests/unit/common/security/authentication/test_key_store.py new file mode 100644 index 00000000..c035331f --- /dev/null +++ b/tests/unit/common/security/authentication/test_key_store.py @@ -0,0 +1,349 @@ +"""common.security.authentication.key_store: 签发、解析、撤销、常时间与「不存明文」回归防线。""" + +from __future__ import annotations + +# The plaintext-retention assertion must inspect the in-memory registry directly. +# pylint: disable=protected-access +import json +import time +from statistics import median + +import pytest + +from common.bootstrap import register_plugins +from common.errors import PermissionDeniedError, ValidationError +from common.security.authentication.key_store import ( + KeyStoreProducer, + fingerprint, + generate_api_key, +) +from common.security.types import Role +from common.type_def.scope import Scope +from config.context import AssemblyContext + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def store(): + """module 作用域:Argon2 的 dummy hash 每次构造约 200ms,不必每条测试重算。""" + register_plugins() + return KeyStoreProducer.build("memory", {}, AssemblyContext()) + + +# -- issue ------------------------------------------------------------------ # + + +def test_cannot_issue_root_key(store) -> None: + """§3.2 明确禁止:ROOT 只能来自配置声明的 Root API Key。""" + with pytest.raises(PermissionDeniedError): + store.issue(Scope(org="acme", user="alice"), Role.ROOT) + + +@pytest.mark.parametrize( + "actor", + [ + Scope(org="acme"), # 既非 user 也非 agent + Scope(org="acme", user="alice", agent="a1"), # 两者都有 + Scope(user="alice"), # 无 org + ], +) +def test_issue_rejects_malformed_principal_scope(store, actor) -> None: + with pytest.raises(ValidationError): + store.issue(actor, Role.USER) + + +def test_issued_keys_are_high_entropy_and_unique(store) -> None: + keys = {store.issue(Scope(org="acme", user=f"u{i}"), Role.USER) for i in range(5)} + assert len(keys) == 5 + assert all(len(k) == 43 for k in keys) # token_urlsafe(32) → 256 bit + + +def test_generate_api_key_is_unique() -> None: + assert len({generate_api_key() for _ in range(100)}) == 100 + + +# -- resolve ---------------------------------------------------------------- # + + +def test_resolve_returns_bound_identity(store) -> None: + key = store.issue(Scope(org="acme", user="alice"), Role.ADMIN) + ctx = store.resolve(key) + assert ctx is not None + assert ctx.actor == Scope(org="acme", user="alice") + assert ctx.role is Role.ADMIN + assert ctx.credential_id == fingerprint(key) + + +def test_api_key_auth_carries_no_delegation(store) -> None: + """API key 证明的是「这把 key 属于谁」,不含任何代操作关系。 + + 委托只能来自 DelegationStore 里的服务端记录(F05 §Delegation)。 + """ + key = store.issue(Scope(org="acme", agent="bot1"), Role.USER) + ctx = store.resolve(key) + assert ctx is not None + assert ctx.delegation_id == "" + + +def test_resolve_misses_on_wrong_key(store) -> None: + store.issue(Scope(org="acme", user="wrong-key-probe"), Role.USER) + assert store.resolve(generate_api_key()) is None + + +def test_resolve_does_not_raise_on_garbage(store) -> None: + for garbage in ("", "x", "中文密钥", "a" * 500): + assert store.resolve(garbage) is None + + +# -- revoke ----------------------------------------------------------------- # + + +def test_revoke_takes_effect_immediately_and_is_idempotent(store) -> None: + actor = Scope(org="acme", user="revoked-user") + key = store.issue(actor, Role.USER) + assert store.resolve(key) is not None + + store.revoke(fingerprint(key)) + assert store.resolve(key) is None + assert store.get_role(actor) is None + + store.revoke(fingerprint(key)) # 幂等 + store.revoke("nonexistent-fingerprint") + + +# -- get_role --------------------------------------------------------------- # + + +def test_get_role_backs_trusted_mode(store) -> None: + """TRUSTED 模式据此实现「role 不从 header 读」。""" + actor = Scope(org="acme", agent="gateway-bot") + assert store.get_role(actor) is None + store.issue(actor, Role.ADMIN) + assert store.get_role(actor) is Role.ADMIN + + +def test_role_is_principal_scoped_not_session_scoped(store) -> None: + """role 按 principal 索引(§3.1),不含 session。 + + 同一 principal 换 session 登录仍应查到同一 role;session 进 role_key 会让 + TRUSTED 的 get_role(actor 来自网关、不带 session)查不到已注册主体。 + """ + actor = Scope(org="acme", user="sess-user") + key = store.issue(actor, Role.USER) + + # 网关声明的 actor 不带 session,但能查到 role + assert store.get_role(Scope(org="acme", user="sess-user")) is Role.USER + store.revoke(fingerprint(key)) + + +def test_revoking_one_key_keeps_role_for_other_keys_of_same_principal(store) -> None: + """同 principal 多 key 共用一个 role 条目:revoke 一把不能让另一把失效。 + + 回归审计 P2-2:此前 revoke 无条件 pop ``_roles``,导致同 principal 的其它 + 有效 key 一起失去角色。 + """ + actor = Scope(org="acme", user="multi-key") + key_a = store.issue(actor, Role.USER) + key_b = store.issue(actor, Role.USER) # 同 role,允许多 key + + store.revoke(fingerprint(key_a)) + # key_b 仍有效,role 仍在 + assert store.resolve(key_b) is not None + assert store.get_role(actor) is Role.USER + + store.revoke(fingerprint(key_b)) + assert store.get_role(actor) is None + + +def test_issue_rejects_conflicting_role_for_same_principal(store) -> None: + """审计验收 P2-role:同 principal 已有不同 role 的 key 时拒绝签发。 + + role 是 principal 唯一权威状态,不是每把 key 的可冲突副本。否则 issue 覆盖 + _roles 后 resolve(读 record.role)与 get_role(读 _roles)返回不一致。 + """ + actor = Scope(org="acme", user="role-conflict") + key = store.issue(actor, Role.USER) + try: + with pytest.raises(ValidationError): + store.issue(actor, Role.ADMIN) + finally: + store.revoke(fingerprint(key)) + # revoke 全部后可重新签发不同 role + store.issue(actor, Role.ADMIN) + assert store.get_role(actor) is Role.ADMIN + + +def test_revoke_recomputes_role_order_independent(store) -> None: + """审计验收 P2-role:revoke 按剩余有效 key 重算 role,与撤销顺序无关。 + + 覆盖「USER+ADMIN 两 key 分别按两种顺序撤销」--但 issue 禁止同 principal 不同 + role,故此处验证同 role 多 key 的撤销:revoke 任一把,剩余 key 的 role 仍在; + revoke 全部后 role 清空。重点是不再有「残留被撤销 key 的 role」。 + """ + actor = Scope(org="acme", user="revoke-order") + key_a = store.issue(actor, Role.USER) + key_b = store.issue(actor, Role.USER) # 同 role,允许多 key + + # revoke 一把,另一把仍撑住 role + store.revoke(fingerprint(key_a)) + assert store.get_role(actor) is Role.USER + assert store.resolve(key_b) is not None + + # revoke 第二把,role 清空 + store.revoke(fingerprint(key_b)) + assert store.get_role(actor) is None + + +def test_concurrent_issue_conflicting_role_is_atomic(store) -> None: + """验收第三次 P3:两线程并发为同 principal 签 USER/ADMIN,恰好一个成功一个冲突。 + + 严格断言(审计第三次):join(timeout) 确认线程退出、捕获非 ValidationError 异常 + 上抛、结果数 2 / 成功 1 / 冲突 1。实现本身经审计 20 轮强制同拍攻击验证。 + """ + import threading + + actor = Scope(org="acme", user="race-principal-strict") + barrier = threading.Barrier(2) + results: list = [] + errors: list = [] + + def attempt(role): + try: + barrier.wait(timeout=5) # 两线程同时通过,最大化竞态窗口 + except threading.BrokenBarrierError as exc: + errors.append(exc) + return + try: + key = store.issue(actor, role) + results.append(("ok", role, key)) + except ValidationError: + results.append(("conflict", role, None)) + except Exception as exc: # 非 ValidationError 不该发生,上抛 + errors.append(exc) + + t1 = threading.Thread(target=attempt, args=(Role.USER,)) + t2 = threading.Thread(target=attempt, args=(Role.ADMIN,)) + t1.start() + t2.start() + t1.join(timeout=10) + t2.join(timeout=10) + assert not t1.is_alive() and not t2.is_alive(), "线程未在限时内退出" + assert not errors, f"非预期异常: {errors}" + + # 恰好一个成功、一个冲突 + assert len(results) == 2, f"结果数应为 2,得到 {results}" + successes = [r for r in results if r[0] == "ok"] + conflicts = [r for r in results if r[0] == "conflict"] + assert len(successes) == 1, f"应恰好一个成功,得到 {results}" + assert len(conflicts) == 1, f"应恰好一个冲突,得到 {results}" + + # _roles 与 _records 一致:_roles 是赢家的 role + winner_role = successes[0][1] + assert store.get_role(actor) is winner_role + # 清理 + store.revoke(fingerprint(successes[0][2])) + assert store.get_role(actor) is None + + +def test_issued_identity_is_immutable_to_original_scope_mutation(store) -> None: + """验收第三次 P2-1:签发后改原 actor,不影响 resolve 的身份。 + + Scope 现为 frozen 值对象;_Record.actor 保存的是不可变值,原对象后续修改 + (若调用方仍持旧可变引用)不会改变已签发 key 的 principal。防的是「签发后 + 把 actor 改成受害者 org」的越权。 + """ + from dataclasses import FrozenInstanceError + + actor = Scope(org="tenant-a", user="alice") + key = store.issue(actor, Role.USER) + # 原 actor 已 frozen,无法原地改;确认身份未变 + with pytest.raises(FrozenInstanceError): + actor.org = "tenant-victim" + ctx = store.resolve(key) + assert ctx.actor.org == "tenant-a" + assert ctx.actor.user == "alice" + store.revoke(fingerprint(key)) + + +def test_resolved_auth_context_actor_is_deeply_immutable(store) -> None: + """验收第三次 P2-1:resolved AuthContext.actor 不可原地修改。 + + AuthContext(frozen=True) 此前只是浅冻结,actor 是可变 Scope;frozen Scope 后 + 深度不可变,请求生命周期内身份不可篡改。 + """ + from dataclasses import FrozenInstanceError + + actor = Scope(org="acme", user="immutable-ctx-probe") + key = store.issue(actor, Role.USER) + ctx = store.resolve(key) + with pytest.raises(FrozenInstanceError): + ctx.actor.org = "tenant-attacker" + with pytest.raises(FrozenInstanceError): + ctx.actor.user = "bob" + store.revoke(fingerprint(key)) + + +def test_role_does_not_partition_by_space(store) -> None: + """§3.1 role 是 principal 级,不按 space 分:同 principal 不同 space 同 role。 + + 审计 P2-2 建议给 role_key 加 space;但 §3.1 角色是 principal 级(USER/ADMIN/ + ROOT 不随 space 变),space 入索引会让「同 principal 同 role」变成两条互覆 + 记录。本条钉住 principal 级语义。若业务需要 space 级 role,需先演进 §3.1。 + """ + actor = Scope(org="acme", space="s1", user="space-probe") + key = store.issue(actor, Role.USER) + + # 网关声明的 actor 不带 space,仍能查到该 principal 的 role + assert store.get_role(Scope(org="acme", user="space-probe")) is Role.USER + store.revoke(fingerprint(key)) + + +# -- 安全属性 ---------------------------------------------------------------- # + + +def test_registry_never_holds_plaintext(store) -> None: + """最重要的回归防线:注册表里存的必须是哈希,不是明文。""" + key = store.issue(Scope(org="acme", user="plaintext-check"), Role.USER) + dumped = json.dumps( + [ + {"fp": r.key_fp, "hash": r.key_hash, "org": r.actor.org, "revoked": r.revoked} + for r in store._records.values() + ] + ) + assert key not in dumped + assert dumped.count("$argon2id$") >= 1 + + +def test_resolve_pads_time_on_miss(store) -> None: + """未命中不得比「命中前缀但 key 错」快一整个 Argon2 verify。 + + 差异若存在是 ~100x 量级(差一整个 verify),故区间给到 [0.5, 2.0] 足以检出, + 同时容忍 CI 抖动。取中位数而非平均,避免单次 GC 抖动主导。 + """ + key = store.issue(Scope(org="acme", user="timing"), Role.USER) + # 同前缀但内容不同 → 走「候选存在但 verify 失败」路径 + wrong_same_prefix = key[:8] + generate_api_key()[8:] + + def elapsed(candidate: str) -> float: + start = time.perf_counter() + store.resolve(candidate) + return time.perf_counter() - start + + no_candidate = median(elapsed(generate_api_key()) for _ in range(5)) + wrong_key = median(elapsed(wrong_same_prefix) for _ in range(5)) + + ratio = no_candidate / wrong_key + assert 0.5 < ratio < 2.0, f"timing side channel: ratio={ratio:.2f}" + + +def test_single_resolve_stays_under_budget(store) -> None: + """性能基线:防止 Argon2 参数被误配成更离谱的值。 + + 实测单次约 200ms(128 MiB × time_cost=4),对应 5~20 QPS/核——这是已知 + 限制,不是本测试要防的;本测试只防「参数配错一个数量级」。 + """ + key = store.issue(Scope(org="acme", user="perf"), Role.USER) + start = time.perf_counter() + store.resolve(key) + assert (time.perf_counter() - start) < 1.0 diff --git a/tests/unit/common/security/authorization/test_base.py b/tests/unit/common/security/authorization/test_base.py new file mode 100644 index 00000000..5bb7c8e4 --- /dev/null +++ b/tests/unit/common/security/authorization/test_base.py @@ -0,0 +1,97 @@ +"""Authorizer 契约层(F05 §PEP 与 PDP / §授权不变量 8)。""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from common.security.authorization.authorization_impl.allow_all_authorizer import ( + AllowAllAuthorizer, +) +from common.security.authorization.base import AuthorizationDecision, Authorizer +from common.security.types import ( + Action, + AuthContext, + AuthorizationEnvironment, + DenyReason, + ResourceDescriptor, +) +from common.type_def import Scope + +NOW = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + + +# ====================================================================== # +# AuthorizationDecision +# ====================================================================== # + + +def test_allow_decision_carries_the_rule_that_permitted_it() -> None: + """allow 侧也必须记录放行规则,供审计区分 owner 与 Grant 路径。""" + decision = AuthorizationDecision.allow("owner_cover") + assert decision.allowed + assert decision.rule == "owner_cover" + assert decision.reason is None + + +def test_deny_decision_requires_a_reason() -> None: + with pytest.raises(ValueError): + AuthorizationDecision(allowed=False, rule="whatever") + + +def test_allow_decision_rejects_a_deny_reason() -> None: + """``allowed=True`` 却带着 ``CROSS_ORG`` 是矛盾状态,构造期就该拒。""" + with pytest.raises(ValueError): + AuthorizationDecision(allowed=True, rule="owner_cover", reason=DenyReason.CROSS_ORG) + + +def test_decision_requires_a_rule() -> None: + with pytest.raises(ValueError): + AuthorizationDecision.allow("") + + +def test_decision_is_frozen() -> None: + decision = AuthorizationDecision.allow("owner_cover") + with pytest.raises(Exception): + decision.allowed = False # type: ignore[misc] + + +# ====================================================================== # +# 测试专用能力 +# ====================================================================== # + + +def test_allow_all_declares_itself_test_only() -> None: + """allow-all 通过 **capability** 声明自己是测试件(F05 §授权不变量 8)。 + + 装配层据此在生产模式拒绝启动,而不是靠核心去认 ``target == "allow_all"`` + 这个名字——第三方注册的恒放行实现同样要能被拦住,而它的 target 名核心不认识 + (S08 不变量 7)。 + """ + assert AllowAllAuthorizer().is_test_only() + + +def test_authorizer_default_is_not_test_only() -> None: + """默认 ``False``:新实现不会因为忘了覆写而被误判成测试件放进生产。""" + + class Minimal(Authorizer): + def authorize(self, *, auth, resource, environment): + return AuthorizationDecision.deny(DenyReason.DEFAULT_DENY, "minimal") + + def health(self) -> None: + return None + + assert not Minimal().is_test_only() + + +def test_allow_all_ignores_every_input() -> None: + """恒放行是它的全部语义——过期上下文、空 actor、管理动作一律放行。""" + decision = AllowAllAuthorizer().authorize( + auth=AuthContext(actor=Scope()), + resource=ResourceDescriptor( + action=Action.ADMINISTER_SYSTEM, resource_type="admin", scope=Scope() + ), + environment=AuthorizationEnvironment(now=NOW), + ) + assert decision.allowed diff --git a/tests/unit/common/security/authorization/test_scope_rules.py b/tests/unit/common/security/authorization/test_scope_rules.py new file mode 100644 index 00000000..bded9598 --- /dev/null +++ b/tests/unit/common/security/authorization/test_scope_rules.py @@ -0,0 +1,134 @@ +"""Scope 覆盖规则(F05 §Authorization 决策顺序第 4 步)。 + +覆盖规则同时服务 owner 判定与 Grant 匹配,判错的后果是越权,故单独测。 +""" + +from __future__ import annotations + +import pytest + +from common.security.authorization.scope_rules import PrincipalPath, scope_covers +from common.type_def import Scope + +# ====================================================================== # +# 硬边界 +# ====================================================================== # + + +def test_cross_org_never_covers() -> None: + assert not scope_covers(Scope(org="acme", user="alice"), Scope(org="other", user="alice")) + + +def test_cross_space_never_covers() -> None: + """同 org 跨 space 也不覆盖:同名 user 在别的 space 不是同一份数据。""" + parent = Scope(org="acme", space="s1", user="alice") + child = Scope(org="acme", space="s2", user="alice") + assert not scope_covers(parent, child) + + +# ====================================================================== # +# 空 Scope 不再是通配 +# ====================================================================== # + + +def test_empty_scope_does_not_cover_everything() -> None: + """与旧 ``_owner_scope_covers`` 的关键差异:空 parent 不再通配。 + + 旧实现用「parent == Scope() 即覆盖一切」同时表达 platform admin 与 grant 行的 + 宽松匹配。F05 §授权不变量 1 要求 ROOT 只由 role 表达,这条通配分支必须消失—— + 否则一个空 actor 或一条 grantor 留空的 Grant 就能横扫全平台。 + """ + assert not scope_covers(Scope(), Scope(org="acme", user="alice")) + + +def test_empty_scope_covers_only_empty_scope() -> None: + assert scope_covers(Scope(), Scope()) + + +def test_org_only_parent_does_not_cover_named_user() -> None: + """留空最外层主体维不等于「不限该维」。 + + ``Scope(org="acme")`` 覆盖不了 ``Scope(org="acme", user="alice")``:否则一条 + 只写了 org 的记录就能读遍全 org。 + """ + assert not scope_covers(Scope(org="acme"), Scope(org="acme", user="alice")) + + +# ====================================================================== # +# 子树覆盖 +# ====================================================================== # + + +def test_user_covers_own_agent_branch() -> None: + parent = Scope(org="acme", user="alice") + child = Scope(org="acme", user="alice", agent="assistant") + assert scope_covers(parent, child) + + +def test_user_covers_own_agent_session_branch() -> None: + parent = Scope(org="acme", user="alice") + child = Scope(org="acme", user="alice", agent="assistant", session="s1") + assert scope_covers(parent, child) + + +def test_agent_branch_does_not_cover_sibling_agent() -> None: + parent = Scope(org="acme", user="alice", agent="assistant") + child = Scope(org="acme", user="alice", agent="other") + assert not scope_covers(parent, child) + + +def test_child_cannot_cover_parent() -> None: + """覆盖是单向的:子分支拿不到父分支的范围。""" + parent = Scope(org="acme", user="alice", agent="assistant") + child = Scope(org="acme", user="alice") + assert not scope_covers(parent, child) + + +def test_different_users_do_not_cover_each_other() -> None: + assert not scope_covers(Scope(org="acme", user="alice"), Scope(org="acme", user="bob")) + + +# ====================================================================== # +# 空洞形状 +# ====================================================================== # + + +def test_gapped_parent_does_not_cover() -> None: + """``user=alice, agent="", session="s1"`` 跳过 agent 却限制 session。 + + 这种形状表达不成一棵连续子树。忽略空洞继续比会让一条写坏的 Grant 意外扩大 + 覆盖面——它本想限制 session,结果变成了「alice 名下所有 agent 的 s1 会话」。 + """ + parent = Scope(org="acme", user="alice", session="s1") + child = Scope(org="acme", user="alice", agent="assistant", session="s1") + assert not scope_covers(parent, child) + + +# ====================================================================== # +# 主体路径 +# ====================================================================== # + + +def test_agent_user_path_flips_the_nesting() -> None: + """``agent_user`` 下 agent 是外层:一个 agent 服务多个 user 的平台形态。""" + parent = Scope(org="acme", agent="bot") + child = Scope(org="acme", user="alice", agent="bot") + assert scope_covers(parent, child, principal_path=PrincipalPath.AGENT_USER) + assert not scope_covers(parent, child, principal_path=PrincipalPath.USER_AGENT) + + +def test_user_agent_path_is_the_default() -> None: + parent = Scope(org="acme", user="alice") + child = Scope(org="acme", user="alice", agent="bot") + assert scope_covers(parent, child) + assert not scope_covers(parent, child, principal_path=PrincipalPath.AGENT_USER) + + +def test_principal_path_is_keyword_only() -> None: + """位置传参会让「这次按哪种主体路径判定」在调用点看不出来。""" + with pytest.raises(TypeError): + scope_covers( # type: ignore[misc] + Scope(org="acme", user="alice"), + Scope(org="acme", user="alice"), + PrincipalPath.AGENT_USER, + ) diff --git a/tests/unit/common/security/authorization/test_standard_authorizer.py b/tests/unit/common/security/authorization/test_standard_authorizer.py new file mode 100644 index 00000000..22fdf06d --- /dev/null +++ b/tests/unit/common/security/authorization/test_standard_authorizer.py @@ -0,0 +1,576 @@ +"""StandardAuthorizer 的授权 truth table(F05 §Authorization 决策顺序 / §5.4 验收)。 + +覆盖迁移计划 §5.4 点名的每一项:owner、Grant、Delegation、role、默认拒绝的完整 +truth table;跨 org、跨 space、跨主体、伪造 delegation、撤销/过期 delegation 全部拒绝; +SHARE 与管理动作默认不可委托。 + +Store 用内存假件而不是真 SQLite:这里测的是**判定**,不是记录怎么存。假件的 +``find_active`` 刻意**不做**时效过滤,好让「Authorizer 是否自己也复核一遍时效」 +这件事被真正测到——真实现按契约会过滤,那样反而测不出 Authorizer 的兜底。 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from common.security.authorization.authorization_impl.standard_authorizer import ( + StandardAuthorizer, +) +from common.security.authorization.store import DelegationStore, GrantStore +from common.security.types import ( + Action, + AuthContext, + AuthorizationEnvironment, + Delegation, + DenyReason, + Grant, + ResourceDescriptor, + Role, +) +from common.type_def import Scope + +NOW = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + +ALICE = Scope(org="acme", space="main", user="alice") +ALICE_AGENT = Scope(org="acme", space="main", user="alice", agent="assistant") +BOB = Scope(org="acme", space="main", user="bob") +OTHER_ORG = Scope(org="globex", space="main", user="alice") + + +# ====================================================================== # +# 内存假件 +# ====================================================================== # + + +class FakeGrantStore(GrantStore): + def __init__(self, grants: list[Grant] | None = None) -> None: + self.grants = list(grants or []) + + def add(self, grant: Grant) -> None: + self.grants.append(grant) + + def revoke(self, grant_id: str) -> None: + self.grants = [g for g in self.grants if g.grant_id != grant_id] + + def find_active(self, *, grantee, grantor_org, action, now): + # 刻意只按 action 与 org 过滤,不滤时效——见模块 docstring。 + return [ + g for g in self.grants if action in g.actions and g.grantor.org == grantor_org + ] + + def health(self) -> None: + return None + + +class FakeDelegationStore(DelegationStore): + def __init__(self, delegations: list[Delegation] | None = None) -> None: + self.by_id = {d.delegation_id: d for d in (delegations or [])} + + def add(self, delegation: Delegation) -> None: + self.by_id[delegation.delegation_id] = delegation + + def revoke(self, delegation_id: str) -> None: + self.by_id.pop(delegation_id, None) + + def get(self, delegation_id: str): + return self.by_id.get(delegation_id) + + def health(self) -> None: + return None + + +def _authorizer( + *, grants: list[Grant] | None = None, delegations: list[Delegation] | None = None +) -> StandardAuthorizer: + return StandardAuthorizer( + grant_store=FakeGrantStore(grants), + delegation_store=FakeDelegationStore(delegations), + ) + + +def _resource( + action: Action = Action.READ, + scope: Scope = ALICE, + *, + resource_type: str = "memory_unit", + attributes: dict[str, str] | None = None, +) -> ResourceDescriptor: + return ResourceDescriptor( + action=action, + resource_type=resource_type, + scope=scope, + attributes=attributes or {}, + ) + + +def _env(now: datetime = NOW) -> AuthorizationEnvironment: + return AuthorizationEnvironment(now=now) + + +def _auth(actor: Scope = ALICE, **overrides) -> AuthContext: + return AuthContext(actor=actor, **overrides) + + +def _decide( + authorizer: StandardAuthorizer, + auth: AuthContext, + resource: ResourceDescriptor, + now: datetime = NOW, +): + return authorizer.authorize(auth=auth, resource=resource, environment=_env(now)) + + +# ====================================================================== # +# 第 1 步:上下文时效 +# ====================================================================== # + + +def test_expired_context_is_denied_before_anything_else() -> None: + """过期上下文先于一切被拒——即使 owner 规则本会放行。""" + auth = _auth(expires_at=NOW - timedelta(seconds=1)) + decision = _decide(_authorizer(), auth, _resource()) + assert not decision.allowed + assert decision.reason is DenyReason.EXPIRED_CONTEXT + + +def test_expired_root_context_is_still_denied() -> None: + """ROOT 也不能拿过期上下文操作。""" + auth = _auth(role=Role.ROOT, expires_at=NOW - timedelta(seconds=1)) + decision = _decide(_authorizer(), auth, _resource(Action.ADMINISTER_SYSTEM)) + assert not decision.allowed + assert decision.reason is DenyReason.EXPIRED_CONTEXT + + +# ====================================================================== # +# 第 2 步:空 actor 不是特权 +# ====================================================================== # + + +def test_empty_actor_is_denied_not_privileged() -> None: + """空 Scope 是「没填内容的身份」,不是 platform admin(F05 §授权不变量 1)。 + + 这是与旧 PermissionManager 的关键行为反转:旧实现在无认证上下文时把空 actor + 当全局放行。 + """ + decision = _decide(_authorizer(), _auth(actor=Scope()), _resource()) + assert not decision.allowed + assert decision.reason is DenyReason.CONTEXT_MISMATCH + + +def test_empty_actor_with_root_role_is_still_denied() -> None: + """连 ROOT 都救不了空 actor:审计需要知道**谁**做了这件事。""" + decision = _decide(_authorizer(), _auth(actor=Scope(), role=Role.ROOT), _resource()) + assert not decision.allowed + assert decision.reason is DenyReason.CONTEXT_MISMATCH + + +# ====================================================================== # +# 第 3 步:角色闸门 +# ====================================================================== # + + +@pytest.mark.parametrize( + "action", + [ + Action.MANAGE_PRINCIPAL, + Action.MANAGE_SPACE, + Action.MANAGE_POLICY, + Action.READ_AUDIT, + Action.VERIFY_AUDIT, + Action.ADMINISTER_SYSTEM, + ], +) +def test_user_role_cannot_do_management_actions(action: Action) -> None: + """普通用户拿不到任何管理动作——即使目标是自己的 scope。""" + decision = _decide(_authorizer(), _auth(), _resource(action)) + assert not decision.allowed + assert decision.reason is DenyReason.ROLE_REQUIRED + + +def test_admin_can_manage_within_own_org() -> None: + auth = _auth(role=Role.ADMIN) + decision = _decide(_authorizer(), auth, _resource(Action.MANAGE_SPACE, BOB)) + assert decision.allowed + # 管理面由**角色**放行,不是碰巧命中了 owner——ADMIN 管理别人的 space,目标本就 + # 不在自己 scope 内。这条断言钉住的是「管理面判定在第 3 步终结」。 + assert decision.rule == "role_gate" + + +def test_admin_cannot_manage_across_org() -> None: + """ADMIN 的管辖止于本 org。""" + auth = _auth(role=Role.ADMIN) + decision = _decide(_authorizer(), auth, _resource(Action.MANAGE_SPACE, OTHER_ORG)) + assert not decision.allowed + assert decision.reason is DenyReason.CROSS_ORG + + +def test_admin_cannot_verify_audit() -> None: + """审计链校验要 ROOT:能校验也就能知道校验何时失败,那是 ROOT 才该有的视野。""" + decision = _decide(_authorizer(), _auth(role=Role.ADMIN), _resource(Action.VERIFY_AUDIT)) + assert not decision.allowed + assert decision.reason is DenyReason.ROLE_REQUIRED + + +def test_root_passes_management_gate() -> None: + decision = _decide(_authorizer(), _auth(role=Role.ROOT), _resource(Action.ADMINISTER_SYSTEM)) + assert decision.allowed + + +def test_grant_cannot_bypass_the_role_gate() -> None: + """一条写着管理动作的 Grant 也拿不到管理面。 + + 闸门排在所有放行规则之前,正是为了挡住这条路径:否则谁能写 Grant,谁就能 + 自助提权到管理面。 + """ + grant = Grant( + grant_id="g1", + grantor=BOB, + grantee=ALICE, + actions=frozenset({Action.MANAGE_POLICY}), + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(Action.MANAGE_POLICY, BOB)) + assert not decision.allowed + assert decision.reason is DenyReason.ROLE_REQUIRED + + +# ====================================================================== # +# ROOT 与 org 硬边界 +# ====================================================================== # + + +def test_root_crosses_org() -> None: + decision = _decide(_authorizer(), _auth(role=Role.ROOT), _resource(Action.READ, OTHER_ORG)) + assert decision.allowed + + +def test_user_cannot_cross_org_even_with_grant() -> None: + """Grant 不跨 org 生效(F05 §Grant)。""" + grant = Grant( + grant_id="g1", + grantor=OTHER_ORG, + grantee=ALICE, + actions=frozenset({Action.READ}), + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(Action.READ, OTHER_ORG)) + assert not decision.allowed + assert decision.reason is DenyReason.CROSS_ORG + + +# ====================================================================== # +# 第 4 步:owner 覆盖 +# ====================================================================== # + + +def test_owner_reads_own_scope() -> None: + decision = _decide(_authorizer(), _auth(), _resource()) + assert decision.allowed + assert decision.rule == "owner_cover" + + +def test_owner_covers_own_agent_branch() -> None: + decision = _decide(_authorizer(), _auth(), _resource(scope=ALICE_AGENT)) + assert decision.allowed + + +def test_agent_does_not_cover_its_users_scope() -> None: + """反向不成立:agent 身份读不到 user 的完整分支(F05 §授权不变量 2)。""" + decision = _decide(_authorizer(), _auth(actor=ALICE_AGENT), _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.NOT_COVERED + + +def test_cross_user_within_org_is_denied() -> None: + decision = _decide(_authorizer(), _auth(), _resource(scope=BOB)) + assert not decision.allowed + assert decision.reason is DenyReason.NOT_COVERED + + +def test_cross_space_is_denied_without_grant() -> None: + other_space = Scope(org="acme", space="archive", user="alice") + decision = _decide(_authorizer(), _auth(), _resource(scope=other_space)) + assert not decision.allowed + + +def test_principal_path_comes_from_resource_attributes() -> None: + """主体路径来自 descriptor(PEP 从 space policy 真源构造),不来自请求声明。""" + bot = Scope(org="acme", space="main", agent="bot") + target = Scope(org="acme", space="main", user="alice", agent="bot") + + denied = _decide(_authorizer(), _auth(actor=bot), _resource(scope=target)) + assert not denied.allowed + + allowed = _decide( + _authorizer(), + _auth(actor=bot), + _resource(scope=target, attributes={"principal_path": "agent_user"}), + ) + assert allowed.allowed + + +def test_invalid_principal_path_falls_back_to_stricter_default() -> None: + """写坏的 principal_path 回落 ``user_agent``(覆盖面更小的那个),不放宽。""" + bot = Scope(org="acme", space="main", agent="bot") + target = Scope(org="acme", space="main", user="alice", agent="bot") + decision = _decide( + _authorizer(), + _auth(actor=bot), + _resource(scope=target, attributes={"principal_path": "nonsense"}), + ) + assert not decision.allowed + + +# ====================================================================== # +# 第 5 步:Delegation +# ====================================================================== # + + +def _delegation(**overrides) -> Delegation: + base = { + "delegation_id": "d1", + "delegator": ALICE, + "delegate": ALICE_AGENT, + "actions": frozenset({Action.READ, Action.WRITE}), + "expires_at": NOW + timedelta(hours=1), + } + base.update(overrides) + return Delegation(**base) # type: ignore[arg-type] + + +def test_delegation_allows_agent_to_act_for_user() -> None: + auth = _auth(actor=ALICE_AGENT, delegation_id="d1") + decision = _decide(_authorizer(delegations=[_delegation()]), auth, _resource(scope=ALICE)) + assert decision.allowed + assert decision.rule == "delegation" + + +def test_user_to_user_delegation_is_denied() -> None: + """F05 只允许 user 委托 agent/service;user -> user 是第二套 Grant,必须拒(P1-5)。""" + delegation = _delegation(delegate=BOB) + auth = _auth(actor=BOB, delegation_id="d1") + decision = _decide(_authorizer(delegations=[delegation]), auth, _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +def test_forged_delegation_id_is_denied() -> None: + """``AuthContext`` 里编一个 id 不管用——内容一律回真源读。""" + auth = _auth(actor=ALICE_AGENT, delegation_id="forged") + decision = _decide(_authorizer(delegations=[_delegation()]), auth, _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +def test_expired_delegation_is_denied() -> None: + delegation = _delegation(expires_at=NOW - timedelta(seconds=1)) + auth = _auth(actor=ALICE_AGENT, delegation_id="d1") + decision = _decide(_authorizer(delegations=[delegation]), auth, _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +def test_revoked_delegation_is_denied() -> None: + delegation = _delegation(revoked=True) + auth = _auth(actor=ALICE_AGENT, delegation_id="d1") + decision = _decide(_authorizer(delegations=[delegation]), auth, _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +def test_delegation_cannot_be_used_by_another_agent() -> None: + """别人的委托 id 捡去用不管用。""" + other_agent = Scope(org="acme", space="main", user="alice", agent="rogue") + auth = _auth(actor=other_agent, delegation_id="d1") + decision = _decide(_authorizer(delegations=[_delegation()]), auth, _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +def test_delegation_does_not_reach_beyond_delegator_scope() -> None: + """委托授不出委托方自己都没有的范围。""" + auth = _auth(actor=ALICE_AGENT, delegation_id="d1") + decision = _decide(_authorizer(delegations=[_delegation()]), auth, _resource(scope=BOB)) + assert not decision.allowed + + +@pytest.mark.parametrize("action", [Action.SHARE, Action.REVOKE_SHARE]) +def test_share_is_never_delegatable(action: Action) -> None: + """让被委托方能再授权,等于让一次性委托升级成永久 Grant。""" + delegation = _delegation(actions=frozenset({Action.READ, action})) + auth = _auth(actor=ALICE_AGENT, delegation_id="d1") + decision = _decide(_authorizer(delegations=[delegation]), auth, _resource(action, ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_ACTION + + +def test_action_outside_delegation_allowlist_is_denied() -> None: + auth = _auth(actor=ALICE_AGENT, delegation_id="d1") + decision = _decide( + _authorizer(delegations=[_delegation()]), auth, _resource(Action.DELETE, ALICE) + ) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_ACTION + + +def test_delegation_respects_allowed_spaces() -> None: + delegation = _delegation(allowed_spaces=frozenset({"archive"})) + auth = _auth(actor=ALICE_AGENT, delegation_id="d1") + decision = _decide(_authorizer(delegations=[delegation]), auth, _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +def test_delegation_bound_to_credential_rejects_other_credential() -> None: + """绑定凭据后换一把 key 就用不了——泄露的爆炸半径收敛在单把 key 上。""" + delegation = _delegation(bound_credential_id="cred-1") + auth = _auth(actor=ALICE_AGENT, delegation_id="d1", credential_id="cred-2") + decision = _decide(_authorizer(delegations=[delegation]), auth, _resource(scope=ALICE)) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +def test_delegation_bound_to_credential_accepts_matching_credential() -> None: + delegation = _delegation(bound_credential_id="cred-1") + auth = _auth(actor=ALICE_AGENT, delegation_id="d1", credential_id="cred-1") + decision = _decide(_authorizer(delegations=[delegation]), auth, _resource(scope=ALICE)) + assert decision.allowed + + +def test_failed_delegation_does_not_fall_back_to_grant() -> None: + """声明了代操作就按代操作判:失效委托不该被一条 Grant 悄悄兜住。 + + 否则审计里看不出委托失效过——运维会以为代理链路一切正常。 + """ + grant = Grant( + grant_id="g1", grantor=ALICE, grantee=ALICE_AGENT, actions=frozenset({Action.READ}) + ) + auth = _auth(actor=ALICE_AGENT, delegation_id="revoked-one") + decision = _decide( + _authorizer(grants=[grant], delegations=[_delegation()]), auth, _resource(scope=ALICE) + ) + assert not decision.allowed + assert decision.reason is DenyReason.DELEGATION_INVALID + + +# ====================================================================== # +# 第 6 步:Grant +# ====================================================================== # + + +def test_grant_allows_cross_user_access() -> None: + grant = Grant( + grant_id="g1", grantor=BOB, grantee=ALICE, actions=frozenset({Action.READ}) + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(scope=BOB)) + assert decision.allowed + assert decision.rule == "grant" + + +def test_grant_for_another_action_does_not_apply() -> None: + grant = Grant( + grant_id="g1", grantor=BOB, grantee=ALICE, actions=frozenset({Action.READ}) + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(Action.DELETE, BOB)) + assert not decision.allowed + assert decision.reason is DenyReason.NOT_COVERED + + +def test_grant_for_another_grantee_does_not_apply() -> None: + grant = Grant( + grant_id="g1", grantor=BOB, grantee=Scope(org="acme", space="main", user="carol"), + actions=frozenset({Action.READ}), + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(scope=BOB)) + assert not decision.allowed + + +def test_grantor_must_cover_the_target() -> None: + """授权方管不着的资源,授出去也不作数。""" + carol = Scope(org="acme", space="main", user="carol") + grant = Grant( + grant_id="g1", grantor=BOB, grantee=ALICE, actions=frozenset({Action.READ}) + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(scope=carol)) + assert not decision.allowed + + +def test_expired_grant_is_rechecked_by_authorizer() -> None: + """Store 契约要求滤掉过期记录,Authorizer 仍复核一遍。 + + 时效判定必须用本次判定的同一个 ``now``;Store 用的是入参 now 还是自己取的, + 跨实现无法保证。这里的假件刻意不滤,测的就是这道兜底。 + """ + grant = Grant( + grant_id="g1", + grantor=BOB, + grantee=ALICE, + actions=frozenset({Action.READ}), + expires_at=NOW - timedelta(seconds=1), + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(scope=BOB)) + assert not decision.allowed + + +def test_revoked_grant_is_rechecked_by_authorizer() -> None: + grant = Grant( + grant_id="g1", + grantor=BOB, + grantee=ALICE, + actions=frozenset({Action.READ}), + revoked=True, + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(scope=BOB)) + assert not decision.allowed + + +def test_grant_across_space_within_org_is_allowed_when_explicit() -> None: + """跨 space 需要显式 Grant——owner 规则挡住的,Grant 可以放行。""" + archive = Scope(org="acme", space="archive", user="alice") + grant = Grant( + grant_id="g1", grantor=archive, grantee=ALICE, actions=frozenset({Action.READ}) + ) + decision = _decide(_authorizer(grants=[grant]), _auth(), _resource(scope=archive)) + assert decision.allowed + + +# ====================================================================== # +# 第 7 步:默认拒绝 +# ====================================================================== # + + +def test_default_deny_with_no_rules() -> None: + decision = _decide(_authorizer(), _auth(), _resource(scope=BOB)) + assert not decision.allowed + assert decision.reason is DenyReason.NOT_COVERED + assert decision.rule == "default_deny" + + +# ====================================================================== # +# 契约形态 +# ====================================================================== # + + +def test_authorize_arguments_are_keyword_only() -> None: + """三个入参类型不同但都是「一坨上下文」,位置传参写反了不会报错。""" + with pytest.raises(TypeError): + _authorizer().authorize(_auth(), _resource(), _env()) # type: ignore[misc] + + +def test_authorizer_does_not_read_contextvar() -> None: + """F05 §授权不变量 7:全部判定依据显式入参。 + + ContextVar 里放一个 ROOT,判定仍按入参的 USER 走。 + """ + from common.security.types import reset_current, set_current + + token = set_current(AuthContext(actor=BOB, role=Role.ROOT)) + try: + decision = _decide(_authorizer(), _auth(), _resource(scope=BOB)) + finally: + reset_current(token) + assert not decision.allowed + + +def test_standard_authorizer_is_not_test_only() -> None: + assert not _authorizer().is_test_only() diff --git a/tests/unit/common/security/authorization/test_stores.py b/tests/unit/common/security/authorization/test_stores.py new file mode 100644 index 00000000..d633eb48 --- /dev/null +++ b/tests/unit/common/security/authorization/test_stores.py @@ -0,0 +1,373 @@ +"""GrantStore 与 DelegationStore 两套实现的契约测试。 + +内存与 SQLite 两个后端跑**同一批用例**:它们背后是同一份契约,分开写两份测试的结果 +是其中一份先漂——通常是 SQLite 那份,因为它改起来更麻烦。 + +测的是契约行为(软撤销、存储层滤时效、空 id 不查表、按 id 幂等),不测实现细节 +(表结构、锁、序列化格式)。 +""" + +from __future__ import annotations + +import sqlite3 +from datetime import datetime, timedelta, timezone + +import pytest + +from common.security.authorization.authorization_impl.memory_stores import ( + InMemoryDelegationStore, + InMemoryGrantStore, +) +from common.security.authorization.authorization_impl.sqlite_stores import ( + SQLiteDelegationStore, + SQLiteGrantStore, +) +from common.security.authorization.store import ( + DelegationStore, + DelegationStoreProducer, + GrantStore, + GrantStoreProducer, +) +from common.security.types import Action, Delegation, Grant +from common.type_def import Scope + +NOW = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) +ALICE = Scope(org="acme", space="main", user="alice") +BOB = Scope(org="acme", space="main", user="bob") + +_GRANT_BACKENDS = [InMemoryGrantStore, SQLiteGrantStore] +_DELEGATION_BACKENDS = [InMemoryDelegationStore, SQLiteDelegationStore] + + +@pytest.fixture(params=_GRANT_BACKENDS, ids=["memory", "sqlite"]) +def grant_store(request) -> GrantStore: + if request.param is SQLiteGrantStore: + return SQLiteGrantStore(":memory:") + return InMemoryGrantStore() + + +@pytest.fixture(params=_DELEGATION_BACKENDS, ids=["memory", "sqlite"]) +def delegation_store(request) -> DelegationStore: + if request.param is SQLiteDelegationStore: + return SQLiteDelegationStore(":memory:") + return InMemoryDelegationStore() + + +def _grant( + grant_id: str = "g1", + *, + grantor: Scope = ALICE, + grantee: Scope = BOB, + actions: frozenset[Action] = frozenset({Action.READ}), + expires_at: datetime | None = None, +) -> Grant: + return Grant( + grant_id=grant_id, + grantor=grantor, + grantee=grantee, + actions=actions, + expires_at=expires_at, + ) + + +def _delegation( + delegation_id: str = "d1", + *, + delegator: Scope = ALICE, + delegate: Scope = Scope(org="acme", space="main", user="alice", agent="assistant"), + actions: frozenset[Action] = frozenset({Action.READ, Action.WRITE}), + expires_at: datetime = NOW + timedelta(hours=1), + **kwargs, +) -> Delegation: + return Delegation( + delegation_id=delegation_id, + delegator=delegator, + delegate=delegate, + actions=actions, + expires_at=expires_at, + **kwargs, + ) + + +def _find(store: GrantStore, *, action: Action = Action.READ, now: datetime = NOW) -> list[Grant]: + return store.find_active(grantee=BOB, grantor_org="acme", action=action, now=now) + + +# ====================================================================== # +# GrantStore +# ====================================================================== # + + +def test_grant_round_trips(grant_store: GrantStore) -> None: + """写进去的字段要原样读回来——scope 五维、动作集合、有效期一个都不能丢。""" + grant = _grant( + grantor=Scope(org="acme", space="main", user="alice", agent="a1", session="s1"), + actions=frozenset({Action.READ, Action.WRITE}), + expires_at=NOW + timedelta(days=1), + ) + grant_store.add(grant) + found = _find(grant_store) + assert len(found) == 1 + assert found[0].grant_id == "g1" + assert found[0].grantor == grant.grantor + assert found[0].grantee == BOB + assert found[0].actions == frozenset({Action.READ, Action.WRITE}) + assert found[0].expires_at == grant.expires_at + + +def test_grant_add_is_idempotent_by_id(grant_store: GrantStore) -> None: + """同 id 写两次是一条而不是两条:重试写入不该在库里留下副本。""" + grant_store.add(_grant()) + grant_store.add(_grant(actions=frozenset({Action.READ, Action.WRITE}))) + found = _find(grant_store) + assert len(found) == 1 + assert found[0].actions == frozenset({Action.READ, Action.WRITE}) + + +def test_revoked_grant_is_not_returned(grant_store: GrantStore) -> None: + grant_store.add(_grant()) + grant_store.revoke("g1") + assert _find(grant_store) == [] + + +def test_revoke_is_idempotent(grant_store: GrantStore) -> None: + grant_store.add(_grant()) + grant_store.revoke("g1") + grant_store.revoke("g1") + assert _find(grant_store) == [] + + +def test_revoke_unknown_grant_is_silent(grant_store: GrantStore) -> None: + """撤销不存在的 id 不抛:撤销是幂等操作,重放不该变成错误。""" + grant_store.revoke("never-existed") + + +def test_expired_grant_is_filtered_by_the_store(grant_store: GrantStore) -> None: + """时效在**存储层**就滤掉(契约要求),不是留给 Authorizer 筛。""" + grant_store.add(_grant(expires_at=NOW - timedelta(seconds=1))) + assert _find(grant_store) == [] + + +def test_grant_expiring_exactly_now_is_inactive(grant_store: GrantStore) -> None: + """``expires_at == now`` 判失效——边界向「更严」的一侧靠。""" + grant_store.add(_grant(expires_at=NOW)) + assert _find(grant_store) == [] + + +def test_grant_without_expiry_stays_active(grant_store: GrantStore) -> None: + """Grant 允许长期有效(与 Delegation 的区别之一)。""" + grant_store.add(_grant(expires_at=None)) + found = _find(grant_store, now=NOW + timedelta(days=3650)) + assert len(found) == 1 + assert found[0].expires_at is None + + +def test_grant_for_another_action_is_not_returned(grant_store: GrantStore) -> None: + grant_store.add(_grant(actions=frozenset({Action.READ}))) + assert _find(grant_store, action=Action.DELETE) == [] + + +def test_action_match_is_not_substring_match(grant_store: GrantStore) -> None: + """``read_audit`` 不能被 ``read`` 的查询命中。 + + 动作集合在 SQLite 里存成逗号串,用 ``LIKE '%read%'`` 筛就会把 ``read_audit`` + 一起捞出来——一条只开放了读审计的授权会变成能读数据。 + """ + grant_store.add(_grant(actions=frozenset({Action.READ_AUDIT}))) + assert _find(grant_store, action=Action.READ) == [] + assert len(_find(grant_store, action=Action.READ_AUDIT)) == 1 + + +def test_grant_from_another_org_is_not_returned(grant_store: GrantStore) -> None: + """org 是硬边界,存储查询就按它收窄。""" + grant_store.add(_grant(grantor=Scope(org="globex", space="main", user="carol"))) + assert _find(grant_store) == [] + + +def test_grant_to_another_org_grantee_is_not_returned(grant_store: GrantStore) -> None: + grant_store.add(_grant(grantee=Scope(org="globex", space="main", user="bob"))) + assert _find(grant_store) == [] + + +def test_multiple_grants_are_all_returned(grant_store: GrantStore) -> None: + """同一对主体可以有多条授权,查询不做去重——挑哪条由 Authorizer 按覆盖规则定。""" + grant_store.add(_grant("g1")) + grant_store.add(_grant("g2", grantor=Scope(org="acme", space="main", user="dave"))) + assert {g.grant_id for g in _find(grant_store)} == {"g1", "g2"} + + +def test_grant_store_health_passes(grant_store: GrantStore) -> None: + assert grant_store.health() is None + + +def test_revoked_grant_cannot_be_resurrected_by_replay(grant_store: GrantStore) -> None: + """撤销后用同 id 重放旧创建请求不得复活授权(P1-4)。 + + 两个后端必须同语义:memory 不复活,sqlite 的 upsert 也不动 revoked_at。 + """ + grant_store.add(_grant()) + grant_store.revoke("g1") + grant_store.add(_grant()) # 模拟重放旧 create + assert _find(grant_store) == [] + + +# ====================================================================== # +# DelegationStore +# ====================================================================== # + + +def test_delegation_round_trips(delegation_store: DelegationStore) -> None: + delegation = _delegation( + not_before=NOW - timedelta(minutes=5), + allowed_spaces=frozenset({"main", "scratch"}), + bound_credential_id="cred-7", + bound_session="sess-9", + ) + delegation_store.add(delegation) + loaded = delegation_store.get("d1") + assert loaded == delegation + + +def test_delegation_add_is_idempotent_by_id(delegation_store: DelegationStore) -> None: + delegation_store.add(_delegation()) + delegation_store.add(_delegation(actions=frozenset({Action.READ}))) + loaded = delegation_store.get("d1") + assert loaded is not None + assert loaded.actions == frozenset({Action.READ}) + + +def test_missing_delegation_returns_none(delegation_store: DelegationStore) -> None: + assert delegation_store.get("nope") is None + + +def test_empty_delegation_id_returns_none(delegation_store: DelegationStore) -> None: + """``AuthContext.delegation_id`` 默认是空串。 + + 让空 id 去查表,就意味着一条 id 为空的记录能被任何**没有**声明委托的请求命中。 + """ + delegation_store.add(_delegation("")) + assert delegation_store.get("") is None + + +def test_revoked_delegation_is_returned_with_the_flag_set( + delegation_store: DelegationStore, +) -> None: + """撤销后记录**仍然读得到**,只是 ``revoked=True``。 + + 与 GrantStore 的差别是有意的:``get`` 返回原始记录,有效性由 Authorizer 用本次 + 判定的同一个 ``now`` 来判(见 :meth:`DelegationStore.get` 契约)。存储自己判会和 + Grant 的时效判定错开。 + """ + delegation_store.add(_delegation()) + delegation_store.revoke("d1") + loaded = delegation_store.get("d1") + assert loaded is not None + assert loaded.revoked is True + assert not loaded.is_active(now=NOW) + + +def test_revoke_preserves_the_rest_of_the_record(delegation_store: DelegationStore) -> None: + """撤销只改一个标记,其余字段原样保留——审计要能回答「这条委托原本能做什么」。""" + delegation_store.add( + _delegation( + allowed_spaces=frozenset({"main"}), + bound_credential_id="cred-7", + bound_session="sess-9", + ) + ) + delegation_store.revoke("d1") + loaded = delegation_store.get("d1") + assert loaded is not None + assert loaded.delegator == ALICE + assert loaded.actions == frozenset({Action.READ, Action.WRITE}) + assert loaded.allowed_spaces == frozenset({"main"}) + assert loaded.bound_credential_id == "cred-7" + assert loaded.bound_session == "sess-9" + + +def test_delegation_revoke_is_idempotent(delegation_store: DelegationStore) -> None: + delegation_store.add(_delegation()) + delegation_store.revoke("d1") + delegation_store.revoke("d1") + loaded = delegation_store.get("d1") + assert loaded is not None + assert loaded.revoked is True + + +def test_revoke_unknown_delegation_is_silent(delegation_store: DelegationStore) -> None: + delegation_store.revoke("never-existed") + + +def test_expired_delegation_round_trips_as_inactive(delegation_store: DelegationStore) -> None: + """过期委托同样读得回来——由 Authorizer 判失效,理由同撤销。""" + delegation_store.add(_delegation(expires_at=NOW - timedelta(seconds=1))) + loaded = delegation_store.get("d1") + assert loaded is not None + assert not loaded.is_active(now=NOW) + + +def test_delegation_store_health_passes(delegation_store: DelegationStore) -> None: + assert delegation_store.health() is None + + +def test_revoked_delegation_cannot_be_resurrected_by_replay( + delegation_store: DelegationStore, +) -> None: + """撤销后用同 id 重放旧创建请求不得恢复代操作关系(P1-4,同 Grant 语义)。""" + delegation_store.add(_delegation()) + delegation_store.revoke("d1") + delegation_store.add(_delegation()) # 模拟重放旧 create + loaded = delegation_store.get("d1") + assert loaded is not None + assert loaded.revoked is True + + +# ====================================================================== # +# 装配 +# ====================================================================== # + + +@pytest.mark.parametrize("target", ["memory", "sqlite"]) +def test_stores_are_registered(target: str) -> None: + """两个后端都能从注册名装出来(F05 §独立 Producer)。""" + from common.security.bootstrap import register_security + + register_security() + assert target in GrantStoreProducer.known() + assert target in DelegationStoreProducer.known() + + +def test_sqlite_store_persists_across_instances(tmp_path) -> None: + """SQLite 后端跨实例可见——内存后端做不到,这是选它的唯一理由。""" + db = str(tmp_path / "auth.db") + writer = SQLiteGrantStore(db) + writer.add(_grant()) + writer.close() + + reader = SQLiteGrantStore(db) + try: + assert len(_find(reader)) == 1 + finally: + reader.close() + + +def test_sqlite_store_ignores_unknown_actions(tmp_path) -> None: + """库里存着核心不认识的动作名时,跳过该动作而不是让整条查询失败。 + + 降级部署(新版写入、旧版读取)会造出这种记录。认不出就当没有,是 F05 §授权 + 不变量 5「新 Action 默认拒绝」在存储层的形态;抛异常则会让一条脏记录瘫掉所有 + 授权查询。 + """ + db = str(tmp_path / "auth.db") + store = SQLiteGrantStore(db) + try: + store.add(_grant(actions=frozenset({Action.READ}))) + with sqlite3.connect(db) as connection: + connection.execute( + "UPDATE auth_grants SET actions=? WHERE grant_id=?", ("read,teleport", "g1") + ) + found = _find(store) + assert len(found) == 1 + assert found[0].actions == frozenset({Action.READ}) + finally: + store.close() diff --git a/tests/unit/common/security/cryptography/test_local_envelope.py b/tests/unit/common/security/cryptography/test_local_envelope.py new file mode 100644 index 00000000..d5d24818 --- /dev/null +++ b/tests/unit/common/security/cryptography/test_local_envelope.py @@ -0,0 +1,371 @@ +"""ENC1 本地信封:往返、AAD 绑定、无明文回退、KeyProvider 与 v1 只读兼容。""" + +from __future__ import annotations + +import os +import stat +import struct + +import pytest + +import common.security.cryptography.cryptography_impl +from common.errors import ValidationError +from common.security.cryptography import ( + AuthenticationFailedError, + CorruptedCiphertextError, + CryptographyProducer, + InvalidMagicError, + KeyMismatchError, + KeyProviderProducer, + KeyRef, +) +from common.security.cryptography.cryptography_impl.local_envelope import ( + ENVELOPE_MAGIC, + ENVELOPE_VERSION, + LocalEnvelopeCryptographyProvider, + LocalKeyProvider, +) +from common.security.types import CryptoContext +from common.type_def import Scope +from config import AssemblyContext + +_KEY_HEX = "11" * 32 +_LEGACY_V1_ENVELOPE = bytes.fromhex( + "454e433101010030000c000cb76369ad7e6142f97d74bae917899876cd25dcc762" + "c74ecb09ed4f6f75418e2c7c997b7190a303fa251f0fdd4dcac238040404040404" + "0404040404040505050505050505050505057a47b805d1a53731cb77a66cb2835c" + "c4a1131152146fc6b52e2a3c95c6cb" +) + +pytestmark = pytest.mark.unit + + +def _context( + *, + org: str = "acme", + user: str = "alice", + purpose: str = "memory_unit", + object_id: str = "/memory/u1", +) -> CryptoContext: + return CryptoContext( + scope=Scope(org=org, user=user), + purpose=purpose, + object_id=object_id, + ) + + +def _provider_from_hex() -> LocalEnvelopeCryptographyProvider: + return LocalEnvelopeCryptographyProvider(LocalKeyProvider(key_hex=_KEY_HEX)) + + +# -- 往返与信封格式 ---------------------------------------------------------- # + + +def test_encrypts_enc1_and_round_trips(tmp_path) -> None: + key_file = tmp_path / "master.key" + provider = LocalEnvelopeCryptographyProvider(LocalKeyProvider(key_file=str(key_file))) + context = _context() + + ciphertext = provider.encrypt(b"secret payload", context=context, aad=b"kv:a") + second_ciphertext = provider.encrypt(b"secret payload", context=context, aad=b"kv:a") + + assert ciphertext.startswith(ENVELOPE_MAGIC) + assert ciphertext != b"secret payload" + assert ciphertext != second_ciphertext # 每次新 data key + 新 nonce + assert provider.decrypt(ciphertext, context=context, aad=b"kv:a") == b"secret payload" + assert key_file.exists() + if os.name != "nt": + # NTFS 不映射 POSIX mode 位,os.open(..., 0o600) 在 Windows 上恒为 0o666。 + assert stat.S_IMODE(key_file.stat().st_mode) == 0o600 + + +def test_writes_version_two_envelopes() -> None: + """写出一律 v2:v1 只读兼容,不再产出(F05 §信封格式要求 key id 与 epoch)。""" + ciphertext = _provider_from_hex().encrypt(b"payload", context=_context()) + assert ciphertext[len(ENVELOPE_MAGIC)] == ENVELOPE_VERSION + + +def test_envelope_carries_key_id_and_epoch() -> None: + """信封须自带 key ref,否则轮换后无从判断该用哪代密钥解。""" + key_provider = LocalKeyProvider(key_hex=_KEY_HEX, key_epoch=3) + provider = LocalEnvelopeCryptographyProvider(key_provider) + ciphertext = provider.encrypt(b"payload", context=_context()) + + ref = key_provider.active_key() + assert ref.epoch == 3 + assert ref.key_id.encode("utf-8") in ciphertext + assert provider.decrypt(ciphertext, context=_context()) == b"payload" + + +# -- 无明文回退(F05 §明文策略)--------------------------------------------- # + + +def test_plaintext_is_always_rejected() -> None: + """不是合法信封就拒绝读取——不存在 ``allow_plaintext`` 降级开关。 + + 有降级开关时,拥有底层存储写权限的攻击者可用任意明文替换密文, + 绕过 AES-GCM tag 与 AAD。 + """ + provider = _provider_from_hex() + with pytest.raises(InvalidMagicError): + provider.decrypt(b"legacy plaintext", context=_context()) + + +def test_provider_takes_no_plaintext_switch() -> None: + """构造签名里不留 ``allow_plaintext``:降级只能靠换存储适配器表达。""" + with pytest.raises(TypeError): + LocalEnvelopeCryptographyProvider( # type: ignore[call-arg] + LocalKeyProvider(key_hex=_KEY_HEX), + allow_plaintext=True, + ) + + +# -- AAD 绑定 ---------------------------------------------------------------- # + + +def test_rejects_aad_or_actor_mismatch() -> None: + provider = _provider_from_hex() + ciphertext = provider.encrypt(b"secret payload", context=_context(user="alice"), aad=b"kv:a") + + with pytest.raises(AuthenticationFailedError): + provider.decrypt(ciphertext, context=_context(user="alice"), aad=b"kv:b") + with pytest.raises(AuthenticationFailedError): + provider.decrypt(ciphertext, context=_context(user="bob"), aad=b"kv:a") + + +def test_rejects_object_id_mismatch() -> None: + """对象标识进 AAD:否则同租户同用途的密文可在两个 key 之间原样搬运。""" + provider = _provider_from_hex() + ciphertext = provider.encrypt(b"secret payload", context=_context(object_id="/memory/u1")) + + with pytest.raises(AuthenticationFailedError): + provider.decrypt(ciphertext, context=_context(object_id="/memory/u2")) + + +def test_rejects_purpose_mismatch() -> None: + """用途隔离(F05 §密钥隔离):包裹密钥按 purpose 派生,换用途就解不开。""" + provider = _provider_from_hex() + ciphertext = provider.encrypt(b"secret payload", context=_context(purpose="memory_unit")) + + with pytest.raises(KeyMismatchError): + provider.decrypt(ciphertext, context=_context(purpose="raw_message")) + + +def test_rejects_org_key_mismatch() -> None: + provider = _provider_from_hex() + ciphertext = provider.encrypt(b"secret payload", context=_context(org="acme"), aad=b"kv:a") + + with pytest.raises(KeyMismatchError): + provider.decrypt(ciphertext, context=_context(org="other"), aad=b"kv:a") + + +def test_key_ref_in_header_cannot_be_swapped() -> None: + """key id/epoch 也进 AAD:只写进头部而不参与认证的字段是可篡改的。""" + provider = _provider_from_hex() + ciphertext = bytearray(provider.encrypt(b"secret payload", context=_context())) + + # header 尾部 4 字节是 key_epoch(!4sBBHHHBI)。改掉它而不动其余任何字节。 + epoch_offset = struct.calcsize("!4sBBHHHB") + ciphertext[epoch_offset:epoch_offset + 4] = (9).to_bytes(4, "big") + + with pytest.raises(KeyMismatchError): + provider.decrypt(bytes(ciphertext), context=_context()) + + +def test_rejects_corrupted_envelope() -> None: + provider = _provider_from_hex() + + with pytest.raises(CorruptedCiphertextError): + provider.decrypt(ENVELOPE_MAGIC, context=_context()) + + +def test_rejects_unknown_envelope_version() -> None: + """未来版本的信封不能被当前实现「尽力而为」地解——不认识就拒绝。""" + provider = _provider_from_hex() + ciphertext = bytearray(provider.encrypt(b"payload", context=_context())) + ciphertext[len(ENVELOPE_MAGIC)] = 0x7F + + with pytest.raises(CorruptedCiphertextError): + provider.decrypt(bytes(ciphertext), context=_context()) + + +# -- KeyProvider 契约 -------------------------------------------------------- # + + +def test_wrap_unwrap_round_trip() -> None: + provider = LocalKeyProvider(key_hex=_KEY_HEX) + data_key = b"\x02" * 32 + + wrapped = provider.wrap(data_key, purpose="memory_unit", org="acme") + assert wrapped.ref == provider.active_key() + assert provider.unwrap(wrapped, purpose="memory_unit", org="acme") == data_key + + +def test_unwrap_rejects_other_key_generation() -> None: + """未保留材料的 epoch 不拿活动密钥试解:试成功等于 epoch 绑定失效。""" + provider = LocalKeyProvider(key_hex=_KEY_HEX) + wrapped = provider.wrap(b"\x02" * 32, purpose="memory_unit", org="acme") + forged = type(wrapped)( + ciphertext=wrapped.ciphertext, + nonce=wrapped.nonce, + ref=KeyRef(key_id=wrapped.ref.key_id, epoch=wrapped.ref.epoch + 1), + ) + + with pytest.raises(KeyMismatchError): + provider.unwrap(forged, purpose="memory_unit", org="acme") + + +def test_rotate_advances_epoch_and_keeps_old_epoch_readable() -> None: + """rotate 推进 epoch,且旧 epoch 信封仍可解(F05 §KeyProvider 轮换契约)。""" + provider = LocalKeyProvider(key_hex=_KEY_HEX) + data_key = b"\x02" * 32 + wrapped = provider.wrap(data_key, purpose="memory_unit", org="acme") + before = provider.active_key() + + after = provider.rotate() + + assert after.epoch > before.epoch + # 旧 epoch 信封仍可解(rotate 保留了旧代根密钥) + assert provider.unwrap(wrapped, purpose="memory_unit", org="acme") == data_key + # 新 epoch 写入用新 ref,且可解 + wrapped_new = provider.wrap(data_key, purpose="memory_unit", org="acme") + assert wrapped_new.ref.epoch == after.epoch + assert provider.unwrap(wrapped_new, purpose="memory_unit", org="acme") == data_key + + +def test_rotate_changes_key_id() -> None: + """新 epoch 用新随机根密钥,key_id 随之改变。""" + provider = LocalKeyProvider(key_hex=_KEY_HEX) + before = provider.active_key() + after = provider.rotate() + assert after.key_id != before.key_id + + +def test_key_id_does_not_leak_root_key() -> None: + """key id 明文落盘:必须是不可逆派生,不能是根密钥本身或其直接编码。""" + provider = LocalKeyProvider(key_hex=_KEY_HEX) + key_id = provider.active_key().key_id + assert key_id + assert _KEY_HEX not in key_id + assert bytes.fromhex(_KEY_HEX).hex() not in key_id + + +def test_key_id_is_stable_across_instances() -> None: + """同一根密钥必须给出同一 key id,否则重启后旧密文全部无法匹配。""" + first = LocalKeyProvider(key_hex=_KEY_HEX).active_key() + second = LocalKeyProvider(key_hex=_KEY_HEX).active_key() + assert first == second + + +def test_different_roots_give_different_key_ids() -> None: + assert ( + LocalKeyProvider(key_hex=_KEY_HEX).active_key().key_id + != LocalKeyProvider(key_hex="22" * 32).active_key().key_id + ) + + +def test_epoch_must_be_positive() -> None: + """epoch 0 会与 v1 信封「未声明 epoch」的哨兵值撞上。""" + with pytest.raises(ValidationError): + LocalKeyProvider(key_hex=_KEY_HEX, key_epoch=0) + + +def test_wrap_rejects_wrong_data_key_length() -> None: + provider = LocalKeyProvider(key_hex=_KEY_HEX) + with pytest.raises(ValidationError): + provider.wrap(b"short", purpose="memory_unit", org="acme") + + +def test_missing_key_file_is_not_silently_created() -> None: + """``create_key_file=False`` 时缺密钥必须拒绝,不能凭空造一把新的。 + + 静默新建等于把「密钥丢了」变成「旧数据全部解不开且无人察觉」。 + """ + provider = LocalKeyProvider( + key_file="/nonexistent/agent-memory/master.key", + key_env="", + create_key_file=False, + ) + with pytest.raises(Exception) as exc: + provider.health() + assert not isinstance(exc.value, KeyMismatchError) + + +# -- v1 只读兼容 ------------------------------------------------------------- # + + +def test_v1_envelope_still_readable() -> None: + """迁移前落盘的密文不能因为格式升级就读不出来。""" + key_provider = LocalKeyProvider(key_hex=_KEY_HEX) + provider = LocalEnvelopeCryptographyProvider(key_provider) + + context = CryptoContext(scope=Scope(org="acme", user="alice"), purpose="memory_unit") + assert provider.decrypt(_LEGACY_V1_ENVELOPE, context=context) == b"legacy payload" + + +def test_v1_envelope_still_enforces_tenant_isolation() -> None: + """只读兼容不等于放宽校验:跨 org 读旧密文照样拒绝。""" + key_provider = LocalKeyProvider(key_hex=_KEY_HEX) + provider = LocalEnvelopeCryptographyProvider(key_provider) + + context = CryptoContext(scope=Scope(org="other", user="alice"), purpose="memory_unit") + with pytest.raises(KeyMismatchError): + provider.decrypt(_LEGACY_V1_ENVELOPE, context=context) + + +# -- 装配 -------------------------------------------------------------------- # + + +def test_producer_builds_local_provider_from_config(tmp_path) -> None: + assert ( + common.security.cryptography.cryptography_impl.CryptographyProducer is CryptographyProducer + ) + key_file = tmp_path / "configured.key" + ctx = AssemblyContext.from_dict( + { + "cryptography": { + "default": { + "target": "local", + "params": {"key_provider": {"target": "local"}}, + } + }, + "key_provider": { + "default": { + "target": "local", + "params": {"key_file": str(key_file)}, + } + }, + } + ) + + provider = CryptographyProducer.build( + "local", + {"key_provider": {"target": "local", "params": {"key_file": str(key_file)}}}, + ctx, + ) + context = _context() + ciphertext = provider.encrypt(b"value", context=context, aad=b"kv:a") + + assert isinstance(provider, LocalEnvelopeCryptographyProvider) + assert provider.decrypt(ciphertext, context=context, aad=b"kv:a") == b"value" + assert key_file.exists() + + +def test_key_provider_producer_is_separately_addressable(tmp_path) -> None: + """KeyProvider 是独立 Producer:换 KMS/Vault 不必改加密实现(F05 §Producer 清单)。""" + key_file = tmp_path / "named.key" + ctx = AssemblyContext.from_dict( + { + "key_provider": { + "default": { + "target": "local", + "params": {"key_file": str(key_file)}, + } + } + } + ) + + key_provider = KeyProviderProducer.build_named("default", ctx) + assert isinstance(key_provider, LocalKeyProvider) + key_provider.health() + assert key_file.exists() diff --git a/tests/unit/common/security/protection/test_binding_policy.py b/tests/unit/common/security/protection/test_binding_policy.py new file mode 100644 index 00000000..d8c65e69 --- /dev/null +++ b/tests/unit/common/security/protection/test_binding_policy.py @@ -0,0 +1,100 @@ +"""common.security.protection.binding_policy: loopback 强制绑定策略。""" + +from __future__ import annotations + +import pytest + +from common.bootstrap import register_plugins +from common.errors import ValidationError +from common.factory.factory import Factory +from common.security.protection.binding_policy import BindingPolicy, BindingPolicyProducer +from config.context import AssemblyContext + +pytestmark = pytest.mark.unit + + +@pytest.fixture(scope="module") +def policy() -> BindingPolicy: + register_plugins() + return BindingPolicyProducer.build("loopback", {}, AssemblyContext()) + + +@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "::1", "[::1]", "LOCALHOST"]) +def test_loopback_accepted(policy, host) -> None: + policy.check(host, requires_loopback=True) + + +@pytest.mark.parametrize("host", ["0.0.0.0", "::", "", "*", None]) +def test_wildcard_rejected(policy, host) -> None: + """容器化场景下最危险的情况:以为只是没配,实际暴露给了整个网络。""" + with pytest.raises(ValidationError): + policy.check(host, requires_loopback=True) + + +@pytest.mark.parametrize("host", ["192.168.1.10", "10.0.0.1", "example.com"]) +def test_non_loopback_rejected(policy, host) -> None: + with pytest.raises(ValidationError): + policy.check(host, requires_loopback=True) + + +def test_any_dangerous_host_in_sequence_rejects(policy) -> None: + """多网卡:任一 host 危险即拒绝,不是「有一个安全就放行」。""" + with pytest.raises(ValidationError): + policy.check(["127.0.0.1", "0.0.0.0"], requires_loopback=True) + + +def test_all_loopback_sequence_accepted(policy) -> None: + policy.check(["127.0.0.1", "::1"], requires_loopback=True) + + +def test_empty_sequence_rejected(policy) -> None: + with pytest.raises(ValidationError): + policy.check([], requires_loopback=True) + + +def test_message_names_the_remedy(policy) -> None: + """错误消息要能自解释:告诉运维改绑哪里、或改用哪个模式。""" + with pytest.raises(ValidationError) as exc: + policy.check("0.0.0.0", requires_loopback=True) + message = str(exc.value) + assert "127.0.0.1" in message + assert "api_key" in message + + +def test_container_only_warns(policy, monkeypatch, caplog) -> None: + """容器里绑 127.0.0.1 是合法的:是否暴露取决于 port mapping,框架无法检查。""" + monkeypatch.setenv("KUBERNETES_SERVICE_HOST", "10.96.0.1") + with caplog.at_level("WARNING"): + policy.check("127.0.0.1", requires_loopback=True) # 不抛 + assert any("容器" in r.message for r in caplog.records) + + +# -- capability 驱动而非 target 名驱动 --------------------------------------- # + + +@pytest.mark.parametrize("host", ["0.0.0.0", "example.com", "", None]) +def test_no_loopback_requirement_allows_any_host(policy, host) -> None: + """裁决只看认证能力自报的 ``requires_loopback_binding()``。 + + 声明具备远程暴露保护的实现(api_key / trusted / 第三方)可绑任意地址, + 本模块无需认识它们的 target 名。 + """ + policy.check(host, requires_loopback=False) + + +def test_requires_loopback_is_keyword_only(policy) -> None: + """位置传参会让 ``check(host, False)`` 这类调用看不出放宽了什么。""" + with pytest.raises(TypeError): + policy.check("0.0.0.0", False) # type: ignore[misc] + + +def test_check_returns_none_not_bool(policy) -> None: + """返回 bool 会诱导调用方写 ``if not ok: log.warning(...)`` 然后照常监听。""" + assert policy.check("127.0.0.1", requires_loopback=True) is None + + +def test_policy_registered_and_healthy(policy) -> None: + register_plugins() + assert "loopback" in BindingPolicyProducer.known() + assert "binding_policy" in Factory.known_top_names() + assert policy.health() is None diff --git a/tests/unit/common/security/protection/test_rate_limit.py b/tests/unit/common/security/protection/test_rate_limit.py new file mode 100644 index 00000000..6fcfd946 --- /dev/null +++ b/tests/unit/common/security/protection/test_rate_limit.py @@ -0,0 +1,192 @@ +"""common.security.protection.rate_limit:令牌桶限流(§8.1)。 + +测的是行为而非内部状态:桶的 tokens 字段是实现细节,「第 N 个请求被拒、 +等一会儿又能过」才是契约。时间相关的断言全部注入假时钟,不用 sleep—— +sleep 会让测试又慢又 flaky。 +""" + +from __future__ import annotations + +# The bounded-table assertions are intentional white-box checks of the LRU state. +# pylint: disable=protected-access +import threading + +import pytest + +from common.bootstrap import register_plugins +from common.errors import ValidationError +from common.security.protection.protection_impl.token_bucket_limiter import ( + TokenBucketLimiter, +) +from common.security.protection.rate_limit import RateLimitProducer +from config.context import AssemblyContext + +pytestmark = pytest.mark.unit + +_MONOTONIC = ( + "common.security.protection.protection_impl.token_bucket_limiter.time.monotonic" +) + + +@pytest.fixture(autouse=True, scope="module") +def _registered(): + register_plugins() + + +def _limiter(capacity=3, refill_per_sec=1.0, max_tracked=100) -> TokenBucketLimiter: + return TokenBucketLimiter( + capacity=capacity, refill_per_sec=refill_per_sec, max_tracked=max_tracked + ) + + +# -- 准入 -------------------------------------------------------------------- # + + +def test_burst_up_to_capacity_then_denied() -> None: + limiter = _limiter(capacity=3) + assert [limiter.allow("10.0.0.1") for _ in range(3)] == [True, True, True] + assert limiter.allow("10.0.0.1") is False + + +def test_peers_have_independent_buckets() -> None: + """一个调用方打满不该影响别人——否则单个攻击者就能拒绝全部服务。""" + limiter = _limiter(capacity=2) + assert limiter.allow("10.0.0.1") and limiter.allow("10.0.0.1") + assert limiter.allow("10.0.0.1") is False + assert limiter.allow("10.0.0.2") is True + + +def test_empty_peer_is_never_limited() -> None: + """进程内直连 / MCP stdio 没有网络对端:没有攻击面,限流只会卡住本地 CLI。""" + limiter = _limiter(capacity=1) + assert all(limiter.allow("") for _ in range(50)) + + +# -- 补充 -------------------------------------------------------------------- # + + +def test_tokens_refill_over_time(monkeypatch) -> None: + """桶空之后等够时间要能再放行——不然限流等于永久拉黑。""" + now = [1000.0] + monkeypatch.setattr(_MONOTONIC, lambda: now[0]) + + limiter = _limiter(capacity=2, refill_per_sec=1.0) + assert limiter.allow("10.0.0.1") and limiter.allow("10.0.0.1") + assert limiter.allow("10.0.0.1") is False + + now[0] += 0.5 # 不足一个令牌 + assert limiter.allow("10.0.0.1") is False + + now[0] += 0.5 # 累计 1.0s → 恰好一个令牌 + assert limiter.allow("10.0.0.1") is True + assert limiter.allow("10.0.0.1") is False + + +def test_refill_is_capped_at_capacity(monkeypatch) -> None: + """长时间空闲不该攒出无限额度,否则突发保护形同虚设。""" + now = [1000.0] + monkeypatch.setattr(_MONOTONIC, lambda: now[0]) + + limiter = _limiter(capacity=3, refill_per_sec=1.0) + assert limiter.allow("10.0.0.1") + now[0] += 3600 # 空闲一小时 + + assert [limiter.allow("10.0.0.1") for _ in range(3)] == [True, True, True] + assert limiter.allow("10.0.0.1") is False + + +# -- 桶表有界 ---------------------------------------------------------------- # + + +def test_bucket_table_is_bounded() -> None: + """桶按 peer 建、peer 由远端决定:无界字典会让防耗尽的组件自己成为耗尽入口。""" + limiter = _limiter(capacity=1, max_tracked=10) + for i in range(100): + limiter.allow(f"10.0.0.{i}") + assert len(limiter._buckets) == 10 + + +def test_eviction_drops_least_recently_used() -> None: + """淘汰最久未活跃的那个:活跃 peer 的限流状态不能被一串陌生 IP 冲掉。""" + limiter = _limiter(capacity=1, max_tracked=3) + assert limiter.allow("busy") is True # busy 的桶已耗尽 + limiter.allow("a") + limiter.allow("busy") # 触碰一次,把 busy 移到 LRU 末尾 + limiter.allow("b") + limiter.allow("c") # 超出 3 个 → 淘汰最久未活跃的 "a" + + assert "busy" in limiter._buckets + assert "a" not in limiter._buckets + # busy 仍然被限流——它的状态没被冲掉。 + assert limiter.allow("busy") is False + + +# -- 并发 -------------------------------------------------------------------- # + + +def test_concurrent_requests_do_not_exceed_capacity() -> None: + """「读余量 → 减一 → 写回」在 GIL 下不是原子的:两个线程能同时看到最后一个令牌。 + + 没有锁时本测试会看到 allowed > capacity。 + """ + limiter = _limiter(capacity=50, refill_per_sec=0.0001) + allowed: list[bool] = [] + lock = threading.Lock() + barrier = threading.Barrier(20) + + def hammer() -> None: + barrier.wait() # 尽量让 20 个线程同时进 allow + results = [limiter.allow("10.0.0.1") for _ in range(20)] + with lock: + allowed.extend(results) + + threads = [threading.Thread(target=hammer) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert sum(allowed) == 50 + + +# -- 装配 -------------------------------------------------------------------- # + + +def test_build_uses_defaults() -> None: + limiter = RateLimitProducer.build("token_bucket", {}, AssemblyContext()) + assert isinstance(limiter, TokenBucketLimiter) + limiter.health() + + +def test_build_honours_params() -> None: + limiter = RateLimitProducer.build( + "token_bucket", {"capacity": 2, "refill_per_sec": 7.5}, AssemblyContext() + ) + assert limiter.allow("10.0.0.1") and limiter.allow("10.0.0.1") + assert limiter.allow("10.0.0.1") is False + + +@pytest.mark.parametrize( + "params", + [ + {"capacity": 0}, + {"capacity": -1}, + {"refill_per_sec": 0}, + {"refill_per_sec": -1.0}, + {"max_tracked": 0}, + ], +) +def test_invalid_params_rejected_at_assembly(params) -> None: + """配错了要在启动时炸:capacity=0 会拒绝一切请求,refill=0 会永久拉黑调用方。 + + 这两种「配置写错等于服务下线」的情况,运行期才暴露就是一次生产事故。 + """ + with pytest.raises(ValidationError): + RateLimitProducer.build("token_bucket", params, AssemblyContext()) + + +def test_disabling_is_explicit_not_a_magic_value() -> None: + """关闭限流走 target: unlimited;capacity 不接受反着读的魔法值。""" + limiter = RateLimitProducer.build("unlimited", {}, AssemblyContext()) + assert all(limiter.allow("10.0.0.1") for _ in range(1000)) + limiter.health() diff --git a/tests/unit/common/security/protection/test_workload_guard.py b/tests/unit/common/security/protection/test_workload_guard.py new file mode 100644 index 00000000..9b65ad1e --- /dev/null +++ b/tests/unit/common/security/protection/test_workload_guard.py @@ -0,0 +1,138 @@ +"""common.security.protection.workload_guard: 昂贵操作的并发预算。""" + +from __future__ import annotations + +import threading + +import pytest + +from common.bootstrap import register_plugins +from common.errors import ValidationError +from common.security.protection.protection_impl.semaphore_guard import SemaphoreWorkloadGuard +from common.security.protection.workload_guard import WorkloadGuardProducer +from config.context import AssemblyContext + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True, scope="module") +def _registered(): + register_plugins() + + +# -- 预算语义 ---------------------------------------------------------------- # + + +def test_budget_is_exhausted_after_max_concurrent_acquires() -> None: + guard = SemaphoreWorkloadGuard(2) + assert guard.acquire() is True + assert guard.acquire() is True + assert guard.acquire() is False + + +def test_release_returns_the_slot() -> None: + guard = SemaphoreWorkloadGuard(1) + assert guard.acquire() is True + assert guard.acquire() is False + guard.release() + assert guard.acquire() is True + + +def test_acquire_never_blocks() -> None: + """耗尽即快速拒绝,不排队——排队把资源耗尽从 CPU/内存转移到线程与请求队列。 + + 若实现改成阻塞式 acquire,本测试会挂在 join 上直到超时。 + """ + guard = SemaphoreWorkloadGuard(1) + assert guard.acquire() is True + + result: list[bool] = [] + done = threading.Event() + + def worker() -> None: + result.append(guard.acquire()) + done.set() + + thread = threading.Thread(target=worker) + thread.start() + assert done.wait(timeout=2.0), "acquire 阻塞了——预算耗尽必须立即返回 False" + thread.join() + assert result == [False] + + +def test_over_release_does_not_inflate_the_budget() -> None: + """acquire 失败后误 release 不能凭空造出槽位,否则预算上限被悄悄突破。""" + guard = SemaphoreWorkloadGuard(1) + guard.release() # 越界 release:记录但不抛 + assert guard.acquire() is True + assert guard.acquire() is False + + +def test_concurrent_acquires_do_not_exceed_budget() -> None: + """20 个线程同时抢 5 个槽位:成功数必须恰好等于预算。""" + guard = SemaphoreWorkloadGuard(5) + acquired: list[bool] = [] + lock = threading.Lock() + barrier = threading.Barrier(20) + + def hammer() -> None: + barrier.wait() + got = guard.acquire() + with lock: + acquired.append(got) + + threads = [threading.Thread(target=hammer) for _ in range(20)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert sum(acquired) == 5 + + +def test_max_concurrent_is_exposed_for_diagnostics() -> None: + assert SemaphoreWorkloadGuard(7).max_concurrent == 7 + + +def test_process_local_guard_does_not_claim_distributed_budget() -> None: + """多副本部署下 N 个副本 = N 倍实际并发:能力由类型声明,不按 target 名推断。""" + assert SemaphoreWorkloadGuard(1).supports_distributed_budget() is False + + +# -- 装配 -------------------------------------------------------------------- # + + +def test_build_uses_defaults() -> None: + guard = WorkloadGuardProducer.build("semaphore", {}, AssemblyContext()) + assert isinstance(guard, SemaphoreWorkloadGuard) + assert guard.max_concurrent >= 1 + guard.health() + + +def test_build_honours_params() -> None: + guard = WorkloadGuardProducer.build("semaphore", {"max_concurrent": 2}, AssemblyContext()) + assert guard.max_concurrent == 2 + + +@pytest.mark.parametrize("max_concurrent", [0, -1]) +def test_invalid_budget_rejected_at_assembly(max_concurrent) -> None: + """预算为 0 会拒绝一切昂贵操作(认证全挂),必须在启动期炸而非运行期。""" + with pytest.raises(ValidationError): + WorkloadGuardProducer.build( + "semaphore", {"max_concurrent": max_concurrent}, AssemblyContext() + ) + + +def test_named_instances_are_shared() -> None: + """进程级共享通过具名实例表达,不用模块级单例(见 Producer docstring)。""" + ctx = AssemblyContext.from_dict( + {"workload_guard": {"default": {"target": "semaphore", "params": {"max_concurrent": 1}}}} + ) + first = WorkloadGuardProducer.build_named("default", ctx) + second = WorkloadGuardProducer.build_named("default", ctx) + assert first is second + + # 真正共享预算:一个持有者占满后另一个引用也拿不到槽位。 + assert first.acquire() is True + assert second.acquire() is False + first.release() diff --git a/tests/unit/common/security/test_authorization_types.py b/tests/unit/common/security/test_authorization_types.py new file mode 100644 index 00000000..7f20c31a --- /dev/null +++ b/tests/unit/common/security/test_authorization_types.py @@ -0,0 +1,230 @@ +"""授权类型的安全约束(F05 §Action / §ResourceDescriptor / §Grant / §Delegation)。 + +这些断言钉住的是**默认拒绝**与**不可伪造**两件事:动作集合封闭、管理动作不可委托、 +时效与撤销真的生效、请求写不进只读的安全属性。 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest + +from common.security.types import ( + DELEGATABLE_ACTIONS, + MANAGEMENT_ACTIONS, + Action, + AuthContext, + AuthorizationEnvironment, + Delegation, + DenyReason, + Grant, + RequestSecurityContext, + ResourceDescriptor, + Role, + Surface, +) +from common.type_def import Scope + +NOW = datetime(2026, 8, 5, 12, 0, tzinfo=timezone.utc) + + +# ====================================================================== # +# Action +# ====================================================================== # + + +def test_management_actions_are_never_delegatable() -> None: + """管理动作默认不可委托(F05 §授权不变量 4)。""" + assert not (MANAGEMENT_ACTIONS & DELEGATABLE_ACTIONS) + + +def test_share_actions_are_not_delegatable() -> None: + """被委托方不能再授权——否则委托关系自我复制,撤销追不上。""" + assert Action.SHARE not in DELEGATABLE_ACTIONS + assert Action.REVOKE_SHARE not in DELEGATABLE_ACTIONS + + +def test_delegatable_is_an_allowlist_not_a_complement() -> None: + """可委托集合是白名单:新增 Action 不会自动获得可委托性。 + + 这条测试的价值在于它会随 Action 扩张而**主动变红**:加了新动作却没想清楚 + 它该不该可委托时,这里会提醒。取反写法(非管理即可委托)则会静默放行。 + """ + covered = DELEGATABLE_ACTIONS | MANAGEMENT_ACTIONS | {Action.SHARE, Action.REVOKE_SHARE} + uncategorised = set(Action) - covered + assert not uncategorised, f"新增动作未归类,默认应拒绝:{uncategorised}" + + +# ====================================================================== # +# Grant +# ====================================================================== # + + +def test_grant_expired_is_inactive() -> None: + grant = Grant( + grant_id="g1", + grantor=Scope(org="acme", user="alice"), + grantee=Scope(org="acme", user="bob"), + actions=frozenset({Action.READ}), + expires_at=NOW - timedelta(seconds=1), + ) + assert not grant.is_active(now=NOW) + + +def test_grant_revoked_is_inactive_even_when_unexpired() -> None: + """撤销优先于有效期:撤销后立即失效,不等过期。""" + grant = Grant( + grant_id="g1", + grantor=Scope(org="acme", user="alice"), + grantee=Scope(org="acme", user="bob"), + actions=frozenset({Action.READ}), + expires_at=NOW + timedelta(days=365), + revoked=True, + ) + assert not grant.is_active(now=NOW) + + +def test_grant_without_expiry_stays_active() -> None: + grant = Grant( + grant_id="g1", + grantor=Scope(org="acme", user="alice"), + grantee=Scope(org="acme", user="bob"), + actions=frozenset({Action.READ}), + ) + assert grant.is_active(now=NOW) + + +# ====================================================================== # +# Delegation +# ====================================================================== # + + +def _delegation(**overrides: object) -> Delegation: + base: dict[str, object] = { + "delegation_id": "d1", + "delegator": Scope(org="acme", user="alice"), + "delegate": Scope(org="acme", user="alice", agent="assistant"), + "actions": frozenset({Action.READ, Action.WRITE}), + "expires_at": NOW + timedelta(hours=1), + } + base.update(overrides) + return Delegation(**base) # type: ignore[arg-type] + + +def test_delegation_requires_explicit_expiry() -> None: + """``expires_at`` 无默认值:代操作授权必须有限期。""" + with pytest.raises(TypeError): + Delegation( # type: ignore[call-arg] + delegation_id="d1", + delegator=Scope(org="acme", user="alice"), + delegate=Scope(org="acme", user="alice", agent="assistant"), + actions=frozenset({Action.READ}), + ) + + +def test_delegation_expired_is_inactive() -> None: + assert not _delegation(expires_at=NOW - timedelta(seconds=1)).is_active(now=NOW) + + +def test_delegation_revoked_is_inactive() -> None: + assert not _delegation(revoked=True).is_active(now=NOW) + + +def test_delegation_not_yet_valid_is_inactive() -> None: + assert not _delegation(not_before=NOW + timedelta(minutes=5)).is_active(now=NOW) + + +def test_delegation_permits_only_allowlisted_actions() -> None: + delegation = _delegation() + assert delegation.permits(Action.READ) + assert not delegation.permits(Action.DELETE) # 不在本条 allowlist 内 + + +def test_delegation_cannot_permit_management_even_if_recorded() -> None: + """一条写坏或被篡改的委托记录也拿不到管理动作。 + + ``permits`` 同时查数据(allowlist)与策略(DELEGATABLE_ACTIONS),后者是 + 存储层污染兜不住的那道。 + """ + tampered = _delegation(actions=frozenset({Action.READ, Action.ADMINISTER_SYSTEM})) + assert not tampered.permits(Action.ADMINISTER_SYSTEM) + assert tampered.permits(Action.READ) + + +# ====================================================================== # +# ResourceDescriptor / AuthorizationEnvironment +# ====================================================================== # + + +def test_resource_descriptor_attributes_are_read_only() -> None: + """安全属性冻结成只读映射:拿到引用也改不动判定依据。""" + descriptor = ResourceDescriptor( + action=Action.READ, + resource_type="memory_unit", + scope=Scope(org="acme", user="alice"), + attributes={"memory_type": "episodic"}, + ) + with pytest.raises(TypeError): + descriptor.attributes["memory_type"] = "coding" # type: ignore[index] + + +def test_resource_descriptor_copies_attributes_at_construction() -> None: + """构造时复制入参:构造方留着的引用改不动已定型的判定依据。 + + 比「只读映射挡住直接赋值」更贴近真实形态——PEP 通常是从一个可变 dict 攒出 + descriptor 的,若只是包一层视图,那个 dict 在授权判定期间仍可被改。 + """ + mutable = {"memory_type": "episodic"} + descriptor = ResourceDescriptor( + action=Action.READ, + resource_type="memory_unit", + scope=Scope(org="acme", user="alice"), + attributes=mutable, + ) + mutable["memory_type"] = "tampered" + assert descriptor.attributes["memory_type"] == "episodic" + + +def test_resource_descriptor_requires_action_and_source_of_truth_scope() -> None: + """``action`` 与 ``scope`` 无默认值:PEP 必须显式给出动作与资源真实归属。""" + with pytest.raises(TypeError): + ResourceDescriptor(resource_type="memory_unit") # type: ignore[call-arg] + + +def test_environment_from_request_drops_identity() -> None: + """环境只带服务端属性,不含身份——身份是 Authorizer 的独立入参。""" + security = RequestSecurityContext( + auth=AuthContext(actor=Scope(org="acme", user="alice"), role=Role.USER), + request_id="req-1", + peer="10.0.0.1", + surface=Surface.HTTP, + attributes={"tls": "1.3"}, + ) + env = AuthorizationEnvironment.from_request(security, now=NOW) + + assert env.now == NOW + assert env.surface is Surface.HTTP + assert env.request_id == "req-1" + assert env.peer == "10.0.0.1" + assert env.attributes["tls"] == "1.3" + assert not hasattr(env, "auth") + assert not hasattr(env, "actor") + + +def test_environment_attributes_are_read_only() -> None: + env = AuthorizationEnvironment(now=NOW, attributes={"tls": "1.3"}) + with pytest.raises(TypeError): + env.attributes["tls"] = "1.2" # type: ignore[index] + + +# ====================================================================== # +# DenyReason +# ====================================================================== # + + +def test_deny_reasons_do_not_distinguish_missing_from_forbidden() -> None: + """不区分「资源不存在」与「无权访问」——那是资源枚举侧信道。""" + values = {r.value for r in DenyReason} + assert "not_found" not in values + assert DenyReason.NOT_COVERED.value == "not_covered" diff --git a/tests/unit/common/security/test_runtime.py b/tests/unit/common/security/test_runtime.py new file mode 100644 index 00000000..6961742f --- /dev/null +++ b/tests/unit/common/security/test_runtime.py @@ -0,0 +1,339 @@ +"""common.security.runtime: 能力组合、启动期健康检查与统一生命周期。""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from common.bootstrap import register_plugins +from common.errors import ValidationError +from common.factory.factory import Factory +from common.security.protection.protection_impl.token_bucket_limiter import TokenBucketLimiter +from common.security.protection.protection_impl.unlimited_limiter import NoRateLimit +from common.security.runtime import SecurityRuntime, SecurityRuntimeProducer +from config.context import AssemblyContext + +pytestmark = pytest.mark.unit + + +@pytest.fixture(autouse=True, scope="module") +def _registered(): + register_plugins() + + +_AUTHORIZER = { + "target": "standard", + "params": {"grant_store": {"target": "memory"}, "delegation_store": {"target": "memory"}}, +} +"""内联 authorizer:Runtime 的默认引用是具名实例 ``authorizer.default``,那由内核装配 +建立。单测不建内核,故显式给出——这也是 ``_authorizer`` 重抛的那条错误指的做法。""" + + +def _build(params: dict | None = None) -> SecurityRuntime: + return SecurityRuntimeProducer.build( + "standard", + {"authenticator": {"target": "dev"}, "authorizer": _AUTHORIZER, **(params or {})}, + AssemblyContext(), + ) + + +# -- 装配 -------------------------------------------------------------------- # + + +def test_top_name_enters_config_validation() -> None: + assert "security" in Factory.known_top_names() + + +def test_authenticator_is_required_without_default() -> None: + """给认证一个默认会让「忘了配认证」静默变成某种可用配置(F05 §装配不变量 6)。""" + with pytest.raises(ValidationError): + SecurityRuntimeProducer.build("standard", {}, AssemblyContext()) + + +def test_all_request_path_capabilities_are_populated() -> None: + """五条请求路径能力缺一不可:缺一项就意味着某条边界没人把守。""" + runtime = _build() + assert runtime.authenticator is not None + assert runtime.authorizer is not None + assert runtime.rate_limiter is not None + assert runtime.workload_guard is not None + assert runtime.binding_policy is not None + + +def test_runtime_is_frozen() -> None: + """装配后不可替换能力:运行期换掉 authenticator 等于绕过认证。""" + runtime = _build() + with pytest.raises(FrozenInstanceError): + runtime.authenticator = None # type: ignore[misc] + + +def test_no_placeholder_fields_for_later_prs() -> None: + """恒为 ``None`` 的字段会诱导消费方写 ``if runtime.x:`` 的 fail-open 分支。 + + ``audit_integrity_provider`` 是 F05 目标态成员,由 PR3 连同实现一起加。 + """ + assert not hasattr(_build(), "audit_integrity_provider") + + +# -- Authorizer 装配 --------------------------------------------------------- # + + +def test_missing_named_authorizer_names_the_assembly_order() -> None: + """默认引用的 ``authorizer.default`` 由内核装配建立,装配顺序反了要说清楚。 + + Factory 原始错误说的是「配置里没有 authorizer.default」,会把人引向配置文件; + 真正要改的是装配顺序或显式给出 authorizer。 + """ + with pytest.raises(ValidationError) as exc: + SecurityRuntimeProducer.build( + "standard", {"authenticator": {"target": "dev"}}, AssemblyContext() + ) + assert "build_kernel" in str(exc.value) + + +def test_authorizer_is_the_same_instance_the_pep_uses() -> None: + """Runtime 健康检查的必须是 PEP 实际在用的那一个。 + + 匿名新建会得到另一份持有另一套 Grant/Delegation 存储的 authorizer——那比不检查 + 更糟,它给出的是虚假保证。 + """ + ctx = AssemblyContext.from_dict( + { + "security": {"default": {"target": "standard", "params": {"authenticator": "a"}}}, + "authenticator": {"a": {"target": "dev"}}, + "authorizer": { + "default": { + "target": "standard", + "params": {"grant_store": "g", "delegation_store": "d"}, + } + }, + "grant_store": {"g": {"target": "memory"}}, + "delegation_store": {"d": {"target": "memory"}}, + } + ) + # 模拟内核装配:先建具名 authorizer.default,Runtime 随后必须命中同一个。 + from common.security.authorization.base import AuthorizationProducer + + pep_authorizer = AuthorizationProducer.build_named("default", ctx) + runtime = SecurityRuntimeProducer.build_named("default", ctx) + assert runtime.authorizer is pep_authorizer + + +def test_test_only_authorizer_is_rejected_by_capability_not_by_name() -> None: + """判据是 ``is_test_only()``,不是 ``target == "allow_all"``(S08 不变量 7)。""" + with pytest.raises(ValidationError) as exc: + _build({"authorizer": {"target": "allow_all"}}) + assert "allow_test_only_security" in str(exc.value) + + +def test_test_only_authorizer_needs_an_explicit_opt_in() -> None: + """要用恒放行实现得让「这次装配不做真实授权」在配置里留下痕迹。""" + runtime = SecurityRuntimeProducer.build( + "standard", + { + "authenticator": {"target": "dev"}, + "authorizer": {"target": "allow_all"}, + "allow_test_only_security": True, + }, + AssemblyContext(), + ) + assert runtime.authorizer.is_test_only() is True + + +# -- 默认值取保守侧 ---------------------------------------------------------- # + + +def test_loopback_only_deployment_defaults_to_unlimited() -> None: + """分岔由 capability 决定,不看 target 名(F05 §依据 capability 做安全决策)。 + + dev 认证声明 ``requires_loopback_binding()``:没有远端攻击面,默认限流只会卡住 + 本地压测与调试脚本。 + """ + runtime = _build() + assert runtime.authenticator.requires_loopback_binding() is True + assert isinstance(runtime.rate_limiter, NoRateLimit) + + +def test_remote_capable_authenticator_defaults_to_token_bucket() -> None: + """声明可远程暴露的认证有攻击面,默认必须是限流的那个。""" + runtime = SecurityRuntimeProducer.build( + "standard", + { + "authenticator": { + "target": "api_key", + "params": {"root_api_key": "root-key-for-tests"}, + }, + "authorizer": _AUTHORIZER, + }, + AssemblyContext(), + ) + assert runtime.authenticator.requires_loopback_binding() is False + assert isinstance(runtime.rate_limiter, TokenBucketLimiter) + + +def test_explicit_rate_limiter_overrides_the_capability_default() -> None: + runtime = _build({"rate_limiter": {"target": "token_bucket"}}) + assert isinstance(runtime.rate_limiter, TokenBucketLimiter) + + +def test_workload_guard_defaults_to_a_bounded_budget() -> None: + """没配等于没读过文档:默认必须是拦住请求的那个,不是放行的那个。""" + assert _build().workload_guard.max_concurrent >= 1 + + +def test_binding_policy_defaults_to_loopback_enforcement() -> None: + runtime = _build() + runtime.binding_policy.check("127.0.0.1", requires_loopback=True) + with pytest.raises(ValidationError): + runtime.binding_policy.check("0.0.0.0", requires_loopback=True) + + +# -- 密码学是可选的,且无默认 ------------------------------------------------ # + + +def test_cryptography_is_absent_unless_configured() -> None: + """默认装一个加密 provider 会凭空造出一把没人管理生命周期的根密钥。""" + assert _build().cryptography_provider is None + + +def test_cryptography_is_wired_when_configured(tmp_path) -> None: + runtime = _build( + { + "cryptography": { + "target": "local", + "params": { + "key_provider": { + "target": "local", + "params": {"key_file": str(tmp_path / "master.key")}, + } + }, + } + } + ) + assert runtime.cryptography_provider is not None + runtime.health() + + +# -- 健康检查与生命周期 ------------------------------------------------------ # + + +class _Unhealthy: + """只在 health() 上失败的探针,用来断言 Runtime 的传播行为。""" + + @staticmethod + def health() -> None: + raise RuntimeError("backend unreachable: token=s3cret") + + +def test_health_returns_none_when_all_capabilities_are_healthy() -> None: + """返回 bool 会诱导调用方写 ``if not runtime.health(): warn(...)`` 然后照常启动。""" + assert _build().health() is None + + +def test_health_rejects_when_any_capability_is_unhealthy() -> None: + runtime = _build() + broken = SecurityRuntime( + authenticator=runtime.authenticator, + authorizer=runtime.authorizer, + rate_limiter=runtime.rate_limiter, + workload_guard=runtime.workload_guard, + binding_policy=runtime.binding_policy, + cryptography_provider=_Unhealthy(), # type: ignore[arg-type] + ) + with pytest.raises(ValidationError): + broken.health() + + +def test_health_error_names_the_capability_not_the_secret() -> None: + """异常消息只带能力名——具体原因由各实现决定暴露多少(F05 §装配不变量 8)。""" + runtime = _build() + broken = SecurityRuntime( + authenticator=runtime.authenticator, + authorizer=runtime.authorizer, + rate_limiter=runtime.rate_limiter, + workload_guard=runtime.workload_guard, + binding_policy=runtime.binding_policy, + cryptography_provider=_Unhealthy(), # type: ignore[arg-type] + ) + with pytest.raises(ValidationError) as exc: + broken.health() + assert "cryptography" in str(exc.value) + assert "s3cret" not in str(exc.value) + + +def test_close_is_safe_for_capabilities_without_close() -> None: + _build().close() + + +def test_close_continues_past_a_failing_capability() -> None: + """关闭路径上放弃剩余能力会漏掉连接与文件句柄。""" + closed: list[str] = [] + + class _Failing: + @staticmethod + def health() -> None: + return None + + @staticmethod + def close() -> None: + raise RuntimeError("close failed") + + class _Recording: + @staticmethod + def health() -> None: + return None + + @staticmethod + def close() -> None: + closed.append("recorded") + + runtime = _build() + combined = SecurityRuntime( + authenticator=_Failing(), # type: ignore[arg-type] + authorizer=runtime.authorizer, + rate_limiter=runtime.rate_limiter, + workload_guard=runtime.workload_guard, + binding_policy=runtime.binding_policy, + cryptography_provider=_Recording(), # type: ignore[arg-type] + ) + combined.close() + assert closed == ["recorded"] + + +# -- 具名共享 ---------------------------------------------------------------- # + + +def test_named_capabilities_are_shared_across_surfaces() -> None: + """运行期共享状态通过具名实例显式共享,不靠模块级单例(F05 §SecurityRuntime)。""" + ctx = AssemblyContext.from_dict( + { + "security": { + "default": { + "target": "standard", + "params": { + "authenticator": "shared_auth", + "authorizer": "shared_authz", + "workload_guard": "shared_budget", + }, + } + }, + "authenticator": {"shared_auth": {"target": "dev"}}, + "authorizer": { + "shared_authz": { + "target": "standard", + "params": {"grant_store": "shared_grants", "delegation_store": "shared_dlg"}, + } + }, + "grant_store": {"shared_grants": {"target": "memory"}}, + "delegation_store": {"shared_dlg": {"target": "memory"}}, + "workload_guard": { + "shared_budget": {"target": "semaphore", "params": {"max_concurrent": 1}} + }, + } + ) + http_runtime = SecurityRuntimeProducer.build_named("default", ctx) + mcp_runtime = SecurityRuntimeProducer.build_named("default", ctx) + + assert http_runtime is mcp_runtime + assert http_runtime.workload_guard is mcp_runtime.workload_guard diff --git a/tests/unit/common/security/test_types.py b/tests/unit/common/security/test_types.py new file mode 100644 index 00000000..067a0a03 --- /dev/null +++ b/tests/unit/common/security/test_types.py @@ -0,0 +1,181 @@ +"""common.security.types: 值对象不可变性、actor 必填、ContextVar 传播与线程隔离。""" + +from __future__ import annotations + +import threading +from dataclasses import FrozenInstanceError +from datetime import datetime, timedelta, timezone + +import pytest + +from common.errors import AgentMemoryError, AuthenticationError, PermissionDeniedError +from common.security.types import ( + ROLE_RANK, + AuthContext, + RequestSecurityContext, + Role, + Surface, + get_current, + reset_current, + set_current, +) +from common.type_def.scope import Scope + +pytestmark = pytest.mark.unit + + +def test_actor_has_no_default() -> None: + """无参构造必须失败:否则「忘了传 actor」会静默得到 ROOT 的空 Scope()。""" + with pytest.raises(TypeError): + AuthContext() # type: ignore[call-arg] + + +def test_context_is_frozen() -> None: + ctx = AuthContext(actor=Scope(org="acme", user="alice")) + with pytest.raises(FrozenInstanceError): + ctx.actor = Scope() # type: ignore[misc] + with pytest.raises(FrozenInstanceError): + ctx.role = Role.ROOT # type: ignore[misc] + + +def test_defaults_are_least_privilege() -> None: + ctx = AuthContext(actor=Scope(org="acme", user="alice")) + assert ctx.role is Role.USER + assert ctx.delegation_id == "" + assert ctx.credential_id == "" + assert ctx.expires_at is None + + +def test_role_is_str_for_audit_detail() -> None: + """Role 要能直接进 AuditEvent.detail(dict[str, str]),无需转换。""" + detail: dict[str, str] = {"role": Role.ADMIN} + assert detail["role"] == "admin" + + +def test_role_rank_is_ordered() -> None: + assert ROLE_RANK[Role.USER] < ROLE_RANK[Role.ADMIN] < ROLE_RANK[Role.ROOT] + + +def test_unknown_role_rejected_at_construction() -> None: + """拼错的角色名在构造点就炸,而不是在权限判断时静默走 else 分支。""" + with pytest.raises(ValueError): + Role("superuser") + + +# --- 过期判定 --- + + +def test_no_expiry_never_expires() -> None: + """``expires_at=None`` 表示不随上下文过期,不能被读成「已过期」。""" + assert AuthContext(actor=Scope(org="acme", user="alice")).is_expired() is False + + +def test_expiry_boundary_is_inclusive() -> None: + """到点即失效:``now == expires_at`` 判过期,不留一个刚好等于的放行缝。""" + moment = datetime(2026, 1, 1, tzinfo=timezone.utc) + ctx = AuthContext(actor=Scope(org="acme", user="alice"), expires_at=moment) + assert ctx.is_expired(now=moment) is True + assert ctx.is_expired(now=moment - timedelta(seconds=1)) is False + + +# --- RequestSecurityContext --- + + +def test_request_context_attributes_are_read_only() -> None: + """``attributes`` 参与 PR2 的授权环境:构造后不能再被任何持有者改写。""" + ctx = RequestSecurityContext( + auth=AuthContext(actor=Scope(org="acme", user="alice")), + attributes={"tenant_tier": "gold"}, + ) + with pytest.raises(TypeError): + ctx.attributes["tenant_tier"] = "platinum" # type: ignore[index] + + +def test_request_context_copies_the_source_mapping() -> None: + """传入的 dict 事后被改,不能影响已建立的请求上下文。""" + source = {"tenant_tier": "gold"} + ctx = RequestSecurityContext( + auth=AuthContext(actor=Scope(org="acme", user="alice")), + attributes=source, + ) + source["tenant_tier"] = "platinum" + assert ctx.attributes["tenant_tier"] == "gold" + + +def test_request_context_actor_comes_from_auth() -> None: + """actor 只能来自认证产出,不是请求上下文自带的可写字段。""" + auth = AuthContext(actor=Scope(org="acme", user="alice")) + ctx = RequestSecurityContext(auth=auth, surface=Surface.HTTP) + assert ctx.actor == Scope(org="acme", user="alice") + with pytest.raises(AttributeError): + ctx.actor = Scope(org="evil") # type: ignore[misc] + + +def test_request_context_surface_defaults_to_internal() -> None: + """未声明接入形态时按进程内算,不猜成某个网络 surface。""" + ctx = RequestSecurityContext(auth=AuthContext(actor=Scope())) + assert ctx.surface is Surface.INTERNAL + + +# --- ContextVar 传播 --- + + +def test_get_current_is_none_without_authentication() -> None: + """未认证返回 None 而非默认 AuthContext——后者是 fail-open。""" + assert get_current() is None + + +def test_set_then_reset_restores_none() -> None: + ctx = AuthContext(actor=Scope(org="acme", user="alice")) + token = set_current(ctx) + try: + assert get_current() is ctx + finally: + reset_current(token) + assert get_current() is None + + +def test_nested_set_restores_outer_context() -> None: + outer = AuthContext(actor=Scope(org="acme", user="alice")) + inner = AuthContext(actor=Scope(org="evil", user="mallory")) + outer_token = set_current(outer) + inner_token = set_current(inner) + assert get_current() is inner + reset_current(inner_token) + assert get_current() is outer + reset_current(outer_token) + assert get_current() is None + + +def test_threads_do_not_share_context() -> None: + """ThreadingHTTPServer 每请求一线程:一个线程的身份绝不能被另一个看到。""" + seen: dict[str, AuthContext | None] = {} + started = threading.Event() + + def worker() -> None: + seen["before_main_set"] = get_current() + token = set_current(AuthContext(actor=Scope(org="evil", user="mallory"))) + started.set() + seen["own"] = get_current() + reset_current(token) + + main_token = set_current(AuthContext(actor=Scope(org="acme", user="alice"))) + try: + thread = threading.Thread(target=worker) + thread.start() + thread.join() + assert seen["before_main_set"] is None # 主线程的身份不泄漏进子线程 + assert seen["own"].actor == Scope(org="evil", user="mallory") + assert get_current().actor == Scope(org="acme", user="alice") # 未被子线程污染 + finally: + reset_current(main_token) + + +# --- AuthenticationError --- + + +def test_authentication_error_is_distinct_from_permission_denied() -> None: + """401「不知道你是谁」与 403「知道但不许」必须可分,否则 HTTP 层无法映射。""" + assert issubclass(AuthenticationError, AgentMemoryError) + assert not issubclass(AuthenticationError, PermissionDeniedError) + assert not issubclass(PermissionDeniedError, AuthenticationError) diff --git a/tests/unit/common/test_jieba_tokenizer.py b/tests/unit/common/test_jieba_tokenizer.py index 9fdd6cd9..e9b71c5d 100644 --- a/tests/unit/common/test_jieba_tokenizer.py +++ b/tests/unit/common/test_jieba_tokenizer.py @@ -17,6 +17,7 @@ from common.base import PluginType from common.tokenizer.tokenizer_impl import TokenizerProducer from common.tokenizer.tokenizer_impl.jieba_tokenizer import JiebaTokenizer +from tests.conftest import sec # --------------------------------------------------------------------------- # Tests: Core interface @@ -195,7 +196,7 @@ def test_assemble_with_jieba(): scope = Scope(org="test", user="alice", agent="a1", session="s1") actor = Scope(org="test", user="alice") # write → jieba 分词建索引 → recall - units = api.write("用户偏好简洁回答", scope, source=Modality.TEXT, identity=actor) + units = api.write("用户偏好简洁回答", scope, source=Modality.TEXT, security=sec(actor)) assert len(units) == 1 - result = api.recall("偏好", Context(scope), identity=actor, top_k=10) + result = api.recall("偏好", Context(scope), security=sec(actor), top_k=10) assert len(result.items) > 0 diff --git a/tests/unit/common/test_local_security_provider.py b/tests/unit/common/test_local_security_provider.py deleted file mode 100644 index 0c12464d..00000000 --- a/tests/unit/common/test_local_security_provider.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -import stat - -import pytest - -import common.security.security_impl -from common.factory.factory import Factory -from common.security import ( - AuthenticationFailedError, - CorruptedCiphertextError, - InvalidMagicError, - KeyMismatchError, - SecurityContext, - SecurityProducer, -) -from common.security.security_impl.local_envelope_security_provider import ( - ENVELOPE_MAGIC, - LocalEnvelopeSecurityProvider, - LocalKeyProvider, -) -from common.type_def import Scope -from config import AssemblyContext - -_KEY_HEX = "11" * 32 - -pytestmark = pytest.mark.unit - - -def _context( - *, - org: str = "acme", - user: str = "alice", - purpose: str = "memory_unit", -) -> SecurityContext: - return SecurityContext( - scope=Scope(org=org, user=user), - purpose=purpose, - metadata={"key": "/memory/u1"}, - ) - - -def _provider_from_hex(*, allow_plaintext: bool = True) -> LocalEnvelopeSecurityProvider: - return LocalEnvelopeSecurityProvider( - LocalKeyProvider(key_hex=_KEY_HEX), - allow_plaintext=allow_plaintext, - ) - - -def test_local_security_provider_encrypts_enc1_and_round_trips(tmp_path) -> None: - key_file = tmp_path / "master.key" - provider = LocalEnvelopeSecurityProvider(LocalKeyProvider(key_file=str(key_file))) - context = _context() - - ciphertext = provider.encrypt(b"secret payload", context=context, aad=b"kv:a") - second_ciphertext = provider.encrypt(b"secret payload", context=context, aad=b"kv:a") - - assert ciphertext.startswith(ENVELOPE_MAGIC) - assert ciphertext != b"secret payload" - assert ciphertext != second_ciphertext - assert provider.decrypt(ciphertext, context=context, aad=b"kv:a") == b"secret payload" - assert key_file.exists() - assert stat.S_IMODE(key_file.stat().st_mode) == 0o600 - - -def test_local_security_provider_supports_plaintext_compatibility() -> None: - provider = _provider_from_hex() - - assert provider.decrypt(b"legacy plaintext", context=_context()) == b"legacy plaintext" - - -def test_local_security_provider_can_reject_plaintext_in_strict_mode() -> None: - provider = _provider_from_hex(allow_plaintext=False) - - with pytest.raises(InvalidMagicError): - provider.decrypt(b"legacy plaintext", context=_context()) - - -def test_local_security_provider_rejects_aad_or_context_mismatch() -> None: - provider = _provider_from_hex() - ciphertext = provider.encrypt(b"secret payload", context=_context(user="alice"), aad=b"kv:a") - - with pytest.raises(AuthenticationFailedError): - provider.decrypt(ciphertext, context=_context(user="alice"), aad=b"kv:b") - with pytest.raises(AuthenticationFailedError): - provider.decrypt(ciphertext, context=_context(user="bob"), aad=b"kv:a") - - -def test_local_security_provider_rejects_org_key_mismatch() -> None: - provider = _provider_from_hex() - ciphertext = provider.encrypt(b"secret payload", context=_context(org="acme"), aad=b"kv:a") - - with pytest.raises(KeyMismatchError): - provider.decrypt(ciphertext, context=_context(org="other"), aad=b"kv:a") - - -def test_local_security_provider_rejects_corrupted_envelope() -> None: - provider = _provider_from_hex() - - with pytest.raises(CorruptedCiphertextError): - provider.decrypt(ENVELOPE_MAGIC, context=_context()) - - -def test_security_producer_builds_local_provider_from_config(tmp_path) -> None: - assert common.security.security_impl.SecurityProducer is SecurityProducer - Factory.reset_all() - key_file = tmp_path / "configured.key" - ctx = AssemblyContext.from_dict( - { - "security": { - "default": { - "target": "local", - "params": {"key_file": str(key_file)}, - } - } - } - ) - - provider = SecurityProducer.build_named("default", ctx) - context = _context() - ciphertext = provider.encrypt(b"value", context=context, aad=b"kv:a") - - assert isinstance(provider, LocalEnvelopeSecurityProvider) - assert provider.decrypt(ciphertext, context=context, aad=b"kv:a") == b"value" - assert key_file.exists() diff --git a/tests/unit/construction/test_dynamic_extraction_consolidation.py b/tests/unit/construction/test_dynamic_extraction_consolidation.py index 735b1a5c..69579e0e 100644 --- a/tests/unit/construction/test_dynamic_extraction_consolidation.py +++ b/tests/unit/construction/test_dynamic_extraction_consolidation.py @@ -34,6 +34,7 @@ ) from storage.graph_impl.in_memory_graph_store import InMemoryGraphStore from storage.kv_impl.in_memory_kv_store import InMemoryKVStore +from tests.conftest import sec class _ScriptedLLM(LLM): @@ -498,8 +499,8 @@ def test_default_engine_writes_through_without_consolidator(): kernel = build_kernel() scope = Scope(org="org", user="user") - first = kernel.api.write("完全相同的记忆", scope, identity=scope) - second = kernel.api.write("完全相同的记忆", scope, identity=scope) + first = kernel.api.write("完全相同的记忆", scope, security=sec(scope)) + second = kernel.api.write("完全相同的记忆", scope, security=sec(scope)) # 默认直写路径:两次都落盘,不去重(去重交给显式 evolve) assert len(first) == 1 diff --git a/tests/unit/construction/test_e2e_evolution.py b/tests/unit/construction/test_e2e_evolution.py index d5a1561b..0c3f04df 100644 --- a/tests/unit/construction/test_e2e_evolution.py +++ b/tests/unit/construction/test_e2e_evolution.py @@ -14,6 +14,7 @@ from common.type_def import Context, MemoryTier, Modality, Scope from config import Config from construction import EvolveMode +from tests.conftest import sec DEFAULT_SCOPE = Scope(org="test", user="alice", agent="a1", session="s1") DEFAULT_ACTOR = Scope(org="test", user="alice") @@ -41,7 +42,7 @@ def test_write_recall_returns_written_unit(llm_api): "用户偏好简洁回答风格", DEFAULT_SCOPE, source=Modality.TEXT, - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), ) assert len(units) == 1 # write 不调 classify:tier 保持 MemoryUnit 默认 EPISODIC,无 classify metadata @@ -52,7 +53,7 @@ def test_write_recall_returns_written_unit(llm_api): result = llm_api.recall( "简洁", Context(DEFAULT_SCOPE), - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), top_k=10, ) assert len(result.items) > 0 @@ -81,7 +82,7 @@ def test_background_extract_trigger(llm_api): "用户偏好简洁回答", DEFAULT_SCOPE, source=Modality.TEXT, - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), ) # write() 内 scheduler.submit(EXTRACT, BACKGROUND) → InProcessScheduler 同步执行 # EchoLLM 返回原文(非 JSON),LLMExtractor 降级为空 list @@ -92,7 +93,7 @@ def test_background_extract_trigger(llm_api): result = llm_api.recall( "偏好", Context(DEFAULT_SCOPE), - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), top_k=10, ) assert len(result.items) > 0 @@ -104,13 +105,13 @@ def test_explicit_evolve_extract(llm_api): "用户讨论了架构设计", DEFAULT_SCOPE, source=Modality.TEXT, - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), ) # 手动触发演进 job_id = llm_api.evolve( DEFAULT_SCOPE, EvolveMode.EXTRACT, - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), ) assert job_id # 返回 job_id @@ -130,14 +131,14 @@ def test_offline_write_and_recall(offline_api): "测试内容", DEFAULT_SCOPE, source=Modality.TEXT, - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), ) assert len(units) == 1 result = offline_api.recall( "测试", Context(DEFAULT_SCOPE), - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), top_k=5, ) assert len(result.items) > 0 @@ -149,14 +150,14 @@ def test_offline_evolve_noop(offline_api): "测试内容", DEFAULT_SCOPE, source=Modality.TEXT, - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), ) # background EXTRACT 自动触发(keyword extractor 产出 chunk 类派生 unit) # 验证不崩溃即可 result = offline_api.recall( "测试", Context(DEFAULT_SCOPE), - identity=DEFAULT_ACTOR, + security=sec(DEFAULT_ACTOR), top_k=5, ) assert len(result.items) > 0 diff --git a/tests/unit/control/test_engine_delete_selector.py b/tests/unit/control/test_engine_delete_selector.py index 500f1abc..ea91e602 100644 --- a/tests/unit/control/test_engine_delete_selector.py +++ b/tests/unit/control/test_engine_delete_selector.py @@ -9,32 +9,33 @@ from common.errors import NotFoundError, ValidationError from common.type_def import LifecycleState, MemoryUnit, Segment, memory_key from common.type_def.memory_codec import dumps +from tests.conftest import sec def test_delete_selector_matches_tags_within_scope() -> None: scope = Scope(org="acme", user="u1", agent="a1", session="s1") actor = scope kernel = build_kernel() - stale = kernel.api.write("old temporary note", scope, identity=actor, tags=["temp"])[0] - keep = kernel.api.write("fresh durable note", scope, identity=actor, tags=["durable"])[0] + stale = kernel.api.write("old temporary note", scope, security=sec(actor), tags=["temp"])[0] + keep = kernel.api.write("fresh durable note", scope, security=sec(actor), tags=["durable"])[0] affected = kernel.api.delete( DeleteSelector(scope=scope, tags=["temp"], mode=DeleteMode.ARCHIVE), - identity=actor, + security=sec(actor), ) assert stale.id in affected assert keep.id not in affected assert all( - "temp" in kernel.api.get(unit_id, scope, identity=actor).tags + "temp" in kernel.api.get(unit_id, scope, security=sec(actor)).tags for unit_id in affected ) assert all( - kernel.api.get(unit_id, scope, identity=actor).lifecycle == LifecycleState.ARCHIVED + kernel.api.get(unit_id, scope, security=sec(actor)).lifecycle == LifecycleState.ARCHIVED for unit_id in affected ) - assert kernel.api.get(stale.id, scope, identity=actor).lifecycle == LifecycleState.ARCHIVED - assert kernel.api.get(keep.id, scope, identity=actor).lifecycle == LifecycleState.ACTIVE + assert kernel.api.get(stale.id, scope, security=sec(actor)).lifecycle == LifecycleState.ARCHIVED + assert kernel.api.get(keep.id, scope, security=sec(actor)).lifecycle == LifecycleState.ACTIVE def test_delete_selector_matches_before_event_time() -> None: @@ -44,13 +45,13 @@ def test_delete_selector_matches_before_event_time() -> None: old = kernel.api.write( "old event", scope, - identity=actor, + security=sec(actor), occurred_at=datetime(2026, 6, 17, 9, 0, tzinfo=timezone.utc), )[0] new = kernel.api.write( "new event", scope, - identity=actor, + security=sec(actor), occurred_at=datetime(2026, 6, 17, 12, 0, tzinfo=timezone.utc), )[0] @@ -60,22 +61,22 @@ def test_delete_selector_matches_before_event_time() -> None: before=datetime(2026, 6, 17, 10, 0, tzinfo=timezone.utc), mode=DeleteMode.FORGET, ), - identity=actor, + security=sec(actor), ) cutoff = datetime(2026, 6, 17, 10, 0, tzinfo=timezone.utc) assert old.id in affected assert new.id not in affected assert all( - kernel.api.get(unit_id, scope, identity=actor).temporal.t_event < cutoff + kernel.api.get(unit_id, scope, security=sec(actor)).temporal.t_event < cutoff for unit_id in affected ) assert all( - kernel.api.get(unit_id, scope, identity=actor).lifecycle == LifecycleState.FORGOTTEN + kernel.api.get(unit_id, scope, security=sec(actor)).lifecycle == LifecycleState.FORGOTTEN for unit_id in affected ) - assert kernel.api.get(old.id, scope, identity=actor).lifecycle == LifecycleState.FORGOTTEN - assert kernel.api.get(new.id, scope, identity=actor).lifecycle == LifecycleState.ACTIVE + assert kernel.api.get(old.id, scope, security=sec(actor)).lifecycle == LifecycleState.FORGOTTEN + assert kernel.api.get(new.id, scope, security=sec(actor)).lifecycle == LifecycleState.ACTIVE def test_delete_selector_combines_conditions_with_and() -> None: @@ -85,21 +86,21 @@ def test_delete_selector_combines_conditions_with_and() -> None: matching = kernel.api.write( "old temp", scope, - identity=actor, + security=sec(actor), tags=["temp"], occurred_at=datetime(2026, 6, 17, 9, 0, tzinfo=timezone.utc), )[0] wrong_tag = kernel.api.write( "old durable", scope, - identity=actor, + security=sec(actor), tags=["durable"], occurred_at=datetime(2026, 6, 17, 9, 0, tzinfo=timezone.utc), )[0] too_new = kernel.api.write( "new temp", scope, - identity=actor, + security=sec(actor), tags=["temp"], occurred_at=datetime(2026, 6, 17, 12, 0, tzinfo=timezone.utc), )[0] @@ -111,7 +112,7 @@ def test_delete_selector_combines_conditions_with_and() -> None: before=datetime(2026, 6, 17, 10, 0, tzinfo=timezone.utc), mode=DeleteMode.FORGET, ), - identity=actor, + security=sec(actor), ) cutoff = datetime(2026, 6, 17, 10, 0, tzinfo=timezone.utc) @@ -119,27 +120,29 @@ def test_delete_selector_combines_conditions_with_and() -> None: assert wrong_tag.id not in affected assert too_new.id not in affected assert all( - "temp" in kernel.api.get(unit_id, scope, identity=actor).tags + "temp" in kernel.api.get(unit_id, scope, security=sec(actor)).tags for unit_id in affected ) assert all( - kernel.api.get(unit_id, scope, identity=actor).temporal.t_event < cutoff + kernel.api.get(unit_id, scope, security=sec(actor)).temporal.t_event < cutoff for unit_id in affected ) assert all( - kernel.api.get(unit_id, scope, identity=actor).lifecycle == LifecycleState.FORGOTTEN + kernel.api.get(unit_id, scope, security=sec(actor)).lifecycle == LifecycleState.FORGOTTEN for unit_id in affected ) - assert kernel.api.get(matching.id, scope, identity=actor).lifecycle == LifecycleState.FORGOTTEN - assert kernel.api.get(wrong_tag.id, scope, identity=actor).lifecycle == LifecycleState.ACTIVE - assert kernel.api.get(too_new.id, scope, identity=actor).lifecycle == LifecycleState.ACTIVE + got = kernel.api.get(matching.id, scope, security=sec(actor)) + assert got.lifecycle == LifecycleState.FORGOTTEN + kept = kernel.api.get(wrong_tag.id, scope, security=sec(actor)) + assert kept.lifecycle == LifecycleState.ACTIVE + assert kernel.api.get(too_new.id, scope, security=sec(actor)).lifecycle == LifecycleState.ACTIVE def test_empty_delete_selector_raises_validation_error() -> None: kernel = build_kernel() with pytest.raises(ValidationError): - kernel.api.delete(DeleteSelector(), identity=Scope(org="acme", user="u1")) + kernel.api.delete(DeleteSelector(), security=sec(Scope(org="acme", user="u1"))) def test_delete_downweight_updates_importance_without_changing_lifecycle() -> None: @@ -149,16 +152,16 @@ def test_delete_downweight_updates_importance_without_changing_lifecycle() -> No unit = kernel.api.write( "lower priority", scope, - identity=actor, + security=sec(actor), metadata={"importance": "0.8"}, )[0] affected = kernel.api.delete( DeleteSelector(unit_ids=[unit.id], scope=scope, mode=DeleteMode.DOWNWEIGHT), - identity=actor, + security=sec(actor), ) - stored = kernel.api.get(unit.id, scope, identity=actor) + stored = kernel.api.get(unit.id, scope, security=sec(actor)) assert affected == [unit.id] assert stored.lifecycle == LifecycleState.ACTIVE assert stored.metadata["importance"] == "0.4" @@ -168,17 +171,17 @@ def test_delete_purge_removes_memory_unit_from_truth_store() -> None: scope = Scope(org="acme", user="u1", agent="a1", session="s1") actor = scope kernel = build_kernel() - unit = kernel.api.write("remove permanently", scope, identity=actor)[0] + unit = kernel.api.write("remove permanently", scope, security=sec(actor))[0] affected = kernel.api.delete( DeleteSelector(unit_ids=[unit.id], scope=scope, mode=DeleteMode.PURGE), - identity=actor, + security=sec(actor), ) assert unit.id in affected for unit_id in affected: with pytest.raises(NotFoundError): - kernel.api.get(unit_id, scope, identity=actor) + kernel.api.get(unit_id, scope, security=sec(actor)) def test_delete_archive_uses_lifecycle_transition_validation() -> None: @@ -196,10 +199,11 @@ def test_delete_archive_uses_lifecycle_transition_validation() -> None: with pytest.raises(ValidationError): kernel.api.delete( DeleteSelector(unit_ids=[forgotten.id], scope=scope, mode=DeleteMode.ARCHIVE), - identity=actor, + security=sec(actor), ) - assert kernel.api.get(forgotten.id, scope, identity=actor).lifecycle == LifecycleState.FORGOTTEN + got = kernel.api.get(forgotten.id, scope, security=sec(actor)) + assert got.lifecycle == LifecycleState.FORGOTTEN def test_delete_purge_recursively_removes_provenance_descendants() -> None: @@ -225,11 +229,11 @@ def test_delete_purge_recursively_removes_provenance_descendants() -> None: affected = kernel.api.delete( DeleteSelector(unit_ids=[source.id], scope=scope, mode=DeleteMode.PURGE), - identity=actor, + security=sec(actor), ) assert set(affected) == {source.id, direct.id, nested.id} for unit_id in [source.id, direct.id, nested.id]: with pytest.raises(NotFoundError): - kernel.api.get(unit_id, scope, identity=actor) - assert kernel.api.get(unrelated.id, scope, identity=actor).id == unrelated.id + kernel.api.get(unit_id, scope, security=sec(actor)) + assert kernel.api.get(unrelated.id, scope, security=sec(actor)).id == unrelated.id diff --git a/tests/unit/control/test_engine_evolve_scheduler.py b/tests/unit/control/test_engine_evolve_scheduler.py index 2ede184a..1ca2b4ce 100644 --- a/tests/unit/control/test_engine_evolve_scheduler.py +++ b/tests/unit/control/test_engine_evolve_scheduler.py @@ -2,17 +2,19 @@ import asyncio +import pytest + +from api.memory_api_impl import build_kernel from common.type_def import Scope -from construction import EvolveMode, EvolveResult, Evolver +from construction import EvolveMode, Evolver, EvolveResult from construction.base import OperatorType -from api.memory_api_impl import build_kernel -from config.config import Config from control.engine_impl.in_memory_engine import InMemoryEngine from control.jobs import Job, JobFactory, JobType -from control.jobs_impl.evolve_job import EvolveJobSpec -from control.types import Channel, JobStatus -from control.types import BatchWriteItem, Channel, JobStatus +from control.types import Channel, JobInfo, JobStatus from storage.kv_impl.in_memory_kv_store import InMemoryKVStore +from tests.conftest import sec + +pytestmark = pytest.mark.unit class RaisingEvolver(Evolver): @@ -27,8 +29,6 @@ def evolve(self, units, mode: EvolveMode) -> EvolveResult: class RecordingScheduler: - """记录 submit 调用入参的 Scheduler 替身(不实际执行 Job)。""" - def __init__(self) -> None: self.calls: list[tuple[Job, Channel]] = [] @@ -36,102 +36,57 @@ async def submit(self, job: Job, channel: Channel) -> str: self.calls.append((job, channel)) return "job-1" - @staticmethod - def status(job_id: str): - ... - @staticmethod - def cancel(job_id: str) -> None: - ... +class _NeverRunJob(Job): + async def run(self) -> JobInfo: + raise AssertionError("Engine.evolve must only submit the constructed job") -def _build_test_job_factory(evolver) -> JobFactory: - """构造测试用 JobFactory——注册 EvolveJob 的 Spec builder。 +class RecordingJobFactory(JobFactory): + def __init__(self) -> None: + super().__init__() + self.calls: list[tuple[JobType, Scope, dict, Job]] = [] - Engine.evolve 经 JobFactory.get_job(JobType.EVOLVE, scope, mode=mode) - 取 EvolveJob 实例——Engine 不再直接 new EvolveJob(统一 Job 创建路径)。 - """ - factory = JobFactory() - factory.register( - JobType.EVOLVE, - EvolveJobSpec(kv=None, evolver=evolver).with_scope, - ) - return factory + def get_job(self, job_type: JobType, scope: Scope, **kwargs) -> Job: + job = _NeverRunJob(scope=scope) + self.calls.append((job_type, scope, kwargs, job)) + return job def test_engine_evolve_only_submits_scheduler_job() -> None: - """Engine.evolve 经 JobFactory 取 EvolveJob(mode=mode) 提交,不实际执行 evolver。""" scope = Scope(user="u1") scheduler = RecordingScheduler() - evolver = RaisingEvolver() + job_factory = RecordingJobFactory() engine = InMemoryEngine( ingestor=None, index_builder=None, retriever=None, kv=InMemoryKVStore(), scheduler=scheduler, - evolver=evolver, + evolver=RaisingEvolver(), lifecycle=None, - job_factory=_build_test_job_factory(evolver), + job_factory=job_factory, ) job_id = asyncio.run(engine.evolve(scope, EvolveMode.CONSOLIDATE, Channel.HOT)) assert job_id == "job-1" - assert len(scheduler.calls) == 1 - job, channel = scheduler.calls[0] - assert channel == Channel.HOT - assert job.scope == scope - assert job.interval == 0 - # mode 经 EvolveJob 构造参数流入——不该由 Scheduler 看到或硬编码 - assert job._mode == EvolveMode.CONSOLIDATE # pylint: disable=protected-access - - -def test_in_memory_batch_write_collects_unexpected_error_and_continues() -> None: - scope = Scope(user="u1") - engine = InMemoryEngine( - ingestor=None, - index_builder=None, - retriever=None, - kv=InMemoryKVStore(), - scheduler=None, - evolver=None, - lifecycle=None, - ) - attempted: list[str] = [] - - async def _write(content, *_args, **_kwargs): - attempted.append(content) - if content == "bad": - raise RuntimeError("unavailable dependency") - return [] - - engine.write = _write # type: ignore[method-assign] - result = asyncio.run( - engine.batch_write( - [BatchWriteItem(content="bad", scope=scope), BatchWriteItem(content="good", scope=scope)] - ) - ) - - assert attempted == ["bad", "good"] - assert result.outcomes[0].error_type == "InternalError" - assert not result.outcomes[1].error + assert len(job_factory.calls) == 1 + job_type, requested_scope, kwargs, job = job_factory.calls[0] + assert job_type == JobType.EVOLVE + assert requested_scope == scope + assert kwargs == {"mode": EvolveMode.CONSOLIDATE} + assert scheduler.calls == [(job, Channel.HOT)] def test_api_evolve_returns_completed_scheduler_job_with_evolve_result_detail() -> None: - # 显式覆盖 scheduler=in_process——本测试验证 evolve 语义(同步 SUCCEEDED), - # 不验证 AsyncTimerScheduler 的异步调度行为(后者由阶段 5 集成测试覆盖)。 - # AsyncTimerScheduler 需事件循环驱动,submit 后不立即完成,与同步断言不兼容。 - config = Config.from_dict( - {"scheduler": {"default": {"target": "in_process", "params": {}}}} - ) - kernel = build_kernel(config=config) + kernel = build_kernel() scope = Scope(user="u1") - kernel.api.write("Alice likes tea", scope, identity=scope) + kernel.api.write("Alice likes tea", scope, security=sec(scope)) - job_id = kernel.api.evolve(scope, EvolveMode.EXTRACT, identity=scope) + job_id = kernel.api.evolve(scope, EvolveMode.EXTRACT, security=sec(scope)) - job = kernel.api.job_status(job_id, identity=scope) + job = kernel.api.job_status(job_id, security=sec(scope)) assert job.status == JobStatus.SUCCEEDED assert job.detail["created_ids"] is not None assert job.detail["updated_ids"] == "" diff --git a/tests/unit/control/test_engine_get_as_of.py b/tests/unit/control/test_engine_get_as_of.py index 4d2649ca..f6871c62 100644 --- a/tests/unit/control/test_engine_get_as_of.py +++ b/tests/unit/control/test_engine_get_as_of.py @@ -9,6 +9,7 @@ from common.errors import NotFoundError from common.type_def import MemoryTier, MemoryUnit, Modality, Segment, Temporal, memory_key from common.type_def.memory_codec import dumps +from tests.conftest import sec def test_get_as_of_returns_version_valid_at_that_time() -> None: @@ -38,13 +39,13 @@ def test_get_as_of_returns_version_valid_at_that_time() -> None: before_update = kernel.api.get( new.id, scope, - identity=actor, + security=sec(actor), as_of=datetime(2026, 6, 17, 10, 30, tzinfo=timezone.utc), ) after_update = kernel.api.get( old.id, scope, - identity=actor, + security=sec(actor), as_of=datetime(2026, 6, 17, 11, 30, tzinfo=timezone.utc), ) @@ -59,7 +60,7 @@ def test_get_as_of_handles_historical_update_before_original_write_time() -> Non actor = scope kernel = build_kernel() - old = kernel.api.write("home is Shanghai", scope, identity=actor)[0] + old = kernel.api.write("home is Shanghai", scope, security=sec(actor))[0] new = kernel.api.update( old.id, scope, @@ -67,19 +68,19 @@ def test_get_as_of_handles_historical_update_before_original_write_time() -> Non content="home is Beijing", t_valid=datetime(2026, 6, 17, 11, 0, tzinfo=timezone.utc), ), - identity=actor, + security=sec(actor), ) before_update = kernel.api.get( new.id, scope, - identity=actor, + security=sec(actor), as_of=datetime(2026, 6, 17, 10, 30, tzinfo=timezone.utc), ) after_update = kernel.api.get( old.id, scope, - identity=actor, + security=sec(actor), as_of=datetime(2026, 6, 17, 11, 30, tzinfo=timezone.utc), ) @@ -115,13 +116,13 @@ def test_get_as_of_does_not_return_forgotten_version() -> None: kernel.kv.insert(scope, memory_key(new.id), dumps(new)) kernel.api.delete( DeleteSelector(unit_ids=[old.id], scope=scope, mode=DeleteMode.FORGET), - identity=actor, + security=sec(actor), ) with pytest.raises(NotFoundError): kernel.api.get( new.id, scope, - identity=actor, + security=sec(actor), as_of=datetime(2026, 6, 17, 10, 30, tzinfo=timezone.utc), ) diff --git a/tests/unit/control/test_engine_update_versioning.py b/tests/unit/control/test_engine_update_versioning.py index ed4ba853..7219e9df 100644 --- a/tests/unit/control/test_engine_update_versioning.py +++ b/tests/unit/control/test_engine_update_versioning.py @@ -9,6 +9,7 @@ from common.type_def.memory_codec import dumps, loads from control.base import ControlOperatorType from control.lifecycle import LifecycleManager +from tests.conftest import sec class RecordingLifecycle(LifecycleManager): @@ -49,16 +50,16 @@ def test_supersede_sets_version_chain_and_invalidates_old_version() -> None: actor = scope kernel = build_kernel() - old = kernel.api.write("home is Shanghai", scope, identity=actor)[0] + old = kernel.api.write("home is Shanghai", scope, security=sec(actor))[0] new = kernel.api.update( old.id, scope, MemoryPatch(content="home is Beijing"), - identity=actor, + security=sec(actor), ) - stored_old = kernel.api.get(old.id, scope, identity=actor) + stored_old = kernel.api.get(old.id, scope, security=sec(actor)) assert new.id != old.id assert new.supersedes == old.id assert new.temporal.t_valid is not None @@ -72,15 +73,15 @@ def test_supersede_uses_patch_valid_time_as_new_version_boundary() -> None: kernel = build_kernel() valid_from = datetime(2026, 6, 17, 11, 0, tzinfo=timezone.utc) - old = kernel.api.write("home is Shanghai", scope, identity=actor)[0] + old = kernel.api.write("home is Shanghai", scope, security=sec(actor))[0] new = kernel.api.update( old.id, scope, MemoryPatch(content="home is Beijing", t_valid=valid_from), - identity=actor, + security=sec(actor), ) - stored_old = kernel.api.get(old.id, scope, identity=actor) + stored_old = kernel.api.get(old.id, scope, security=sec(actor)) assert new.temporal.t_valid == valid_from assert stored_old.temporal.t_invalid == valid_from @@ -93,16 +94,16 @@ def test_update_supersede_delegates_old_version_lifecycle_to_manager() -> None: setattr(getattr(kernel.api, "_engine"), "_lifecycle", lifecycle) valid_from = datetime(2026, 6, 17, 11, 0, tzinfo=timezone.utc) - old = kernel.api.write("home is Shanghai", scope, identity=actor)[0] + old = kernel.api.write("home is Shanghai", scope, security=sec(actor))[0] new = kernel.api.update( old.id, scope, MemoryPatch(content="home is Beijing", t_valid=valid_from), - identity=actor, + security=sec(actor), ) assert lifecycle.supersede_calls == [(scope, old.id, valid_from)] - stored_old = kernel.api.get(old.id, scope, identity=actor) + stored_old = kernel.api.get(old.id, scope, security=sec(actor)) assert stored_old.lifecycle == LifecycleState.SUPERSEDED assert stored_old.temporal.t_invalid == valid_from assert new.supersedes == old.id diff --git a/tests/unit/control/test_governance.py b/tests/unit/control/test_governance.py index 392f891e..51d3a98e 100644 --- a/tests/unit/control/test_governance.py +++ b/tests/unit/control/test_governance.py @@ -9,6 +9,7 @@ from common.type_def import AuditEvent, MemoryUnit, Segment, memory_key from common.type_def.memory_codec import dumps from control.governance_impl.in_memory_governor import InMemoryGovernor +from tests.conftest import sec pytestmark = pytest.mark.unit @@ -49,7 +50,7 @@ def test_trace_follows_provenance_sources_depth_first() -> None: for unit in [source, direct, nested]: kernel.kv.insert(scope, memory_key(unit.id), dumps(unit)) - assert [unit.id for unit in kernel.api.trace("nested", scope, identity=scope)] == [ + assert [unit.id for unit in kernel.api.trace("nested", scope, security=sec(scope))] == [ "nested", "direct", "source", @@ -64,7 +65,7 @@ def test_trace_stops_on_provenance_cycles() -> None: for unit in [a, b]: kernel.kv.insert(scope, memory_key(unit.id), dumps(unit)) - assert [unit.id for unit in kernel.api.trace("a", scope, identity=scope)] == ["a", "b"] + assert [unit.id for unit in kernel.api.trace("a", scope, security=sec(scope))] == ["a", "b"] def test_inspect_is_bound_to_the_authorized_scope() -> None: @@ -84,7 +85,7 @@ def test_inspect_is_bound_to_the_authorized_scope() -> None: kernel.kv.insert(scope_a, memory_key(unit_a.id), dumps(unit_a)) kernel.kv.insert(scope_b, memory_key(unit_b.id), dumps(unit_b)) - inspected = kernel.api.inspect([unit_b.id], scope_b, identity=scope_b) + inspected = kernel.api.inspect([unit_b.id], scope_b, security=sec(scope_b)) assert [unit.content for unit in inspected] == ["space B content"] diff --git a/tests/unit/control/test_lifecycle_manager.py b/tests/unit/control/test_lifecycle_manager.py index 1957ec3b..1a6ae1c7 100644 --- a/tests/unit/control/test_lifecycle_manager.py +++ b/tests/unit/control/test_lifecycle_manager.py @@ -12,6 +12,7 @@ from control.lifecycle_impl.kv_lifecycle_manager import KVLifecycleManager from control.policy_impl.dict_policy_manager import DictPolicyManager from storage.kv_impl.in_memory_kv_store import InMemoryKVStore +from tests.conftest import root_sec pytestmark = pytest.mark.unit @@ -202,20 +203,17 @@ def test_sweep_rejects_invalid_policy_target(unit_factory) -> None: def test_default_kernel_exposes_lifecycle_policy_keys() -> None: - scope = Scope(org="acme", user="u1", agent="a1", session="s1") api = build_kernel().api - root = Scope() - assert api.admin_get("lifecycle.expired_active.target", identity=root) == "forgotten" - assert api.admin_get("lifecycle.superseded.target", identity=root) == "forgotten" + assert api.admin_get("lifecycle.expired_active.target", security=root_sec()) == "forgotten" + assert api.admin_get("lifecycle.superseded.target", security=root_sec()) == "forgotten" - api.admin_set("lifecycle.expired_active.target", "archived", identity=root) - assert api.admin_get("lifecycle.expired_active.target", identity=root) == "archived" + api.admin_set("lifecycle.expired_active.target", "archived", security=root_sec()) + assert api.admin_get("lifecycle.expired_active.target", security=root_sec()) == "archived" def test_default_kernel_lifecycle_sweep_uses_runtime_policy(unit_factory) -> None: scope = Scope(org="acme", user="u1", agent="a1", session="s1") - root = Scope() kernel = build_kernel() api = kernel.api expired = unit_factory( @@ -226,7 +224,7 @@ def test_default_kernel_lifecycle_sweep_uses_runtime_policy(unit_factory) -> Non ) kernel.kv.insert(scope, memory_key(expired.id), dumps(expired)) - api.admin_set("lifecycle.expired_active.target", "archived", identity=root) + api.admin_set("lifecycle.expired_active.target", "archived", security=root_sec()) swept = getattr(getattr(api, "_engine"), "_lifecycle").sweep() stored = loads(kernel.kv.get(scope, memory_key(expired.id))) diff --git a/tests/unit/control/test_permission_context_routing.py b/tests/unit/control/test_permission_context_routing.py index f8306445..6a4d5d98 100644 --- a/tests/unit/control/test_permission_context_routing.py +++ b/tests/unit/control/test_permission_context_routing.py @@ -22,25 +22,34 @@ from common.type_def import Context, Scope from config import Config from control.types import DeleteMode, DeleteSelector +from tests.conftest import root_sec, sec pytestmark = pytest.mark.unit def _routing_config() -> Config: - """coding 受 strict 保护、episodic 显式放宽;fallback 取最小权限。""" + """coding 受 strict 保护、episodic 显式放宽;fallback 取最小权限。 + + 配的是 ``authorizer`` 段而不是旧的 ``permission`` 段:授权判定已收敛到 + ``common.security.authorization``,``permission`` 只剩 grant/revoke 的记录通道, + 往它上面配路由不再影响任何判定。 + """ return Config.from_dict( { - "permission": { + "authorizer": { "default": { "target": "routing", "params": { "route_key": "memory_type", "fallback": "strict", - "routes": {"coding": "strict", "episodic": "standard"}, + "routes": {"coding": "strict", "episodic": "lenient"}, }, }, - "standard": "allow_all", - "strict": "sqlite", + "lenient": "allow_all", + "strict": { + "target": "standard", + "params": {"grant_store": "default", "delegation_store": "default"}, + }, } } ) @@ -50,17 +59,20 @@ def test_permissive_fallback_rejected_at_assembly() -> None: """fallback 承接路由值缺失的请求(调用方不写 filters 即可触发),不得是 allow_all。""" cfg = Config.from_dict( { - "permission": { + "authorizer": { "default": { "target": "routing", "params": { "route_key": "memory_type", - "fallback": "standard", + "fallback": "lenient", "routes": {"coding": "strict"}, }, }, - "standard": "allow_all", - "strict": "sqlite", + "lenient": "allow_all", + "strict": { + "target": "standard", + "params": {"grant_store": "default", "delegation_store": "default"}, + }, } } ) @@ -78,11 +90,11 @@ def test_policy_name_is_not_accepted_as_route_value() -> None: api = build_kernel(config=_routing_config()).api outsider, victim = Scope(org="evil", user="x"), Scope(org="acme", user="owner") - # episodic 是显式声明的宽松路由,standard 只是它背后的 policy 名 - api.write("ok", victim, identity=outsider, metadata={"memory_type": "episodic"}) + # episodic 是显式声明的宽松路由,lenient 只是它背后的 policy 名 + api.write("ok", victim, security=sec(outsider), metadata={"memory_type": "episodic"}) with pytest.raises(PermissionDeniedError): - api.write("secret", victim, identity=outsider, metadata={"memory_type": "standard"}) + api.write("secret", victim, security=sec(outsider), metadata={"memory_type": "lenient"}) def test_write_permission_routes_by_memory_type() -> None: @@ -90,13 +102,13 @@ def test_write_permission_routes_by_memory_type() -> None: actor = Scope(org="acme", user="reader") target = Scope(org="acme", user="owner") - api.write("general note", target, identity=actor, metadata={"memory_type": "episodic"}) + api.write("general note", target, security=sec(actor), metadata={"memory_type": "episodic"}) with pytest.raises(PermissionDeniedError): api.write( "repo must use pytest", target, - identity=actor, + security=sec(actor), metadata={"memory_type": "coding"}, ) @@ -110,7 +122,7 @@ def test_recall_permission_routes_by_metadata_memory_type_filter() -> None: api.recall( "repo", Context(scope=target), - identity=actor, + security=sec(actor), filters={"metadata.memory_type": "coding"}, ) @@ -124,7 +136,7 @@ def test_recall_permission_routes_to_lenient_policy_for_declared_type() -> None: api.recall( "general", Context(scope=target), - identity=actor, + security=sec(actor), filters={"metadata.memory_type": "episodic"}, ) @@ -133,7 +145,9 @@ def test_recall_permission_routes_to_lenient_policy_for_declared_type() -> None: def _seed(api, owner: Scope) -> None: - api.write("repo must use pytest", owner, identity=owner, metadata={"memory_type": "coding"}) + api.write( + "repo must use pytest", owner, security=sec(owner), metadata={"memory_type": "coding"} + ) def test_escalation_1_unknown_extensions_value_falls_to_strict_fallback() -> None: @@ -146,7 +160,7 @@ def test_escalation_1_unknown_extensions_value_falls_to_strict_fallback() -> Non api.recall( "repo must use pytest", Context(scope=owner, extensions={"memory_type": "unknown"}), - identity=reader, + security=sec(reader), filters={"metadata.memory_type": "coding"}, ) @@ -158,7 +172,7 @@ def test_escalation_2_missing_route_value_falls_to_strict_fallback() -> None: _seed(api, owner) with pytest.raises(PermissionDeniedError): - api.recall("repo must use pytest", Context(scope=owner), identity=reader) + api.recall("repo must use pytest", Context(scope=owner), security=sec(reader)) def test_escalation_3_ambiguous_or_filter_falls_to_strict_fallback() -> None: @@ -171,7 +185,7 @@ def test_escalation_3_ambiguous_or_filter_falls_to_strict_fallback() -> None: api.recall( "repo must use pytest", Context(scope=owner), - identity=reader, + security=sec(reader), filters={ "OR": [ {"metadata.memory_type": "coding"}, @@ -194,7 +208,7 @@ def test_escalation_4_lenient_route_cannot_read_protected_data() -> None: result = api.recall( "repo must use pytest", Context(scope=owner, extensions={"memory_type": "episodic"}), - identity=reader, + security=sec(reader), filters={"metadata.memory_type": "coding"}, top_k=10, ) @@ -206,12 +220,14 @@ def test_route_value_injection_still_returns_own_type_data() -> None: """回注谓词不得误伤:按 episodic 授权时,episodic 的数据必须照常可读。""" api = build_kernel(config=_routing_config()).api owner, reader = Scope(org="acme", user="owner"), Scope(org="acme", user="reader") - api.write("lunch plan tomorrow", owner, identity=owner, metadata={"memory_type": "episodic"}) + api.write( + "lunch plan tomorrow", owner, security=sec(owner), metadata={"memory_type": "episodic"} + ) result = api.recall( "lunch plan tomorrow", Context(scope=owner, extensions={"memory_type": "episodic"}), - identity=reader, + security=sec(reader), top_k=10, ) @@ -229,14 +245,15 @@ def test_unresolved_route_keeps_owner_base_rule() -> None: api = build_kernel(config=_routing_config()).api owner = Scope(org="acme", user="owner") - api.recall("general", Context(scope=owner), identity=owner) # 未限定 memory_type + api.recall("general", Context(scope=owner), security=sec(owner)) # 未限定 memory_type def test_unresolved_route_keeps_root_base_rule() -> None: + """ROOT 同理:ROOT 由 ``role`` 表达,不由空 ``Scope()`` 表达(空 actor 现在直接拒)。""" api = build_kernel(config=_routing_config()).api - owner, root = Scope(org="acme", user="owner"), Scope() + owner = Scope(org="acme", user="owner") - api.recall("general", Context(scope=owner), identity=root) + api.recall("general", Context(scope=owner), security=root_sec()) # -- 已有 unit 的操作按真源元数据鉴权 ------------------------------------------ # @@ -249,12 +266,12 @@ def test_get_permission_uses_stored_memory_type_context() -> None: unit = api.write( "repo must use pytest", owner, - identity=owner, + security=sec(owner), metadata={"memory_type": "coding"}, )[0] with pytest.raises(PermissionDeniedError): - api.get(unit.id, owner, identity=reader) + api.get(unit.id, owner, security=sec(reader)) def test_delete_permission_checks_each_matched_unit_context() -> None: @@ -264,7 +281,7 @@ def test_delete_permission_checks_each_matched_unit_context() -> None: unit = api.write( "repo must use pytest", owner, - identity=owner, + security=sec(owner), tags=["repo"], metadata={"memory_type": "coding"}, )[0] @@ -272,5 +289,5 @@ def test_delete_permission_checks_each_matched_unit_context() -> None: with pytest.raises(PermissionDeniedError): api.delete( DeleteSelector(unit_ids=[unit.id], scope=owner, mode=DeleteMode.FORGET), - identity=reader, + security=sec(reader), ) diff --git a/tests/unit/control/test_permission_role_aware.py b/tests/unit/control/test_permission_role_aware.py new file mode 100644 index 00000000..e5a2d1e7 --- /dev/null +++ b/tests/unit/control/test_permission_role_aware.py @@ -0,0 +1,221 @@ +"""角色感知授权(security.md §3.2 / §3.5)。 + +覆盖把 ``AuthContext.role`` 接进旧 PDP 之后的行为,以及「没有认证上下文时行为逐字 +不变」这条向后兼容线。 + +代操作(原 §4.3)的覆盖已随 ``acting_user`` 判定路径一起移出本文件,见下方注释。 +""" + +from __future__ import annotations + +import pytest + +from common.security.types import AuthContext, Role +from common.type_def import Scope +from control.permission_impl.allow_all_permission_manager import AllowAllPermissionManager +from control.permission_impl.sqlite_permission_manager import SQLitePermissionManager +from control.types import Action, PermissionContext + +pytestmark = pytest.mark.unit + +_ALICE = Scope(org="acme", space="product", user="alice") +_BOB = Scope(org="acme", space="product", user="bob") +_AGENT = Scope(org="acme", space="product", agent="assistant") + + +@pytest.fixture() +def mgr(tmp_path) -> SQLitePermissionManager: + return SQLitePermissionManager(str(tmp_path / "permission.db")) + + +# -- 角色闸门 ------------------------------------------------------------- # + + +def test_promoted_root_is_equivalent_to_declared_root(mgr) -> None: + """§3.5:「提升式 ROOT」与「声明式 ROOT」在运行时权限检查中等价。 + + 今天 PDP 唯一能识别的特权是 ``actor == Scope()``(声明式 ROOT 的 actor 形态), + 一个绑了具体 org/user 的 ROOT 在它眼里就是普通用户——两者并不等价。 + """ + promoted = AuthContext(actor=_ALICE, role=Role.ROOT) + + assert mgr.check(_ALICE, _BOB, Action.READ, auth=promoted) is True + assert mgr.check(_ALICE, Scope(org="other", user="carol"), Action.WRITE, auth=promoted) is True + + +def test_empty_actor_without_root_role_is_denied(mgr) -> None: + """空 actor 不再单凭形状拿到全局放行——纵深防御。 + + 今天 ``PrincipalKeyStore.issue`` 会拒绝签发 actor 为空的 key,但那道闸在 + ``security/`` 层。PDP 自己必须也守住:换一个 authenticator 实现、或将来加 + OAuth 通道时,没人保证那个前置假设还在。 + """ + impostor = AuthContext(actor=Scope(), role=Role.USER) + + assert mgr.check(Scope(), _ALICE, Action.READ, auth=impostor) is False + + +def test_empty_actor_is_still_root_without_auth_context(mgr) -> None: + """无认证上下文时保留旧规则——测试、后台 job 与直连 build_kernel 的路径不受影响。""" + assert mgr.check(Scope(), _ALICE, Action.READ) is True + + +def test_admin_role_gets_no_extra_power(mgr) -> None: + """ADMIN 在本期**刻意**没有额外权限:§3.2 里属于 ADMIN 的那行(管理本租户 + user/agent)在本仓一个接口都没有,凭空造闸门守一扇不存在的门就是 dead flexibility。 + """ + admin = AuthContext(actor=_ALICE, role=Role.ADMIN) + + assert mgr.check(_ALICE, _BOB, Action.READ, auth=admin) is False + + +def test_auth_actor_mismatch_is_denied(mgr) -> None: + """``auth`` 与 ``actor`` 指向不同主体时 fail-closed。 + + 两个身份来源不一致,要么是接线错误要么是攻击,两种都该拒。与 F01 决策 1 + (``AuthContext.actor`` 不给默认值)是同一思路的两面:不让装配错误变成静默的权限。 + """ + alice_ctx = AuthContext(actor=_ALICE, role=Role.ROOT) + + # 拿着 alice 的 ROOT 上下文去问「bob 能不能读」——不认。 + assert mgr.check(_BOB, _ALICE, Action.READ, auth=alice_ctx) is False + + +# -- agent 代 user 操作 ---------------------------------------------------- # +# +# 这一节原有 7 条用例,覆盖 header 送来的 ``acting_user`` 触发的代操作判定。整节随 +# 该判定路径一起删除:header 只能证明网关声称某个 user,证明不了那个 user 真的授权了 +# 这个 agent(F05 §从 header 直接产生 Delegation)。 +# +# 等价覆盖迁到 ``tests/unit/common/security/authorization/test_standard_authorizer.py`` +# 的 Delegation 一节,并且更严——那里的委托来自 ``DelegationStore``,还额外覆盖了 +# 伪造 id、过期、撤销、绑定凭据与 allowed_spaces。 + + +def test_agent_cannot_reach_a_user_scope(mgr) -> None: + """agent 主体够不到 user 的 scope——这条 PDP 不再有任何代操作放行路径。 + + ``_owner_scope_covers(Scope(agent=...), Scope(user=...))`` 恒 False(primary 维 + 不等),grants 表里也没有这条。留着这条断言是为了钉住「删掉委托路径之后确实是拒」, + 而不是被别的规则顺带放过。 + """ + bare = AuthContext(actor=_AGENT, role=Role.USER) + + assert mgr.check(_AGENT, _ALICE, Action.READ, auth=bare) is False + + +# -- 管理面闸门(§3.2 后四行里有接口的那三行) ------------------------------ # + + +@pytest.mark.parametrize("resource_type", ["admin", "audit"]) +def test_management_resources_require_root(mgr, resource_type: str) -> None: + """管理面靠 ``PermissionContext.resource_type`` 表达,不靠「target 恰好是空 scope」。 + + 靠形状表达语义正是角色缺口的同一个毛病。``resource_type`` 的注释里本来就列了 + ``admin``(``control/types.py``),只是从没有人填。 + """ + user_ctx = AuthContext(actor=_ALICE, role=Role.USER) + root_ctx = AuthContext(actor=_ALICE, role=Role.ROOT) + context = PermissionContext(resource_type=resource_type) + + assert mgr.check(_ALICE, Scope(), Action.READ, context, auth=user_ctx) is False + assert mgr.check(_ALICE, Scope(), Action.READ, context, auth=root_ctx) is True + + +def test_sharing_own_scope_is_not_a_management_operation(mgr) -> None: + """``grant`` **不**进管理面闸门。 + + §3.2 那行说的是「**跨租户**修改权限」,而跨 org 的 grant 今天已被 + ``actor.org != target.org`` 挡住。对自己 scope 发 grant 是 Grant 模型的 + 主用途——把它闸进 ROOT 会把正常共享一起废掉。 + """ + user_ctx = AuthContext(actor=_ALICE, role=Role.USER) + + assert mgr.check(_ALICE, _ALICE, Action.SHARE, auth=user_ctx) is True + assert mgr.check(_ALICE, Scope(org="other"), Action.SHARE, auth=user_ctx) is False + + +def test_management_resource_denied_even_within_own_scope(mgr) -> None: + """管理面闸门优先于 owner-cover:否则把 target 填成自己的 scope 就绕过去了。""" + user_ctx = AuthContext(actor=_ALICE, role=Role.USER) + context = PermissionContext(resource_type="admin", scope=_ALICE) + + assert mgr.check(_ALICE, _ALICE, Action.WRITE, context, auth=user_ctx) is False + + +def test_space_lifecycle_requires_root(mgr) -> None: + """§3.2「创建/删除租户」要求 ROOT。 + + ``create_space`` / ``delete_space`` 已在传 ``resource_type="space"``, + 只需在 check 里对写类动作要求 ROOT。 + """ + user_ctx = AuthContext(actor=_ALICE, role=Role.USER) + root_ctx = AuthContext(actor=_ALICE, role=Role.ROOT) + context = PermissionContext(resource_type="space") + target = Scope(org="acme", space="product") + + assert mgr.check(_ALICE, target, Action.WRITE, context, auth=user_ctx) is False + assert mgr.check(_ALICE, target, Action.DELETE, context, auth=user_ctx) is False + assert mgr.check(_ALICE, target, Action.WRITE, context, auth=root_ctx) is True + + +def test_space_read_is_not_gated_by_role(mgr) -> None: + """读 space 元数据不受 ROOT 角色闸门限制。 + + 读取并非「创建/删除租户」,否则普通用户连自己所在 space 的名字都拿不到。 + """ + user_ctx = AuthContext(actor=_ALICE, role=Role.USER) + context = PermissionContext(resource_type="space_list") + + assert mgr.check(_ALICE, Scope(org="acme"), Action.READ, context, auth=user_ctx) is False + assert mgr.check(_ALICE, _ALICE, Action.READ, context, auth=user_ctx) is True + + +# -- 其它实现 -------------------------------------------------------------- # + + +def test_allow_all_stays_all_allow_with_auth() -> None: + """测试用实现不该被安全逻辑污染:它的全部语义就是「恒 True」。""" + mgr = AllowAllPermissionManager() + denied_shape = AuthContext(actor=Scope(), role=Role.USER) + + assert mgr.check(_ALICE, _BOB, Action.WRITE, auth=denied_shape) is True + + +def test_routing_passes_auth_through_to_delegate(tmp_path) -> None: + """Routing 必须把 ``auth`` 原样透传给 delegate。 + + S03 约定 routing 不改变授权语义、只选择 delegate;否则路由型部署下角色闸门与 + 委托会静默失效。 + """ + from control.permission_impl.routing_permission_manager import RoutingPermissionManager + + seen: list[AuthContext | None] = [] + + class _Spy(SQLitePermissionManager): + def check(self, actor, target, action, context=None, *, auth=None): + seen.append(auth) + return super().check(actor, target, action, context, auth=auth) + + delegate = _Spy(str(tmp_path / "permission.db")) + router = RoutingPermissionManager(policies={"strict": delegate}, routes={}, fallback="strict") + ctx = AuthContext(actor=_ALICE, role=Role.ROOT) + + assert router.check(_ALICE, _BOB, Action.READ, auth=ctx) is True + assert seen == [ctx] + + +# -- 向后兼容 -------------------------------------------------------------- # + + +def test_no_auth_context_preserves_every_legacy_rule(mgr) -> None: + """``auth=None`` 时逐字回到今天的纯 ACL 行为。 + + 这条撑着三件事同时成立:33 处既有 ``_authorize`` 调用点不改也能跑、 + ``AllowAllPermissionManager`` 语义不动、直接调 ``api.write(security=sec(...))`` + 的测试与 ``examples/quickstart.py`` 不受影响。 + """ + assert mgr.check(Scope(), _ALICE, Action.READ) is True # platform admin + assert mgr.check(_ALICE, _ALICE, Action.WRITE) is True # owner covers + assert mgr.check(_ALICE, _BOB, Action.READ) is False # 同 org 不同主体 + assert mgr.check(_ALICE, Scope(org="other"), Action.READ) is False # 跨 org diff --git a/tests/unit/control/test_pipeline.py b/tests/unit/control/test_pipeline.py index 0c13f970..b9434cf8 100644 --- a/tests/unit/control/test_pipeline.py +++ b/tests/unit/control/test_pipeline.py @@ -8,6 +8,7 @@ from retrieval.base import RetrievalOperatorType from retrieval.retriever import Retriever, RetrieverProducer from retrieval.types import RetrievalQuery, RetrievalResult, RetrievedItem +from tests.conftest import sec _INDEX_BUILDERS: dict[str, "RecordingIndexBuilder"] = {} @@ -111,7 +112,7 @@ def test_engine_write_uses_pipeline_profile_from_memory_type() -> None: kernel.api.write( "use pytest for this repo", scope, - identity=scope, + security=sec(scope), metadata={"memory_type": "coding"}, ) @@ -126,7 +127,7 @@ def test_engine_recall_uses_pipeline_profile_from_context_extensions() -> None: result = kernel.api.recall( "test strategy", Context(scope=scope, extensions={"memory_type": "coding"}), - identity=scope, + security=sec(scope), ) assert [item.unit_id for item in result.items] == ["coding"] @@ -139,7 +140,7 @@ def test_engine_recall_uses_pipeline_profile_from_metadata_memory_type_filter() result = kernel.api.recall( "test strategy", Context(scope=scope), - identity=scope, + security=sec(scope), filters={"metadata.memory_type": "coding"}, ) @@ -153,7 +154,7 @@ def test_engine_recall_canonicalizes_legacy_memory_type_filter_name() -> None: result = kernel.api.recall( "test strategy", Context(scope=scope), - identity=scope, + security=sec(scope), filters={"memory_type": "coding"}, ) diff --git a/tests/unit/storage/test_encrypted_fs_store.py b/tests/unit/storage/test_encrypted_fs_store.py new file mode 100644 index 00000000..28d9b930 --- /dev/null +++ b/tests/unit/storage/test_encrypted_fs_store.py @@ -0,0 +1,499 @@ +"""EncryptedFSStore:装饰器契约 + 它的装配。 + +与 ``test_encrypted_kv_store.py`` 同构(同一个假 provider 套路),断言的核心是 +两句:**上层看不出区别,内层看到的全是密文**;以及**装饰器交给 provider 的 +``CryptoContext`` / AAD 到底绑了什么**——后者是加密能否抵抗「密文搬家」的唯一 +依据,只测 roundtrip 的话完全不加密也是绿的。 + +明文兼容(迁移期读加密上线前的老数据)由 provider 的 ``allow_plaintext`` 控制, +本装饰器不重复提供同语义开关,故这里只验「provider 允许则读得出」。 +""" + +from __future__ import annotations + +# Boundary and TOCTOU tests intentionally replace private decorator internals. +# pylint: disable=protected-access +import io +import json + +import pytest + +from common.errors import BackendError, NotFoundError, ValidationError +from common.factory.factory import Factory +from common.security.cryptography import CryptographyProducer, CryptographyProvider +from common.security.types import CryptoContext +from common.type_def import Scope +from config.context import AssemblyContext +from storage.fs import FsProducer +from storage.fs_impl.encrypted_fs_store import EncryptedFSStore +from storage.fs_impl.local_fs import LocalFSStore + +pytestmark = pytest.mark.unit + +_PREFIX = b"fake1:" +_ALICE = Scope(org="acme", space="product", user="alice") + + +class _FakeSecurity(CryptographyProvider): + """把 AAD 编进密文的假 provider:AAD 对不上就解不开,与真信封同性质。""" + + def __init__(self, *, allow_plaintext: bool = True) -> None: + self.allow_plaintext = allow_plaintext + self.fail_decrypt = False + self.encrypt_calls: list[tuple[CryptoContext | None, bytes, bytes]] = [] + self.decrypt_calls: list[tuple[CryptoContext | None, bytes, bytes]] = [] + + def encrypt( + self, + plaintext: bytes, + *, + context: CryptoContext | None = None, + aad: bytes = b"", + ) -> bytes: + self.encrypt_calls.append((context, aad, plaintext)) + return _PREFIX + len(aad).to_bytes(4, "big") + aad + plaintext[::-1] + + def decrypt( + self, + ciphertext: bytes, + *, + context: CryptoContext | None = None, + aad: bytes = b"", + ) -> bytes: + self.decrypt_calls.append((context, aad, ciphertext)) + if self.fail_decrypt: + raise RuntimeError("decrypt failed") + if not ciphertext.startswith(_PREFIX): + if self.allow_plaintext: + return ciphertext + raise RuntimeError("missing encrypted envelope") + offset = len(_PREFIX) + aad_size_end = offset + 4 + aad_len = int.from_bytes(ciphertext[offset:aad_size_end], "big") + offset = aad_size_end + aad_end = offset + aad_len + embedded_aad = ciphertext[offset:aad_end] + if embedded_aad != aad: + raise RuntimeError("aad mismatch") + return ciphertext[aad_end:][::-1] + + +@CryptographyProducer.register("fake_encrypted_fs") +def _build_fake_security(config): + return _FakeSecurity(allow_plaintext=bool(config.get("allow_plaintext", True))) + + +def _fs( + tmp_path, encryption: _FakeSecurity | None = None +) -> tuple[EncryptedFSStore, LocalFSStore, _FakeSecurity]: + inner = LocalFSStore(root=str(tmp_path / "files")) + fake = encryption or _FakeSecurity() + return EncryptedFSStore(inner, fake, max_plaintext_bytes=64 * 1024 * 1024), inner, fake + + +def _aad_payload(aad: bytes) -> dict: + return json.loads(aad.decode("utf-8")) + + +def test_encrypted_fs_store_encrypts_content_and_decrypts_get(tmp_path) -> None: + fs, inner, encryption = _fs(tmp_path) + + ref = fs.insert(_ALICE, "a/b/x.bin", io.BytesIO(b"secret payload")) + + with inner.get(_ALICE, ref) as fh: + stored = fh.read() + assert stored.startswith(_PREFIX) + assert b"secret payload" not in stored + with fs.get(_ALICE, ref) as fh: + assert fh.read() == b"secret payload" + + context, aad, plaintext = encryption.encrypt_calls[0] + assert plaintext == b"secret payload" + assert context is not None + assert context.scope == _ALICE + assert context.purpose == "fs_object" + assert context.object_id == "a/b/x.bin" # 对象标识是专有字段,不塞 metadata + + +def test_encrypted_fs_store_aad_binds_all_five_scope_dimensions(tmp_path) -> None: + """AAD 少绑一维,那一维就能搬密文。 + + 存储层的 scope 隔离是访问控制、可以被绕过(直接写底层、备份恢复串了); + AAD 是密码学的,绕不过——前提是它真的绑满了。``space`` 是 ``Scope`` 五维化时 + 新加的维度,漏了它同 org 下的两个 space 就能互读。 + """ + fs, _, encryption = _fs(tmp_path) + scope = Scope(org="acme", space="product", user="alice", agent="bot", session="s1") + + fs.insert(scope, "x.bin", io.BytesIO(b"v")) + + payload = _aad_payload(encryption.encrypt_calls[0][1]) + assert payload["scope"] == { + "org": "acme", + "space": "product", + "user": "alice", + "agent": "bot", + "session": "s1", + } + assert payload["ref"] == "x.bin" + assert payload["purpose"] == "fs_object" + + +def test_encrypted_fs_store_cross_scope_ciphertext_move_fails(tmp_path) -> None: + """把 alice 的密文直接塞进 bob 的槽位——绕过存储层隔离后仍然读不出来。""" + fs, inner, _ = _fs(tmp_path) + bob = Scope(org="acme", space="product", user="bob") + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"alice-data")) + with inner.get(_ALICE, "x.bin") as fh: + inner.insert(bob, "x.bin", io.BytesIO(fh.read())) + + with pytest.raises(BackendError): # 不是 NotFoundError——是「解不开」 + fs.get(bob, "x.bin") + + +def test_encrypted_fs_store_update_also_encrypts(tmp_path) -> None: + """update 是第二条写路径——只在 insert 上加密是个真实会犯的错。""" + fs, inner, _ = _fs(tmp_path) + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"old")) + fs.update(_ALICE, "x.bin", io.BytesIO(b"newer-secret")) + + with inner.get(_ALICE, "x.bin") as fh: + stored = fh.read() + assert stored.startswith(_PREFIX) + assert b"newer-secret" not in stored + with fs.get(_ALICE, "x.bin") as fh: + assert fh.read() == b"newer-secret" + + +def test_encrypted_fs_store_roundtrips_empty_file(tmp_path) -> None: + fs, _, _ = _fs(tmp_path) + + fs.insert(_ALICE, "empty.bin", io.BytesIO(b"")) + + with fs.get(_ALICE, "empty.bin") as fh: + assert fh.read() == b"" + + +def test_encrypted_fs_store_stat_reports_ciphertext_size(tmp_path) -> None: + """已知代价,显式钉住:size 是密文长度,比明文长。改了要有人主动来改这条。""" + fs, _, _ = _fs(tmp_path) + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"12345")) + + assert fs.stat(_ALICE, "x.bin").size > 5 + + +def test_encrypted_fs_store_passes_through_missing_and_delete(tmp_path) -> None: + fs, _, encryption = _fs(tmp_path) + + with pytest.raises(NotFoundError): + fs.get(_ALICE, "nope") + fs.insert(_ALICE, "x.bin", io.BytesIO(b"a")) + fs.delete(_ALICE, "x.bin") + fs.delete(_ALICE, "x.bin") # 幂等 + with pytest.raises(NotFoundError): + fs.get(_ALICE, "x.bin") + assert not encryption.decrypt_calls # delete 不经加解密 + + +def test_encrypted_fs_store_supports_plaintext_compatibility_via_provider(tmp_path) -> None: + """迁移期:加密层上线前写进去的老数据必须还能读,否则上线即全量不可用。""" + fs, inner, _ = _fs(tmp_path, _FakeSecurity(allow_plaintext=True)) + + inner.insert(_ALICE, "legacy.bin", io.BytesIO(b"legacy plaintext")) + + with fs.get(_ALICE, "legacy.bin") as fh: + assert fh.read() == b"legacy plaintext" + + +def test_encrypted_fs_store_write_always_encrypts_even_when_plaintext_allowed(tmp_path) -> None: + """兼容开关只影响**读**。若它顺带放松了写,迁移期写进去的数据就永远是明文。""" + fs, inner, _ = _fs(tmp_path, _FakeSecurity(allow_plaintext=True)) + + fs.insert(_ALICE, "x.bin", io.BytesIO(b"secret")) + + with inner.get(_ALICE, "x.bin") as fh: + assert fh.read().startswith(_PREFIX) + + +def test_encrypted_fs_store_decryption_failure_is_fail_closed(tmp_path) -> None: + fs, _, encryption = _fs(tmp_path) + fs.insert(_ALICE, "x.bin", io.BytesIO(b"v")) + encryption.fail_decrypt = True + + with pytest.raises(BackendError): + fs.get(_ALICE, "x.bin") + + +def test_encrypted_fs_store_factory_builds_wrapper_from_named_dependencies(tmp_path) -> None: + Factory.reset_all() + ctx = AssemblyContext.from_dict( + { + "cryptography": {"default": "fake_encrypted_fs"}, + "fs_store": { + "raw": {"target": "local", "params": {"root": str(tmp_path / "files")}}, + "default": { + "target": "encrypted", + "params": {"inner": "raw", "cryptography": "default"}, + }, + }, + } + ) + + fs = FsProducer.build_named("default", ctx) + + assert isinstance(fs, EncryptedFSStore) + ref = fs.insert(_ALICE, "x.bin", io.BytesIO(b"value")) + with fs.get(_ALICE, ref) as fh: + assert fh.read() == b"value" + + +def test_encrypted_fs_store_factory_requires_inner_dependency() -> None: + """没配 inner 时必须报错。给个默认会让「配错了」静默变成「加密了一个内存 + store」——数据写得进去,重启后全没了。 + """ + Factory.reset_all() + ctx = AssemblyContext.from_dict( + { + "cryptography": {"default": "fake_encrypted_fs"}, + "fs_store": {"default": {"target": "encrypted", "params": {"cryptography": "default"}}}, + } + ) + + with pytest.raises(ValidationError): + FsProducer.build_named("default", ctx) + + +def test_encrypted_fs_is_registered_by_storage_bootstrap() -> None: + """``api.build_kernel`` 只调 ``register_backends()``,从不调 ``register_plugins()``。 + + 装饰器住在 storage 下就是为了这个:注册若挂在别处,不经该装配路径会得到 + 「未注册的实现 'encrypted'」——一个只在部分入口出现的故障。 + """ + from storage.bootstrap import register_backends + + register_backends() + + assert "encrypted" in FsProducer.known() + + +def test_encrypted_fs_store_rejects_oversized_plaintext(tmp_path) -> None: + """审计验收 P2-FS:写入用有界 read,超限即拒,不先整块读入内存。""" + fs, inner, _ = _fs(tmp_path) + fs._max_plaintext_bytes = 4 + with pytest.raises(ValidationError): + fs.insert(_ALICE, "big.bin", io.BytesIO(b"abcdef")) + ref = fs.insert(_ALICE, "ok.bin", io.BytesIO(b"ok")) + fs._max_plaintext_bytes = 4 + with pytest.raises(ValidationError): + fs.update(_ALICE, ref, io.BytesIO(b"oversized")) + + +def test_encrypted_fs_store_bounded_read_does_not_load_oversized(tmp_path) -> None: + """审计验收 P2-FS:超大输入不先全读。用 TrackingReader 证明只读了 max+1。""" + fs, inner, _ = _fs(tmp_path) + fs._max_plaintext_bytes = 4 + + class _TrackingReader(io.BytesIO): + def __init__(self, data): + super().__init__(data) + self.read_calls = [] + + def read(self, n=-1): + self.read_calls.append(n) + return super().read(n) + + reader = _TrackingReader(b"x" * 1024) + with pytest.raises(ValidationError): + fs.insert(_ALICE, "big.bin", reader) + # 只读了 max+1=5 字节就判定超限,没读完整 1024 + assert reader.read_calls == [5] + + +def test_encrypted_fs_store_rejects_oversized_ciphertext_on_read(tmp_path) -> None: + """验收第三次 P3:stat 早拒超大密文,且 decrypt 不被调用。 + + 用显式小 max_ciphertext_bytes,使 1024 字节密文触发 stat 早拒(而非走到 + 解密后被明文复核拒--那是另一条分支)。 + """ + inner = LocalFSStore(root=str(tmp_path / "files")) + fake = _FakeSecurity() + fs = EncryptedFSStore(inner, fake, max_plaintext_bytes=4, max_ciphertext_bytes=8) + big_ciphertext = b"x" * 1024 + ref = inner.insert(_ALICE, "big.enc", io.BytesIO(big_ciphertext)) + with pytest.raises(ValidationError): + fs.get(_ALICE, ref) + # stat 早拒:decrypt 根本没被调用 + assert fake.decrypt_calls == [] + + +def test_encrypted_fs_store_handles_short_reads_without_truncation(tmp_path) -> None: + """验收复验 P2-FS 问题 1:短读流不能被当完整文件静默截断。 + + BinaryIO.read(n) 允许返回 < n 字节而未 EOF。单次 read 会把第一段当完整内容。 + 循环有界读取必须反复 read 到 EOF,否则 b'a' 会被当成整个文件存下。 + """ + + class _ShortReader(io.BytesIO): + """每次只返回 1 字节,模拟短读流。""" + + def read(self, n=-1): + if n is None or n < 0: + return super().read() + return super().read(1) + + fs, inner, fake = _fs(tmp_path) + reader = _ShortReader(b"abcdef") + ref = fs.insert(_ALICE, "short.bin", reader) + # 完整 6 字节都应被读取并加密,不是只存第一段 b'a' + with fs.get(_ALICE, ref) as fh: + assert fh.read() == b"abcdef" + + +def test_encrypted_fs_store_toctou_stat_get_mismatch_still_bounded(tmp_path) -> None: + """验收第四次 P3-test:stat 与 get 不一致时,读取按显式密文上限有界(TOCTOU)。 + + stat 报小、get 返回大,stat 早拒通过后,真正读取仍用循环有界,读到 + max_ciphertext_bytes+1 即止并拒。用 tracking reader 断言**实际读取量**有界-- + 防回归成「全读后再检查长度」(那样 decrypt 也没被调,旧断言测不出退化)。 + """ + + class _TrackingReader(io.BytesIO): + """记录每次 read(n) 的请求大小,用于断言有界读取。""" + + def __init__(self, data): + super().__init__(data) + self.read_calls: list[int] = [] + + def read(self, n=-1): + self.read_calls.append(n) + return super().read(n) + + class _LyingStat: + """stat 永远报 1,get 返回 tracking reader(1024 bytes)--模拟 stat/get 不一致。""" + + def __init__(self, inner): + self._inner = inner + self.last_reader: _TrackingReader | None = None # 供测试断言 + + def __getattr__(self, name): + return getattr(self._inner, name) + + @staticmethod + def stat(scope, ref): + from storage.types import FileStat + + return FileStat(ref=ref, size=1) + + def get(self, scope, ref): + self.last_reader = _TrackingReader(b"x" * 1024) + return self.last_reader + + inner = LocalFSStore(root=str(tmp_path / "files")) + fake = _FakeSecurity() + fs = EncryptedFSStore(inner, fake, max_plaintext_bytes=4, max_ciphertext_bytes=8) + lying = _LyingStat(inner) + fs._inner = lying + ref = "fake-ref" + with pytest.raises(ValidationError): + fs.get(_ALICE, ref) + # 密文流超过 max_ciphertext_bytes 被拒,decrypt 未调用 + assert fake.decrypt_calls == [] + # 实际读取有界(防回归成 fh.read() 全读后检查): + reader = lying.last_reader + assert reader is not None + calls = reader.read_calls + assert calls, "未发生任何 read" + # - 没有 read(-1)(无界全读) + assert -1 not in calls, f"退化成 read(-1) 无界全读:{calls}" + # - 首次请求大小 = max_ciphertext_bytes + 1 = 9 + assert calls[0] == 9, f"首次应请求 max+1=9,得到 {calls[0]}" + # - 循环有界:读到上限即拒,不会有第二次大请求(首次 read(9) 即读到 9 字节超限) + assert len(calls) == 1, f"应在首次 read 即超限拒,不该多次 read:{calls}" + + +def test_encrypted_fs_store_rejects_oversized_plaintext_after_decrypt(tmp_path) -> None: + """验收复验 P2-FS:解密后复核明文上限。 + + stat/密文长度都通过,但解密出的明文超限(密文被替换成另一个合法但解出超大的 + 信封)也要拒。用一个解密时返回超大明文的 fake 触发。 + """ + + class _InflatingSecurity(_FakeSecurity): + def decrypt(self, ciphertext, *, context=None, aad=b""): + return b"y" * 100 # 远超 max_plaintext_bytes=4 + + fs, inner, fake = _fs(tmp_path, encryption=_InflatingSecurity()) + fs._max_plaintext_bytes = 4 + # 先正常写入一个小文件 + ref = fs.insert(_ALICE, "ok.bin", io.BytesIO(b"ok")) + # 读取时解密返回 100 字节,应被解密后复核拒 + with pytest.raises(ValidationError): + fs.get(_ALICE, ref) + + +def test_encrypted_fs_store_byte_by_byte_stream_does_not_amplify_memory(tmp_path) -> None: + """验收第三次 P2-2:1-byte 短读不按 chunk 数线性增长内存。 + + 此前 list[bytes] + join 会为百万级 1-byte 分片造出 ~700 MiB / 8MiB 内容。 + bytearray 累积使内存与字节数成正比。用小尺寸(8 KiB + 1-byte 读)验证不放大: + 断言峰值增量与内容字节数同量级,而非百倍。 + """ + import tracemalloc + + fs, inner, fake = _fs(tmp_path) + fs._max_plaintext_bytes = 8 * 1024 # 8 KiB,足够看出放大比、不压 CI + + class _ByteByByte(io.BytesIO): + def read(self, n=-1): + if n is None or n < 0: + return super().read() + return super().read(1) + + data = b"x" * (8 * 1024) + tracemalloc.start() + ref = fs.insert(_ALICE, "frag.bin", _ByteByByte(data)) + cur, peak = tracemalloc.get_traced_memory() + tracemalloc.stop() + # bytearray 累积:峰值与内容同量级(8 KiB),不应是百倍放大 + assert peak < len(data) * 20, f"内存放大 {peak / len(data):.1f}x,疑似 list 累积" + with fs.get(_ALICE, ref) as fh: + assert fh.read() == data + + +def test_encrypted_fs_store_factory_accepts_max_plaintext_bytes(tmp_path) -> None: + """factory 读取 max_plaintext_bytes 配置;非法值在装配期炸。""" + Factory.reset_all() + ctx = AssemblyContext.from_dict( + { + "cryptography": {"default": "fake_encrypted_fs"}, + "fs_store": { + "raw": {"target": "local", "params": {"root": str(tmp_path / "files")}}, + "default": { + "target": "encrypted", + "params": {"inner": "raw", "cryptography": "default", "max_plaintext_bytes": 8}, + }, + }, + } + ) + fs = FsProducer.build_named("default", ctx) + assert isinstance(fs, EncryptedFSStore) + assert fs._max_plaintext_bytes == 8 + + # 非法值:装配期炸。reset_all 避开上一次 build 的实例缓存。 + Factory.reset_all() + ctx_bad = AssemblyContext.from_dict( + { + "cryptography": {"default": "fake_encrypted_fs"}, + "fs_store": { + "default": { + "target": "encrypted", + "params": {"cryptography": "default", "max_plaintext_bytes": 0}, + } + }, + } + ) + with pytest.raises(ValidationError): + FsProducer.build_named("default", ctx_bad) diff --git a/tests/unit/storage/test_encrypted_kv_store.py b/tests/unit/storage/test_encrypted_kv_store.py index ae1690b8..4f2d9f00 100644 --- a/tests/unit/storage/test_encrypted_kv_store.py +++ b/tests/unit/storage/test_encrypted_kv_store.py @@ -6,7 +6,8 @@ from common.errors import BackendError, NotFoundError, ValidationError from common.factory.factory import Factory -from common.security import SecurityContext, SecurityProducer, SecurityProvider +from common.security.cryptography import CryptographyProducer, CryptographyProvider +from common.security.types import CryptoContext from common.type_def import MESSAGES_KEY_PREFIX, Scope, memory_key from config.context import AssemblyContext from storage.kv import KvProducer @@ -18,18 +19,18 @@ pytestmark = pytest.mark.unit -class _FakeSecurity(SecurityProvider): +class _FakeSecurity(CryptographyProvider): def __init__(self, *, allow_plaintext: bool = True) -> None: self.allow_plaintext = allow_plaintext self.fail_decrypt = False - self.encrypt_calls: list[tuple[SecurityContext | None, bytes, bytes]] = [] - self.decrypt_calls: list[tuple[SecurityContext | None, bytes, bytes]] = [] + self.encrypt_calls: list[tuple[CryptoContext | None, bytes, bytes]] = [] + self.decrypt_calls: list[tuple[CryptoContext | None, bytes, bytes]] = [] def encrypt( self, plaintext: bytes, *, - context: SecurityContext | None = None, + context: CryptoContext | None = None, aad: bytes = b"", ) -> bytes: self.encrypt_calls.append((context, aad, plaintext)) @@ -39,7 +40,7 @@ def decrypt( self, ciphertext: bytes, *, - context: SecurityContext | None = None, + context: CryptoContext | None = None, aad: bytes = b"", ) -> bytes: self.decrypt_calls.append((context, aad, ciphertext)) @@ -50,24 +51,24 @@ def decrypt( return ciphertext raise RuntimeError("missing encrypted envelope") offset = len(_PREFIX) - aad_len = int.from_bytes(ciphertext[offset: offset + 4], "big") + aad_len = int.from_bytes(ciphertext[offset:offset + 4], "big") offset += 4 - embedded_aad = ciphertext[offset: offset + aad_len] + embedded_aad = ciphertext[offset:offset + aad_len] if embedded_aad != aad: raise RuntimeError("aad mismatch") return ciphertext[offset + aad_len:][::-1] -@SecurityProducer.register("fake_encrypted_kv") +@CryptographyProducer.register("fake_encrypted_kv") def _build_fake_security(config): return _FakeSecurity(allow_plaintext=bool(config.get("allow_plaintext", True))) def _kv( - security: _FakeSecurity | None = None, + encryption: _FakeSecurity | None = None, ) -> tuple[EncryptedKVStore, InMemoryKVStore, _FakeSecurity]: raw = InMemoryKVStore() - fake = security or _FakeSecurity() + fake = encryption or _FakeSecurity() return EncryptedKVStore(raw, fake), raw, fake @@ -76,7 +77,7 @@ def _aad_payload(aad: bytes) -> dict: def test_encrypted_kv_store_encrypts_raw_value_and_decrypts_get() -> None: - kv, raw, security = _kv() + kv, raw, encryption = _kv() scope = Scope(org="acme", user="alice") key = memory_key("unit-1") @@ -87,12 +88,12 @@ def test_encrypted_kv_store_encrypts_raw_value_and_decrypts_get() -> None: assert b"secret memory" not in raw_value assert kv.get(scope, key) == b"secret memory" - context, aad, plaintext = security.encrypt_calls[0] + context, aad, plaintext = encryption.encrypt_calls[0] assert plaintext == b"secret memory" assert context is not None assert context.scope == scope assert context.purpose == "memory_unit" - assert context.metadata["key"] == key + assert context.object_id == key # 对象标识是专有字段,不塞 metadata payload = _aad_payload(aad) assert payload["scope"]["org"] == "acme" assert payload["scope"]["space"] == "" @@ -101,7 +102,7 @@ def test_encrypted_kv_store_encrypts_raw_value_and_decrypts_get() -> None: def test_encrypted_kv_store_list_decrypts_every_value_with_each_key_aad() -> None: - kv, _, security = _kv() + kv, _, encryption = _kv() scope = Scope(org="acme", user="alice") kv.insert(scope, memory_key("u1"), b"one") @@ -111,10 +112,7 @@ def test_encrypted_kv_store_list_decrypts_every_value_with_each_key_aad() -> Non assert listed[memory_key("u1")] == b"one" assert listed[f"{MESSAGES_KEY_PREFIX}m1"] == b"two" - purposes = [ - _aad_payload(aad)["purpose"] - for _, aad, _ in security.decrypt_calls - ] + purposes = [_aad_payload(aad)["purpose"] for _, aad, _ in encryption.decrypt_calls] assert purposes == ["memory_unit", "raw_message"] @@ -146,7 +144,7 @@ def test_encrypted_kv_store_mget_missing_raises_not_found() -> None: def test_encrypted_kv_store_passes_through_exists_delete_and_scopes() -> None: - kv, _, security = _kv() + kv, _, encryption = _kv() scope = Scope(org="acme", user="alice") key = "plain-key" @@ -157,13 +155,13 @@ def test_encrypted_kv_store_passes_through_exists_delete_and_scopes() -> None: kv.delete(scope, key) assert not kv.exists(scope, key) - assert len(security.encrypt_calls) == 1 - assert not security.decrypt_calls + assert len(encryption.encrypt_calls) == 1 + assert not encryption.decrypt_calls def test_encrypted_kv_store_supports_plaintext_compatibility_via_provider() -> None: - security = _FakeSecurity(allow_plaintext=True) - kv, raw, _ = _kv(security) + encryption = _FakeSecurity(allow_plaintext=True) + kv, raw, _ = _kv(encryption) scope = Scope(org="acme", user="alice") raw.insert(scope, "legacy", b"legacy plaintext") @@ -172,10 +170,10 @@ def test_encrypted_kv_store_supports_plaintext_compatibility_via_provider() -> N def test_encrypted_kv_store_decryption_failure_is_fail_closed() -> None: - kv, _, security = _kv() + kv, _, encryption = _kv() scope = Scope(org="acme", user="alice") kv.insert(scope, "key", b"value") - security.fail_decrypt = True + encryption.fail_decrypt = True try: kv.get(scope, "key") @@ -188,14 +186,14 @@ def test_encrypted_kv_store_factory_builds_wrapper_from_named_dependencies() -> Factory.reset_all() ctx = AssemblyContext.from_dict( { - "security": {"default": "fake_encrypted_kv"}, + "cryptography": {"default": "fake_encrypted_kv"}, "kv_store": { "raw": "memory", "default": { "target": "encrypted", "params": { "raw_kv_store": "raw", - "security": "default", + "cryptography": "default", }, }, }, @@ -214,11 +212,11 @@ def test_encrypted_kv_store_factory_requires_raw_dependency() -> None: Factory.reset_all() ctx = AssemblyContext.from_dict( { - "security": {"default": "fake_encrypted_kv"}, + "cryptography": {"default": "fake_encrypted_kv"}, "kv_store": { "default": { "target": "encrypted", - "params": {"security": "default"}, + "params": {"cryptography": "default"}, } }, } diff --git a/tests/unit/storage/test_kv_memory_list.py b/tests/unit/storage/test_kv_memory_list.py index f811d628..f1cb5040 100644 --- a/tests/unit/storage/test_kv_memory_list.py +++ b/tests/unit/storage/test_kv_memory_list.py @@ -5,7 +5,7 @@ import pytest -from common.security import SecurityProvider +from common.security.cryptography import CryptographyProvider from common.type_def import ( FilterClause, FilterGroup, @@ -70,7 +70,7 @@ def client(self): return self.fake_client -class _ReverseSecurity(SecurityProvider): +class _ReverseSecurity(CryptographyProvider): def encrypt(self, plaintext, *, context=None, aad=b""): _ = context, aad return plaintext[::-1] diff --git a/uv.lock b/uv.lock index 788e9097..b5fc8be8 100644 --- a/uv.lock +++ b/uv.lock @@ -203,6 +203,49 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/ba/16/9826f089383c593cdfc4a6e5aca94d9e91ae1692c57af82c3b2aa5e810f7/anyio-4.14.0-py3-none-any.whl", hash = "sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9" }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94" }, +] + [[package]] name = "async-timeout" version = "5.0.1" @@ -1367,6 +1410,9 @@ nlp = [ { name = "hanlp" }, { name = "spacy" }, ] +security = [ + { name = "argon2-cffi" }, +] [package.dev-dependencies] dev = [ @@ -1377,6 +1423,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "argon2-cffi", marker = "extra == 'security'", specifier = ">=23.1" }, { name = "cryptography", specifier = ">=42" }, { name = "elasticsearch", marker = "extra == 'deploy'", specifier = ">=8,<9" }, { name = "flagembedding", marker = "extra == 'embed'", specifier = ">=1.2" }, @@ -1398,7 +1445,7 @@ requires-dist = [ { name = "torch", marker = "extra == 'embed'", specifier = ">=2.0" }, { name = "transformers", marker = "extra == 'embed'", specifier = ">=4.39,<5" }, ] -provides-extras = ["dev", "nlp", "embed", "deploy", "mcp"] +provides-extras = ["dev", "nlp", "embed", "deploy", "mcp", "security"] [package.metadata.requires-dev] dev = [