Files
versitygw/auth/iam_standalone_test.go
T
niksis02 c84c5f645a feat: accept principal ARNs in bucket policies under standalone IAM
Bucket policy `Principal` named callers by access key id. Under the standalone IAM service it now names them by AWS-style ARN, as real S3 does: a user ARN, a role ARN (covering every session of that role), an assumed-role ARN (covering one session), the account root ARN or bare account id, or `*`. Every other IAM backend has no ARNs to name anything by and keeps access-key principals unchanged, gated on a new `auth.PrincipalResolver` capability interface that only the standalone client implements.

`auth.Account` carries `Arn` and `RoleArn`, filled at authentication time, so a session can be matched against both its own ARN and its role's. Principals are validated at PutBucketPolicy time through a new `/private/resolve-principals` endpoint, which rejects anything that does not name a live identity with `MalformedPolicy: Invalid principal in policy`.

An `Allow` naming the account root ARN or bare account id delegates to the account's own IAM rather than granting on its own, while a `Deny` naming it denies every principal in the account outright. Denial messages now name the caller by ARN wherever one exists.

Also fixes `aws:PrincipalArn` for assumed-role sessions, which reported the session ARN where AWS reports the role's, and stops an unreachable IAM service being reported as a malformed policy.
2026-09-03 23:51:55 +04:00

