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.
603 lines
32 KiB
Go
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))
|
|
}
|
|
}
|