mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +00:00
Bucket policy `Principal` named callers by access key id. Under the standalone IAM service it now names them by AWS-style ARN, as real S3 does: a user ARN, a role ARN (covering every session of that role), an assumed-role ARN (covering one session), the account root ARN or bare account id, or `*`. Every other IAM backend has no ARNs to name anything by and keeps access-key principals unchanged, gated on a new `auth.PrincipalResolver` capability interface that only the standalone client implements. `auth.Account` carries `Arn` and `RoleArn`, filled at authentication time, so a session can be matched against both its own ARN and its role's. Principals are validated at PutBucketPolicy time through a new `/private/resolve-principals` endpoint, which rejects anything that does not name a live identity with `MalformedPolicy: Invalid principal in policy`. An `Allow` naming the account root ARN or bare account id delegates to the account's own IAM rather than granting on its own, while a `Deny` naming it denies every principal in the account outright. Denial messages now name the caller by ARN wherever one exists. Also fixes `aws:PrincipalArn` for assumed-role sessions, which reported the session ARN where AWS reports the role's, and stops an unreachable IAM service being reported as a malformed policy.
665 lines
25 KiB
Go
665 lines
25 KiB
Go
// Copyright 2023 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
package auth
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/versity/versitygw/backend"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
func VerifyObjectCopyAccess(ctx fiber.Ctx, be backend.Backend, copySource string, opts AccessOptions) error {
|
|
// Verify destination bucket access first. VerifyAccess enforces the
|
|
// readonly gate before its own root/admin bypass, and that ordering
|
|
// must hold here too — readonly mode blocks writes for everyone,
|
|
// including root/admin, not just ordinary users.
|
|
if err := VerifyAccess(ctx, be, opts); err != nil {
|
|
return err
|
|
}
|
|
// Root/admin already cleared the destination check above; skip the
|
|
// source-bucket ACL lookup entirely for them, same as before.
|
|
if opts.IsRoot {
|
|
return nil
|
|
}
|
|
if opts.Acc.Role == RoleAdmin {
|
|
return nil
|
|
}
|
|
|
|
// Verify source bucket access.
|
|
// URL-decode the copy source before splitting so that clients which send
|
|
// the bucket/key separator as "%2F" are handled correctly.
|
|
// Callers are expected to have already stripped any leading '/'.
|
|
decodedSrc, err := url.QueryUnescape(copySource)
|
|
if err != nil {
|
|
return s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceEncoding, copySource)
|
|
}
|
|
srcBucket, srcObject, found := strings.Cut(decodedSrc, "/")
|
|
if !found {
|
|
return s3err.GetInvalidArgumentErr(s3err.InvalidArgCopySourceBucket, copySource)
|
|
}
|
|
|
|
// Get source bucket ACL
|
|
srcBucketACLBytes, err := be.GetBucketAcl(ctx.RequestCtx(), &s3.GetBucketAclInput{Bucket: &srcBucket})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
var srcBucketAcl ACL
|
|
if err := json.Unmarshal(srcBucketACLBytes, &srcBucketAcl); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := VerifyAccess(ctx, be, AccessOptions{
|
|
Acl: srcBucketAcl,
|
|
AclPermission: PermissionRead,
|
|
IsRoot: opts.IsRoot,
|
|
Acc: opts.Acc,
|
|
Bucket: srcBucket,
|
|
Object: srcObject,
|
|
Actions: []Action{GetObjectAction},
|
|
Iam: opts.Iam,
|
|
DisableACL: opts.DisableACL,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type AccessOptions struct {
|
|
Acl ACL
|
|
AclPermission Permission
|
|
IsRoot bool
|
|
Acc Account
|
|
Bucket string
|
|
Object string
|
|
Actions []Action
|
|
Readonly bool
|
|
IsPublicRequest bool
|
|
DisableACL bool
|
|
Iam IAMService
|
|
}
|
|
|
|
// VerifyAccess decides whether opts.Acc may perform opts.Actions against
|
|
// opts.Bucket/opts.Object, combining the bucket's own resource-based
|
|
// decision (policy, or ACL absent one) with an identity-based decision from
|
|
// opts.Iam when it implements PolicyEvaluator. An explicit Deny from either
|
|
// source denies the request outright, even when the other source would
|
|
// otherwise allow it; absent any explicit Deny, either source's Allow is
|
|
// independently sufficient; absent both, the request is denied. All three
|
|
// denial shapes are Code: AccessDenied, HTTP 403 — differing only in the
|
|
// dynamic Message text.
|
|
func VerifyAccess(ctx fiber.Ctx, be backend.Backend, opts AccessOptions) error {
|
|
if err := verifyAccessGates(opts); err != nil || !authorizationApplies(opts) {
|
|
return err
|
|
}
|
|
|
|
errs, err := objectsAccessErrors(ctx.RequestCtx(), be, opts, []string{opts.Object}, requestConditionContext(ctx))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return errs[0]
|
|
}
|
|
|
|
// VerifyObjectsAccess authorizes a multi-object delete — the parsed contents
|
|
// of a DeleteObjects request body, passed straight through. It answers, for
|
|
// every object independently, whether policy allows deleting it and whether
|
|
// an object lock protects it, in a single pass. DeleteObjects supports
|
|
// partial success — unlike every other write path — so a denial on one
|
|
// object must not affect any other: the caller sends only the objects that
|
|
// pass through to the backend, and reports the rest as per-object errors
|
|
// straight from the returned slice.
|
|
//
|
|
// Both halves are deliberately here rather than split across the caller: the
|
|
// per-object work needs one loop, not one loop per concern at a different
|
|
// layer, and the expensive parts of each half — the bucket policy, the
|
|
// batched identity-policy round trip, the bucket's lock configuration — are
|
|
// resolved once up front for the whole request.
|
|
//
|
|
// Every key is authorized against its own object ARN, the way real AWS does
|
|
// it: a policy granting s3:DeleteObject on "arn:aws:s3:::bucket/*" and
|
|
// nothing else deletes successfully. An object named with a VersionId is
|
|
// authorized against s3:DeleteObjectVersion instead of s3:DeleteObject, the
|
|
// same split the single-object DELETE path already makes: a policy granting
|
|
// only s3:DeleteObject denies the versioned deletes in the same batch that
|
|
// its keyed deletes succeed under, and the batch's response reports that
|
|
// denial on just that object, the rest unaffected.
|
|
//
|
|
// The returned slice has one entry per object: nil where that object may
|
|
// proceed, an AWS-shaped denial otherwise. opts.Object and opts.Actions are
|
|
// both ignored in favor of objects. The second return is non-nil only for a
|
|
// failure that isn't about any one object — readonly mode, or an error
|
|
// resolving the bucket's policy or lock configuration — and fails the whole
|
|
// request, matching what a hard failure did before this returned per-object
|
|
// results at all.
|
|
func VerifyObjectsAccess(ctx fiber.Ctx, be backend.Backend, opts AccessOptions, objects []types.ObjectIdentifier, bypass BypassMode) ([]error, error) {
|
|
if err := verifyAccessGates(opts); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(objects) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
rctx := ctx.RequestCtx()
|
|
condCtx := requestConditionContext(ctx)
|
|
|
|
keys := make([]string, len(objects))
|
|
for i, obj := range objects {
|
|
if obj.Key != nil {
|
|
keys[i] = *obj.Key
|
|
}
|
|
}
|
|
|
|
errs := make([]error, len(objects))
|
|
|
|
// Authorization doesn't apply to root, admin, or a public-bucket
|
|
// request — errs stays all-nil from policy's perspective, and object
|
|
// locks still apply to them, so the loop below runs either way.
|
|
if authorizationApplies(opts) {
|
|
var plainIdx, versionedIdx []int
|
|
for i, obj := range objects {
|
|
if obj.VersionId != nil && *obj.VersionId != "" {
|
|
versionedIdx = append(versionedIdx, i)
|
|
} else {
|
|
plainIdx = append(plainIdx, i)
|
|
}
|
|
}
|
|
|
|
if err := authorizeObjectSubset(rctx, be, opts, keys, plainIdx, DeleteObjectAction, errs, condCtx); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := authorizeObjectSubset(rctx, be, opts, keys, versionedIdx, DeleteObjectVersionAction, errs, condCtx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
lockState, err := loadObjectLockState(rctx, be, opts.Bucket, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if lockState.applies {
|
|
for i, obj := range objects {
|
|
if errs[i] != nil {
|
|
// Already denied by policy — no need to also resolve this
|
|
// object's lock state, and a lock error here would only
|
|
// overwrite the more specific policy denial.
|
|
continue
|
|
}
|
|
if err := lockState.checkObject(rctx, be, opts.Iam, opts.Acc, opts.Bucket, obj, bypass, opts.IsPublicRequest, condCtx); err != nil {
|
|
errs[i] = err
|
|
}
|
|
}
|
|
}
|
|
|
|
return errs, nil
|
|
}
|
|
|
|
// authorizeObjectSubset runs objectsAccessErrors for the objects at idx (a
|
|
// subset of keys, given by original index) against a single action, and
|
|
// scatters the results back into errs at their original positions. Splitting
|
|
// DeleteObjects' batch into one group per action this way keeps the
|
|
// round-trip count at one per distinct action in the batch — normally one or
|
|
// two — rather than one per object.
|
|
func authorizeObjectSubset(ctx context.Context, be backend.Backend, opts AccessOptions, keys []string, idx []int, action Action, errs []error, condCtx map[string][]string) error {
|
|
if len(idx) == 0 {
|
|
return nil
|
|
}
|
|
|
|
subKeys := make([]string, len(idx))
|
|
for i, origIdx := range idx {
|
|
subKeys[i] = keys[origIdx]
|
|
}
|
|
|
|
subOpts := opts
|
|
subOpts.Actions = []Action{action}
|
|
subErrs, err := objectsAccessErrors(ctx, be, subOpts, subKeys, condCtx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, origIdx := range idx {
|
|
errs[origIdx] = subErrs[i]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// verifyAccessGates applies the checks that depend on gateway configuration
|
|
// rather than on the caller's policies. Readonly mode blocks writes for
|
|
// everyone, root and admin included, which is why it runs before any bypass.
|
|
func verifyAccessGates(opts AccessOptions) error {
|
|
if opts.Readonly {
|
|
if opts.AclPermission == PermissionWrite || opts.AclPermission == PermissionWriteAcp {
|
|
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// authorizationApplies reports whether policy/ACL evaluation is meaningful
|
|
// for this caller at all. It is not for an anonymous request to a public
|
|
// bucket (already authorized by the public-access check) nor for root/admin
|
|
// (who bypass policy entirely — though not object locks).
|
|
func authorizationApplies(opts AccessOptions) bool {
|
|
return !opts.IsPublicRequest && !opts.IsRoot && opts.Acc.Role != RoleAdmin
|
|
}
|
|
|
|
// objectsAccessErrors evaluates every key against the bucket's resource
|
|
// policy (or ACL) and the caller's identity policy, returning one result per
|
|
// key: nil where the key is authorized, and the AWS-shaped denial otherwise.
|
|
// The returned slice always has one entry per key.
|
|
//
|
|
// The keys are evaluated as one batch, not one VerifyAccess call each: the
|
|
// bucket policy is fetched once, and the identity policy is evaluated for
|
|
// every key in a single round trip to the IAM service. A per-key loop would
|
|
// cost a backend call and a network round trip per object, and DeleteObjects
|
|
// accepts up to 1000 of them.
|
|
func objectsAccessErrors(ctx context.Context, be backend.Backend, opts AccessOptions, keys []string, condCtx map[string][]string) ([]error, error) {
|
|
resourceDecisions, err := verifyResourceAccess(ctx, be, opts, keys, condCtx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
errs := make([]error, len(keys))
|
|
|
|
// An explicit deny from the bucket policy wins outright, whatever the
|
|
// IAM backend is and whatever an identity policy would have said, so
|
|
// every denied key is settled here and never revisited below. Each key
|
|
// is settled on its own: this is a partial-success API, so a deny on one
|
|
// key says nothing about the next one, which still has to be evaluated
|
|
// on its own merits.
|
|
allDenied := true
|
|
for i, rd := range resourceDecisions {
|
|
if rd.Decision == policyDecisionDeny {
|
|
errs[i] = s3err.GetExplicitDenyAccessErr(principalName(opts.Acc), string(rd.Action), objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey), "a resource-based policy")
|
|
continue
|
|
}
|
|
allDenied = false
|
|
}
|
|
// Nothing is left to decide, so skip the identity policy entirely —
|
|
// which also saves the standalone IAM service round trip. That shortcut
|
|
// only holds when the bucket policy denied every key; a single
|
|
// undecided key still needs the identity policy consulted for it.
|
|
if allDenied {
|
|
return errs, nil
|
|
}
|
|
|
|
pe, hasPolicyEvaluator := opts.Iam.(PolicyEvaluator)
|
|
if !hasPolicyEvaluator {
|
|
// No identity-policy layer exists for this backend at all: preserve
|
|
// today's exact behavior and generic message, unconditionally, for
|
|
// every internal/LDAP/Vault/IPA/S3-IAM deployment.
|
|
for i, rd := range resourceDecisions {
|
|
if errs[i] != nil {
|
|
// Explicitly denied above — keep that specific message
|
|
// rather than flattening it to the generic one.
|
|
continue
|
|
}
|
|
if rd.Decision != policyDecisionAllow {
|
|
errs[i] = s3err.GetAPIError(s3err.ErrAccessDenied)
|
|
}
|
|
}
|
|
return errs, nil
|
|
}
|
|
|
|
identity, err := identityPolicyDecisions(pe, opts, keys, be.NormalizeObjectKey, condCtx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
principal := identity.PrincipalArn
|
|
if principal == "" {
|
|
principal = principalName(opts.Acc)
|
|
}
|
|
|
|
for i := range keys {
|
|
if errs[i] != nil {
|
|
// Explicitly denied by the bucket policy. An explicit deny is
|
|
// final, so no identity-policy result can clear it, and the
|
|
// resource-based message is the one AWS reports for it.
|
|
continue
|
|
}
|
|
|
|
resourceArn := objectPolicyArn(opts.Bucket, keys[i], be.NormalizeObjectKey)
|
|
|
|
if identity.Decisions[i].Decision == policyDecisionDeny {
|
|
errs[i] = s3err.GetExplicitDenyAccessErr(principal, string(identity.Decisions[i].Action), resourceArn, "an identity-based policy")
|
|
continue
|
|
}
|
|
if identity.HasSessionPolicy && identity.SessionDecisions[i].Decision == policyDecisionDeny {
|
|
errs[i] = s3err.GetExplicitDenyAccessErr(principal, string(identity.SessionDecisions[i].Action), resourceArn, "an identity-based policy")
|
|
continue
|
|
}
|
|
|
|
granted := resourceDecisions[i].Decision == policyDecisionAllow ||
|
|
identity.Decisions[i].Decision == policyDecisionAllow
|
|
|
|
// A session policy filters everything the session can do — including
|
|
// what the bucket policy granted it, not just what the role's own
|
|
// policies did. Confirmed against real AWS: a role with no identity
|
|
// policy at all, a bucket policy granting it both s3:GetObject and
|
|
// s3:PutObject, and a session policy allowing only s3:GetObject
|
|
// yields a successful Get and a denied Put.
|
|
if identity.HasSessionPolicy && identity.SessionDecisions[i].Decision != policyDecisionAllow {
|
|
granted = false
|
|
}
|
|
if granted {
|
|
continue
|
|
}
|
|
|
|
blamedAction := resourceDecisions[i].Action
|
|
if blamedAction == "" {
|
|
blamedAction = identity.Decisions[i].Action
|
|
}
|
|
if blamedAction == "" && identity.HasSessionPolicy {
|
|
blamedAction = identity.SessionDecisions[i].Action
|
|
}
|
|
errs[i] = s3err.GetImplicitDenyAccessErr(principal, string(blamedAction), resourceArn)
|
|
}
|
|
|
|
return errs, nil
|
|
}
|
|
|
|
// principalName is how a denial message names acc: by its principal ARN
|
|
// where the IAM backend gives it one, and by its access key id otherwise —
|
|
// the only name the other backends have for it.
|
|
func principalName(acc Account) string {
|
|
if acc.Arn != "" {
|
|
return acc.Arn
|
|
}
|
|
return acc.Access
|
|
}
|
|
|
|
// decisionForResource is one resource's tri-state decision plus, for
|
|
// Deny/NoMatch, the specific action responsible — so the caller can build an
|
|
// AWS-shaped message naming it.
|
|
type decisionForResource struct {
|
|
Decision policyDecision
|
|
Action Action
|
|
}
|
|
|
|
// verifyResourceAccess checks the bucket's own policy or, absent one, ACL,
|
|
// for each object key, returning one decision per key. The bucket policy is
|
|
// fetched once regardless of how many keys there are. ACL evaluation can
|
|
// only ever produce Allow/NoMatch — ACLs have no concept of an explicit
|
|
// deny — and applies to the whole bucket, so every key shares its verdict.
|
|
func verifyResourceAccess(ctx context.Context, be backend.Backend, opts AccessOptions, objects []string, condCtx map[string][]string) ([]decisionForResource, error) {
|
|
decisions := make([]decisionForResource, len(objects))
|
|
|
|
policy, policyErr := be.GetBucketPolicy(ctx, opts.Bucket)
|
|
if policyErr != nil {
|
|
if !errors.Is(policyErr, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) {
|
|
return nil, policyErr
|
|
}
|
|
|
|
decision := policyDecisionAllow
|
|
if err := verifyACL(opts.Acl, opts.Acc.Access, opts.AclPermission, opts.DisableACL); err != nil {
|
|
decision = policyDecisionNoMatch
|
|
}
|
|
for i := range decisions {
|
|
decisions[i] = decisionForResource{Decision: decision}
|
|
}
|
|
return decisions, nil
|
|
}
|
|
|
|
for i, object := range objects {
|
|
decision, action, err := verifyBucketPolicy(policy, opts.Acc, opts.Bucket, object, condCtx, be.NormalizeObjectKey, opts.Actions...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
decisions[i] = decisionForResource{Decision: decision, Action: action}
|
|
}
|
|
return decisions, nil
|
|
}
|
|
|
|
// identityPolicyDecisions evaluates every action in opts.Actions against
|
|
// every object key, all in a single request, and aggregates each key's
|
|
// actions with the same precedence bucketPolicyDecision uses for a bucket
|
|
// policy: a Deny on any action wins immediately; otherwise Allow only if
|
|
// every action has a matching Allow; otherwise NoMatch, paired with the
|
|
// first action that lacked one.
|
|
//
|
|
// It returns one decision per key, plus the resolved principal ARN, which is
|
|
// shared across the whole batch since one call always evaluates a single
|
|
// identity.
|
|
func identityPolicyDecisions(pe PolicyEvaluator, opts AccessOptions, objects []string, normalizeObjectKey objectKeyNormalizer, condition map[string][]string) (identityDecisions, error) {
|
|
resources := make([]string, len(objects))
|
|
for i, object := range objects {
|
|
resources[i] = objectPolicyArn(opts.Bucket, object, normalizeObjectKey)
|
|
}
|
|
|
|
eval, err := pe.EvaluatePolicy(opts.Acc.Access, opts.Acc.SessionToken, opts.Actions, resources, condition)
|
|
if err != nil {
|
|
return identityDecisions{}, err
|
|
}
|
|
|
|
decisions, err := aggregateActionDecisions(eval.Decisions, resources, opts.Actions)
|
|
if err != nil {
|
|
return identityDecisions{}, err
|
|
}
|
|
|
|
result := identityDecisions{Decisions: decisions, PrincipalArn: eval.PrincipalArn}
|
|
if eval.HasSessionPolicy {
|
|
sessionDecisions, err := aggregateActionDecisions(eval.SessionDecisions, resources, opts.Actions)
|
|
if err != nil {
|
|
return identityDecisions{}, err
|
|
}
|
|
result.HasSessionPolicy = true
|
|
result.SessionDecisions = sessionDecisions
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// identityDecisions is identityPolicyDecisions' result: one aggregated
|
|
// decision per object from the caller's identity policies, the same from its
|
|
// session policy when it has one, and the resolved principal ARN.
|
|
type identityDecisions struct {
|
|
Decisions []decisionForResource
|
|
SessionDecisions []decisionForResource
|
|
HasSessionPolicy bool
|
|
PrincipalArn string
|
|
}
|
|
|
|
// aggregateActionDecisions collapses each resource's per-action decisions
|
|
// into one, using the same precedence bucketPolicyDecision uses: a Deny on
|
|
// any action wins immediately; otherwise Allow only if every action has a
|
|
// matching Allow; otherwise NoMatch, paired with the first action that
|
|
// lacked one.
|
|
func aggregateActionDecisions(matrix [][]policyDecision, resources []string, actions []Action) ([]decisionForResource, error) {
|
|
if len(matrix) != len(resources) {
|
|
// A protocol mismatch between the gateway and IAM service builds —
|
|
// fail closed rather than authorizing a key nobody evaluated.
|
|
return nil, fmt.Errorf("evaluate policy returned %d resource decisions for %d resources", len(matrix), len(resources))
|
|
}
|
|
|
|
results := make([]decisionForResource, len(resources))
|
|
for i, perAction := range matrix {
|
|
if len(perAction) != len(actions) {
|
|
return nil, fmt.Errorf("evaluate policy returned %d action decisions for %d actions", len(perAction), len(actions))
|
|
}
|
|
|
|
result := decisionForResource{Decision: policyDecisionAllow}
|
|
for j, decision := range perAction {
|
|
if decision == policyDecisionDeny {
|
|
result = decisionForResource{Decision: policyDecisionDeny, Action: actions[j]}
|
|
break
|
|
}
|
|
if decision == policyDecisionNoMatch && result.Decision != policyDecisionNoMatch {
|
|
result.Decision = policyDecisionNoMatch
|
|
result.Action = actions[j]
|
|
}
|
|
}
|
|
results[i] = result
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// objectPolicyArn builds the ARN a policy statement is matched against for
|
|
// one bucket/object pair — the bucket's own ARN when object is empty.
|
|
func objectPolicyArn(bucket, object string, normalizeObjectKey objectKeyNormalizer) string {
|
|
return ResourceArnPrefix + makePolicyResource(bucket, object, normalizeObjectKey)
|
|
}
|
|
|
|
// VerifyPublicAccess checks if the bucket is publically accessible by ACL or Policy
|
|
func VerifyPublicAccess(ctx fiber.Ctx, be backend.Backend, action Action, permission Permission, bucket, object string) error {
|
|
// ACL disabled
|
|
policy, err := be.GetBucketPolicy(ctx.RequestCtx(), bucket)
|
|
if err != nil && !errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchBucketPolicy)) {
|
|
return err
|
|
}
|
|
if err == nil {
|
|
err = VerifyPublicBucketPolicy(policy, bucket, object, requestConditionContext(ctx), be.NormalizeObjectKey, action)
|
|
if errors.Is(err, errExplicitDeny) {
|
|
// Explicit public-policy Deny has higher precedence than any
|
|
// public ACL grant, so do not continue to ACL fallback.
|
|
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
|
}
|
|
if err == nil {
|
|
// if ACLs are disabled, and the bucket grants public access,
|
|
// policy actions should return 'MethodNotAllowed'
|
|
switch action {
|
|
case GetBucketPolicyAction:
|
|
return s3err.GetMethodNotAllowedErr(http.MethodGet, s3err.ResourceTypeBucketPolicy, nil)
|
|
case PutBucketPolicyAction:
|
|
return s3err.GetMethodNotAllowedErr(http.MethodPut, s3err.ResourceTypeBucketPolicy, nil)
|
|
case DeleteBucketPolicyAction:
|
|
return s3err.GetMethodNotAllowedErr(http.MethodDelete, s3err.ResourceTypeBucketPolicy, nil)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// if the action is not in the ACL whitelist the access is denied
|
|
_, ok := publicACLAllowedActions[action]
|
|
if !ok {
|
|
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
|
}
|
|
|
|
err = VerifyPublicBucketACL(ctx.RequestCtx(), be, bucket, action, permission)
|
|
if err != nil {
|
|
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// VerifyCreateBucketAccess decides whether acc may create a bucket named
|
|
// bucket. Unlike VerifyAccess, the bucket doesn't exist yet at this point,
|
|
// so there is no bucket policy or ACL to consult — root/admin always
|
|
// bypass, and otherwise authorization comes from whichever mechanism the
|
|
// configured iam backend actually supports: for backends that implement
|
|
// PolicyEvaluator (currently only the standalone IAM service client), an
|
|
// identity-policy Allow for s3:CreateBucket grants access, exactly like any
|
|
// other IAM-policy-gated action; the legacy userplus-role bypass applies
|
|
// only to backends with no such policy layer (internal/LDAP/Vault/IPA/S3-IAM),
|
|
// since those have no other way to grant a plain "user" account this
|
|
// permission.
|
|
func VerifyCreateBucketAccess(ctx fiber.Ctx, iam IAMService, isRoot bool, acc Account, bucket string) error {
|
|
if isRoot || acc.Role == RoleAdmin {
|
|
return nil
|
|
}
|
|
|
|
pe, hasPolicyEvaluator := iam.(PolicyEvaluator)
|
|
if !hasPolicyEvaluator {
|
|
if acc.Role == RoleUserPlus {
|
|
return nil
|
|
}
|
|
return s3err.GetAPIError(s3err.ErrAccessDenied)
|
|
}
|
|
|
|
return verifyIdentityOnlyAccess(ctx, pe, acc, CreateBucketAction, bucket)
|
|
}
|
|
|
|
// VerifyListAllMyBucketsAccess decides whether acc may list buckets. The
|
|
// request names no bucket, so only identity policies apply: an Allow for
|
|
// s3:ListAllMyBuckets on "arn:aws:s3:::*", the ARN AWS's own bucket-listing
|
|
// policy names. Backends with no identity-policy layer already narrow the
|
|
// listing to the caller's own buckets, so they need no permission of their own.
|
|
func VerifyListAllMyBucketsAccess(ctx fiber.Ctx, iam IAMService, isRoot bool, acc Account) error {
|
|
if isRoot || acc.Role == RoleAdmin {
|
|
return nil
|
|
}
|
|
|
|
pe, hasPolicyEvaluator := iam.(PolicyEvaluator)
|
|
if !hasPolicyEvaluator {
|
|
return nil
|
|
}
|
|
|
|
return verifyIdentityOnlyAccess(ctx, pe, acc, ListAllMyBucketsAction, "*")
|
|
}
|
|
|
|
// verifyIdentityOnlyAccess decides one action from the caller's identity
|
|
// policies alone, for requests naming no existing bucket and therefore no
|
|
// resource-based policy. resource is the ARN part after "arn:aws:s3:::": a
|
|
// bucket name for CreateBucket, "*" for an account-level action.
|
|
func verifyIdentityOnlyAccess(ctx fiber.Ctx, pe PolicyEvaluator, acc Account, action Action, resource string) error {
|
|
resourceArn := ResourceArnPrefix + resource
|
|
identity, err := identityPolicyDecisions(pe, AccessOptions{
|
|
Acc: acc,
|
|
Bucket: resource,
|
|
Actions: []Action{action},
|
|
}, []string{""}, nil, requestConditionContext(ctx))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
principal := identity.PrincipalArn
|
|
if principal == "" {
|
|
principal = principalName(acc)
|
|
}
|
|
|
|
// A session policy narrows what the session may do; there is no resource
|
|
// policy to combine with here, so the two decisions simply intersect.
|
|
decision := identity.Decisions[0].Decision
|
|
if identity.HasSessionPolicy {
|
|
switch sd := identity.SessionDecisions[0].Decision; {
|
|
case sd == policyDecisionDeny:
|
|
decision = policyDecisionDeny
|
|
case sd != policyDecisionAllow && decision == policyDecisionAllow:
|
|
decision = policyDecisionNoMatch
|
|
}
|
|
}
|
|
|
|
switch decision {
|
|
case policyDecisionDeny:
|
|
return s3err.GetExplicitDenyAccessErr(principal, string(action), resourceArn, "an identity-based policy")
|
|
case policyDecisionAllow:
|
|
return nil
|
|
}
|
|
return s3err.GetImplicitDenyAccessErr(principal, string(action), resourceArn)
|
|
}
|
|
|
|
type PublicACLAllowedActions map[Action]struct{}
|
|
|
|
var publicACLAllowedActions PublicACLAllowedActions = PublicACLAllowedActions{
|
|
ListBucketAction: struct{}{},
|
|
PutObjectAction: struct{}{},
|
|
ListBucketMultipartUploadsAction: struct{}{},
|
|
DeleteObjectAction: struct{}{},
|
|
ListBucketVersionsAction: struct{}{},
|
|
GetObjectAction: struct{}{},
|
|
GetObjectAttributesAction: struct{}{},
|
|
GetObjectAclAction: struct{}{},
|
|
}
|