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:
1 parent
c63b75152a
commit
2483a25f63
29 files changed
+10533
-179
No files matched your search
@@ -0,0 +1,121 @@
|
||||
"""器谱_设备配对与逐项授契
|
||||
|
||||
Revision ID: 010
|
||||
Revises: 009
|
||||
Create Date: 2026-08-28 09:25:57.705846
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '010'
|
||||
down_revision: Union[str, Sequence[str], None] = '009'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('device_grants',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('device_id', sa.String(length=80), nullable=False),
|
||||
sa.Column('owner_scope', sa.String(length=80), nullable=False),
|
||||
sa.Column('capability', sa.String(length=60), nullable=False),
|
||||
sa.Column('scope_json', sa.Text(), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('granted_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_device_grants_capability'), 'device_grants', ['capability'], unique=False)
|
||||
op.create_index(op.f('ix_device_grants_device_id'), 'device_grants', ['device_id'], unique=False)
|
||||
op.create_index(op.f('ix_device_grants_owner_scope'), 'device_grants', ['owner_scope'], unique=False)
|
||||
op.create_index(op.f('ix_device_grants_status'), 'device_grants', ['status'], unique=False)
|
||||
op.create_table('devices',
|
||||
sa.Column('id', sa.String(length=80), nullable=False),
|
||||
sa.Column('owner_scope', sa.String(length=80), nullable=False),
|
||||
sa.Column('kind', sa.String(length=30), nullable=False),
|
||||
sa.Column('name', sa.String(length=120), nullable=False),
|
||||
sa.Column('key_fingerprint', sa.String(length=128), nullable=False),
|
||||
sa.Column('status', sa.String(length=20), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_devices_owner_scope'), 'devices', ['owner_scope'], unique=False)
|
||||
op.create_index(op.f('ix_devices_status'), 'devices', ['status'], unique=False)
|
||||
op.drop_index(op.f('ix_character_profile_candidates_character_id'), table_name='character_profile_candidates')
|
||||
op.drop_index(op.f('ix_character_profile_candidates_import_job_id'), table_name='character_profile_candidates')
|
||||
op.drop_index(op.f('ix_character_profile_candidates_status'), table_name='character_profile_candidates')
|
||||
op.drop_table('character_profile_candidates')
|
||||
op.drop_index(op.f('ix_memory_candidates_character_id'), table_name='memory_candidates')
|
||||
op.drop_index(op.f('ix_memory_candidates_cycle_id'), table_name='memory_candidates')
|
||||
op.drop_index(op.f('ix_memory_candidates_import_job_id'), table_name='memory_candidates')
|
||||
op.drop_index(op.f('ix_memory_candidates_status'), table_name='memory_candidates')
|
||||
op.drop_table('memory_candidates')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('memory_candidates',
|
||||
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
|
||||
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
|
||||
sa.Column('import_job_id', sa.VARCHAR(length=80), nullable=True),
|
||||
sa.Column('cycle_id', sa.VARCHAR(length=80), nullable=True),
|
||||
sa.Column('type', sa.VARCHAR(length=60), nullable=False),
|
||||
sa.Column('title', sa.VARCHAR(length=200), nullable=False),
|
||||
sa.Column('content', sa.TEXT(), nullable=False),
|
||||
sa.Column('confidence', sa.FLOAT(), nullable=False),
|
||||
sa.Column('importance', sa.FLOAT(), nullable=False),
|
||||
sa.Column('sensitivity', sa.VARCHAR(length=40), nullable=False),
|
||||
sa.Column('evidence_ids_json', sa.TEXT(), nullable=False),
|
||||
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
|
||||
sa.Column('reason', sa.TEXT(), nullable=True),
|
||||
sa.Column('reviewed_at', sa.DATETIME(), nullable=True),
|
||||
sa.Column('created_at', sa.DATETIME(), nullable=False),
|
||||
sa.Column('updated_at', sa.DATETIME(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||
sa.ForeignKeyConstraint(['cycle_id'], ['big_cycles.id'], ),
|
||||
sa.ForeignKeyConstraint(['import_job_id'], ['character_import_jobs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_memory_candidates_status'), 'memory_candidates', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_memory_candidates_import_job_id'), 'memory_candidates', ['import_job_id'], unique=False)
|
||||
op.create_index(op.f('ix_memory_candidates_cycle_id'), 'memory_candidates', ['cycle_id'], unique=False)
|
||||
op.create_index(op.f('ix_memory_candidates_character_id'), 'memory_candidates', ['character_id'], unique=False)
|
||||
op.create_table('character_profile_candidates',
|
||||
sa.Column('id', sa.VARCHAR(length=80), nullable=False),
|
||||
sa.Column('character_id', sa.VARCHAR(length=80), nullable=False),
|
||||
sa.Column('import_job_id', sa.VARCHAR(length=80), nullable=True),
|
||||
sa.Column('field', sa.VARCHAR(length=60), nullable=False),
|
||||
sa.Column('value', sa.TEXT(), nullable=False),
|
||||
sa.Column('confidence', sa.FLOAT(), nullable=False),
|
||||
sa.Column('evidence_ids_json', sa.TEXT(), nullable=False),
|
||||
sa.Column('status', sa.VARCHAR(length=40), nullable=False),
|
||||
sa.Column('reason', sa.TEXT(), nullable=True),
|
||||
sa.Column('reviewed_at', sa.DATETIME(), nullable=True),
|
||||
sa.Column('created_at', sa.DATETIME(), nullable=False),
|
||||
sa.Column('updated_at', sa.DATETIME(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['character_id'], ['characters.id'], ),
|
||||
sa.ForeignKeyConstraint(['import_job_id'], ['character_import_jobs.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_character_profile_candidates_status'), 'character_profile_candidates', ['status'], unique=False)
|
||||
op.create_index(op.f('ix_character_profile_candidates_import_job_id'), 'character_profile_candidates', ['import_job_id'], unique=False)
|
||||
op.create_index(op.f('ix_character_profile_candidates_character_id'), 'character_profile_candidates', ['character_id'], unique=False)
|
||||
op.drop_index(op.f('ix_devices_status'), table_name='devices')
|
||||
op.drop_index(op.f('ix_devices_owner_scope'), table_name='devices')
|
||||
op.drop_table('devices')
|
||||
op.drop_index(op.f('ix_device_grants_status'), table_name='device_grants')
|
||||
op.drop_index(op.f('ix_device_grants_owner_scope'), table_name='device_grants')
|
||||
op.drop_index(op.f('ix_device_grants_device_id'), table_name='device_grants')
|
||||
op.drop_index(op.f('ix_device_grants_capability'), table_name='device_grants')
|
||||
op.drop_table('device_grants')
|
||||
# ### end Alembic commands ###
|
||||
@@ -19,6 +19,7 @@ from app.api.v1.endpoints import (
|
||||
baigong,
|
||||
hetu,
|
||||
soul,
|
||||
tianzai,
|
||||
kouxin,
|
||||
tiangong,
|
||||
xingyi,
|
||||
@@ -47,6 +48,7 @@ api_router.include_router(hetu.world_router)
|
||||
api_router.include_router(soul.soul_router)
|
||||
api_router.include_router(tiangong.task_router)
|
||||
api_router.include_router(kouxin.knock_router)
|
||||
api_router.include_router(tianzai.device_router)
|
||||
api_router.include_router(xingyi.form_router)
|
||||
api_router.include_router(yuanqi.cov_router)
|
||||
for _baigong_r in baigong.routers:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""器谱路由(域实现见 app/domains/tianzai/api.py)。"""
|
||||
|
||||
from app.domains.tianzai.api import device_router
|
||||
|
||||
__all__ = ["device_router"]
|
||||
@@ -0,0 +1,5 @@
|
||||
"""天宰域器谱:设备配对与逐项授契(rewrite/1.0 M9)。"""
|
||||
|
||||
from app.domains.tianzai import models, service # noqa: F401
|
||||
|
||||
__all__ = ["models", "service"]
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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,
|
||||
}
|
||||
@@ -8,6 +8,7 @@ from app.domains.baigong.models import Capability, Mandate, ToolCall
|
||||
from app.domains.hetu.models import PersistentWorld, WorldEntity, WorldRelation
|
||||
from app.domains.kouxin.models import KnockingCandidate
|
||||
from app.domains.tiangong.models import BackstageTask
|
||||
from app.domains.tianzai.models import Device, DeviceGrant
|
||||
from app.domains.xingyi.models import Form, FormAnchor, VoiceProfile
|
||||
from app.domains.yuanqi.models import Covenant, CovenantEvent
|
||||
from app.models.jingsheng import Custodianship, EmbryoDraft, IdentityRecord, RuntimeSeat, SoulForging
|
||||
@@ -28,6 +29,8 @@ from app.models.user import User
|
||||
|
||||
__all__ = [
|
||||
"BackstageTask",
|
||||
"Device",
|
||||
"DeviceGrant",
|
||||
"Capability",
|
||||
"KnockingCandidate",
|
||||
"Form",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""M9 验收测试(rewrite-plan M9 验收门)。
|
||||
|
||||
① 器谱:未授契能力被拒(403 留痕)、撤回立即生效、设备撤销连带全部授契失效;
|
||||
② 镜匣零平台密钥(rg 审计桌面壳源码与配置);
|
||||
③ 硬急停可用( halted 后全部通道命令拒绝,resume 恢复)——经 HMAC 单元逻辑钉死。
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
DESKTOP_DIR = Path(__file__).resolve().parents[2] / "desktop"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client(auth_headers: dict[str, str]) -> AsyncGenerator[AsyncClient, None]:
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test", headers=auth_headers) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ── ① 器谱:逐项授契与撤回 ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_grants_lifecycle(client: AsyncClient) -> None:
|
||||
paired = await client.post(
|
||||
"/api/v1/devices",
|
||||
json={"kind": "desktop_shell", "name": "执镜者的镜匣", "key_fingerprint": "sha256:abcd1234efgh5678"},
|
||||
)
|
||||
assert paired.status_code == 201, paired.text
|
||||
device_id = paired.json()["id"]
|
||||
|
||||
# 未授契能力 → 403(逐项授权)
|
||||
denied = await client.post(f"/api/v1/devices/{device_id}/assert/notifications")
|
||||
assert denied.status_code == 403
|
||||
assert "未授契" in denied.text
|
||||
|
||||
# 未知能力键 → 拒
|
||||
unknown = await client.post(
|
||||
f"/api/v1/devices/{device_id}/grants", json={"capability": "teleport"}
|
||||
)
|
||||
assert unknown.status_code == 422 or "未知器谱能力" in unknown.text
|
||||
|
||||
# 授契 → 断言通过
|
||||
granted = await client.post(
|
||||
f"/api/v1/devices/{device_id}/grants",
|
||||
json={"capability": "notifications", "scope": {"channels": ["in_app"]}},
|
||||
)
|
||||
assert granted.status_code == 201
|
||||
grant_id = granted.json()["id"]
|
||||
ok = await client.post(f"/api/v1/devices/{device_id}/assert/notifications")
|
||||
assert ok.status_code == 200
|
||||
|
||||
# 撤回单项 → 立即生效
|
||||
revoked = await client.post(f"/api/v1/devices/grants/{grant_id}/revoke")
|
||||
assert revoked.json()["effective"] == "immediate"
|
||||
denied2 = await client.post(f"/api/v1/devices/{device_id}/assert/notifications")
|
||||
assert denied2.status_code == 403
|
||||
|
||||
# 再授后撤销设备 → 全部授契连带失效
|
||||
await client.post(
|
||||
f"/api/v1/devices/{device_id}/grants", json={"capability": "file_dialog"}
|
||||
)
|
||||
device_revoked = await client.post(f"/api/v1/devices/{device_id}/revoke")
|
||||
assert device_revoked.json()["grants_revoked"] >= 1
|
||||
denied3 = await client.post(f"/api/v1/devices/{device_id}/assert/file_dialog")
|
||||
assert denied3.status_code == 403
|
||||
# 撤销后不可再授
|
||||
regrant = await client.post(
|
||||
f"/api/v1/devices/{device_id}/grants", json={"capability": "notifications"}
|
||||
)
|
||||
assert regrant.status_code == 409
|
||||
|
||||
|
||||
# ── ② 镜匣零平台密钥(静态审计) ──────────────────────────────
|
||||
|
||||
|
||||
def test_jingxia_zero_platform_secrets() -> None:
|
||||
"""桌面壳源码与配置不得内置任何密钥形态(密钥只经环境注入)。"""
|
||||
import re
|
||||
|
||||
patterns = [
|
||||
re.compile(r"sk-[a-zA-Z0-9]{16,}"),
|
||||
re.compile(r"(?i)api[_-]?key\s*=\s*['\"][^'\"]{8,}"),
|
||||
]
|
||||
for path in [*DESKTOP_DIR.rglob("*.rs"), *DESKTOP_DIR.rglob("*.toml"),
|
||||
*DESKTOP_DIR.rglob("*.json"), *DESKTOP_DIR.rglob("capabilities/*")]:
|
||||
if "target" in path.parts or "Cargo.lock" in path.name:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for pat in patterns:
|
||||
assert not pat.search(text), f"镜匣 {path.name} 内置密钥形态:{pat.pattern}"
|
||||
|
||||
|
||||
# ── ③ 硬急停(HMAC 通道单元逻辑与 Rust 侧同构断言) ──────────────────────────────
|
||||
|
||||
|
||||
def test_qiankun_grant_requires_secret() -> None:
|
||||
"""乾坤凭证构造必须显式密钥——空/缺失即通道关闭(与 main.rs 同语义)。"""
|
||||
import hmac as _hmac
|
||||
import hashlib as _hashlib
|
||||
import base64
|
||||
|
||||
def build(secret: str | None) -> str | None:
|
||||
if not secret:
|
||||
return None
|
||||
payload = '{"tool":"shell","expires_at":1}'
|
||||
b64 = base64.urlsafe_b64encode(payload.encode()).decode().rstrip("=")
|
||||
sig = _hmac.new(secret.encode(), b64.encode(), _hashlib.sha256).digest()
|
||||
return base64.urlsafe_b64encode(sig).decode().rstrip("=")
|
||||
|
||||
assert build(None) is None, "无密钥不得产出凭证"
|
||||
assert build("") is None, "空密钥不得产出凭证"
|
||||
sig = build("local-dev-secret")
|
||||
assert sig and len(sig) > 20
|
||||
|
||||
|
||||
def test_hard_stop_semantics_in_source() -> None:
|
||||
"""硬急停语义静态钉死:halted 状态被全部通道命令检查,resume 为唯一恢复路径。"""
|
||||
main_rs = (DESKTOP_DIR / "src" / "main.rs").read_text(encoding="utf-8")
|
||||
assert "ensure_not_halted" in main_rs
|
||||
# 通道命令都必须先过急停检查
|
||||
for cmd in ["send_notification", "qiankun_grant"]:
|
||||
m = main_rs.split(f"fn {cmd}(")[1].split("}")[0]
|
||||
assert "ensure_not_halted" in m, f"{cmd} 未检查硬急停"
|
||||
assert 'store(true' in main_rs and 'store(false' in main_rs
|
||||
Reference in new issue
Block a user