mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 20:56:21 +00:00
Implements the `AssumeRoleWithWebIdentity` and `GetCallerIdentity` STS actions, letting callers exchange an external OIDC token for temporary credentials scoped to an IAM role. Token handling covers JWT claim parsing, issuer/audience resolution (including `azp` override semantics), JWKS fetching and caching with `singleflight`-deduplicated refresh, and rate-limited forced refresh on unrecognized `kid` values. OIDC provider thumbprint fetching now performs a real TLS handshake verified against the system trust store and the provider hostname (previously `InsecureSkipVerify`), since the observed certificate is persisted as a long-lived trust anchor rather than used once and discarded; all discovery-document and JWKS fetches go through an SSRF-safe HTTP client with bounded redirects and response size.
Adds policy `Condition` block evaluation, supporting `String`, `Numeric`, `Date`, `Bool`, `BinaryEquals`, and `IpAddress` operators along with their `IfExists`/`Not` variants and `ForAllValues`/`ForAnyValues` set qualifiers, plus policy variable substitution (e.g. `${aws:username}`) in supported operators. Adds identity-based inline policy evaluation and a new IAM authorization middleware that authorizes each request against action, resource, and condition context together, applying the session-policy-intersects-role-policy semantics for assumed-role sessions.
Adds a new debug logger `--log-level` flag (`silent`/`debug`/`unsafe`), along with a tree-based XML masker that redacts secrets and tokens at the property level in logged request/response bodies instead of skipping the whole body. The old `--debug/VGW_DEBUG` flag is kept as a deprecated alias for `--log-level=debug`, printing a console warning that points users at `--log-level` for finer-grained control.
Fixes a Vault storage bug where CAS (check-and-set) writes always read the current document version as 0 because `kvVersion` asserted metadata as `float64` while the Vault client actually returns `json.Number`, causing every write past the first to be rejected as a concurrent modification. Also adds a constant-time `SecureCompare` for signature/token comparisons in sigv4 auth.
Adds an integration test suite (`iam_access_control.go`) covering IAM access control across user, role, and session identities.
681 lines
25 KiB
Go
681 lines
25 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 storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/iamapi/iamerr"
|
|
"github.com/versity/versitygw/iamapi/types"
|
|
)
|
|
|
|
func TestNewRequiresConfig(t *testing.T) {
|
|
_, err := New(Config{})
|
|
if err == nil {
|
|
t.Fatal("New returned nil error without a storer config")
|
|
}
|
|
if !strings.Contains(err.Error(), "no IAM storer config specified") {
|
|
t.Fatalf("error = %q, want missing storer config", err)
|
|
}
|
|
}
|
|
|
|
func TestNewCreatesInternalStore(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
_, err := New(Config{Dir: dir})
|
|
if err != nil {
|
|
t.Fatalf("New: %v", err)
|
|
}
|
|
|
|
if _, err := os.Stat(filepath.Join(dir, "iam.json")); err != nil {
|
|
t.Fatalf("stat initialized IAM file: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNewRejectsMultipleConfigs(t *testing.T) {
|
|
_, err := New(Config{
|
|
Dir: t.TempDir(),
|
|
Vault: VaultConfig{
|
|
EndpointURL: "https://vault.example.test",
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("New returned nil error with multiple storer configs")
|
|
}
|
|
if !strings.Contains(err.Error(), "multiple IAM storer configs specified") {
|
|
t.Fatalf("error = %q, want multiple storer configs", err)
|
|
}
|
|
}
|
|
|
|
func TestNewVaultRequiresAuth(t *testing.T) {
|
|
_, err := New(Config{
|
|
Vault: VaultConfig{
|
|
EndpointURL: "https://vault.example.test",
|
|
},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("New returned nil error for vault storer without auth credentials")
|
|
}
|
|
if !strings.Contains(err.Error(), "vault authentication requires either roleid/rolesecret or root token") {
|
|
t.Fatalf("error = %q, want auth required error", err)
|
|
}
|
|
}
|
|
|
|
func TestInternalStoreUserCRUDAndPagination(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
store, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewInternal: %v", err)
|
|
}
|
|
|
|
created := time.Date(2026, 6, 23, 18, 0, 0, 0, time.UTC)
|
|
users := []types.User{
|
|
{
|
|
Path: "/engineering/",
|
|
UserName: "alice",
|
|
UserID: "AIDAx2222222222222222",
|
|
Arn: "arn:aws:iam::000000000000:user/engineering/alice",
|
|
CreateDate: created,
|
|
Tags: []types.Tag{
|
|
{Key: "env", Value: "test"},
|
|
{Key: "empty", Value: ""},
|
|
},
|
|
},
|
|
{
|
|
Path: "/engineering/platform/",
|
|
UserName: "bob",
|
|
UserID: "AIDAx3333333333333333",
|
|
Arn: "arn:aws:iam::000000000000:user/engineering/platform/bob",
|
|
CreateDate: created.Add(time.Second),
|
|
},
|
|
{
|
|
Path: "/ops/",
|
|
UserName: "carol",
|
|
UserID: "AIDAx4444444444444444",
|
|
Arn: "arn:aws:iam::000000000000:user/ops/carol",
|
|
CreateDate: created.Add(2 * time.Second),
|
|
},
|
|
}
|
|
for _, user := range users {
|
|
if _, err := store.CreateUser(ctx, user); err != nil {
|
|
t.Fatalf("CreateUser(%s): %v", user.UserName, err)
|
|
}
|
|
}
|
|
|
|
if _, err := store.CreateUser(ctx, users[0]); !errors.Is(err, iamerr.EntityAlreadyExistsUser("alice")) {
|
|
t.Fatalf("CreateUser duplicate err = %v, want EntityAlreadyExists", err)
|
|
}
|
|
duplicateID := users[2]
|
|
duplicateID.UserName = "dave"
|
|
if _, err := store.CreateUser(ctx, duplicateID); !errors.Is(err, ErrUserIDAlreadyExists) {
|
|
t.Fatalf("CreateUser duplicate id err = %v, want ErrUserIDAlreadyExists", err)
|
|
}
|
|
|
|
got, err := store.GetUser(ctx, "alice")
|
|
if err != nil {
|
|
t.Fatalf("GetUser: %v", err)
|
|
}
|
|
if got.UserName != "alice" || got.UserID != users[0].UserID {
|
|
t.Fatalf("GetUser = %#v, want alice with stable id", got)
|
|
}
|
|
if !reflect.DeepEqual(got.Tags, users[0].Tags) {
|
|
t.Fatalf("GetUser tags = %#v, want %#v", got.Tags, users[0].Tags)
|
|
}
|
|
|
|
page1, err := store.ListUsers(ctx, ListUsersInput{PathPrefix: "/engineering/", MaxItems: 1})
|
|
if err != nil {
|
|
t.Fatalf("ListUsers page1: %v", err)
|
|
}
|
|
if len(page1.Users) != 1 || page1.Users[0].UserName != "alice" || !page1.IsTruncated || page1.Marker != "alice" {
|
|
t.Fatalf("page1 = %#v, want truncated alice page", page1)
|
|
}
|
|
if !reflect.DeepEqual(page1.Users[0].Tags, users[0].Tags) {
|
|
t.Fatalf("ListUsers tags = %#v, want %#v", page1.Users[0].Tags, users[0].Tags)
|
|
}
|
|
|
|
page2, err := store.ListUsers(ctx, ListUsersInput{PathPrefix: "/engineering/", Marker: page1.Marker, MaxItems: 10})
|
|
if err != nil {
|
|
t.Fatalf("ListUsers page2: %v", err)
|
|
}
|
|
if len(page2.Users) != 1 || page2.Users[0].UserName != "bob" || page2.IsTruncated {
|
|
t.Fatalf("page2 = %#v, want final bob page", page2)
|
|
}
|
|
|
|
updated, err := store.UpdateUser(ctx, UpdateUserInput{
|
|
UserName: "alice",
|
|
NewPath: "/ops/",
|
|
NewUserName: "zoe",
|
|
NewArn: "arn:aws:iam::000000000000:user/ops/zoe",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateUser: %v", err)
|
|
}
|
|
if updated.UserName != "zoe" || updated.Path != "/ops/" || updated.Arn != "arn:aws:iam::000000000000:user/ops/zoe" {
|
|
t.Fatalf("updated = %#v, want renamed/path-updated user", updated)
|
|
}
|
|
if updated.UserID != users[0].UserID || !updated.CreateDate.Equal(users[0].CreateDate) {
|
|
t.Fatalf("updated identity changed: %#v", updated)
|
|
}
|
|
if !reflect.DeepEqual(updated.Tags, users[0].Tags) {
|
|
t.Fatalf("updated tags = %#v, want %#v", updated.Tags, users[0].Tags)
|
|
}
|
|
if _, err := store.GetUser(ctx, "alice"); !errors.Is(err, iamerr.NoSuchEntityUser("alice")) {
|
|
t.Fatalf("GetUser old name err = %v, want NoSuchEntity", err)
|
|
}
|
|
if _, err := store.UpdateUser(ctx, UpdateUserInput{UserName: "zoe", NewUserName: "bob"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("bob")) {
|
|
t.Fatalf("UpdateUser duplicate err = %v, want EntityAlreadyExists", err)
|
|
}
|
|
|
|
reopened, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("reopen NewInternal: %v", err)
|
|
}
|
|
reopenedUser, err := reopened.GetUser(ctx, "zoe")
|
|
if err != nil {
|
|
t.Fatalf("GetUser after reopen: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(reopenedUser.Tags, users[0].Tags) {
|
|
t.Fatalf("reopened tags = %#v, want %#v", reopenedUser.Tags, users[0].Tags)
|
|
}
|
|
|
|
if _, err := reopened.CreateAccessKey(ctx, CreateAccessKeyInput{
|
|
UserName: "zoe",
|
|
AccessKeyID: "AKIAzZZZZZZZZZZZZZZZ",
|
|
SecretAccessKey: "secret",
|
|
Status: "Active",
|
|
CreateDate: created,
|
|
}); err != nil {
|
|
t.Fatalf("CreateAccessKey: %v", err)
|
|
}
|
|
if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflict)) {
|
|
t.Fatalf("DeleteUser with access keys err = %v, want DeleteConflict", err)
|
|
}
|
|
if err := reopened.DeleteAccessKey(ctx, "zoe", "AKIAzZZZZZZZZZZZZZZZ"); err != nil {
|
|
t.Fatalf("DeleteAccessKey: %v", err)
|
|
}
|
|
|
|
if err := reopened.DeleteUser(ctx, "zoe"); err != nil {
|
|
t.Fatalf("DeleteUser: %v", err)
|
|
}
|
|
if err := reopened.DeleteUser(ctx, "zoe"); !errors.Is(err, iamerr.NoSuchEntityUser("zoe")) {
|
|
t.Fatalf("DeleteUser missing err = %v, want NoSuchEntity", err)
|
|
}
|
|
}
|
|
|
|
func TestInternalStoreGetUserByAccessKeyID(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, err := NewInternal(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewInternal: %v", err)
|
|
}
|
|
|
|
if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDAx1111111111111111"}); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
if _, err := store.CreateAccessKey(ctx, CreateAccessKeyInput{
|
|
UserName: "alice",
|
|
AccessKeyID: "AKIAALICE0000000000",
|
|
SecretAccessKey: "secret",
|
|
Status: "Active",
|
|
CreateDate: time.Now().UTC(),
|
|
}); err != nil {
|
|
t.Fatalf("CreateAccessKey: %v", err)
|
|
}
|
|
|
|
got, err := store.GetUserByAccessKeyID(ctx, "AKIAALICE0000000000")
|
|
if err != nil {
|
|
t.Fatalf("GetUserByAccessKeyID: %v", err)
|
|
}
|
|
if got.UserName != "alice" {
|
|
t.Fatalf("GetUserByAccessKeyID = %#v, want alice", got)
|
|
}
|
|
|
|
if _, err := store.GetUserByAccessKeyID(ctx, "AKIAuNKNOWN0000000000"); !errors.Is(err, iamerr.NoSuchEntityAccessKey("AKIAuNKNOWN0000000000")) {
|
|
t.Fatalf("GetUserByAccessKeyID unknown key err = %v, want NoSuchEntityAccessKey", err)
|
|
}
|
|
}
|
|
|
|
func TestInternalStoreUserNameCaseInsensitive(t *testing.T) {
|
|
ctx := context.Background()
|
|
store, err := NewInternal(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewInternal: %v", err)
|
|
}
|
|
|
|
if _, err := store.CreateUser(ctx, types.User{UserName: "alice", UserID: "AIDAx1111111111111111"}); err != nil {
|
|
t.Fatalf("CreateUser: %v", err)
|
|
}
|
|
if _, err := store.CreateUser(ctx, types.User{UserName: "ALICE", UserID: "AIDAx2222222222222222"}); !errors.Is(err, iamerr.EntityAlreadyExistsUser("ALICE")) {
|
|
t.Fatalf("CreateUser case-variant duplicate err = %v, want EntityAlreadyExists", err)
|
|
}
|
|
|
|
got, err := store.GetUser(ctx, "ALICE")
|
|
if err != nil {
|
|
t.Fatalf("GetUser case-insensitive lookup: %v", err)
|
|
}
|
|
if got.UserName != "alice" {
|
|
t.Fatalf("GetUser case-insensitive lookup = %#v, want canonical casing preserved", got)
|
|
}
|
|
|
|
if err := store.DeleteUser(ctx, "ALICE"); err != nil {
|
|
t.Fatalf("DeleteUser case-insensitive lookup: %v", err)
|
|
}
|
|
if _, err := store.GetUser(ctx, "alice"); !errors.Is(err, iamerr.NoSuchEntityUser("alice")) {
|
|
t.Fatalf("GetUser after case-insensitive delete err = %v, want NoSuchEntity", err)
|
|
}
|
|
}
|
|
|
|
func TestInternalStoreRoleCRUDAndPagination(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
store, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewInternal: %v", err)
|
|
}
|
|
|
|
created := time.Date(2026, 7, 11, 18, 0, 0, 0, time.UTC)
|
|
roles := []types.Role{
|
|
{
|
|
Path: "/engineering/",
|
|
RoleName: "alice-role",
|
|
RoleID: "AROAx2222222222222222",
|
|
Arn: "arn:aws:iam::000000000000:role/engineering/alice-role",
|
|
CreateDate: created,
|
|
AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`,
|
|
MaxSessionDuration: 3600,
|
|
Tags: []types.Tag{
|
|
{Key: "env", Value: "test"},
|
|
},
|
|
},
|
|
{
|
|
Path: "/engineering/platform/",
|
|
RoleName: "bob-role",
|
|
RoleID: "AROAx3333333333333333",
|
|
Arn: "arn:aws:iam::000000000000:role/engineering/platform/bob-role",
|
|
CreateDate: created.Add(time.Second),
|
|
AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`,
|
|
MaxSessionDuration: 3600,
|
|
},
|
|
{
|
|
Path: "/ops/",
|
|
RoleName: "carol-role",
|
|
RoleID: "AROAx4444444444444444",
|
|
Arn: "arn:aws:iam::000000000000:role/ops/carol-role",
|
|
CreateDate: created.Add(2 * time.Second),
|
|
AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`,
|
|
MaxSessionDuration: 3600,
|
|
},
|
|
}
|
|
for _, role := range roles {
|
|
created, err := store.CreateRole(ctx, role)
|
|
if err != nil {
|
|
t.Fatalf("CreateRole(%s): %v", role.RoleName, err)
|
|
}
|
|
if created.RoleLastUsed == nil {
|
|
t.Fatalf("CreateRole(%s) RoleLastUsed = nil, want non-nil empty element", role.RoleName)
|
|
}
|
|
}
|
|
|
|
if _, err := store.CreateRole(ctx, roles[0]); !errors.Is(err, iamerr.EntityAlreadyExistsRole("alice-role")) {
|
|
t.Fatalf("CreateRole duplicate err = %v, want EntityAlreadyExists", err)
|
|
}
|
|
if _, err := store.CreateRole(ctx, types.Role{RoleName: "ALICE-ROLE", RoleID: "AROAx5555555555555555"}); !errors.Is(err, iamerr.EntityAlreadyExistsRole("ALICE-ROLE")) {
|
|
t.Fatalf("CreateRole case-variant duplicate err = %v, want EntityAlreadyExists", err)
|
|
}
|
|
duplicateID := roles[2]
|
|
duplicateID.RoleName = "dave-role"
|
|
if _, err := store.CreateRole(ctx, duplicateID); !errors.Is(err, ErrRoleIDAlreadyExists) {
|
|
t.Fatalf("CreateRole duplicate id err = %v, want ErrRoleIDAlreadyExists", err)
|
|
}
|
|
|
|
got, err := store.GetRole(ctx, "ALICE-ROLE")
|
|
if err != nil {
|
|
t.Fatalf("GetRole: %v", err)
|
|
}
|
|
if got.RoleName != "alice-role" || got.RoleID != roles[0].RoleID {
|
|
t.Fatalf("GetRole = %#v, want alice-role with stable id and preserved casing", got)
|
|
}
|
|
if !reflect.DeepEqual(got.Tags, roles[0].Tags) {
|
|
t.Fatalf("GetRole tags = %#v, want %#v", got.Tags, roles[0].Tags)
|
|
}
|
|
if got.RoleLastUsed == nil {
|
|
t.Fatal("GetRole RoleLastUsed = nil, want non-nil empty element")
|
|
}
|
|
|
|
page1, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", MaxItems: 1})
|
|
if err != nil {
|
|
t.Fatalf("ListRoles page1: %v", err)
|
|
}
|
|
if len(page1.Roles) != 1 || page1.Roles[0].RoleName != "alice-role" || !page1.IsTruncated || page1.Marker != "alice-role" {
|
|
t.Fatalf("page1 = %#v, want truncated alice-role page", page1)
|
|
}
|
|
if page1.Roles[0].RoleLastUsed != nil {
|
|
t.Fatalf("ListRoles RoleLastUsed = %#v, want nil (list/get asymmetry)", page1.Roles[0].RoleLastUsed)
|
|
}
|
|
|
|
page2, err := store.ListRoles(ctx, ListRolesInput{PathPrefix: "/engineering/", Marker: page1.Marker, MaxItems: 10})
|
|
if err != nil {
|
|
t.Fatalf("ListRoles page2: %v", err)
|
|
}
|
|
if len(page2.Roles) != 1 || page2.Roles[0].RoleName != "bob-role" || page2.IsTruncated {
|
|
t.Fatalf("page2 = %#v, want final bob-role page", page2)
|
|
}
|
|
|
|
updatedRole, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{
|
|
RoleName: "alice-role",
|
|
PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateAssumeRolePolicy: %v", err)
|
|
}
|
|
if updatedRole.AssumeRolePolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}` {
|
|
t.Fatalf("UpdateAssumeRolePolicy result = %#v", updatedRole)
|
|
}
|
|
if updatedRole.RoleID != roles[0].RoleID {
|
|
t.Fatalf("UpdateAssumeRolePolicy identity changed: %#v", updatedRole)
|
|
}
|
|
if _, err := store.UpdateAssumeRolePolicy(ctx, UpdateAssumeRolePolicyInput{RoleName: "missing-role", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
|
|
t.Fatalf("UpdateAssumeRolePolicy missing role err = %v, want NoSuchEntity", err)
|
|
}
|
|
|
|
reopened, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("reopen NewInternal: %v", err)
|
|
}
|
|
reopenedRole, err := reopened.GetRole(ctx, "alice-role")
|
|
if err != nil {
|
|
t.Fatalf("GetRole after reopen: %v", err)
|
|
}
|
|
if reopenedRole.AssumeRolePolicyDocument != updatedRole.AssumeRolePolicyDocument {
|
|
t.Fatalf("reopened AssumeRolePolicyDocument = %q, want %q", reopenedRole.AssumeRolePolicyDocument, updatedRole.AssumeRolePolicyDocument)
|
|
}
|
|
|
|
if err := reopened.DeleteRole(ctx, "carol-role"); err != nil {
|
|
t.Fatalf("DeleteRole: %v", err)
|
|
}
|
|
if err := reopened.DeleteRole(ctx, "carol-role"); !errors.Is(err, iamerr.NoSuchEntityRole("carol-role")) {
|
|
t.Fatalf("DeleteRole missing err = %v, want NoSuchEntity", err)
|
|
}
|
|
}
|
|
|
|
func TestInternalStoreRolePolicyCRUD(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
store, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewInternal: %v", err)
|
|
}
|
|
|
|
if _, err := store.CreateRole(ctx, types.Role{
|
|
RoleName: "alice-role",
|
|
RoleID: "AROAx2222222222222222",
|
|
AssumeRolePolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":"*"},"Action":"sts:AssumeRole"}]}`,
|
|
}); err != nil {
|
|
t.Fatalf("CreateRole: %v", err)
|
|
}
|
|
|
|
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{
|
|
RoleName: "ALICE-ROLE",
|
|
PolicyName: "ReadOnly",
|
|
PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`,
|
|
}); err != nil {
|
|
t.Fatalf("PutRolePolicy: %v", err)
|
|
}
|
|
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "missing-role", PolicyName: "P", PolicyDocument: "{}"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
|
|
t.Fatalf("PutRolePolicy missing role err = %v, want NoSuchEntity", err)
|
|
}
|
|
|
|
entry, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly")
|
|
if err != nil {
|
|
t.Fatalf("GetRolePolicy: %v", err)
|
|
}
|
|
if entry.PolicyDocument != `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}` {
|
|
t.Fatalf("GetRolePolicy document = %q", entry.PolicyDocument)
|
|
}
|
|
if entry.CreateDate.IsZero() || entry.UpdateDate.IsZero() {
|
|
t.Fatalf("GetRolePolicy CreateDate/UpdateDate zero: %#v", entry)
|
|
}
|
|
if _, err := store.GetRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) {
|
|
t.Fatalf("GetRolePolicy missing policy err = %v, want NoSuchEntity", err)
|
|
}
|
|
if _, err := store.GetRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
|
|
t.Fatalf("GetRolePolicy missing role err = %v, want NoSuchEntity", err)
|
|
}
|
|
|
|
// Overwriting an existing PolicyName replaces its document rather than
|
|
// stacking toward the aggregate size quota.
|
|
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{
|
|
RoleName: "alice-role",
|
|
PolicyName: "ReadOnly",
|
|
PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`,
|
|
}); err != nil {
|
|
t.Fatalf("overwrite PutRolePolicy: %v", err)
|
|
}
|
|
overwritten, err := store.GetRolePolicy(ctx, "alice-role", "ReadOnly")
|
|
if err != nil {
|
|
t.Fatalf("GetRolePolicy after overwrite: %v", err)
|
|
}
|
|
if !strings.Contains(overwritten.PolicyDocument, "Deny") {
|
|
t.Fatalf("GetRolePolicy after overwrite = %q, want the Deny statement", overwritten.PolicyDocument)
|
|
}
|
|
|
|
// Aggregate inline policy size for a role is capped at
|
|
// MaxInlinePolicyBytesPerRole (10240), distinct from and larger than
|
|
// the 2048 byte cap for users.
|
|
oversized := `{"Version":"2012-10-17","Statement":[{"Sid":"` + strings.Repeat("x", 10300) + `","Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
|
|
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{RoleName: "alice-role", PolicyName: "TooBig", PolicyDocument: oversized}); !errors.Is(err, iamerr.InlinePolicyQuotaExceeded("role", "alice-role", MaxInlinePolicyBytesPerRole)) {
|
|
t.Fatalf("PutRolePolicy oversized err = %v, want LimitExceeded", err)
|
|
}
|
|
|
|
if err := store.PutRolePolicy(ctx, PutRolePolicyInput{
|
|
RoleName: "alice-role",
|
|
PolicyName: "SecondPolicy",
|
|
PolicyDocument: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:PutObject","Resource":"*"}]}`,
|
|
}); err != nil {
|
|
t.Fatalf("PutRolePolicy second policy: %v", err)
|
|
}
|
|
|
|
list, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "ALICE-ROLE", MaxItems: 1})
|
|
if err != nil {
|
|
t.Fatalf("ListRolePolicies page1: %v", err)
|
|
}
|
|
if len(list.PolicyNames) != 1 || list.PolicyNames[0] != "ReadOnly" || !list.IsTruncated || list.Marker != "ReadOnly" {
|
|
t.Fatalf("ListRolePolicies page1 = %#v, want truncated ReadOnly page", list)
|
|
}
|
|
page2, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "alice-role", Marker: list.Marker, MaxItems: 10})
|
|
if err != nil {
|
|
t.Fatalf("ListRolePolicies page2: %v", err)
|
|
}
|
|
if len(page2.PolicyNames) != 1 || page2.PolicyNames[0] != "SecondPolicy" || page2.IsTruncated {
|
|
t.Fatalf("ListRolePolicies page2 = %#v, want final SecondPolicy page", page2)
|
|
}
|
|
if _, err := store.ListRolePolicies(ctx, ListRolePoliciesInput{RoleName: "missing-role"}); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
|
|
t.Fatalf("ListRolePolicies missing role err = %v, want NoSuchEntity", err)
|
|
}
|
|
|
|
// A role with attached inline policies cannot be deleted until they are
|
|
// all removed first.
|
|
if err := store.DeleteRole(ctx, "alice-role"); !errors.Is(err, iamerr.GetAPIError(iamerr.ErrDeleteConflictPolicies)) {
|
|
t.Fatalf("DeleteRole with policies err = %v, want DeleteConflict", err)
|
|
}
|
|
|
|
if err := store.DeleteRolePolicy(ctx, "alice-role", "SecondPolicy"); err != nil {
|
|
t.Fatalf("DeleteRolePolicy: %v", err)
|
|
}
|
|
if err := store.DeleteRolePolicy(ctx, "alice-role", "NoSuchPolicy"); !errors.Is(err, iamerr.NoSuchEntityRolePolicy("alice-role", "NoSuchPolicy")) {
|
|
t.Fatalf("DeleteRolePolicy missing policy err = %v, want NoSuchEntity", err)
|
|
}
|
|
if err := store.DeleteRolePolicy(ctx, "missing-role", "P"); !errors.Is(err, iamerr.NoSuchEntityRole("missing-role")) {
|
|
t.Fatalf("DeleteRolePolicy missing role err = %v, want NoSuchEntity", err)
|
|
}
|
|
|
|
reopened, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("reopen NewInternal: %v", err)
|
|
}
|
|
if _, err := reopened.GetRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil {
|
|
t.Fatalf("GetRolePolicy after reopen: %v", err)
|
|
}
|
|
|
|
if err := reopened.DeleteRolePolicy(ctx, "alice-role", "ReadOnly"); err != nil {
|
|
t.Fatalf("DeleteRolePolicy: %v", err)
|
|
}
|
|
if err := reopened.DeleteRole(ctx, "alice-role"); err != nil {
|
|
t.Fatalf("DeleteRole after removing all policies: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInternalStoreSessionCRUDAndExpiry(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
store, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("NewInternal: %v", err)
|
|
}
|
|
|
|
// GetSession compares Expiration against the real wall clock, so (unlike
|
|
// most other timestamps in this package's tests) now must track it.
|
|
now := time.Now().UTC()
|
|
session := types.Session{
|
|
AccessKeyId: "ASIAeXAMPLE1234567890",
|
|
SecretAccessKey: "secret",
|
|
SessionToken: "token",
|
|
RoleArn: "arn:aws:iam::000000000000:role/my-role",
|
|
RoleName: "my-role",
|
|
RoleID: "AROAeXAMPLE1234567890",
|
|
RoleSessionName: "my-session",
|
|
Provider: "arn:aws:iam::000000000000:oidc-provider/example.com",
|
|
Audience: "client1",
|
|
Subject: "user1",
|
|
CreateDate: now,
|
|
Expiration: now.Add(time.Hour),
|
|
}
|
|
|
|
if _, err := store.CreateSession(ctx, session); err != nil {
|
|
t.Fatalf("CreateSession: %v", err)
|
|
}
|
|
|
|
got, err := store.GetSession(ctx, session.AccessKeyId)
|
|
if err != nil {
|
|
t.Fatalf("GetSession: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(*got, session) {
|
|
t.Fatalf("GetSession = %#v, want %#v", *got, session)
|
|
}
|
|
|
|
if _, err := store.GetSession(ctx, "ASIAUNKNOWN"); !errors.Is(err, ErrSessionNotFound) {
|
|
t.Fatalf("GetSession unknown access key err = %v, want ErrSessionNotFound", err)
|
|
}
|
|
|
|
// A session persists across process restarts (round-trips through the
|
|
// same on-disk file the rest of the IAM store uses).
|
|
reopened, err := NewInternal(dir)
|
|
if err != nil {
|
|
t.Fatalf("reopen NewInternal: %v", err)
|
|
}
|
|
if _, err := reopened.GetSession(ctx, session.AccessKeyId); err != nil {
|
|
t.Fatalf("GetSession after reopen: %v", err)
|
|
}
|
|
|
|
expired := types.Session{
|
|
AccessKeyId: "ASIAeXPIRED1234567890",
|
|
CreateDate: now,
|
|
Expiration: now.Add(-time.Minute),
|
|
}
|
|
if _, err := reopened.CreateSession(ctx, expired); err != nil {
|
|
t.Fatalf("CreateSession expired: %v", err)
|
|
}
|
|
if _, err := reopened.GetSession(ctx, expired.AccessKeyId); !errors.Is(err, ErrSessionNotFound) {
|
|
t.Fatalf("GetSession expired err = %v, want ErrSessionNotFound", err)
|
|
}
|
|
|
|
// Creating a new session opportunistically prunes the already-expired
|
|
// one from storage rather than letting it accumulate forever.
|
|
another := types.Session{
|
|
AccessKeyId: "ASIAaNOTHER1234567890",
|
|
CreateDate: now,
|
|
Expiration: now.Add(time.Hour),
|
|
}
|
|
if _, err := reopened.CreateSession(ctx, another); err != nil {
|
|
t.Fatalf("CreateSession another: %v", err)
|
|
}
|
|
internal := reopened.(*InternalStore)
|
|
conf, err := internal.engine.GetIAM()
|
|
if err != nil {
|
|
t.Fatalf("GetIAM: %v", err)
|
|
}
|
|
if _, ok := conf.Sessions[expired.AccessKeyId]; ok {
|
|
t.Fatalf("expired session %q was not pruned: %#v", expired.AccessKeyId, conf.Sessions)
|
|
}
|
|
if _, ok := conf.Sessions[another.AccessKeyId]; !ok {
|
|
t.Fatalf("unexpired session %q missing after prune: %#v", another.AccessKeyId, conf.Sessions)
|
|
}
|
|
}
|
|
|
|
func TestInternalStoreSessionCapPerRole(t *testing.T) {
|
|
// Each CreateSession call rewrites the whole IAM file, so hitting the
|
|
// real 1000 cap here would mean O(n^2) JSON work just to prove the cap
|
|
// is enforced. Lower it for the duration of the test instead.
|
|
orig := MaxActiveSessionsPerRole
|
|
MaxActiveSessionsPerRole = 5
|
|
t.Cleanup(func() { MaxActiveSessionsPerRole = orig })
|
|
|
|
ctx := context.Background()
|
|
store, err := NewInternal(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewInternal: %v", err)
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
newSession := func(i int, roleArn string) types.Session {
|
|
return types.Session{
|
|
AccessKeyId: fmt.Sprintf("ASIACAPPEDROLE%06d", i),
|
|
RoleArn: roleArn,
|
|
CreateDate: now,
|
|
Expiration: now.Add(time.Hour),
|
|
}
|
|
}
|
|
|
|
const roleArn = "arn:aws:iam::000000000000:role/capped-role"
|
|
for i := range MaxActiveSessionsPerRole {
|
|
if _, err := store.CreateSession(ctx, newSession(i, roleArn)); err != nil {
|
|
t.Fatalf("CreateSession %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
// The role is now at its cap - one more session for the same role must
|
|
// be rejected rather than accepted unboundedly.
|
|
_, err = store.CreateSession(ctx, newSession(MaxActiveSessionsPerRole, roleArn))
|
|
var apiErr iamerr.APIError
|
|
if !errors.As(err, &apiErr) || apiErr.StatusCode() != 400 {
|
|
t.Fatalf("CreateSession at cap err = %v, want a Throttling APIError", err)
|
|
}
|
|
|
|
// A different role is entirely unaffected by the first role's cap.
|
|
const otherRoleArn = "arn:aws:iam::000000000000:role/other-role"
|
|
if _, err := store.CreateSession(ctx, newSession(MaxActiveSessionsPerRole+1, otherRoleArn)); err != nil {
|
|
t.Fatalf("CreateSession for a different role: %v", err)
|
|
}
|
|
}
|