mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 08:24:17 +00:00
fix: role last-used tracking, and record S3 requests in last-used metadata
Role last-used tracking was missing entirely - `GetRole` returned a `RoleLastUsed` element that nothing ever wrote, rendering the zero time instead of the empty element AWS returns for an unused role - and access key last-used only ever saw the `IAM`/`STS` control plane, so a credential used exclusively against the S3 gateway reported as never used. Roles now record a use whenever a request authenticates with one of their session credentials, through a new `Storer.RecordRoleUsage` mirroring `RecordAccessKeyUsage`, gated on the session's role still being the one it was minted against so a session outliving its role can't attribute its use to a same-named replacement. `LastUsedDate` became a `*time.Time` so an unused role renders as an empty element. Both records now cover the S3 data plane as well: the gateway sends its configured region and `s3` on evaluate-policy and the IAM service records the caller there, so `GetAccessKeyLastUsed's` `ServiceName` is now iam, sts or s3. That call was chosen over derive-signing-key, which runs before signature verification and takes its region and service from the caller's own `Authorization` header - recording there would let anyone who knows an access key id refresh and poison another identity's audit record. Requests denied by a bucket policy or made against a public bucket are not recorded, since neither reaches identity-policy evaluation. To keep per-request recording affordable, an update is skipped while the stored record has the same service and region and is under a minute old; a change of either is written through immediately. Assuming a role is not a use, a request denied by an identity policy is, and both successful and denied S3 requests update the record. Also moves the `OIDC-dependent` tests into the `s3-iam-session` group so runoidctests.sh runs a single group.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
+12
-1
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+100
-33
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+55
-1
@@ -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)
|
||||
|
||||
+14
-4
@@ -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
|
||||
// <RoleLastUsed></RoleLastUsed> 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user