Files
versitygw/iamapi/private/private_test.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

1023 lines
38 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 private
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
"github.com/versity/versitygw/iamapi/internal/iamutil"
"github.com/versity/versitygw/iamapi/storage"
"github.com/versity/versitygw/iamapi/types"
"github.com/versity/versitygw/internal/sigv4auth"
)
var testRoot = iammiddleware.RootCredentials{Access: "AKIDTESTROOT", Secret: "TESTROOTSECRET"}
// newTestServer builds a fresh file-backed store rooted at t.TempDir() and a
// PrivateAPI on top of it — no public control-plane IAMApiServer involved,
// since this package's handlers only ever need a populated storage.Storer.
func newTestServer(t *testing.T) (*PrivateAPI, storage.Storer) {
t.Helper()
store, err := storage.New(storage.Config{Dir: t.TempDir()})
if err != nil {
t.Fatalf("storage.New: %v", err)
}
p, err := New(store, testRoot)
if err != nil {
t.Fatalf("New: %v", err)
}
return p, store
}
// createTestUser creates a user with the given name, access key, and
// (optional) inline policy directly against store — bypassing the public
// control-plane API entirely, since it isn't under test here. Arn is set
// explicitly (iamutil.BuildUserArn, matching what the control-plane
// controller computes before calling storage.CreateUser — storage.CreateUser
// itself never populates it) so tests can assert on a realistic principal
// ARN in an evaluate-policy response.
func createTestUser(t *testing.T, store storage.Storer, userName, accessKeyID, secret, policyDocument string) {
t.Helper()
ctx := context.Background()
if _, err := store.CreateUser(ctx, types.User{
UserName: userName,
Path: "/",
Arn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", userName),
CreateDate: time.Now().UTC(),
}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if _, err := store.CreateAccessKey(ctx, storage.CreateAccessKeyInput{
UserName: userName,
AccessKeyID: accessKeyID,
SecretAccessKey: secret,
Status: "Active",
CreateDate: time.Now().UTC(),
}); err != nil {
t.Fatalf("CreateAccessKey: %v", err)
}
if policyDocument != "" {
if err := store.PutUserPolicy(ctx, storage.PutUserPolicyInput{
UserName: userName,
PolicyName: "P",
PolicyDocument: policyDocument,
}); err != nil {
t.Fatalf("PutUserPolicy: %v", err)
}
}
}
// signPrivateRequest signs req as access/secret for the private endpoints'
// SigV4 protocol (service "iam", iammiddleware.SigningRegion), mutating its
// Authorization/X-Amz-Date headers in place.
func signPrivateRequest(t *testing.T, req *http.Request, access, secret string, payloadHash string) {
t.Helper()
signingTime := time.Now().UTC()
yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD)
derivedKey := sigv4auth.DeriveKey(secret, yyyymmdd, iammiddleware.SigningRegion, privateService)
in := sigv4auth.SigningInputFromRequest(req)
in.AccessKeyID = access
in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, iammiddleware.SigningRegion, privateService)
in.PayloadHash = payloadHash
in.SigningTime = signingTime
result := sigv4auth.BuildAndSign(derivedKey, in)
req.Header.Set("X-Amz-Date", result.AmzDate)
req.Header.Set("Authorization", result.AuthorizationHeader)
}
func doPrivateRequest(t *testing.T, p *PrivateAPI, method, target, access, secret string, body []byte) *http.Response {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set(ProtocolHeader, strconv.Itoa(ProtocolVersion))
req.ContentLength = int64(len(body))
hash := sigv4auth.PayloadSHA256Hex(body)
signPrivateRequest(t, req, access, secret, hash)
resp, err := p.app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
return resp
}
// doPrivateRequestWithProtocol is doPrivateRequest with the protocol header
// set to an arbitrary value — including "" for a gateway build that predates
// versioning and sends none at all.
func doPrivateRequestWithProtocol(t *testing.T, p *PrivateAPI, target, protocol string, body []byte) *http.Response {
t.Helper()
req := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if protocol != "" {
req.Header.Set(ProtocolHeader, protocol)
}
req.ContentLength = int64(len(body))
signPrivateRequest(t, req, testRoot.Access, testRoot.Secret, sigv4auth.PayloadSHA256Hex(body))
resp, err := p.app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
return resp
}
func readBody(t *testing.T, resp *http.Response) string {
t.Helper()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(body)
}
func TestPrivateAPIDeriveSigningKey(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
body, _ := json.Marshal(DeriveSigningKeyRequest{
AccessKeyID: "AKIAALICE",
Date: yyyymmdd,
Region: "us-east-1",
Service: "s3",
})
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var got DeriveSigningKeyResponse
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
want := sigv4auth.DeriveKey("alicesecret", yyyymmdd, "us-east-1", "s3")
if !bytes.Equal(got.DerivedKey, want) {
t.Errorf("DerivedKey = %x, want %x", got.DerivedKey, want)
}
}
func TestPrivateAPIDeriveSigningKeyRejectsUnknownAccessKey(t *testing.T) {
p, _ := newTestServer(t)
body, _ := json.Marshal(DeriveSigningKeyRequest{
AccessKeyID: "AKIADOESNOTEXIST",
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
Region: "us-east-1",
Service: "s3",
})
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp))
}
}
// TestPrivateAPIDeriveSigningKeySessionToken covers every way a temporary
// (ASIA…) access key can be presented to derive-signing-key. The security of
// the whole session path rests on exactly one thing — that a signing key is
// handed out only for a session token matching the one stored — so each
// wrong-token shape is pinned, along with the error code that tells the S3
// gateway to report InvalidToken rather than InvalidAccessKeyId.
func TestPrivateAPIDeriveSigningKeySessionToken(t *testing.T) {
p, store := newTestServer(t)
role := createTestRole(t, store, "testrole", "")
session := createTestSessionForRole(t, store, role, "ASIASOMESESSIONKEY", "sessionsecret", "correct-session-token", "")
tests := []struct {
name string
token string
wantStatus int
wantCode string
}{
{name: "no token at all", token: "", wantStatus: http.StatusForbidden, wantCode: CodeNoSuchIdentity},
{name: "wrong token", token: "wrong-session-token", wantStatus: http.StatusForbidden, wantCode: CodeInvalidToken},
{name: "correct token", token: session.SessionToken, wantStatus: http.StatusOK},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(DeriveSigningKeyRequest{
AccessKeyID: session.AccessKeyId,
SessionToken: tt.token,
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
Region: "us-east-1",
Service: "s3",
})
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != tt.wantStatus {
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, tt.wantStatus, raw)
}
if tt.wantCode != "" {
var errBody struct{ Code string }
if err := json.Unmarshal([]byte(raw), &errBody); err != nil {
t.Fatalf("unmarshal error body %s: %v", raw, err)
}
if errBody.Code != tt.wantCode {
t.Fatalf("error code = %q, want %q", errBody.Code, tt.wantCode)
}
return
}
var out DeriveSigningKeyResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
want := sigv4auth.DeriveKey("sessionsecret", time.Now().UTC().Format(sigv4auth.YYYYMMDD), "us-east-1", "s3")
if string(out.DerivedKey) != string(want) {
t.Errorf("derived key = %x, want %x", out.DerivedKey, want)
}
})
}
}
// TestPrivateAPIDeriveSigningKeyRejectsTokenWithPermanentKey confirms a
// session token offered alongside a long-term (AKIA…) key is rejected rather
// than ignored — accepting it silently would mask a misrouted request.
func TestPrivateAPIDeriveSigningKeyRejectsTokenWithPermanentKey(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
body, _ := json.Marshal(DeriveSigningKeyRequest{
AccessKeyID: "AKIAALICE",
SessionToken: "some-session-token",
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
Region: "us-east-1",
Service: "s3",
})
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp))
}
}
// TestPrivateAPIEvaluatePolicySessionPolicy confirms the role's own policies
// and the session policy are reported *separately*, not folded together.
//
// The S3 gateway needs them apart because a bucket policy is also in play
// there: a session policy filters permissions that came from the bucket
// policy too, while the role's own decision does not. See
// iammiddleware.AuthorizeSplit.
func TestPrivateAPIEvaluatePolicySessionPolicy(t *testing.T) {
rolePolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":"*"}]}`
tests := []struct {
name string
sessionPolicy string
action string
want string
wantSession string
wantHasSessionPo bool
}{
{
name: "no session policy: role decision stands alone",
action: "s3:GetObject",
want: DecisionAllow,
},
{
name: "session policy narrows to a subset",
sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`,
action: "s3:PutObject",
want: DecisionAllow,
wantSession: DecisionNoMatch,
wantHasSessionPo: true,
},
{
name: "session policy cannot widen beyond the role",
sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`,
action: "s3:DeleteObject",
want: DecisionNoMatch,
wantSession: DecisionAllow,
wantHasSessionPo: true,
},
{
name: "session policy explicit deny against the role's allow",
sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:GetObject","Resource":"*"}]}`,
action: "s3:GetObject",
want: DecisionAllow,
wantSession: DecisionDeny,
wantHasSessionPo: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
p, store := newTestServer(t)
role := createTestRole(t, store, "testrole", rolePolicy)
session := createTestSessionForRole(t, store, role, "ASIASESSION", "sessionsecret", "tok", tt.sessionPolicy)
body, _ := json.Marshal(EvaluatePolicyRequest{
AccessKeyID: session.AccessKeyId,
SessionToken: session.SessionToken,
Actions: []string{tt.action},
Resources: []string{"*"},
})
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
}
var out EvaluatePolicyResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != tt.want {
t.Errorf("Decisions = %v, want [[%v]]", out.Decisions, tt.want)
}
if out.HasSessionPolicy != tt.wantHasSessionPo {
t.Errorf("HasSessionPolicy = %v, want %v", out.HasSessionPolicy, tt.wantHasSessionPo)
}
if tt.wantHasSessionPo {
if len(out.SessionDecisions) != 1 || len(out.SessionDecisions[0]) != 1 || out.SessionDecisions[0][0] != tt.wantSession {
t.Errorf("SessionDecisions = %v, want [[%v]]", out.SessionDecisions, tt.wantSession)
}
} else if len(out.SessionDecisions) != 0 {
t.Errorf("SessionDecisions = %v, want none when no session policy applies", out.SessionDecisions)
}
wantArn := iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, session.RoleSessionName)
if out.PrincipalArn != wantArn {
t.Errorf("PrincipalArn = %q, want %q", out.PrincipalArn, wantArn)
}
})
}
}
// TestPrivateAPIEvaluatePolicyStripsCallerSuppliedIdentityKeys confirms the
// gateway cannot influence an identity-namespace condition key by sending
// one itself. Stripping rather than overriding matters for keys the service
// does not set at all: aws:PrincipalTag/team below has no value for an
// untagged user, and a policy that Allows on its *absence* must not be
// satisfiable by a value the caller supplied.
func TestPrivateAPIEvaluatePolicyStripsCallerSuppliedIdentityKeys(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*",`+
`"Condition":{"StringEquals":{"aws:PrincipalTag/team":"admins"}}}]}`)
body, _ := json.Marshal(EvaluatePolicyRequest{
AccessKeyID: "AKIAALICE",
Actions: []string{"s3:GetObject"},
Resources: []string{"*"},
Condition: map[string][]string{
"aws:PrincipalTag/team": {"admins"},
// Case-varied spellings must be stripped too: policy key lookup
// is case-insensitive, so a case-sensitive filter would be no
// filter at all.
"AWS:PrincipalArn": {"arn:aws:iam::000000000000:user/somebodyelse"},
},
})
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
}
var out EvaluatePolicyResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != DecisionNoMatch {
t.Errorf("Decisions = %v, want [[%v]]: a caller-supplied aws:PrincipalTag must not satisfy the condition", out.Decisions, DecisionNoMatch)
}
}
// TestPrivateAPIEvaluatePolicyUsesRequestConditionKeys is the counterpart to
// the test above: the request-derived keys the gateway *is* the authority
// for must reach the policy evaluator intact.
func TestPrivateAPIEvaluatePolicyUsesRequestConditionKeys(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*",`+
`"Condition":{"IpAddress":{"aws:SourceIp":"10.1.2.0/24"}}}]}`)
tests := []struct {
name string
sourceIP string
want string
}{
{name: "matching source ip", sourceIP: "10.1.2.3", want: DecisionAllow},
{name: "non-matching source ip", sourceIP: "10.9.9.9", want: DecisionNoMatch},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(EvaluatePolicyRequest{
AccessKeyID: "AKIAALICE",
Actions: []string{"s3:GetObject"},
Resources: []string{"*"},
Condition: map[string][]string{"aws:SourceIp": {tt.sourceIP}},
})
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
}
var out EvaluatePolicyResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != tt.want {
t.Errorf("Decisions = %v, want [%v]", out.Decisions, tt.want)
}
})
}
}
// TestPrivateAPIResolveIdentity covers the metadata-only endpoint: it must
// answer positionally for a whole batch, resolve a session with no token
// (the disclosure is harmless, since nothing it returns authenticates
// anyone), and label session versus user so the gateway can refuse to
// persist a reference to an ephemeral principal.
func TestPrivateAPIResolveIdentity(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
role := createTestRole(t, store, "testrole", "")
session := createTestSessionForRole(t, store, role, "ASIASESSION", "sessionsecret", "tok", "")
body, _ := json.Marshal(ResolveIdentityRequest{
AccessKeyIDs: []string{"AKIAALICE", "AKIADOESNOTEXIST", session.AccessKeyId},
})
resp := doPrivateRequest(t, p, http.MethodPost, ResolveIdentityPath, testRoot.Access, testRoot.Secret, body)
raw := readBody(t, resp)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
}
var out ResolveIdentityResponse
if err := json.Unmarshal([]byte(raw), &out); err != nil {
t.Fatalf("unmarshal %s: %v", raw, err)
}
want := []ResolvedIdentity{
{Found: true, Kind: KindUser, PrincipalArn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice")},
{},
{Found: true, Kind: KindSession, PrincipalArn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, session.RoleSessionName)},
}
if len(out.Identities) != len(want) {
t.Fatalf("Identities = %+v, want %d entries", out.Identities, len(want))
}
for i := range want {
if out.Identities[i] != want[i] {
t.Errorf("Identities[%d] = %+v, want %+v", i, out.Identities[i], want[i])
}
}
}
func TestPrivateAPIEvaluatePolicy(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`)
wantArn := iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice")
tests := []struct {
name string
action string
want string
}{
{name: "allowed action", action: "s3:GetObject", want: DecisionAllow},
{name: "action not granted", action: "s3:PutObject", want: DecisionNoMatch},
{name: "explicitly denied action", action: "s3:DeleteObject", want: DecisionDeny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body, _ := json.Marshal(EvaluatePolicyRequest{
AccessKeyID: "AKIAALICE",
Actions: []string{tt.action},
Resources: []string{"*"},
})
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var got EvaluatePolicyResponse
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if len(got.Decisions) != 1 || len(got.Decisions[0]) != 1 || got.Decisions[0][0] != tt.want {
t.Errorf("Decisions = %v, want [[%v]]", got.Decisions, tt.want)
}
if got.PrincipalArn != wantArn {
t.Errorf("PrincipalArn = %q, want %q", got.PrincipalArn, wantArn)
}
})
}
}
// TestPrivateAPIEvaluatePolicyBatchesMultipleActions confirms multiple
// actions supplied in one EvaluatePolicyRequest are each evaluated
// independently against the same resource, in a single request, with
// Decisions returned in the same order as Actions.
func TestPrivateAPIEvaluatePolicyBatchesMultipleActions(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`)
body, _ := json.Marshal(EvaluatePolicyRequest{
AccessKeyID: "AKIAALICE",
Actions: []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"},
Resources: []string{"*"},
})
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var got EvaluatePolicyResponse
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
want := []string{DecisionAllow, DecisionNoMatch, DecisionDeny}
if len(got.Decisions) != 1 || len(got.Decisions[0]) != len(want) {
t.Fatalf("Decisions = %v, want [%v]", got.Decisions, want)
}
for i := range want {
if got.Decisions[0][i] != want[i] {
t.Errorf("Decisions[0][%d] = %v, want %v", i, got.Decisions[0][i], want[i])
}
}
}
// TestPrivateAPIEvaluatePolicyBatchesMultipleResources confirms several
// resources are each evaluated against every action in one request — what
// keeps a 1000-key DeleteObjects a single round trip.
func TestPrivateAPIEvaluatePolicyBatchesMultipleResources(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::b/allowed/*"}]}`)
body, _ := json.Marshal(EvaluatePolicyRequest{
AccessKeyID: "AKIAALICE",
Actions: []string{"s3:DeleteObject"},
Resources: []string{"arn:aws:s3:::b/allowed/one", "arn:aws:s3:::b/denied/two", "arn:aws:s3:::b/allowed/three"},
})
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var got EvaluatePolicyResponse
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
want := []string{DecisionAllow, DecisionNoMatch, DecisionAllow}
if len(got.Decisions) != len(want) {
t.Fatalf("Decisions = %v, want %d resource entries", got.Decisions, len(want))
}
for i := range want {
if len(got.Decisions[i]) != 1 || got.Decisions[i][0] != want[i] {
t.Errorf("Decisions[%d] = %v, want [%v]", i, got.Decisions[i], want[i])
}
}
}
// TestPrivateAPIRejectsNonRootCredential confirms a validly-signed request
// from a real (non-root) IAM user's own credentials is rejected outright —
// only the S3 gateway's own root-equivalent identity may ever call these
// endpoints.
func TestPrivateAPIRejectsNonRootCredential(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
body, _ := json.Marshal(DeriveSigningKeyRequest{
AccessKeyID: "AKIAALICE",
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
Region: "us-east-1",
Service: "s3",
})
// Signed with alice's own, otherwise-valid credentials — not root.
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, "AKIAALICE", "alicesecret", body)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp))
}
}
func TestPrivateAPIRejectsMalformedBody(t *testing.T) {
p, _ := newTestServer(t)
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, []byte("not json"))
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusBadRequest, readBody(t, resp))
}
}
func TestPrivateAPIRejectsUnsignedRequest(t *testing.T) {
p, _ := newTestServer(t)
body, _ := json.Marshal(DeriveSigningKeyRequest{AccessKeyID: "AKIAX", Date: "20260101", Region: "us-east-1", Service: "s3"})
req := httptest.NewRequest(http.MethodPost, DerivePath, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := p.app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode == http.StatusOK {
t.Errorf("expected an unsigned request to be rejected, got 200")
}
}
func TestPrivateAPIVersion(t *testing.T) {
store, err := storage.New(storage.Config{Dir: t.TempDir()})
if err != nil {
t.Fatalf("storage.New: %v", err)
}
p, err := New(store, testRoot, WithPrivateServerVersion("v1.2.3"))
if err != nil {
t.Fatalf("New: %v", err)
}
resp := doPrivateRequest(t, p, http.MethodPost, VersionPath, testRoot.Access, testRoot.Secret, []byte("{}"))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, readBody(t, resp))
}
var got VersionResponse
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Protocol != ProtocolVersion || got.MinClient != MinClientProtocol {
t.Errorf("VersionResponse = %+v, want protocol %d minClient %d", got, ProtocolVersion, MinClientProtocol)
}
if got.ServerVersion != "v1.2.3" {
t.Errorf("ServerVersion = %q, want %q", got.ServerVersion, "v1.2.3")
}
}
// TestPrivateAPIVersionRequiresRootCredential confirms the version endpoint is
// authenticated like every other one here — that is what lets the gateway's
// startup probe verify its own credential in the same round trip.
func TestPrivateAPIVersionRequiresRootCredential(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
resp := doPrivateRequest(t, p, http.MethodPost, VersionPath, "AKIAALICE", "alicesecret", []byte("{}"))
if resp.StatusCode == http.StatusOK {
t.Fatalf("version endpoint served a non-root credential: %s", readBody(t, resp))
}
}
// TestPrivateAPIProtocolHeaderOnEveryResponse covers the success path, an
// application error, and an unknown route. The last two go through
// errorHandler, which must not drop the header — a mismatch response that
// carries no version is the one response an operator most needs it on.
func TestPrivateAPIProtocolHeaderOnEveryResponse(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
for _, tc := range []struct {
name string
path string
body string
}{
{"success", ResolveIdentityPath, `{"accessKeyIds":["AKIAALICE"]}`},
{"application error", DerivePath, "not json"},
{"unknown route", "/private/nope", "{}"},
} {
t.Run(tc.name, func(t *testing.T) {
resp := doPrivateRequest(t, p, http.MethodPost, tc.path, testRoot.Access, testRoot.Secret, []byte(tc.body))
if got := resp.Header.Get(ProtocolHeader); got != strconv.Itoa(ProtocolVersion) {
t.Errorf("%s = %q, want %q", ProtocolHeader, got, strconv.Itoa(ProtocolVersion))
}
})
}
}
// TestPrivateAPIRejectsIncompatibleClientProtocol covers every request-header
// value this build refuses. A gateway too old to be served safely, and one
// whose version cannot be read at all, are both refused with a code the
// gateway dispatches on — never served on an assumed version.
func TestPrivateAPIRejectsIncompatibleClientProtocol(t *testing.T) {
p, store := newTestServer(t)
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
for _, tc := range []struct {
name string
protocol string
}{
{"absent", ""},
{"empty", " "},
{"not a number", "one"},
{"signed", "+1"},
{"zero", "0"},
{"absurdly long", "11111111111111111111"},
} {
t.Run(tc.name, func(t *testing.T) {
resp := doPrivateRequestWithProtocol(t, p, ResolveIdentityPath, tc.protocol, []byte(`{"accessKeyIds":["AKIAALICE"]}`))
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", resp.StatusCode, http.StatusBadRequest, readBody(t, resp))
}
body := readBody(t, resp)
if !strings.Contains(body, CodeProtocolMismatch) {
t.Errorf("body = %s, want code %s", body, CodeProtocolMismatch)
}
if got := resp.Header.Get(ProtocolHeader); got != strconv.Itoa(ProtocolVersion) {
t.Errorf("%s = %q, want the refusing build's own version", ProtocolHeader, got)
}
})
}
}
// TestPrivateAPIVersionExemptFromClientProtocolCheck confirms the version
// endpoint answers a gateway this build would otherwise refuse. Without it, a
// future service that raised MinClientProtocol could not tell an older gateway
// why it was being turned away.
func TestPrivateAPIVersionExemptFromClientProtocolCheck(t *testing.T) {
p, _ := newTestServer(t)
resp := doPrivateRequestWithProtocol(t, p, VersionPath, "", []byte("{}"))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200; body = %s", resp.StatusCode, readBody(t, resp))
}
if got := resp.Header.Get(ProtocolHeader); got != strconv.Itoa(ProtocolVersion) {
t.Errorf("%s = %q, want %q", ProtocolHeader, got, strconv.Itoa(ProtocolVersion))
}
}
func TestParseProtocolVersion(t *testing.T) {
for _, tc := range []struct {
value string
want int
}{
{"1", 1},
{"2", 2},
{"1000", 1000},
{"", 0},
{" 1", 0},
{"1 ", 0},
{"+1", 0},
{"-1", 0},
{"0", 0},
{"1.0", 0},
{"v1", 0},
{"99999", 0},
} {
got, err := ParseProtocolVersion(tc.value)
if tc.want == 0 {
if err == nil {
t.Errorf("ParseProtocolVersion(%q) = %d, want an error", tc.value, got)
}
continue
}
if err != nil {
t.Errorf("ParseProtocolVersion(%q): %v", tc.value, err)
}
if got != tc.want {
t.Errorf("ParseProtocolVersion(%q) = %d, want %d", tc.value, got, tc.want)
}
}
}
// 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
// storage.CreateRole doesn't populate them, and iamutil.ResolveSessionByToken
// re-checks both against the session before attaching the role's policies.
func createTestRole(t *testing.T, store storage.Storer, roleName, policyDocument string) *types.Role {
t.Helper()
ctx := context.Background()
role, err := store.CreateRole(ctx, types.Role{
RoleName: roleName,
Path: "/",
RoleID: "AROA" + roleName,
Arn: iamutil.BuildRoleArn(iamutil.DefaultAccountID, "/", roleName),
CreateDate: time.Now().UTC(),
})
if err != nil {
t.Fatalf("CreateRole: %v", err)
}
if policyDocument != "" {
if err := store.PutRolePolicy(ctx, storage.PutRolePolicyInput{
RoleName: roleName,
PolicyName: "P",
PolicyDocument: policyDocument,
}); err != nil {
t.Fatalf("PutRolePolicy: %v", err)
}
}
return role
}
// createTestSessionForRole creates a session against role as
// AssumeRoleWithWebIdentity would, with an optional inline session policy.
// RoleID and RoleArn are copied from role so the session survives
// iamutil.ResolveSessionByToken's same-role re-check.
func createTestSessionForRole(t *testing.T, store storage.Storer, role *types.Role, accessKeyID, secret, token, sessionPolicy string) *types.Session {
t.Helper()
session, err := store.CreateSession(context.Background(), types.Session{
AccessKeyId: accessKeyID,
SecretAccessKey: secret,
SessionToken: token,
RoleArn: role.Arn,
RoleName: role.RoleName,
RoleID: role.RoleID,
RoleSessionName: "testsession",
CreateDate: time.Now().UTC(),
Expiration: time.Now().UTC().Add(time.Hour),
Policy: sessionPolicy,
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return session
}