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

公开资产 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
+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(