Rails 设置 public: true 后为何无法写入 S3 兼容 bucket?
结论
public: true 会让 Active Storage 在上传对象时设置 public-read ACL,因此 AWS SDK 会发送:
x-amz-acl: public-read
当前 S3 兼容端点不支持这个 ACL 功能,返回了 Aws::S3::Errors::NotImplemented。移除 public: true 后上传成功,也与这个原因一致。
可以通过 bucket policy 允许匿名用户执行 s3:GetObject,写入和删除仍由应用凭证授权。如果还需要 Active Storage 生成无签名 URL,可以自定义一个 S3 service,保留公开 URL 的行为,同时移除上传参数中的 ACL。
为什么已有公开读取策略,上传仍然失败?
Rails 7.2.3 的 S3 service 会在两处使用 public: true:
- 上传时设置
acl: "public-read"。 - 生成文件 URL 时使用公开 URL,不附加临时访问签名。
bucket policy 和对象 ACL 是两套不同的权限机制。即使 policy 已允许匿名读取,Rails 仍会根据 public: true 发送 ACL 参数,存储端也仍会处理并拒绝这个参数。
这个报错只能说明当前端点不支持请求涉及的 ACL 功能,无法据此判断所有 Linode Object Storage 端点都不支持 ACL,也无法确定限制来自哪个底层配置。当前场景中,不发送这个 ACL 后就能成功上传,可以据此处理。
增加 s3:PutObjectAcl 权限无法解决服务端未实现该功能的问题。CORS: none 也不会导致这里的 Rails 服务端上传报错。
第一步:通过 policy 允许匿名读取对象
已有的 PublicReadGetObject 声明基本符合需求,可以整理为:
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::bucketname/*"
}
将这条声明加入 bucket policy 的 Statement 数组。不要用它覆盖整个 policy,以免删除应用依赖的其他授权。
s3:GetObject 对应对象资源,使用以下 ARN 即可,无需同时加入 bucket 自身的 ARN:
arn:aws:s3:::bucketname/*
这条声明允许任何知道对象地址的人读取文件,不授予匿名用户上传、覆盖、删除或列出 bucket 内容的权限。应用需要的写入和删除权限,继续授予指定身份或访问密钥。
原配置中,第二条声明的 Principal 是指定用户。该声明中的 s3:PutObject、s3:DeleteObject 等权限不会因为公开读取声明的存在而自动开放给所有人。不过,这个应用身份的权限范围较大,可以按实际需要收紧。
输出中的以下内容属于 bucket ACL:
ACL: *anon*: READ
按 S3 的权限语义,bucket 的 READ ACL 涉及列出对象,和读取对象内容是不同的权限。如果不希望匿名用户列出文件,应检查这条 ACL,确认 policy 已提供公开读取权限后,再移除不需要的匿名 bucket 权限。
第二步:让 Active Storage 生成公开 URL,但不发送 ACL
Rails 7.2.3 的标准 S3 配置没有独立选项,可以在生成公开 URL 的同时省去上传时的公开 ACL。可以通过自定义 service 实现。
新建文件:
# lib/active_storage/service/s3_public_without_acl_service.rb
require "active_storage/service/s3_service"
class ActiveStorage::Service::S3PublicWithoutAclService <
ActiveStorage::Service::S3Service
def initialize(**options)
super
@upload_options.delete(:acl)
end
end
父类初始化时会根据 public: true 启用公开 URL,随后删除 :acl,让上传请求不再通过这个参数设置对象 ACL。
修改 config/storage.yml:
bucketname:
service: S3PublicWithoutAcl
endpoint: https://region.linodeobjects.com
access_key_id: <%= ENV.fetch("S3_ACCESS_KEY_ID") %>
secret_access_key: <%= ENV.fetch("S3_SECRET_ACCESS_KEY") %>
region: default
bucket: bucketname
public: true
endpoint 应填写实际可用的地址。当前配置移除 public: true 后已经可以上传,因此先保留能正常工作的 endpoint 和 region。
注意 YAML 缩进:service、endpoint 等字段必须缩进到 bucketname 下。如果实际文件中这些字段完全没有缩进,也需要修正。
在应用中选择这个服务,例如:
# config/environments/production.rb
config.active_storage.service = :bucketname
重启应用和使用 Active Storage 的后台任务进程,重新加载配置。
这段代码访问了 Rails 的内部实例变量 @upload_options。升级 Rails 后,应检查 S3 service 的实现,重新验证上传和 URL 生成是否正常。
仅添加下面的配置无法移除 ACL:
upload:
acl: null
因为在 Rails 7.2.3 中,public: true 会在初始化时将上传 ACL 设置为 public-read。
第三步:获取并验证无签名 URL
上传后,通过 blob 获取存储服务的 URL:
user = Users::User.last
user.avatar.attach(
io: img_io,
filename: "avatar.png",
content_type: "image/png"
)
blob = user.avatar.blob
blob.url
使用上述公开服务配置后,blob.url 应返回不含 S3 临时鉴权参数的对象 URL。选一个实际存在的对象地址,验证匿名访问:
curl -I 'https://bucketname.region.linodeobjects.com/实际对象key'
这个请求不携带凭证,应能成功读取对象元信息。也可以在未登录应用的浏览器中直接打开该地址。
这里的“不会过期”是指 URL 没有签名有效期。如果对象被删除、key 改变、访问策略收紧或域名失效,地址仍会不可用。
blob.url 和 url_for(user.avatar) 的用途也有区别:前者取得存储服务 URL,后者通常生成 Rails 的 Active Storage 路由,可能涉及重定向或代理。要保存或输出对象存储的直接公开地址,应使用对应 blob 的公开服务 URL。
写入、删除和已有文件需要注意什么?
公开读取不会自动开放写入或删除。应检查全部 policy 和 ACL,确保匿名主体没有 s3:PutObject、s3:DeleteObject 或其他不需要的权限,应用密钥也只保留业务所需权限。Active Storage 的清理操作需要删除对象,是否保留删除权限,应根据应用功能决定。
如果应用提供上传接口,接口本身仍需鉴权,并限制上传量。客户端通过应用获得上传授权后,仍可能消耗 bucket 空间,公开读取策略不会限制这类上传。
已有对象只要匹配公开读取 policy,且存储端没有其他限制,通常无需重新上传。修改 Rails 的 public 配置不会批量修改已有对象的 ACL。
如果不想维护自定义 service,也可以继续使用 public: false 上传,再单独构造公开对象 URL。只要 policy 允许匿名读取,就能通过该 URL 访问对象。不过,此时 Active Storage 的 blob.url 仍会按私有服务逻辑生成签名 URL,需要另行处理地址生成。
备注:内容仅供参考。
