feat(store): add Area admission and audit outbox [T-010]
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Sense 控制面与站点配额契约 v1
|
||||
# Sense 控制面、准入投影与本地审计契约 v1
|
||||
|
||||
> 冻结日期:2026-08-07。契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC 与配额的所有者。本文冻结接口,不表示 HTTP handler、Bell 表或 PostgreSQL migration 已实现。
|
||||
> 冻结日期:2026-08-07。契约版本:`1.0.0`。Sense 是设备期望态的提供方;Bell 是 Tenant、Site、Area、RBAC、配额与全局审计的所有者。T-009/T-010 已实现两个只读投影和本地审计事务基础;HTTP handler、Bell 管理服务和 Outbox relay 仍未实现。
|
||||
|
||||
## 契约文件
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
| --- | --- | --- | --- |
|
||||
| [`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 | 脱敏设备操作审计事实,不包含传输协议 |
|
||||
|
||||
OpenAPI 的 `/api/v1` 路径是公共控制面边界;`/healthz`、`/readyz` 仍是非业务运维探针。v1 不提供设备删除:停用设备使用期望态接口,保留设备、操作和审计历史。Site、Area、配额、RBAC 和审计聚合不由 Sense 提供 CRUD。
|
||||
|
||||
@@ -48,7 +50,13 @@ Bell migration 最终创建 `bell.site_quota_v1`,列顺序和含义固定如
|
||||
|
||||
配额只统计 `desired_state=enabled` 且 capabilities 含 `video_capture` 的设备。创建已启用视频设备或把视频设备启用时,Sense 必须在同一设备写路径读取并记录所用 `source_version`,同时验证 Area 策略投影。降低配额不会自动停用已有设备;若当前占用已超限,后续创建/启用返回 `409 quota_exceeded`。配额行缺失、越界、版本回退或投影不可读时返回 `503 quota_projection_unavailable`,只阻止相关创建/启用,读取、非准入属性修改和停用仍允许,已有流保持运行。
|
||||
|
||||
Area/capture policy 的投影形态不在 T-008 中冻结;Sense v1 仍保留 `area_policy_unavailable` 与 `area_policy_denied` 稳定错误语义,后续契约不得放宽同写路径校验要求。
|
||||
T-010 冻结 `bell.area_policy_v1` 的列顺序为 `tenant_id/site_id/area_id/capture_policy/source_version/source_updated_at`,策略仅允许 `video_allowed | non_imaging_only`。Sense 对所有 PostgreSQL 新建设备验证 Area 归属;具有 `video_capture` 能力的设备在创建(包括 disabled 创建)和启用时检查策略。缺失、非法、版本回退或不可读映射为 `area_policy_unavailable`,`non_imaging_only` 拒绝成像变更并映射为 `area_policy_denied`。同库视图实时读取,不把长期未修改记录的 `source_updated_at` 年龄误判为过期。
|
||||
|
||||
## 本地设备操作审计
|
||||
|
||||
`sense-device-audit-v1.schema.json` 冻结本地审计事实的逻辑 envelope。当前事件只有 `device.created` 和 `device.desired_state.accepted`;主体类型为 `user | service | system`,投影版本与 generation 随事实保存。`data` 只包含 Area、模态、能力和状态变化等脱敏字段,禁止 endpoint、credential、profile token、path、密码、完整流 URI 或 MediaMTX 配置。
|
||||
|
||||
PostgreSQL repository 必须在设备创建/期望态事务内写 `sense.device_operation_outbox`;Outbox 失败回滚业务写入。相同期望态不增加 generation,但仍产生独立审计事实。Schema 不是 Bell relay 协议:传输端点、签名、批量确认、重放窗口和留存由后续任务冻结。
|
||||
|
||||
## 兼容与废弃
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Contract-only reference for the Bell -> Sense Area admission projection.
|
||||
-- The executable migration is deploy/postgres/005_area_policy.sql.
|
||||
|
||||
CREATE OR REPLACE VIEW bell.area_policy_v1 (
|
||||
tenant_id,
|
||||
site_id,
|
||||
area_id,
|
||||
capture_policy,
|
||||
source_version,
|
||||
source_updated_at
|
||||
) AS
|
||||
SELECT
|
||||
area.tenant_id,
|
||||
area.site_id,
|
||||
area.id,
|
||||
area.capture_policy,
|
||||
area.version,
|
||||
area.updated_at
|
||||
FROM bell.areas AS area
|
||||
JOIN bell.sites AS site
|
||||
ON site.tenant_id = area.tenant_id AND site.id = area.site_id
|
||||
WHERE area.deleted_at IS NULL AND site.deleted_at IS NULL;
|
||||
|
||||
COMMENT ON VIEW bell.area_policy_v1 IS
|
||||
'v1 read-only Area capture-policy projection owned by Bell and consumed by Sense';
|
||||
|
||||
ALTER VIEW bell.area_policy_v1 OWNER TO bell_app;
|
||||
REVOKE ALL PRIVILEGES ON TABLE bell.area_policy_v1 FROM PUBLIC;
|
||||
REVOKE ALL PRIVILEGES ON TABLE bell.area_policy_v1 FROM sense_app;
|
||||
GRANT SELECT ON TABLE bell.area_policy_v1 TO sense_app;
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://yovision.local/contracts/sense-device-audit-v1.schema.json",
|
||||
"title": "Sense Device Audit Event v1",
|
||||
"description": "Sense 本地持久化的设备操作审计事实;不定义 Bell relay 的传输、签名、确认或留存。",
|
||||
"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": {
|
||||
"enum": ["device.created", "device.desired_state.accepted"]
|
||||
},
|
||||
"tenant_id": {"$ref": "#/$defs/logicalId"},
|
||||
"site_id": {"$ref": "#/$defs/logicalId"},
|
||||
"device_id": {"$ref": "#/$defs/logicalId"},
|
||||
"actor": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "id"],
|
||||
"properties": {
|
||||
"type": {"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": {
|
||||
"oneOf": [
|
||||
{"$ref": "#/$defs/deviceCreatedData"},
|
||||
{"$ref": "#/$defs/desiredStateData"}
|
||||
]
|
||||
},
|
||||
"occurred_at": {"type": "string", "format": "date-time"}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {"properties": {"event_type": {"const": "device.created"}}},
|
||||
"then": {"properties": {"data": {"$ref": "#/$defs/deviceCreatedData"}}}
|
||||
},
|
||||
{
|
||||
"if": {"properties": {"event_type": {"const": "device.desired_state.accepted"}}},
|
||||
"then": {"properties": {"data": {"$ref": "#/$defs/desiredStateData"}}}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"logicalId": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 128,
|
||||
"pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$"
|
||||
},
|
||||
"desiredState": {"enum": ["disabled", "enabled"]},
|
||||
"deviceCreatedData": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "area_id", "modality", "capabilities", "desired_state"],
|
||||
"properties": {
|
||||
"kind": {"const": "device_created"},
|
||||
"area_id": {"$ref": "#/$defs/logicalId"},
|
||||
"modality": {"enum": ["video", "radar", "contact", "button", "wearable", "other"]},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"uniqueItems": true,
|
||||
"items": {"enum": ["video_capture", "audio_capture", "spatial_rule", "telemetry"]}
|
||||
},
|
||||
"desired_state": {"$ref": "#/$defs/desiredState"}
|
||||
}
|
||||
},
|
||||
"desiredStateData": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["kind", "previous_desired_state", "desired_state", "changed"],
|
||||
"properties": {
|
||||
"kind": {"const": "desired_state_accepted"},
|
||||
"previous_desired_state": {"$ref": "#/$defs/desiredState"},
|
||||
"desired_state": {"$ref": "#/$defs/desiredState"},
|
||||
"changed": {"type": "boolean"}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user