修复公开资产授权一致性与签名期限
公开资产 metadata 和作品授权改为在同一 SpacetimeDB 事务快照中判断 移除连接级公开授权订阅缓存对 ACL 的参与 公开读取签名有效期上限固定为 600 秒并保留 owner 和 admin 行为 同步生成 bindings 并更新后端契约与项目记忆
This commit is contained in:
@@ -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 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<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`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+20
@@ -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;
|
||||
}
|
||||
+59
@@ -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<'_>,
|
||||
|
||||
Reference in New Issue
Block a user