mirror of
https://github.com/versity/versitygw.git
synced 2026-09-22 07:54:14 +00:00
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.
713 lines
28 KiB
Go
713 lines
28 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"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"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/aws/smithy-go"
|
|
"github.com/versity/versitygw/s3err"
|
|
)
|
|
|
|
const (
|
|
actS3GetObject = "s3:GetObject"
|
|
actS3PutObject = "s3:PutObject"
|
|
actS3DeleteObject = "s3:DeleteObject"
|
|
actS3DeleteObjectVersion = "s3:DeleteObjectVersion"
|
|
actS3ListBucket = "s3:ListBucket"
|
|
actS3CreateBucket = "s3:CreateBucket"
|
|
actS3ListAllMyBuckets = "s3:ListAllMyBuckets"
|
|
actS3BypassGovernance = "s3:BypassGovernanceRetention"
|
|
)
|
|
|
|
const (
|
|
// githubOIDCIssuerURL is GitHub Actions' own OIDC token issuer: a real,
|
|
// publicly reachable HTTPS endpoint with a CA-issued certificate.
|
|
githubOIDCIssuerURL = "https://token.actions.githubusercontent.com"
|
|
|
|
// githubOIDCTestAudience is deliberately distinct from GitHub's default
|
|
// audience (which is the caller's own server URL). If this org ever
|
|
// configures a real cloud-provider role trusting
|
|
// token.actions.githubusercontent.com for this repo (e.g. for
|
|
// publishing/deploys), a leaked test token must not be replayable
|
|
// against that unrelated trust relationship - binding the throwaway
|
|
// role's trust policy to this audience (instead of GitHub's default)
|
|
// is what prevents that.
|
|
githubOIDCTestAudience = "versitygw-integration-tests"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// createGitHubOIDCTrust registers a throwaway OIDC provider for GitHub
|
|
// Actions' own issuer (ThumbprintList omitted, exercising
|
|
// CreateOpenIDConnectProvider's autofetch-and-CA-verify path against a real
|
|
// publicly reachable HTTPS endpoint instead of thumbprint pinning) and a
|
|
// throwaway role trusting it, returning the role's name, its ARN, and a
|
|
// cleanup func that removes both unconditionally.
|
|
//
|
|
// The trust policy's Condition requires both:
|
|
// - the effective audience to equal githubOIDCTestAudience (not GitHub's
|
|
// default audience - see that constant's doc comment), and
|
|
// - the sub claim to match "repo:<repo>:*".
|
|
//
|
|
// The sub match is a repo-wide wildcard rather than pinning an exact
|
|
// ref/event suffix: GitHub's sub claim differs by trigger and branch (e.g.
|
|
// "repo:o/r:pull_request" for a pull_request event vs.
|
|
// "repo:o/r:ref:refs/heads/main" for a push to main), and pinning one exact
|
|
// form would make this test fail depending on how it was triggered. That
|
|
// tradeoff only holds because this role is created and deleted within a
|
|
// single test run - the same repo-wide wildcard left in a real production
|
|
// trust policy would grant every workflow run in the repo, on any branch,
|
|
// the same trust, which is far too broad outside this throwaway context.
|
|
func createGitHubOIDCTrust(client *iam.Client, repo string) (roleName, roleArn string, cleanup func(), err error) {
|
|
// The provider is keyed by URL alone — a second CreateOpenIDConnectProvider
|
|
// for the same githubOIDCIssuerURL fails with EntityAlreadyExists, same as
|
|
// real AWS. Some tests mint more than one session (and so call this more
|
|
// than once) within a single run, so a provider left by an earlier call
|
|
// that hasn't been cleaned up yet is expected, not a leak: reuse it rather
|
|
// than failing, and only this call's cleanup deletes it if this call is
|
|
// the one that actually created it.
|
|
ownsProvider := true
|
|
out, err := createOIDCProvider(client, &iam.CreateOpenIDConnectProviderInput{
|
|
Url: aws.String(githubOIDCIssuerURL),
|
|
ClientIDList: []string{githubOIDCTestAudience},
|
|
})
|
|
var providerArn string
|
|
if err != nil {
|
|
var ae smithy.APIError
|
|
if !errors.As(err, &ae) || ae.ErrorCode() != "EntityAlreadyExists" {
|
|
return "", "", nil, fmt.Errorf("create GitHub OIDC provider: %w", err)
|
|
}
|
|
ownsProvider = false
|
|
providerArn = oidcProviderArn(githubOIDCIssuerURL)
|
|
} else {
|
|
providerArn = aws.ToString(out.OpenIDConnectProviderArn)
|
|
}
|
|
|
|
host := trimProviderScheme(githubOIDCIssuerURL)
|
|
roleName = "github-oidc-" + genRandString(12)
|
|
trust := fmt.Sprintf(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":%q},"Action":"sts:AssumeRoleWithWebIdentity",`+
|
|
`"Condition":{"StringEquals":{"%s:aud":%q},"StringLike":{"%s:sub":%q}}}]}`,
|
|
providerArn, host, githubOIDCTestAudience, host, "repo:"+repo+":*")
|
|
if _, err := createIAMRole(client, &iam.CreateRoleInput{RoleName: &roleName, AssumeRolePolicyDocument: &trust}); err != nil {
|
|
if ownsProvider {
|
|
deleteOIDCProvider(client, providerArn)
|
|
}
|
|
return "", "", nil, fmt.Errorf("create GitHub OIDC trust role: %w", err)
|
|
}
|
|
|
|
roleArn = "arn:aws:iam::000000000000:role/" + roleName
|
|
cleanup = func() {
|
|
deleteIAMRole(client, roleName)
|
|
if ownsProvider {
|
|
deleteOIDCProvider(client, providerArn)
|
|
}
|
|
}
|
|
return roleName, roleArn, cleanup, nil
|
|
}
|
|
|
|
// githubIDTokenResponse is the JSON body GitHub's runtime ID-token endpoint
|
|
// returns: {"value": "<jwt>", "count": <n>}. Only value is needed here.
|
|
type githubIDTokenResponse struct {
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
// fetchGitHubIDToken fetches a real, signed OIDC ID token for audience from
|
|
// GitHub Actions' runtime token endpoint (requestURL/requestToken are
|
|
// ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN, only present
|
|
// inside a GitHub Actions job with id-token: write permission).
|
|
//
|
|
// The returned token is a real, unmasked bearer credential - unlike a
|
|
// secrets.* value, GitHub does not scrub it from logs automatically since it
|
|
// never appears in the workflow YAML. Every error path here is deliberately
|
|
// built from fixed strings and status codes only, never from the response
|
|
// body or the request's Authorization header, so a failure here can never
|
|
// leak the token into CI output.
|
|
func fetchGitHubIDToken(requestURL, requestToken, audience string) (string, error) {
|
|
parsed, err := url.Parse(requestURL)
|
|
if err != nil {
|
|
return "", fmt.Errorf("parse ACTIONS_ID_TOKEN_REQUEST_URL: invalid URL")
|
|
}
|
|
q := parsed.Query()
|
|
q.Set("audience", audience)
|
|
parsed.RawQuery = q.Encode()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil)
|
|
if err != nil {
|
|
return "", fmt.Errorf("build GitHub OIDC token request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+requestToken)
|
|
req.Header.Set("Accept", "application/json; api-version=2.0")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("fetch GitHub OIDC token: request failed")
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
|
if err != nil {
|
|
return "", fmt.Errorf("read GitHub OIDC token response: failed after status %d", resp.StatusCode)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return "", fmt.Errorf("GitHub OIDC token endpoint returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var out githubIDTokenResponse
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return "", fmt.Errorf("parse GitHub OIDC token response: malformed JSON")
|
|
}
|
|
if out.Value == "" {
|
|
return "", fmt.Errorf("GitHub OIDC token endpoint returned an empty token value")
|
|
}
|
|
return out.Value, nil
|
|
}
|
|
|
|
// getCallerIdentityWithSessionCreds calls GetCallerIdentity authenticated
|
|
// with a full access/secret/session-token triple.
|
|
func getCallerIdentityWithSessionCreds(cfg S3Conf, access, secret, token string) (*sts.GetCallerIdentityOutput, error) {
|
|
cfg.awsID = access
|
|
cfg.awsSecret = secret
|
|
stsCfg := cfg.iamConfig()
|
|
stsCfg.Credentials = credentials.NewStaticCredentialsProvider(access, secret, token)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
return sts.NewFromConfig(stsCfg).GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
|
|
}
|