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

410 lines
17 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 (
"encoding/json"
"fmt"
"strings"
"github.com/versity/versitygw/iamapi/iamerr"
)
// trustPrincipalKeys are the only keys IAM accepts inside a trust policy
// statement's Principal object. CanonicalUser is deliberately not accepted
// here (see errTrustInvalidPrincipalKey) since it identifies an S3 canonical
// user id which is the legacy s3 user identifier and is not planned to support
var trustPrincipalKeys = map[string]bool{
"AWS": true,
"Service": true,
"Federated": true,
}
const cognitoFederatedProvider = "cognito-identity.amazonaws.com"
// azureSentinelProviderURL is Microsoft Sentinel's registered OIDC provider
// Url (scheme stripped) — a shared provider like the ones in
// sharedOIDCProviderRequiredClaim, but its required identity-provider
// control is not a claim on the token at all: AWS requires the trust
// statement's Condition to scope sts:RoleSessionName (a global STS
// condition key, see policy.go's requestConditionContext and
// webidentity.go's WebIdentityContext.RoleSessionName) instead of a
// "<url>:<claim>" key, so it's handled as its own case in
// validateSharedProviderTenancy rather than fitting the shared map.
const azureSentinelProviderURL = "sts.windows.net/33e01921-4d64-4f8c-a055-5bdaffd5e33d"
// azureSentinelRequiredKey is the condition key azureSentinelProviderURL's
// trust statements must scope.
const azureSentinelRequiredKey = "sts:RoleSessionName"
// oidcProviderArnInfix is the fixed separator between the account segment
// and the provider Url in an OIDC provider ARN, matching
// iamutil.BuildOIDCProviderArn's "arn:aws:iam::<account>:oidc-provider/<url>"
// shape (this package can't import iamutil to reuse its ARN parser: iamutil
// already imports policy).
const oidcProviderArnInfix = ":oidc-provider/"
// sharedOIDCProviderRequiredClaim maps a known shared-audience OIDC issuer's
// hostname (a registered provider's Url, scheme already stripped) to the
// claim suffix a trust statement federating it must scope with a Condition.
// AWS added this requirement for popular CI/CD OIDC issuers because their
// audience is commonly left at a single shared, non-secret default (e.g.
// "sts.amazonaws.com"): unlike a private or self-hosted provider, whose Url
// alone is already tenant-specific, the audience here doesn't distinguish
// one organization's/repo's token from any other's identically-configured
// one, so the trust policy must scope its tenancy claim itself.
//
// Sourced from AWS's own published table of shared OIDC providers and their
// required claims:
// https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_oidc_secure-by-default.html
// Amazon Cognito and Microsoft Sentinel are handled as
// their own special cases in validateSharedProviderTenancy rather than this
// map: Cognito's federated-principal value isn't an OIDC provider ARN at
// all, and Sentinel's required control is a global STS key, not a claim.
// IBM Turbonomic SaaS is a documented shared provider too, but AWS's own
// table declines to give it a fixed Url ("periodically updates their OIDC
// Issuer URL with new versions of the platform") — there is no stable
// hostname to key a map entry on, so it's deliberately omitted here.
var sharedOIDCProviderRequiredClaim = map[string]string{
"token.actions.githubusercontent.com": "sub", // GitHub Actions
"vstoken.actions.githubusercontent.com": "sub", // GitHub vstoken
"oidc-configuration.audit-log.githubusercontent.com": "sub", // GitHub audit log streaming
"gitlab.com": "sub", // GitLab.com (SaaS)
"agent.buildkite.com": "sub", // Buildkite
"app.terraform.io": "sub", // HCP Terraform / Terraform Cloud
"oidc.codefresh.io": "sub", // Codefresh SaaS
"studio.datachain.ai/api": "sub", // DVC Studio
"scalr.io": "sub", // Scalr
"tokens.cloud.shisho.dev": "sub", // Shisho Cloud
"proidc.upbound.io": "sub", // Upbound
"api.pulumi.com/oidc": "aud", // Pulumi Cloud
"sandboxes.cloud": "aud", // sandboxes.cloud
"oidc.vercel.com": "aud", // Vercel global endpoint
}
// validServicePrincipals are the only Service principal values the gateway
// recognizes. Real AWS validates Service against its live catalog of
// ~300+ service principals; the gateway only exposes S3, STS, and IAM
// APIs, so those are the only services that could plausibly ever assume a
// role here.
var validServicePrincipals = map[string]bool{
"s3.amazonaws.com": true,
"sts.amazonaws.com": true,
"iam.amazonaws.com": true,
}
// MaxTrustPolicyBytes is IAM's ACLSizePerRole quota: a role has exactly one
// trust policy, so unlike inline identity policies (which sum across all of
// a user's/role's named policies) this is a plain length check against the
// single AssumeRolePolicyDocument/PolicyDocument value.
const MaxTrustPolicyBytes = 2048
var (
errTrustInvalidJSON = iamerr.MalformedPolicyDocument("This policy contains invalid Json")
errTrustInvalidVersion = iamerr.MalformedPolicyDocument("The policy must contain a valid version string")
errTrustEmptyStatement = iamerr.MalformedPolicyDocument("Could not parse the policy: Statement is empty!")
errTrustDuplicateSid = iamerr.MalformedPolicyDocument("The Statement Ids in the policy are not unique")
errTrustMissingEffect = iamerr.MalformedPolicyDocument("Missing required field Effect")
errTrustMissingPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal")
errTrustEmptyPrincipal = iamerr.MalformedPolicyDocument("Missing required field Principal cannot be empty!")
errTrustPrincipalNotObject = iamerr.MalformedPolicyDocument("Principal must be a JSON object.")
errTrustAllowNotPrincipal = iamerr.MalformedPolicyDocument("Allow with NotPrincipal is not allowed.")
errTrustNotPrincipalForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain NotPrincipal field.")
errTrustMissingAction = iamerr.MalformedPolicyDocument("Missing required field Action")
errTrustNonSTSAction = iamerr.MalformedPolicyDocument("AssumeRole policy may only specify STS AssumeRole actions.")
errTrustResourceForbidden = iamerr.MalformedPolicyDocument("Has prohibited field Resource")
errTrustNotResourceForbidden = iamerr.MalformedPolicyDocument("AssumeRole policy must not contain resources.")
errTrustCognitoConditionRequired = iamerr.MalformedPolicyDocument("A condition block must be present for the Cognito provider")
errTrustSyntax = iamerr.MalformedPolicyDocument("Syntax error in policy.")
)
// ParseTrust parses raw as an IAM role trust-policy document (the value of
// AssumeRolePolicyDocument / UpdateAssumeRolePolicy's PolicyDocument) and
// checks it against trust-policy grammar: Principal is required (the
// opposite of an identity policy), Action/NotAction values must carry the
// "sts:" prefix, and Resource/NotResource are forbidden.
func ParseTrust(raw string) error {
var doc Document
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return errTrustInvalidJSON
}
return doc.ValidateTrust()
}
// ValidateTrust checks d against IAM's trust-policy document grammar: a
// valid Version if present, a non-empty Statement (single object or
// array), document-wide unique Sids, and per statement, the rules enforced
// by Statement.ValidateTrust.
func (d Document) ValidateTrust() error {
if d.Version != "" && d.Version != Version2008 && d.Version != Version2012 {
return errTrustInvalidVersion
}
if len(d.Statement) == 0 {
return errTrustEmptyStatement
}
seenSids := make(map[string]struct{}, len(d.Statement))
for _, stmt := range d.Statement {
if err := stmt.ValidateTrust(); err != nil {
return err
}
if stmt.Sid != "" {
if _, ok := seenSids[stmt.Sid]; ok {
return errTrustDuplicateSid
}
seenSids[stmt.Sid] = struct{}{}
}
}
return nil
}
// ValidateTrust checks s against IAM trust-policy statement grammar: a
// valid Effect, a required Principal (never NotPrincipal), an Action or
// NotAction with only "sts:"-prefixed values, no Resource/NotResource, and -
// if present - a Condition block whose operators are all recognized (see
// conditionShapeValid, shared with the identity-policy side; condition
// *keys* and operand *values* are deliberately not validated here, matching
// AWS behavior).
func (s Statement) ValidateTrust() error {
switch s.Effect {
case "Allow", "Deny":
case "":
return errTrustMissingEffect
default:
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid effect: %s", s.Effect))
}
if len(s.NotPrincipal) > 0 {
if s.Effect == "Allow" {
return errTrustAllowNotPrincipal
}
return errTrustNotPrincipalForbidden
}
if err := s.validateTrustPrincipal(); err != nil {
return err
}
if !conditionShapeValid(s.Condition) {
return errTrustSyntax
}
if len(s.Action) > 0 && len(s.NotAction) > 0 {
// Same exclusivity identity policies already enforce (Statement.Validate):
// AWS documents Action and NotAction as mutually exclusive within a
// single statement, and real policy simulation rejects a document
// combining them with InvalidInput - a trust statement isn't
// exempt just because its evaluator (statementCoversAction) happens
// to have well-defined single-field behavior.
return errTrustSyntax
}
if len(s.Action) == 0 && len(s.NotAction) == 0 {
return errTrustMissingAction
}
for _, action := range s.Action {
if !strings.HasPrefix(action, "sts:") {
return errTrustNonSTSAction
}
}
for _, action := range s.NotAction {
if !strings.HasPrefix(action, "sts:") {
return errTrustNonSTSAction
}
}
if len(s.Resource) > 0 {
return errTrustResourceForbidden
}
if len(s.NotResource) > 0 {
return errTrustNotResourceForbidden
}
return nil
}
// validateTrustPrincipal checks s.Principal against trust-policy grammar:
// required, a JSON object (not a bare string or array), non-empty, with
// only AWS/Service/Federated keys, plus the Cognito-specific Condition
// requirement. Real AWS additionally validates that AWS/Service values
// resolve to real accounts/services against its live catalog; the gateway
// has no such catalog for AWS account/ARN values and validates those shape
// only. Service values are the exception — they're checked against
// validServicePrincipals, since the gateway only exposes S3, STS, and IAM
// APIs and so only those services could ever assume a role here.
func (s Statement) validateTrustPrincipal() error {
raw := s.Principal
if len(raw) == 0 {
return errTrustMissingPrincipal
}
var principal map[string]StringOrSlice
if err := json.Unmarshal(raw, &principal); err != nil {
var asString string
if err := json.Unmarshal(raw, &asString); err == nil {
return errTrustPrincipalNotObject
}
return errTrustSyntax
}
if len(principal) == 0 {
return errTrustEmptyPrincipal
}
for key, values := range principal {
if !trustPrincipalKeys[key] {
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q", key))
}
if key == "Service" {
for _, v := range values {
if !validServicePrincipals[v] {
return iamerr.MalformedPolicyDocument(fmt.Sprintf("Invalid principal in policy: %q:%q", strings.ToUpper(key), v))
}
}
}
}
return validateSharedProviderTenancy(s, principal["Federated"])
}
// validateSharedProviderTenancy rejects a trust statement that federates a
// known shared-audience provider (Cognito Identity Pools, or a registered
// OIDC provider whose Url is in sharedOIDCProviderRequiredClaim) without a
// Condition that scopes the provider's tenant-identifying claim to a
// specific, non-wildcard value — see sharedOIDCProviderRequiredClaim's
// doc comment for why the audience alone isn't enough for these providers.
// A Federated value that doesn't match either shape (a private/self-hosted
// OIDC provider, or a value too malformed to resolve to a real provider at
// all) imposes no extra requirement here; those are unaffected by this
// check.
func validateSharedProviderTenancy(s Statement, federated []string) error {
for _, v := range federated {
if v == cognitoFederatedProvider {
if !conditionScopesClaim(s.Condition, cognitoFederatedProvider+":aud") {
return errTrustCognitoConditionRequired
}
continue
}
url, ok := oidcProviderURLFromFederatedArn(v)
if !ok {
continue
}
if url == azureSentinelProviderURL {
if !conditionScopesClaim(s.Condition, azureSentinelRequiredKey) {
return iamerr.MalformedPolicyDocument(fmt.Sprintf(
"The trust policy trusts shared OpenID Connect provider %q without a Condition scoping %q to your own tenant.", url, azureSentinelRequiredKey))
}
continue
}
claim, known := sharedOIDCProviderRequiredClaim[url]
if !known {
continue
}
key := url + ":" + claim
if !conditionScopesClaim(s.Condition, key) {
return iamerr.MalformedPolicyDocument(fmt.Sprintf(
"The trust policy trusts shared OpenID Connect provider %q without a Condition scoping %q to your own tenant.", url, key))
}
}
return nil
}
// oidcProviderURLFromFederatedArn extracts the provider Url from a Federated
// principal ARN shaped like "arn:aws:iam::<account>:oidc-provider/<url>"
// (see iamutil.BuildOIDCProviderArn), reporting ok=false for any value not
// shaped like an OIDC provider ARN at all — a bare federation identifier
// (e.g. "cognito-identity.amazonaws.com") or a malformed value, both handled
// elsewhere (this is deliberately a lightweight shape check, not full ARN
// validation: an actually-malformed ARN is caught later, when the runtime
// AssumeRoleWithWebIdentity path resolves it against real registered
// providers and finds nothing).
func oidcProviderURLFromFederatedArn(value string) (string, bool) {
_, url, ok := strings.Cut(value, oidcProviderArnInfix)
if !ok || url == "" {
return "", false
}
return url, true
}
// conditionScopesClaim reports whether raw (a statement's Condition block)
// contains a positive String-family comparison (StringEquals, StringLike, or
// StringEqualsIgnoreCase — optionally ForAllValues/ForAnyValue-qualified;
// their Not-negated counterparts don't count, since excluding one value
// doesn't scope to a tenant) against key (matched case-insensitively, same
// as identity-policy condition keys) with at least one value that actually
// scopes the claim. For StringLike specifically — the one operator here
// where '*'/'?' are wildcards, not literal characters — a value consisting
// entirely of wildcard characters (e.g. "*", "**", "?", "*?*") is rejected
// even though it's non-empty: AWS documents that a shared provider's
// tenancy claim "must not consist only of wildcard characters", since
// a pattern with no literal character left after stripping '*'/'?' matches
// every possible value just as completely as a bare "*" does. StringEquals
// and StringEqualsIgnoreCase don't treat '*'/'?' as wildcards at all, so
// only the plain "empty or exactly '*'" check applies to them. A block that
// fails to parse reports false, same as an absent one —
// conditionShapeValid/evaluateCondition are responsible for rejecting or
// fail-closing a block this can't understand; this check only ever adds a
// stricter write-time requirement on top of that.
func conditionScopesClaim(raw json.RawMessage, key string) bool {
if len(raw) == 0 {
return false
}
var block map[string]map[string]ConditionValues
if err := json.Unmarshal(raw, &block); err != nil {
return false
}
for operator, kvs := range block {
op, ok := parseOperatorName(operator)
if !ok {
continue
}
switch op.base {
case "StringEquals", "StringLike", "StringEqualsIgnoreCase":
default:
continue
}
for k, values := range kvs {
if !strings.EqualFold(k, key) {
continue
}
for _, v := range values {
if v == "" || v == "*" {
continue
}
if op.base == "StringLike" && !hasNonWildcardCharacter(v) {
continue
}
return true
}
}
}
return false
}
// hasNonWildcardCharacter reports whether v contains at least one character
// other than the StringLike wildcards '*' (any run of characters) and '?'
// (any single character) — i.e. whether it scopes to anything narrower than
// "every possible value".
func hasNonWildcardCharacter(v string) bool {
for _, r := range v {
if r != '*' && r != '?' {
return true
}
}
return false
}