mirror of
https://github.com/versity/versitygw.git
synced 2026-09-24 08:54:47 +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.
1248 lines
49 KiB
Go
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
|
|
})
|
|
}
|