Files
versitygw/tests/integration/s3_iam_session_access_control.go
T
niksis02 a4d4519ffe feat: version the private IAM protocol between gateway and standalone service
The S3 gateway and the standalone IAM service exchange authorization decisions over the private endpoints, where a version skew is silently unsafe in both directions: an older service drops a request field it does not know (a `Condition` block, say) and evaluates fail-open, while an older gateway ignores a response field it does not know and misses a deny the service intended. Neither side could previously detect either case.

Both peers now declare a protocol version on every exchange via the `X-Vgw-Private-Protocol` header — the gateway on each request, the service on each response, error responses included — and each refuses a peer it cannot serve safely. The service rejects a gateway below `MinClientProtocol` with a `ProtocolMismatch` code; the gateway rejects a service older than the `ProtocolVersion` it speaks, and rejects a response carrying no version at all, since no build of this protocol omits the header and something else answering on that address should not be interpreted as an IAM decision. `ParseProtocolVersion` is shared by both sides and deliberately strict: an unreadable value is a mismatch, never an assumed default.

A new root-signed `/private/version` endpoint reports the protocol version, the minimum client the service will serve, and the build tag (`WithPrivateServerVersion`). It is exempt from the service's own client-version check so it can still answer a gateway the service refuses — which is how that gateway learns why. Being authenticated like every other private endpoint, it also lets the gateway's startup probe verify its own credential and its mTLS transport in the same round trip.

The gateway probes it once in `NewIAMServiceStandalone` rather than discovering a skew as an opaque per-request 500. An incompatible service is fatal after a 30s window, since a gateway that cannot authorize a single request is more useful refusing to start with the reason in its log; an unreachable one is only a warning, because the two processes legitimately start in parallel and every request checks the version regardless. Only conditions that can resolve on their own are retried — a rejected credential is reported immediately.
2026-08-25 01:41:12 +04:00

794 lines
31 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"
"strings"
"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 bucket policy names "*" rather than the session: this gateway matches
// bucket-policy principals against the caller's access key, and a session's
// key is ephemeral, so auth.CheckIfAccountsExist rejects one as a principal
// outright rather than let a policy come to reference a principal that stops
// existing. See bucketStatement.
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
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: "*", Action: actS3GetObject, Resource: objectsArn(bucket),
}); err != nil {
return err
}
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()
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
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Allow", Principal: "*",
Action: []string{actS3GetObject, actS3PutObject}, Resource: objectsArn(bucket),
}); 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()
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
}
if err := putBucketPolicyDoc(s, bucket, bucketStatement{
Effect: "Deny", Principal: "*", Action: actS3GetObject, Resource: objectsArn(bucket),
}); 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()
// A resource-based denial names the raw access key: bucket-policy
// principals are access-key-based for every backend, so no ARN is in
// hand at that point.
return checkApiErr(err, wantExplicitResourceDeny(session.conf.awsID, 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 describe the *session*, not the underlying role: aws:userid
// carries the role id and session name, and aws:PrincipalArn the
// assumed-role ARN.
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 matches the assumed-role session",
condition: func(p *s3IAMPrincipal) []byte { return cond("StringEquals", "aws:PrincipalArn", p.arn) },
wantAllowed: true,
},
{
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")))
})
}