fix: role last-used tracking, and record S3 requests in last-used metadata

Role last-used tracking was missing entirely - `GetRole` returned a `RoleLastUsed` element that nothing ever wrote, rendering the zero time instead of the empty element AWS returns for an unused role - and access key last-used only ever saw the `IAM`/`STS` control plane, so a credential used exclusively against the S3 gateway reported as never used. Roles now record a use whenever a request authenticates with one of their session credentials, through a new `Storer.RecordRoleUsage` mirroring `RecordAccessKeyUsage`, gated on the session's role still being the one it was minted against so a session outliving its role can't attribute its use to a same-named replacement. `LastUsedDate` became a `*time.Time` so an unused role renders as an empty element.

Both records now cover the S3 data plane as well: the gateway sends its configured region and `s3` on evaluate-policy and the IAM service records the caller there, so `GetAccessKeyLastUsed's` `ServiceName` is now iam, sts or s3. That call was chosen over derive-signing-key, which runs before signature verification and takes its region and service from the caller's own `Authorization` header - recording there would let anyone who knows an access key id refresh and poison another identity's audit record. Requests denied by a bucket policy or made against a public bucket are not recorded, since neither reaches identity-policy evaluation. To keep per-request recording affordable, an update is skipped while the stored record has the same service and region and is under a minute old; a change of either is written through immediately.

Assuming a role is not a use, a request denied by an identity policy is, and both successful and denied S3 requests update the record. Also moves the `OIDC-dependent` tests into the `s3-iam-session` group so runoidctests.sh runs a single group.
This commit is contained in:
niksis02
2026-09-01 19:55:27 +04:00
parent 7a1a3e4775
commit afbee5be01
23 changed files with 1235 additions and 311 deletions
+102
View File
@@ -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)