mirror of
https://github.com/versity/versitygw.git
synced 2026-09-23 00:14:15 +00:00
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.
959 lines
35 KiB
Go
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")
|
|
}
|
|
}
|