Files
versitygw/tests/integration/s3_iam_session_access_control.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

1248 lines
49 KiB
Go

// Copyright 2026 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 integration
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/versity/versitygw/s3err"
)
// S3IAMSession_role_policy_allows verifies a session inherits the assumed
// role's inline policies, and that they are sufficient on their own with no
// bucket policy in play.
func S3IAMSession_role_policy_allows(s *S3Conf) error {
testName := "S3IAMSession_role_policy_allows"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: "s3:*",
Resource: []string{bucketArn(bucket), objectsArn(bucket)},
}),
}, "")
if err != nil {
return err
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected PutObject to be allowed by the role policy: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed by the role policy: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket})
cancel()
if err != nil {
return fmt.Errorf("expected ListObjects to be allowed by the role policy: %w", err)
}
return nil
})
}
// S3IAMSession_role_without_policy_denied verifies a session with no role
// policy and no bucket policy is denied, and that the denial names the
// assumed-role session ARN rather than the temporary access key.
func S3IAMSession_role_without_policy_denied(s *S3Conf) error {
testName := "S3IAMSession_role_without_policy_denied"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_role_policy_explicit_deny_wins verifies an explicit Deny in
// the role's own policy overrides its Allow, exactly as for a long-term
// user.
func S3IAMSession_role_policy_explicit_deny_wins(s *S3Conf) error {
testName := "S3IAMSession_role_policy_explicit_deny_wins"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(
accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)},
accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)},
),
}, "")
if err != nil {
return err
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected PutObject to still be allowed: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantExplicitIdentityDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_role_policy_resource_scoped verifies a role policy's Resource
// pattern scopes what the session may touch.
func S3IAMSession_role_policy_resource_scoped(s *S3Conf) error {
testName := "S3IAMSession_role_policy_resource_scoped"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: "s3:*", Resource: objectArn(bucket, "allowed/*"),
}),
}, "")
if err != nil {
return err
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("allowed/obj")})
cancel()
if err != nil {
return fmt.Errorf("expected the in-scope key to be allowed: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("denied/obj")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "denied/obj")))
})
}
// S3IAMSession_session_policy_narrows_role verifies a session policy
// restricts what the role would otherwise permit — the primary reason to
// pass one.
func S3IAMSession_session_policy_narrows_role(s *S3Conf) error {
testName := "S3IAMSession_session_policy_narrows_role"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: "s3:*",
Resource: []string{bucketArn(bucket), objectsArn(bucket)},
}),
}, policyDoc(accessStatement{
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
}))
if err != nil {
return err
}
defer cleanup()
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed by both layers: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other")))
})
}
// S3IAMSession_session_policy_cannot_widen_role verifies a session policy
// can only ever subtract: granting more than the role has does not add
// anything.
func S3IAMSession_session_policy_cannot_widen_role(s *S3Conf) error {
testName := "S3IAMSession_session_policy_cannot_widen_role"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
}),
}, policyDoc(accessStatement{
Effect: "Allow", Action: "s3:*", Resource: "*",
}))
if err != nil {
return err
}
defer cleanup()
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed by both layers: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other")))
})
}
// S3IAMSession_session_policy_explicit_deny_overrides_role verifies an
// explicit Deny in the session policy beats the role's Allow.
func S3IAMSession_session_policy_explicit_deny_overrides_role(s *S3Conf) error {
testName := "S3IAMSession_session_policy_explicit_deny_overrides_role"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: "s3:*",
Resource: []string{bucketArn(bucket), objectsArn(bucket)},
}),
}, policyDoc(
accessStatement{Effect: "Allow", Action: "s3:*", Resource: "*"},
accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)},
))
if err != nil {
return err
}
defer cleanup()
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantExplicitIdentityDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_role_policy_deny_overrides_session_allow verifies the reverse
// direction: an explicit Deny in the role's policy is not escapable by a
// permissive session policy.
func S3IAMSession_role_policy_deny_overrides_session_allow(s *S3Conf) error {
testName := "S3IAMSession_role_policy_deny_overrides_session_allow"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(
accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)},
accessStatement{Effect: "Deny", Action: actS3GetObject, Resource: objectsArn(bucket)},
),
}, policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: "*"}))
if err != nil {
return err
}
defer cleanup()
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantExplicitIdentityDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_session_policy_without_role_policy_denied verifies a session
// policy alone grants nothing: with the role carrying no policy and no
// bucket policy in play, there is nothing for it to narrow.
func S3IAMSession_session_policy_without_role_policy_denied(s *S3Conf) error {
testName := "S3IAMSession_session_policy_without_role_policy_denied"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, nil,
policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: "*"}))
if err != nil {
return err
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_bucket_policy_allows_without_role_policy verifies the bucket
// policy is independently sufficient for a session too, exactly as it is for
// a long-term user.
//
// The policy names the session's role ARN, which is how a bucket policy
// names every session of a role: no wildcard is allowed inside a principal
// ARN, so the role ARN is the only form that covers sessions the policy was
// written before. Naming one specific session is
// S3IAMSession_bucket_policy_names_one_session.
func S3IAMSession_bucket_policy_allows_without_role_policy(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_allows_without_role_policy"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: roleArnFor(session.name), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed by the bucket policy: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other")))
})
}
// S3IAMSession_session_policy_filters_bucket_policy_grant is the property
// that distinguishes a session policy from an ordinary identity policy: it
// filters *everything* the session can do, including permissions that came
// from the bucket policy rather than from the role.
//
// Verified against real AWS with a role carrying no identity policy at all,
// a bucket policy granting it both s3:GetObject and s3:PutObject, and a
// session policy allowing only s3:GetObject — the Get succeeds and the Put
// is denied.
func S3IAMSession_session_policy_filters_bucket_policy_grant(s *S3Conf) error {
testName := "S3IAMSession_session_policy_filters_bucket_policy_grant"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil,
policyDoc(accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}))
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: roleArnFor(session.name),
Action: []string{actS3GetObject, actS3PutObject}, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed by the bucket policy within the session policy: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("other")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3PutObject, objectArn(bucket, "other")))
})
}
// S3IAMSession_bucket_policy_deny_overrides_role_allow verifies a
// bucket-policy Deny beats the role's Allow for a session, and reports the
// resource-based-policy message.
func S3IAMSession_bucket_policy_deny_overrides_role_allow(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_deny_overrides_role_allow"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}),
}, "")
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: roleArnFor(session.name), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
// A session is named by its assumed-role ARN in a denial message,
// which is what real S3 reports too.
return checkApiErr(err, wantExplicitResourceDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_missing_and_wrong_security_token verifies the two ways a
// session credential can be presented wrongly, each with the error real S3
// returns for it.
func S3IAMSession_missing_and_wrong_security_token(s *S3Conf) error {
testName := "S3IAMSession_missing_and_wrong_security_token"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}),
}, "")
if err != nil {
return err
}
defer cleanup()
// No token at all: with nothing to resolve the temporary access key
// against, it simply does not name any identity.
noToken := s3ClientWithSessionCreds(s, session.conf.awsID, session.conf.awsSecret, "")
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = noToken.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err := checkApiErr(err, s3err.GetInvalidAccessKeyIdErr(session.conf.awsID)); err != nil {
return fmt.Errorf("missing security token: %w", err)
}
// A token that doesn't match the session it names.
wrongToken := s3ClientWithSessionCreds(s, session.conf.awsID, session.conf.awsSecret, "not-the-real-session-token")
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = wrongToken.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrInvalidToken)); err != nil {
return fmt.Errorf("wrong security token: %w", err)
}
return nil
})
}
// S3IAMSession_presigned_url_with_session_credentials verifies a presigned
// URL signed with temporary credentials works: the security token rides in
// the query string, where it is part of the signed canonical request.
func S3IAMSession_presigned_url_with_session_credentials(s *S3Conf) error {
testName := "S3IAMSession_presigned_url_with_session_credentials"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket)}),
}, "")
if err != nil {
return err
}
defer cleanup()
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
presigned, err := s3.NewPresignClient(session.client).PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: &bucket, Key: aws.String("obj"),
})
cancel()
if err != nil {
return fmt.Errorf("presign: %w", err)
}
if !strings.Contains(presigned.URL, "X-Amz-Security-Token") {
return fmt.Errorf("expected the presigned URL to carry X-Amz-Security-Token")
}
req, err := http.NewRequest(presigned.Method, presigned.URL, nil)
if err != nil {
return err
}
req.Header = presigned.SignedHeader
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("expected the presigned request to succeed, got status %d", resp.StatusCode)
}
return nil
})
}
// S3IAMSession_deleted_role_denies verifies a session outlives its role's
// deletion as a credential — it still authenticates — but loses every
// permission the role gave it.
func S3IAMSession_deleted_role_denies(s *S3Conf) error {
testName := "S3IAMSession_deleted_role_denies"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{Effect: "Allow", Action: "s3:*", Resource: objectsArn(bucket)}),
}, "")
if err != nil {
return err
}
defer cleanup()
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed before the role is deleted: %w", err)
}
if err := deleteIAMRoleAndPolicies(root, session.name); err != nil {
return fmt.Errorf("delete role: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_create_bucket_via_role_policy verifies s3:CreateBucket is
// grantable to a session by its role policy, and denied without it.
func S3IAMSession_create_bucket_via_role_policy(s *S3Conf) error {
testName := "S3IAMSession_create_bucket_via_role_policy"
// The skip is checked before actionHandlerNoSetup rather than inside it,
// so a skipped run doesn't also report itself as a pass.
if _, ok := gitHubOIDCToken(); !ok {
skipF("%v: %v", testName, gitHubOIDCSkipReason)
return nil
}
return actionHandlerNoSetup(s, testName, func(_ *s3.Client, _ string) error {
root := s.GetIAMClient()
allowed, denied := getBucketName(), getBucketName()
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: actS3CreateBucket, Resource: bucketArn(allowed),
}),
}, "")
if err != nil {
return err
}
defer cleanup()
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &allowed})
cancel()
if err != nil {
return fmt.Errorf("expected CreateBucket to be allowed for the granted name: %w", err)
}
defer teardown(s, allowed)
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: &denied})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3CreateBucket, bucketArn(denied)))
})
}
// S3IAMSession_governance_bypass_via_role_policy verifies a session can be
// granted s3:BypassGovernanceRetention through its role, and that a session
// policy withholding it takes it away again.
func S3IAMSession_governance_bypass_via_role_policy(s *S3Conf) error {
testName := "S3IAMSession_governance_bypass_via_role_policy"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
grantAll := map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: []string{actS3DeleteObject, actS3BypassGovernance},
Resource: objectsArn(bucket),
}),
}
// Role grants the bypass, session policy withholds it: denied.
withheld, cleanupWithheld, err := newGitHubSession(root, s, grantAll,
policyDoc(accessStatement{Effect: "Allow", Action: actS3DeleteObject, Resource: objectsArn(bucket)}))
if err != nil {
return err
}
defer cleanupWithheld()
if err := putGovernanceLockedObject(s, bucket, "locked-withheld"); err != nil {
return err
}
if err := deleteObjectBypassingGovernance(withheld.client, bucket, "locked-withheld"); err == nil {
return fmt.Errorf("expected the delete to be denied when the session policy withholds the bypass permission")
}
// Role grants it and no session policy narrows it: allowed.
granted, cleanupGranted, err := newGitHubSession(root, s, grantAll, "")
if err != nil {
return err
}
defer cleanupGranted()
if err := putGovernanceLockedObject(s, bucket, "locked-granted"); err != nil {
return err
}
if err := deleteObjectBypassingGovernance(granted.client, bucket, "locked-granted"); err != nil {
return fmt.Errorf("expected the delete to be allowed by the role's bypass grant: %w", err)
}
return nil
}, withLock())
}
// S3IAMSession_delete_objects_authorizes_each_key verifies the per-key
// authorization of a batch delete applies to a session's role policy too.
func S3IAMSession_delete_objects_authorizes_each_key(s *S3Conf) error {
testName := "S3IAMSession_delete_objects_authorizes_each_key"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
for _, key := range []string{"allowed/one", "denied/two"} {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr(key)})
cancel()
if err != nil {
return err
}
}
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: actS3DeleteObject, Resource: objectArn(bucket, "allowed/*"),
}),
}, "")
if err != nil {
return err
}
defer cleanup()
out, err := deleteObjectsWithBypass(session.client, bucket, "allowed/one", "denied/two")
if err != nil {
return fmt.Errorf("expected DeleteObjects to succeed with a per-object denial, not fail outright: %w", err)
}
if len(out.Errors) != 1 {
return fmt.Errorf("expected exactly 1 per-object error, got %+v", out.Errors)
}
if err := checkDeleteObjectsErr(out.Errors[0], "denied/two", wantImplicitDeny(session.arn, actS3DeleteObject, objectArn(bucket, "denied/two"))); err != nil {
return err
}
if _, err := deleteObjectsWithBypass(session.client, bucket, "allowed/one"); err != nil {
return fmt.Errorf("expected the in-scope key to be deletable: %w", err)
}
return nil
})
}
// S3IAMSession_condition_identity_keys verifies the identity-derived
// condition keys for a session. They do not all describe the same thing:
// aws:userid carries the role id and the session name, and so pins one
// session, while aws:PrincipalArn is the assumed *role's* ARN and therefore
// covers every session of it — a Condition on it can never single one out.
// A denial message names the session by its assumed-role ARN, which is a
// different thing from aws:PrincipalArn and deliberately so.
func S3IAMSession_condition_identity_keys(s *S3Conf) error {
testName := "S3IAMSession_condition_identity_keys"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
cases := []struct {
name string
condition func(session *s3IAMPrincipal) []byte
wantAllowed bool
}{
{
name: "principal arn is the assumed role's arn",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalArn", roleArnFor(p.name)) },
wantAllowed: true,
},
{
name: "principal arn is not the assumed-role session arn",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalArn", p.arn) },
},
{
name: "principal type is AssumedRole",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalType", "AssumedRole") },
wantAllowed: true,
},
{
name: "userid ends with the session name",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringLike", "aws:userid", "*:"+sessionNameFor(p)) },
wantAllowed: true,
},
{
name: "principal arn mismatch",
condition: func(p *s3IAMPrincipal) []byte {
return cond("StringEquals", "aws:PrincipalArn", "arn:aws:sts::000000000000:assumed-role/other/other")
},
},
{
name: "aws:username is absent for a session",
condition: func(p *s3IAMPrincipal) []byte { return cond("Null", "aws:username", "false") },
},
}
for _, tc := range cases {
if err := func() error {
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{
RoleName: aws.String(session.name),
PolicyName: aws.String("p"),
PolicyDocument: aws.String(policyDoc(accessStatement{
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
Condition: tc.condition(session),
})),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if tc.wantAllowed {
if err != nil {
return fmt.Errorf("expected the request to be allowed: %w", err)
}
return nil
}
return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
}(); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}
// S3IAMSession_get_caller_identity_matches_s3_principal verifies STS and the
// S3 data plane agree on who the session is: the ARN GetCallerIdentity
// reports is the one an S3 denial names.
func S3IAMSession_get_caller_identity_matches_s3_principal(s *S3Conf) error {
testName := "S3IAMSession_get_caller_identity_matches_s3_principal"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
callerOut, err := getCallerIdentityWithSessionCreds(*s, session.conf.awsID, session.conf.awsSecret, session.sessionToken)
if err != nil {
return fmt.Errorf("GetCallerIdentity: %w", err)
}
if aws.ToString(callerOut.Arn) != session.arn {
return fmt.Errorf("GetCallerIdentity reported Arn %q, want %q", aws.ToString(callerOut.Arn), session.arn)
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_AssumeRoleWithWebIdentity_github_oidc_live exercises
// AssumeRoleWithWebIdentity against a REAL external OIDC identity provider —
// GitHub Actions' own OIDC issuer — end-to-end: discovery-document fetch,
// JWKS fetch, real RS256 signature verification, claims mapping, and
// session credential issuance. It's the only web-identity test that does
// this; every other one in this package uses a fake token that never
// reaches real signature verification.
func S3IAMSession_AssumeRoleWithWebIdentity_github_oidc_live(s *S3Conf) error {
testName := "S3IAMSession_AssumeRoleWithWebIdentity_github_oidc_live"
reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
if reqURL == "" || reqToken == "" {
skipF("%v: ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN not set "+
"(expected outside a GitHub Actions job with id-token: write permission)", testName)
return nil
}
return iamActionHandler(s, testName, func(client *iam.Client) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" {
return fmt.Errorf("GITHUB_REPOSITORY is not set, but ACTIONS_ID_TOKEN_REQUEST_URL/TOKEN are - unexpected environment")
}
roleName, roleArn, cleanup, err := createGitHubOIDCTrust(client, repo)
if err != nil {
return err
}
defer cleanup()
token, err := fetchGitHubIDToken(reqURL, reqToken, githubOIDCTestAudience)
if err != nil {
return err
}
const sessionName = "github-oidc-live"
assumeOut, err := assumeRoleWithWebIdentity(s, roleArn, sessionName, token, 0)
if err != nil {
// checkIAMApiErr-style wrapping isn't used here since a live
// AssumeRoleWithWebIdentity SDK error carries no token material
// of its own to guard against - it's the request we build
// (never printed) and GitHub's response (never printed either,
// see fetchGitHubIDToken) that could leak the token.
return fmt.Errorf("AssumeRoleWithWebIdentity: %w", err)
}
if assumeOut.Credentials == nil {
return fmt.Errorf("expected Credentials in AssumeRoleWithWebIdentity response")
}
accessKeyID := aws.ToString(assumeOut.Credentials.AccessKeyId)
secretAccessKey := aws.ToString(assumeOut.Credentials.SecretAccessKey)
sessionToken := aws.ToString(assumeOut.Credentials.SessionToken)
if accessKeyID == "" || secretAccessKey == "" || sessionToken == "" {
return fmt.Errorf("expected a full AccessKeyId/SecretAccessKey/SessionToken triple in AssumeRoleWithWebIdentity response")
}
wantArn := fmt.Sprintf("arn:aws:sts::000000000000:assumed-role/%s/%s", roleName, sessionName)
if aws.ToString(assumeOut.AssumedRoleUser.Arn) != wantArn {
return fmt.Errorf("expected AssumedRoleUser.Arn %q, instead got %q", wantArn, aws.ToString(assumeOut.AssumedRoleUser.Arn))
}
// A follow-up call authenticated with the session credentials
// AssumeRoleWithWebIdentity just issued proves the whole chain -
// discovery, JWKS, signature verification, claims mapping, and
// session creds - actually works, not just that a 200 came back.
callerOut, err := getCallerIdentityWithSessionCreds(*s, accessKeyID, secretAccessKey, sessionToken)
if err != nil {
return fmt.Errorf("GetCallerIdentity with assumed-role session credentials: %w", err)
}
if aws.ToString(callerOut.Arn) != wantArn {
return fmt.Errorf("GetCallerIdentity: expected Arn %q, instead got %q", wantArn, aws.ToString(callerOut.Arn))
}
return nil
})
}
// S3IAMSession_GetRole_role_last_used_recorded exercises role last-used tracking
// end-to-end: a role assumed with a real GitHub Actions OIDC token, then
// used — a request authenticated with the session credentials that assume
// issued — records that use as GetRole's RoleLastUsed.
//
// Like every other session test, it needs a genuine ID token, so it runs
// only inside the workflow that can mint one and skips itself everywhere
// else.
func S3IAMSession_GetRole_role_last_used_recorded(s *S3Conf) error {
testName := "S3IAMSession_GetRole_role_last_used_recorded"
token, ok := gitHubOIDCToken()
if !ok {
skipF("%v: %v", testName, gitHubOIDCSkipReason)
return nil
}
return iamActionHandler(s, testName, func(client *iam.Client) error {
repo := os.Getenv("GITHUB_REPOSITORY")
if repo == "" {
return fmt.Errorf("GITHUB_REPOSITORY is not set, but the OIDC token request variables are - unexpected environment")
}
roleName, roleArn, cleanup, err := createGitHubOIDCTrust(client, repo)
if err != nil {
return err
}
defer cleanup()
assumeOut, err := assumeRoleWithWebIdentity(s, roleArn, "role-last-used", token, 0)
if err != nil {
// The error is not wrapped with the request or response, either
// of which could carry the ID token - see the same reasoning in
// IAMAssumeRoleWithWebIdentity_github_oidc_live.
return fmt.Errorf("AssumeRoleWithWebIdentity: %w", err)
}
if assumeOut.Credentials == nil {
return fmt.Errorf("expected Credentials in AssumeRoleWithWebIdentity response")
}
// Assuming a role is not itself a use of it: the role stays
// never-used until a request actually authenticates as the session.
out, err := getIAMRole(client, roleName)
if err != nil {
return err
}
if out.Role == nil {
return fmt.Errorf("expected GetRole to return a role")
}
if err := checkRoleNeverUsed(out.Role.RoleLastUsed); err != nil {
return fmt.Errorf("after AssumeRoleWithWebIdentity, before any use: %w", err)
}
before := time.Now().UTC().Add(-time.Second)
if _, err := getCallerIdentityWithSessionCreds(*s,
aws.ToString(assumeOut.Credentials.AccessKeyId),
aws.ToString(assumeOut.Credentials.SecretAccessKey),
aws.ToString(assumeOut.Credentials.SessionToken)); err != nil {
return fmt.Errorf("GetCallerIdentity with assumed-role session credentials: %w", err)
}
out, err = getIAMRole(client, roleName)
if err != nil {
return err
}
if out.Role == nil || out.Role.RoleLastUsed == nil {
return fmt.Errorf("expected GetRole to return a role with a RoleLastUsed element")
}
lastUsed := out.Role.RoleLastUsed
if lastUsed.LastUsedDate == nil {
return fmt.Errorf("expected a role last used date after a session-authenticated request")
}
if lastUsed.LastUsedDate.Before(before) {
return fmt.Errorf("expected role last used date to be at or after %v, instead got %v", before, *lastUsed.LastUsedDate)
}
if aws.ToString(lastUsed.Region) != iamAuthRegion {
return fmt.Errorf("expected role last used region to be %q, instead got %q", iamAuthRegion, aws.ToString(lastUsed.Region))
}
// ListRoles omits RoleLastUsed from every entry — the list/get
// asymmetry other tests only ever see on never-used roles, where a
// leaked element would be empty anyway.
list, err := listIAMRoles(client, &iam.ListRolesInput{MaxItems: aws.Int32(1000)})
if err != nil {
return err
}
found := false
for _, role := range list.Roles {
if aws.ToString(role.RoleName) != roleName {
continue
}
found = true
if role.RoleLastUsed != nil {
return fmt.Errorf("expected ListRoles RoleLastUsed to be nil for a used role, instead got %#v", role.RoleLastUsed)
}
}
if !found {
return fmt.Errorf("expected ListRoles to return the used role %q", roleName)
}
return nil
})
}
// S3IAMSession_role_last_used_records_s3 is the same for an assumed-role
// session: the role's RoleLastUsed reports the S3 request its temporary
// credentials made, which — unlike an access key — is the only place that
// use is visible at all.
func S3IAMSession_role_last_used_records_s3(s *S3Conf) error {
testName := "S3IAMSession_role_last_used_records_s3"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, map[string]string{
"p": policyDoc(accessStatement{
Effect: "Allow", Action: actS3ListBucket, Resource: []string{bucketArn(bucket)},
}),
}, "")
if err != nil {
return err
}
defer cleanup()
// The role was just assumed, and assuming is not using: nothing is
// recorded until a request authenticates as the session.
before, err := getIAMRole(root, session.name)
if err != nil {
return err
}
if before.Role == nil {
return fmt.Errorf("expected GetRole to return a role")
}
if err := checkRoleNeverUsed(before.Role.RoleLastUsed); err != nil {
return fmt.Errorf("after AssumeRoleWithWebIdentity, before any s3 request: %w", err)
}
start := time.Now().UTC().Add(-time.Second)
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket})
cancel()
if err != nil {
return fmt.Errorf("expected ListObjects to be allowed by the role policy: %w", err)
}
after, err := getIAMRole(root, session.name)
if err != nil {
return err
}
if after.Role == nil || after.Role.RoleLastUsed == nil {
return fmt.Errorf("expected GetRole to return a role with a RoleLastUsed element")
}
lastUsed := after.Role.RoleLastUsed
if lastUsed.LastUsedDate == nil {
return fmt.Errorf("expected the s3 request to record a role last used date")
}
if lastUsed.LastUsedDate.Before(start) {
return fmt.Errorf("expected role last used date to be at or after %v, instead got %v", start, *lastUsed.LastUsedDate)
}
if aws.ToString(lastUsed.Region) != s.awsRegion {
return fmt.Errorf("expected role last used region to be %q, instead got %q", s.awsRegion, aws.ToString(lastUsed.Region))
}
return nil
})
}
// S3IAMSession_bucket_policy_role_arn_covers_every_session verifies a
// Principal naming a role covers sessions of it that did not exist when the
// policy was written. That is the only way to express "any session of this
// role": no wildcard is allowed inside a principal ARN.
func S3IAMSession_bucket_policy_role_arn_covers_every_session(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_role_arn_covers_every_session"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: roleArnFor(session.name), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
// A session minted after the policy was written is covered by it
// just as the first one is.
later, err := anotherSessionOfRole(s, session.name)
if err != nil {
return err
}
for _, p := range []*s3IAMPrincipal{session, later} {
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = p.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed for %v: %w", p.arn, err)
}
}
return nil
})
}
// S3IAMSession_bucket_policy_names_one_session verifies the other half:
// a Principal naming one assumed-role session covers that session and no
// other session of the same role.
func S3IAMSession_bucket_policy_names_one_session(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_names_one_session"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
named, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
other, err := anotherSessionOfRole(s, named.name)
if err != nil {
return err
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: named.arn, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = named.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected GetObject to be allowed for the named session: %w", err)
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = other.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantImplicitDeny(other.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_bucket_policy_account_delegates verifies the account-level
// principal forms delegate rather than grant for a session too: naming the
// account allows nothing without a role policy, and denies everything under
// a Deny.
func S3IAMSession_bucket_policy_account_delegates(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_account_delegates"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s.GetClient().PutObject(ctx, &s3.PutObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return err
}
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: accountArn(), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err := checkApiErr(err, wantImplicitDeny(session.arn, actS3GetObject, objectArn(bucket, "obj"))); err != nil {
return fmt.Errorf("allow naming the account: %w", err)
}
// The role policy the account principal delegates to is what
// actually grants. It has to be removed again before teardown:
// DeleteRole refuses a role that still carries an inline policy.
if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{
RoleName: aws.String(session.name),
PolicyName: aws.String("p"),
PolicyDocument: aws.String(policyDoc(accessStatement{
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
})),
}); err != nil {
return err
}
defer deleteIAMRolePolicy(root, session.name, "p")
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
if err != nil {
return fmt.Errorf("expected the role policy to grant what the account principal delegated: %w", err)
}
// A Deny naming the account is not a delegation.
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: accountArn(), Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = session.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr("obj")})
cancel()
return checkApiErr(err, wantExplicitResourceDeny(session.arn, actS3GetObject, objectArn(bucket, "obj")))
})
}
// S3IAMSession_bucket_policy_session_principal_forms covers what
// PutBucketPolicy makes of the session-shaped principal forms: an
// assumed-role ARN resolves as long as its role does, whatever session name
// it carries, and the forms that name no role do not resolve at all.
func S3IAMSession_bucket_policy_session_principal_forms(s *S3Conf) error {
testName := "S3IAMSession_bucket_policy_session_principal_forms"
return s3IAMSessionActionHandler(s, testName, func(root *iam.Client, bucket string) error {
session, cleanup, err := newGitHubSession(root, s, nil, "")
if err != nil {
return err
}
defer cleanup()
accepted := []struct {
name string
principal string
}{
{"the live session's arn", session.arn},
{"a session name never assumed", assumedRoleArnFor(session.name, "never-assumed")},
}
for _, tc := range accepted {
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: tc.principal, Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return fmt.Errorf("%s: expected the policy to be accepted: %w", tc.name, err)
}
}
rejected := []struct {
name string
principal string
}{
{"session access key id", session.conf.awsID},
{"wildcard session name", assumedRoleArnFor(session.name, "*")},
{"assumed-role arn of a non existing role", assumedRoleArnFor("no-such-role", "sess")},
{"assumed-role arn with the iam service", "arn:aws:iam::" + testAccountID + ":assumed-role/" + session.name + "/sess"},
{"role arn with the sts service", "arn:aws:sts::" + testAccountID + ":role/" + session.name},
}
for _, tc := range rejected {
err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: tc.principal, Action: actS3GetObject, Resource: objectsArn(bucket),
})
if err := checkApiErr(err, wantInvalidPrincipal()); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
}
return nil
})
}