feat: deliver Sense audits to Bell (T-016)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Sense 控制面、准入投影与本地审计契约 v1
|
||||
|
||||
> 冻结日期:2026-08-07。Control API 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-011 已实现 Control API handler 与 PostgreSQL 一致性边界;Bell 管理服务、JWT/OIDC 和 Outbox relay 仍未实现。
|
||||
> 冻结日期:2026-08-11。Control API 与审计 relay 契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-016 已实现 Sense Outbox 到 Bell 的内部 relay;Bell 公共管理服务与 JWT/OIDC 仍未实现。
|
||||
|
||||
## 契约文件
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
| [`sense-control-v1.openapi.json`](sense-control-v1.openapi.json) | Sense | Bell 管理面、受控集成方 | 设备查询、创建、修改、启停与批量操作 |
|
||||
| [`site-quota-v1.sql`](site-quota-v1.sql) | Bell | Sense | 单 PostgreSQL 实例内的站点视频配额只读投影 |
|
||||
| [`area-policy-v1.sql`](area-policy-v1.sql) | Bell | Sense | Area 归属与 `capture_policy` 只读投影 |
|
||||
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;未来 Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
|
||||
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;未来 Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
||||
| [`sense-device-audit-v1.schema.json`](sense-device-audit-v1.schema.json) | Sense | 本地 Outbox;Bell relay | 脱敏设备操作审计事实,不包含传输协议 |
|
||||
| [`sense-device-audit-v2.schema.json`](sense-device-audit-v2.schema.json) | Sense | 本地 Outbox;Bell relay | v1 后继,增加脱敏配置修改受理事实;v1 文件保持不变 |
|
||||
| [`sense-audit-relay-v1.openapi.json`](sense-audit-relay-v1.openapi.json) | Bell | Sense | 内部批量端点、HMAC、逐项确认、nonce 防重与重试边界 |
|
||||
|
||||
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||
|
||||
@@ -57,7 +58,19 @@ T-010 冻结 `bell.area_policy_v1` 的列顺序为 `tenant_id/site_id/area_id/ca
|
||||
|
||||
`sense-device-audit-v1.schema.json` 继续冻结创建与期望态两类事实且不原地扩展严格枚举。T-011 新增 v2 后继,兼容 v1 两类事件并增加 `device.configuration.accepted`;该 payload 只保存是否变化、字段名和 Area 逻辑 ID,不保存字段值。主体类型为 `user | service | system`,投影版本与 generation 随事实保存;endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置始终禁止进入审计。
|
||||
|
||||
PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。Schema 不是 Bell relay 协议:传输端点、签名、批量确认、重放窗口和留存由后续任务冻结。
|
||||
PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。事件 schema 继续只定义事实;传输由 `sense-audit-relay-v1.openapi.json` 独立冻结。
|
||||
|
||||
## Sense → Bell 审计 relay
|
||||
|
||||
Sense 向 `/internal/v1/audit-events:batch` 每批发送 1~100 个事件,请求体不超过 1 MiB、deadline 10 秒。请求用外部文件中的至少 32 字节 secret 做 HMAC-SHA256,canonical string 为 method、path、Unix 秒、随机 nonce 与 body SHA-256 的换行拼接;非回环地址必须使用 HTTPS。Bell 允许 300 秒时钟偏差并将 `(key_id, nonce)` 收据保留 600 秒:相同请求摘要返回原结果,不同摘要返回 `409 replay_conflict`。
|
||||
|
||||
Outbox 用 30 秒数据库时钟 lease、单调 fencing token 和 `FOR UPDATE SKIP LOCKED` 协调实例。`accepted/duplicate` 才标记 delivered;逐项 `rejected` 进入 dead letter;网络、5xx、认证失败或缺失结果以 1 秒起步、最多 300 秒指数退避。Sense 不直接访问 Bell schema,Bell 不读取 Sense Outbox;全局 `bell.audit_events` 不自动删除,只有短期 relay receipt 自动过期。
|
||||
|
||||
两端读取同格式的仓库外 key 文件;Bell 可同时接受多个 key,Sense 用 `SENSE_AUDIT_RELAY_KEY_ID` 选择一个,便于先加新 key、切换发送端、再移除旧 key。占位结构如下,`secret_base64url` 必须替换为至少 32 个随机字节的无填充 base64url,不能提交真实值:
|
||||
|
||||
```json
|
||||
{"version":1,"keys":[{"key_id":"sense-a","secret_base64url":"<external-secret>"}]}
|
||||
```
|
||||
|
||||
## 兼容与废弃
|
||||
|
||||
@@ -73,8 +86,10 @@ PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_
|
||||
```powershell
|
||||
python -m json.tool docs/contracts/sense-control-v1.openapi.json | Out-Null
|
||||
python -m json.tool docs/contracts/sense-device-audit-v2.schema.json | Out-Null
|
||||
python -m json.tool docs/contracts/sense-audit-relay-v1.openapi.json | Out-Null
|
||||
python -m unittest discover -s tests -p "test_sense_control_contract.py"
|
||||
python -m unittest discover -s tests -p "test_sense_control_implementation.py"
|
||||
python -m unittest discover -s tests -p "test_sense_audit_relay_contract.py"
|
||||
```
|
||||
|
||||
测试同时校验 OpenAPI 结构、生成 server glue、HTTP handler 与 PostgreSQL migration/事务;它不替代 Bell 消费方联合验收或客户现场容量验证。
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "YoVision Sense Audit Relay",
|
||||
"version": "1.0.0",
|
||||
"description": "Internal, signed and idempotent delivery of redacted Sense device audit facts to Bell."
|
||||
},
|
||||
"paths": {
|
||||
"/internal/v1/audit-events:batch": {
|
||||
"post": {
|
||||
"operationId": "receiveSenseAuditBatch",
|
||||
"description": "Accepts 1-100 events in a body no larger than 1048576 bytes. The request deadline is 10 seconds. HMAC clock skew is at most 300 seconds and nonce receipts live for 600 seconds.",
|
||||
"parameters": [
|
||||
{"name": "X-YoVision-Key-Id", "in": "header", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$"}},
|
||||
{"name": "X-YoVision-Timestamp", "in": "header", "required": true, "description": "Unix seconds", "schema": {"type": "string", "pattern": "^[0-9]{10,}$"}},
|
||||
{"name": "X-YoVision-Nonce", "in": "header", "required": true, "description": "16-48 random bytes encoded as unpadded base64url", "schema": {"type": "string", "minLength": 22, "maxLength": 64, "pattern": "^[A-Za-z0-9_-]+$"}},
|
||||
{"name": "X-YoVision-Signature", "in": "header", "required": true, "description": "Unpadded base64url HMAC-SHA256 over POST, path, timestamp, nonce and lowercase SHA-256 body digest joined by newlines", "schema": {"type": "string", "minLength": 43, "maxLength": 43}}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/BatchRequest"}}}
|
||||
},
|
||||
"responses": {
|
||||
"200": {"description": "Stored, duplicate or permanently rejected per item. Same key ID, nonce and request digest returns the original response.", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/BatchResponse"}}}},
|
||||
"400": {"description": "Malformed batch envelope"},
|
||||
"401": {"description": "Invalid key, timestamp, nonce or signature"},
|
||||
"409": {"description": "Same key ID and nonce used with a different request digest; error is replay_conflict"},
|
||||
"413": {"description": "Body exceeds 1048576 bytes"},
|
||||
"503": {"description": "Bell cannot atomically persist the batch and receipt"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"BatchRequest": {
|
||||
"type": "object", "additionalProperties": false, "required": ["events"],
|
||||
"properties": {"events": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/components/schemas/Envelope"}}}
|
||||
},
|
||||
"Envelope": {
|
||||
"type": "object", "additionalProperties": false, "required": ["schema_version", "event"],
|
||||
"properties": {
|
||||
"schema_version": {"type": "integer", "enum": [1, 2]},
|
||||
"event": {"$ref": "#/components/schemas/AuditEvent"}
|
||||
}
|
||||
},
|
||||
"AuditEvent": {
|
||||
"type": "object", "additionalProperties": false,
|
||||
"required": ["event_id", "event_type", "tenant_id", "site_id", "device_id", "actor", "reason", "trace_id", "aggregate_generation", "projection_versions", "data", "occurred_at"],
|
||||
"properties": {
|
||||
"event_id": {"type": "string", "pattern": "^audit_[0-9a-f]{32}$"},
|
||||
"event_type": {"type": "string", "enum": ["device.created", "device.desired_state.accepted", "device.configuration.accepted"]},
|
||||
"tenant_id": {"$ref": "#/components/schemas/LogicalId"},
|
||||
"site_id": {"$ref": "#/components/schemas/LogicalId"},
|
||||
"device_id": {"$ref": "#/components/schemas/LogicalId"},
|
||||
"actor": {"type": "object", "additionalProperties": false, "required": ["type", "id"], "properties": {"type": {"type": "string", "enum": ["user", "service", "system"]}, "id": {"type": "string", "minLength": 1, "maxLength": 200}}},
|
||||
"reason": {"type": ["string", "null"], "maxLength": 500},
|
||||
"trace_id": {"type": ["string", "null"], "maxLength": 128},
|
||||
"aggregate_generation": {"type": "integer", "minimum": 1},
|
||||
"projection_versions": {"type": "object", "additionalProperties": false, "required": ["quota_source_version", "area_policy_source_version"], "properties": {"quota_source_version": {"type": ["integer", "null"], "minimum": 1}, "area_policy_source_version": {"type": ["integer", "null"], "minimum": 1}}},
|
||||
"data": {"type": "object"},
|
||||
"occurred_at": {"type": "string", "format": "date-time"}
|
||||
}
|
||||
},
|
||||
"LogicalId": {"type": "string", "minLength": 1, "maxLength": 128, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"},
|
||||
"BatchResponse": {
|
||||
"type": "object", "additionalProperties": false, "required": ["results"],
|
||||
"properties": {"results": {"type": "array", "minItems": 1, "maxItems": 100, "items": {"$ref": "#/components/schemas/Result"}}}
|
||||
},
|
||||
"Result": {
|
||||
"type": "object", "additionalProperties": false, "required": ["event_id", "status"],
|
||||
"properties": {
|
||||
"event_id": {"type": "string"},
|
||||
"status": {"type": "string", "enum": ["accepted", "duplicate", "rejected"]},
|
||||
"error_code": {"type": "string", "pattern": "^[a-z][a-z0-9_]{0,63}$"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-yovision-signature": {
|
||||
"canonical": "METHOD\\nPATH\\nTIMESTAMP\\nNONCE\\nLOWERCASE_SHA256_BODY",
|
||||
"algorithm": "HMAC-SHA256",
|
||||
"encoding": "base64url-no-padding",
|
||||
"clock_skew_seconds": 300,
|
||||
"receipt_ttl_seconds": 600
|
||||
},
|
||||
"x-yovision-delivery": {
|
||||
"lease_seconds": 30,
|
||||
"initial_retry_seconds": 1,
|
||||
"maximum_retry_seconds": 300,
|
||||
"successful_statuses": ["accepted", "duplicate"],
|
||||
"permanent_status": "rejected"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user