修复公开资产授权一致性与签名期限

公开资产 metadata 和作品授权改为在同一 SpacetimeDB 事务快照中判断
移除连接级公开授权订阅缓存对 ACL 的参与
公开读取签名有效期上限固定为 600 秒并保留 owner 和 admin 行为
同步生成 bindings 并更新后端契约与项目记忆
This commit is contained in:
2026-07-13 20:04:29 +08:00
parent 4fc8beea72
commit 052d656a52
15 changed files with 293 additions and 95 deletions
@@ -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 秒
@@ -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`
@@ -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 tableprocedure 只允许 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<PublicWorkAssetReadGrant>`
- 源码:`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`
+86 -43
View File
@@ -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<AssetReadAccessScope, AppError> {
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<AssetReadAccessScope, AppError> {
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<u64>,
access_scope: AssetReadAccessScope,
) -> Option<u64> {
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(
@@ -19,6 +19,15 @@ pub struct AssetObjectProcedureResult {
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetObjectReadAccessProcedureResult {
pub ok: bool,
pub record: Option<AssetObjectUpsertSnapshot>,
pub public_work_granted: bool,
pub error_message: Option<String>,
}
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AssetHistoryListResult {
+2 -1
View File
@@ -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::{
+18 -36
View File
@@ -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<bool, SpacetimeClientError> {
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<AssetObjectRecord>, 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
+3 -4
View File
@@ -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"));
@@ -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,
@@ -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<AssetObjectRecord>, 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(
@@ -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;
@@ -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<AssetObjectUpsertSnapshot>,
pub public_work_granted: bool,
pub error_message: Option<String>,
}
impl __sdk::InModule for AssetObjectReadAccessProcedureResult {
type Module = super::RemoteModule;
}
@@ -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<AssetObjectReadAccessProcedureResult, __sdk::InternalError>,
) + 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<AssetObjectReadAccessProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, AssetObjectReadAccessProcedureResult>(
"get_asset_read_access_by_location_and_return",
GetAssetReadAccessByLocationAndReturnArgs { input },
__callback,
);
}
}
@@ -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,
@@ -240,6 +240,27 @@ pub fn public_work_asset_read_grant(ctx: &AnonymousViewContext) -> Vec<PublicWor
grants.into_values().collect()
}
pub(crate) fn asset_object_has_public_work_read_grant(
ctx: &ReducerContext,
asset_object: &AssetObjectUpsertSnapshot,
) -> 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<String, PublicWorkAssetReadGrant>,
scope: &PublicWorkAssetGrantScope<'_>,