Files
versitygw/tests/integration/s3_iam_session_access_control.go
T
niksis02 afbee5be01 fix: role last-used tracking, and record S3 requests in last-used metadata
Role last-used tracking was missing entirely - `GetRole` returned a `RoleLastUsed` element that nothing ever wrote, rendering the zero time instead of the empty element AWS returns for an unused role - and access key last-used only ever saw the `IAM`/`STS` control plane, so a credential used exclusively against the S3 gateway reported as never used. Roles now record a use whenever a request authenticates with one of their session credentials, through a new `Storer.RecordRoleUsage` mirroring `RecordAccessKeyUsage`, gated on the session's role still being the one it was minted against so a session outliving its role can't attribute its use to a same-named replacement. `LastUsedDate` became a `*time.Time` so an unused role renders as an empty element.

Both records now cover the S3 data plane as well: the gateway sends its configured region and `s3` on evaluate-policy and the IAM service records the caller there, so `GetAccessKeyLastUsed's` `ServiceName` is now iam, sts or s3. That call was chosen over derive-signing-key, which runs before signature verification and takes its region and service from the caller's own `Authorization` header - recording there would let anyone who knows an access key id refresh and poison another identity's audit record. Requests denied by a bucket policy or made against a public bucket are not recorded, since neither reaches identity-policy evaluation. To keep per-request recording affordable, an update is skipped while the stored record has the same service and region and is under a minute old; a change of either is written through immediately.

Assuming a role is not a use, a request denied by an identity policy is, and both successful and denied S3 requests update the record. Also moves the `OIDC-dependent` tests into the `s3-iam-session` group so runoidctests.sh runs a single group.
2026-09-01 19:55:27 +04:00

1034 lines
40 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 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")))
})
}
// 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
})
}