"""五档严格能力(feat-chv-muse-p5-B,叶子模块,纯标准库)。 建在 P3 的 ``Capability`` 之上(继承,不重写绑定语义):P3 只给 单次 + 300 秒一档;本模块加五档与用例维度,签发/使用/过期/ 超次/撤销全审计(事件名 + 能力 id + 原因,绝无凭证值与用户 数据内容)。 档位(``TIERS``)与签发默认值(``CapabilityIssuer.issue``): - one-shot:单次、300 秒(P3 默认); - session:会话内多次、无固定次数,会话 id 绑定,默认 3600 秒; - task:任务内多次、无固定次数,任务 id 绑定,默认 3600 秒; - timed:时限内多次、无固定次数,须显式 ttl; - permanent:永久、无过期无次数上限——默认不签发,须显式 ``allow_permanent=True`` + 审计,否则 ``ValueError``。 能力绑定 = {连接器,主机,用例} 三元组 + 方法/路径规则 + 次数/过期。``matches(request, now, scope)`` 精确匹配(``scope`` 须按关键字传,``now`` 仍占第二位置参数,与 P3 旧口径一致): 用例、方法、路径、会话/任务绑定、有效期、剩余次数任一项越界 即 False(并记审计)。``scope`` 是 dict 或带属性的对象,含 ``use_case`` / ``session_id`` / ``task_id``;没给 scope(None) 按空 scope 计,即用例/会话/任务绑定一律不匹配(fail-closed)。 """ import time from collections.abc import Callable, Iterable from cloud.chv.sentinel import ( DEFAULT_CAPABILITY_TTL_SECONDS, Capability, EgressRequest, _path_allowed, ) TIERS = ("one-shot", "session", "task", "timed", "permanent") DEFAULT_SESSION_TTL_SECONDS = 3600 DEFAULT_TASK_TTL_SECONDS = 3600 _EVENT_ISSUED = "sentinel_capability_issued" _EVENT_USED = "sentinel_capability_used" _EVENT_DENIED = "sentinel_capability_denied" _EVENT_EXPIRED = "sentinel_capability_expired" _EVENT_REVOKED = "sentinel_capability_revoked" def _scope_get(scope: object | None, key: str) -> object | None: """从 scope(dict 或带属性的对象)取一键,缺失即 None。""" if scope is None: return None if isinstance(scope, dict): return scope.get(key) return getattr(scope, key, None) def _audit_record(cap: "ScopedCapability", event: str, reason: str) -> dict: """审计记录:事件名 + 能力 id + 原因,无值。""" return {"event": event, "capability_id": cap.id, "tier": cap.tier, "reason": reason, "connector": cap.connector, "host": cap.host, "use_case": cap.use_case} class ScopedCapability(Capability): """五档严格能力:P3 绑定 + 用例三元组 + 会话/任务绑定。 ``max_uses=None`` 表示无固定次数(session/task/timed/ permanent 的默认);``expires_at=None`` 表示永不过期 (仅 permanent)。``revoked`` 后 ``matches`` 恒 False。 ``audit_log`` 与签发方共享:签发/使用/过期/超次/撤销 全记进同一份审计(事件名 + 能力 id + 原因,无值)。 """ def __init__(self, connector: str, host: str, methods: Iterable[str], path_rules: Iterable[tuple[str, str, str]], tier: str, use_case: str, session_id: str | None = None, task_id: str | None = None, ttl_seconds: int | None = None, max_uses: int | None = None, now: float | None = None, audit: list | None = None) -> None: super().__init__( connector, host, methods, path_rules, ttl_seconds=(ttl_seconds if ttl_seconds is not None else 0), max_uses=(max_uses if max_uses is not None else 1), now=now) self.tier = tier self.use_case = use_case self.session_id = session_id self.task_id = task_id self.revoked = False self.max_uses = max_uses self.uses_left = max_uses if ttl_seconds is None: self.expires_at = None self.audit_log = audit if audit is not None else [] def _mismatch_reason(self, request: EgressRequest, scope: object | None, now: float | None) -> str | None: """越界原因(None 表示在绑定内)。检查顺序固定: 撤销 → 用例 → 会话/任务 → 连接器/主机/方法/路径 → 过期 → 次数。""" if self.revoked: return "revoked" if _scope_get(scope, "use_case") != self.use_case: return "use_case_mismatch" if self.tier == "session" and ( _scope_get(scope, "session_id") != self.session_id): return "session_mismatch" if self.tier == "task" and ( _scope_get(scope, "task_id") != self.task_id): return "task_mismatch" if request.connector != self.connector: return "connector_mismatch" if request.host != self.host: return "host_mismatch" if request.method not in self.methods: return "method_not_allowed" if not _path_allowed(request, self.path_rules): return "path_not_allowed" at = now if now is not None else time.time() if self.expires_at is not None and at >= self.expires_at: return "expired" if self.uses_left is not None and self.uses_left <= 0: return "uses_exhausted" return None def _audit(self, event: str, reason: str) -> None: """记一条审计(事件名 + 能力 id + 原因,无值)。""" self.audit_log.append(_audit_record(self, event, reason)) def matches(self, request: EgressRequest, now: float | None = None, scope: object | None = None) -> bool: """该请求是否在能力绑定内(不消耗次数,只判定)。 ``scope`` 须按关键字传(``now`` 仍占第二位置参数, 与 P3 旧口径一致)。越界即 False 并记审计:过期记 expired,其余记 denied。 """ reason = self._mismatch_reason(request, scope, now) if reason is None: return True event = (_EVENT_EXPIRED if reason == "expired" else _EVENT_DENIED) self._audit(event, reason) return False def use(self, request: EgressRequest, now: float | None = None, scope: object | None = None) -> bool: """消耗一次能力:绑定内才扣次数并返回 True,否则 False (失败原因已由 ``matches`` 记审计,不再重复记)。""" if not self.matches(request, now=now, scope=scope): return False if self.uses_left is not None: self.uses_left -= 1 self._audit(_EVENT_USED, "used") return True def revoke(self) -> bool: """撤销:首次返回 True 并记审计;已撤销再调返回 False。 撤销后 ``matches`` 恒 False(原因 revoked)。""" if self.revoked: self._audit(_EVENT_DENIED, "already_revoked") return False self.revoked = True self._audit(_EVENT_REVOKED, "revoked") return True def describe(self) -> dict: """审计/展示用绑定描述(只含绑定,不含值)。""" return {"capability_id": self.id, "tier": self.tier, "connector": self.connector, "host": self.host, "use_case": self.use_case, "session_id": self.session_id, "task_id": self.task_id, "methods": sorted(self.methods), "max_uses": self.max_uses, "uses_left": self.uses_left, "expires_at": self.expires_at, "revoked": self.revoked} class CapabilityIssuer: """五档能力签发入口:按档位设默认值,审计与签出的能力共享。 permanent 默认拒绝:须调用时显式 ``allow_permanent=True``, 拒绝本身也记审计(denied,原因 ``permanent_requires_explicit_opt_in``)。 """ def __init__(self, audit: list | None = None, clock: Callable[[], float] | None = None) -> None: self.audit_log = audit if audit is not None else [] self._clock = clock or time.time def issue(self, tier: str, connector: str, host: str, methods: Iterable[str], path_rules: Iterable[tuple[str, str, str]], use_case: str, session_id: str | None = None, task_id: str | None = None, ttl_seconds: int | None = None, max_uses: int | None = None, now: float | None = None, allow_permanent: bool = False) -> ScopedCapability: """签发一档能力(按档位设默认值,见模块 docstring)。 未知档位、空用例、session 缺会话 id、task 缺任务 id、 timed 缺 ttl、permanent 未显式开启一律 ValueError; 校验失败先记审计(无能力 id)再抛错。 """ at = now if now is not None else self._clock() if tier not in TIERS: raise ValueError("unknown_tier:%s" % tier) if not use_case: raise ValueError("use_case_required") try: ttl, uses = self._defaults( tier, session_id, task_id, ttl_seconds, max_uses, allow_permanent) except ValueError as exc: self.audit_log.append( {"event": _EVENT_DENIED, "capability_id": None, "tier": tier, "reason": str(exc), "connector": connector, "host": host, "use_case": use_case}) raise cap = ScopedCapability( connector, host, methods, path_rules, tier, use_case, session_id=session_id, task_id=task_id, ttl_seconds=ttl, max_uses=uses, now=at, audit=self.audit_log) cap._audit(_EVENT_ISSUED, "issued") return cap def _defaults(self, tier: str, session_id: str | None, task_id: str | None, ttl_seconds: int | None, max_uses: int | None, allow_permanent: bool ) -> tuple[int | None, int | None]: """各档默认值(非法组合抛 ValueError,原因即消息)。 one-shot:1 次/300 秒;session/task:无固定次数,默认 3600 秒;timed:须显式 ttl;permanent:无过期无次数, 忽略传入的 ttl/max_uses。""" if tier == "one-shot": return (ttl_seconds if ttl_seconds is not None else DEFAULT_CAPABILITY_TTL_SECONDS, max_uses if max_uses is not None else 1) if tier == "session": if not session_id: raise ValueError("session_id_required") return (ttl_seconds if ttl_seconds is not None else DEFAULT_SESSION_TTL_SECONDS, max_uses) if tier == "task": if not task_id: raise ValueError("task_id_required") return (ttl_seconds if ttl_seconds is not None else DEFAULT_TASK_TTL_SECONDS, max_uses) if tier == "timed": if ttl_seconds is None: raise ValueError("ttl_required") return (ttl_seconds, max_uses) if not allow_permanent: raise ValueError("permanent_requires_explicit_opt_in") return (None, None) __all__ = ["TIERS", "DEFAULT_SESSION_TTL_SECONDS", "DEFAULT_TASK_TTL_SECONDS", "ScopedCapability", "CapabilityIssuer"]