mirror of
https://github.com/versity/versitygw.git
synced 2026-08-18 05:06:27 +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.
269 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|