mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +00:00
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.
This commit is contained in:
@@ -1368,6 +1368,7 @@ func TestIAMGetRole(ts *TestState) {
|
||||
ts.Run(IAMGetRole_long_role_name)
|
||||
ts.Run(IAMGetRole_non_existing_role)
|
||||
ts.Run(IAMGetRole_success)
|
||||
ts.Run(IAMGetRole_role_last_used_never_used)
|
||||
}
|
||||
|
||||
func TestIAMListRoles(ts *TestState) {
|
||||
@@ -1620,7 +1621,6 @@ func TestIAMAssumeRoleWithWebIdentity(ts *TestState) {
|
||||
ts.Run(IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch)
|
||||
ts.Run(IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch)
|
||||
ts.Run(IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch)
|
||||
ts.Run(IAMAssumeRoleWithWebIdentity_github_oidc_live)
|
||||
}
|
||||
|
||||
func TestIAMGetCallerIdentity(ts *TestState) {
|
||||
@@ -1737,8 +1737,14 @@ func TestS3IAMAccessControl(ts *TestState) {
|
||||
ts.Run(S3IAMAccessControl_condition_multiple_keys_anded)
|
||||
ts.Run(S3IAMAccessControl_inactive_and_deleted_credentials)
|
||||
ts.Run(S3IAMAccessControl_bucket_policy_unknown_principal_rejected)
|
||||
ts.Run(S3IAMAccessControl_access_key_last_used_records_s3)
|
||||
}
|
||||
|
||||
// TestS3IAMSessionAccessControl is the one group the OIDC workflow runs, so
|
||||
// it carries every test that needs a real, signed ID token — including the
|
||||
// IAM/STS-endpoint ones below, which are not S3 access-control tests but
|
||||
// have the same GitHub-OIDC prerequisite. Every test here skips itself
|
||||
// outside a job that can mint a token.
|
||||
func TestS3IAMSessionAccessControl(ts *TestState) {
|
||||
ts.Run(S3IAMSession_role_policy_allows)
|
||||
ts.Run(S3IAMSession_role_without_policy_denied)
|
||||
@@ -1760,6 +1766,9 @@ func TestS3IAMSessionAccessControl(ts *TestState) {
|
||||
ts.Run(S3IAMSession_delete_objects_authorizes_each_key)
|
||||
ts.Run(S3IAMSession_condition_identity_keys)
|
||||
ts.Run(S3IAMSession_get_caller_identity_matches_s3_principal)
|
||||
ts.Run(S3IAMSession_AssumeRoleWithWebIdentity_github_oidc_live)
|
||||
ts.Run(S3IAMSession_GetRole_role_last_used_recorded)
|
||||
ts.Run(S3IAMSession_role_last_used_records_s3)
|
||||
}
|
||||
|
||||
func TestIAM(ts *TestState) {
|
||||
@@ -2136,6 +2145,9 @@ func GetIntTests() IntTests {
|
||||
"S3IAMSession_role_policy_explicit_deny_wins": S3IAMSession_role_policy_explicit_deny_wins,
|
||||
"S3IAMSession_role_without_policy_denied": S3IAMSession_role_without_policy_denied,
|
||||
"S3IAMSession_role_policy_allows": S3IAMSession_role_policy_allows,
|
||||
"S3IAMSession_AssumeRoleWithWebIdentity_github_oidc_live": S3IAMSession_AssumeRoleWithWebIdentity_github_oidc_live,
|
||||
"S3IAMSession_GetRole_role_last_used_recorded": S3IAMSession_GetRole_role_last_used_recorded,
|
||||
"S3IAMSession_role_last_used_records_s3": S3IAMSession_role_last_used_records_s3,
|
||||
"S3IAMAccessControl_retention_extension_needs_no_bypass": S3IAMAccessControl_retention_extension_needs_no_bypass,
|
||||
"S3IAMAccessControl_delete_objects_authorizes_each_key": S3IAMAccessControl_delete_objects_authorizes_each_key,
|
||||
"S3IAMAccessControl_delete_objects_version_needs_separate_permission": S3IAMAccessControl_delete_objects_version_needs_separate_permission,
|
||||
@@ -2168,6 +2180,7 @@ func GetIntTests() IntTests {
|
||||
"S3IAMAccessControl_condition_multiple_keys_anded": S3IAMAccessControl_condition_multiple_keys_anded,
|
||||
"S3IAMAccessControl_inactive_and_deleted_credentials": S3IAMAccessControl_inactive_and_deleted_credentials,
|
||||
"S3IAMAccessControl_bucket_policy_unknown_principal_rejected": S3IAMAccessControl_bucket_policy_unknown_principal_rejected,
|
||||
"S3IAMAccessControl_access_key_last_used_records_s3": S3IAMAccessControl_access_key_last_used_records_s3,
|
||||
"Authentication_invalid_auth_header": Authentication_invalid_auth_header,
|
||||
"Authentication_unsupported_signature_version": Authentication_unsupported_signature_version,
|
||||
"Authentication_missing_components": Authentication_missing_components,
|
||||
@@ -2405,6 +2418,7 @@ func GetIntTests() IntTests {
|
||||
"IAMGetRole_long_role_name": IAMGetRole_long_role_name,
|
||||
"IAMGetRole_non_existing_role": IAMGetRole_non_existing_role,
|
||||
"IAMGetRole_success": IAMGetRole_success,
|
||||
"IAMGetRole_role_last_used_never_used": IAMGetRole_role_last_used_never_used,
|
||||
"IAMListRoles_invalid_path_prefix": IAMListRoles_invalid_path_prefix,
|
||||
"IAMListRoles_long_path_prefix": IAMListRoles_long_path_prefix,
|
||||
"IAMListRoles_invalid_max_items": IAMListRoles_invalid_max_items,
|
||||
@@ -2594,7 +2608,6 @@ func GetIntTests() IntTests {
|
||||
"IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch": IAMAssumeRoleWithWebIdentity_oaud_condition_mismatch,
|
||||
"IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch": IAMAssumeRoleWithWebIdentity_issuer_trailing_slash_mismatch,
|
||||
"IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch": IAMAssumeRoleWithWebIdentity_issuer_scheme_mismatch,
|
||||
"IAMAssumeRoleWithWebIdentity_github_oidc_live": IAMAssumeRoleWithWebIdentity_github_oidc_live,
|
||||
"IAMGetCallerIdentity_root_success": IAMGetCallerIdentity_root_success,
|
||||
"IAMGetCallerIdentity_user_success": IAMGetCallerIdentity_user_success,
|
||||
"IAMGetCallerIdentity_unknown_access_key": IAMGetCallerIdentity_unknown_access_key,
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
// 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"
|
||||
|
||||
"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/sts"
|
||||
"github.com/aws/smithy-go"
|
||||
)
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// IAMAssumeRoleWithWebIdentity_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 IAMAssumeRoleWithWebIdentity_github_oidc_live(s *S3Conf) error {
|
||||
testName := "IAMAssumeRoleWithWebIdentity_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
|
||||
})
|
||||
}
|
||||
|
||||
// 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{})
|
||||
}
|
||||
@@ -415,8 +415,8 @@ func checkRoleFields(operation string, role *iamtypes.Role, roleName, path, desc
|
||||
if gotDocument != wantDocument {
|
||||
return fmt.Errorf("expected assume role policy document %q, instead got %q", wantDocument, gotDocument)
|
||||
}
|
||||
if role.RoleLastUsed == nil {
|
||||
return fmt.Errorf("expected role RoleLastUsed to be non-nil (empty element)")
|
||||
if err := checkRoleNeverUsed(role.RoleLastUsed); err != nil {
|
||||
return fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if expectTags {
|
||||
if len(role.Tags) != 1 || aws.ToString(role.Tags[0].Key) != "env" || aws.ToString(role.Tags[0].Value) != "test" {
|
||||
|
||||
@@ -106,6 +106,39 @@ func IAMGetRole_success(s *S3Conf) error {
|
||||
})
|
||||
}
|
||||
|
||||
// IAMGetRole_role_last_used_never_used confirms a role nobody has assumed
|
||||
// reports the empty RoleLastUsed element — present, but carrying neither a
|
||||
// date nor a region, exactly as real IAM answers for a never-used role.
|
||||
func IAMGetRole_role_last_used_never_used(s *S3Conf) error {
|
||||
testName := "IAMGetRole_role_last_used_never_used"
|
||||
return iamActionHandler(s, testName, func(client *iam.Client) error {
|
||||
roleName := newIAMRoleName()
|
||||
if _, err := createIAMRole(client, &iam.CreateRoleInput{
|
||||
RoleName: &roleName,
|
||||
AssumeRolePolicyDocument: aws.String(validTrustPolicyDocument),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := getIAMRole(client, roleName)
|
||||
checkErr := func() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out.Role == nil {
|
||||
return fmt.Errorf("expected GetRole to return a role")
|
||||
}
|
||||
return checkRoleNeverUsed(out.Role.RoleLastUsed)
|
||||
}()
|
||||
|
||||
deleteErr := deleteIAMRole(client, roleName)
|
||||
if checkErr != nil {
|
||||
return checkErr
|
||||
}
|
||||
return deleteErr
|
||||
})
|
||||
}
|
||||
|
||||
func getIAMRole(client *iam.Client, roleName string) (*iam.GetRoleOutput, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
defer cancel()
|
||||
@@ -120,3 +153,19 @@ func checkGetRoleOutput(out *iam.GetRoleOutput, roleName, path, description stri
|
||||
requestID, hasRequestID := awsmiddleware.GetRequestIDMetadata(out.ResultMetadata)
|
||||
return checkRoleFields("GetRole", out.Role, roleName, path, description, maxSessionDuration, wantDocument, expectTags, requestID, hasRequestID)
|
||||
}
|
||||
|
||||
// checkRoleNeverUsed asserts lastUsed is the empty element real IAM returns
|
||||
// for a role that has never been used: present, but carrying neither a date
|
||||
// nor a region.
|
||||
func checkRoleNeverUsed(lastUsed *iamtypes.RoleLastUsed) error {
|
||||
if lastUsed == nil {
|
||||
return fmt.Errorf("expected role RoleLastUsed to be non-nil (empty element)")
|
||||
}
|
||||
if lastUsed.LastUsedDate != nil {
|
||||
return fmt.Errorf("expected no role last used date, instead got %v", *lastUsed.LastUsedDate)
|
||||
}
|
||||
if aws.ToString(lastUsed.Region) != "" {
|
||||
return fmt.Errorf("expected no role last used region, instead got %q", aws.ToString(lastUsed.Region))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1902,6 +1902,62 @@ func S3IAMAccessControl_bucket_policy_unknown_principal_rejected(s *S3Conf) erro
|
||||
})
|
||||
}
|
||||
|
||||
// S3IAMAccessControl_access_key_last_used_records_s3 covers last-used
|
||||
// tracking for the S3 data plane: an IAM user's S3 request is recorded
|
||||
// against the access key that signed it, with the "s3" service name and the
|
||||
// gateway's region — neither of which the IAM control plane can produce.
|
||||
func S3IAMAccessControl_access_key_last_used_records_s3(s *S3Conf) error {
|
||||
testName := "S3IAMAccessControl_access_key_last_used_records_s3"
|
||||
return s3IAMActionHandler(s, testName, func(root *iam.Client, bucket string) error {
|
||||
user, cleanup, err := newS3IAMUser(root, s, map[string]string{
|
||||
"p": policyDoc(accessStatement{
|
||||
Effect: "Allow", Action: actS3ListBucket, Resource: []string{bucketArn(bucket)},
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
accessKeyID := user.conf.awsID
|
||||
before, err := getIAMAccessKeyLastUsed(root, accessKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if before.AccessKeyLastUsed.LastUsedDate != nil {
|
||||
return fmt.Errorf("expected a freshly created access key to be unused, instead got %v", before.AccessKeyLastUsed.LastUsedDate)
|
||||
}
|
||||
|
||||
start := time.Now().UTC().Add(-time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = user.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{Bucket: &bucket})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return fmt.Errorf("expected ListObjects to be allowed by the identity policy: %w", err)
|
||||
}
|
||||
|
||||
after, err := getIAMAccessKeyLastUsed(root, accessKeyID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastUsed := after.AccessKeyLastUsed
|
||||
if lastUsed.LastUsedDate == nil {
|
||||
return fmt.Errorf("expected the s3 request to record an access key last used date")
|
||||
}
|
||||
if lastUsed.LastUsedDate.Before(start) {
|
||||
return fmt.Errorf("expected access key last used date to be at or after %v, instead got %v", start, *lastUsed.LastUsedDate)
|
||||
}
|
||||
if aws.ToString(lastUsed.ServiceName) != "s3" {
|
||||
return fmt.Errorf("expected access key last used service name to be %q, instead got %q", "s3", aws.ToString(lastUsed.ServiceName))
|
||||
}
|
||||
if aws.ToString(lastUsed.Region) != s.awsRegion {
|
||||
return fmt.Errorf("expected access key last used region to be %q, instead got %q", s.awsRegion, aws.ToString(lastUsed.Region))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// containsBucket reports whether buckets names bucket, so a listing can be
|
||||
// asserted without depending on what else other tests left behind.
|
||||
func containsBucket(buckets []types.Bucket, bucket string) bool {
|
||||
|
||||
@@ -18,7 +18,9 @@ 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"
|
||||
@@ -791,3 +793,241 @@ func S3IAMSession_get_caller_identity_matches_s3_principal(s *S3Conf) error {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ package integration
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -29,6 +33,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -43,6 +48,22 @@ const (
|
||||
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,
|
||||
@@ -549,3 +570,143 @@ func deleteObjectBypassingGovernance(client *s3.Client, bucket, key string) erro
|
||||
})
|
||||
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{})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user