diff --git a/README.md b/README.md index 73ed3ce53..7aff82aa8 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,14 @@ $ aws s3api delete-object --bucket %BUCKET_NAME --key %FILE_NAME Reference: * [AWS S3 API Reference](https://docs.aws.amazon.com/AmazonS3/latest/API/s3-api.pdf) +### Limitations +#### ACL +For now there are some restrictions: +* [Bucket policy](https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-policies.html) + support only one `Principal` (type `AWS`) per `Statement`. To refer all users use `"AWS": "*"` +* AWS conditions and wildcard are not supported in [resources](https://docs.aws.amazon.com/AmazonS3/latest/userguide/s3-arn-format.html) +* Only `CanonicalUser` (with hex encoded public key) and `All Users Group` are supported in [ACL](https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html) + ### Object | Method | Status | @@ -469,8 +477,8 @@ Reference: | Method | Status | | ------------------------- | ----------------------- | -| GetObjectAcl | Unsupported | -| PutObjectAcl | Unsupported | +| GetObjectAcl | Supported | +| PutObjectAcl | Supported | #### Locking @@ -540,8 +548,8 @@ See also `GetObject` and other method parameters. | Method | Status | | ------------------------- | ----------------------- | -| GetBucketAcl | Unsupported | -| PutBucketAcl | Unsupported | +| GetBucketAcl | Supported | +| PutBucketAcl | Supported | #### Analytics @@ -630,11 +638,11 @@ See also `GetObject` and other method parameters. | DeleteBucketPolicy | Unsupported | | DeleteBucketReplication | Unsupported | | DeletePublicAccessBlock | Unsupported | -| GetBucketPolicy | Unsupported | +| GetBucketPolicy | Supported | | GetBucketPolicyStatus | Unsupported | | GetBucketReplication | Unsupported | | PostPolicyBucket | Unsupported, non-standard? | -| PutBucketPolicy | Unsupported | +| PutBucketPolicy | Supported | | PutBucketReplication | Unsupported | #### Request payment diff --git a/api/errors/errors.go b/api/errors/errors.go index 7abbf83e0..24424a6bc 100644 --- a/api/errors/errors.go +++ b/api/errors/errors.go @@ -298,6 +298,7 @@ const ( ErrEvaluatorInvalidTimestampFormatPatternSymbol ErrEvaluatorBindingDoesNotExist ErrMissingHeaders + ErrInvalidArgument ErrInvalidColumnIndex ErrAdminConfigNotificationTargetsFailed @@ -1838,6 +1839,12 @@ var errorCodes = errorCodeMap{ Description: "Some headers in the query are missing from the file. Check the file and try again.", HTTPStatusCode: http.StatusBadRequest, }, + ErrInvalidArgument: { + ErrCode: ErrInvalidArgument, + Code: "InvalidArgument", + Description: "The specified argument was invalid.", + HTTPStatusCode: http.StatusBadRequest, + }, ErrInvalidColumnIndex: { ErrCode: ErrInvalidColumnIndex, Code: "InvalidColumnIndex", diff --git a/api/handler/acl.go b/api/handler/acl.go new file mode 100644 index 000000000..2131e0ca4 --- /dev/null +++ b/api/handler/acl.go @@ -0,0 +1,1158 @@ +package handler + +import ( + "crypto/ecdsa" + "encoding/hex" + "encoding/json" + "encoding/xml" + "fmt" + "net/http" + "strings" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neofs-api-go/pkg/acl/eacl" + "github.com/nspcc-dev/neofs-api-go/pkg/object" + "github.com/nspcc-dev/neofs-s3-gw/api" + "github.com/nspcc-dev/neofs-s3-gw/api/errors" + "github.com/nspcc-dev/neofs-s3-gw/api/layer" +) + +var ( + writeOps = []eacl.Operation{eacl.OperationPut, eacl.OperationDelete} + readOps = []eacl.Operation{eacl.OperationGet, eacl.OperationHead, + eacl.OperationSearch, eacl.OperationRange, eacl.OperationRangeHash} + fullOps = []eacl.Operation{eacl.OperationGet, eacl.OperationHead, eacl.OperationPut, + eacl.OperationDelete, eacl.OperationSearch, eacl.OperationRange, eacl.OperationRangeHash} +) + +var actionToOpMap = map[string][]eacl.Operation{ + s3DeleteObject: {eacl.OperationDelete}, + s3GetObject: readOps, + s3PutObject: {eacl.OperationPut}, + s3ListBucket: readOps, +} + +const ( + arnAwsPrefix = "arn:aws:s3:::" + allUsersWildcard = "*" + allUsersGroup = "http://acs.amazonaws.com/groups/global/AllUsers" + + s3DeleteObject = "s3:DeleteObject" + s3GetObject = "s3:GetObject" + s3PutObject = "s3:PutObject" + s3ListBucket = "s3:ListBucket" + s3ListBucketVersions = "s3:ListBucketVersions" + s3ListBucketMultipartUploads = "s3:ListBucketMultipartUploads" + s3GetObjectVersion = "s3:GetObjectVersion" +) + +// AWSACL is aws permission constants. +type AWSACL string + +const ( + aclFullControl AWSACL = "FULL_CONTROL" + aclWrite AWSACL = "WRITE" + aclRead AWSACL = "READ" +) + +// GranteeType is aws grantee permission type constants. +type GranteeType string + +const ( + acpCanonicalUser GranteeType = "CanonicalUser" + acpAmazonCustomerByEmail GranteeType = "AmazonCustomerByEmail" + acpGroup GranteeType = "Group" +) + +type bucketPolicy struct { + Version string `json:"Version"` + ID string `json:"Id"` + Statement []statement `json:"Statement"` +} + +type statement struct { + Sid string `json:"Sid"` + Effect string `json:"Effect"` + Principal principal `json:"Principal"` + Action []string `json:"Action"` + Resource []string `json:"Resource"` +} + +type principal struct { + AWS string `json:"AWS,omitempty"` + CanonicalUser string `json:"CanonicalUser,omitempty"` +} + +type ast struct { + Resources []*astResource +} + +type astResource struct { + Name string + Operations []*astOperation +} + +type astOperation struct { + Users []string + Role eacl.Role + Op eacl.Operation + Action eacl.Action +} + +func (h *handler) GetBucketACLHandler(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + + bucketACL, err := h.obj.GetBucketACL(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not fetch bucket acl", reqInfo, err) + return + } + + if err := checkOwner(bucketACL.Info, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + h.logAndSendError(w, "expected owner doesn't match", reqInfo, err) + return + } + + if err = api.EncodeToResponse(w, h.encodeBucketACL(bucketACL)); err != nil { + h.logAndSendError(w, "something went wrong", reqInfo, err) + } +} + +func (h *handler) PutBucketACLHandler(w http.ResponseWriter, r *http.Request) { + var ( + err error + reqInfo = api.GetReqInfo(r.Context()) + ) + + list := &AccessControlPolicy{} + if r.ContentLength == 0 { + list, err = parseACLHeaders(r) + if err != nil { + h.logAndSendError(w, "could not parse bucket acl", reqInfo, err) + return + } + } else if err := xml.NewDecoder(r.Body).Decode(list); err != nil { + h.logAndSendError(w, "could not parse bucket acl", reqInfo, err) + return + } + + list.Resource = reqInfo.BucketName + list.IsBucket = true + + bktPolicy, err := aclToPolicy(list) + if err != nil { + h.logAndSendError(w, "could not translate acl to policy", reqInfo, err) + return + } + + if err = h.updateBucketACL(r, bktPolicy, reqInfo.BucketName); err != nil { + h.logAndSendError(w, "could not update bucket acl", reqInfo, err) + return + } +} + +func (h *handler) updateBucketACL(r *http.Request, bktPolicy *bucketPolicy, bkt string) error { + astChild, err := policyToAst(bktPolicy) + if err != nil { + return fmt.Errorf("could not translate policy to ast: %w", err) + } + + bucketACL, err := h.obj.GetBucketACL(r.Context(), bkt) + if err != nil { + return fmt.Errorf("could not get bucket eacl: %w", err) + } + + if err = checkOwner(bucketACL.Info, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + return fmt.Errorf("expected owner doesn't match: %w", err) + } + + parentAst := tableToAst(bucketACL.EACL, bkt) + for _, resource := range parentAst.Resources { + if resource.Name == bucketACL.Info.CID.String() { + resource.Name = bkt + } + } + + resAst, updated := mergeAst(parentAst, astChild) + if !updated { + return nil + } + + table, err := astToTable(resAst, bkt) + if err != nil { + return fmt.Errorf("could not translate ast to table: %w", err) + } + + p := &layer.PutBucketACLParams{ + Name: bkt, + EACL: table, + } + + if err = h.obj.PutBucketACL(r.Context(), p); err != nil { + return fmt.Errorf("could not put bucket acl: %w", err) + } + + return nil +} + +func (h *handler) GetObjectACLHandler(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + bucketACL, err := h.obj.GetBucketACL(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not fetch bucket acl", reqInfo, err) + return + } + + if err := checkOwner(bucketACL.Info, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + h.logAndSendError(w, "expected owner doesn't match", reqInfo, err) + return + } + + if err = api.EncodeToResponse(w, h.encodeObjectACL(bucketACL, reqInfo.ObjectName)); err != nil { + h.logAndSendError(w, "something went wrong", reqInfo, err) + } +} + +func (h *handler) PutObjectACLHandler(w http.ResponseWriter, r *http.Request) { + var ( + err error + reqInfo = api.GetReqInfo(r.Context()) + ) + + list := &AccessControlPolicy{} + if r.ContentLength == 0 { + list, err = parseACLHeaders(r) + if err != nil { + h.logAndSendError(w, "could not parse bucket acl", reqInfo, err) + return + } + } else if err := xml.NewDecoder(r.Body).Decode(list); err != nil { + h.logAndSendError(w, "could not parse bucket acl", reqInfo, err) + return + } + + list.Resource = reqInfo.BucketName + "/" + reqInfo.ObjectName + + bktPolicy, err := aclToPolicy(list) + if err != nil { + h.logAndSendError(w, "could not translate acl to policy", reqInfo, err) + return + } + + if _, err = h.obj.GetObjectInfo(r.Context(), reqInfo.BucketName, reqInfo.ObjectName); err != nil { + h.logAndSendError(w, "could not get object info", reqInfo, err) + return + } + + if err = h.updateBucketACL(r, bktPolicy, reqInfo.BucketName); err != nil { + h.logAndSendError(w, "could not update bucket acl", reqInfo, err) + return + } +} + +func (h *handler) GetBucketPolicyHandler(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + bucketACL, err := h.obj.GetBucketACL(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not fetch bucket acl", reqInfo, err) + return + } + + if err = checkOwner(bucketACL.Info, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + h.logAndSendError(w, "expected owner doesn't match", reqInfo, err) + return + } + + ast := tableToAst(bucketACL.EACL, reqInfo.BucketName) + bktPolicy := astToPolicy(ast) + + w.WriteHeader(http.StatusOK) + + if err = json.NewEncoder(w).Encode(bktPolicy); err != nil { + h.logAndSendError(w, "something went wrong", reqInfo, err) + } +} + +func checkOwner(info *layer.BucketInfo, owner string) error { + if owner == "" { + return nil + } + + // maybe need to convert owner to appropriate format + if info.Owner.String() != owner { + return errors.GetAPIError(errors.ErrAccessDenied) + } + + return nil +} + +func (h *handler) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Request) { + reqInfo := api.GetReqInfo(r.Context()) + bktPolicy := &bucketPolicy{} + if err := json.NewDecoder(r.Body).Decode(bktPolicy); err != nil { + h.logAndSendError(w, "could not parse bucket policy", reqInfo, err) + return + } + + if err := h.updateBucketACL(r, bktPolicy, reqInfo.BucketName); err != nil { + h.logAndSendError(w, "could not update bucket acl", reqInfo, err) + return + } +} + +func parseACLHeaders(r *http.Request) (*AccessControlPolicy, error) { + var err error + box, err := layer.GetBoxData(r.Context()) + if err != nil { + return nil, err + } else if box.Gate.GateKey == nil { + return nil, fmt.Errorf("gate key must not be nil") + } + gateKey := box.Gate.GateKey + + acp := &AccessControlPolicy{Owner: Owner{ + ID: hex.EncodeToString(gateKey.Bytes()), + DisplayName: gateKey.Address(), + }} + acp.AccessControlList = []*Grant{{ + Grantee: &Grantee{ + ID: hex.EncodeToString(gateKey.Bytes()), + DisplayName: gateKey.Address(), + Type: acpCanonicalUser, + }, + Permission: aclFullControl, + }} + + cannedACL := r.Header.Get(api.AmzACL) + if cannedACL != "" { + return addPredefinedACP(acp, cannedACL) + } + + if acp.AccessControlList, err = addGrantees(acp.AccessControlList, r.Header, api.AmzGrantFullControl); err != nil { + return nil, err + } + if acp.AccessControlList, err = addGrantees(acp.AccessControlList, r.Header, api.AmzGrantRead); err != nil { + return nil, err + } + if acp.AccessControlList, err = addGrantees(acp.AccessControlList, r.Header, api.AmzGrantWrite); err != nil { + return nil, err + } + + return acp, nil +} + +func addGrantees(list []*Grant, headers http.Header, hdr string) ([]*Grant, error) { + grant := headers.Get(hdr) + if grant == "" { + return list, nil + } + + permission, err := grantHdrToPermission(hdr) + if err != nil { + return nil, err + } + + grantees, err := parseGrantee(grant) + if err != nil { + return nil, err + } + + for _, grantee := range grantees { + if grantee.Type == acpAmazonCustomerByEmail || (grantee.Type == acpGroup && grantee.URI != allUsersGroup) { + return nil, fmt.Errorf("unsupported grantee: %v", grantee) + } + + list = append(list, &Grant{ + Grantee: grantee, + Permission: permission, + }) + } + return list, nil +} + +func grantHdrToPermission(grant string) (AWSACL, error) { + switch grant { + case api.AmzGrantFullControl: + return aclFullControl, nil + case api.AmzGrantRead: + return aclRead, nil + case api.AmzGrantWrite: + return aclWrite, nil + } + return "", fmt.Errorf("unsuppoted header: %s", grant) +} + +func parseGrantee(grantees string) ([]*Grantee, error) { + var result []*Grantee + + split := strings.Split(grantees, ", ") + for _, pair := range split { + split2 := strings.Split(pair, "=") + if len(split2) != 2 { + return nil, errors.GetAPIError(errors.ErrInvalidArgument) + } + + grantee, err := formGrantee(split2[0], split2[1]) + if err != nil { + return nil, err + } + result = append(result, grantee) + } + + return result, nil +} + +func formGrantee(granteeType, value string) (*Grantee, error) { + value = strings.Trim(value, "\"") + switch granteeType { + case "id": + return &Grantee{ + ID: value, + Type: acpCanonicalUser, + }, nil + case "uri": + return &Grantee{ + URI: value, + Type: acpGroup, + }, nil + case "emailAddress": + return &Grantee{ + EmailAddress: value, + Type: acpAmazonCustomerByEmail, + }, nil + } + + return nil, fmt.Errorf("unknown type: %s", granteeType) +} + +func addPredefinedACP(acp *AccessControlPolicy, cannedACL string) (*AccessControlPolicy, error) { + switch cannedACL { + case basicACLPrivate: + case basicACLPublic: + acp.AccessControlList = append(acp.AccessControlList, &Grant{ + Grantee: &Grantee{ + URI: allUsersGroup, + Type: acpGroup, + }, + Permission: aclFullControl, + }) + case cannedACLAuthRead: + fallthrough + case basicACLReadOnly: + acp.AccessControlList = append(acp.AccessControlList, &Grant{ + Grantee: &Grantee{ + URI: allUsersGroup, + Type: acpGroup, + }, + Permission: aclRead, + }) + default: + return nil, errors.GetAPIError(errors.ErrInvalidArgument) + } + + return acp, nil +} + +func tableToAst(table *eacl.Table, bktName string) *ast { + res := &ast{} + rr := make(map[string]*astResource) + + for _, record := range table.Records() { + resname := bktName + for _, filter := range record.Filters() { + if filter.Matcher() == eacl.MatchStringEqual && filter.Key() == object.AttributeFileName { + resname = filter.Value() + } + } + r, ok := rr[resname] + if !ok { + r = &astResource{ + Name: resname, + } + } + for _, target := range record.Targets() { + r.Operations = addToList(r.Operations, record, target) + } + rr[resname] = r + } + + for _, val := range rr { + res.Resources = append(res.Resources, val) + } + + return res +} + +func mergeAst(parent, child *ast) (*ast, bool) { + updated := false + for _, resource := range child.Resources { + parentResource := getParentResource(parent, resource.Name) + if parentResource == nil { + parent.Resources = append(parent.Resources, resource) + updated = true + continue + } + + var newOps []*astOperation + for _, astOp := range resource.Operations { + ops := getAstOps(parentResource, astOp) + switch len(ops) { + case 2: + // potential inconsistency + if astOp.Role == eacl.RoleOthers { + // it is not likely (such state must be detected early) + // inconsistency + action := eacl.ActionAllow + if astOp.Action == eacl.ActionAllow { + action = eacl.ActionDeny + } + removeAstOp(parentResource, astOp.Role, astOp.Op, action) + updated = true + continue + } + + opToAdd, opToDelete := ops[0], ops[1] + if ops[1].Action == astOp.Action { + opToAdd, opToDelete = ops[1], ops[0] + } + + if handleAddOperations(parentResource, astOp, opToAdd) { + updated = true + } + if handleRemoveOperations(parentResource, astOp, opToDelete) { + updated = true + } + if opToDelete.Role == eacl.RoleUser && len(opToDelete.Users) == 0 { + removeAstOp(parentResource, opToDelete.Role, opToDelete.Op, opToDelete.Action) + } + case 1: + if astOp.Action != ops[0].Action { + // potential inconsistency + if astOp.Role == eacl.RoleOthers { + // inconsistency + removeAstOp(parentResource, astOp.Role, astOp.Op, ops[0].Action) + parentResource.Operations = append(parentResource.Operations, astOp) + updated = true + continue + } + + if handleRemoveOperations(parentResource, astOp, ops[0]) { + updated = true + } + if ops[0].Role == eacl.RoleUser && len(ops[0].Users) == 0 { + removeAstOp(parentResource, ops[0].Role, ops[0].Op, ops[0].Action) + } + parentResource.Operations = append(parentResource.Operations, astOp) + continue + } + + if handleAddOperations(parentResource, astOp, ops[0]) { + updated = true + } + case 0: + newOps = append(newOps, astOp) + updated = true + } + } + + if newOps != nil { + parentResource.Operations = append(newOps, parentResource.Operations...) + } + } + + return parent, updated +} + +func handleAddOperations(parentResource *astResource, astOp, existedOp *astOperation) bool { + var needToAdd []string + for _, user := range astOp.Users { + if !containsStr(existedOp.Users, user) { + needToAdd = append(needToAdd, user) + } + } + if len(needToAdd) != 0 { + addUsers(parentResource, existedOp, needToAdd) + return true + } + return false +} + +func handleRemoveOperations(parentResource *astResource, astOp, existedOp *astOperation) bool { + var needToRemove []string + for _, user := range astOp.Users { + if containsStr(existedOp.Users, user) { + needToRemove = append(needToRemove, user) + } + } + if len(needToRemove) != 0 { + removeUsers(parentResource, existedOp, needToRemove) + return true + } + + return false +} + +func containsStr(list []string, element string) bool { + for _, str := range list { + if str == element { + return true + } + } + return false +} + +func getAstOps(resource *astResource, childOp *astOperation) []*astOperation { + var res []*astOperation + for _, astOp := range resource.Operations { + if astOp.Role == childOp.Role && astOp.Op == childOp.Op { + res = append(res, astOp) + } + } + return res +} + +func removeAstOp(resource *astResource, role eacl.Role, op eacl.Operation, action eacl.Action) { + for i, astOp := range resource.Operations { + if astOp.Role == role && astOp.Op == op && astOp.Action == action { + resource.Operations = append(resource.Operations[:i], resource.Operations[i+1:]...) + return + } + } +} + +func addUsers(resource *astResource, astO *astOperation, users []string) { + for _, astOp := range resource.Operations { + if astOp.Role == astO.Role && astOp.Op == astO.Op && astOp.Action == astO.Action { + astOp.Users = append(astO.Users, users...) + return + } + } +} + +func removeUsers(resource *astResource, astOperation *astOperation, users []string) { + for _, astOp := range resource.Operations { + if astOp.Role == astOperation.Role && astOp.Op == astOperation.Op && astOp.Action == astOperation.Action { + ind := 0 + for _, user := range astOp.Users { + if containsStr(users, user) { + astOp.Users = append(astOp.Users[:ind], astOp.Users[ind+1:]...) + } else { + ind++ + } + } + return + } + } +} + +func getParentResource(parent *ast, resource string) *astResource { + for _, parentResource := range parent.Resources { + if resource == parentResource.Name { + return parentResource + } + } + return nil +} + +func astToTable(ast *ast, bkt string) (*eacl.Table, error) { + table := eacl.NewTable() + + for _, resource := range ast.Resources { + records, err := formRecords(resource.Operations, resource.Name, bkt) + if err != nil { + return nil, err + } + for _, rec := range records { + table.AddRecord(rec) + } + } + + return table, nil +} + +func formRecords(operations []*astOperation, resource, bkt string) ([]*eacl.Record, error) { + var res []*eacl.Record + + for _, astOp := range operations { + record := eacl.NewRecord() + record.SetOperation(astOp.Op) + record.SetAction(astOp.Action) + if astOp.Role == eacl.RoleOthers { + eacl.AddFormedTarget(record, eacl.RoleOthers) + } else { + for _, user := range astOp.Users { + pk, err := keys.NewPublicKeyFromString(user) + if err != nil { + return nil, err + } + eacl.AddFormedTarget(record, eacl.RoleUser, (ecdsa.PublicKey)(*pk)) + } + } + if resource != bkt { + trimmedName := strings.TrimPrefix(resource, bkt+"/") + record.AddObjectAttributeFilter(eacl.MatchStringEqual, object.AttributeFileName, trimmedName) + } + res = append(res, record) + } + + return res, nil +} + +func addToList(operations []*astOperation, rec *eacl.Record, target *eacl.Target) []*astOperation { + var found *astOperation + for _, astOp := range operations { + if astOp.Op == rec.Operation() && astOp.Role == target.Role() { + found = astOp + } + } + + if found != nil { + if target.Role() == eacl.RoleUser { + for _, key := range target.BinaryKeys() { + found.Users = append(found.Users, hex.EncodeToString(key)) + } + } + } else { + astOperation := &astOperation{ + Role: target.Role(), + Op: rec.Operation(), + Action: rec.Action(), + } + if target.Role() == eacl.RoleUser { + for _, key := range target.BinaryKeys() { + astOperation.Users = append(astOperation.Users, hex.EncodeToString(key)) + } + } + + operations = append(operations, astOperation) + } + + return operations +} + +func policyToAst(bktPolicy *bucketPolicy) (*ast, error) { + res := &ast{} + + rr := make(map[string]*astResource) + + for _, state := range bktPolicy.Statement { + if state.Principal.AWS != "" && state.Principal.AWS != allUsersWildcard || + state.Principal.AWS == "" && state.Principal.CanonicalUser == "" { + return nil, fmt.Errorf("unsupported principal: %v", state.Principal) + } + role := eacl.RoleUser + if state.Principal.AWS == allUsersWildcard { + role = eacl.RoleOthers + } + + for _, resource := range state.Resource { + trimmedResource := strings.TrimPrefix(resource, arnAwsPrefix) + r, ok := rr[trimmedResource] + if !ok { + r = &astResource{ + Name: trimmedResource, + } + } + for _, action := range state.Action { + for _, op := range actionToOpMap[action] { + toAction := effectToAction(state.Effect) + r.Operations = addTo(r.Operations, state, op, role, toAction) + } + } + + rr[trimmedResource] = r + } + } + + for _, val := range rr { + res.Resources = append(res.Resources, val) + } + + return res, nil +} + +func astToPolicy(ast *ast) *bucketPolicy { + bktPolicy := &bucketPolicy{} + + for _, resource := range ast.Resources { + allowed, denied := triageOperations(resource.Operations) + handleResourceOperations(bktPolicy, allowed, eacl.ActionAllow, resource.Name) + handleResourceOperations(bktPolicy, denied, eacl.ActionDeny, resource.Name) + } + + return bktPolicy +} + +func handleResourceOperations(bktPolicy *bucketPolicy, list []*astOperation, eaclAction eacl.Action, resourceName string) { + userOpsMap := make(map[string][]eacl.Operation) + + for _, op := range list { + if op.Role == eacl.RoleUser { + for _, user := range op.Users { + userOps := userOpsMap[user] + userOps = append(userOps, op.Op) + userOpsMap[user] = userOps + } + } else { + userOps := userOpsMap[allUsersGroup] + userOps = append(userOps, op.Op) + userOpsMap[allUsersGroup] = userOps + } + } + + for user, userOps := range userOpsMap { + var actions []string + LOOP: + for action, ops := range actionToOpMap { + for _, op := range ops { + if !contains(userOps, op) { + break LOOP + } + } + actions = append(actions, action) + } + if len(actions) != 0 { + state := statement{ + Effect: actionToEffect(eaclAction), + Principal: principal{CanonicalUser: user}, + Action: actions, + Resource: []string{arnAwsPrefix + resourceName}, + } + if user == allUsersGroup { + state.Principal = principal{AWS: allUsersWildcard} + } + bktPolicy.Statement = append(bktPolicy.Statement, state) + } + } +} + +func triageOperations(operations []*astOperation) ([]*astOperation, []*astOperation) { + var allowed, denied []*astOperation + for _, op := range operations { + if op.Action == eacl.ActionAllow { + allowed = append(allowed, op) + } else { + denied = append(denied, op) + } + } + return allowed, denied +} + +func addTo(list []*astOperation, state statement, op eacl.Operation, role eacl.Role, action eacl.Action) []*astOperation { + var found *astOperation + for _, astop := range list { + if astop.Op == op && astop.Role == role { + found = astop + } + } + + if found != nil { + if role == eacl.RoleUser { + found.Users = append(found.Users, state.Principal.CanonicalUser) + } + } else { + astoperation := &astOperation{ + Role: role, + Op: op, + Action: action, + } + if role == eacl.RoleUser { + astoperation.Users = append(astoperation.Users, state.Principal.CanonicalUser) + } + + list = append(list, astoperation) + } + + return list +} + +func aclToPolicy(acl *AccessControlPolicy) (*bucketPolicy, error) { + if acl.Resource == "" { + return nil, fmt.Errorf("resource must not be empty") + } + + results := []statement{ + getAllowStatement(acl, acl.Owner.ID, aclFullControl), + } + + for _, grant := range acl.AccessControlList { + if grant.Grantee.Type == acpAmazonCustomerByEmail || (grant.Grantee.Type == acpGroup && grant.Grantee.URI != allUsersGroup) { + return nil, fmt.Errorf("unsupported grantee: %v", grant.Grantee) + } + + user := grant.Grantee.ID + if grant.Grantee.Type == acpGroup { + user = allUsersWildcard + } else if user == acl.Owner.ID { + continue + } + results = append(results, getAllowStatement(acl, user, grant.Permission)) + } + + return &bucketPolicy{ + Statement: results, + }, nil +} + +func getAllowStatement(acl *AccessControlPolicy, id string, permission AWSACL) statement { + state := statement{ + Effect: "Allow", + Principal: principal{ + CanonicalUser: id, + }, + Action: getActions(permission, acl.IsBucket), + Resource: []string{arnAwsPrefix + acl.Resource}, + } + + if id == allUsersWildcard { + state.Principal = principal{AWS: allUsersWildcard} + } + + return state +} + +func getActions(permission AWSACL, isBucket bool) []string { + var res []string + switch permission { + case aclRead: + if isBucket { + res = []string{s3ListBucket, s3ListBucketVersions, s3ListBucketMultipartUploads} + } else { + res = []string{s3GetObject, s3GetObjectVersion} + } + case aclWrite: + if isBucket { + res = []string{s3PutObject, s3DeleteObject} + } + case aclFullControl: + if isBucket { + res = []string{s3ListBucket, s3ListBucketVersions, s3ListBucketMultipartUploads, s3PutObject, s3DeleteObject} + } else { + res = []string{s3GetObject, s3GetObjectVersion} + } + } + + return res +} + +func effectToAction(effect string) eacl.Action { + switch effect { + case "Allow": + return eacl.ActionAllow + case "Deny": + return eacl.ActionDeny + } + return eacl.ActionUnknown +} + +func actionToEffect(action eacl.Action) string { + switch action { + case eacl.ActionAllow: + return "Allow" + case eacl.ActionDeny: + return "Deny" + default: + return "" + } +} + +func permissionToOperations(permission AWSACL) []eacl.Operation { + switch permission { + case aclFullControl: + return fullOps + case aclRead: + return readOps + case aclWrite: + return writeOps + } + return nil +} + +func isWriteOperation(op eacl.Operation) bool { + return op == eacl.OperationDelete || op == eacl.OperationPut +} + +func (h *handler) encodeObjectACL(bucketACL *layer.BucketACL, objectName string) *AccessControlPolicy { + res := &AccessControlPolicy{ + Owner: Owner{ + ID: bucketACL.Info.Owner.String(), + DisplayName: bucketACL.Info.Owner.String(), + }, + } + + m := make(map[string][]eacl.Operation) + + for _, record := range bucketACL.EACL.Records() { + if len(record.Targets()) != 1 { + h.log.Warn("some acl not fully mapped") + continue + } + + if objectName != "" { + var found bool + for _, filter := range record.Filters() { + if filter.Matcher() == eacl.MatchStringEqual && + filter.Key() == object.AttributeFileName && filter.Key() == objectName { + found = true + } + } + if !found { + continue + } + } + + target := record.Targets()[0] + if target.Role() == eacl.RoleOthers { + if record.Action() == eacl.ActionAllow { + list := append(m[allUsersGroup], record.Operation()) + m[allUsersGroup] = list + } + continue + } + + for _, key := range target.BinaryKeys() { + id := hex.EncodeToString(key) + list := append(m[id], record.Operation()) + m[id] = list + } + } + + for key, val := range m { + permission := aclFullControl + read, write := true, true + for op := eacl.OperationGet; op <= eacl.OperationRangeHash; op++ { + if !contains(val, op) { + if isWriteOperation(op) { + write = false + } else { + read = false + } + } + } + + if !read && !write { + h.log.Warn("some acl not fully mapped") + continue + } + if !read { + permission = aclWrite + } else if !write { + permission = aclRead + } + + var grantee *Grantee + if key == allUsersGroup { + grantee = NewGrantee(acpGroup) + grantee.URI = allUsersGroup + } else { + grantee = NewGrantee(acpCanonicalUser) + grantee.ID = key + } + + grant := &Grant{ + Grantee: grantee, + Permission: permission, + } + res.AccessControlList = append(res.AccessControlList, grant) + } + + return res +} + +func (h *handler) encodeBucketACL(bucketACL *layer.BucketACL) *AccessControlPolicy { + return h.encodeObjectACL(bucketACL, "") +} + +func contains(list []eacl.Operation, op eacl.Operation) bool { + for _, operation := range list { + if operation == op { + return true + } + } + return false +} + +type getRecordFunc func(op eacl.Operation) *eacl.Record + +func bucketACLToTable(acp *AccessControlPolicy) (*eacl.Table, error) { + if !acp.IsBucket { + return nil, fmt.Errorf("allowed only bucket acl") + } + + var found bool + table := eacl.NewTable() + + ownerKey, err := keys.NewPublicKeyFromString(acp.Owner.ID) + if err != nil { + return nil, err + } + + for _, grant := range acp.AccessControlList { + if !isValidGrant(grant) { + return nil, fmt.Errorf("unsupported grantee: %v", grant.Grantee) + } + if grant.Grantee.ID == acp.Owner.ID { + found = true + } + + getRecord, err := getRecordFunction(grant.Grantee) + if err != nil { + return nil, err + } + for _, op := range permissionToOperations(grant.Permission) { + table.AddRecord(getRecord(op)) + } + } + + if !found { + for _, op := range fullOps { + table.AddRecord(getAllowRecord(op, ownerKey)) + } + } + + for _, op := range fullOps { + table.AddRecord(getOthersRecord(op, eacl.ActionDeny)) + } + + return table, nil +} + +func getRecordFunction(grantee *Grantee) (getRecordFunc, error) { + switch grantee.Type { + case acpAmazonCustomerByEmail: + case acpCanonicalUser: + pk, err := keys.NewPublicKeyFromString(grantee.ID) + if err != nil { + return nil, fmt.Errorf("couldn't parse canonical ID %s: %w", grantee.ID, err) + } + return func(op eacl.Operation) *eacl.Record { + return getAllowRecord(op, pk) + }, nil + case acpGroup: + return func(op eacl.Operation) *eacl.Record { + return getOthersRecord(op, eacl.ActionAllow) + }, nil + } + return nil, fmt.Errorf("unknown type: %s", grantee.Type) +} + +func isValidGrant(grant *Grant) bool { + return (grant.Permission == aclFullControl || grant.Permission == aclRead || grant.Permission == aclWrite) && + (grant.Grantee.Type == acpCanonicalUser || (grant.Grantee.Type == acpGroup && grant.Grantee.URI == allUsersGroup)) +} + +func getAllowRecord(op eacl.Operation, pk *keys.PublicKey) *eacl.Record { + record := eacl.NewRecord() + record.SetOperation(op) + record.SetAction(eacl.ActionAllow) + eacl.AddFormedTarget(record, eacl.RoleUser, (ecdsa.PublicKey)(*pk)) + return record +} + +func getOthersRecord(op eacl.Operation, action eacl.Action) *eacl.Record { + record := eacl.NewRecord() + record.SetOperation(op) + record.SetAction(action) + eacl.AddFormedTarget(record, eacl.RoleOthers) + return record +} diff --git a/api/handler/acl_test.go b/api/handler/acl_test.go new file mode 100644 index 000000000..207d9467d --- /dev/null +++ b/api/handler/acl_test.go @@ -0,0 +1,698 @@ +package handler + +import ( + "context" + "crypto/ecdsa" + "encoding/hex" + "net/http" + "testing" + + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + "github.com/nspcc-dev/neofs-api-go/pkg/acl/eacl" + "github.com/nspcc-dev/neofs-api-go/pkg/object" + "github.com/nspcc-dev/neofs-s3-gw/api" + "github.com/nspcc-dev/neofs-s3-gw/creds/accessbox" + "github.com/stretchr/testify/require" +) + +func TestTableToAst(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + key2, err := keys.NewPrivateKey() + require.NoError(t, err) + + table := new(eacl.Table) + record := eacl.NewRecord() + record.SetAction(eacl.ActionAllow) + record.SetOperation(eacl.OperationGet) + eacl.AddFormedTarget(record, eacl.RoleOthers) + table.AddRecord(record) + record2 := eacl.NewRecord() + record2.SetAction(eacl.ActionDeny) + record2.SetOperation(eacl.OperationPut) + eacl.AddFormedTarget(record2, eacl.RoleUser, *(*ecdsa.PublicKey)(key.PublicKey())) + eacl.AddFormedTarget(record2, eacl.RoleUser, *(*ecdsa.PublicKey)(key2.PublicKey())) + record2.AddObjectAttributeFilter(eacl.MatchStringEqual, object.AttributeFileName, "objectName") + table.AddRecord(record2) + + expectedAst := &ast{ + Resources: []*astResource{ + { + Name: "bucketName", + Operations: []*astOperation{{ + Role: eacl.RoleOthers, + Op: eacl.OperationGet, + Action: eacl.ActionAllow, + }}}, + { + Name: "objectName", + Operations: []*astOperation{{ + Users: []string{ + hex.EncodeToString(key.PublicKey().Bytes()), + hex.EncodeToString(key2.PublicKey().Bytes()), + }, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionDeny, + }}}, + }, + } + + actualAst := tableToAst(table, expectedAst.Resources[0].Name) + + if actualAst.Resources[0].Name == expectedAst.Resources[0].Name { + require.Equal(t, expectedAst, actualAst) + } else { + require.Equal(t, len(expectedAst.Resources), len(actualAst.Resources)) + require.Equal(t, expectedAst.Resources[0], actualAst.Resources[1]) + require.Equal(t, expectedAst.Resources[1], actualAst.Resources[0]) + } +} + +func TestPolicyToAst(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + + policy := &bucketPolicy{ + Statement: []statement{ + { + Effect: "Allow", + Principal: principal{AWS: allUsersWildcard}, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::bucketName"}, + }, + { + Effect: "Deny", + Principal: principal{ + CanonicalUser: hex.EncodeToString(key.PublicKey().Bytes()), + }, + Action: []string{"s3:GetObject"}, + Resource: []string{"arn:aws:s3:::bucketName/object"}, + }}, + } + + expectedAst := &ast{ + Resources: []*astResource{ + { + Name: "bucketName", + Operations: []*astOperation{{ + Role: eacl.RoleOthers, + Op: eacl.OperationPut, + Action: eacl.ActionAllow, + }}, + }, + { + Name: "bucketName/object", + Operations: getReadOps(key, eacl.RoleUser, eacl.ActionDeny), + }, + }, + } + + actualAst, err := policyToAst(policy) + require.NoError(t, err) + + if actualAst.Resources[0].Name == expectedAst.Resources[0].Name { + require.Equal(t, expectedAst, actualAst) + } else { + require.Equal(t, len(expectedAst.Resources), len(actualAst.Resources)) + require.Equal(t, expectedAst.Resources[0], actualAst.Resources[1]) + require.Equal(t, expectedAst.Resources[1], actualAst.Resources[0]) + } +} + +func getReadOps(key *keys.PrivateKey, role eacl.Role, action eacl.Action) []*astOperation { + var result []*astOperation + + for _, op := range readOps { + result = append(result, &astOperation{ + Users: []string{hex.EncodeToString(key.PublicKey().Bytes())}, + Role: role, + Op: op, + Action: action, + }) + } + + return result +} + +func TestMergeAstUnModified(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + + child := &ast{ + Resources: []*astResource{ + { + Name: "objectName", + Operations: []*astOperation{{ + Users: []string{hex.EncodeToString(key.PublicKey().Bytes())}, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionDeny, + }}, + }, + }, + } + + parent := &ast{ + Resources: []*astResource{ + { + Name: "bucket", + Operations: []*astOperation{{ + Role: eacl.RoleOthers, + Op: eacl.OperationGet, + Action: eacl.ActionAllow, + }}, + }, + child.Resources[0], + }, + } + + result, updated := mergeAst(parent, child) + require.False(t, updated) + require.Equal(t, parent, result) +} + +func TestMergeAstModified(t *testing.T) { + child := &ast{ + Resources: []*astResource{ + { + Name: "objectName", + Operations: []*astOperation{{ + Role: eacl.RoleOthers, + Op: eacl.OperationPut, + Action: eacl.ActionDeny, + }, { + Users: []string{"user2"}, + Role: eacl.RoleUser, + Op: eacl.OperationGet, + Action: eacl.ActionDeny, + }}, + }, + }, + } + + parent := &ast{ + Resources: []*astResource{ + { + Name: "objectName", + Operations: []*astOperation{{ + Users: []string{"user1"}, + Role: eacl.RoleUser, + Op: eacl.OperationGet, + Action: eacl.ActionDeny, + }}, + }, + }, + } + + expected := &ast{ + Resources: []*astResource{ + { + Name: "objectName", + Operations: []*astOperation{ + child.Resources[0].Operations[0], + { + Users: []string{"user1", "user2"}, + Role: eacl.RoleUser, + Op: eacl.OperationGet, + Action: eacl.ActionDeny, + }, + }, + }, + }, + } + + actual, updated := mergeAst(parent, child) + require.True(t, updated) + require.Equal(t, expected, actual) +} + +func TestMergeAstModifiedConflict(t *testing.T) { + child := &ast{ + Resources: []*astResource{ + { + Name: "objectName", + Operations: []*astOperation{{ + Users: []string{"user1"}, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionDeny, + }, { + Users: []string{"user3"}, + Role: eacl.RoleUser, + Op: eacl.OperationGet, + Action: eacl.ActionAllow, + }}, + }, + }, + } + + parent := &ast{ + Resources: []*astResource{ + { + Name: "objectName", + Operations: []*astOperation{{ + Users: []string{"user1"}, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionAllow, + }, { + Users: []string{"user2"}, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionDeny, + }, { + Users: []string{"user3"}, + Role: eacl.RoleUser, + Op: eacl.OperationGet, + Action: eacl.ActionDeny, + }}, + }, + }, + } + + expected := &ast{ + Resources: []*astResource{ + { + Name: "objectName", + Operations: []*astOperation{ + { + Users: []string{"user2", "user1"}, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionDeny, + }, { + Users: []string{"user3"}, + Role: eacl.RoleUser, + Op: eacl.OperationGet, + Action: eacl.ActionAllow, + }, + }, + }, + }, + } + + actual, updated := mergeAst(parent, child) + require.True(t, updated) + require.Equal(t, expected, actual) +} + +func TestAstToTable(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + + ast := &ast{ + Resources: []*astResource{ + { + Name: "bucketName", + Operations: []*astOperation{{ + Users: []string{hex.EncodeToString(key.PublicKey().Bytes())}, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionAllow, + }}, + }, + { + Name: "bucketName/objectName", + Operations: []*astOperation{{ + Role: eacl.RoleOthers, + Op: eacl.OperationGet, + Action: eacl.ActionDeny, + }}, + }, + }, + } + + expectedTable := eacl.NewTable() + record := eacl.NewRecord() + record.SetAction(eacl.ActionAllow) + record.SetOperation(eacl.OperationPut) + eacl.AddFormedTarget(record, eacl.RoleUser, *(*ecdsa.PublicKey)(key.PublicKey())) + expectedTable.AddRecord(record) + record2 := eacl.NewRecord() + record2.SetAction(eacl.ActionDeny) + record2.SetOperation(eacl.OperationGet) + eacl.AddFormedTarget(record2, eacl.RoleOthers) + record2.AddObjectAttributeFilter(eacl.MatchStringEqual, object.AttributeFileName, "objectName") + expectedTable.AddRecord(record2) + + actualTable, err := astToTable(ast, "bucketName") + require.NoError(t, err) + require.Equal(t, expectedTable, actualTable) +} + +func TestRemoveUsers(t *testing.T) { + resource := &astResource{ + Name: "name", + Operations: []*astOperation{{ + Users: []string{"user1", "user3"}, + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionAllow, + }}, + } + + op := &astOperation{ + Role: eacl.RoleUser, + Op: eacl.OperationPut, + Action: eacl.ActionAllow, + } + + removeUsers(resource, op, []string{"user1", "user2"}) + + require.Equal(t, len(resource.Operations), 1) + require.Equal(t, resource.Name, resource.Name) + require.Equal(t, resource.Operations[0].Users, []string{"user3"}) +} + +func TestBucketAclToPolicy(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + key2, err := keys.NewPrivateKey() + require.NoError(t, err) + + id := hex.EncodeToString(key.PublicKey().Bytes()) + id2 := hex.EncodeToString(key2.PublicKey().Bytes()) + + acl := &AccessControlPolicy{ + Owner: Owner{ + ID: id, + DisplayName: "user1", + }, + AccessControlList: []*Grant{{ + Grantee: &Grantee{ + URI: allUsersGroup, + Type: acpGroup, + }, + Permission: aclRead, + }, { + Grantee: &Grantee{ + ID: id2, + Type: acpCanonicalUser, + }, + Permission: aclWrite, + }}, + Resource: "bucketName", + IsBucket: true, + } + + expectedPolicy := &bucketPolicy{ + Statement: []statement{ + { + Effect: "Allow", + Principal: principal{ + CanonicalUser: id, + }, + Action: []string{"s3:ListBucket", "s3:ListBucketVersions", "s3:ListBucketMultipartUploads", "s3:PutObject", "s3:DeleteObject"}, + Resource: []string{arnAwsPrefix + acl.Resource}, + }, { + Effect: "Allow", + Principal: principal{AWS: allUsersWildcard}, + Action: []string{"s3:ListBucket", "s3:ListBucketVersions", "s3:ListBucketMultipartUploads"}, + Resource: []string{arnAwsPrefix + acl.Resource}, + }, { + Effect: "Allow", + Principal: principal{ + CanonicalUser: id2, + }, + Action: []string{"s3:PutObject", "s3:DeleteObject"}, + Resource: []string{arnAwsPrefix + acl.Resource}, + }, + }, + } + + actualPolicy, err := aclToPolicy(acl) + require.NoError(t, err) + require.Equal(t, expectedPolicy, actualPolicy) +} + +func TestObjectAclToPolicy(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + key2, err := keys.NewPrivateKey() + require.NoError(t, err) + + id := hex.EncodeToString(key.PublicKey().Bytes()) + id2 := hex.EncodeToString(key2.PublicKey().Bytes()) + + acl := &AccessControlPolicy{ + Owner: Owner{ + ID: id, + DisplayName: "user1", + }, + AccessControlList: []*Grant{{ + Grantee: &Grantee{ + ID: id, + Type: acpCanonicalUser, + }, + Permission: aclFullControl, + }, { + Grantee: &Grantee{ + ID: id2, + Type: acpCanonicalUser, + }, + Permission: aclFullControl, + }, { + Grantee: &Grantee{ + URI: allUsersGroup, + Type: acpGroup, + }, + Permission: aclRead, + }}, + Resource: "bucketName/object", + IsBucket: false, + } + + expectedPolicy := &bucketPolicy{ + Statement: []statement{ + { + Effect: "Allow", + Principal: principal{ + CanonicalUser: id, + }, + Action: []string{"s3:GetObject", "s3:GetObjectVersion"}, + Resource: []string{arnAwsPrefix + acl.Resource}, + }, + { + Effect: "Allow", + Principal: principal{ + CanonicalUser: id2, + }, + Action: []string{"s3:GetObject", "s3:GetObjectVersion"}, + Resource: []string{arnAwsPrefix + acl.Resource}, + }, { + Effect: "Allow", + Principal: principal{AWS: allUsersWildcard}, + Action: []string{"s3:GetObject", "s3:GetObjectVersion"}, + Resource: []string{arnAwsPrefix + acl.Resource}, + }, + }, + } + + actualPolicy, err := aclToPolicy(acl) + require.NoError(t, err) + require.Equal(t, expectedPolicy, actualPolicy) +} + +func TestParseCannedACLHeaders(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + + id := hex.EncodeToString(key.PublicKey().Bytes()) + address := key.PublicKey().Address() + + req := &http.Request{ + Header: map[string][]string{ + api.AmzACL: {basicACLReadOnly}, + }, + } + + box := &accessbox.Box{ + Gate: &accessbox.GateData{ + GateKey: key.PublicKey(), + }, + } + ctx := context.Background() + ctx = context.WithValue(ctx, api.BoxData, box) + + expectedACL := &AccessControlPolicy{ + Owner: Owner{ + ID: id, + DisplayName: address, + }, + AccessControlList: []*Grant{{ + Grantee: &Grantee{ + ID: id, + DisplayName: address, + Type: acpCanonicalUser, + }, + Permission: aclFullControl, + }, { + Grantee: &Grantee{ + URI: allUsersGroup, + Type: acpGroup, + }, + Permission: aclRead, + }}, + } + + actualACL, err := parseACLHeaders(req.WithContext(ctx)) + require.NoError(t, err) + require.Equal(t, expectedACL, actualACL) +} + +func TestParseACLHeaders(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + + id := hex.EncodeToString(key.PublicKey().Bytes()) + address := key.PublicKey().Address() + + req := &http.Request{ + Header: map[string][]string{ + api.AmzGrantFullControl: {"id=\"user1\""}, + api.AmzGrantRead: {"uri=\"" + allUsersGroup + "\", id=\"user2\""}, + api.AmzGrantWrite: {"id=\"user2\", id=\"user3\""}, + }, + } + + box := &accessbox.Box{ + Gate: &accessbox.GateData{ + GateKey: key.PublicKey(), + }, + } + ctx := context.Background() + ctx = context.WithValue(ctx, api.BoxData, box) + + expectedACL := &AccessControlPolicy{ + Owner: Owner{ + ID: id, + DisplayName: address, + }, + AccessControlList: []*Grant{{ + Grantee: &Grantee{ + ID: id, + DisplayName: address, + Type: acpCanonicalUser, + }, + Permission: aclFullControl, + }, { + Grantee: &Grantee{ + ID: "user1", + Type: acpCanonicalUser, + }, + Permission: aclFullControl, + }, { + Grantee: &Grantee{ + URI: allUsersGroup, + Type: acpGroup, + }, + Permission: aclRead, + }, { + Grantee: &Grantee{ + ID: "user2", + Type: acpCanonicalUser, + }, + Permission: aclRead, + }, { + Grantee: &Grantee{ + ID: "user2", + Type: acpCanonicalUser, + }, + Permission: aclWrite, + }, { + Grantee: &Grantee{ + ID: "user3", + Type: acpCanonicalUser, + }, + Permission: aclWrite, + }}, + } + + actualACL, err := parseACLHeaders(req.WithContext(ctx)) + require.NoError(t, err) + require.Equal(t, expectedACL, actualACL) +} + +func TestAddGranteeError(t *testing.T) { + headers := map[string][]string{ + api.AmzGrantFullControl: {"i=\"user1\""}, + api.AmzGrantRead: {"uri, id=\"user2\""}, + api.AmzGrantWrite: {"emailAddress=\"user2\""}, + "unknown header": {"something"}, + } + + expectedList := []*Grant{{ + Permission: "predefined", + }} + + actualList, err := addGrantees(expectedList, headers, "unknown header1") + require.NoError(t, err) + require.Equal(t, expectedList, actualList) + + actualList, err = addGrantees(expectedList, headers, "unknown header") + require.Error(t, err) + require.Nil(t, actualList) + + actualList, err = addGrantees(expectedList, headers, api.AmzGrantFullControl) + require.Error(t, err) + require.Nil(t, actualList) + + actualList, err = addGrantees(expectedList, headers, api.AmzGrantRead) + require.Error(t, err) + require.Nil(t, actualList) + + actualList, err = addGrantees(expectedList, headers, api.AmzGrantWrite) + require.Error(t, err) + require.Nil(t, actualList) +} + +func TestBucketAclToTable(t *testing.T) { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + key2, err := keys.NewPrivateKey() + require.NoError(t, err) + + id := hex.EncodeToString(key.PublicKey().Bytes()) + id2 := hex.EncodeToString(key2.PublicKey().Bytes()) + + acl := &AccessControlPolicy{ + Owner: Owner{ + ID: id, + DisplayName: "user1", + }, + AccessControlList: []*Grant{{ + Grantee: &Grantee{ + URI: allUsersGroup, + Type: acpGroup, + }, + Permission: aclRead, + }, { + Grantee: &Grantee{ + ID: id2, + Type: acpCanonicalUser, + }, + Permission: aclWrite, + }}, + Resource: "bucketName", + IsBucket: true, + } + + expectedTable := new(eacl.Table) + for _, op := range readOps { + expectedTable.AddRecord(getOthersRecord(op, eacl.ActionAllow)) + } + for _, op := range writeOps { + expectedTable.AddRecord(getAllowRecord(op, key2.PublicKey())) + } + for _, op := range fullOps { + expectedTable.AddRecord(getAllowRecord(op, key.PublicKey())) + } + for _, op := range fullOps { + expectedTable.AddRecord(getOthersRecord(op, eacl.ActionDeny)) + } + + actualTable, err := bucketACLToTable(acl) + require.NoError(t, err) + require.Equal(t, expectedTable.Records(), actualTable.Records()) +} diff --git a/api/handler/put.go b/api/handler/put.go index 205a96c32..93c23e51d 100644 --- a/api/handler/put.go +++ b/api/handler/put.go @@ -2,27 +2,24 @@ package handler import ( "encoding/xml" - "fmt" "net" "net/http" - "strconv" "strings" - "github.com/nspcc-dev/neofs-s3-gw/api/errors" - - "github.com/nspcc-dev/neofs-api-go/pkg/acl" "github.com/nspcc-dev/neofs-node/pkg/policy" "github.com/nspcc-dev/neofs-s3-gw/api" + "github.com/nspcc-dev/neofs-s3-gw/api/errors" "github.com/nspcc-dev/neofs-s3-gw/api/layer" "go.uber.org/zap" ) // keywords of predefined basic ACL values. const ( - basicACLPrivate = "private" - basicACLReadOnly = "public-read" - basicACLPublic = "public-read-write" - defaultPolicy = "REP 3" + basicACLPrivate = "private" + basicACLReadOnly = "public-read" + basicACLPublic = "public-read-write" + cannedACLAuthRead = "authenticated-read" + defaultPolicy = "REP 3" publicBasicRule = 0x0FFFFFFF ) @@ -39,6 +36,50 @@ func (h *handler) PutObjectHandler(w http.ResponseWriter, r *http.Request) { reqInfo = api.GetReqInfo(r.Context()) ) + objectACL, err := parseACLHeaders(r) + if err != nil { + h.logAndSendError(w, "could not parse object acl", reqInfo, err) + return + } + objectACL.Resource = reqInfo.BucketName + "/" + reqInfo.ObjectName + + bktPolicy, err := aclToPolicy(objectACL) + if err != nil { + h.logAndSendError(w, "could not translate object acl to bucket policy", reqInfo, err) + return + } + + astChild, err := policyToAst(bktPolicy) + if err != nil { + h.logAndSendError(w, "could not translate policy to ast", reqInfo, err) + return + } + + bacl, err := h.obj.GetBucketACL(r.Context(), reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not get bucket eacl", reqInfo, err) + return + } + + if err = checkOwner(bacl.Info, r.Header.Get(api.AmzExpectedBucketOwner)); err != nil { + h.logAndSendError(w, "expected owner doesn't match", reqInfo, err) + return + } + + parentAst := tableToAst(bacl.EACL, reqInfo.BucketName) + for _, resource := range parentAst.Resources { + if resource.Name == bacl.Info.CID.String() { + resource.Name = reqInfo.BucketName + } + } + + resAst, updated := mergeAst(parentAst, astChild) + table, err := astToTable(resAst, reqInfo.BucketName) + if err != nil { + h.logAndSendError(w, "could not translate ast to table", reqInfo, err) + return + } + metadata := parseMetadata(r) if contentType := r.Header.Get(api.ContentType); len(contentType) > 0 { metadata[api.ContentType] = contentType @@ -57,6 +98,18 @@ func (h *handler) PutObjectHandler(w http.ResponseWriter, r *http.Request) { return } + if updated { + p := &layer.PutBucketACLParams{ + Name: reqInfo.BucketName, + EACL: table, + } + + if err = h.obj.PutBucketACL(r.Context(), p); err != nil { + h.logAndSendError(w, "could not put bucket acl", reqInfo, err) + return + } + } + w.Header().Set(api.ETag, info.HashSum) api.WriteSuccessResponseHeadersOnly(w) } @@ -74,24 +127,25 @@ func parseMetadata(r *http.Request) map[string]string { func (h *handler) CreateBucketHandler(w http.ResponseWriter, r *http.Request) { var ( - err error reqInfo = api.GetReqInfo(r.Context()) - p = layer.CreateBucketParams{Name: reqInfo.BucketName} + p = layer.CreateBucketParams{Name: reqInfo.BucketName, ACL: publicBasicRule} ) - if err = checkBucketName(reqInfo.BucketName); err != nil { + if err := checkBucketName(reqInfo.BucketName); err != nil { h.logAndSendError(w, "invalid bucket name", reqInfo, err) return } - if val, ok := r.Header["X-Amz-Acl"]; ok { - p.ACL, err = parseBasicACL(val[0]) - } else { - p.ACL = publicBasicRule - } - + bktACL, err := parseACLHeaders(r) if err != nil { - h.logAndSendError(w, "could not parse basic ACL", reqInfo, err) + h.logAndSendError(w, "could not parse bucket acl", reqInfo, err) + return + } + bktACL.IsBucket = true + + p.EACL, err = bucketACLToTable(bktACL) + if err != nil { + h.logAndSendError(w, "could translate bucket acl to eacl", reqInfo, err) return } @@ -180,23 +234,3 @@ func parseLocationConstraint(r *http.Request) (*createBucketParams, error) { } return params, nil } - -func parseBasicACL(basicACL string) (uint32, error) { - switch basicACL { - case basicACLPublic: - return acl.PublicBasicRule, nil - case basicACLPrivate: - return acl.PrivateBasicRule, nil - case basicACLReadOnly: - return acl.ReadOnlyBasicRule, nil - default: - basicACL = strings.Trim(strings.ToLower(basicACL), "0x") - - value, err := strconv.ParseUint(basicACL, 16, 32) - if err != nil { - return 0, fmt.Errorf("can't parse basic ACL: %s", basicACL) - } - - return uint32(value), nil - } -} diff --git a/api/handler/response.go b/api/handler/response.go index da86c141b..90f1fdabd 100644 --- a/api/handler/response.go +++ b/api/handler/response.go @@ -52,6 +52,41 @@ type Bucket struct { CreationDate string // time string of format "2006-01-02T15:04:05.000Z" } +// AccessControlPolicy contains ACL. +type AccessControlPolicy struct { + XMLName xml.Name `xml:"http://s3.amazonaws.com/doc/2006-03-01/ AccessControlPolicy" json:"-"` + Owner Owner + AccessControlList []*Grant `xml:"AccessControlList>Grant"` + Resource string `xml:"-"` + IsBucket bool `xml:"-"` +} + +// Grant is container for Grantee data. +type Grant struct { + Grantee *Grantee + Permission AWSACL +} + +// Grantee is info about access rights of some actor. +type Grantee struct { + XMLName xml.Name `xml:"Grantee"` + XMLNS string `xml:"xmlns:xsi,attr"` + ID string `xml:"ID,omitempty"` + DisplayName string `xml:"DisplayName,omitempty"` + EmailAddress string `xml:"EmailAddress,omitempty"` + URI string `xml:"URI,omitempty"` + Type GranteeType `xml:"xsi:type,attr"` +} + +// NewGrantee creates new grantee using workaround +// https://github.com/golang/go/issues/9519#issuecomment-252196382 +func NewGrantee(t GranteeType) *Grantee { + return &Grantee{ + XMLNS: "http://www.w3.org/2001/XMLSchema-instance", + Type: t, + } +} + // Owner - bucket owner/principal. type Owner struct { ID string diff --git a/api/handler/unimplemented.go b/api/handler/unimplemented.go index b8009551a..d1b0255de 100644 --- a/api/handler/unimplemented.go +++ b/api/handler/unimplemented.go @@ -31,14 +31,6 @@ func (h *handler) AbortMultipartUploadHandler(w http.ResponseWriter, r *http.Req h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } -func (h *handler) GetObjectACLHandler(w http.ResponseWriter, r *http.Request) { - h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) -} - -func (h *handler) PutObjectACLHandler(w http.ResponseWriter, r *http.Request) { - h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) -} - func (h *handler) GetObjectTaggingHandler(w http.ResponseWriter, r *http.Request) { h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } @@ -71,10 +63,6 @@ func (h *handler) PutObjectLegalHoldHandler(w http.ResponseWriter, r *http.Reque h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } -func (h *handler) GetBucketPolicyHandler(w http.ResponseWriter, r *http.Request) { - h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) -} - func (h *handler) GetBucketLifecycleHandler(w http.ResponseWriter, r *http.Request) { h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } @@ -83,14 +71,6 @@ func (h *handler) GetBucketEncryptionHandler(w http.ResponseWriter, r *http.Requ h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } -func (h *handler) GetBucketACLHandler(w http.ResponseWriter, r *http.Request) { - h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) -} - -func (h *handler) PutBucketACLHandler(w http.ResponseWriter, r *http.Request) { - h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) -} - func (h *handler) GetBucketCorsHandler(w http.ResponseWriter, r *http.Request) { h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } @@ -151,10 +131,6 @@ func (h *handler) PutBucketEncryptionHandler(w http.ResponseWriter, r *http.Requ h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } -func (h *handler) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Request) { - h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) -} - func (h *handler) PutBucketObjectLockConfigHandler(w http.ResponseWriter, r *http.Request) { h.logAndSendError(w, "not implemented", api.GetReqInfo(r.Context()), errors.GetAPIError(errors.ErrNotImplemented)) } diff --git a/api/headers.go b/api/headers.go index 090ffe94b..7717d6936 100644 --- a/api/headers.go +++ b/api/headers.go @@ -34,4 +34,9 @@ const ( AmzCopyIfUnmodifiedSince = "X-Amz-Copy-Source-If-Unmodified-Since" AmzCopyIfMatch = "X-Amz-Copy-Source-If-Match" AmzCopyIfNoneMatch = "X-Amz-Copy-Source-If-None-Match" + AmzACL = "X-Amz-Acl" + AmzGrantFullControl = "X-Amz-Grant-Full-Control" + AmzGrantRead = "X-Amz-Grant-Read" + AmzGrantWrite = "X-Amz-Grant-Write" + AmzExpectedBucketOwner = "X-Amz-Expected-Bucket-Owner" ) diff --git a/api/layer/container.go b/api/layer/container.go index 381b6af6a..3bf7dc0e3 100644 --- a/api/layer/container.go +++ b/api/layer/container.go @@ -3,13 +3,11 @@ package layer import ( "bytes" "context" - "crypto/ecdsa" "fmt" "strconv" "strings" "time" - "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/nspcc-dev/neofs-api-go/pkg/acl/eacl" "github.com/nspcc-dev/neofs-api-go/pkg/container" cid "github.com/nspcc-dev/neofs-api-go/pkg/container/id" @@ -23,10 +21,16 @@ import ( type ( // BucketInfo stores basic bucket data. BucketInfo struct { - Name string - CID *cid.ID - Owner *owner.ID - Created time.Time + Name string + CID *cid.ID + Owner *owner.ID + Created time.Time + BasicACL uint32 + } + // BucketACL extends BucketInfo by eacl.Table. + BucketACL struct { + Info *BucketInfo + EACL *eacl.Table } ) @@ -56,6 +60,7 @@ func (n *layer) containerInfo(ctx context.Context, cid *cid.ID) (*BucketInfo, er } info.Owner = res.OwnerID() + info.BasicACL = res.BasicACL() for _, attr := range res.Attributes() { switch key, val := attr.Key(), attr.Value(); key { @@ -131,19 +136,15 @@ func (n *layer) createContainer(ctx context.Context, p *CreateBucketParams) (*ci return nil, err } - if err := n.setContainerEACL(ctx, cid, p.BoxData.Gate.GateKey); err != nil { + if err := n.setContainerEACLTable(ctx, cid, p.EACL); err != nil { return nil, err } return cid, nil } -func (n *layer) setContainerEACL(ctx context.Context, cid *cid.ID, gateKey *keys.PublicKey) error { - if gateKey == nil { - return fmt.Errorf("gate key must not be nil") - } - - table := formDefaultTable(cid, *(*ecdsa.PublicKey)(gateKey)) +func (n *layer) setContainerEACLTable(ctx context.Context, cid *cid.ID, table *eacl.Table) error { + table.SetCID(cid) if err := n.pool.SetEACL(ctx, table, n.SessionOpt(ctx)); err != nil { return err } @@ -155,25 +156,12 @@ func (n *layer) setContainerEACL(ctx context.Context, cid *cid.ID, gateKey *keys return nil } -func formDefaultTable(cid *cid.ID, gateKey ecdsa.PublicKey) *eacl.Table { - table := eacl.NewTable() - table.SetCID(cid) - - for op := eacl.OperationGet; op <= eacl.OperationRangeHash; op++ { - record := eacl.NewRecord() - record.SetOperation(op) - record.SetAction(eacl.ActionAllow) - eacl.AddFormedTarget(record, eacl.RoleUser, gateKey) - table.AddRecord(record) - - record2 := eacl.NewRecord() - record2.SetOperation(op) - record2.SetAction(eacl.ActionDeny) - eacl.AddFormedTarget(record2, eacl.RoleOthers) - table.AddRecord(record2) +func (n *layer) GetContainerEACL(ctx context.Context, cid *cid.ID) (*eacl.Table, error) { + signedEacl, err := n.pool.GetEACL(ctx, cid) + if err != nil { + return nil, err } - - return table + return signedEacl.EACL(), nil } type waitParams struct { diff --git a/api/layer/layer.go b/api/layer/layer.go index 556d1c4ef..fd7d05816 100644 --- a/api/layer/layer.go +++ b/api/layer/layer.go @@ -9,6 +9,7 @@ import ( "sort" "time" + "github.com/nspcc-dev/neofs-api-go/pkg/acl/eacl" "github.com/nspcc-dev/neofs-api-go/pkg/client" cid "github.com/nspcc-dev/neofs-api-go/pkg/container/id" "github.com/nspcc-dev/neofs-api-go/pkg/netmap" @@ -84,8 +85,14 @@ type ( Name string ACL uint32 Policy *netmap.PlacementPolicy + EACL *eacl.Table BoxData *accessbox.Box } + // PutBucketACLParams stores put bucket acl request parameters. + PutBucketACLParams struct { + Name string + EACL *eacl.Table + } // DeleteBucketParams stores delete bucket request parameters. DeleteBucketParams struct { Name string @@ -112,6 +119,8 @@ type ( ListBuckets(ctx context.Context) ([]*BucketInfo, error) GetBucketInfo(ctx context.Context, name string) (*BucketInfo, error) + GetBucketACL(ctx context.Context, name string) (*BucketACL, error) + PutBucketACL(ctx context.Context, p *PutBucketACLParams) error CreateBucket(ctx context.Context, p *CreateBucketParams) (*cid.ID, error) DeleteBucket(ctx context.Context, p *DeleteBucketParams) error @@ -204,6 +213,34 @@ func (n *layer) GetBucketInfo(ctx context.Context, name string) (*BucketInfo, er return n.containerInfo(ctx, containerID) } +// GetBucketACL returns bucket acl info by name. +func (n *layer) GetBucketACL(ctx context.Context, name string) (*BucketACL, error) { + inf, err := n.GetBucketInfo(ctx, name) + if err != nil { + return nil, err + } + + eacl, err := n.GetContainerEACL(ctx, inf.CID) + if err != nil { + return nil, err + } + + return &BucketACL{ + Info: inf, + EACL: eacl, + }, nil +} + +// PutBucketACL put bucket acl by name. +func (n *layer) PutBucketACL(ctx context.Context, param *PutBucketACLParams) error { + inf, err := n.GetBucketInfo(ctx, param.Name) + if err != nil { + return err + } + + return n.setContainerEACLTable(ctx, inf.CID, param.EACL) +} + // ListBuckets returns all user containers. Name of the bucket is a container // id. Timestamp is omitted since it is not saved in neofs container. func (n *layer) ListBuckets(ctx context.Context) ([]*BucketInfo, error) {