Files
versitygw/iamapi/internal/iamutil/identity.go
T
niksis02 afbee5be01 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.
2026-09-01 19:55:27 +04:00

147 lines
6.3 KiB
Go

// 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 iamutil
import (
"context"
"errors"
"time"
"github.com/versity/versitygw/iamapi/types"
"github.com/versity/versitygw/internal/sigv4auth"
)
// ErrIdentityNotFound and ErrInvalidSessionToken are returned by
// ResolveSessionByToken and ResolveUserIdentity, so a caller that needs to
// know *which* failure occurred — the standalone-IAM private endpoints, and
// iammiddleware's own sigv4 pipeline, which turn them into S3's own
// InvalidAccessKeyId and InvalidToken respectively — can distinguish them.
//
// The public IAM control plane deliberately collapses both into a single
// InvalidClientTokenId: an unauthenticated caller must not learn whether an
// access key exists.
var (
ErrIdentityNotFound = errors.New("identity not found")
ErrInvalidSessionToken = errors.New("invalid session token")
)
// IdentityStore resolves an access key id to the session or long-term user
// that owns it, and resolves named resources for policy evaluation.
// storage.Storer satisfies this directly.
type IdentityStore interface {
GetSession(ctx context.Context, accessKeyID string) (*types.Session, error)
GetRole(ctx context.Context, roleName string) (*types.Role, error)
GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error)
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
// session that owns it, requiring token to match the session's stored
// SessionToken. The empty-token rejection and the constant-time comparison
// both live here rather than in any caller: this is the only function that
// may turn a session access key id into a secret, so no caller can be
// written that skips them.
//
// It does not itself verify a SigV4 signature — request-pipeline callers do
// that next, so a stolen or guessed access key id plus token is never
// sufficient on its own.
func ResolveSessionByToken(ctx context.Context, store IdentityStore, accessKeyID, token string) (*types.Identity, string, error) {
// No token means there is nothing to resolve the access key against, so
// the key is reported as simply not existing rather than as a bad token
// — matching real S3, which answers InvalidAccessKeyId for a temporary
// access key presented with no X-Amz-Security-Token, and InvalidToken
// only once a token is actually present and wrong.
if token == "" {
return nil, "", ErrIdentityNotFound
}
session, err := store.GetSession(ctx, accessKeyID)
if err != nil {
return nil, "", ErrIdentityNotFound
}
if !sigv4auth.SecureCompare(token, session.SessionToken) {
return nil, "", ErrInvalidSessionToken
}
// A signature-valid, unexpired session still authenticates even if its
// role has since been deleted — real STS credentials are self-contained
// and don't re-check role existence on every call. What such a session
// can no longer do is get any IAM action past the policy middleware:
// with Role/IdentityPolicies left unset, EvaluateIdentityPolicies denies
// by default, same effective outcome as an explicit rejection here would
// have had for every pipeline except GetCallerIdentity, which needs
// none of this and must keep working regardless.
//
// The reloaded role must also still be the *same* role the session was
// originally minted against — RoleID and Arn, both captured in the
// session at AssumeRoleWithWebIdentity time, must match the freshly
// loaded role's own values. Without this check, deleting a role and
// recreating one of the same name (necessarily getting a new RoleID)
// would let every pre-existing session for the old role silently
// inherit whatever policies the new role happens to carry.
identity := &types.Identity{
Session: session,
SessionPolicy: session.Policy,
}
if role, err := store.GetRole(ctx, session.RoleName); err == nil &&
role.RoleID == session.RoleID && role.Arn == session.RoleArn {
identity.Role = role
identity.IdentityPolicies = role.Policies.Inline
}
return identity, session.SecretAccessKey, nil
}
// ResolveUserIdentity resolves accessKeyID to its long-term (AKIA…) IAM user
// and secret, reporting the ErrIdentityNotFound/ErrInvalidSessionToken
// sentinels rather than an opaque API error — callers on the public control
// plane that want the opaque error do that translation themselves.
//
// Temporary (ASIA…) session access keys are rejected here: resolving one
// safely requires validating its security token, which this function has no
// parameter for. A caller that can supply a token uses ResolveSessionByToken
// instead. Silently resolving a session's secret from its access key id
// alone, with no token check at all, would let anyone who merely knows the
// id impersonate the session.
func ResolveUserIdentity(ctx context.Context, store IdentityStore, accessKeyID string) (*types.Identity, string, error) {
if sigv4auth.IsTempAccessKeyID(accessKeyID) {
return nil, "", ErrInvalidSessionToken
}
user, err := store.GetUserByAccessKeyID(ctx, accessKeyID)
if err != nil {
return nil, "", ErrIdentityNotFound
}
var keyEntry *types.AccessKeyEntry
for i := range user.AccessKeys {
if user.AccessKeys[i].AccessKeyId == accessKeyID {
keyEntry = &user.AccessKeys[i]
break
}
}
if keyEntry == nil || keyEntry.Status != AccessKeyStatusActive {
return nil, "", ErrIdentityNotFound
}
identity := &types.Identity{
User: user,
IdentityPolicies: user.Policies.Inline,
}
return identity, keyEntry.SecretAccessKey, nil
}