Files
chenxin 2483a25f63 M9:桌面壳与收口——镜匣最小壳、器谱登记、发布证据落位、重写计划功成身退
- 镜匣 desktop/(Tauri v2 最小壳):WebView 承载琉璃镜(frontend/out 或 devUrl);能力桥最小命令(通知);乾坤通道 HMAC 短期凭证构造(与 sandbox/src/auth.rs 验签同算法,密钥仅经 SANDBOX_AUTH_SECRET 环境注入——二进制零平台密钥,静态审计钉死);hard_stop/resume 本地硬急停原子断路(全部通道命令过 ensure_not_halted,本地最高优先级不经云端);tantivy 依赖与 capabilities/ 目录预留;updater 插件位预留;cargo check 通过
- 器谱域 domains/tianzai(迁移 010):devices/device_grants——一次配对(密钥指纹登记)、逐项授契(可授面显式枚举,未知能力键拒)、撤回立即生效、设备撤销连带全部授契失效且不可再授;/devices 器谱 API 组
- 发布收口:release-evidence.md 落位重写期 RW-* 证据包(M0–M9 十里程碑验收自动化套件与结果物);能力矩阵 M9 行收官;rewrite-plan.md 按计划删除(M0–M9 全部完成,能力矩阵与证据台账接棒),全库引用清理
- 验收(test_m9_jingxia 四用例):器谱未授契 403 留痕/撤回即效/设备撤销连带;镜匣零平台密钥(rg 审计源码与配置为空);硬急停语义钉死(通道命令全检查+resume 唯一恢复+HMAC 无密钥不出凭证)
- 五套件终检全绿:后端 349、前端 203、BFF 4、神识 5 包、镜匣 cargo check(2 个预存环境失败历次 HEAD 复验与本周期无关);Compose nginx profile 配置校验通过
2026-08-28 09:35:27 +08:00

168 lines
6.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""器谱应用服务:设备配对、逐项授契、撤回与可用性裁决。
铁律(PRD §8.3):器谱属于镜域、逐项授契;撤回立即生效;
失联设备只允许安全自治与输入暂存(不产生本地人物副本)。
"""
from __future__ import annotations
import json
from sqlalchemy import select
from app.core.errors import AppError
from app.core.time import utc_now
from app.db.session import AsyncSessionLocal
from app.domains import one_qi
from app.domains.tianzai.models import Device, DeviceGrant
# 器谱能力键(可授契面;新增能力必须显式登记于此)
GRANTABLE = {
"notifications", "file_dialog", "voice_input",
"camera", "screen", "location", "local_search",
}
async def pair_device(*, owner_scope: str, kind: str, name: str, key_fingerprint: str) -> dict:
"""配对设备(一次配对;指纹登记后续凭证验签依据)。"""
async with AsyncSessionLocal() as session:
device = Device(
owner_scope=owner_scope, kind=kind or "desktop_shell",
name=name, key_fingerprint=key_fingerprint,
)
session.add(device)
await session.commit()
session.expunge(device)
return _device_dict(device)
async def list_devices(owner_scope: str) -> list[dict]:
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(Device).where(Device.owner_scope == owner_scope)
.order_by(Device.created_at.desc())
)
).scalars().all()
return [_device_dict(r) for r in rows]
async def revoke_device(device_id: str, owner_scope: str) -> dict:
"""撤销设备:立即生效(全部授契随之失效)。"""
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
device.status = "revoked"
grants = (
await session.execute(
select(DeviceGrant).where(DeviceGrant.device_id == device_id, DeviceGrant.status == "active")
)
).scalars().all()
now = utc_now()
for g in grants:
g.status = "revoked"
g.revoked_at = now
await session.commit()
await one_qi.emit(
"device.revoked", character_id=None,
payload={"device_id": device_id}, source="tianzai",
)
return {"device_id": device_id, "status": "revoked", "grants_revoked": len(grants)}
async def grant(device_id: str, owner_scope: str, *, capability: str, scope: dict) -> dict:
"""逐项授契(能力键必须显式登记;设备撤销后不可再授)。"""
if capability not in GRANTABLE:
raise AppError(
"invalid_request",
f"未知器谱能力:{capability}(可授面:{sorted(GRANTABLE)})",
)
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
if device.status != "active":
raise AppError("invalid_request", "设备已撤销,不可授契。", status_code=409)
row = DeviceGrant(
device_id=device_id, owner_scope=owner_scope,
capability=capability, scope_json=json.dumps(scope or {}, ensure_ascii=False),
)
session.add(row)
await session.commit()
session.expunge(row)
return _grant_dict(row)
async def revoke_grant(grant_id: str, owner_scope: str) -> dict:
"""撤回单项授契:立即生效。"""
async with AsyncSessionLocal() as session:
row = await session.get(DeviceGrant, grant_id)
if row is None or row.owner_scope != owner_scope:
raise AppError("not_found", "授契不存在。", status_code=404)
row.status = "revoked"
row.revoked_at = utc_now()
await session.commit()
return {"grant_id": grant_id, "status": "revoked", "effective": "immediate"}
async def assert_granted(device_id: str, owner_scope: str, capability: str) -> dict:
"""能力使用前置裁决:设备 active 且该能力授契 active(失联= revoked 同拒)。"""
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
if device.status != "active":
raise AppError("invalid_request", "设备已撤销,能力不可用。", status_code=403)
row = (
await session.execute(
select(DeviceGrant).where(
DeviceGrant.device_id == device_id,
DeviceGrant.capability == capability,
DeviceGrant.status == "active",
)
)
).scalars().first()
if row is None:
raise AppError(
"invalid_request", f"能力 {capability} 未授契(器谱逐项授权)。", status_code=403
)
device.last_seen_at = utc_now()
await session.commit()
session.expunge(row)
return _grant_dict(row)
async def list_grants(device_id: str, owner_scope: str) -> list[dict]:
await _owned_device(device_id, owner_scope)
async with AsyncSessionLocal() as session:
rows = (
await session.execute(
select(DeviceGrant).where(DeviceGrant.device_id == device_id)
.order_by(DeviceGrant.granted_at.desc())
)
).scalars().all()
return [_grant_dict(r) for r in rows]
async def _owned_device(device_id: str, owner_scope: str) -> None:
async with AsyncSessionLocal() as session:
device = await session.get(Device, device_id)
if device is None or device.owner_scope != owner_scope:
raise AppError("not_found", "设备不存在。", status_code=404)
def _device_dict(d: Device) -> dict:
return {
"id": d.id, "kind": d.kind, "name": d.name,
"key_fingerprint": d.key_fingerprint, "status": d.status,
"created_at": d.created_at.isoformat() if d.created_at else None,
"last_seen_at": d.last_seen_at.isoformat() if d.last_seen_at else None,
}
def _grant_dict(g: DeviceGrant) -> dict:
return {
"id": g.id, "device_id": g.device_id, "capability": g.capability,
"scope": json.loads(g.scope_json or "{}"), "status": g.status,
"granted_at": g.granted_at.isoformat() if g.granted_at else None,
"revoked_at": g.revoked_at.isoformat() if g.revoked_at else None,
}