Files
versitygw/iamapi/private/private_test.go
T
niksis02 a4d4519ffe feat: version the private IAM protocol between gateway and standalone service
The S3 gateway and the standalone IAM service exchange authorization decisions over the private endpoints, where a version skew is silently unsafe in both directions: an older service drops a request field it does not know (a `Condition` block, say) and evaluates fail-open, while an older gateway ignores a response field it does not know and misses a deny the service intended. Neither side could previously detect either case.

Both peers now declare a protocol version on every exchange via the `X-Vgw-Private-Protocol` header — the gateway on each request, the service on each response, error responses included — and each refuses a peer it cannot serve safely. The service rejects a gateway below `MinClientProtocol` with a `ProtocolMismatch` code; the gateway rejects a service older than the `ProtocolVersion` it speaks, and rejects a response carrying no version at all, since no build of this protocol omits the header and something else answering on that address should not be interpreted as an IAM decision. `ParseProtocolVersion` is shared by both sides and deliberately strict: an unreadable value is a mismatch, never an assumed default.

A new root-signed `/private/version` endpoint reports the protocol version, the minimum client the service will serve, and the build tag (`WithPrivateServerVersion`). It is exempt from the service's own client-version check so it can still answer a gateway the service refuses — which is how that gateway learns why. Being authenticated like every other private endpoint, it also lets the gateway's startup probe verify its own credential and its mTLS transport in the same round trip.

The gateway probes it once in `NewIAMServiceStandalone` rather than discovering a skew as an opaque per-request 500. An incompatible service is fatal after a 30s window, since a gateway that cannot authorize a single request is more useful refusing to start with the reason in its log; an unreachable one is only a warning, because the two processes legitimately start in parallel and every request checks the version regardless. Only conditions that can resolve on their own are retried — a rejected credential is reported immediately.
2026-08-25 01:41:12 +04:00

871 lines
32 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)
}
}
}
// 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
}