Files
versitygw/iamapi/authorization_test.go
T
niksis02 4756b4d236 feat: add STS web identity federation, IAM policy Condition support, and access control enforcement
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.
2026-08-15 17:49:00 +04:00

603 lines
32 KiB
Go

// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package iamapi
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
awsv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
iamtypes "github.com/versity/versitygw/iamapi/types"
)
// signedIAMActionAs signs params (as an "iam"-service request, matching
// every non-STS action) with an arbitrary access key/secret/session token,
// unlike signedIAMRequest/querySignedIAMRequest which always sign as root.
func signedIAMActionAs(t *testing.T, access, secret, sessionToken string, params url.Values) *http.Request {
t.Helper()
if !params.Has("Version") {
params.Set("Version", iamAPIVersion)
}
body := []byte(params.Encode())
req := httptest.NewRequest(http.MethodPost, "http://example.com/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
hash := sha256.Sum256(body)
payloadHash := hex.EncodeToString(hash[:])
creds := aws.Credentials{AccessKeyID: access, SecretAccessKey: secret, SessionToken: sessionToken}
signer := awsv4.NewSigner()
if err := signer.SignHTTP(context.Background(), creds, req, payloadHash, "iam", iammiddleware.SigningRegion, time.Now().UTC()); err != nil {
t.Fatalf("sign iam request: %v", err)
}
return req
}
func doSignedIAMActionAs(t *testing.T, server *IAMApiServer, access, secret, sessionToken string, params url.Values) *http.Response {
t.Helper()
req := signedIAMActionAs(t, access, secret, sessionToken, params)
resp, err := server.app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
return resp
}
// createTestUserWithAccessKey creates a user (and, if policyDocument != "",
// an inline policy for it) via root, and an access key for it, returning the
// key material tests sign requests with.
func createTestUserWithAccessKey(t *testing.T, server *IAMApiServer, userName, policyDocument string) (accessKeyID, secretAccessKey string) {
t.Helper()
if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {userName}}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
if policyDocument != "" {
resp := doIAMActionPost(t, server, url.Values{
"Action": {"PutUserPolicy"},
"UserName": {userName},
"PolicyName": {"test-policy"},
"PolicyDocument": {policyDocument},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("PutUserPolicy status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
resp := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {userName}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateAccessKey status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out iamtypes.CreateAccessKeyResponse
unmarshalXML(t, readBody(t, resp), &out)
return out.Result.AccessKey.AccessKeyId, out.Result.AccessKey.SecretAccessKey
}
func TestVerifyIAMPolicyAllowsGrantedAction(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "alice",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"alice"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
func TestVerifyIAMPolicyDeniesUngrantedAction(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "bob",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"CreateUser"}, "UserName": {"carol"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/bob is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action")
}
func TestVerifyIAMPolicyDeniesUserWithNoPolicies(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "dave", "")
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"dave"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/dave is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
}
func TestVerifyIAMAuthRejectsInactiveAccessKey(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "erin",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`)
resp := doIAMAction(t, server, url.Values{
"Action": {"UpdateAccessKey"},
"UserName": {"erin"},
"AccessKeyId": {accessKeyID},
"Status": {"Inactive"},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("UpdateAccessKey status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"erin"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.")
}
func TestVerifyIAMAuthRejectsUnknownAccessKey(t *testing.T) {
server := newIAMControllerTestServer(t)
resp := doSignedIAMActionAs(t, server, "unknown-access-key-id", "does-not-matter", "", url.Values{"Action": {"ListUsers"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "InvalidClientTokenId", "The security token included in the request is invalid.")
}
func TestIAMApiControllerGetCallerIdentityWithUser(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "frank", "")
resp := doSignedSTSAction(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetCallerIdentity"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetCallerIdentity status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out iamtypes.GetCallerIdentityResponse
unmarshalXML(t, readBody(t, resp), &out)
if out.Result.Arn != "arn:aws:iam::000000000000:user/frank" {
t.Fatalf("GetCallerIdentity user Arn = %q", out.Result.Arn)
}
if out.Result.Account != "000000000000" {
t.Fatalf("GetCallerIdentity user Account = %q", out.Result.Account)
}
}
// createTestSession creates a role with rolePolicyDocument as its sole
// inline policy and directly stores a session assuming it (bypassing
// AssumeRoleWithWebIdentity's OIDC token verification, which needs a live
// provider) carrying sessionPolicyDocument as its session policy.
func createTestSession(t *testing.T, server *IAMApiServer, roleName, rolePolicyDocument, sessionPolicyDocument string) iamtypes.Session {
t.Helper()
resp := doIAMAction(t, server, url.Values{
"Action": {"CreateRole"},
"RoleName": {roleName},
"AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateRole status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var createRoleOut iamtypes.CreateRoleResponse
unmarshalXML(t, readBody(t, resp), &createRoleOut)
role := createRoleOut.Result.Role
resp = doIAMActionPost(t, server, url.Values{
"Action": {"PutRolePolicy"},
"RoleName": {roleName},
"PolicyName": {"test-policy"},
"PolicyDocument": {rolePolicyDocument},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("PutRolePolicy status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
now := time.Now().UTC()
session := iamtypes.Session{
AccessKeyId: "ASIATEST" + roleName,
SecretAccessKey: "sessionsecret",
SessionToken: "sessiontoken",
RoleArn: role.Arn,
RoleName: roleName,
RoleID: role.RoleID,
RoleSessionName: "my-session",
CreateDate: now,
Expiration: now.Add(time.Hour),
Policy: sessionPolicyDocument,
}
if _, err := server.store.CreateSession(context.Background(), session); err != nil {
t.Fatalf("CreateSession: %v", err)
}
return session
}
func TestVerifyIAMPolicySessionUsesRolePolicy(t *testing.T) {
server := newIAMControllerTestServer(t)
if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"looked-up"}}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
session := createTestSession(t, server, "role-a",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "")
// UserName names an existing user (rather than the caller's own
// self-lookup form) so this specifically exercises the role's
// identity-based policy granting iam:GetUser, independent of GetUser's
// separate self-lookup-vs-named-lookup behavior.
resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken,
url.Values{"Action": {"GetUser"}, "UserName": {"looked-up"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetUser (role-granted) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
resp = doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken,
url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:sts::000000000000:assumed-role/role-a/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action")
}
func TestVerifyIAMPolicySessionPolicyCanOnlyNarrowRolePermissions(t *testing.T) {
server := newIAMControllerTestServer(t)
// The role broadly allows both actions; the session policy only allows
// one of them. Effective permissions = role ∩ session policy, so the
// narrower session policy is what actually governs.
if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"looked-up"}}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
session := createTestSession(t, server, "role-b",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["iam:GetUser","iam:CreateUser"],"Resource":"*"}]}`,
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`)
resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken,
url.Values{"Action": {"GetUser"}, "UserName": {"looked-up"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetUser (allowed by both) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
resp = doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken,
url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:sts::000000000000:assumed-role/role-b/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action")
}
func TestVerifyIAMPolicyResourceScopedAllowDeniesDifferentResource(t *testing.T) {
server := newIAMControllerTestServer(t)
for _, roleName := range []string{"role-x", "role-y"} {
resp := doIAMAction(t, server, url.Values{
"Action": {"CreateRole"},
"RoleName": {roleName},
"AssumeRolePolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"sts.amazonaws.com"},"Action":"sts:AssumeRole"}]}`},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateRole(%s) status = %d, body=%s", roleName, resp.StatusCode, readBody(t, resp))
}
}
accessKeyID, secret := createTestUserWithAccessKey(t, server, "gina",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-x"}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetRole"}, "RoleName": {"role-x"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetRole(role-x) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
// The policy only names role-x's ARN as Resource; a request for role-y
// must not be authorized by it, even though the Action matches.
resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetRole"}, "RoleName": {"role-y"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/gina is not authorized to perform: iam:GetRole because no identity-based policy allows the iam:GetRole action")
}
func TestVerifyIAMPolicySessionDeniedWhenStoredRoleIDNoLongerMatches(t *testing.T) {
server := newIAMControllerTestServer(t)
session := createTestSession(t, server, "role-mismatch",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`, "")
// Simulate the role having been deleted and recreated (getting a new
// RoleID) while this session, minted against the old role, is still
// unexpired: mutate the stored session's RoleID so it no longer matches
// the role currently on record.
stale := session
stale.RoleID = "AROASTALEROLEID"
if _, err := server.store.CreateSession(context.Background(), stale); err != nil {
t.Fatalf("CreateSession: %v", err)
}
resp := doSignedIAMActionAs(t, server, stale.AccessKeyId, stale.SecretAccessKey, stale.SessionToken,
url.Values{"Action": {"GetUser"}, "UserName": {""}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:sts::000000000000:assumed-role/role-mismatch/my-session is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
}
func TestVerifyIAMPolicySessionPolicyCannotWidenRolePermissions(t *testing.T) {
server := newIAMControllerTestServer(t)
// The role only allows GetUser; a broad session policy cannot grant
// CreateUser on top of that.
session := createTestSession(t, server, "role-c",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`,
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`)
resp := doSignedIAMActionAs(t, server, session.AccessKeyId, session.SecretAccessKey, session.SessionToken,
url.Values{"Action": {"CreateUser"}, "UserName": {"someone"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:sts::000000000000:assumed-role/role-c/my-session is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action")
}
// TestVerifyIAMPolicyUpdateUserDeniedWithoutPermissionOnTargetResource
// exercises the two-resource nature of a rename/path-move: AWS's UpdateUser
// requires permission on both the source object and the object being moved
// to (see the UpdateUser API's documented "Note" on required permissions).
// A policy scoped only to the source path must not authorize moving the
// user out of it.
func TestVerifyIAMPolicyUpdateUserDeniedWithoutPermissionOnTargetResource(t *testing.T) {
server := newIAMControllerTestServer(t)
if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}, "Path": {"/developers/"}}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
accessKeyID, secret := createTestUserWithAccessKey(t, server, "irene",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:UpdateUser","Resource":"arn:aws:iam::000000000000:user/developers/*"}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "",
url.Values{"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewPath": {"/admins/"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/irene is not authorized to perform: iam:UpdateUser because no identity-based policy allows the iam:UpdateUser action")
}
// TestVerifyIAMPolicyUpdateUserAllowedWithPermissionOnBothResources is the
// positive counterpart: once the policy names both the source and the
// target ARN, the same rename/path-move succeeds.
func TestVerifyIAMPolicyUpdateUserAllowedWithPermissionOnBothResources(t *testing.T) {
server := newIAMControllerTestServer(t)
if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"alice"}, "Path": {"/developers/"}}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
accessKeyID, secret := createTestUserWithAccessKey(t, server, "judy",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:UpdateUser","Resource":["arn:aws:iam::000000000000:user/developers/alice","arn:aws:iam::000000000000:user/admins/alice"]}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "",
url.Values{"Action": {"UpdateUser"}, "UserName": {"alice"}, "NewPath": {"/admins/"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("UpdateUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
// TestVerifyIAMPolicyGetUserSelfLookupResourceScoped guards against
// GetUser's omitted-UserName ("look up my own identity") form resolving to
// "*" instead of the caller's own ARN: with only a wildcard fallback, a
// Resource-scoped policy naming the caller's own ARN could never authorize
// their own self-lookup, forcing callers to be granted Resource:"*" just to
// use the feature.
func TestVerifyIAMPolicyGetUserSelfLookupResourceScoped(t *testing.T) {
server := newIAMControllerTestServer(t)
hankAccessKeyID, hankSecret := createTestUserWithAccessKey(t, server, "hank",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/hank"}]}`)
resp := doSignedIAMActionAs(t, server, hankAccessKeyID, hankSecret, "", url.Values{"Action": {"GetUser"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetUser(self) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
ivyAccessKeyID, ivySecret := createTestUserWithAccessKey(t, server, "ivy",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/hank"}]}`)
// A policy scoped to hank's ARN must not authorize ivy's self-lookup,
// which resolves against ivy's own ARN, not hank's.
resp = doSignedIAMActionAs(t, server, ivyAccessKeyID, ivySecret, "", url.Values{"Action": {"GetUser"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/ivy is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
}
// TestVerifyIAMPolicyGetAccessKeyLastUsedResourceScoped guards against
// GetAccessKeyLastUsed (which carries only AccessKeyId, never UserName)
// falling back to "*" instead of resolving the queried key's owning user:
// with only a wildcard fallback, a Resource-scoped policy could never
// authorize the action at all, and — once granted via Resource:"*" — could
// not stop a caller from looking up any other user's key.
func TestVerifyIAMPolicyGetAccessKeyLastUsedResourceScoped(t *testing.T) {
server := newIAMControllerTestServer(t)
ninaAccessKeyID, ninaSecret := createTestUserWithAccessKey(t, server, "nina",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetAccessKeyLastUsed","Resource":"arn:aws:iam::000000000000:user/nina"}]}`)
resp := doSignedIAMActionAs(t, server, ninaAccessKeyID, ninaSecret, "",
url.Values{"Action": {"GetAccessKeyLastUsed"}, "AccessKeyId": {ninaAccessKeyID}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetAccessKeyLastUsed(own key) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
oscarAccessKeyID, _ := createTestUserWithAccessKey(t, server, "oscar", "")
// nina's policy only names her own ARN as Resource; it must not
// authorize looking up oscar's access key, even though the Action
// matches — the resource-level check resolves AccessKeyId to its
// owning user, not a wildcard.
resp = doSignedIAMActionAs(t, server, ninaAccessKeyID, ninaSecret, "",
url.Values{"Action": {"GetAccessKeyLastUsed"}, "AccessKeyId": {oscarAccessKeyID}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/nina is not authorized to perform: iam:GetAccessKeyLastUsed because no identity-based policy allows the iam:GetAccessKeyLastUsed action")
}
func TestVerifyIAMPolicySecureTransportDenyAppliesToPlaintextRequest(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "paul",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Bool":{"aws:SecureTransport":"false"}}}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"paul"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/paul is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
}
// TestVerifyIAMPolicyConditionKeyMatchIsCaseInsensitive verifies that
// condition-key lookup treats key *names* (unlike their values) as
// case-insensitive, so a Deny written against this package's internal
// aws:SourceIp key using different casing is still evaluated, not silently
// treated as naming an absent key.
func TestVerifyIAMPolicyConditionKeyMatchIsCaseInsensitive(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "quinn",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Null":{"AWS:SOURCEIP":"false"}}}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"quinn"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/quinn is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
}
// TestVerifyIAMPolicyPermanentUserHasUserId verifies that aws:userid is
// populated for a long-term IAM user principal, not only for a session (AWS
// sets aws:username and aws:userid simultaneously). A Deny guarding on its
// absence must not fire for a permanent user.
func TestVerifyIAMPolicyPermanentUserHasUserId(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "ray",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"Null":{"aws:userid":"true"}}}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"ray"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetUser status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
// TestVerifyIAMPolicyDenyResourceSubstitutesUsernameVariable verifies that
// ${aws:username} in a statement's Resource is substituted before matching,
// so a Deny scoped to the caller's own resource via this variable matches
// the actual resource ARN instead of letting the broader Allow win.
func TestVerifyIAMPolicyDenyResourceSubstitutesUsernameVariable(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "sam",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"sam"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/sam is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
}
// TestVerifyIAMPolicyCreateUserDeniedByRequestTagCondition verifies that
// aws:RequestTag/<key> and aws:TagKeys are populated from a Create action's
// own Tags parameter, so a Deny guarding against a specific tag value blocks
// the tagged create.
func TestVerifyIAMPolicyCreateUserDeniedByRequestTagCondition(t *testing.T) {
server := newIAMControllerTestServer(t)
accessKeyID, secret := createTestUserWithAccessKey(t, server, "tina",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"StringEquals":{"aws:RequestTag/env":"prod"}}}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{
"Action": {"CreateUser"},
"UserName": {"newbie"},
"Tags.member.1.Key": {"env"},
"Tags.member.1.Value": {"prod"},
})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/tina is not authorized to perform: iam:CreateUser because no identity-based policy allows the iam:CreateUser action")
// A different tag value doesn't match the Deny's condition, so creation
// proceeds - confirming the Deny above was tag-value-specific, not a
// blanket denial of tagged creates.
resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{
"Action": {"CreateUser"},
"UserName": {"newbie2"},
"Tags.member.1.Key": {"env"},
"Tags.member.1.Value": {"dev"},
})
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser(env=dev) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
// TestVerifyIAMPolicyResourceTagConditionDeniesTaggedResource verifies that
// iam:ResourceTag/<key> (and, identically, the generic aws:ResourceTag/<key>)
// is hydrated from an existing target resource's own stored tags, so a Deny
// guarding on it overrides the broad Allow underneath it when the target
// carries that tag.
func TestVerifyIAMPolicyResourceTagConditionDeniesTaggedResource(t *testing.T) {
server := newIAMControllerTestServer(t)
// victor is the tagged target; his tag is set at creation time, via root.
if resp := doIAMAction(t, server, url.Values{
"Action": {"CreateUser"},
"UserName": {"victor"},
"Tags.member.1.Key": {"sensitive"},
"Tags.member.1.Value": {"true"},
}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser(victor) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
accessKeyID, secret := createTestUserWithAccessKey(t, server, "wendy",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"iam:ResourceTag/sensitive":"true"}}}]}`)
resp := doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"victor"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/wendy is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
// The generic aws:ResourceTag/<key> form is populated identically to the
// iam:ResourceTag/<key> one.
accessKeyID2, secret2 := createTestUserWithAccessKey(t, server, "xander",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:ResourceTag/sensitive":"true"}}}]}`)
resp = doSignedIAMActionAs(t, server, accessKeyID2, secret2, "", url.Values{"Action": {"GetUser"}, "UserName": {"victor"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/xander is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
// An untagged user isn't affected by either Deny.
if resp := doIAMAction(t, server, url.Values{"Action": {"CreateUser"}, "UserName": {"yolanda"}}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser(yolanda) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
resp = doSignedIAMActionAs(t, server, accessKeyID, secret, "", url.Values{"Action": {"GetUser"}, "UserName": {"yolanda"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetUser(yolanda, untagged) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}
// TestVerifyIAMPolicyPrincipalTagConditionAppliesToCaller verifies that
// aws:PrincipalTag/<key> is hydrated from the *calling* user's own stored
// tags, so a Deny guarding on it overrides the broad Allow underneath it
// when the caller carries that tag.
func TestVerifyIAMPolicyPrincipalTagConditionAppliesToCaller(t *testing.T) {
server := newIAMControllerTestServer(t)
if resp := doIAMAction(t, server, url.Values{
"Action": {"CreateUser"},
"UserName": {"zack"},
"Tags.member.1.Key": {"team"},
"Tags.member.1.Value": {"contractor"},
}); resp.StatusCode != http.StatusOK {
t.Fatalf("CreateUser(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
if resp := doIAMActionPost(t, server, url.Values{
"Action": {"PutUserPolicy"},
"UserName": {"zack"},
"PolicyName": {"test-policy"},
"PolicyDocument": {`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},` +
`{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:PrincipalTag/team":"contractor"}}}]}`},
}); resp.StatusCode != http.StatusOK {
t.Fatalf("PutUserPolicy(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
resp := doIAMAction(t, server, url.Values{"Action": {"CreateAccessKey"}, "UserName": {"zack"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("CreateAccessKey(zack) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
var out iamtypes.CreateAccessKeyResponse
unmarshalXML(t, readBody(t, resp), &out)
resp = doSignedIAMActionAs(t, server, out.Result.AccessKey.AccessKeyId, out.Result.AccessKey.SecretAccessKey, "", url.Values{"Action": {"GetUser"}, "UserName": {"zack"}})
requireIAMError(t, resp, http.StatusForbidden, "Sender", "AccessDenied",
"User: arn:aws:iam::000000000000:user/zack is not authorized to perform: iam:GetUser because no identity-based policy allows the iam:GetUser action")
// A caller without that tag isn't affected by the same policy shape.
untaggedAccessKeyID, untaggedSecret := createTestUserWithAccessKey(t, server, "abby",
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"},{"Effect":"Deny","Action":"iam:GetUser","Resource":"*","Condition":{"StringEquals":{"aws:PrincipalTag/team":"contractor"}}}]}`)
resp = doSignedIAMActionAs(t, server, untaggedAccessKeyID, untaggedSecret, "", url.Values{"Action": {"GetUser"}, "UserName": {"abby"}})
if resp.StatusCode != http.StatusOK {
t.Fatalf("GetUser(abby, untagged principal) status = %d, body=%s", resp.StatusCode, readBody(t, resp))
}
}