diff --git a/auth/iam.go b/auth/iam.go
index d8ec96dc..20ce62a8 100644
--- a/auth/iam.go
+++ b/auth/iam.go
@@ -212,6 +212,7 @@ type Opts struct {
StandaloneDefaultUserID int
StandaloneDefaultGroupID int
StandaloneDefaultProjectID int
+ StandaloneRegion string
}
func New(o *Opts) (IAMService, error) {
@@ -231,6 +232,7 @@ func New(o *Opts) (IAMService, error) {
DefaultUserID: o.StandaloneDefaultUserID,
DefaultGroupID: o.StandaloneDefaultGroupID,
DefaultProjectID: o.StandaloneDefaultProjectID,
+ Region: o.StandaloneRegion,
})
if err != nil {
return nil, err
diff --git a/auth/iam_standalone.go b/auth/iam_standalone.go
index a177c336..cb2cee3c 100644
--- a/auth/iam_standalone.go
+++ b/auth/iam_standalone.go
@@ -99,6 +99,13 @@ type IAMServiceStandaloneConfig struct {
DefaultUserID int
DefaultGroupID int
DefaultProjectID int
+ // Region is the gateway's own configured region, reported to the IAM
+ // service as the region an S3 request was made in when it records the
+ // caller's last-used metadata. It is taken from configuration rather
+ // than from the request's credential scope on purpose: the scope is
+ // attacker-controlled until the signature is verified. Empty disables
+ // the reporting rather than storing a blank region.
+ Region string
}
// IAMServiceStandalone is the S3 gateway's client for a standalone IAM
@@ -463,6 +470,9 @@ func (s *IAMServiceStandalone) EvaluatePolicy(access, sessionToken string, actio
Actions: actionStrs,
Resources: resources,
Condition: condition,
+ // Reported for last-used metadata only; see EvaluatePolicyRequest.
+ Region: s.cfg.Region,
+ Service: sigv4auth.ServiceS3,
}, &resp)
if err != nil {
return PolicyEvaluation{}, err
diff --git a/embedgw/embedgw.go b/embedgw/embedgw.go
index 4d2ec083..1a0c72a6 100644
--- a/embedgw/embedgw.go
+++ b/embedgw/embedgw.go
@@ -753,6 +753,7 @@ func RunVersityGW(ctx context.Context, be backend.Backend, cfg *Config) error {
StandaloneDefaultUserID: cfg.StandaloneDefaultUserID,
StandaloneDefaultGroupID: cfg.StandaloneDefaultGroupID,
StandaloneDefaultProjectID: cfg.StandaloneDefaultProjectID,
+ StandaloneRegion: cfg.Region,
})
if err != nil {
return fmt.Errorf("setup iam: %w", err)
diff --git a/iamapi/controller_test.go b/iamapi/controller_test.go
index 6a935f10..08198c5c 100644
--- a/iamapi/controller_test.go
+++ b/iamapi/controller_test.go
@@ -1469,6 +1469,108 @@ func TestIAMApiControllerRoleLifecycle(t *testing.T) {
requireIAMError(t, missing, http.StatusNotFound, "Sender", "NoSuchEntity", "The role with name my-role cannot be found.")
}
+// TestIAMApiControllerRoleLastUsed covers the RoleLastUsed lifecycle GetRole
+// reports: a role nobody has assumed carries the empty element, and a
+// request authenticated with one of the role's session credentials records
+// that use — the role's counterpart to an access key's GetAccessKeyLastUsed
+// tracking. GetCallerIdentity is the request here because it needs no
+// policy of its own, so this exercises the auth middleware's recording
+// independently of what the role is allowed to do.
+func TestIAMApiControllerRoleLastUsed(t *testing.T) {
+ server := newIAMControllerTestServer(t)
+ session := createTestSession(t, server, "tracked-role",
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "")
+
+ if lastUsed := getRoleLastUsed(t, server, "tracked-role"); lastUsed == nil || lastUsed.LastUsedDate != nil || lastUsed.Region != "" {
+ t.Fatalf("RoleLastUsed before any use = %#v, want the empty element", lastUsed)
+ }
+
+ before := time.Now().UTC().Add(-time.Second)
+ resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken,
+ url.Values{"Action": {"GetCallerIdentity"}})
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp))
+ }
+
+ lastUsed := getRoleLastUsed(t, server, "tracked-role")
+ if lastUsed == nil || lastUsed.LastUsedDate == nil {
+ t.Fatalf("RoleLastUsed after a session-authenticated request = %#v, want a recorded date", lastUsed)
+ }
+ if lastUsed.LastUsedDate.Before(before) {
+ t.Fatalf("RoleLastUsed.LastUsedDate = %v, want at or after %v", lastUsed.LastUsedDate, before)
+ }
+ if lastUsed.Region != iammiddleware.SigningRegion {
+ t.Fatalf("RoleLastUsed.Region = %q, want %q", lastUsed.Region, iammiddleware.SigningRegion)
+ }
+
+ // ListRoles omits RoleLastUsed entirely, used or not.
+ list := doIAMAction(t, server, url.Values{"Action": {"ListRoles"}})
+ var listOut iamtypes.ListRolesResponse
+ unmarshalXML(t, readBody(t, list), &listOut)
+ if len(listOut.Result.Roles.Members) != 1 || listOut.Result.Roles.Members[0].RoleLastUsed != nil {
+ t.Fatalf("ListRoles members = %#v, want the used role with no RoleLastUsed", listOut.Result.Roles.Members)
+ }
+}
+
+// TestIAMApiControllerRoleLastUsedNotRecordedForReplacedRole confirms a
+// session that outlived its role does not attribute its own use to a
+// same-named replacement role: the session still authenticates (STS
+// credentials are self-contained), but the new role — which it was never
+// minted against — must still report as never used.
+func TestIAMApiControllerRoleLastUsedNotRecordedForReplacedRole(t *testing.T) {
+ server := newIAMControllerTestServer(t)
+ createTestRoleForTrust(t, server, "recreated-role", validTrustPolicy)
+
+ get := doIAMAction(t, server, url.Values{"Action": {"GetRole"}, "RoleName": {"recreated-role"}})
+ var getOut iamtypes.GetRoleResponse
+ unmarshalXML(t, readBody(t, get), &getOut)
+
+ now := time.Now().UTC()
+ session := iamtypes.Session{
+ AccessKeyId: "ASIAtESTREPLACEDROLE1",
+ SecretAccessKey: "sessionsecret",
+ SessionToken: "sessiontoken",
+ RoleArn: getOut.Result.Role.Arn,
+ RoleName: getOut.Result.Role.RoleName,
+ RoleID: getOut.Result.Role.RoleID,
+ RoleSessionName: "my-session",
+ CreateDate: now,
+ Expiration: now.Add(time.Hour),
+ }
+ if _, err := server.store.CreateSession(context.Background(), session); err != nil {
+ t.Fatalf("CreateSession: %v", err)
+ }
+
+ if resp := doIAMAction(t, server, url.Values{"Action": {"DeleteRole"}, "RoleName": {"recreated-role"}}); resp.StatusCode != http.StatusOK {
+ t.Fatalf("DeleteRole status = %d, body=%s", resp.StatusCode, readBody(t, resp))
+ }
+ createTestRoleForTrust(t, server, "recreated-role", validTrustPolicy)
+
+ resp := doSignedSTSAction(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken,
+ url.Values{"Action": {"GetCallerIdentity"}})
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp))
+ }
+
+ if lastUsed := getRoleLastUsed(t, server, "recreated-role"); lastUsed == nil || lastUsed.LastUsedDate != nil {
+ t.Fatalf("replacement role RoleLastUsed = %#v, want the empty element", lastUsed)
+ }
+}
+
+func getRoleLastUsed(t *testing.T, server *IAMApiServer, roleName string) *iamtypes.RoleLastUsed {
+ t.Helper()
+ resp := doIAMAction(t, server, url.Values{"Action": {"GetRole"}, "RoleName": {roleName}})
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("GetRole status = %d, body=%s", resp.StatusCode, readBody(t, resp))
+ }
+ var out iamtypes.GetRoleResponse
+ unmarshalXML(t, readBody(t, resp), &out)
+ if out.Result.Role == nil {
+ t.Fatal("GetRole returned no role")
+ }
+ return out.Result.Role.RoleLastUsed
+}
+
func TestIAMApiControllerRoleTagLifecycle(t *testing.T) {
server := newIAMControllerTestServer(t)
createTestRoleForTrust(t, server, "my-role", validTrustPolicy)
diff --git a/iamapi/internal/iammiddleware/auth.go b/iamapi/internal/iammiddleware/auth.go
index 91834588..1161380c 100644
--- a/iamapi/internal/iammiddleware/auth.go
+++ b/iamapi/internal/iammiddleware/auth.go
@@ -122,6 +122,16 @@ func VerifyIAMAuth(service string, root *RootCredentials, store iamutil.Identity
debuglogger.Logf("failed to record access key last-used metadata for %q: %v", authData.Access, err)
}
}
+ // The same, for the role a session credential authenticated as: this
+ // is what GetRole reports as RoleLastUsed. identity.Role is set only
+ // when the session's role still exists *and* is still the same role
+ // the session was minted against, so a session outliving its role
+ // records nothing rather than attributing its use to a same-named replacement.
+ if identity.Role != nil {
+ if err := store.RecordRoleUsage(ctx.Context(), identity.Role.RoleName, SigningRegion, time.Now().UTC()); err != nil {
+ debuglogger.Logf("failed to record role last-used metadata for %q: %v", identity.Role.RoleName, err)
+ }
+ }
return nil
}
}
diff --git a/iamapi/internal/iamutil/identity.go b/iamapi/internal/iamutil/identity.go
index b5ac8275..92a4d693 100644
--- a/iamapi/internal/iamutil/identity.go
+++ b/iamapi/internal/iamutil/identity.go
@@ -46,6 +46,7 @@ type IdentityStore interface {
GetUser(ctx context.Context, username string) (*types.User, error)
GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error)
RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error
+ RecordRoleUsage(ctx context.Context, roleName, region string, when time.Time) error
}
// ResolveSessionByToken resolves a temporary (ASIA…) access key to the
diff --git a/iamapi/private/handlers.go b/iamapi/private/handlers.go
index d4273f5c..641e8bfa 100644
--- a/iamapi/private/handlers.go
+++ b/iamapi/private/handlers.go
@@ -17,8 +17,10 @@ import (
"encoding/json"
"maps"
"strings"
+ "time"
"github.com/gofiber/fiber/v3"
+ "github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
"github.com/versity/versitygw/iamapi/policy"
"github.com/versity/versitygw/iamapi/types"
@@ -55,6 +57,42 @@ func (p *PrivateAPI) handleDeriveSigningKey(ctx fiber.Ctx) error {
return ctx.JSON(DeriveSigningKeyResponse{DerivedKey: derivedKey})
}
+// recordDataPlaneUsage records this S3 request as a use of the credential
+// that made it — an access key's GetAccessKeyLastUsed metadata, a session's
+// role's RoleLastUsed, or both for a session (its role is what AWS reports,
+// and a session has no long-term key of its own). It is the data-plane
+// counterpart of what iammiddleware.VerifyIAMAuth records for the IAM/STS
+// control plane, and is deliberately here rather than on derive-signing-key:
+// this endpoint is only reached once the gateway has verified the request's
+// signature, so an unauthenticated caller who merely knows an access key id
+// cannot refresh — or, since it would supply the credential scope, poison —
+// another identity's last-used record.
+//
+// Everything about it is best-effort: failures are logged and dropped, and a
+// gateway too old to send Region/Service records nothing at all rather than
+// storing a blank service or region.
+func (p *PrivateAPI) recordDataPlaneUsage(ctx fiber.Ctx, identity types.Identity, req EvaluatePolicyRequest) {
+ if req.Region == "" || req.Service == "" {
+ return
+ }
+
+ now := time.Now().UTC()
+ if identity.User != nil {
+ if err := p.store.RecordAccessKeyUsage(ctx.Context(), req.AccessKeyID, req.Service, req.Region, now); err != nil {
+ debuglogger.Logf("failed to record access key last-used metadata for %q: %v", req.AccessKeyID, err)
+ }
+ }
+ // identity.Role is set only when the session's role still exists and is
+ // still the one the session was minted against, so a session outliving
+ // its role records nothing rather than attributing its use to a
+ // same-named replacement — same rule as the control plane.
+ if identity.Role != nil {
+ if err := p.store.RecordRoleUsage(ctx.Context(), identity.Role.RoleName, req.Region, now); err != nil {
+ debuglogger.Logf("failed to record role last-used metadata for %q: %v", identity.Role.RoleName, err)
+ }
+ }
+}
+
// handleResolveIdentity answers "does this access key exist, and what
// principal is it" for a batch of access key ids, returning no credential
// material at all — see ResolveIdentityResponse for why that is what makes
@@ -101,6 +139,8 @@ func (p *PrivateAPI) handleEvaluatePolicy(ctx fiber.Ctx) error {
return mapResolveError(err)
}
+ p.recordDataPlaneUsage(ctx, *identity, req)
+
condition := conditionContextFor(*identity, req.Condition)
decisions := make([][]string, len(req.Resources))
diff --git a/iamapi/private/private_test.go b/iamapi/private/private_test.go
index 96ce399a..cff9d827 100644
--- a/iamapi/private/private_test.go
+++ b/iamapi/private/private_test.go
@@ -812,6 +812,158 @@ func TestParseProtocolVersion(t *testing.T) {
}
}
+// TestEvaluatePolicyRecordsDataPlaneUsage covers the S3 side of last-used
+// tracking: authorizing a data-plane request records it against the
+// credential that made it — a user's access key (with the "s3" service name
+// its control-plane counterpart could never produce) and, for a session, the
+// role it assumed.
+func TestEvaluatePolicyRecordsDataPlaneUsage(t *testing.T) {
+ p, store := newTestServer(t)
+ ctx := context.Background()
+ const allowGet = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
+
+ createTestUser(t, store, "nina", "AKIDNINA", "ninasecret", allowGet)
+ role := createTestRole(t, store, "reader", allowGet)
+ session := createTestSessionForRole(t, store, role, "ASIASESSION1", "sessionsecret", "sessiontoken", "")
+
+ before := time.Now().UTC().Add(-time.Second)
+
+ evaluateAs(t, p, EvaluatePolicyRequest{
+ AccessKeyID: "AKIDNINA",
+ Actions: []string{"s3:GetObject"},
+ Resources: []string{"arn:aws:s3:::bucket/key"},
+ Region: "us-west-2",
+ Service: "s3",
+ })
+
+ lastUsed, err := store.GetAccessKeyLastUsed(ctx, "AKIDNINA")
+ if err != nil {
+ t.Fatalf("GetAccessKeyLastUsed: %v", err)
+ }
+ if lastUsed.ServiceName != "s3" || lastUsed.Region != "us-west-2" {
+ t.Fatalf("access key last used = %s/%s, want s3/us-west-2", lastUsed.ServiceName, lastUsed.Region)
+ }
+ if lastUsed.LastUsedDate.Before(before) {
+ t.Fatalf("access key LastUsedDate = %v, want at or after %v", lastUsed.LastUsedDate, before)
+ }
+
+ evaluateAs(t, p, EvaluatePolicyRequest{
+ AccessKeyID: session.AccessKeyId,
+ SessionToken: session.SessionToken,
+ Actions: []string{"s3:GetObject"},
+ Resources: []string{"arn:aws:s3:::bucket/key"},
+ Region: "us-west-2",
+ Service: "s3",
+ })
+
+ stored, err := store.GetRole(ctx, "reader")
+ if err != nil {
+ t.Fatalf("GetRole: %v", err)
+ }
+ if stored.RoleLastUsed == nil || stored.RoleLastUsed.LastUsedDate == nil {
+ t.Fatalf("RoleLastUsed = %#v, want a recorded date", stored.RoleLastUsed)
+ }
+ if stored.RoleLastUsed.Region != "us-west-2" {
+ t.Fatalf("RoleLastUsed.Region = %q, want us-west-2", stored.RoleLastUsed.Region)
+ }
+ if stored.RoleLastUsed.LastUsedDate.Before(before) {
+ t.Fatalf("RoleLastUsed.LastUsedDate = %v, want at or after %v", stored.RoleLastUsed.LastUsedDate, before)
+ }
+}
+
+// TestEvaluatePolicyWithoutRegionRecordsNothing covers a gateway too old to
+// send Region/Service: the request must still authorize normally, and must
+// not store a blank service or region as if it were real data.
+func TestEvaluatePolicyWithoutRegionRecordsNothing(t *testing.T) {
+ p, store := newTestServer(t)
+ createTestUser(t, store, "nina", "AKIDNINA", "ninasecret",
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`)
+
+ out := evaluateAs(t, p, EvaluatePolicyRequest{
+ AccessKeyID: "AKIDNINA",
+ Actions: []string{"s3:GetObject"},
+ Resources: []string{"arn:aws:s3:::bucket/key"},
+ })
+ if len(out.Decisions) != 1 || out.Decisions[0][0] != DecisionAllow {
+ t.Fatalf("Decisions = %v, want [[allow]] — authorization must not depend on the reporting fields", out.Decisions)
+ }
+
+ lastUsed, err := store.GetAccessKeyLastUsed(context.Background(), "AKIDNINA")
+ if err != nil {
+ t.Fatalf("GetAccessKeyLastUsed: %v", err)
+ }
+ if !lastUsed.LastUsedDate.IsZero() || lastUsed.ServiceName != "" || lastUsed.Region != "" {
+ t.Fatalf("last used = %#v, want nothing recorded", lastUsed)
+ }
+}
+
+// TestEvaluatePolicyCoalescesRepeatedUsage confirms the write-coalescing
+// that makes per-request recording affordable: a second request in the same
+// region and service leaves the stored timestamp alone, while a request from
+// a different region is written through immediately, since that is the part
+// an operator reads to see what a credential is being used for.
+func TestEvaluatePolicyCoalescesRepeatedUsage(t *testing.T) {
+ p, store := newTestServer(t)
+ ctx := context.Background()
+ createTestUser(t, store, "nina", "AKIDNINA", "ninasecret",
+ `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`)
+
+ req := EvaluatePolicyRequest{
+ AccessKeyID: "AKIDNINA",
+ Actions: []string{"s3:GetObject"},
+ Resources: []string{"arn:aws:s3:::bucket/key"},
+ Region: "us-west-2",
+ Service: "s3",
+ }
+
+ evaluateAs(t, p, req)
+ first, err := store.GetAccessKeyLastUsed(ctx, "AKIDNINA")
+ if err != nil {
+ t.Fatalf("GetAccessKeyLastUsed: %v", err)
+ }
+
+ evaluateAs(t, p, req)
+ second, err := store.GetAccessKeyLastUsed(ctx, "AKIDNINA")
+ if err != nil {
+ t.Fatalf("GetAccessKeyLastUsed: %v", err)
+ }
+ if !second.LastUsedDate.Equal(first.LastUsedDate) {
+ t.Fatalf("LastUsedDate = %v after a repeat request, want it coalesced to %v", second.LastUsedDate, first.LastUsedDate)
+ }
+
+ req.Region = "eu-west-1"
+ evaluateAs(t, p, req)
+ moved, err := store.GetAccessKeyLastUsed(ctx, "AKIDNINA")
+ if err != nil {
+ t.Fatalf("GetAccessKeyLastUsed: %v", err)
+ }
+ if moved.Region != "eu-west-1" || !moved.LastUsedDate.After(first.LastUsedDate) {
+ t.Fatalf("last used = %s at %v, want eu-west-1 written through after %v", moved.Region, moved.LastUsedDate, first.LastUsedDate)
+ }
+}
+
+// evaluateAs posts one evaluate-policy request as root and returns the
+// decoded response, failing the test on any non-200.
+func evaluateAs(t *testing.T, p *PrivateAPI, req EvaluatePolicyRequest) EvaluatePolicyResponse {
+ t.Helper()
+
+ body, err := json.Marshal(req)
+ if err != nil {
+ t.Fatalf("marshal request: %v", err)
+ }
+ resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
+ raw := readBody(t, resp)
+ if resp.StatusCode != http.StatusOK {
+ t.Fatalf("evaluate-policy status = %d, body=%s", resp.StatusCode, raw)
+ }
+
+ var out EvaluatePolicyResponse
+ if err := json.Unmarshal([]byte(raw), &out); err != nil {
+ t.Fatalf("unmarshal %s: %v", raw, err)
+ }
+ return out
+}
+
// createTestRole creates a role with an optional inline permission policy
// directly against store, the same way createTestUser bypasses the
// control-plane API. Arn and RoleID are set explicitly because
diff --git a/iamapi/private/types.go b/iamapi/private/types.go
index 4a1cf298..b40d605e 100644
--- a/iamapi/private/types.go
+++ b/iamapi/private/types.go
@@ -35,13 +35,24 @@ type DeriveSigningKeyResponse struct {
DerivedKey []byte `json:"derivedKey"`
}
-// EvaluatePolicyRequest is the evaluate-policy request body
+// EvaluatePolicyRequest is the evaluate-policy request body.
+//
+// Region and Service describe the data-plane request being authorized, and
+// exist only so the identity's last-used metadata (GetAccessKeyLastUsed,
+// GetRole's RoleLastUsed) can record S3 use. They are recorded rather than
+// evaluated — nothing about the authorization decision depends on them —
+// and they are the gateway's own configured region and a fixed "s3", never
+// anything a client supplied: the credential scope in a request's
+// Authorization header is attacker-controlled until the signature is
+// verified, and this endpoint is the first one reached after that.
type EvaluatePolicyRequest struct {
AccessKeyID string `json:"accessKeyId"`
SessionToken string `json:"sessionToken,omitempty"`
Actions []string `json:"actions"`
Resources []string `json:"resources"`
Condition map[string][]string `json:"condition,omitempty"`
+ Region string `json:"region,omitempty"`
+ Service string `json:"service,omitempty"`
}
// ResolveIdentityRequest asks whether each access key id exists and what
diff --git a/iamapi/storage/common.go b/iamapi/storage/common.go
index a34386b4..914f11e0 100644
--- a/iamapi/storage/common.go
+++ b/iamapi/storage/common.go
@@ -278,6 +278,47 @@ func paginateTags(tags []types.Tag, marker string, maxItems int32, keyCase iamut
return out
}
+// UsageRecordCoalesceWindow is how long a last-used record is left alone
+// after a write that already reported the same service and region. Every
+// authenticated S3 data-plane request records a use, so without this the
+// internal storer would rewrite the whole IAM file (twice — it keeps a
+// backup) on every GET, and the Vault storer would issue a read-modify-write
+// per request. Real IAM's own last-used data is coarse for the same reason,
+// so nothing observable is lost.
+//
+// A change of service or region is never coalesced: that is the part of the
+// record an operator reads to answer "what is this credential being used
+// for", and it must be able to change the moment the answer does.
+const UsageRecordCoalesceWindow = time.Minute
+
+// shouldRecordUsage reports whether a last-used update is worth the write.
+// prev/prevService/prevRegion describe what is already stored — a zero prev
+// meaning nothing is — and service/region/when the use being recorded. Roles
+// have no service dimension and pass "" for both service arguments.
+func shouldRecordUsage(prev time.Time, prevService, prevRegion, service, region string, when time.Time) bool {
+ if prev.IsZero() || prevService != service || prevRegion != region {
+ return true
+ }
+ // A clock that moved backwards (or a racing writer that already stored a
+ // later use) leaves prev in the future: keep the newer record rather
+ // than replacing it with this older one.
+ return when.Sub(prev) >= UsageRecordCoalesceWindow
+}
+
+// roleLastUsedRecord unpacks role's stored last-used values for
+// shouldRecordUsage, reading a missing element or a missing date as never
+// used.
+func roleLastUsedRecord(role types.Role) (time.Time, string) {
+ if role.RoleLastUsed == nil {
+ return time.Time{}, ""
+ }
+ var when time.Time
+ if role.RoleLastUsed.LastUsedDate != nil {
+ when = *role.RoleLastUsed.LastUsedDate
+ }
+ return when, role.RoleLastUsed.Region
+}
+
func unwrapAPIError(err error) error {
var apiErr iamerr.APIError
if errors.As(err, &apiErr) {
diff --git a/iamapi/storage/internal.go b/iamapi/storage/internal.go
index 3f68e1f7..b786e551 100644
--- a/iamapi/storage/internal.go
+++ b/iamapi/storage/internal.go
@@ -523,6 +523,28 @@ func (s *InternalStore) DeleteAccessKey(_ context.Context, username, accessKeyID
return unwrapAPIError(err)
}
+// lookupAccessKey resolves accessKeyID to its stored entry and owning user
+// name through the access key index, reporting NoSuchEntity for a key that
+// resolves to nothing at any step.
+func lookupAccessKey(conf iamConfig, accessKeyID string) (types.AccessKeyEntry, string, error) {
+ username, ok := conf.AccessKeyIndex[accessKeyID]
+ if !ok {
+ return types.AccessKeyEntry{}, "", iamerr.NoSuchEntityAccessKey(accessKeyID)
+ }
+ user, ok := conf.Users[username]
+ if !ok {
+ return types.AccessKeyEntry{}, "", iamerr.NoSuchEntityAccessKey(accessKeyID)
+ }
+
+ for _, key := range user.AccessKeys {
+ if key.AccessKeyId == accessKeyID {
+ return key, username, nil
+ }
+ }
+
+ return types.AccessKeyEntry{}, "", iamerr.NoSuchEntityAccessKey(accessKeyID)
+}
+
func (s *InternalStore) GetAccessKeyLastUsed(_ context.Context, accessKeyID string) (*GetAccessKeyLastUsedOutput, error) {
s.RLock()
defer s.RUnlock()
@@ -532,63 +554,61 @@ func (s *InternalStore) GetAccessKeyLastUsed(_ context.Context, accessKeyID stri
return nil, err
}
- username, ok := conf.AccessKeyIndex[accessKeyID]
- if !ok {
- return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
- }
- user, ok := conf.Users[username]
- if !ok {
- return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
+ key, username, err := lookupAccessKey(conf, accessKeyID)
+ if err != nil {
+ return nil, err
}
- for _, key := range user.AccessKeys {
- if key.AccessKeyId == accessKeyID {
- return &GetAccessKeyLastUsedOutput{
- UserName: username,
- LastUsedDate: key.LastUsedDate,
- ServiceName: key.LastUsedService,
- Region: key.LastUsedRegion,
- }, nil
- }
- }
-
- return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
+ return &GetAccessKeyLastUsedOutput{
+ UserName: username,
+ LastUsedDate: key.LastUsedDate,
+ ServiceName: key.LastUsedService,
+ Region: key.LastUsedRegion,
+ }, nil
}
+// RecordAccessKeyUsage rewrites the whole IAM file, so it first reads the
+// stored record and returns without writing anything when the update would
+// be redundant — a read per request instead of a file rewrite per request,
+// which is what makes recording every S3 data-plane request affordable here.
func (s *InternalStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) error {
s.Lock()
defer s.Unlock()
- err := s.engine.StoreIAM(func(data []byte) ([]byte, error) {
+ conf, err := s.engine.GetIAM()
+ if err != nil {
+ return err
+ }
+ key, _, err := lookupAccessKey(conf, accessKeyID)
+ if err != nil {
+ return err
+ }
+ if !shouldRecordUsage(key.LastUsedDate, key.LastUsedService, key.LastUsedRegion, service, region, when) {
+ return nil
+ }
+
+ err = s.engine.StoreIAM(func(data []byte) ([]byte, error) {
conf, err := s.engine.ParseIAM(data)
if err != nil {
return nil, err
}
- username, ok := conf.AccessKeyIndex[accessKeyID]
- if !ok {
- return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
- }
- user, ok := conf.Users[username]
- if !ok {
- return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
+ _, username, err := lookupAccessKey(conf, accessKeyID)
+ if err != nil {
+ return nil, err
}
- found := false
+ user := conf.Users[username]
for i, key := range user.AccessKeys {
if key.AccessKeyId == accessKeyID {
user.AccessKeys[i].LastUsedDate = when
user.AccessKeys[i].LastUsedService = service
user.AccessKeys[i].LastUsedRegion = region
- found = true
break
}
}
- if !found {
- return nil, iamerr.NoSuchEntityAccessKey(accessKeyID)
- }
-
conf.Users[username] = user
+
return json.Marshal(conf)
})
return unwrapAPIError(err)
@@ -957,6 +977,48 @@ func (s *InternalStore) UpdateAssumeRolePolicy(_ context.Context, input UpdateAs
return cloneRole(updated), nil
}
+// RecordRoleUsage is RecordAccessKeyUsage's role counterpart, including its
+// read-first check: a redundant update writes nothing at all, and an update
+// worth keeping rewrites the whole IAM file.
+func (s *InternalStore) RecordRoleUsage(_ context.Context, roleName, region string, when time.Time) error {
+ s.Lock()
+ defer s.Unlock()
+
+ conf, err := s.engine.GetIAM()
+ if err != nil {
+ return err
+ }
+ _, role, ok := lookupRole(conf, roleName)
+ if !ok {
+ return iamerr.NoSuchEntityRole(roleName)
+ }
+ prev, prevRegion := roleLastUsedRecord(role)
+ if !shouldRecordUsage(prev, "", prevRegion, "", region, when) {
+ return nil
+ }
+
+ err = s.engine.StoreIAM(func(data []byte) ([]byte, error) {
+ conf, err := s.engine.ParseIAM(data)
+ if err != nil {
+ return nil, err
+ }
+
+ canonical, role, ok := lookupRole(conf, roleName)
+ if !ok {
+ return nil, iamerr.NoSuchEntityRole(roleName)
+ }
+
+ role.RoleLastUsed = &types.RoleLastUsed{
+ LastUsedDate: &when,
+ Region: region,
+ }
+ conf.Roles[canonical] = role
+
+ return json.Marshal(conf)
+ })
+ return unwrapAPIError(err)
+}
+
func (s *InternalStore) TagRole(_ context.Context, roleName string, tags []types.Tag) error {
return s.updateRoleTags(roleName, func(role *types.Role) error {
merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded)
@@ -1183,6 +1245,11 @@ func cloneRole(role types.Role) *types.Role {
cloned := role
cloned.Tags = slices.Clone(role.Tags)
cloned.Policies.Inline = slices.Clone(role.Policies.Inline)
+ if role.RoleLastUsed != nil {
+ lastUsed := *role.RoleLastUsed
+ cloned.RoleLastUsed = &lastUsed
+ }
+ cloned.EnsureRoleLastUsed()
return &cloned
}
diff --git a/iamapi/storage/storer.go b/iamapi/storage/storer.go
index 801422e7..ffa88260 100644
--- a/iamapi/storage/storer.go
+++ b/iamapi/storage/storer.go
@@ -58,6 +58,11 @@ type Storer interface {
ListRoles(ctx context.Context, input ListRolesInput) (*ListRolesOutput, error)
DeleteRole(ctx context.Context, roleName string) error
UpdateAssumeRolePolicy(ctx context.Context, input UpdateAssumeRolePolicyInput) (*types.Role, error)
+ // RecordRoleUsage updates roleName's RoleLastUsed metadata (region and
+ // timestamp) to reflect a use of the role at when — GetRole's
+ // counterpart to RecordAccessKeyUsage, and best-effort in exactly the
+ // same way.
+ RecordRoleUsage(ctx context.Context, roleName, region string, when time.Time) error
TagRole(ctx context.Context, roleName string, tags []types.Tag) error
UntagRole(ctx context.Context, roleName string, tagKeys []string) error
diff --git a/iamapi/storage/storer_test.go b/iamapi/storage/storer_test.go
index cbf32af4..381b98b1 100644
--- a/iamapi/storage/storer_test.go
+++ b/iamapi/storage/storer_test.go
@@ -419,6 +419,172 @@ func TestInternalStoreRoleCRUDAndPagination(t *testing.T) {
}
}
+func TestInternalStoreRecordRoleUsage(t *testing.T) {
+ ctx := context.Background()
+ dir := t.TempDir()
+ store, err := NewInternal(dir)
+ if err != nil {
+ t.Fatalf("NewInternal: %v", err)
+ }
+
+ role := types.Role{
+ Path: "/",
+ RoleName: "used-role",
+ RoleID: "AROAx5555555555555555",
+ Arn: "arn:aws:iam::000000000000:role/used-role",
+ CreateDate: time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC),
+ AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`,
+ }
+ created, err := store.CreateRole(ctx, role)
+ if err != nil {
+ t.Fatalf("CreateRole: %v", err)
+ }
+ if created.RoleLastUsed.LastUsedDate != nil {
+ t.Fatalf("CreateRole RoleLastUsed = %#v, want the never-used empty element", created.RoleLastUsed)
+ }
+
+ when := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
+ if err := store.RecordRoleUsage(ctx, "USED-ROLE", "us-east-1", when); err != nil {
+ t.Fatalf("RecordRoleUsage: %v", err)
+ }
+
+ // Reopened from disk, so this also covers the record surviving the
+ // JSON round trip rather than only living in the returned copy.
+ reopened, err := NewInternal(dir)
+ if err != nil {
+ t.Fatalf("reopen NewInternal: %v", err)
+ }
+ got, err := reopened.GetRole(ctx, "used-role")
+ if err != nil {
+ t.Fatalf("GetRole: %v", err)
+ }
+ if got.RoleLastUsed == nil || got.RoleLastUsed.LastUsedDate == nil || !got.RoleLastUsed.LastUsedDate.Equal(when) {
+ t.Fatalf("RoleLastUsed = %#v, want LastUsedDate %v", got.RoleLastUsed, when)
+ }
+ if got.RoleLastUsed.Region != "us-east-1" {
+ t.Fatalf("RoleLastUsed.Region = %q, want us-east-1", got.RoleLastUsed.Region)
+ }
+
+ listed, err := reopened.ListRoles(ctx, ListRolesInput{})
+ if err != nil {
+ t.Fatalf("ListRoles: %v", err)
+ }
+ if len(listed.Roles) != 1 || listed.Roles[0].RoleLastUsed != nil {
+ t.Fatalf("ListRoles = %#v, want the used role with no RoleLastUsed", listed.Roles)
+ }
+
+ if err := store.RecordRoleUsage(ctx, "missing-role", "us-east-1", when); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
+ t.Fatalf("RecordRoleUsage missing role err = %v, want NoSuchEntity", err)
+ }
+}
+
+func TestShouldRecordUsage(t *testing.T) {
+ base := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
+
+ for _, tt := range []struct {
+ name string
+ prev time.Time
+ prevService, prevRegion string
+ service, region string
+ when time.Time
+ want bool
+ }{
+ {
+ name: "nothing recorded yet",
+ when: base, service: "s3", region: "us-east-1",
+ want: true,
+ },
+ {
+ name: "same service and region inside the window",
+ prev: base, prevService: "s3", prevRegion: "us-east-1",
+ service: "s3", region: "us-east-1", when: base.Add(UsageRecordCoalesceWindow - time.Second),
+ want: false,
+ },
+ {
+ name: "same service and region past the window",
+ prev: base, prevService: "s3", prevRegion: "us-east-1",
+ service: "s3", region: "us-east-1", when: base.Add(UsageRecordCoalesceWindow),
+ want: true,
+ },
+ {
+ name: "a different service is never coalesced",
+ prev: base, prevService: "iam", prevRegion: "us-east-1",
+ service: "s3", region: "us-east-1", when: base.Add(time.Second),
+ want: true,
+ },
+ {
+ name: "a different region is never coalesced",
+ prev: base, prevService: "s3", prevRegion: "us-east-1",
+ service: "s3", region: "eu-west-1", when: base.Add(time.Second),
+ want: true,
+ },
+ {
+ name: "an older use never replaces a newer record",
+ prev: base, prevService: "s3", prevRegion: "us-east-1",
+ service: "s3", region: "us-east-1", when: base.Add(-time.Hour),
+ want: false,
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ got := shouldRecordUsage(tt.prev, tt.prevService, tt.prevRegion, tt.service, tt.region, tt.when)
+ if got != tt.want {
+ t.Errorf("shouldRecordUsage = %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// TestInternalStoreRecordAccessKeyUsageCoalesces confirms the storer applies
+// shouldRecordUsage rather than rewriting the IAM file on every recorded
+// use, and that a service change still lands immediately.
+func TestInternalStoreRecordAccessKeyUsageCoalesces(t *testing.T) {
+ ctx := context.Background()
+ store, err := NewInternal(t.TempDir())
+ if err != nil {
+ t.Fatalf("NewInternal: %v", err)
+ }
+
+ if _, err := store.CreateUser(ctx, types.User{UserName: "nina", Path: "/"}); err != nil {
+ t.Fatalf("CreateUser: %v", err)
+ }
+ if _, err := store.CreateAccessKey(ctx, CreateAccessKeyInput{
+ UserName: "nina", AccessKeyID: "AKIDNINA", SecretAccessKey: "s", Status: "Active", CreateDate: time.Now().UTC(),
+ }); err != nil {
+ t.Fatalf("CreateAccessKey: %v", err)
+ }
+
+ first := time.Date(2026, 8, 2, 9, 30, 0, 0, time.UTC)
+ if err := store.RecordAccessKeyUsage(ctx, "AKIDNINA", "s3", "us-east-1", first); err != nil {
+ t.Fatalf("RecordAccessKeyUsage: %v", err)
+ }
+ if err := store.RecordAccessKeyUsage(ctx, "AKIDNINA", "s3", "us-east-1", first.Add(time.Second)); err != nil {
+ t.Fatalf("RecordAccessKeyUsage (repeat): %v", err)
+ }
+
+ got, err := store.GetAccessKeyLastUsed(ctx, "AKIDNINA")
+ if err != nil {
+ t.Fatalf("GetAccessKeyLastUsed: %v", err)
+ }
+ if !got.LastUsedDate.Equal(first) {
+ t.Fatalf("LastUsedDate = %v after a coalesced repeat, want %v", got.LastUsedDate, first)
+ }
+
+ if err := store.RecordAccessKeyUsage(ctx, "AKIDNINA", "iam", "us-east-1", first.Add(time.Second)); err != nil {
+ t.Fatalf("RecordAccessKeyUsage (service change): %v", err)
+ }
+ got, err = store.GetAccessKeyLastUsed(ctx, "AKIDNINA")
+ if err != nil {
+ t.Fatalf("GetAccessKeyLastUsed: %v", err)
+ }
+ if got.ServiceName != "iam" || !got.LastUsedDate.Equal(first.Add(time.Second)) {
+ t.Fatalf("last used = %s at %v, want iam at %v", got.ServiceName, got.LastUsedDate, first.Add(time.Second))
+ }
+
+ if err := store.RecordAccessKeyUsage(ctx, "missing", "s3", "us-east-1", first); !errors.Is(err, iamerr.NoSuchEntityAccessKey("missing")) {
+ t.Fatalf("RecordAccessKeyUsage missing key err = %v, want NoSuchEntity", err)
+ }
+}
+
func TestInternalStoreRolePolicyCRUD(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
diff --git a/iamapi/storage/vault.go b/iamapi/storage/vault.go
index 43c377fe..365c0d17 100644
--- a/iamapi/storage/vault.go
+++ b/iamapi/storage/vault.go
@@ -820,10 +820,19 @@ func (s *VaultStore) recordAccessKeyUsage(ctx context.Context, accessKeyID, serv
if err != nil {
continue
}
- if !slices.ContainsFunc(user.AccessKeys, func(k types.AccessKeyEntry) bool { return k.AccessKeyId == accessKeyID }) {
+ idx := slices.IndexFunc(user.AccessKeys, func(k types.AccessKeyEntry) bool { return k.AccessKeyId == accessKeyID })
+ if idx == -1 {
continue
}
+ // The user was just read, so the redundant-update check costs
+ // nothing here and saves the read-modify-write below on every
+ // request after the first within the coalescing window.
+ key := user.AccessKeys[idx]
+ if !shouldRecordUsage(key.LastUsedDate, key.LastUsedService, key.LastUsedRegion, service, region, when) {
+ return nil
+ }
+
_, err = s.withUserCAS(ctx, username, func(u *types.User) error {
for i, key := range u.AccessKeys {
if key.AccessKeyId == accessKeyID {
@@ -1202,6 +1211,51 @@ func (s *VaultStore) UpdateAssumeRolePolicy(ctx context.Context, input UpdateAss
})
}
+// recordRoleUsageTimeout bounds RecordRoleUsage's detached background
+// update, the way recordAccessKeyUsageTimeout does for access keys.
+const recordRoleUsageTimeout = 5 * time.Second
+
+// RecordRoleUsage updates roleName's RoleLastUsed metadata in its own
+// background goroutine, detached from ctx, and always returns nil
+// immediately — for the same reasons RecordAccessKeyUsage does: it runs on
+// the hot path of every request authenticated with a session credential,
+// and the metadata is purely informational, so a Vault round trip (plus a
+// CAS retry loop) must not be charged to the request, and a lost or failed
+// update is only logged.
+func (s *VaultStore) RecordRoleUsage(_ context.Context, roleName, region string, when time.Time) error {
+ go func() {
+ ctx, cancel := context.WithTimeout(context.Background(), recordRoleUsageTimeout)
+ defer cancel()
+ if err := s.recordRoleUsage(ctx, roleName, region, when); err != nil {
+ debuglogger.Logf("failed to record Vault role last-used metadata for %q: %v", roleName, err)
+ }
+ }()
+ return nil
+}
+
+func (s *VaultStore) recordRoleUsage(ctx context.Context, roleName, region string, when time.Time) error {
+ role, _, err := s.readRoleVersion(roleName)
+ if err != nil {
+ return err
+ }
+
+ // A redundant update is dropped after the read, before the far more
+ // expensive read-modify-write — see shouldRecordUsage.
+ prev, prevRegion := roleLastUsedRecord(*role)
+ if !shouldRecordUsage(prev, "", prevRegion, "", region, when) {
+ return nil
+ }
+
+ _, err = s.withRoleCAS(ctx, roleName, func(role *types.Role) error {
+ role.RoleLastUsed = &types.RoleLastUsed{
+ LastUsedDate: &when,
+ Region: region,
+ }
+ return nil
+ })
+ return err
+}
+
func (s *VaultStore) TagRole(ctx context.Context, roleName string, tags []types.Tag) error {
_, err := s.withRoleCAS(ctx, roleName, func(role *types.Role) error {
merged, err := mergeTags(role.Tags, tags, iamutil.TagKeysFolded)
diff --git a/iamapi/types/role.go b/iamapi/types/role.go
index 2e2bd755..a009ace6 100644
--- a/iamapi/types/role.go
+++ b/iamapi/types/role.go
@@ -33,16 +33,26 @@ type Role struct {
Policies Policies `xml:"-"` // unused until role inline-policy CRUD exists; see DeleteRole conflict check
}
+// RoleLastUsed reports when, and in which region, a role was last used.
+// LastUsedDate is a pointer so a never-used role renders as the empty
+// element AWS returns, rather than as a role
+// used at the zero time.
type RoleLastUsed struct {
- LastUsedDate time.Time `xml:",omitempty"`
- Region string `xml:",omitempty"`
+ LastUsedDate *time.Time `xml:",omitempty"`
+ Region string `xml:",omitempty"`
}
-// EnsureRoleLastUsed defaults RoleLastUsed to a zero value if unset,
-// without clobbering an already-set value.
+// EnsureRoleLastUsed defaults RoleLastUsed to a never-used value if unset,
+// without clobbering an already-set one. A zero LastUsedDate is normalized
+// away to nil: a role stored before last-used tracking existed persists the
+// zero time, which must still report as never used.
func (r *Role) EnsureRoleLastUsed() {
if r.RoleLastUsed == nil {
r.RoleLastUsed = &RoleLastUsed{}
+ return
+ }
+ if r.RoleLastUsed.LastUsedDate != nil && r.RoleLastUsed.LastUsedDate.IsZero() {
+ r.RoleLastUsed.LastUsedDate = nil
}
}
diff --git a/runoidctests.sh b/runoidctests.sh
index d24ce1bd..3124ed82 100755
--- a/runoidctests.sh
+++ b/runoidctests.sh
@@ -95,11 +95,7 @@ echo "Starting the s3 gateway backed by it"
GW_PID=$!
wait_for_server "s3 gateway" "http://127.0.0.1:$GW_PORT/healthz" "$GW_PID"
-echo "Running the live GitHub OIDC web-identity test"
-./versitygw test -a user -s pass -e "http://127.0.0.1:$IAM_PORT" \
- IAMAssumeRoleWithWebIdentity_github_oidc_live
-
-echo "Running the s3 assumed-role session access control tests"
+echo "Running the tests that need a real OIDC identity provider"
./versitygw test -a user -s pass \
-e "http://127.0.0.1:$GW_PORT" \
--iam-endpoint "http://127.0.0.1:$IAM_PORT" \
diff --git a/tests/integration/group-tests.go b/tests/integration/group-tests.go
index a4715a51..6baba8d4 100644
--- a/tests/integration/group-tests.go
+++ b/tests/integration/group-tests.go
@@ -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,
diff --git a/tests/integration/iam_assume_role_with_web_identity_github_oidc.go b/tests/integration/iam_assume_role_with_web_identity_github_oidc.go
deleted file mode 100644
index e218702c..00000000
--- a/tests/integration/iam_assume_role_with_web_identity_github_oidc.go
+++ /dev/null
@@ -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::*".
-//
-// 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": "", "count": }. 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{})
-}
diff --git a/tests/integration/iam_create_role.go b/tests/integration/iam_create_role.go
index 6bddf576..e404179b 100644
--- a/tests/integration/iam_create_role.go
+++ b/tests/integration/iam_create_role.go
@@ -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" {
diff --git a/tests/integration/iam_get_role.go b/tests/integration/iam_get_role.go
index 9d7aa07c..e6f8b12f 100644
--- a/tests/integration/iam_get_role.go
+++ b/tests/integration/iam_get_role.go
@@ -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
+}
diff --git a/tests/integration/s3_iam_access_control.go b/tests/integration/s3_iam_access_control.go
index d9ea5f2f..8a47610a 100644
--- a/tests/integration/s3_iam_access_control.go
+++ b/tests/integration/s3_iam_access_control.go
@@ -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 {
diff --git a/tests/integration/s3_iam_session_access_control.go b/tests/integration/s3_iam_session_access_control.go
index 1b2a5da6..74a51739 100644
--- a/tests/integration/s3_iam_session_access_control.go
+++ b/tests/integration/s3_iam_session_access_control.go
@@ -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
+ })
+}
diff --git a/tests/integration/s3_iam_utils.go b/tests/integration/s3_iam_utils.go
index 358ed7f0..1c990410 100644
--- a/tests/integration/s3_iam_utils.go
+++ b/tests/integration/s3_iam_utils.go
@@ -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::*".
+//
+// 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": "", "count": }. 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{})
+}