Files
versitygw/iamapi/policy/identity_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

269 lines
15 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 policy
import (
"testing"
"github.com/versity/versitygw/iamapi/types"
)
func policyEntries(documents ...string) []types.PolicyEntry {
entries := make([]types.PolicyEntry, len(documents))
for i, doc := range documents {
entries[i] = types.PolicyEntry{PolicyDocument: doc}
}
return entries
}
func TestEvaluateIdentityPolicies(t *testing.T) {
tests := []struct {
name string
documents []types.PolicyEntry
reqCtx RequestContext
want bool
}{
{
name: "no documents denies by default",
documents: nil,
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
name: "no matching statement denies by default",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
name: "matching allow statement allows",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: true,
},
{
name: "wildcard action allows",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: true,
},
{
name: "explicit deny overrides an allow in another document",
documents: policyEntries(
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`,
`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`,
),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
name: "explicit deny overrides an allow in the same document",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
name: "action match is case-insensitive",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"IAM:CREATEUSER","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: true,
},
{
// A malformed document might have contained a Deny we can no
// longer see, so the whole evaluation denies rather than
// silently proceeding as if the document wasn't there.
name: "malformed document denies the whole evaluation, even with a valid Allow elsewhere",
documents: policyEntries(`not json`, `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
name: "malformed document denies the whole evaluation regardless of document order",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"}]}`, `not json`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
// A Deny guarded by a Condition operator this package doesn't
// recognize (simulating a legacy document stored before
// write-time validation existed - Parse() would reject this
// today) must not be silently skipped in favor of the Allow
// underneath it.
name: "unrecognized operator on a Deny denies, does not let an Allow underneath it win",
documents: policyEntries(
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`,
),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}},
want: false,
},
{
// Fail-closed on a condition-evaluation error isn't scoped to
// Deny statements specifically - it's a deny-all result for the
// whole evaluation.
name: "unrecognized operator on an Allow-only statement still denies (fail-closed is not Deny-specific)",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}},
want: false,
},
{
// A document containing any statement Validate() would
// reject (here, an unrelated statement's unrecognized condition
// operator) is invalid as a whole and denies every evaluation
// against it, even a request the offending statement doesn't
// itself cover - assigning no meaning to a document AWS itself
// would reject at write time is safer than evaluating the parts
// of it that happen to look fine.
name: "unrecognized operator in an unrelated statement invalidates the whole document",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:DeleteUser","Resource":"*","Condition":{"FooBarOperator":{"aws:username":"alice"}}}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
name: "Null operator end-to-end: denies presence of aws:username",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}},
want: false,
},
{
name: "Null operator end-to-end: allows when aws:username is absent (session, not user)",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:CreateUser","Resource":"*"},{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*","Condition":{"Null":{"aws:username":"false"}}}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*", Condition: map[string][]string{"aws:userid": {"role-id:session"}}},
want: true,
},
{
name: "NotAction denies coverage for the excluded action",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
want: false,
},
{
name: "NotAction allows actions outside the exclusion",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","NotAction":"iam:CreateUser","Resource":"*"}]}`),
reqCtx: RequestContext{Action: "iam:DeleteUser", Resource: "*"},
want: true,
},
{
name: "resource-scoped allow matches the named resource",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"},
want: true,
},
{
name: "resource-scoped allow does not cover a different resource",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"},
want: false,
},
{
name: "resource match is case-sensitive",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/Role-A"}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"},
want: false,
},
{
name: "resource-scoped deny only affects the named resource",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"},
want: true,
},
{
name: "resource-scoped deny denies the named resource",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"arn:aws:iam::000000000000:role/role-a"}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"},
want: false,
},
{
name: "NotResource excludes the named resource",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-a"},
want: false,
},
{
name: "NotResource allows resources outside the exclusion",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","NotResource":"arn:aws:iam::000000000000:role/role-a"}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "arn:aws:iam::000000000000:role/role-b"},
want: true,
},
{
// ${aws:username} in Resource must resolve to the requesting
// principal's own name before matching, not be compared as a
// literal string.
name: "policy variable in Resource matches the caller's own resource",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`),
reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}},
want: true,
},
{
name: "policy variable in Resource does not match a different principal's resource",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`),
reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/bob", Condition: map[string][]string{"aws:username": {"alice"}}},
want: false,
},
{
name: "unresolvable policy variable in Resource is left literal and so does not match a real ARN",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`),
reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice"},
want: false,
},
{
// AWS requires Version 2012-10-17 to use policy variables at
// all - the same statement under 2008-10-17 must treat
// "${aws:username}" as literal text, not expand it.
name: "policy variable in Resource is not substituted under version 2008-10-17",
documents: policyEntries(`{"Version":"2008-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`),
reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}},
want: false,
},
{
name: "policy variable in Resource is not substituted with no Version at all",
documents: policyEntries(`{"Statement":[{"Effect":"Allow","Action":"iam:GetUser","Resource":"arn:aws:iam::000000000000:user/${aws:username}"}]}`),
reqCtx: RequestContext{Action: "iam:GetUser", Resource: "arn:aws:iam::000000000000:user/alice", Condition: map[string][]string{"aws:username": {"alice"}}},
want: false,
},
{
name: "condition must match",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"alice"}}},
want: true,
},
{
name: "condition mismatch denies by default",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*","Condition":{"StringEquals":{"aws:username":"alice"}}}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:username": {"bob"}}},
want: false,
},
{
name: "deny condition must also match to take effect",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"203.0.113.5"}}},
want: true,
},
{
name: "deny condition matching denies",
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:GetRole","Resource":"*"},{"Effect":"Deny","Action":"iam:GetRole","Resource":"*","Condition":{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}}]}`),
reqCtx: RequestContext{Action: "iam:GetRole", Resource: "*", Condition: map[string][]string{"aws:SourceIp": {"10.1.2.3"}}},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := EvaluateIdentityPolicies(tt.documents, tt.reqCtx); got != tt.want {
t.Fatalf("EvaluateIdentityPolicies() = %v, want %v", got, tt.want)
}
})
}
}