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 配置校验通过
This commit is contained in:
chenxin committed 2026-08-28 09:35:27 +08:00
1 parent c63b75152a
commit 2483a25f63
29 files changed
+10533 -179

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
"""天宰域器谱:设备配对与逐项授契(rewrite/1.0 M9)。"""
from app.domains.tianzai import models, service # noqa: F401
__all__ = ["models", "service"]
+66
View File
@@ -0,0 +1,66 @@
"""器谱 API:设备配对/撤销、逐项授契/撤回、可用性裁决。"""
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from app.api.v1.deps import get_current_user
from app.domains.tianzai import service
from app.schemas.auth import CurrentUser
device_router = APIRouter(prefix="/devices", tags=["tianzai"])
class PairRequest(BaseModel):
kind: str = "desktop_shell"
name: str = Field(default="", max_length=120)
key_fingerprint: str = Field(min_length=8, max_length=128)
class GrantRequest(BaseModel):
capability: str
scope: dict = Field(default_factory=dict)
@device_router.post("", status_code=201)
async def pair_device_api(payload: PairRequest, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.pair_device(
owner_scope=user.owner_scope, kind=payload.kind,
name=payload.name, key_fingerprint=payload.key_fingerprint,
)
@device_router.get("")
async def list_devices_api(user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_devices(user.owner_scope)
@device_router.post("/{device_id}/revoke")
async def revoke_device_api(device_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.revoke_device(device_id, user.owner_scope)
@device_router.post("/{device_id}/grants", status_code=201)
async def grant_api(
device_id: str, payload: GrantRequest, user: CurrentUser = Depends(get_current_user)
) -> dict:
return await service.grant(
device_id, user.owner_scope, capability=payload.capability, scope=payload.scope
)
@device_router.get("/{device_id}/grants")
async def list_grants_api(device_id: str, user: CurrentUser = Depends(get_current_user)) -> list[dict]:
return await service.list_grants(device_id, user.owner_scope)
@device_router.post("/{device_id}/assert/{capability}")
async def assert_grant_api(
device_id: str, capability: str, user: CurrentUser = Depends(get_current_user)
) -> dict:
"""能力使用前置裁决(未授契 403)。"""
return await service.assert_granted(device_id, user.owner_scope, capability)
@device_router.post("/grants/{grant_id}/revoke")
async def revoke_grant_api(grant_id: str, user: CurrentUser = Depends(get_current_user)) -> dict:
return await service.revoke_grant(grant_id, user.owner_scope)
+54
View File
@@ -0,0 +1,54 @@
"""天宰域器谱表:设备配对与逐项授契(PRD §8.3)。
设计依据:
- 设备采用一次配对、设备密钥、可撤销证书;
- 器谱属于镜域,人物逐项授契;撤回立即生效;
- 失联时只允许安全自治与输入暂存,不允许本地人物副本。
"""
from datetime import datetime
from sqlalchemy import DateTime, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.core.ids import new_id
from app.core.time import utc_now
from app.db.base import Base
class Device(Base):
"""已配对设备(镜匣桌面端/移动端):一次配对,可撤销。"""
__tablename__ = "devices"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("dev"))
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 设备形态:desktop_shell(镜匣)/ mobile / other
kind: Mapped[str] = mapped_column(String(30), nullable=False, default="desktop_shell")
name: Mapped[str] = mapped_column(String(120), nullable=False, default="")
# 设备公钥指纹(配对时登记;凭证与通信验签用其派生)
key_fingerprint: Mapped[str] = mapped_column(String(128), nullable=False, default="")
# active / revoked / lost
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
last_seen_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
class DeviceGrant(Base):
"""器谱授契:设备级能力逐项授权(通知/文件对话框/语音输入/通感…);撤回立即生效。"""
__tablename__ = "device_grants"
id: Mapped[str] = mapped_column(String(80), primary_key=True, default=lambda: new_id("dgrant"))
device_id: Mapped[str] = mapped_column(
String(80), nullable=False, index=True
)
owner_scope: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
# 能力键:notifications / file_dialog / voice_input / camera / screen / location / local_search
capability: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
# 授权范围(时间窗/精度/单次触发等自由结构;敏感设备必须用户主动触发)
scope_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# active / revoked
status: Mapped[str] = mapped_column(String(20), nullable=False, default="active", index=True)
granted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utc_now)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+167
View File
@@ -0,0 +1,167 @@
"""器谱应用服务:设备配对、逐项授契、撤回与可用性裁决。
铁律(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,
}