From 052d656a526fc6f090c0f26474deba17a9d53d8c Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 13 Jul 2026 20:04:29 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=85=AC=E5=BC=80=E8=B5=84?= =?UTF-8?q?=E4=BA=A7=E6=8E=88=E6=9D=83=E4=B8=80=E8=87=B4=E6=80=A7=E4=B8=8E?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E6=9C=9F=E9=99=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公开资产 metadata 和作品授权改为在同一 SpacetimeDB 事务快照中判断 移除连接级公开授权订阅缓存对 ACL 的参与 公开读取签名有效期上限固定为 600 秒并保留 owner 和 admin 行为 同步生成 bindings 并更新后端契约与项目记忆 --- .../shared-memory/decision-log.md | 8 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 +- server-rs/crates/api-server/src/assets.rs | 129 ++++++++++++------ .../crates/module-assets/src/application.rs | 9 ++ server-rs/crates/module-assets/src/lib.rs | 3 +- .../crates/spacetime-client/src/assets.rs | 54 +++----- server-rs/crates/spacetime-client/src/lib.rs | 7 +- .../crates/spacetime-client/src/mapper.rs | 4 +- .../spacetime-client/src/mapper/assets.rs | 29 ++++ .../spacetime-client/src/module_bindings.rs | 4 + ...bject_read_access_procedure_result_type.rs | 20 +++ ...access_by_location_and_return_procedure.rs | 59 ++++++++ .../src/asset_metadata/objects.rs | 32 +++++ .../src/public_asset_access.rs | 21 +++ 15 files changed, 293 insertions(+), 95 deletions(-) create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/asset_object_read_access_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 300d5b4c3..9a7ca4631 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4048,10 +4048,10 @@ ## 2026-07-13 公开作品资产使用派生精确读授权 - 背景:资产 ACL 严格执行后,已登记为 `private` 的作品封面和正式资产不能再依赖 generated 前缀匿名读取;但公开作品仍需要允许访客读取它实际展示和运行的资产。 -- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;`spacetime-client` 将该 view 纳入每个池连接的长期订阅。 +- 决策:已登记 `asset_object` 继续保持 `private`,新增匿名派生 view `public_work_asset_read_grant`。view 只从 `Published + visible` 作品(`custom-world` 另要求未删除)正式发布快照中收集实际使用的资产,历史作品随 view 计算自动补齐;资产读取 procedure 在同一事务快照内组合 `asset_object` 与该 view,不从连接级长期订阅 cache 判断 ACL。 - 授权边界:grant 携带作品 owner,API 只有在它与 `asset_object.owner_user_id` 一致,且 `asset_object_id` 或精确 `object_key` 命中时才允许匿名读取。隐藏、删除或取消发布会使 grant 自动消失;参考图、未选中候选图和 `generationInputs` 明确排除。Custom World 只遍历角色、地标、营地、章节和 opening CG 等已知正式根,不能递归 legacy payload 的未知预览 / 编辑字段。 - 禁止项:不得通过放开 `generated-*` 前缀或批量把历史对象改为 `PublicRead` 修复公开作品,两种方式都会让作品可见性生命周期与资产授权脱节,并重新引入跨账号读取。 -- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view、`spacetime-client` 订阅 cache 与 `api-server` 资产读取 ACL。 +- 影响范围:`module-assets` 公开资产授权判定、`spacetime-module` 跨玩法公开资产 view 与权威读取 procedure、`spacetime-client` facade 和 `api-server` 资产读取 ACL。 - 权威查询:`asset_object` 不进入 client 长期订阅。API 通过仅 runtime service identity 可调用的 procedure,按主键或 `(bucket, object_key)` 服务端索引读取事务内 metadata;只有位置查询明确返回不存在时才允许进入 legacy curated 前缀兼容,procedure 失败、超时或重复位置一律失败关闭。 -- 一致性:grant 通过长期订阅进入 API 本地 cache;隐藏、删除或取消发布提交后,在连接收到 view 更新时撤销。已签发 URL 仍受其短 TTL 约束,因此该能力不是对既有签名的瞬时吊销机制。 -- 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后 grant 随 view 更新撤销,再恢复公开可见时随 view 恢复。 +- 一致性:隐藏、删除或取消发布提交后,后续读取 procedure 的事务快照立即按新状态判断,不等待任意池连接追上订阅水位。公开派生授权、`PublicRead` 和 legacy 兼容读取签名 URL 的有效期最多 600 秒,因此该能力仍不是对既有签名的瞬时吊销机制;owner / admin 读取保持原有效期口径。 +- 验证方式:公开可见作品的正式资产可匿名读取;未选候选图、参考图、跨 owner 伪造 key 仍返回不存在;隐藏、删除或取消发布后新的读取请求立即拒绝,再恢复公开可见时新的读取请求立即恢复;超长公开 `expireSeconds` 被截断为 600 秒。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 18ca6449a..69e905c5d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3012,7 +3012,7 @@ - 现象:资产 ACL 收紧后,公开页面读取其他作者作品资产集中返回 `404`;对象在 OSS 中真实存在,但已登记 `asset_object.access_policy = private`。 - 原因:“作品公开”不等于“作者账号下所有 generated 对象永久公开”。只按 profile / session 关联也会误公开同会话的未选候选图、参考图或生成输入;批量改 `PublicRead` 则无法随作品隐藏、删除或取消发布自动撤销。 - 处理:已登记对象继续保持 `private`,通过 `public_work_asset_read_grant` 只派生 `Published + visible`(`custom-world` 还必须未删除)正式发布快照实际使用资产的匿名读授权。API 必须同时校验 grant owner 与资产 owner 一致,以及 `asset_object_id` 或精确 `object_key` 命中;明确排除参考图、未选候选图和 `generationInputs`。Custom World 只能扫描角色、地标、营地、章节和 opening CG 等正式根,不能遍历 legacy payload 的未知根。历史作品交给 view 现算补齐,不做永久 ACL 数据补丁。 -- 负查询边界:不能从 `asset_object` 订阅 cache miss 推断 metadata 不存在;订阅失败或增量尚未到达会让已登记 private 对象误走 legacy 放行。资产定位必须通过受 runtime service identity 限制的 procedure 按 bucket / key 权威点查,失败时拒绝读取;同时不要在每个池连接订阅复制全量 private 资产表。 +- 权威查询边界:不能从 `asset_object` 或 `public_work_asset_read_grant` 的连接级订阅 cache 推断当前 ACL;池连接水位不一致会让刚撤销的 grant 继续签发 URL,也会让刚公开的作品短暂 404。资产定位和公开授权必须通过受 runtime service identity 限制的 procedure 在同一事务快照中计算,失败时拒绝读取;同时不要在每个池连接订阅复制全量 private 资产表。公开派生授权、`PublicRead` 和 legacy 兼容读取的签名 URL 最长 600 秒,owner / admin 不受该公开上限影响。 - Remix 边界:拼图、Custom World 和大鱼现有 Remix 会把源资产引用复制到新 owner,但没有持久化不可伪造的资产来源。不得因此放宽跨 owner grant;源作品隐藏后仍公开的 Remix 资产,需要后续通过 Remix 时复制资产或持久化 provenance 解决。 - 验证:资产 owner 本人仍可读;公开可见作品的正式资产可匿名读;跨 owner、只命中前缀、参考图、未选候选图和 `generationInputs` 仍返回不存在;作品隐藏、删除或取消发布后 grant 消失。 - 关联:`server-rs/crates/spacetime-module/src/public_asset_access.rs`、`server-rs/crates/spacetime-client/src/assets.rs`、`server-rs/crates/api-server/src/assets.rs`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index a73c386c7..555194ae6 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -58,7 +58,7 @@ npm run check:server-rs-ddd - 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。 - 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。 - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 -- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 按配置 bucket 与精确 key 权威查询 `asset_object`;不得把订阅 cache miss 解释为对象未登记。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 的 `public_work_asset_read_grant` 精确授权读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品只获得与正式发布快照生命周期一致的派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理。 +- 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-url`、`/api/assets/read-bytes`,负责直传、确认、绑定和读取。两个读取入口共用同一授权函数,并通过受 runtime service identity 限制的 procedure 在同一事务快照内按配置 bucket 与精确 key 权威查询 `asset_object`、计算 `public_work_asset_read_grant`;不得把任意连接的订阅 cache miss 或命中解释为当前授权真相。一旦存在 metadata,即使 key 命中 legacy 前缀,也必须按 `PublicRead`、当前登录 owner,或同 owner 的公开作品派生授权精确读取;只有同 bucket / key 的权威查询确认未登记时,才允许显式 `legacyPublicPath` 命中 `platform_oss::LEGACY_PUBLIC_PREFIXES` curated 白名单后匿名兼容。已登记资产继续保持 `private`,公开作品只获得与正式发布快照生命周期一致的派生读授权,不得批量改为 `PublicRead` 或放开 `generated-*` 前缀。任意未登记 `objectKey`、跨 owner 和未获授权的匿名私有读取统一返回不存在,`read-bytes` 不得成为绕过 `read-url` 授权的同源代理;公开派生授权、`PublicRead` 和 legacy 兼容读取签发的 URL 统一限制为最长 600 秒,owner / admin 读取保持原有有效期口径。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。主站和 External 的 asset object confirm 都必须从已认证主体派生 owner,不能信任请求体 owner;同 bucket / key 已登记后不得改变 owner。 - 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/frontend-config`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/frontend-config` 由 `api-server` 从运行时环境变量下发非敏感 UI 开关;画板右侧 Agent 入口由 `GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR` 控制,默认关闭,前端不再读取 `VITE_*` 构建期变量决定生产显示。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 - 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。 @@ -309,14 +309,14 @@ npm run check:server-rs-ddd - Rust 结构体:`AssetObject` - 源码:`server-rs/crates/spacetime-module/src/asset_metadata/objects.rs` -- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table,procedure 只允许 runtime service identity 调用;`spacetime-client` 不订阅全量 `asset_object`。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。 +- 说明:对象 metadata 以 bucket / key 标识正式对象及其 owner、访问策略。确认接口的 owner 必须来自登录会话或 External API Key 绑定的认证主体,不接受请求体指定 owner;同 bucket / key 首次登记后,重复 confirm 不得改变 owner。已登记对象默认并继续保持 `private`。API 通过 `get_asset_object_by_location_and_return` / `get_asset_object_by_id_and_return` 在服务端索引上权威查询 private table;资产读取 ACL 使用 `get_asset_read_access_by_location_and_return` 在同一事务快照内同时返回位置查询与公开作品派生授权,procedure 只允许 runtime service identity 调用。`spacetime-client` 不订阅全量 `asset_object`,也不把公开资产授权 view 的连接级 cache 当成安全判断。位置查询发现重复 bucket / key 时失败关闭,不能任选一条继续授权。只有权威位置查询返回不存在的历史对象才能进入 curated legacy 白名单兼容。 ### SpacetimeDB view:`public_work_asset_read_grant` - Rust view:`public_work_asset_read_grant` - 返回类型:`Vec` - 源码:`server-rs/crates/spacetime-module/src/public_asset_access.rs` -- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;作品隐藏、删除或取消发布后 grant 随 client 收到 view 更新而消失,已签发 URL 继续受短 TTL 约束。投影明确排除参考图、未选中候选图和 `generationInputs`;Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。 +- 说明:匿名公开派生授权投影,仅从 `Published + visible` 作品(`custom-world` 还必须满足未删除)的正式发布快照收集实际使用的已登记资产。每条 grant 都携带作品 owner,读取时必须与 `asset_object.owner_user_id` 一致,并且只能按 `asset_object_id` 或精确 `object_key` 命中;跨 owner、同前缀或相似 key 不构成授权。历史公开作品由 view 现算自动补齐;资产读取 procedure 在自己的事务快照中直接执行该 view,作品隐藏、删除或取消发布提交后不能再被不同连接的陈旧订阅 cache 继续授权。已签发的公开 URL 最长保留 600 秒,能力本身不承诺撤销已经签出的 OSS URL。投影明确排除参考图、未选中候选图和 `generationInputs`;Custom World 只扫描角色、地标、营地、章节和 opening CG 等正式根,不递归 legacy payload 未知字段,避免把预览、编辑输入或同会话其他私有资产扩大为公开资产。 ### `auth_identity` @@ -930,7 +930,6 @@ npm run check:server-rs-ddd - `SELECT * FROM public_work_gallery_entry` - `SELECT * FROM public_work_detail_entry` -- `SELECT * FROM public_work_asset_read_grant` - `SELECT * FROM bark_battle_gallery_view` - `SELECT * FROM puzzle_gallery_card_view` - `SELECT * FROM puzzle_clear_gallery_card_view` diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 2f7684425..dfc6d90f0 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -50,6 +50,7 @@ const SUPPORTED_ASSET_HISTORY_KINDS: [&str; 9] = [ // 中文注释:同源字节读取同时服务图片转 Data URL 与 Match3D 私有 GLB 预览,Rodin GLB 可能明显超过图片上限。 const ASSET_READ_BYTES_MAX_SIZE_BYTES: u64 = 120 * 1024 * 1024; const ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS: u64 = 300; +const PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS: u64 = 600; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum AssetReadAuthorization { @@ -58,6 +59,12 @@ pub(crate) enum AssetReadAuthorization { Admin, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AssetReadAccessScope { + Public, + Privileged, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct AssetReadTarget { object_key: String, @@ -188,12 +195,17 @@ pub(crate) async fn get_asset_read_url_with_query( })?; let target = resolve_asset_read_target(&query)?; - authorize_asset_read_target(state, oss_client.config_bucket(), &target, &authorization).await?; + let access_scope = + authorize_asset_read_target(state, oss_client.config_bucket(), &target, &authorization) + .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: target.object_key, - expire_seconds: query.expire_seconds, + expire_seconds: clamp_public_asset_read_expire_seconds( + query.expire_seconds, + access_scope, + ), }) .map_err(|error| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ @@ -298,16 +310,20 @@ pub async fn get_asset_read_bytes( "/api/assets/read-bytes", ) .await?; - authorize_asset_read_target(&state, oss_client.config_bucket(), &target, &authorization) - .await?; + let access_scope = + authorize_asset_read_target(&state, oss_client.config_bucket(), &target, &authorization) + .await?; let signed = oss_client .sign_get_object_url(OssSignedGetObjectUrlRequest { object_key: target.object_key, - expire_seconds: Some( - query - .expire_seconds - .unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS), + expire_seconds: clamp_public_asset_read_expire_seconds( + Some( + query + .expire_seconds + .unwrap_or(ASSET_READ_BYTES_DEFAULT_EXPIRE_SECONDS), + ), + access_scope, ), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; @@ -657,32 +673,20 @@ async fn authorize_asset_read_target( configured_bucket: &str, target: &AssetReadTarget, authorization: &AssetReadAuthorization, -) -> Result<(), AppError> { +) -> Result { if matches!(authorization, AssetReadAuthorization::Admin) { - return Ok(()); + return Ok(AssetReadAccessScope::Privileged); } - let asset_object = state + let (asset_object, public_work_granted) = state .spacetime_client() - .get_asset_object_by_location(module_assets::AssetObjectLocationInput { + .get_asset_read_access_by_location(module_assets::AssetObjectLocationInput { bucket: configured_bucket.to_string(), object_key: target.object_key.clone(), }) .await .map_err(map_asset_read_authorization_error)?; if let Some(asset_object) = asset_object.as_ref() { - let public_work_granted = if asset_object.access_policy == AssetObjectAccessPolicy::Private - && !asset_object_owner_matches(asset_object, authorization) - && asset_object_storage_matches(asset_object, configured_bucket, &target.object_key) - { - state - .spacetime_client() - .is_asset_object_referenced_by_public_work(asset_object.clone()) - .await - .map_err(map_asset_read_authorization_error)? - } else { - false - }; return require_asset_object_read_access( asset_object, configured_bucket, @@ -694,7 +698,7 @@ async fn authorize_asset_read_target( // 已登记对象始终服从 metadata ACL;只有没有 metadata 的历史资源才走公开前缀兼容。 if target.is_legacy_public_path && is_supported_legacy_public_object_key(&target.object_key) { - return Ok(()); + return Ok(AssetReadAccessScope::Public); } Err(asset_read_not_found()) @@ -706,20 +710,34 @@ fn require_asset_object_read_access( object_key: &str, authorization: &AssetReadAuthorization, public_work_granted: bool, -) -> Result<(), AppError> { +) -> Result { if !asset_object_storage_matches(asset_object, configured_bucket, object_key) { return Err(asset_read_not_found()); } - if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead - || public_work_granted - || asset_object_owner_matches(asset_object, authorization) - { - return Ok(()); + if asset_object_owner_matches(asset_object, authorization) { + return Ok(AssetReadAccessScope::Privileged); + } + if asset_object.access_policy == AssetObjectAccessPolicy::PublicRead || public_work_granted { + return Ok(AssetReadAccessScope::Public); } Err(asset_read_not_found()) } +fn clamp_public_asset_read_expire_seconds( + requested_expire_seconds: Option, + access_scope: AssetReadAccessScope, +) -> Option { + match access_scope { + AssetReadAccessScope::Public => Some( + requested_expire_seconds + .unwrap_or(PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) + .min(PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS), + ), + AssetReadAccessScope::Privileged => requested_expire_seconds, + } +} + fn asset_object_storage_matches( asset_object: &module_assets::AssetObjectRecord, configured_bucket: &str, @@ -1017,16 +1035,16 @@ mod tests { Some("user-owner"), ); - assert!( + assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Owner("user-owner".to_string()), false, - ) - .is_ok() - ); + ), + Ok(super::AssetReadAccessScope::Privileged) + )); for authorization in [ super::AssetReadAuthorization::Anonymous, super::AssetReadAuthorization::Owner("user-other".to_string()), @@ -1047,16 +1065,16 @@ mod tests { fn public_asset_read_allows_anonymous_but_rejects_storage_mismatch() { let record = asset_object_record(module_assets::AssetObjectAccessPolicy::PublicRead, None); - assert!( + assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, false, - ) - .is_ok() - ); + ), + Ok(super::AssetReadAccessScope::Public) + )); assert_eq!( super::require_asset_object_read_access( &record, @@ -1078,16 +1096,16 @@ mod tests { Some("user-owner"), ); - assert!( + assert!(matches!( super::require_asset_object_read_access( &record, "genarrative-assets", record.object_key.as_str(), &super::AssetReadAuthorization::Anonymous, true, - ) - .is_ok() - ); + ), + Ok(super::AssetReadAccessScope::Public) + )); assert_eq!( super::require_asset_object_read_access( &record, @@ -1102,6 +1120,31 @@ mod tests { ); } + #[test] + fn public_asset_read_url_expiry_is_capped_but_privileged_expiry_is_preserved() { + assert_eq!( + super::clamp_public_asset_read_expire_seconds( + None, + super::AssetReadAccessScope::Public, + ), + Some(super::PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) + ); + assert_eq!( + super::clamp_public_asset_read_expire_seconds( + Some(86_400), + super::AssetReadAccessScope::Public, + ), + Some(super::PUBLIC_ASSET_READ_MAX_EXPIRE_SECONDS) + ); + assert_eq!( + super::clamp_public_asset_read_expire_seconds( + Some(86_400), + super::AssetReadAccessScope::Privileged, + ), + Some(86_400) + ); + } + #[test] fn legacy_public_fallback_only_accepts_curated_prefixes() { assert!(super::is_supported_legacy_public_object_key( diff --git a/server-rs/crates/module-assets/src/application.rs b/server-rs/crates/module-assets/src/application.rs index bad6ddbea..7b3f8fdb7 100644 --- a/server-rs/crates/module-assets/src/application.rs +++ b/server-rs/crates/module-assets/src/application.rs @@ -19,6 +19,15 @@ pub struct AssetObjectProcedureResult { pub error_message: Option, } +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AssetObjectReadAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub public_work_granted: bool, + pub error_message: Option, +} + #[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct AssetHistoryListResult { diff --git a/server-rs/crates/module-assets/src/lib.rs b/server-rs/crates/module-assets/src/lib.rs index 2b76f42cf..410967b8f 100644 --- a/server-rs/crates/module-assets/src/lib.rs +++ b/server-rs/crates/module-assets/src/lib.rs @@ -10,7 +10,8 @@ mod asset_object_service; pub use application::{ AssetEntityBindingProcedureResult, AssetHistoryListResult, AssetObjectProcedureResult, - ConfirmAssetObjectResult, build_asset_entity_binding_input, build_asset_object_upsert_input, + AssetObjectReadAccessProcedureResult, ConfirmAssetObjectResult, + build_asset_entity_binding_input, build_asset_object_upsert_input, }; #[cfg(feature = "server-service")] pub use asset_object_service::{ diff --git a/server-rs/crates/spacetime-client/src/assets.rs b/server-rs/crates/spacetime-client/src/assets.rs index 83eb6bafd..c33e050c3 100644 --- a/server-rs/crates/spacetime-client/src/assets.rs +++ b/server-rs/crates/spacetime-client/src/assets.rs @@ -90,43 +90,25 @@ impl SpacetimeClient { .await } - pub async fn is_asset_object_referenced_by_public_work( + pub async fn get_asset_read_access_by_location( &self, - asset_object: AssetObjectRecord, - ) -> Result { - let owner_user_id = asset_object.owner_user_id.clone().unwrap_or_default(); - let grant_candidates = [ - module_assets::PublicAssetReadGrant { - owner_user_id: owner_user_id.clone(), - asset_object_id: Some(asset_object.asset_object_id.clone()), - object_key: None, - }, - module_assets::PublicAssetReadGrant { - owner_user_id, - asset_object_id: None, - object_key: Some(asset_object.object_key.clone()), - }, - ]; - self.read_after_connect( - "is_asset_object_referenced_by_public_work", - move |connection| { - let grants = connection.db().public_work_asset_read_grant(); - Ok(grant_candidates.iter().any(|candidate| { - let Some(grant_id) = module_assets::public_asset_read_grant_id(candidate) - else { - return false; - }; - grants.grant_id().find(&grant_id).is_some_and(|row| { - module_assets::asset_object_matches_public_read_grant( - &asset_object, - &module_assets::PublicAssetReadGrant { - owner_user_id: row.owner_user_id, - asset_object_id: row.asset_object_id, - object_key: row.object_key, - }, - ) - }) - })) + input: module_assets::AssetObjectLocationInput, + ) -> Result<(Option, bool), SpacetimeClientError> { + let procedure_input = input.into(); + self.call_after_connect( + "get_asset_read_access_by_location_and_return", + move |connection, sender| { + connection + .procedures() + .get_asset_read_access_by_location_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_asset_read_access_procedure_result); + send_once(&sender, mapped); + }, + ); }, ) .await diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 68f998861..304be0eb9 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -383,10 +383,9 @@ pub enum SpacetimeClientError { const DEFAULT_PROCEDURE_TIMEOUT: Duration = Duration::from_secs(30); const PUBLIC_WORK_PLAY_DAY_MICROS: i64 = 86_400_000_000; const PUBLIC_WORK_RECENT_PLAY_WINDOW_DAYS: i64 = 7; -const REQUIRED_CACHED_READ_MODEL_QUERIES: [&str; 13] = [ +const REQUIRED_CACHED_READ_MODEL_QUERIES: [&str; 12] = [ "SELECT * FROM public_work_gallery_entry", "SELECT * FROM public_work_detail_entry", - "SELECT * FROM public_work_asset_read_grant", "SELECT * FROM bark_battle_gallery_view", "SELECT * FROM puzzle_gallery_card_view", "SELECT * FROM puzzle_clear_gallery_card_view", @@ -1205,9 +1204,9 @@ mod tests { } #[test] - fn required_read_model_subscriptions_include_public_asset_grants() { + fn asset_acl_truth_is_not_read_from_cached_subscriptions() { assert!( - REQUIRED_CACHED_READ_MODEL_QUERIES + !REQUIRED_CACHED_READ_MODEL_QUERIES .contains(&"SELECT * FROM public_work_asset_read_grant") ); assert!(!REQUIRED_CACHED_READ_MODEL_QUERIES.contains(&"SELECT * FROM asset_object")); diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 492ccfe6b..43c2d4b2a 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -191,8 +191,8 @@ pub use self::wooden_fish::{ pub(crate) use self::ai::map_ai_task_procedure_result; pub(crate) use self::assets::{ - map_entity_binding_procedure_result, map_optional_asset_object_procedure_result, - map_procedure_result, + map_asset_read_access_procedure_result, map_entity_binding_procedure_result, + map_optional_asset_object_procedure_result, map_procedure_result, }; pub(crate) use self::auth::{ map_auth_store_projection_procedure_result, map_auth_store_projection_sync_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/assets.rs b/server-rs/crates/spacetime-client/src/mapper/assets.rs index 551df82d3..f9edd4900 100644 --- a/server-rs/crates/spacetime-client/src/mapper/assets.rs +++ b/server-rs/crates/spacetime-client/src/mapper/assets.rs @@ -82,6 +82,22 @@ pub(crate) fn map_optional_asset_object_procedure_result( .map(build_asset_object_record)) } +pub(crate) fn map_asset_read_access_procedure_result( + result: AssetObjectReadAccessProcedureResult, +) -> Result<(Option, bool), SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(( + result + .record + .map(map_snapshot) + .map(build_asset_object_record), + result.public_work_granted, + )) +} + #[cfg(test)] mod asset_object_procedure_result_tests { use super::*; @@ -103,6 +119,19 @@ mod asset_object_procedure_result_tests { }); assert!(failed.is_err()); } + + #[test] + fn read_access_lookup_preserves_authoritative_public_grant() { + let result = map_asset_read_access_procedure_result(AssetObjectReadAccessProcedureResult { + ok: true, + record: None, + public_work_granted: true, + error_message: None, + }) + .expect("authoritative read access lookup should succeed"); + + assert_eq!(result, (None, true)); + } } pub(crate) fn map_entity_binding_procedure_result( diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 4d208bd54..01e478d4f 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -96,6 +96,7 @@ pub mod asset_history_list_result_type; pub mod asset_object_access_policy_type; pub mod asset_object_location_input_type; pub mod asset_object_procedure_result_type; +pub mod asset_object_read_access_procedure_result_type; pub mod asset_object_table; pub mod asset_object_type; pub mod asset_object_upsert_input_type; @@ -519,6 +520,7 @@ pub mod finish_wooden_fish_run_procedure; pub mod generate_big_fish_asset_procedure; pub mod get_asset_object_by_id_and_return_procedure; pub mod get_asset_object_by_location_and_return_procedure; +pub mod get_asset_read_access_by_location_and_return_procedure; pub mod get_bark_battle_run_procedure; pub mod get_bark_battle_runtime_config_procedure; pub mod get_battle_state_procedure; @@ -1439,6 +1441,7 @@ pub use asset_history_list_result_type::AssetHistoryListResult; pub use asset_object_access_policy_type::AssetObjectAccessPolicy; pub use asset_object_location_input_type::AssetObjectLocationInput; pub use asset_object_procedure_result_type::AssetObjectProcedureResult; +pub use asset_object_read_access_procedure_result_type::AssetObjectReadAccessProcedureResult; pub use asset_object_table::*; pub use asset_object_type::AssetObject; pub use asset_object_upsert_input_type::AssetObjectUpsertInput; @@ -1862,6 +1865,7 @@ pub use finish_wooden_fish_run_procedure::finish_wooden_fish_run; pub use generate_big_fish_asset_procedure::generate_big_fish_asset; pub use get_asset_object_by_id_and_return_procedure::get_asset_object_by_id_and_return; pub use get_asset_object_by_location_and_return_procedure::get_asset_object_by_location_and_return; +pub use get_asset_read_access_by_location_and_return_procedure::get_asset_read_access_by_location_and_return; pub use get_bark_battle_run_procedure::get_bark_battle_run; pub use get_bark_battle_runtime_config_procedure::get_bark_battle_runtime_config; pub use get_battle_state_procedure::get_battle_state; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/asset_object_read_access_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_read_access_procedure_result_type.rs new file mode 100644 index 000000000..a5c63e445 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/asset_object_read_access_procedure_result_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_upsert_snapshot_type::AssetObjectUpsertSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct AssetObjectReadAccessProcedureResult { + pub ok: bool, + pub record: Option, + pub public_work_granted: bool, + pub error_message: Option, +} + +impl __sdk::InModule for AssetObjectReadAccessProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs new file mode 100644 index 000000000..bb8314cfa --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_asset_read_access_by_location_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::asset_object_location_input_type::AssetObjectLocationInput; +use super::asset_object_read_access_procedure_result_type::AssetObjectReadAccessProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetAssetReadAccessByLocationAndReturnArgs { + pub input: AssetObjectLocationInput, +} + +impl __sdk::InModule for GetAssetReadAccessByLocationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_asset_read_access_by_location_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_asset_read_access_by_location_and_return { + fn get_asset_read_access_by_location_and_return(&self, input: AssetObjectLocationInput) { + self.get_asset_read_access_by_location_and_return_then(input, |_, _| {}); + } + + fn get_asset_read_access_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_asset_read_access_by_location_and_return for super::RemoteProcedures { + fn get_asset_read_access_by_location_and_return_then( + &self, + input: AssetObjectLocationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, AssetObjectReadAccessProcedureResult>( + "get_asset_read_access_by_location_and_return", + GetAssetReadAccessByLocationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index 39f085e82..f5c14f6e2 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -122,6 +122,38 @@ pub fn get_asset_object_by_location_and_return( } } +// 公开授权与资产 metadata 必须在同一事务快照中判断,不能依赖 API 连接池的订阅水位。 +#[spacetimedb::procedure] +pub fn get_asset_read_access_by_location_and_return( + ctx: &mut ProcedureContext, + input: AssetObjectLocationInput, +) -> AssetObjectReadAccessProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::editor_project_storage::require_editor_generation_runtime_service_identity( + tx, caller, + )?; + let record = find_asset_object_by_location(tx, &input)?; + let public_work_granted = record.as_ref().is_some_and(|asset_object| { + crate::public_asset_access::asset_object_has_public_work_read_grant(tx, asset_object) + }); + Ok((record, public_work_granted)) + }) { + Ok((record, public_work_granted)) => AssetObjectReadAccessProcedureResult { + ok: true, + record, + public_work_granted, + error_message: None, + }, + Err(message) => AssetObjectReadAccessProcedureResult { + ok: false, + record: None, + public_work_granted: false, + error_message: Some(message), + }, + } +} + #[spacetimedb::procedure] pub fn get_asset_object_by_id_and_return( ctx: &mut ProcedureContext, diff --git a/server-rs/crates/spacetime-module/src/public_asset_access.rs b/server-rs/crates/spacetime-module/src/public_asset_access.rs index 895201aee..ccdbd9d53 100644 --- a/server-rs/crates/spacetime-module/src/public_asset_access.rs +++ b/server-rs/crates/spacetime-module/src/public_asset_access.rs @@ -240,6 +240,27 @@ pub fn public_work_asset_read_grant(ctx: &AnonymousViewContext) -> Vec bool { + let asset_object = module_assets::build_asset_object_record(asset_object.clone()); + let view_context = ctx.as_anonymous_read_only(); + + public_work_asset_read_grant(&view_context) + .into_iter() + .any(|grant| { + module_assets::asset_object_matches_public_read_grant( + &asset_object, + &module_assets::PublicAssetReadGrant { + owner_user_id: grant.owner_user_id, + asset_object_id: grant.asset_object_id, + object_key: grant.object_key, + }, + ) + }) +} + fn collect_json_grants( grants: &mut BTreeMap, scope: &PublicWorkAssetGrantScope<'_>,