mirror of
https://github.com/versity/versitygw.git
synced 2026-08-29 04:06:56 +00:00
Fixes #1327 Fixes #1567 Closes #2264 Wires the S3 gateway up to the standalone IAM service so identity policies, not just bucket policies and ACLs, are enforced on the S3 data plane. The gateway authenticates SigV4 requests by calling new private derive-signing-key and resolve-identity endpoints on the IAM service instead of holding secrets itself, and evaluates identity policy through the same PolicyEvaluator path added to auth.VerifyAccess, combined with the bucket policy using explicit-deny-wins precedence. The private endpoints are served over their own mTLS listener (new iamapi/private package, genmtlscerts.sh to generate test material, and client-cert support in internal/netutil), separate from the public IAM API. As part of this the vendored aws/signer/v4 package is deleted and replaced by a pure-Go SigV4 implementation in internal/sigv4auth, which now reads canonical request data directly off the fiber.Ctx instead of reconstructing an http.Request, and is shared by both the S3 request-signing verification and the new private-endpoint signing. DeleteObjects moves from an all-or-nothing authorization check to true partial success: VerifyObjectsAccess evaluates every object in a batch independently against both the identity policy and any object lock, so a denial or a locked object only removes that key from the batch instead of failing the whole request. It also batches the identity-policy round trip and the bucket-policy fetch once per request rather than once per object, and separates plain deletes from versioned ones since a versioned delete needs s3:DeleteObjectVersion rather than s3:DeleteObject. Object lock handling got a few correctness fixes alongside this: a bypass is now modeled as BypassNone/BypassRequested/BypassOverwrite rather than a single bool, because root's blanket ability to override a GOVERNANCE retention should only apply when the client actually asked to bypass it (DeleteObject/DeleteObjects/PutObjectRetention), not when the gateway is silently replacing a locked object via an overwrite, which needs the permission from everyone including root. Retention changes are now correctly classified as an extension (allowed under plain s3:PutObjectRetention) versus a weakening (date or mode change, which needs the bypass permission), and a COMPLIANCE lock can never be weakened by anyone regardless of permissions, matching AWS. Separately, VerifyObjectCopyAccess had a readonly-mode gap: it returned early for root/admin before ever calling VerifyAccess, so the readonly check inside VerifyAccess never ran for them on CopyObject; access checks are now ordered so the readonly gate always applies before any root/admin bypass, for copy as well as every other write path. Bucket policies also gained Condition block support, via a new shared internal/condition package moved out of the IAM policy package since both bucket and identity policies share the same evaluation semantics. It implements the full AWS operator set — String{Equals,NotEquals,EqualsIgnoreCase,NotEqualsIgnoreCase,Like,NotLike}, Numeric{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Date{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Bool, BinaryEquals, Arn{Equals,Like,NotEquals,NotLike}, IpAddress/NotIpAddress, and Null — along with the ForAllValues/ForAnyValue set qualifiers and the IfExists modifier. A new requestConditionContext builds the per-request keys a bucket policy's Condition block can reference — aws:SourceIp, aws:SecureTransport, aws:CurrentTime, aws:EpochTime, aws:UserAgent, aws:Referer, s3:prefix, s3:delimiter, s3:max-keys, s3:x-amz-acl, s3:VersionId — following AWS's own per-action rules for which keys a given S3 operation actually populates. Identity-derived keys such as aws:PrincipalArn and aws:username are deliberately left unwired here, since the gateway has no way to know them; the standalone IAM service fills those in itself when it evaluates an identity policy. Also added new integration test suites for S3-side IAM: s3_iam_access_control.go and s3_iam_session_access_control.go cover identity-policy enforcement and session-credential requests against real S3 operations, alongside expanded OIDC/web-identity coverage and a new runoidctests.sh runner wired into the OIDC GitHub Actions workflow.
551 lines
21 KiB
Go
551 lines
21 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"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
|
"github.com/aws/aws-sdk-go-v2/service/iam"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3"
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
"github.com/aws/aws-sdk-go-v2/service/sts"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
const (
|
|
actS3GetObject = "s3:GetObject"
|
|
actS3PutObject = "s3:PutObject"
|
|
actS3DeleteObject = "s3:DeleteObject"
|
|
actS3DeleteObjectVersion = "s3:DeleteObjectVersion"
|
|
actS3ListBucket = "s3:ListBucket"
|
|
actS3CreateBucket = "s3:CreateBucket"
|
|
actS3BypassGovernance = "s3:BypassGovernanceRetention"
|
|
)
|
|
|
|
// s3IAMPrincipal is an identity that can make S3 requests: an IAM user with
|
|
// a long-term access key, or an assumed-role session with temporary
|
|
// credentials. Tests assert against arn when checking a denial message,
|
|
// since the gateway names the principal by ARN once a PolicyEvaluator
|
|
// resolves it.
|
|
type s3IAMPrincipal struct {
|
|
// name is the IAM user name or, for a session, the role name.
|
|
name string
|
|
arn string
|
|
// conf is a copy of the suite's S3Conf carrying this principal's
|
|
// credentials, so tests can build additional clients (presign, STS)
|
|
// beyond the plain s3 one.
|
|
conf S3Conf
|
|
client *s3.Client
|
|
// sessionToken is set only for an assumed-role session, for the tests
|
|
// that need to build a differently-credentialed client from the same
|
|
// session (a presigned URL, an STS call, a deliberately wrong token).
|
|
sessionToken string
|
|
}
|
|
|
|
// s3IAMActionHandler is actionHandler for the S3+IAM groups: it runs handler
|
|
// with a root-owned bucket and the root IAM client the fixtures below need,
|
|
// then tears the bucket down. Root creates every bucket and object a test
|
|
// operates on, so that what the test measures is the principal's
|
|
// authorization, never its ability to set the scene.
|
|
func s3IAMActionHandler(s *S3Conf, testName string, handler func(root *iam.Client, bucket string) error, opts ...setupOpt) error {
|
|
return actionHandler(s, testName, func(_ *s3.Client, bucket string) error {
|
|
return handler(s.GetIAMClient(), bucket)
|
|
}, opts...)
|
|
}
|
|
|
|
// s3IAMComplianceActionHandler is s3IAMActionHandler for the tests that put
|
|
// an object under COMPLIANCE retention. Such an object cannot be deleted
|
|
// before its retention expires — by anyone, with any permission, by design —
|
|
// so its bucket cannot be torn down either.
|
|
//
|
|
// Rather than fail teardown, the bucket is left behind, and its name gets a
|
|
// random suffix so that a leftover from an earlier run against the same data
|
|
// directory can't collide with this one. The shared getBucketName counter
|
|
// restarts with each test process, so without the suffix a second local run
|
|
// would fail every one of these tests with BucketAlreadyOwnedByYou.
|
|
func s3IAMComplianceActionHandler(s *S3Conf, testName string, handler func(root *iam.Client, bucket string) error) error {
|
|
runF(testName)
|
|
|
|
// Lower-cased because genRandString's charset includes capitals, which
|
|
// bucket names do not allow.
|
|
bucket := getBucketName() + "-" + strings.ToLower(genRandString(8))
|
|
if err := setup(s, bucket, withLock()); err != nil {
|
|
failF("%v: failed to create a bucket: %v", testName, err)
|
|
return fmt.Errorf("%v: failed to create a bucket: %w", testName, err)
|
|
}
|
|
|
|
if err := handler(s.GetIAMClient(), bucket); err != nil {
|
|
failF("%v: %v", testName, err)
|
|
return fmt.Errorf("%v: %w", testName, err)
|
|
}
|
|
|
|
passF(testName)
|
|
return nil
|
|
}
|
|
|
|
// newS3IAMUser creates an IAM user with the given inline policies
|
|
// (policyName -> document, may be nil) and one long-term access key, and
|
|
// returns a principal whose S3 client is authenticated as that user, plus a
|
|
// cleanup func removing the key, the policies, and the user.
|
|
func newS3IAMUser(root *iam.Client, s *S3Conf, policies map[string]string) (*s3IAMPrincipal, func(), error) {
|
|
userName := newIAMUserName()
|
|
|
|
createOut, err := createIAMUser(root, &iam.CreateUserInput{UserName: aws.String(userName)})
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("create user: %w", err)
|
|
}
|
|
|
|
cleanup := func() { deleteS3IAMUser(root, userName) }
|
|
|
|
for name, doc := range policies {
|
|
if _, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{
|
|
UserName: aws.String(userName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc),
|
|
}); err != nil {
|
|
cleanup()
|
|
return nil, nil, fmt.Errorf("attach policy %q: %w", name, err)
|
|
}
|
|
}
|
|
|
|
keyOut, err := createIAMAccessKey(root, &iam.CreateAccessKeyInput{UserName: aws.String(userName)})
|
|
if err != nil {
|
|
cleanup()
|
|
return nil, nil, fmt.Errorf("create access key: %w", err)
|
|
}
|
|
|
|
conf := *s
|
|
conf.awsID = aws.ToString(keyOut.AccessKey.AccessKeyId)
|
|
conf.awsSecret = aws.ToString(keyOut.AccessKey.SecretAccessKey)
|
|
|
|
return &s3IAMPrincipal{
|
|
name: userName,
|
|
arn: aws.ToString(createOut.User.Arn),
|
|
conf: conf,
|
|
client: conf.GetClient(),
|
|
}, cleanup, nil
|
|
}
|
|
|
|
// putS3IAMUserPolicy attaches (or replaces) one inline policy on an existing
|
|
// principal, for tests that vary a policy in place across sub-cases rather
|
|
// than recreating the whole user each time.
|
|
func putS3IAMUserPolicy(root *iam.Client, principal *s3IAMPrincipal, policyName, document string) error {
|
|
_, err := putIAMUserPolicy(root, &iam.PutUserPolicyInput{
|
|
UserName: aws.String(principal.name),
|
|
PolicyName: aws.String(policyName),
|
|
PolicyDocument: aws.String(document),
|
|
})
|
|
return err
|
|
}
|
|
|
|
// deleteS3IAMUser removes every dependency DeleteUser would otherwise reject
|
|
// — inline policies and access keys — before deleting the user. The existing
|
|
// deleteIAMUserAndPolicies/deleteIAMUserAndAccessKeys helpers each cover
|
|
// only one of the two, and these fixtures always create both.
|
|
func deleteS3IAMUser(root *iam.Client, userName string) error {
|
|
polOut, err := listIAMUserPolicies(root, &iam.ListUserPoliciesInput{UserName: aws.String(userName)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, name := range polOut.PolicyNames {
|
|
if err := deleteIAMUserPolicy(root, userName, name); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
keyOut, err := listIAMAccessKeys(root, &iam.ListAccessKeysInput{UserName: aws.String(userName)})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, key := range keyOut.AccessKeyMetadata {
|
|
if err := deleteIAMAccessKey(root, userName, aws.ToString(key.AccessKeyId)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return deleteIAMUser(root, userName)
|
|
}
|
|
|
|
// putBucketPolicyDoc installs a bucket policy as root. Statements are built
|
|
// with bucketStatement so a test's intent stays readable and a typo becomes
|
|
// a compile error rather than a silently-malformed document.
|
|
func putBucketPolicyDoc(s *S3Conf, bucket string, statements ...bucketStatement) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
|
|
doc := bucketPolicyDoc(statements...)
|
|
_, err := s.GetClient().PutBucketPolicy(ctx, &s3.PutBucketPolicyInput{
|
|
Bucket: &bucket,
|
|
Policy: &doc,
|
|
})
|
|
return err
|
|
}
|
|
|
|
// bucketStatement is one S3 bucket-policy statement, built as a typed value
|
|
// rather than a formatted JSON string so a test typo is a compile error.
|
|
// It mirrors accessStatement (iam_access_control.go) for identity policies;
|
|
// the difference is Principal, which bucket policies require and identity
|
|
// policies forbid.
|
|
//
|
|
// Principal is matched against the caller's raw access key by this gateway
|
|
// (auth.Principals.Contains) — deliberately not against an ARN, for
|
|
// compatibility with the non-IAM backends that have no ARNs at all. A
|
|
// long-term user is therefore named by its AKIA… access key. An assumed-role
|
|
// session cannot be named at all: its ASIA… key is ephemeral, so
|
|
// auth.IAMService.ResolveAccounts rejects it outright rather than let a bucket
|
|
// policy come to reference a principal that stops existing. Session tests
|
|
// use "*" for that reason.
|
|
type bucketStatement struct {
|
|
Sid string `json:"Sid,omitempty"`
|
|
Effect string `json:"Effect"`
|
|
Principal any `json:"Principal"`
|
|
Action any `json:"Action"`
|
|
Resource any `json:"Resource"`
|
|
Condition json.RawMessage `json:"Condition,omitempty"`
|
|
}
|
|
|
|
// bucketPolicyDoc marshals statements into a complete bucket-policy
|
|
// document. Marshaling a fixed struct of strings cannot fail in practice; a
|
|
// panic here means a test itself is malformed.
|
|
func bucketPolicyDoc(statements ...bucketStatement) string {
|
|
doc := struct {
|
|
Version string `json:"Version"`
|
|
Statement []bucketStatement `json:"Statement"`
|
|
}{"2012-10-17", statements}
|
|
b, err := json.Marshal(doc)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("s3_iam_utils: bucketPolicyDoc: %v", err))
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// bucketArn and objectArn build the resource ARNs an S3 policy statement
|
|
// names, matching how the gateway builds the resource it evaluates against.
|
|
func bucketArn(bucket string) string { return "arn:aws:s3:::" + bucket }
|
|
func objectArn(bucket, key string) string { return "arn:aws:s3:::" + bucket + "/" + key }
|
|
func objectsArn(bucket string) string { return "arn:aws:s3:::" + bucket + "/*" }
|
|
|
|
// wantExplicitIdentityDeny, wantExplicitResourceDeny and wantImplicitDeny
|
|
// name the three denial shapes VerifyAccess produces. All three share Code
|
|
// AccessDenied and HTTP 403 and differ only in message text, which is
|
|
// exactly why these tests assert on the full message: a test checking only
|
|
// the code could not tell an identity-policy deny from a bucket-policy one,
|
|
// and the precedence between them is the whole point of this group.
|
|
func wantExplicitIdentityDeny(principal, action, resourceArn string) s3err.S3Error {
|
|
return s3err.GetExplicitDenyAccessErr(principal, action, resourceArn, "an identity-based policy")
|
|
}
|
|
|
|
func wantExplicitResourceDeny(principal, action, resourceArn string) s3err.S3Error {
|
|
return s3err.GetExplicitDenyAccessErr(principal, action, resourceArn, "a resource-based policy")
|
|
}
|
|
|
|
func wantImplicitDeny(principal, action, resourceArn string) s3err.S3Error {
|
|
return s3err.GetImplicitDenyAccessErr(principal, action, resourceArn)
|
|
}
|
|
|
|
// s3ClientWithSessionCreds builds an *s3.Client authenticated with a full
|
|
// access/secret/session-token triple, for the assumed-role session tests.
|
|
func s3ClientWithSessionCreds(s *S3Conf, access, secret, token string) *s3.Client {
|
|
conf := *s
|
|
conf.awsID = access
|
|
conf.awsSecret = secret
|
|
|
|
cfg := conf.Config()
|
|
cfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token)
|
|
return s3.NewFromConfig(cfg, func(o *s3.Options) {
|
|
if s.hostStyle {
|
|
o.BaseEndpoint = &s.endpoint
|
|
o.UsePathStyle = false
|
|
}
|
|
})
|
|
}
|
|
|
|
// s3ConditionCase is one row of a table-driven condition test: the Condition
|
|
// block to attach to an otherwise-unconditional GetObject Allow, and whether
|
|
// it should grant.
|
|
type s3ConditionCase struct {
|
|
name string
|
|
condition []byte
|
|
wantAllowed bool
|
|
}
|
|
|
|
// runS3ConditionCases attaches each case's condition to a fresh user's
|
|
// GetObject Allow and checks whether the resulting request is authorized.
|
|
// A failing condition voids the statement entirely, leaving nothing to
|
|
// grant — hence the implicit-deny expectation rather than an explicit one.
|
|
func runS3ConditionCases(root *iam.Client, s *S3Conf, bucket, key string, cases []s3ConditionCase) error {
|
|
user, cleanup, err := newS3IAMUser(root, s, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer cleanup()
|
|
|
|
for _, tc := range cases {
|
|
if err := func() error {
|
|
if err := putS3IAMUserPolicy(root, user, "p", policyDoc(accessStatement{
|
|
Effect: "Allow", Action: actS3GetObject, Resource: objectsArn(bucket),
|
|
Condition: tc.condition,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
_, err = user.client.GetObject(ctx, &s3.GetObjectInput{Bucket: &bucket, Key: getPtr(key)})
|
|
cancel()
|
|
if tc.wantAllowed {
|
|
if err != nil {
|
|
return fmt.Errorf("expected the request to be allowed: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
return checkApiErr(err, wantImplicitDeny(user.arn, actS3GetObject, objectArn(bucket, key)))
|
|
}(); err != nil {
|
|
return fmt.Errorf("%s: %w", tc.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func deleteObjectsWithBypass(client *s3.Client, bucket string, keys ...string) (*s3.DeleteObjectsOutput, error) {
|
|
objects := make([]types.ObjectIdentifier, len(keys))
|
|
for i, key := range keys {
|
|
objects[i] = types.ObjectIdentifier{Key: aws.String(key)}
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
return client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
|
|
Bucket: &bucket,
|
|
Delete: &types.Delete{Objects: objects},
|
|
BypassGovernanceRetention: aws.Bool(true),
|
|
})
|
|
}
|
|
|
|
// checkDeleteObjectsErr checks one DeleteObjects response entry against the
|
|
// key and denial it's expected to carry.
|
|
func checkDeleteObjectsErr(got types.Error, wantKey string, wantErr s3err.S3Error) error {
|
|
if got.Key == nil || *got.Key != wantKey {
|
|
return fmt.Errorf("expected the per-object error to be for key %q, got %+v", wantKey, got)
|
|
}
|
|
base := wantErr.BaseError()
|
|
if got.Code == nil || *got.Code != base.Code {
|
|
return fmt.Errorf("expected error code %q for key %q, got %+v", base.Code, wantKey, got)
|
|
}
|
|
if got.Message == nil || *got.Message != base.Description {
|
|
return fmt.Errorf("expected error message %q for key %q, got %+v", base.Description, wantKey, got)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// checkDeletedKeysInOrder checks that a DeleteObjects response's Deleted
|
|
// list names exactly wantKeys, in that order — DeleteObjects preserves the
|
|
// order objects were requested in across both the Deleted and Error lists.
|
|
func checkDeletedKeysInOrder(got []types.DeletedObject, wantKeys []string) error {
|
|
if len(got) != len(wantKeys) {
|
|
return fmt.Errorf("expected %d deleted objects %v, got %+v", len(wantKeys), wantKeys, got)
|
|
}
|
|
for i, want := range wantKeys {
|
|
if got[i].Key == nil || *got[i].Key != want {
|
|
return fmt.Errorf("expected deleted object %d to be %q, got %+v", i, want, got)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// putGovernanceLockedObject writes an object under GOVERNANCE retention, as
|
|
// root, for the bypass-permission tests to then try to delete.
|
|
func putGovernanceLockedObject(s *S3Conf, bucket, key string) error {
|
|
retainUntil := time.Now().UTC().Add(time.Hour)
|
|
_, err := putObjectWithData(0, &s3.PutObjectInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
ObjectLockMode: types.ObjectLockModeGovernance,
|
|
ObjectLockRetainUntilDate: &retainUntil,
|
|
}, s.GetClient())
|
|
return err
|
|
}
|
|
|
|
// deleteBucketPolicyIfAny clears the bucket policy for a sub-case that needs
|
|
// the resource side silent, tolerating there being none to delete — the
|
|
// table-driven tests reuse one bucket across cases rather than paying for a
|
|
// fresh bucket per row.
|
|
func deleteBucketPolicyIfAny(s *S3Conf, bucket string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
|
|
_, err := s.GetClient().DeleteBucketPolicy(ctx, &s3.DeleteBucketPolicyInput{Bucket: &bucket})
|
|
if err != nil && checkSdkApiErr(err, "NoSuchBucketPolicy") == nil {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// gitHubOIDCSkipReason explains, in the skip message, why a run outside the
|
|
// OIDC workflow can't exercise any of this.
|
|
const gitHubOIDCSkipReason = "ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN not set " +
|
|
"(expected outside a GitHub Actions job with id-token: write permission)"
|
|
|
|
var (
|
|
gitHubOIDCTokenOnce sync.Once
|
|
gitHubOIDCTokenVal string
|
|
gitHubOIDCTokenOK bool
|
|
)
|
|
|
|
// gitHubOIDCToken fetches one real ID token for the whole group and reuses
|
|
// it. Every test needs a token, and they all want the same audience and the
|
|
// same repo subject, so fetching one per test would only add round trips to
|
|
// GitHub's runtime endpoint for no additional coverage.
|
|
func gitHubOIDCToken() (string, bool) {
|
|
gitHubOIDCTokenOnce.Do(func() {
|
|
reqURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL")
|
|
reqToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN")
|
|
if reqURL == "" || reqToken == "" {
|
|
return
|
|
}
|
|
token, err := fetchGitHubIDToken(reqURL, reqToken, githubOIDCTestAudience)
|
|
if err != nil {
|
|
// The error is deliberately not propagated as a token: a fetch
|
|
// failure inside the workflow shows up as every test failing to
|
|
// assume a role, with the reason on the first one.
|
|
return
|
|
}
|
|
gitHubOIDCTokenVal, gitHubOIDCTokenOK = token, true
|
|
})
|
|
return gitHubOIDCTokenVal, gitHubOIDCTokenOK
|
|
}
|
|
|
|
// s3IAMSessionActionHandler is s3IAMActionHandler that first skips the test
|
|
// when no GitHub OIDC token can be minted — which is every environment but
|
|
// the one workflow holding id-token: write permission.
|
|
func s3IAMSessionActionHandler(s *S3Conf, testName string, handler func(root *iam.Client, bucket string) error, opts ...setupOpt) error {
|
|
if _, ok := gitHubOIDCToken(); !ok {
|
|
skipF("%v: %v", testName, gitHubOIDCSkipReason)
|
|
return nil
|
|
}
|
|
return s3IAMActionHandler(s, testName, handler, opts...)
|
|
}
|
|
|
|
// newGitHubSession registers a throwaway OIDC provider for GitHub Actions'
|
|
// issuer and a role trusting it, attaches rolePolicies as the role's inline
|
|
// permission policies, then assumes it with a real ID token and (when
|
|
// sessionPolicy is non-empty) an inline session policy.
|
|
//
|
|
// The returned principal's name is the role name, so a test can put another
|
|
// role policy on it or delete the role mid-test; arn is the assumed-role
|
|
// session ARN, which is what a denial message names.
|
|
func newGitHubSession(root *iam.Client, s *S3Conf, rolePolicies map[string]string, sessionPolicy string) (*s3IAMPrincipal, func(), error) {
|
|
token, ok := gitHubOIDCToken()
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("no GitHub OIDC token available")
|
|
}
|
|
repo := os.Getenv("GITHUB_REPOSITORY")
|
|
if repo == "" {
|
|
return nil, nil, fmt.Errorf("GITHUB_REPOSITORY is not set, but the OIDC token request variables are - unexpected environment")
|
|
}
|
|
|
|
roleName, _, cleanup, err := createGitHubOIDCTrust(root, repo)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
for name, doc := range rolePolicies {
|
|
if _, err := putIAMRolePolicy(root, &iam.PutRolePolicyInput{
|
|
RoleName: aws.String(roleName), PolicyName: aws.String(name), PolicyDocument: aws.String(doc),
|
|
}); err != nil {
|
|
cleanup()
|
|
return nil, nil, fmt.Errorf("attach role policy %q: %w", name, err)
|
|
}
|
|
}
|
|
|
|
sessionName := "s3-sess-" + genRandString(8)
|
|
out, err := assumeRoleWithWebIdentitySessionPolicy(s, roleArnFor(roleName), sessionName, token, sessionPolicy)
|
|
if err != nil {
|
|
cleanup()
|
|
return nil, nil, fmt.Errorf("AssumeRoleWithWebIdentity: %w", err)
|
|
}
|
|
|
|
access := aws.ToString(out.Credentials.AccessKeyId)
|
|
secret := aws.ToString(out.Credentials.SecretAccessKey)
|
|
sessionToken := aws.ToString(out.Credentials.SessionToken)
|
|
|
|
conf := *s
|
|
conf.awsID = access
|
|
conf.awsSecret = secret
|
|
|
|
principal := &s3IAMPrincipal{
|
|
name: roleName,
|
|
arn: aws.ToString(out.AssumedRoleUser.Arn),
|
|
conf: conf,
|
|
client: s3ClientWithSessionCreds(s, access, secret, sessionToken),
|
|
sessionToken: sessionToken,
|
|
}
|
|
// The role may already have been deleted by the test itself
|
|
// (S3IAMSession_deleted_role_denies); cleanup tolerates that.
|
|
return principal, cleanup, nil
|
|
}
|
|
|
|
// assumeRoleWithWebIdentitySessionPolicy is assumeRoleWithWebIdentity with
|
|
// the optional inline session-policy parameter, which no other test in this
|
|
// package needs.
|
|
func assumeRoleWithWebIdentitySessionPolicy(s *S3Conf, roleArn, sessionName, token, sessionPolicy string) (*sts.AssumeRoleWithWebIdentityOutput, error) {
|
|
input := &sts.AssumeRoleWithWebIdentityInput{
|
|
RoleArn: aws.String(roleArn),
|
|
RoleSessionName: aws.String(sessionName),
|
|
WebIdentityToken: aws.String(token),
|
|
}
|
|
if sessionPolicy != "" {
|
|
input.Policy = aws.String(sessionPolicy)
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
return s.GetSTSClient().AssumeRoleWithWebIdentity(ctx, input)
|
|
}
|
|
|
|
// roleArnFor builds the ARN of a role in this gateway's single fixed
|
|
// account.
|
|
func roleArnFor(roleName string) string {
|
|
return "arn:aws:iam::" + testAccountID + ":role/" + roleName
|
|
}
|
|
|
|
// sessionNameFor recovers the session name from an assumed-role ARN, whose
|
|
// last path element it is.
|
|
func sessionNameFor(p *s3IAMPrincipal) string {
|
|
idx := strings.LastIndex(p.arn, "/")
|
|
if idx < 0 {
|
|
return ""
|
|
}
|
|
return p.arn[idx+1:]
|
|
}
|
|
|
|
// deleteObjectBypassingGovernance deletes one object with the
|
|
// bypass-governance-retention header set.
|
|
func deleteObjectBypassingGovernance(client *s3.Client, bucket, key string) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
_, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
|
Bucket: &bucket,
|
|
Key: &key,
|
|
BypassGovernanceRetention: aws.Bool(true),
|
|
})
|
|
return err
|
|
}
|