Files
versitygw/auth/bucket_policy.go
T
niksis02 c84c5f645a feat: accept principal ARNs in bucket policies under standalone IAM
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.
2026-09-03 23:51:55 +04:00

439 lines
15 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 (
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/versity/versitygw/internal/condition"
"github.com/versity/versitygw/s3err"
)
var errAccessDenied = errors.New("access denied")
var errExplicitDeny = errors.New("explicit deny")
// policyDecision preserves the difference between "not allowed" and
// "denied". Public bucket authorization needs that distinction so no-match
// can fall back to ACLs while explicit Deny cannot; VerifyAccess needs it to
// combine a bucket policy's decision with an identity policy's own — an
// explicit Deny from either source must override an Allow from the other,
// which a plain bool can't express.
type policyDecision int
const (
policyDecisionNoMatch policyDecision = iota
policyDecisionAllow
policyDecisionDeny
)
type policyErr string
func (p policyErr) Error() string {
return string(p)
}
const (
policyErrResourceMismatch = policyErr("Action does not apply to any resource(s) in statement")
policyErrInvalidResource = policyErr("Policy has invalid resource")
policyErrInvalidPrincipal = policyErr("Invalid principal in policy")
policyErrInvalidAction = policyErr("Policy has invalid action")
policyErrInvalidPolicy = policyErr("This policy contains invalid Json")
policyErrInvalidFirstChar = policyErr("Policies must be valid JSON and the first byte must be '{'")
policyErrEmptyStatement = policyErr("Could not parse the policy: Statement is empty!")
policyErrMissingStatmentField = policyErr("Missing required field Statement")
policyErrInvalidVersion = policyErr("The policy must contain a valid version string")
policyErrInvalidConditionKey = policyErr("Policy has an invalid condition key")
policyErrConditionActionMismatch = policyErr("Conditions do not apply to combination of actions and resources in statement")
policyErrInvalidIPCondition = policyErr("Invalid IP address in Conditions")
)
type BucketPolicy struct {
Version PolicyVersion `json:"Version"`
Statement []BucketPolicyItem `json:"Statement"`
}
type objectKeyNormalizer func(bucket, object string) string
func (bp *BucketPolicy) UnmarshalJSON(data []byte) error {
var tmp struct {
Version *PolicyVersion
Statement *[]BucketPolicyItem `json:"Statement"`
}
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
// If Statement is nil (not present in JSON), return an error
if tmp.Statement == nil {
return policyErrMissingStatmentField
}
if tmp.Version == nil {
// bucket policy version should default to '2008-10-17'
bp.Version = PolicyVersion2008
} else {
bp.Version = *tmp.Version
}
bp.Statement = *tmp.Statement
return nil
}
func (bp *BucketPolicy) Validate(bucket string, iam IAMService) error {
if !bp.Version.isValid() {
return policyErrInvalidVersion
}
for _, statement := range bp.Statement {
err := statement.Validate(bucket, iam)
if err != nil {
return err
}
}
return nil
}
// decisionFor evaluates a single action against bp for acc/resource,
// returning the tri-state policyDecision. A statement whose principal/action/resource
// otherwise matches but whose Condition block can't be evaluated
// denies the whole decision immediately, regardless of that statement's own
// Effect — the same "can't rule out a hidden Deny" fail-closed contract
// iamapi/policy.EvaluateIdentityPolicies uses for identity policies,
// enforced per-statement here instead of per-document. In practice this
// branch is unreachable for any policy PutBucketPolicy accepted after
// Condition write-time validation existed — it only guards a document
// stored before that validation existed, or naming a future operator the
// gateway doesn't yet recognize.
func (bp *BucketPolicy) decisionFor(acc Account, action Action, resource string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer) policyDecision {
var isAllowed bool
for _, statement := range bp.Statement {
matched, evaluable := statement.findMatch(acc, action, resource, condCtx, bp.Version, normalizeObjectKey)
if !evaluable {
return policyDecisionDeny
}
if !matched {
continue
}
switch statement.Effect {
case BucketPolicyAccessTypeAllow:
isAllowed = true
case BucketPolicyAccessTypeDeny:
return policyDecisionDeny
}
}
if isAllowed {
return policyDecisionAllow
}
return policyDecisionNoMatch
}
// publicDecisionFor mirrors decisionFor for the anonymous/public-bucket-access
// path
func (bp *BucketPolicy) publicDecisionFor(resource string, action Action, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer) policyDecision {
var isAllowed bool
for _, statement := range bp.Statement {
matched, evaluable := statement.isPublicFor(resource, action, condCtx, bp.Version, normalizeObjectKey)
if !evaluable {
return policyDecisionDeny
}
if !matched {
continue
}
switch statement.Effect {
case BucketPolicyAccessTypeAllow:
isAllowed = true
case BucketPolicyAccessTypeDeny:
return policyDecisionDeny
}
}
// A matching Allow grants access only when no matching Deny was found.
if isAllowed {
return policyDecisionAllow
}
return policyDecisionNoMatch
}
// IsPublic checks if one of bucket policy statments grant
// public access to ALL users
func (bp *BucketPolicy) IsPublic() bool {
for _, statement := range bp.Statement {
if statement.isPublic() {
return true
}
}
return false
}
type BucketPolicyItem struct {
Effect BucketPolicyAccessType `json:"Effect"`
Principals Principals `json:"Principal"`
Actions Actions `json:"Action"`
Resources Resources `json:"Resource"`
Condition json.RawMessage `json:"Condition,omitempty"`
}
func (bpi *BucketPolicyItem) Validate(bucket string, iam IAMService) error {
if err := bpi.Effect.Validate(); err != nil {
return err
}
if err := bpi.Principals.Validate(iam); err != nil {
return err
}
if err := bpi.Resources.Validate(bucket); err != nil {
return err
}
// Condition applicability is checked before the action/resource-type
// pairing below: AWS reports a Condition key that doesn't apply to the
// statement's actions even when those actions also don't apply to the
// statement's resource type, e.g. s3:prefix with s3:ListBucketMultipartUploads
// against an object resource — reported as the Condition mismatch, not
// the resource-type one.
if err := validateBucketPolicyCondition(bpi.Condition, bpi.Actions); err != nil {
return err
}
containsObjectAction := bpi.Resources.ContainsObjectPattern()
containsBucketAction := bpi.Resources.ContainsBucketPattern()
for action := range bpi.Actions {
isObjectAction := action.IsObjectAction()
if isObjectAction == nil {
break
}
if *isObjectAction && !containsObjectAction {
return policyErrResourceMismatch
}
if !*isObjectAction && !containsBucketAction {
return policyErrResourceMismatch
}
}
return nil
}
// findMatch reports whether the statement's principal/action/resource cover
// this request, and — only when they do — whether its Condition block holds
// against condCtx. matched is only meaningful when evaluable is true; see
// condition.Evaluate and decisionFor's fail-closed handling of evaluable =false.
func (bpi *BucketPolicyItem) findMatch(acc Account, action Action, resource string, condCtx map[string][]string, version PolicyVersion, normalizeObjectKey objectKeyNormalizer) (matched bool, evaluable bool) {
if !(bpi.matchesPrincipal(acc) && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey)) {
return false, true
}
return condition.Evaluate(bpi.Condition, condCtx, string(version))
}
// matchesPrincipal reports whether this statement's Principal element
// covers acc, given what this statement's Effect makes of an account-level
// match.
//
// A statement naming the account — its root ARN, or the bare account id —
// only delegates to the account: it says the account's own IAM may grant
// this, not that this is granted. So it allows nothing by itself, and a
// caller under it is authorized only if an identity policy independently
// allows the request, which VerifyAccess already covers by treating either
// source's Allow as sufficient. Skipping the statement here is what makes
// the account-level Allow contribute nothing.
//
// Deny is not symmetric with that: a statement denying the account denies
// every principal in it outright, delegating nothing.
func (bpi *BucketPolicyItem) matchesPrincipal(acc Account) bool {
switch bpi.Principals.matchFor(acc) {
case principalDirectMatch:
return true
case principalAccountMatch:
return bpi.Effect == BucketPolicyAccessTypeDeny
default:
return false
}
}
// isPublicFor checks if the bucket policy statement grants public access
// for given resource and action, and — only when it otherwise matches —
// whether its Condition block holds against condCtx. A public statement's
// Condition is evaluated with whatever request-derived keys condCtx carries;
// there is no caller identity to resolve for an anonymous request
func (bpi *BucketPolicyItem) isPublicFor(resource string, action Action, condCtx map[string][]string, version PolicyVersion, normalizeObjectKey objectKeyNormalizer) (matched bool, evaluable bool) {
if !(bpi.Principals.isPublic() && bpi.Actions.FindMatch(action) && bpi.Resources.FindMatch(resource, normalizeObjectKey)) {
return false, true
}
return condition.Evaluate(bpi.Condition, condCtx, string(version))
}
// isPublic checks if the statement grants public access
// to ALL users
func (bpi *BucketPolicyItem) isPublic() bool {
return bpi.Principals.isPublic()
}
func getMalformedPolicyError(err error) error {
return s3err.APIError{
Code: "MalformedPolicy",
Description: err.Error(),
HTTPStatusCode: http.StatusBadRequest,
}
}
// ParsePolicyDocument parses raw bytes to 'BucketPolicy'
func ParsePolicyDocument(data []byte) (*BucketPolicy, error) {
var policy BucketPolicy
if err := json.Unmarshal(data, &policy); err != nil {
var pe policyErr
if errors.As(err, &pe) {
return nil, getMalformedPolicyError(err)
}
return nil, getMalformedPolicyError(policyErrInvalidPolicy)
}
return &policy, nil
}
func ValidatePolicyDocument(policyBin []byte, bucket string, iam IAMService) error {
if len(policyBin) == 0 || policyBin[0] != '{' {
return getMalformedPolicyError(policyErrInvalidFirstChar)
}
policy, err := ParsePolicyDocument(policyBin)
if err != nil {
return err
}
if len(policy.Statement) == 0 {
return getMalformedPolicyError(policyErrEmptyStatement)
}
if err := policy.Validate(bucket, iam); err != nil {
var lookupErr principalLookupError
if errors.As(err, &lookupErr) {
// Not a defect in the document: the IAM service could not be
// asked whether its principals exist. Report that as itself
// rather than telling the caller their policy is malformed.
return lookupErr.err
}
return getMalformedPolicyError(err)
}
return nil
}
// verifyBucketPolicy parses policyBytes and evaluates it against every
// action, aggregating with the same precedence isAllowed uses for a single
// action: a Deny on any action wins immediately (returned along with that
// action, for building an AWS-shaped message); otherwise the decision is
// Allow only if every action has a matching Allow; otherwise NoMatch,
// paired with the first action that lacked one. Zero actions is
// conservatively NoMatch, not vacuously Allow.
func verifyBucketPolicy(policyBytes []byte, acc Account, bucket, object string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer, actions ...Action) (policyDecision, Action, error) {
if len(actions) == 0 {
return policyDecisionNoMatch, "", nil
}
var bp BucketPolicy
if err := json.Unmarshal(policyBytes, &bp); err != nil {
return policyDecisionNoMatch, "", fmt.Errorf("failed to parse the bucket policy: %w", err)
}
resource := makePolicyResource(bucket, object, normalizeObjectKey)
result := policyDecisionAllow
var blamed Action
for _, action := range actions {
switch d := bp.decisionFor(acc, action, resource, condCtx, normalizeObjectKey); d {
case policyDecisionDeny:
return policyDecisionDeny, action, nil
case policyDecisionNoMatch:
if result != policyDecisionNoMatch {
result = policyDecisionNoMatch
blamed = action
}
}
}
return result, blamed, nil
}
// Checks if the bucket policy grants public access
func VerifyPublicBucketPolicy(policy []byte, bucket, object string, condCtx map[string][]string, normalizeObjectKey objectKeyNormalizer, action Action) error {
var bucketPolicy BucketPolicy
if err := json.Unmarshal(policy, &bucketPolicy); err != nil {
return err
}
resource := makePolicyResource(bucket, object, normalizeObjectKey)
switch bucketPolicy.publicDecisionFor(resource, action, condCtx, normalizeObjectKey) {
case policyDecisionAllow:
return nil
case policyDecisionDeny:
return errExplicitDeny
default:
return errAccessDenied
}
}
func makePolicyResource(bucket, object string, normalizeObjectKey objectKeyNormalizer) string {
if object == "" {
return bucket
}
return bucket + "/" + normalizePolicyObjectKey(bucket, object, normalizeObjectKey)
}
func normalizePolicyObjectKey(bucket, key string, normalizeObjectKey objectKeyNormalizer) string {
if key == "" || normalizeObjectKey == nil {
return key
}
return normalizeObjectKey(bucket, key)
}
// matchPattern checks if the input string matches the given pattern with wildcard(`*`) and any character(`?`).
// - `?` matches exactly one occurrence of any character.
// - `*` matches arbitrary many (including zero) occurrences of any character.
func matchPattern(pattern, input string) bool {
pIdx, sIdx := 0, 0
starIdx, matchIdx := -1, 0
for sIdx < len(input) {
if pIdx < len(pattern) && (pattern[pIdx] == '?' || pattern[pIdx] == input[sIdx]) {
sIdx++
pIdx++
} else if pIdx < len(pattern) && pattern[pIdx] == '*' {
starIdx = pIdx
matchIdx = sIdx
pIdx++
} else if starIdx != -1 {
pIdx = starIdx + 1
matchIdx++
sIdx = matchIdx
} else {
return false
}
}
for pIdx < len(pattern) && pattern[pIdx] == '*' {
pIdx++
}
return pIdx == len(pattern)
}