959 lines
35 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 auth
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/versity/versitygw/iamapi"
"github.com/versity/versitygw/iamapi/private"
"github.com/versity/versitygw/iamapi/storage"
"github.com/versity/versitygw/iamapi/types"
"github.com/versity/versitygw/internal/netutil"
"github.com/versity/versitygw/internal/sigv4auth"
"github.com/versity/versitygw/s3err"
)
const standaloneTestRootAccess = "AKIDROOT"
const standaloneTestRootSecret = "ROOTSECRET"
// standaloneTestServer starts a real private.PrivateAPI on a unix socket in
// t.TempDir(), backed by a real file storage.Storer — an actual server, not
// a hand-rolled mock — so IAMServiceStandalone is exercised against exactly
// the same code path the smoke-tested `versitygw iam` binary runs.
func standaloneTestServer(t *testing.T) (store storage.Storer, sockPath string) {
t.Helper()
store, err := storage.New(storage.Config{Dir: t.TempDir()})
if err != nil {
t.Fatalf("storage.New: %v", err)
}
p, err := private.New(store, iamapi.RootCredentials{
Access: standaloneTestRootAccess,
Secret: standaloneTestRootSecret,
})
if err != nil {
t.Fatalf("private.New: %v", err)
}
// A unix socket path is limited to ~104 bytes on macOS (sockaddr_un),
// which t.TempDir() alone can exceed once it embeds this test's full
// name — os.MkdirTemp with a short, fixed prefix keeps it well under
// that regardless of the test name.
sockDir, err := os.MkdirTemp("", "vgw-priv")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(sockDir) })
sockPath = filepath.Join(sockDir, "p.sock")
errCh := make(chan error, 1)
go func() {
errCh <- p.ServeMultiPort([]string{sockPath}, netutil.TLSOptions{})
}()
waitForSocket(t, sockPath, errCh)
t.Cleanup(func() {
if err := p.Shutdown(); err != nil {
t.Logf("shutdown private API: %v", err)
}
})
return store, sockPath
}
func waitForSocket(t *testing.T, path string, errCh <-chan error) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
select {
case err := <-errCh:
t.Fatalf("ServeMultiPort exited early: %v", err)
default:
}
conn, err := net.Dial("unix", path)
if err == nil {
conn.Close()
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("private API socket %s never became ready", path)
}
func createStandaloneTestUser(t *testing.T, store storage.Storer, userName, accessKeyID, secret, policyDocument string) {
t.Helper()
ctx := context.Background()
// Arn is set explicitly, as the control-plane controller does before
// calling storage.CreateUser: it is what a bucket policy names the user
// by, and storage.CreateUser never populates it.
if _, err := store.CreateUser(ctx, types.User{
UserName: userName,
Path: "/",
Arn: standaloneUserArn(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)
}
}
}
func TestIAMServiceStandaloneDeriveSigningKeyAndGetUserAccount(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
derivedKey, account, err := client.DeriveSigningKey("AKIAALICE", "", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("DeriveSigningKey: %v", err)
}
want := sigv4auth.DeriveKey("alicesecret", yyyymmdd, "us-east-1", "s3")
if string(derivedKey) != string(want) {
t.Errorf("derived key = %x, want %x", derivedKey, want)
}
if account.Secret != "" {
t.Errorf("account.Secret should never be populated by the standalone client, got %q", account.Secret)
}
if account.Role != RoleUser {
t.Errorf("account.Role = %v, want %v", account.Role, RoleUser)
}
// GetUserAccount resolves via the metadata-only endpoint and must agree.
got, err := client.GetUserAccount("AKIAALICE")
if err != nil {
t.Fatalf("GetUserAccount: %v", err)
}
if got.Access != "AKIAALICE" || got.Secret != "" {
t.Errorf("GetUserAccount() = %+v", got)
}
}
func TestIAMServiceStandaloneGetUserAccountUnknownReturnsErrNoSuchUser(t *testing.T) {
store, sock := standaloneTestServer(t)
_ = store
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
_, err = client.GetUserAccount("AKIADOESNOTEXIST")
if !errors.Is(err, ErrNoSuchUser) {
t.Errorf("GetUserAccount() error = %v, want ErrNoSuchUser", err)
}
}
func TestIAMServiceStandaloneGetUserAccountRoot(t *testing.T) {
_, sock := standaloneTestServer(t)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
got, err := client.GetUserAccount(standaloneTestRootAccess)
if err != nil {
t.Fatalf("GetUserAccount(root): %v", err)
}
if got.Secret != standaloneTestRootSecret {
t.Errorf("root account should resolve locally with its real secret, got %+v", got)
}
}
func TestIAMServiceStandaloneEvaluatePolicy(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "bob", "AKIABOB", "bobsecret",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
tests := []struct {
name string
action Action
want policyDecision
}{
{name: "allowed action", action: Action("s3:GetObject"), want: policyDecisionAllow},
{name: "action with no matching statement", action: Action("s3:PutObject"), want: policyDecisionNoMatch},
{name: "explicitly denied action", action: Action("s3:DeleteObject"), want: policyDecisionDeny},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
eval, err := client.EvaluatePolicy("AKIABOB", "", []Action{tt.action}, []string{"*"}, nil)
if err != nil {
t.Fatalf("EvaluatePolicy: %v", err)
}
if len(eval.Decisions) != 1 || len(eval.Decisions[0]) != 1 || eval.Decisions[0][0] != tt.want {
t.Errorf("Decisions = %v, want [[%v]]", eval.Decisions, tt.want)
}
})
}
}
// TestIAMServiceStandaloneEvaluatePolicyBatchesMultipleActions confirms
// several actions are evaluated in a single request, with Decisions
// returned in the same order as the requested actions — the fix for
// identityPolicyDecision previously issuing one round trip per action.
func TestIAMServiceStandaloneEvaluatePolicyBatchesMultipleActions(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "bob", "AKIABOB", "bobsecret",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
eval, err := client.EvaluatePolicy("AKIABOB", "", []Action{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"}, []string{"*"}, nil)
if err != nil {
t.Fatalf("EvaluatePolicy: %v", err)
}
want := []policyDecision{policyDecisionAllow, policyDecisionNoMatch, policyDecisionDeny}
if len(eval.Decisions) != 1 || len(eval.Decisions[0]) != len(want) {
t.Fatalf("Decisions = %v, want [%v]", eval.Decisions, want)
}
for i := range want {
if eval.Decisions[0][i] != want[i] {
t.Errorf("Decisions[0][%d] = %v, want %v", i, eval.Decisions[0][i], want[i])
}
}
}
func TestIAMServiceStandaloneMutatingMethodsNotSupported(t *testing.T) {
_, sock := standaloneTestServer(t)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
notSupported := s3err.GetAPIError(s3err.ErrAdminMethodNotSupported)
if err := client.CreateAccount(Account{}); !errors.Is(err, notSupported) {
t.Errorf("CreateAccount() error = %v, want %v", err, notSupported)
}
if err := client.UpdateUserAccount("x", MutableProps{}); !errors.Is(err, notSupported) {
t.Errorf("UpdateUserAccount() error = %v, want %v", err, notSupported)
}
if err := client.DeleteUserAccount("x"); !errors.Is(err, notSupported) {
t.Errorf("DeleteUserAccount() error = %v, want %v", err, notSupported)
}
if _, err := client.ListUserAccounts(); !errors.Is(err, notSupported) {
t.Errorf("ListUserAccounts() error = %v, want %v", err, notSupported)
}
}
func TestNewIAMServiceStandaloneRequiresMTLSForTCPEndpoint(t *testing.T) {
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
_, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: "127.0.0.1:9443"})
if err == nil {
t.Fatal("expected an error constructing a TCP-endpoint client without mTLS configured")
}
}
// TestNewIAMServiceStandaloneDefaultsToRootCredentials confirms this
// client's own signing identity (the credential it signs its private
// requests with) falls back to the gateway's root account when
// Access/Secret aren't explicitly configured — so a deployment doesn't need
// to mint a dedicated IAM identity just for the gateway to talk to its own
// standalone IAM service.
func TestNewIAMServiceStandaloneDefaultsToRootCredentials(t *testing.T) {
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
_, sock := standaloneTestServer(t)
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
if client.access != rootAcc.Access {
t.Errorf("access = %q, want root access %q", client.access, rootAcc.Access)
}
if client.secret != rootAcc.Secret {
t.Errorf("secret = %q, want root secret %q", client.secret, rootAcc.Secret)
}
// Also confirm the client actually works end-to-end when signing with
// the defaulted root identity, not just that the fields were set.
if _, err := client.GetUserAccount(standaloneTestRootAccess); err != nil {
t.Fatalf("GetUserAccount(root) with defaulted signing identity: %v", err)
}
}
// TestNewIAMServiceStandaloneRespectsExplicitCredentials confirms an
// explicitly configured Access/Secret is used as-is, not overridden by the
// root account's credentials.
func TestNewIAMServiceStandaloneRespectsExplicitCredentials(t *testing.T) {
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
_, sock := standaloneTestServer(t)
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{
Endpoint: sock,
Access: "AKIDCUSTOM",
Secret: "CUSTOMSECRET",
})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
if client.access != "AKIDCUSTOM" {
t.Errorf("access = %q, want %q", client.access, "AKIDCUSTOM")
}
if client.secret != "CUSTOMSECRET" {
t.Errorf("secret = %q, want %q", client.secret, "CUSTOMSECRET")
}
}
// TestNewIAMServiceStandalonePartialCredentialsRejected covers a half
// configured signing identity: pairing one supplied key with the other half
// of the root credential would silently sign with a mismatched identity, so
// it must fail at construction instead.
func TestNewIAMServiceStandalonePartialCredentialsRejected(t *testing.T) {
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
_, sock := standaloneTestServer(t)
for _, tc := range []struct {
name string
access string
secret string
}{
{name: "access only", access: "AKIDCUSTOM"},
{name: "secret only", secret: "CUSTOMSECRET"},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{
Endpoint: sock,
Access: tc.access,
Secret: tc.secret,
})
if err == nil {
t.Fatal("expected an error when only one of access/secret is configured")
}
})
}
}
// TestIAMServiceStandaloneRejectsIncompatibleService covers every response a
// peer can give that this gateway must not interpret: no protocol header at
// all (a pre-versioning build, or something else answering on the address),
// one it cannot read, and one older than the protocol this gateway speaks.
// None of them may yield a working client.
func TestIAMServiceStandaloneRejectsIncompatibleService(t *testing.T) {
shortenProbeWindow(t)
for _, tc := range []struct {
name string
protocol string
}{
{"no header", ""},
{"unreadable", "one"},
{"older service", strconv.Itoa(private.ProtocolVersion - 1)},
} {
t.Run(tc.name, func(t *testing.T) {
sock := serveFakePrivate(t, tc.protocol, http.StatusOK, `{"protocol":0}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
_, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err == nil {
t.Fatal("expected the gateway to refuse to start against an incompatible IAM service")
}
var mismatch *protocolMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("error = %v, want a protocolMismatchError", err)
}
})
}
}
// TestIAMServiceStandaloneAcceptsNewerService confirms the rule is
// "not older", not "equal": an IAM service upgraded ahead of its gateways is
// the supported deployment order, so it must keep serving them.
func TestIAMServiceStandaloneAcceptsNewerService(t *testing.T) {
shortenProbeWindow(t)
newer := strconv.Itoa(private.ProtocolVersion + 1)
sock := serveFakePrivate(t, newer, http.StatusOK,
`{"protocol":`+newer+`,"minClient":1,"serverVersion":"v9.9.9"}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone against a newer IAM service: %v", err)
}
defer client.Shutdown()
}
// TestIAMServiceStandaloneRefusedByNewerService is the other direction of the
// same check: an IAM service that has raised its minimum turns this gateway
// away, and the gateway must recognise that as a version problem rather than
// as a generic server error.
func TestIAMServiceStandaloneRefusedByNewerService(t *testing.T) {
shortenProbeWindow(t)
sock := serveFakePrivate(t, strconv.Itoa(private.ProtocolVersion+1), http.StatusBadRequest,
`{"error":"gateway speaks private protocol 1, this IAM service requires 2 or newer","code":"`+private.CodeProtocolMismatch+`"}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
_, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err == nil {
t.Fatal("expected the gateway to refuse to start when the IAM service refuses it")
}
var mismatch *protocolMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("error = %v, want a protocolMismatchError", err)
}
}
// TestIAMServiceStandaloneRefusedByServiceMinimum covers the one direction a
// response header cannot express. The version endpoint is exempt from the
// service's own client-version check, so it answers 200 even to a gateway the
// service will not serve; the gateway has to reach that conclusion from the
// minimum the endpoint reports, or it would start cleanly and then fail every
// real request.
func TestIAMServiceStandaloneRefusedByServiceMinimum(t *testing.T) {
shortenProbeWindow(t)
current := strconv.Itoa(private.ProtocolVersion)
sock := serveFakePrivate(t, current, http.StatusOK,
`{"protocol":`+current+`,"minClient":`+strconv.Itoa(private.ProtocolVersion+1)+`}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
_, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err == nil {
t.Fatal("expected the gateway to refuse to start below the IAM service's minimum")
}
var mismatch *protocolMismatchError
if !errors.As(err, &mismatch) {
t.Fatalf("error = %v, want a protocolMismatchError", err)
}
}
// TestIAMServiceStandaloneUnreachableIsNotFatal confirms an unreachable IAM
// service only warns. The two processes legitimately start in parallel, and
// every request checks the version anyway, so refusing to start here would
// invent an ordering dependency without buying any safety.
func TestIAMServiceStandaloneUnreachableIsNotFatal(t *testing.T) {
shortenProbeWindow(t)
sockDir, err := os.MkdirTemp("", "vgw-priv")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(sockDir) })
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{
Endpoint: filepath.Join(sockDir, "nothing-here.sock"),
})
if err != nil {
t.Fatalf("an unreachable IAM service must not be fatal, got: %v", err)
}
defer client.Shutdown()
}
// TestIAMServiceStandaloneDoesNotRetryDefinitiveRejection confirms the probe's
// retry window applies only to failures that can resolve on their own. A
// rejected gateway credential is answered by a service that is up and
// compatible, so it must warn at once rather than hold startup for the full
// window. Deliberately run against the real, unshortened window.
func TestIAMServiceStandaloneDoesNotRetryDefinitiveRejection(t *testing.T) {
sock := serveFakePrivate(t, strconv.Itoa(private.ProtocolVersion), http.StatusForbidden,
`{"error":"The security token included in the request is invalid","code":"InvalidClientTokenId"}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
start := time.Now()
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("a rejected credential must warn, not fail startup: %v", err)
}
defer client.Shutdown()
if elapsed := time.Since(start); elapsed > standaloneProbeInterval {
t.Errorf("probe took %v; a definitive rejection must not be retried", elapsed)
}
}
// TestIAMServiceStandaloneSendsProtocolHeader confirms the gateway advertises
// its own version, and that it does so inside the signature: the real server
// verifies the signature over that header, so an unsigned or absent one would
// fail before reaching a handler.
func TestIAMServiceStandaloneSendsProtocolHeader(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
if _, err := client.GetUserAccount("AKIAALICE"); err != nil {
t.Fatalf("GetUserAccount: %v", err)
}
}
// TestIAMServiceStandaloneShapeChecksSurviveMatchingProtocol confirms the
// version header did not replace the response-shape checks. A peer can declare
// a compatible version and still send a matrix that disagrees — a forgotten
// bump, a locally patched build — and that must still fail closed.
func TestIAMServiceStandaloneShapeChecksSurviveMatchingProtocol(t *testing.T) {
shortenProbeWindow(t)
// Compatible on the wire version, but one action decision short of the two
// actions asked for below.
current := strconv.Itoa(private.ProtocolVersion)
sock := serveFakePrivate(t, current, http.StatusOK,
`{"protocol":`+current+`,"decisions":[["allow"]]}`)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
_, err = client.EvaluatePolicy("AKIAALICE", "", []Action{GetObjectAction, PutObjectAction}, []string{"arn:aws:s3:::b/o"}, nil)
if err == nil {
t.Fatal("expected a short decision row to fail closed even at a matching protocol version")
}
}
// shortenProbeWindow collapses the startup probe's retry window for tests that
// deliberately point the client at an incompatible or absent service, which
// would otherwise sit through the full production window.
func shortenProbeWindow(t *testing.T) {
t.Helper()
window, interval := standaloneProbeWindow, standaloneProbeInterval
standaloneProbeWindow, standaloneProbeInterval = 0, time.Millisecond
t.Cleanup(func() { standaloneProbeWindow, standaloneProbeInterval = window, interval })
}
// serveFakePrivate serves a stand-in for the standalone IAM service on a unix
// socket, answering every request with the given protocol header (omitted when
// empty), status, and body. It exists because the cases worth testing — a
// build older or newer than this one, or one predating versioning altogether —
// cannot be produced by the real server, which only ever speaks its own
// version.
func serveFakePrivate(t *testing.T, protocol string, status int, body string) string {
t.Helper()
sockDir, err := os.MkdirTemp("", "vgw-priv")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(sockDir) })
sockPath := filepath.Join(sockDir, "p.sock")
ln, err := net.Listen("unix", sockPath)
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if protocol != "" {
w.Header().Set(private.ProtocolHeader, protocol)
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
fmt.Fprint(w, body)
})}
go srv.Serve(ln)
t.Cleanup(func() { srv.Close() })
return sockPath
}
// TestIAMServiceStandaloneRootCarriesPosixIdentity covers the identity a
// storage backend chowns to. Bucket ownership is fixed to root here, so a
// root account left at uid/gid 0 makes the posix backend's --chuid/--chgid
// target root for every bucket and for root's own object writes — which an
// unprivileged gateway can never do.
func TestIAMServiceStandaloneRootCarriesPosixIdentity(t *testing.T) {
_, sock := standaloneTestServer(t)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
client, err := NewIAMServiceStandalone(rootAcc, IAMServiceStandaloneConfig{
Endpoint: sock,
DefaultUserID: 1001,
DefaultGroupID: 1002,
DefaultProjectID: 1003,
})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
defer client.Shutdown()
checkIDs := func(what string, acc Account) {
t.Helper()
if acc.UserID != 1001 || acc.GroupID != 1002 || acc.ProjectID != 1003 {
t.Errorf("%s posix ids = %v/%v/%v, want 1001/1002/1003",
what, acc.UserID, acc.GroupID, acc.ProjectID)
}
}
owner, fixed := ResolveFixedBucketOwner(client)
if !fixed {
t.Fatal("ResolveFixedBucketOwner: standalone client must fix bucket ownership")
}
if owner.Access != standaloneTestRootAccess {
t.Errorf("bucket owner = %q, want the root account %q", owner.Access, standaloneTestRootAccess)
}
checkIDs("BucketOwner()", owner)
// The same identity must come back wherever root is resolved, so that a
// bucket root owns and an object root writes get the same ownership.
acc, err := client.GetUserAccount(standaloneTestRootAccess)
if err != nil {
t.Fatalf("GetUserAccount(root): %v", err)
}
checkIDs("GetUserAccount(root)", acc)
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
_, acc, err = client.DeriveSigningKey(standaloneTestRootAccess, "", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("DeriveSigningKey(root): %v", err)
}
checkIDs("DeriveSigningKey(root)", acc)
missing, err := client.ResolveAccounts([]string{standaloneTestRootAccess})
if err != nil {
t.Fatalf("ResolveAccounts(root): %v", err)
}
if len(missing) != 0 {
t.Errorf("ResolveAccounts(root) = %v, want the root account to resolve", missing)
}
// The stored root account is compared against by credential, and must
// keep the credentials it was constructed with.
if client.rootAcc != rootAcc {
t.Errorf("stored root account was mutated: %+v, want %+v", client.rootAcc, rootAcc)
}
if acc.Secret != standaloneTestRootSecret || acc.Role != RoleAdmin {
t.Errorf("root identity lost its credentials or role: %+v", acc)
}
}
// standaloneTestAccountID is the account every ARN the IAM service mints
// belongs to, and the one it reports on the version endpoint.
const standaloneTestAccountID = "000000000000"
func standaloneUserArn(userName string) string {
return "arn:aws:iam::" + standaloneTestAccountID + ":user/" + userName
}
func standaloneRoleArn(roleName string) string {
return "arn:aws:iam::" + standaloneTestAccountID + ":role/" + roleName
}
// createStandaloneTestSession creates a role and a live session of it,
// returning the session's credentials — the shape AssumeRoleWithWebIdentity
// produces, built directly against the store the way
// createStandaloneTestUser does.
func createStandaloneTestSession(t *testing.T, store storage.Storer, roleName, accessKeyID, secret, token string) *types.Session {
t.Helper()
ctx := context.Background()
role, err := store.CreateRole(ctx, types.Role{
RoleName: roleName,
Path: "/",
RoleID: "AROA" + roleName,
Arn: standaloneRoleArn(roleName),
CreateDate: time.Now().UTC(),
})
if err != nil {
t.Fatalf("CreateRole: %v", err)
}
session, err := store.CreateSession(ctx, types.Session{
AccessKeyId: accessKeyID,
SecretAccessKey: secret,
SessionToken: token,
RoleArn: role.Arn,
RoleName: role.RoleName,
RoleID: role.RoleID,
RoleSessionName: "sess1",
CreateDate: time.Now().UTC(),
Expiration: time.Now().UTC().Add(time.Hour),
})
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return session
}
func newStandaloneTestClient(t *testing.T, sock string) *IAMServiceStandalone {
t.Helper()
client, err := NewIAMServiceStandalone(
Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin},
IAMServiceStandaloneConfig{Endpoint: sock})
if err != nil {
t.Fatalf("NewIAMServiceStandalone: %v", err)
}
t.Cleanup(func() { client.Shutdown() })
return client
}
// TestIAMServiceStandaloneAuthenticatedAccountsCarryArns covers what a
// bucket policy's Principal element is matched against: a user is named by
// its own ARN, and a session by both its assumed-role ARN and the ARN of the
// role it assumed, since a policy naming either one matches it.
func TestIAMServiceStandaloneAuthenticatedAccountsCarryArns(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
session := createStandaloneTestSession(t, store, "reader", "ASIASESSION", "sesssecret", "tok")
client := newStandaloneTestClient(t, sock)
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
_, user, err := client.DeriveSigningKey("AKIAALICE", "", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("DeriveSigningKey(user): %v", err)
}
if want := standaloneUserArn("alice"); user.Arn != want {
t.Errorf("user Arn = %q, want %q", user.Arn, want)
}
if user.RoleArn != "" {
t.Errorf("user RoleArn = %q, want it empty: a user assumed no role", user.RoleArn)
}
_, sess, err := client.DeriveSigningKey(session.AccessKeyId, "tok", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("DeriveSigningKey(session): %v", err)
}
wantSessionArn := "arn:aws:sts::" + standaloneTestAccountID + ":assumed-role/reader/sess1"
if sess.Arn != wantSessionArn {
t.Errorf("session Arn = %q, want %q", sess.Arn, wantSessionArn)
}
if want := standaloneRoleArn("reader"); sess.RoleArn != want {
t.Errorf("session RoleArn = %q, want %q", sess.RoleArn, want)
}
}
// TestIAMServiceStandaloneRootCarriesAccountArn pins that the gateway's own
// root account is named by the account root ARN. The IAM service holds no
// record of root, so the account id comes from the startup probe.
func TestIAMServiceStandaloneRootCarriesAccountArn(t *testing.T) {
_, sock := standaloneTestServer(t)
client := newStandaloneTestClient(t, sock)
want := "arn:aws:iam::" + standaloneTestAccountID + ":root"
owner, fixed := ResolveFixedBucketOwner(client)
if !fixed {
t.Fatal("ResolveFixedBucketOwner: standalone client must fix bucket ownership")
}
if owner.Arn != want {
t.Errorf("BucketOwner().Arn = %q, want %q", owner.Arn, want)
}
acc, err := client.GetUserAccount(standaloneTestRootAccess)
if err != nil {
t.Fatalf("GetUserAccount(root): %v", err)
}
if acc.Arn != want {
t.Errorf("GetUserAccount(root).Arn = %q, want %q", acc.Arn, want)
}
}
// TestIAMServiceStandaloneResolvePrincipals covers the write-time principal
// check PutBucketPolicy makes, end to end against a real IAM service: every
// form a bucket policy may name resolves, and an access key id — what this
// gateway's other IAM backends name principals by — does not.
func TestIAMServiceStandaloneResolvePrincipals(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
createStandaloneTestSession(t, store, "reader", "ASIASESSION", "sesssecret", "tok")
client := newStandaloneTestClient(t, sock)
valid := []string{
standaloneTestAccountID,
"arn:aws:iam::" + standaloneTestAccountID + ":root",
standaloneUserArn("alice"),
standaloneRoleArn("reader"),
"arn:aws:sts::" + standaloneTestAccountID + ":assumed-role/reader/sess1",
"arn:aws:sts::" + standaloneTestAccountID + ":assumed-role/reader/never-assumed",
}
invalid := []string{
"AKIAALICE",
"ASIASESSION",
"alice",
standaloneUserArn("bob"),
standaloneRoleArn("writer"),
standaloneUserArn("*"),
"arn:aws:iam::111111111111:root",
}
got, err := client.ResolvePrincipals(append(append([]string{}, valid...), invalid...))
if err != nil {
t.Fatalf("ResolvePrincipals: %v", err)
}
reported := map[string]bool{}
for _, p := range got {
reported[p] = true
}
for _, p := range valid {
if reported[p] {
t.Errorf("principal %q reported invalid, want it to resolve", p)
}
}
for _, p := range invalid {
if !reported[p] {
t.Errorf("principal %q reported valid, want it rejected", p)
}
}
}
// TestIAMServiceStandaloneResolvePrincipalsEmpty pins that validating a
// policy whose only principal is the wildcard — which never reaches here —
// costs no round trip.
func TestIAMServiceStandaloneResolvePrincipalsEmpty(t *testing.T) {
_, sock := standaloneTestServer(t)
client := newStandaloneTestClient(t, sock)
invalid, err := client.ResolvePrincipals(nil)
if err != nil {
t.Fatalf("ResolvePrincipals(nil): %v", err)
}
if len(invalid) != 0 {
t.Errorf("ResolvePrincipals(nil) = %v, want none", invalid)
}
}
// TestIAMServiceStandalonePrincipalsValidate ties the two halves together:
// a bucket policy validated against the standalone client names principals
// by ARN, and the same document naming an access key id is rejected.
func TestIAMServiceStandalonePrincipalsValidate(t *testing.T) {
store, sock := standaloneTestServer(t)
createStandaloneTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
client := newStandaloneTestClient(t, sock)
if err := (Principals{standaloneUserArn("alice"): {}}).Validate(client); err != nil {
t.Errorf("Validate(user arn) = %v, want it accepted", err)
}
if err := (Principals{"AKIAALICE": {}}).Validate(client); err != policyErrInvalidPrincipal {
t.Errorf("Validate(access key id) = %v, want %v", err, policyErrInvalidPrincipal)
}
if err := (Principals{"*": {}}).Validate(client); err != nil {
t.Errorf("Validate(wildcard) = %v, want it accepted", err)
}
}
// TestIAMServiceStandaloneRootIdentityCarriesArn pins that root reaches the
// S3 request path named. Root is the one identity resolved locally rather
// than through the IAM service, so ResolveDerivedKey never sees an ARN for
// it — rootIdentity has to carry the one the backend defines, or a bucket
// policy naming the account root ARN would miss root entirely.
func TestIAMServiceStandaloneRootIdentityCarriesArn(t *testing.T) {
_, sock := standaloneTestServer(t)
client := newStandaloneTestClient(t, sock)
rootAcc := Account{Access: standaloneTestRootAccess, Secret: standaloneTestRootSecret, Role: RoleAdmin}
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
_, acc, err := ResolveDerivedKey(client, rootAcc, standaloneTestRootAccess, "", yyyymmdd, "us-east-1", "s3")
if err != nil {
t.Fatalf("ResolveDerivedKey(root): %v", err)
}
want := "arn:aws:iam::" + standaloneTestAccountID + ":root"
if acc.Arn != want {
t.Errorf("root Arn = %q, want %q", acc.Arn, want)
}
if acc.Secret != standaloneTestRootSecret || acc.Role != RoleAdmin {
t.Errorf("root identity lost its credentials or role: %+v", acc)
}
}
// TestIAMServiceStandaloneCapabilityInterfaces pins the full set of
// capability interfaces the standalone client implements. Every one is
// resolved by type assertion on the live IAMService, so anything that wraps
// it (auth.IAMCache, cmd/vgwrdma's shutdown-once wrapper) has to re-expose
// all of them — a capability silently dropped by a wrapper is a
// capability switched off gateway-wide.
func TestIAMServiceStandaloneCapabilityInterfaces(t *testing.T) {
_, sock := standaloneTestServer(t)
var iam IAMService = newStandaloneTestClient(t, sock)
if _, ok := iam.(SigningKeyProvider); !ok {
t.Error("standalone client must implement SigningKeyProvider")
}
if _, ok := iam.(PolicyEvaluator); !ok {
t.Error("standalone client must implement PolicyEvaluator")
}
if _, ok := iam.(FixedBucketOwner); !ok {
t.Error("standalone client must implement FixedBucketOwner")
}
if _, ok := iam.(PrincipalResolver); !ok {
t.Error("standalone client must implement PrincipalResolver")
}
}