mirror of
https://github.com/versity/versitygw.git
synced 2026-09-24 08:54:47 +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.
177 lines
6.1 KiB
Go
177 lines
6.1 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 integration
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/aws"
|
|
"github.com/aws/aws-sdk-go-v2/service/iam"
|
|
"github.com/aws/aws-sdk-go-v2/service/sts"
|
|
"github.com/versity/versitygw/iamapi/iamerr"
|
|
)
|
|
|
|
// getCallerIdentity calls GetCallerIdentity through a real STS SDK client
|
|
// configured with access/secret.
|
|
func getCallerIdentity(cfg S3Conf, access, secret string) (*sts.GetCallerIdentityOutput, error) {
|
|
cfg.awsID = access
|
|
cfg.awsSecret = secret
|
|
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
|
defer cancel()
|
|
return cfg.GetSTSClient().GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
|
|
}
|
|
|
|
func IAMGetCallerIdentity_root_success(s *S3Conf) error {
|
|
testName := "IAMGetCallerIdentity_root_success"
|
|
return iamActionHandler(s, testName, func(_ *iam.Client) error {
|
|
out, err := getCallerIdentity(*s, s.awsID, s.awsSecret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
wantArn := "arn:aws:iam::000000000000:root"
|
|
if aws.ToString(out.Arn) != wantArn {
|
|
return fmt.Errorf("expected Arn %q, instead got %q", wantArn, aws.ToString(out.Arn))
|
|
}
|
|
if aws.ToString(out.UserId) != "000000000000" {
|
|
return fmt.Errorf("expected UserId %q, instead got %q", "000000000000", aws.ToString(out.UserId))
|
|
}
|
|
if aws.ToString(out.Account) != "000000000000" {
|
|
return fmt.Errorf("expected Account %q, instead got %q", "000000000000", aws.ToString(out.Account))
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func IAMGetCallerIdentity_user_success(s *S3Conf) error {
|
|
testName := "IAMGetCallerIdentity_user_success"
|
|
return iamActionHandler(s, testName, func(client *iam.Client) (err error) {
|
|
userName := newIAMUserName()
|
|
createOut, err := createIAMUser(client, &iam.CreateUserInput{UserName: &userName})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if delErr := deleteIAMUserAndAccessKeys(client, userName); delErr != nil {
|
|
err = fmt.Errorf("%w (also: delete user: %v)", err, delErr)
|
|
}
|
|
}()
|
|
userArn := aws.ToString(createOut.User.Arn)
|
|
userID := aws.ToString(createOut.User.UserId)
|
|
|
|
keyOut, err := createIAMAccessKey(client, &iam.CreateAccessKeyInput{UserName: &userName})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
out, err := getCallerIdentity(*s, aws.ToString(keyOut.AccessKey.AccessKeyId), aws.ToString(keyOut.AccessKey.SecretAccessKey))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if aws.ToString(out.Arn) != userArn {
|
|
return fmt.Errorf("expected Arn %q, instead got %q", userArn, aws.ToString(out.Arn))
|
|
}
|
|
if aws.ToString(out.UserId) != userID {
|
|
return fmt.Errorf("expected UserId %q, instead got %q", userID, aws.ToString(out.UserId))
|
|
}
|
|
if aws.ToString(out.Account) != "000000000000" {
|
|
return fmt.Errorf("expected Account %q, instead got %q", "000000000000", aws.ToString(out.Account))
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func IAMGetCallerIdentity_unknown_access_key(s *S3Conf) error {
|
|
testName := "IAMGetCallerIdentity_unknown_access_key"
|
|
return iamActionHandler(s, testName, func(_ *iam.Client) error {
|
|
_, err := getCallerIdentity(*s, "AKIAuNKNOWNACCESSKEYID", "does-not-matter")
|
|
return checkIAMApiErr(err, iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID))
|
|
})
|
|
}
|
|
|
|
func IAMGetCallerIdentity_no_auth(s *S3Conf) error {
|
|
testName := "IAMGetCallerIdentity_no_auth"
|
|
runF(testName)
|
|
|
|
body := []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2011-06-15"}}.Encode())
|
|
req, err := http.NewRequest(http.MethodPost, s.endpoint+"/", bytes.NewReader(body))
|
|
if err != nil {
|
|
failF("%v: %v", testName, err)
|
|
return fmt.Errorf("%v: %w", testName, err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
failF("%v: %v", testName, err)
|
|
return fmt.Errorf("%v: %w", testName, err)
|
|
}
|
|
if err := checkSTSApiErr(resp, iamerr.GetAPIError(iamerr.ErrMissingAuthenticationToken)); err != nil {
|
|
failF("%v: %v", testName, err)
|
|
return fmt.Errorf("%v: %w", testName, err)
|
|
}
|
|
|
|
passF(testName)
|
|
return nil
|
|
}
|
|
|
|
func IAMGetCallerIdentity_wrong_version_is_invalid_action(s *S3Conf) error {
|
|
testName := "IAMGetCallerIdentity_wrong_version_is_invalid_action"
|
|
cfg := &authConfig{
|
|
testName: testName,
|
|
method: http.MethodPost,
|
|
service: "sts",
|
|
region: iamAuthRegion,
|
|
body: []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2010-05-08"}}.Encode()),
|
|
date: time.Now().UTC(),
|
|
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
|
|
}
|
|
return authHandler(s, cfg, func(req *http.Request) error {
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkSTSApiErr(resp, iamerr.InvalidAction("GetCallerIdentity", "2010-05-08"))
|
|
})
|
|
}
|
|
|
|
// IAMGetCallerIdentity_incorrect_service_scope confirms the shared sigv4
|
|
// auth pipeline reports the STS-specific service name ("sts", not "iam")
|
|
// when GetCallerIdentity is signed with a Credential scoped to the wrong
|
|
// service.
|
|
func IAMGetCallerIdentity_incorrect_service_scope(s *S3Conf) error {
|
|
testName := "IAMGetCallerIdentity_incorrect_service_scope"
|
|
cfg := &authConfig{
|
|
testName: testName,
|
|
method: http.MethodPost,
|
|
service: "iam", // wrong: GetCallerIdentity expects "sts"
|
|
region: iamAuthRegion,
|
|
body: []byte(url.Values{"Action": {"GetCallerIdentity"}, "Version": {"2011-06-15"}}.Encode()),
|
|
date: time.Now().UTC(),
|
|
headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"},
|
|
}
|
|
return authHandler(s, cfg, func(req *http.Request) error {
|
|
resp, err := s.httpClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return checkSTSApiErr(resp, iamerr.IncorrectServiceScope("sts"))
|
|
})
|
|
}
|