mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-05 11:37:44 +01:00
* shell: accept ReadAcp and WriteAcp in per-bucket and service account actions s3.bucket.access, s3.anonymous.set and s3.serviceaccount.create rejected ReadAcp and WriteAcp, so the ACL actions the S3 gateway enforces could not be granted through them. Share one canonical action table across the three commands and list the ACL actions in their help. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: list ReadAcp and WriteAcp in s3.configure and s3.circuitBreaker help The -actions usage of both commands stopped at Admin, so the ACL actions the gateway enforces could only be found in the source. s3.circuitBreaker now prints the actions it actually accepts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: offer ReadAcp and WriteAcp in the object store user permissions The create and edit user forms only offered Read, Write, List, Tagging and Admin among the bucket permissions, so an identity could not be given the ACL actions from the UI even though uploads with x-amz-acl need WriteAcp. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: log the WriteAcp denial of an upload that sets an ACL An upload or copy that carries x-amz-acl or an x-amz-grant-* header also needs WriteAcp. When that check denies, the client only sees a bare AccessDenied and nothing reached the log at the default verbosity, so the header was never suspected. Log the identity and the missing action. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: quote the key in the ACL upload denial log An object key can carry a decoded newline, which let a caller add forged lines to the default log. Quote the bucket and key, and word the message so it also fits a policy deny, not only a missing grant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: say ACL uploads need both Write and WriteAcp The s3.configure example granted WriteAcp alone, which still fails the upload's Write check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: parse S3 action lists with one shared helper s3.bucket.access, s3.anonymous.set and s3.serviceaccount.create each had their own validation loop, so a test of the table did not cover the parsing the commands run. They now call canonicalActionList, which skips empty entries and repeats. s3.anonymous.set validates before contacting IAM, and s3.bucket.access still rejects a list with no actions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep bucket-scoped actions the user form cannot show When a user has both global and bucket-scoped actions, the edit form shows only the global ones, and saving replaced the identity's actions with what the form rebuilt, deleting the scoped grants. Carry those actions through the save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: list hidden bucket grants in the user edit form Restoring the bucket-scoped grants the form cannot show kept them active even when an admin narrowed the user's permissions, with no way to see or drop them. Show them as checked boxes under Other Bucket Permissions; unchecking one removes it on save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: reject an s3.anonymous.set -access list with no actions A blank list such as ",," parsed to nothing and cleared the bucket's anonymous grants. Use "none" for that, as s3.bucket.access does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * s3api: skip the ACL upload denial log for anonymous requests Anonymous uploads with ACL headers can be sent by anyone, so logging each denial at the default level lets unauthenticated clients flood the log. Keep the line for named identities. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: count kept bucket grants when checking an edited user for permissions The empty check ran before the checked Other Bucket Permissions were merged, so a user left with only those grants could not be saved. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: list bucket grants of Admin users in the user edit form parseBucketPermissions returned before reading scoped actions when the user had Admin, so removing Admin and saving dropped grants such as WriteAcp:bucket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep unpaired bucket grants as separate checkboxes in the user edit form The form saves every selected permission on every selected bucket, so grants such as ReadAcp:a with WriteAcp:b came back as all four pairs. Show the grants through the picker only when they cover that full product; otherwise list each grant on its own. Opening the form also resets the bucket scope left over from the previous user. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep grants the edit pickers have no option for A permission x bucket product was shown through the pickers even when a bucket was not in the bucket list (deleted, prefix or wildcard grants) or a permission had no picker option (DeleteBucket, PutBucketQuota). Saving then dropped those grants. Such grants now go to the checkbox list, global ones included, and are submitted unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: name the s3.bucket.access anonymous example by what it grants Read,Write,List leaves out ReadAcp and WriteAcp, so it is not full access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep unlisted global grants of Admin users in the user edit form parseBucketPermissions returned for Admin before splitting global grants into picker options and the rest, and only raw option values were re-selected. Saving an Admin user dropped s3tables:* and grants with no picker option such as DeleteBucket. Admin users now use the same split. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * admin: keep the /* suffix of bucket grants in the user edit form parseBucketPermissions dropped a trailing /* before matching bucket options, so saving rewrote ReadAcp:bucket/* as ReadAcp:bucket. The bare form also matches bucket-level requests such as GET ?acl, so the save widened the grant. A /* bucket is not a picker option, so the grant now stays in the checkbox list as written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * shell: test that s3.bucket.access saves ReadAcp and WriteAcp grants Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
263 lines
10 KiB
Go
263 lines
10 KiB
Go
package s3api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/aws/aws-sdk-go/service/s3"
|
|
"github.com/seaweedfs/seaweedfs/weed/glog"
|
|
"github.com/seaweedfs/seaweedfs/weed/pb/filer_pb"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
|
|
"github.com/seaweedfs/seaweedfs/weed/s3api/s3err"
|
|
)
|
|
|
|
// putObjectACLContextKey carries validated ACL metadata to each object creation
|
|
// path without exposing an internal header that a client could forge.
|
|
type putObjectACLContextKey struct{}
|
|
|
|
type putObjectACLMetadata struct {
|
|
extended map[string][]byte
|
|
canned string
|
|
}
|
|
|
|
// putObjectACLValue ignores unsigned V2 query ACLs and rejects ambiguity.
|
|
// Headers stay untouched because authentication verifies the original request.
|
|
func putObjectACLValue(r *http.Request, query url.Values, header string) (string, s3err.ErrorCode) {
|
|
// Preserve SigV2's header-only ACL behavior: arbitrary query parameters
|
|
// are not in its canonical resource and must have no effect on grants.
|
|
switch getRequestAuthType(r) {
|
|
case authTypeSignedV2, authTypePresignedV2:
|
|
query = nil
|
|
}
|
|
var queryValues []string
|
|
queryPresent := false
|
|
for key, values := range query {
|
|
if strings.EqualFold(key, header) {
|
|
queryPresent = true
|
|
queryValues = append(queryValues, values...)
|
|
}
|
|
}
|
|
if queryPresent {
|
|
if len(queryValues) != 1 {
|
|
// V4 sorts duplicate values when signing. Choosing the first value
|
|
// would let reordering change the effective ACL without resigning.
|
|
return "", s3err.ErrInvalidRequest
|
|
}
|
|
}
|
|
values := r.Header.Values(header)
|
|
if header == s3_constants.AmzCannedAcl && len(values) > 1 {
|
|
return "", s3err.ErrInvalidRequest
|
|
}
|
|
value := strings.Join(values, ",")
|
|
if queryPresent {
|
|
if len(values) > 0 && value != queryValues[0] {
|
|
return "", s3err.ErrInvalidRequest
|
|
}
|
|
value = queryValues[0]
|
|
}
|
|
return value, s3err.ErrNone
|
|
}
|
|
|
|
// putObjectACLPolicyRequest exposes upload and copy ACLs to policy conditions only
|
|
// after authentication. Other operations keep their original request semantics.
|
|
func putObjectACLPolicyRequest(r *http.Request, action Action, bucket, object string) (*http.Request, s3err.ErrorCode) {
|
|
if (action != s3_constants.ACTION_WRITE && action != s3_constants.ACTION_WRITE_ACP) ||
|
|
r.Method != http.MethodPut || object == "" || object == "/" ||
|
|
ResolveS3Action(r, string(s3_constants.ACTION_WRITE), bucket, object) != s3_constants.S3_ACTION_PUT_OBJECT {
|
|
return r, s3err.ErrNone
|
|
}
|
|
// Rechecks reuse the normalized internal request, preserving signed original values without false query conflicts.
|
|
if len(policy_engine.OriginalGrantConditionsFromRequest(r)) != 0 {
|
|
return r, s3err.ErrNone
|
|
}
|
|
policyRequest := r.Clone(r.Context())
|
|
query := parseRequestQuery(r)
|
|
originalGrants := make(map[string][]string)
|
|
for _, header := range []string{s3_constants.AmzCannedAcl, s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
|
|
value, code := putObjectACLValue(r, query, header)
|
|
if code != s3err.ErrNone {
|
|
return r, code
|
|
}
|
|
if value == "" {
|
|
continue
|
|
}
|
|
if header == s3_constants.AmzCannedAcl {
|
|
policyRequest.Header.Set(header, value)
|
|
continue
|
|
}
|
|
// Preserve only the complete effective list for original-string denies, not individual header lines as separate lists.
|
|
originalGrants["s3:"+strings.ToLower(header)] = []string{value}
|
|
// Policy conditions see the canonical grant list: one comma-separated
|
|
// value covering every persisted grantee, identical for a single line,
|
|
// repeated lines, or a signed query parameter. Sneaking an extra grantee
|
|
// past a StringEquals allow or a StringNotEquals allowlist deny requires
|
|
// changing this value, which a signed request cannot do.
|
|
pairs, pairCode := parseAclGranteePairs(value)
|
|
if pairCode != s3err.ErrNone {
|
|
return r, pairCode
|
|
}
|
|
var tokens []string
|
|
for _, pair := range pairs {
|
|
// Grant conditions use JSON quoting without HTML escaping, so valid
|
|
// literal characters in an account or email still match the policy.
|
|
var encoded strings.Builder
|
|
encoder := json.NewEncoder(&encoded)
|
|
encoder.SetEscapeHTML(false)
|
|
if err := encoder.Encode(pair[1]); err != nil {
|
|
return r, s3err.ErrInvalidRequest
|
|
}
|
|
tokens = append(tokens, pair[0]+"="+strings.TrimSuffix(encoded.String(), "\n"))
|
|
}
|
|
policyRequest.Header.Set(header, strings.Join(tokens, ","))
|
|
}
|
|
if len(originalGrants) != 0 {
|
|
policyRequest = policy_engine.WithOriginalGrantConditions(policyRequest, originalGrants)
|
|
}
|
|
return policyRequest, s3err.ErrNone
|
|
}
|
|
|
|
// preparePutObjectACL validates and authorizes ACLs before upload or copy data
|
|
// is written. The metadata is committed in the same entry as the new object.
|
|
func (s3a *S3ApiServer) preparePutObjectACL(r *http.Request, bucket string) (*http.Request, s3err.ErrorCode) {
|
|
metadata, code := s3a.getBucketConfig(bucket)
|
|
if code != s3err.ErrNone {
|
|
return r, code
|
|
}
|
|
if metadata == nil || s3a.iam == nil {
|
|
return r, s3err.ErrInternalError
|
|
}
|
|
|
|
// Presigners can hoist ACL headers into the signed query string. Normalize a
|
|
// separate request for parsing, preserving the original for signature checks.
|
|
aclRequest := r.Clone(r.Context())
|
|
query := parseRequestQuery(r)
|
|
custom := false
|
|
for _, header := range []string{s3_constants.AmzAclFullControl, s3_constants.AmzAclRead, s3_constants.AmzAclReadAcp, s3_constants.AmzAclWrite, s3_constants.AmzAclWriteAcp} {
|
|
value, code := putObjectACLValue(r, query, header)
|
|
if code != s3err.ErrNone {
|
|
return r, code
|
|
}
|
|
if value != "" {
|
|
custom = true
|
|
aclRequest.Header.Set(header, value)
|
|
}
|
|
}
|
|
canned, code := putObjectACLValue(r, query, s3_constants.AmzCannedAcl)
|
|
if code != s3err.ErrNone {
|
|
return r, code
|
|
}
|
|
aclRequest.Header.Set(s3_constants.AmzCannedAcl, canned)
|
|
explicit := canned != "" || custom
|
|
accountID := s3a.requestAccountId(r)
|
|
if explicit && s3a.iam.isEnabled() {
|
|
// Setting an ACL during upload or copy also requires s3:PutObjectAcl. Use the
|
|
// unified authorization path so bucket-policy allows and explicit denies
|
|
// retain the same semantics as standalone ACL requests.
|
|
identity, authCode, _ := s3a.iam.authRequestWithAuthType(r.Clone(r.Context()), s3_constants.ACTION_WRITE_ACP)
|
|
if authCode != s3err.ErrNone {
|
|
if authCode == s3err.ErrAccessDenied && identity != nil && identity.Name != s3_constants.AccountAnonymousId {
|
|
_, object := s3_constants.GetBucketAndObject(r)
|
|
glog.Infof("denied %s %q for %q: the request sets an ACL and failed the %s (s3:PutObjectAcl) check", r.Method, bucket+"/"+object, identity.Name, s3_constants.ACTION_WRITE_ACP)
|
|
}
|
|
return r, authCode
|
|
}
|
|
if identity == nil || identity.Account == nil {
|
|
return r, s3err.ErrAccessDenied
|
|
}
|
|
accountID = identity.Account.Id
|
|
}
|
|
if explicit && !s3a.iam.isEnabled() {
|
|
_, object := s3_constants.GetBucketAndObject(r)
|
|
policyRequest, policyCode := putObjectACLPolicyRequest(r, s3_constants.ACTION_WRITE, bucket, object)
|
|
if policyCode != s3err.ErrNone {
|
|
return r, policyCode
|
|
}
|
|
for _, action := range []Action{s3_constants.ACTION_WRITE, s3_constants.ACTION_WRITE_ACP} {
|
|
if policyCode, _ := s3a.checkPolicyWithEntry(policyRequest, bucket, object, string(action), "", nil); policyCode != s3err.ErrNone {
|
|
return r, policyCode
|
|
}
|
|
}
|
|
}
|
|
if accountID == "" {
|
|
return r, s3err.ErrAccessDenied
|
|
}
|
|
if canned != "" && custom {
|
|
return r, s3err.ErrInvalidRequest
|
|
}
|
|
|
|
bucketOwner := metadata.Owner
|
|
if bucketOwner == "" {
|
|
// Buckets created outside S3 can have no recorded owner, matching the
|
|
// bucket registry's existing admin fallback for these entries.
|
|
bucketOwner = AccountAdmin.Id
|
|
}
|
|
ownership := s3_constants.EffectiveOwnership(metadata.Ownership)
|
|
if ownership == s3_constants.OwnershipBucketOwnerEnforced {
|
|
if metadata.Ownership == s3_constants.OwnershipBucketOwnerEnforced {
|
|
// Keep legacy buckets without recorded ownership controls accepting
|
|
// ACLs; only an explicitly configured enforced control disables them.
|
|
if custom || (canned != "" && canned != s3_constants.CannedAclBucketOwnerFullControl) {
|
|
return r, s3err.ErrAccessControlListNotSupported
|
|
}
|
|
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
|
|
}
|
|
accountID = bucketOwner
|
|
}
|
|
if aclRequest.Header.Get(s3_constants.AmzCannedAcl) == "" && !custom {
|
|
aclRequest.Header.Set(s3_constants.AmzCannedAcl, s3_constants.CannedAclPrivate)
|
|
}
|
|
// Canned grants contain only authenticated writer and recorded bucket-owner
|
|
// IDs. Dynamic IAM/JWT accounts need not exist in the static account directory.
|
|
// Client-supplied custom grantees must still pass directory validation.
|
|
owner, grants, code := ParseAclHeaders(aclRequest, ownership, bucketOwner, accountID, false)
|
|
if code == s3err.ErrNone && custom {
|
|
grants, code = ValidateAndTransferGrants(s3a.iam, grants)
|
|
}
|
|
if code == s3err.ErrNone {
|
|
code = rejectPublicGrants(metadata, grants)
|
|
}
|
|
if code != s3err.ErrNone {
|
|
return r, code
|
|
}
|
|
if custom {
|
|
// Custom upload grants supplement the owner's default full control.
|
|
// Check after email resolution to avoid duplicating an explicit owner
|
|
// grant; the authenticated owner need not be in the static directory.
|
|
ownerFullControl := false
|
|
for _, grant := range grants {
|
|
if grant.Grantee != nil && grant.Grantee.Type != nil &&
|
|
*grant.Grantee.Type == s3_constants.GrantTypeCanonicalUser &&
|
|
grant.Grantee.ID != nil && *grant.Grantee.ID == owner &&
|
|
grant.Permission != nil && *grant.Permission == s3_constants.PermissionFullControl {
|
|
ownerFullControl = true
|
|
break
|
|
}
|
|
}
|
|
if !ownerFullControl {
|
|
grants = append(grants, &s3.Grant{
|
|
Grantee: &s3.Grantee{Type: &s3_constants.GrantTypeCanonicalUser, ID: &owner},
|
|
Permission: &s3_constants.PermissionFullControl,
|
|
})
|
|
}
|
|
}
|
|
entry := &filer_pb.Entry{}
|
|
if code = AssembleEntryWithAcp(entry, owner, grants); code != s3err.ErrNone {
|
|
return r, code
|
|
}
|
|
prepared := putObjectACLMetadata{extended: entry.Extended, canned: canned}
|
|
return r.WithContext(context.WithValue(r.Context(), putObjectACLContextKey{}, prepared)), s3err.ErrNone
|
|
}
|
|
|
|
// applyPutObjectACL adds prevalidated ownership and grants before CreateEntry.
|
|
// Multipart parts and POST form uploads do not carry this PutObject context.
|
|
func applyPutObjectACL(r *http.Request, entry *filer_pb.Entry) {
|
|
metadata, _ := r.Context().Value(putObjectACLContextKey{}).(putObjectACLMetadata)
|
|
for key, value := range metadata.extended {
|
|
entry.Extended[key] = value
|
|
}
|
|
}
|