mirror of
https://github.com/versity/versitygw.git
synced 2026-08-29 04:06:56 +00:00
Gates bucket listing behind an identity policy, lets browsers reach the standalone IAM API, and turns the WebUI into a dashboard for S3, IAM, or both.
**Bucket listing.** `ListBuckets` is now gated by the new `s3:ListAllMyBuckets` action, evaluated against `arn:aws:s3:::*`. The request names no bucket, so only identity policies apply — there is no resource policy to combine with, which is the same shape `CreateBucket` already had, so both now share one identity-only evaluation path. Root and admin bypass it, and backends with no identity-policy layer keep listing as before since their listing is already narrowed to the caller's own buckets. The action is IAM-only and is deliberately absent from the bucket-policy action list.
**Fixed bucket ownership.** The standalone IAM client has no per-user ownership to express — accounts are all plain users, cannot be enumerated, and access is decided by policy rather than ACL — so it now implements `auth.FixedBucketOwner` and every bucket is owned by root. Bucket creation stops resolving an owner, `ListBuckets` returns every bucket to every caller (what they may then do with one stays a per-request policy decision), and the admin `ChangeBucketOwner` reports method-not-supported. Other IAM backends are untouched.
**IAM service CORS.** `--cors-allow-origin` now applies to the `iam` command: it answers preflights and stamps the CORS headers, mirroring back the requested method and headers rather than enumerating the SigV4 header set. Without it no browser can reach the IAM API at all, so setting `--webui` without it falls back to `*` with a warning. The chart gets `iamServer.corsAllowOrigin`.
**WebUI.** New IAM pages for users, roles and OIDC providers, signing IAM/STS query-form requests directly from the browser. Navigation is capability-gated rather than role-gated: on sign-in the session probes the S3, admin and IAM endpoints independently and each page shows only what those credentials actually reach, so one build serves an IAM-only dashboard, an S3-only dashboard, and a combined one. The login page takes an optional IAM endpoint, seeded from the new `--webui-iam-gateways` (chart: `webui.iamGateways`) — never auto-detected, since the IAM service is a separate process. The WebUI can also be hosted by `versitygw iam` itself, for deployments with no S3 gateway behind it.
**The admin API is ignored once an IAM endpoint is in play.** The IAM service is then the user directory and bucket ownership is fixed, which leaves the admin API no job: the session is given no admin endpoint at all, its login field is hidden, `users.html` redirects to its IAM counterpart, and every admin-only surface stays off screen. Dashboard and Buckets remain available to any S3 session in such a deployment, running on the S3 and IAM APIs alone and surfacing each denial per action instead of redirecting.
Also fixes two WebUI bugs: embedded assets went out with a zero modification time and no `Cache-Control`, so browsers treated them as fresh for centuries and an upgraded gateway served new HTML against stale JS — they now revalidate against an ETag; and the login page's advanced-options section clipped its last field, since it animated to a height named in the stylesheet rather than the one it measures now.
**Usage**
IAM-only dashboard, served by the IAM service:
versitygw iam --port :7076 --webui :8080 --cors-allow-origin http://localhost:8080/
IAM + S3, dashboard served by the IAM service — point it at the gateway with `--webui-gateways`, and let the gateway accept the dashboard's origin:
versitygw iam --port :7076 --webui :8080 --webui-gateways http://localhost:7070/ --cors-allow-origin http://localhost:8080/
versitygw --port :7070 --cors-allow-origin http://localhost:8080/ posix /data
IAM + S3, dashboard served by the S3 gateway — point it at the IAM service with `--webui-iam-gateways`, and let the IAM service accept the dashboard's origin:
versitygw --port :7070 --webui :8080 --webui-iam-gateways http://localhost:7076/ posix /data
versitygw iam --port :7076 --cors-allow-origin http://localhost:8080/
552 lines
21 KiB
Go
552 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"
|
|
actS3ListAllMyBuckets = "s3:ListAllMyBuckets"
|
|
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
|
|
}
|