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{}) +}