mirror of
https://github.com/versity/versitygw.git
synced 2026-09-21 07:24:29 +00:00
feat: integrate standalone IAM service with S3 gateway for identity-based policy enforcement
Fixes #1327 Fixes #1567 Closes #2264 Wires the S3 gateway up to the standalone IAM service so identity policies, not just bucket policies and ACLs, are enforced on the S3 data plane. The gateway authenticates SigV4 requests by calling new private derive-signing-key and resolve-identity endpoints on the IAM service instead of holding secrets itself, and evaluates identity policy through the same PolicyEvaluator path added to auth.VerifyAccess, combined with the bucket policy using explicit-deny-wins precedence. The private endpoints are served over their own mTLS listener (new iamapi/private package, genmtlscerts.sh to generate test material, and client-cert support in internal/netutil), separate from the public IAM API. As part of this the vendored aws/signer/v4 package is deleted and replaced by a pure-Go SigV4 implementation in internal/sigv4auth, which now reads canonical request data directly off the fiber.Ctx instead of reconstructing an http.Request, and is shared by both the S3 request-signing verification and the new private-endpoint signing. DeleteObjects moves from an all-or-nothing authorization check to true partial success: VerifyObjectsAccess evaluates every object in a batch independently against both the identity policy and any object lock, so a denial or a locked object only removes that key from the batch instead of failing the whole request. It also batches the identity-policy round trip and the bucket-policy fetch once per request rather than once per object, and separates plain deletes from versioned ones since a versioned delete needs s3:DeleteObjectVersion rather than s3:DeleteObject. Object lock handling got a few correctness fixes alongside this: a bypass is now modeled as BypassNone/BypassRequested/BypassOverwrite rather than a single bool, because root's blanket ability to override a GOVERNANCE retention should only apply when the client actually asked to bypass it (DeleteObject/DeleteObjects/PutObjectRetention), not when the gateway is silently replacing a locked object via an overwrite, which needs the permission from everyone including root. Retention changes are now correctly classified as an extension (allowed under plain s3:PutObjectRetention) versus a weakening (date or mode change, which needs the bypass permission), and a COMPLIANCE lock can never be weakened by anyone regardless of permissions, matching AWS. Separately, VerifyObjectCopyAccess had a readonly-mode gap: it returned early for root/admin before ever calling VerifyAccess, so the readonly check inside VerifyAccess never ran for them on CopyObject; access checks are now ordered so the readonly gate always applies before any root/admin bypass, for copy as well as every other write path. Bucket policies also gained Condition block support, via a new shared internal/condition package moved out of the IAM policy package since both bucket and identity policies share the same evaluation semantics. It implements the full AWS operator set — String{Equals,NotEquals,EqualsIgnoreCase,NotEqualsIgnoreCase,Like,NotLike}, Numeric{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Date{Equals,NotEquals,LessThan,LessThanEquals,GreaterThan,GreaterThanEquals}, Bool, BinaryEquals, Arn{Equals,Like,NotEquals,NotLike}, IpAddress/NotIpAddress, and Null — along with the ForAllValues/ForAnyValue set qualifiers and the IfExists modifier. A new requestConditionContext builds the per-request keys a bucket policy's Condition block can reference — aws:SourceIp, aws:SecureTransport, aws:CurrentTime, aws:EpochTime, aws:UserAgent, aws:Referer, s3:prefix, s3:delimiter, s3:max-keys, s3:x-amz-acl, s3:VersionId — following AWS's own per-action rules for which keys a given S3 operation actually populates. Identity-derived keys such as aws:PrincipalArn and aws:username are deliberately left unwired here, since the gateway has no way to know them; the standalone IAM service fills those in itself when it evaluates an identity policy. Also added new integration test suites for S3-side IAM: s3_iam_access_control.go and s3_iam_session_access_control.go cover identity-policy enforcement and session-credential requests against real S3 operations, alongside expanded OIDC/web-identity coverage and a new runoidctests.sh runner wired into the OIDC GitHub Actions workflow.
This commit is contained in:
@@ -31,7 +31,6 @@ import (
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
awsv4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
vgwv4 "github.com/versity/versitygw/aws/signer/v4"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
||||
"github.com/versity/versitygw/internal/sigv4auth"
|
||||
@@ -339,12 +338,11 @@ func TestVerifyIAMAuthRejectsQueryWrongCredentialRegion(t *testing.T) {
|
||||
|
||||
// TestVerifyIAMAuthRejectsExpiredQueryRequest confirms a presigned IAM
|
||||
// request signed too long ago is rejected by the same fixed ±15-minute
|
||||
// freshness window (ValidateDateAt) header auth uses — confirmed live
|
||||
// (niksis02 profile): real IAM's query-auth ignores X-Amz-Expires entirely
|
||||
// (see TestVerifyIAMAuthQueryIgnoresXAmzExpires) and instead rejects a
|
||||
// stale signing time with SignatureDoesNotMatch: "Signature expired: ...
|
||||
// is now earlier than ... (... - 15 min.)" — byte-for-byte what this
|
||||
// codebase's own SignatureDoesNotMatchExpired already produces.
|
||||
// freshness window (ValidateDateAt) header auth uses: real IAM's
|
||||
// query-auth ignores X-Amz-Expires entirely and instead rejects a stale
|
||||
// signing time with SignatureDoesNotMatch: "Signature expired: ... is now
|
||||
// earlier than ... (... - 15 min.)" — byte-for-byte what this codebase's
|
||||
// own SignatureDoesNotMatchExpired already produces.
|
||||
func TestVerifyIAMAuthRejectsExpiredQueryRequest(t *testing.T) {
|
||||
app := newIAMAuthTestApp(t)
|
||||
signedTwoHoursAgo := time.Now().UTC().Add(-2 * time.Hour)
|
||||
@@ -373,10 +371,10 @@ func TestVerifyIAMAuthRejectsExpiredQueryRequest(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestVerifyIAMAuthQueryIgnoresXAmzExpires confirms IAM/STS query-auth
|
||||
// neither requires nor validates X-Amz-Expires, unlike S3's presigned URLs
|
||||
// — confirmed live (niksis02 profile) that real IAM's ListUsers accepts a
|
||||
// presigned request with X-Amz-Expires omitted, non-numeric, negative, or
|
||||
// far beyond S3's 604800-second maximum, every time.
|
||||
// neither requires nor validates X-Amz-Expires, unlike S3's presigned URLs:
|
||||
// real IAM's ListUsers accepts a presigned request with X-Amz-Expires
|
||||
// omitted, non-numeric, negative, or far beyond S3's 604800-second
|
||||
// maximum, every time.
|
||||
func TestVerifyIAMAuthQueryIgnoresXAmzExpires(t *testing.T) {
|
||||
for _, expires := range []string{"", "abc", "-5", "9999999"} {
|
||||
t.Run(expires, func(t *testing.T) {
|
||||
@@ -418,15 +416,21 @@ func TestVerifyIAMAuthRejectsSessionTokenHeaderNotSigned(t *testing.T) {
|
||||
hash := sha256.Sum256(body)
|
||||
payloadHash := hex.EncodeToString(hash[:])
|
||||
|
||||
signer := vgwv4.NewSigner()
|
||||
// Sign with only "host" listed — the security-token header is present
|
||||
// on the wire but deliberately excluded from SignedHeaders, simulating
|
||||
// a client (or tampering party) that never binds it to the signature.
|
||||
if _, err := signer.SignHTTP(context.Background(),
|
||||
aws.Credentials{AccessKeyID: session.AccessKeyId, SecretAccessKey: session.SecretAccessKey},
|
||||
req, payloadHash, "iam", iammiddleware.SigningRegion, time.Now().UTC(), []string{"host"}); err != nil {
|
||||
t.Fatalf("sign request: %v", err)
|
||||
}
|
||||
signingTime := time.Now().UTC()
|
||||
yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD)
|
||||
derivedKey := sigv4auth.DeriveKey(session.SecretAccessKey, yyyymmdd, iammiddleware.SigningRegion, "iam")
|
||||
in := sigv4auth.SigningInputFromRequest(req)
|
||||
in.AccessKeyID = session.AccessKeyId
|
||||
in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, iammiddleware.SigningRegion, "iam")
|
||||
in.SignedHdrs = []string{"host"}
|
||||
in.PayloadHash = payloadHash
|
||||
in.SigningTime = signingTime
|
||||
result := sigv4auth.BuildAndSign(derivedKey, in)
|
||||
req.Header.Set("X-Amz-Date", result.AmzDate)
|
||||
req.Header.Set("Authorization", result.AuthorizationHeader)
|
||||
|
||||
resp, err := server.app.Test(req)
|
||||
if err != nil {
|
||||
@@ -672,23 +676,21 @@ func querySignedIAMRequest(t *testing.T, method, target string, body []byte, sec
|
||||
hash := sha256.Sum256(body)
|
||||
payloadHash := hex.EncodeToString(hash[:])
|
||||
|
||||
signer := vgwv4.NewSigner()
|
||||
signedURL, signedHeaders, _, err := signer.PresignHTTP(
|
||||
context.Background(),
|
||||
aws.Credentials{AccessKeyID: testRoot.Access, SecretAccessKey: secret},
|
||||
req,
|
||||
payloadHash,
|
||||
"iam",
|
||||
region,
|
||||
signingTime,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("presign request: %v", err)
|
||||
}
|
||||
yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD)
|
||||
derivedKey := sigv4auth.DeriveKey(secret, yyyymmdd, region, "iam")
|
||||
in := sigv4auth.SigningInputFromRequest(req)
|
||||
in.AccessKeyID = testRoot.Access
|
||||
in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, region, "iam")
|
||||
in.PayloadHash = payloadHash
|
||||
in.SigningTime = signingTime
|
||||
in.IsPreSign = true
|
||||
result := sigv4auth.BuildAndSign(derivedKey, in)
|
||||
|
||||
signedReq := httptest.NewRequest(method, signedURL, bytes.NewReader(body))
|
||||
for key, values := range signedHeaders {
|
||||
signedURL := *req.URL
|
||||
signedURL.RawQuery = result.RawQuery
|
||||
|
||||
signedReq := httptest.NewRequest(method, signedURL.String(), bytes.NewReader(body))
|
||||
for key, values := range result.SignedHeaders {
|
||||
for _, value := range values {
|
||||
signedReq.Header.Add(key, value)
|
||||
}
|
||||
|
||||
+11
-16
@@ -171,10 +171,9 @@ func TestIAMApiControllerUserLifecycle(t *testing.T) {
|
||||
// TestIAMApiControllerGetRootUser confirms GetUser's self-lookup form
|
||||
// (UserName omitted, the only way any real AWS SDK/CLI ever invokes it,
|
||||
// since Query-protocol clients simply don't serialize an absent optional
|
||||
// field — confirmed live: `aws iam get-user` with no --user-name, as root,
|
||||
// succeeds and returns the root pseudo-user) and its non-standard explicit-
|
||||
// empty-string equivalent both resolve to the actual authenticated caller —
|
||||
// root, here, since doIAMAction always signs as root.
|
||||
// field) and its non-standard explicit-empty-string equivalent both
|
||||
// resolve to the actual authenticated caller — root, here, since
|
||||
// doIAMAction always signs as root.
|
||||
func TestIAMApiControllerGetRootUser(t *testing.T) {
|
||||
server := newIAMControllerTestServer(t)
|
||||
|
||||
@@ -2025,7 +2024,7 @@ func TestIAMApiControllerOIDCThumbprintAutoFetchDisabled(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("storage.New: %v", err)
|
||||
}
|
||||
server, err := New(store, WithQuiet(), WithRootUserCreds(testRoot), WithOIDCThumbprintAutoFetchDisabled())
|
||||
server, err := New(store, testRoot, WithQuiet(), WithOIDCThumbprintAutoFetchDisabled())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -2061,7 +2060,7 @@ func newIAMControllerTestServer(t *testing.T) *IAMApiServer {
|
||||
if err != nil {
|
||||
t.Fatalf("storage.New: %v", err)
|
||||
}
|
||||
server, err := New(store, WithQuiet(), WithRootUserCreds(testRoot))
|
||||
server, err := New(store, testRoot, WithQuiet())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
@@ -2723,10 +2722,9 @@ func TestIAMApiControllerAssumeRoleWithWebIdentityMultiplePrincipalsInArray(t *t
|
||||
"WebIdentityToken": {token},
|
||||
})
|
||||
// Passes trust evaluation and the audience check; fails only at the
|
||||
// network-dependent signature verification step (see the IDP
|
||||
// communication error test below for that path exercised
|
||||
// deterministically) — here it's enough to confirm it gets that far
|
||||
// rather than being rejected as AccessDenied/InvalidIdentityToken.
|
||||
// network-dependent signature verification step — here it's enough to
|
||||
// confirm it gets that far rather than being rejected as
|
||||
// AccessDenied/InvalidIdentityToken.
|
||||
requireSTSError(t, resp, http.StatusBadRequest, "Sender", "InvalidIdentityToken",
|
||||
"Couldn't retrieve verification key from your identity provider, please reference AssumeRoleWithWebIdentity documentation for requirements")
|
||||
}
|
||||
@@ -2954,8 +2952,7 @@ func TestIAMApiControllerGetCallerIdentityIncorrectServiceScope(t *testing.T) {
|
||||
// URL), signed with the given credentials. When sessionToken is non-empty,
|
||||
// the real v4 signer adds X-Amz-Security-Token to the query string itself
|
||||
// — the same way AWS's own SDKs presign a request for temporary
|
||||
// credentials (confirmed live against real AWS: such a request, submitted
|
||||
// as a plain HTTP GET with no Authorization header, succeeds).
|
||||
// credentials.
|
||||
func querySignedSTSRequest(t *testing.T, access, secret, sessionToken, target string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
@@ -2975,10 +2972,8 @@ func querySignedSTSRequest(t *testing.T, access, secret, sessionToken, target st
|
||||
|
||||
// TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken confirms a
|
||||
// temporary (ASIA…) session CAN authenticate via query-string (presigned
|
||||
// URL) auth when X-Amz-Security-Token matches the session — confirmed live
|
||||
// against real AWS (a genuine sts.PresignClient-generated presigned
|
||||
// GetCallerIdentity request, signed with real ASIA… credentials and
|
||||
// submitted as a plain HTTP GET, returns 200).
|
||||
// URL) auth when X-Amz-Security-Token matches the session, matching a
|
||||
// genuine sts.PresignClient-generated presigned GetCallerIdentity request.
|
||||
func TestIAMApiControllerGetCallerIdentityQueryAuthWithSessionToken(t *testing.T) {
|
||||
server := newIAMControllerTestServer(t)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
package iammiddleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -43,10 +42,10 @@ const (
|
||||
// canonical request and left unbound to the signature.
|
||||
//
|
||||
// This only applies to header auth. Query-string (presigned) auth carries
|
||||
// the token as a query parameter instead, which createPresignedHTTPRequestFromCtx
|
||||
// already includes in the signed canonical query string regardless of
|
||||
// SignedHeaders, so requiredSignedHeaders (unconditionally "host") is used
|
||||
// for both root/permanent and session query-auth requests.
|
||||
// the token as a query parameter instead, which sigv4auth's presign query
|
||||
// extraction already includes in the signed canonical query string
|
||||
// regardless of SignedHeaders, so requiredSignedHeaders (unconditionally
|
||||
// "host") is used for both root/permanent and session query-auth requests.
|
||||
var (
|
||||
requiredSignedHeaders = []string{"host"}
|
||||
requiredTempSignedHeaders = []string{"host", sigv4auth.HeaderSecurityToken}
|
||||
@@ -67,18 +66,6 @@ type RootCredentials struct {
|
||||
Secret string
|
||||
}
|
||||
|
||||
// IdentityStore resolves an access key id to the session or long-term user
|
||||
// that owns it, and resolves named resources for policy evaluation.
|
||||
// storage.Storer satisfies this directly.
|
||||
type IdentityStore interface {
|
||||
GetSession(ctx context.Context, accessKeyID string) (*types.Session, error)
|
||||
GetRole(ctx context.Context, roleName string) (*types.Role, error)
|
||||
GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error)
|
||||
GetUser(ctx context.Context, username string) (*types.User, error)
|
||||
GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error)
|
||||
RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error
|
||||
}
|
||||
|
||||
// VerifyIAMAuth authenticates a request against service (sigv4auth.ServiceIAM
|
||||
// or sigv4auth.ServiceSTS).
|
||||
//
|
||||
@@ -88,21 +75,21 @@ type IdentityStore interface {
|
||||
// identity (and, for a user/session, its policy documents) is stored via
|
||||
// httpctx.ContextKeyCallerIdentity for the policy middleware and controllers
|
||||
// to read back. Root bypasses the policy middleware entirely
|
||||
func VerifyIAMAuth(service string, root *RootCredentials, store IdentityStore) fiber.Handler {
|
||||
func VerifyIAMAuth(service string, root *RootCredentials, store iamutil.IdentityStore) fiber.Handler {
|
||||
return func(ctx fiber.Ctx) error {
|
||||
authData, tdate, queryAuth, err := parseIAMAuth(ctx, service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A security token in the query string is only ever legitimate
|
||||
// alongside a temporary (ASIA…) access key — reject it outright for
|
||||
// root or any long-term (AKIA…) credential before any signature
|
||||
// work, the same way for both, rather than letting it fall through
|
||||
// to a signature-mismatch error once a tampered/unsigned token
|
||||
// param invalidates the canonical query string.
|
||||
if queryAuth && !iamutil.IsTempAccessKeyID(authData.Access) &&
|
||||
ctx.Request().URI().QueryArgs().Has(sigv4auth.QuerySecurityToken) {
|
||||
// A security token paired with root or any long-term (AKIA…)
|
||||
// credential is rejected inside sigv4auth.ParseQueryAuthorization
|
||||
// for query auth, and just below for header auth — before any
|
||||
// signature work either way, rather than letting it fall through to
|
||||
// a signature-mismatch error once a tampered/unsigned token
|
||||
// invalidates the canonical request.
|
||||
if !queryAuth && !sigv4auth.IsTempAccessKeyID(authData.Access) &&
|
||||
ctx.Get(sigv4auth.HeaderSecurityToken) != "" {
|
||||
return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
|
||||
}
|
||||
|
||||
@@ -124,24 +111,42 @@ func VerifyIAMAuth(service string, root *RootCredentials, store IdentityStore) f
|
||||
}
|
||||
httpctx.ContextKeyCallerIdentity.Set(ctx, *identity)
|
||||
|
||||
// Best-effort update of a permanent access key's GetAccessKeyLastUsed
|
||||
// metadata, matching real IAM's behavior. A failure is only logged,
|
||||
// never returned: this is purely informational, and a lost update
|
||||
// under concurrent use is immaterial. Called synchronously: a Storer
|
||||
// implementation for which this is network-bound (e.g. Vault) is
|
||||
// expected to make it non-blocking itself.
|
||||
if identity.User != nil {
|
||||
recordAccessKeyUsage(ctx.Context(), store, authData.Access, service)
|
||||
if err := store.RecordAccessKeyUsage(ctx.Context(), authData.Access, service, SigningRegion, time.Now().UTC()); err != nil {
|
||||
debuglogger.Logf("failed to record access key last-used metadata for %q: %v", authData.Access, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// recordAccessKeyUsage best-effort-updates a permanent access key's
|
||||
// GetAccessKeyLastUsed metadata (service, region, and timestamp) after it
|
||||
// successfully authenticates a request, matching real IAM's behavior. A
|
||||
// failure is only logged, never returned, since this is purely
|
||||
// informational metadata and a lost update under concurrent use is
|
||||
// immaterial. Called synchronously: a Storer implementation for which this
|
||||
// update is network-bound (e.g. Vault) is expected to make it non-blocking
|
||||
// itself rather than adding that latency to every authenticated request
|
||||
func recordAccessKeyUsage(reqCtx context.Context, store IdentityStore, accessKeyID, service string) {
|
||||
if err := store.RecordAccessKeyUsage(reqCtx, accessKeyID, service, SigningRegion, time.Now().UTC()); err != nil {
|
||||
debuglogger.Logf("failed to record access key last-used metadata for %q: %v", accessKeyID, err)
|
||||
// VerifyRootOnlySigV4 authenticates a request as strictly the configured
|
||||
// root credential — used by the standalone IAM service's private
|
||||
// endpoints, which only the S3 gateway itself ever calls, signing as its
|
||||
// own configured IAM-client identity (root, or a dedicated IAM-access
|
||||
// credential that defaults to root). Unlike VerifyIAMAuth, any
|
||||
// other access key — valid IAM user, session, or unknown — is rejected
|
||||
// outright before any signature work: there is no identity to resolve on
|
||||
// behalf of here, and these two endpoints exist specifically so no identity
|
||||
// other than the gateway's own ever needs to reach them.
|
||||
func VerifyRootOnlySigV4(service string, root *RootCredentials) fiber.Handler {
|
||||
return func(ctx fiber.Ctx) error {
|
||||
authData, tdate, queryAuth, err := parseIAMAuth(ctx, service)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if authData.Access != root.Access {
|
||||
return iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
|
||||
}
|
||||
|
||||
return checkSignature(ctx, authData, root.Secret, tdate, queryAuth, service)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,12 +159,10 @@ func recordAccessKeyUsage(reqCtx context.Context, store IdentityStore, accessKey
|
||||
//
|
||||
// A temporary session can be used via query-string (presigned URL)
|
||||
// authentication — real AWS accepts X-Amz-Security-Token as a query
|
||||
// parameter for exactly this (confirmed live: a genuine presigned
|
||||
// sts:GetCallerIdentity request signed with temporary/session credentials,
|
||||
// carrying X-Amz-Security-Token in the query string, succeeds against real
|
||||
// AWS). VerifyIAMAuth already rejects a security token paired with any
|
||||
// non-temporary credential (root included) before this is ever reached.
|
||||
func resolveIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) {
|
||||
// parameter for exactly this. VerifyIAMAuth already rejects a security
|
||||
// token paired with any non-temporary credential (root included) before
|
||||
// this is ever reached.
|
||||
func resolveIdentity(ctx fiber.Ctx, store iamutil.IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) {
|
||||
if store == nil {
|
||||
return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
|
||||
}
|
||||
@@ -167,73 +170,30 @@ func resolveIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.Auth
|
||||
if iamutil.IsTempAccessKeyID(authData.Access) {
|
||||
return resolveSessionIdentity(ctx, store, authData, queryAuth)
|
||||
}
|
||||
return resolveUserIdentity(ctx, store, authData)
|
||||
}
|
||||
|
||||
func resolveSessionIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) {
|
||||
session, err := store.GetSession(ctx.Context(), authData.Access)
|
||||
identity, secret, err := iamutil.ResolveUserIdentity(ctx, store, authData.Access)
|
||||
if err != nil {
|
||||
return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
|
||||
}
|
||||
return identity, secret, nil
|
||||
}
|
||||
|
||||
// resolveSessionIdentity extracts the security token from wherever this
|
||||
// request carries it and delegates to iamutil.ResolveSessionByToken, mapping
|
||||
// its sentinel errors onto the control plane's single public-facing error —
|
||||
// which deliberately does not distinguish "no such session" from "wrong
|
||||
// token" for an unauthenticated caller.
|
||||
func resolveSessionIdentity(ctx fiber.Ctx, store iamutil.IdentityStore, authData sigv4auth.AuthData, queryAuth bool) (*types.Identity, string, error) {
|
||||
token := ctx.Get(sigv4auth.HeaderSecurityToken)
|
||||
if queryAuth {
|
||||
token = ctx.Query(sigv4auth.QuerySecurityToken)
|
||||
}
|
||||
if token == "" || !sigv4auth.SecureCompare(token, session.SessionToken) {
|
||||
return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
|
||||
}
|
||||
|
||||
// A signature-valid, unexpired session still authenticates even if its
|
||||
// role has since been deleted — real STS credentials are self-contained
|
||||
// and don't re-check role existence on every call. What such a session
|
||||
// can no longer do is get any IAM action past the policy middleware:
|
||||
// with Role/IdentityPolicies left unset, EvaluateIdentityPolicies denies
|
||||
// by default, same effective outcome as an explicit rejection here would
|
||||
// have had for every pipeline except GetCallerIdentity, which needs
|
||||
// none of this and must keep working regardless.
|
||||
//
|
||||
// The reloaded role must also still be the *same* role the session was
|
||||
// originally minted against — RoleID and Arn, both captured in the
|
||||
// session at AssumeRoleWithWebIdentity time, must match the freshly
|
||||
// loaded role's own values. Without this check, deleting a role and
|
||||
// recreating one of the same name (necessarily getting a new RoleID)
|
||||
// would let every pre-existing session for the old role silently
|
||||
// inherit whatever policies the new role happens to carry.
|
||||
identity := &types.Identity{
|
||||
Session: session,
|
||||
SessionPolicy: session.Policy,
|
||||
}
|
||||
if role, err := store.GetRole(ctx.Context(), session.RoleName); err == nil &&
|
||||
role.RoleID == session.RoleID && role.Arn == session.RoleArn {
|
||||
identity.Role = role
|
||||
identity.IdentityPolicies = role.Policies.Inline
|
||||
}
|
||||
return identity, session.SecretAccessKey, nil
|
||||
}
|
||||
|
||||
func resolveUserIdentity(ctx fiber.Ctx, store IdentityStore, authData sigv4auth.AuthData) (*types.Identity, string, error) {
|
||||
user, err := store.GetUserByAccessKeyID(ctx.Context(), authData.Access)
|
||||
identity, secret, err := iamutil.ResolveSessionByToken(ctx.Context(), store, authData.Access, token)
|
||||
if err != nil {
|
||||
return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
|
||||
}
|
||||
|
||||
var keyEntry *types.AccessKeyEntry
|
||||
for i := range user.AccessKeys {
|
||||
if user.AccessKeys[i].AccessKeyId == authData.Access {
|
||||
keyEntry = &user.AccessKeys[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyEntry == nil || keyEntry.Status != iamutil.AccessKeyStatusActive {
|
||||
return nil, "", iamerr.GetAPIError(iamerr.ErrInvalidClientTokenID)
|
||||
}
|
||||
|
||||
identity := &types.Identity{
|
||||
User: user,
|
||||
IdentityPolicies: user.Policies.Inline,
|
||||
}
|
||||
return identity, keyEntry.SecretAccessKey, nil
|
||||
return identity, secret, nil
|
||||
}
|
||||
|
||||
func checkSignature(ctx fiber.Ctx, authData sigv4auth.AuthData, secret string, tdate time.Time, queryAuth bool, service string) error {
|
||||
@@ -242,14 +202,16 @@ func checkSignature(ctx fiber.Ctx, authData sigv4auth.AuthData, secret string, t
|
||||
return err
|
||||
}
|
||||
|
||||
derivedKey := sigv4auth.DeriveKey(secret, tdate.Format(sigv4auth.YYYYMMDD), authData.Region, service)
|
||||
|
||||
payloadHash := sigv4auth.PayloadSHA256Hex(ctx.BodyRaw())
|
||||
if queryAuth {
|
||||
_, err = sigv4auth.CheckQuerySignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{
|
||||
_, err = sigv4auth.CheckQuerySignature(ctx, authData, derivedKey, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{
|
||||
Service: service,
|
||||
RequiredSignedHeaders: requiredSignedHeaders,
|
||||
})
|
||||
} else {
|
||||
_, err = sigv4auth.CheckSignature(ctx, authData, secret, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{
|
||||
_, err = sigv4auth.CheckSignature(ctx, authData, derivedKey, payloadHash, tdate, contentLength, sigv4auth.CheckOptions{
|
||||
Service: service,
|
||||
RequiredSignedHeaders: requiredHeaderAuthSignedHeaders(authData.Access),
|
||||
})
|
||||
@@ -311,12 +273,10 @@ func parseIAMHeaderAuth(ctx fiber.Ctx, expectedService string) (sigv4auth.AuthDa
|
||||
}
|
||||
|
||||
// parseIAMQueryAuth parses SigV4 query-string (presigned URL) authentication
|
||||
// parameters. Unlike S3 (see s3api/utils/presign-auth-reader.go), IAM/STS
|
||||
// query-auth does not use X-Amz-Expires at all: confirmed live (niksis02
|
||||
// profile) against real IAM's ListUsers — a presigned request with
|
||||
// X-Amz-Expires omitted, non-numeric ("abc"), negative ("-5"), or far
|
||||
// beyond the 604800-second S3 maximum ("9999999") is accepted every time,
|
||||
// while a request merely signed too long ago is rejected with
|
||||
// parameters. Unlike S3, IAM/STS query-auth does not use X-Amz-Expires at
|
||||
// all: a presigned request with X-Amz-Expires omitted, non-numeric,
|
||||
// negative, or far beyond S3's 604800-second maximum is accepted every
|
||||
// time, while a request merely signed too long ago is rejected with
|
||||
// SignatureDoesNotMatch ("Signature expired: ... is now earlier than ...
|
||||
// (... - 15 min.)") — byte-for-byte the same message this codebase's own
|
||||
// SignatureDoesNotMatchExpired already produces. So X-Amz-Expires is
|
||||
|
||||
@@ -15,6 +15,7 @@ package iammiddleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
@@ -33,6 +34,13 @@ func GlobalErrorHandler(ctx fiber.Ctx, er error) error {
|
||||
return ctx.Status(apiErr.StatusCode()).Send(apiErr.XMLBody(requestID))
|
||||
}
|
||||
|
||||
var fiberErr *fiber.Error
|
||||
if errors.As(er, &fiberErr) && strings.Contains(strings.ToLower(fiberErr.Message), "cannot parse content-length") {
|
||||
debuglogger.Logf("failed to parse Content-Length")
|
||||
ctx.Status(fiber.StatusBadRequest)
|
||||
return nil
|
||||
}
|
||||
|
||||
if httpctx.ContextKeyStack.IsSet(ctx) {
|
||||
debuglogger.Panic(er)
|
||||
} else {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
package iammiddleware
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -51,7 +52,7 @@ const iamActionPrefix = "iam:"
|
||||
// requestConditionContext supplies the request's aws:SourceIp/aws:username/
|
||||
// aws:PrincipalArn/aws:CurrentTime/aws:EpochTime values for a statement's
|
||||
// Condition block.
|
||||
func VerifyIAMPolicy(store IdentityStore) fiber.Handler {
|
||||
func VerifyIAMPolicy(store iamutil.IdentityStore) fiber.Handler {
|
||||
return func(ctx fiber.Ctx) error {
|
||||
identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity)
|
||||
if identity.IsRoot {
|
||||
@@ -68,8 +69,8 @@ func VerifyIAMPolicy(store IdentityStore) fiber.Handler {
|
||||
Condition: requestConditionContext(ctx, identity, action, resourceTags),
|
||||
}
|
||||
|
||||
if !authorizeRequest(identity, reqCtx) {
|
||||
return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction)
|
||||
if Authorize(identity, reqCtx) != policy.DecisionAllow {
|
||||
return iamerr.AccessDeniedIAMAction(CallerArn(identity), fullAction)
|
||||
}
|
||||
|
||||
// A rename/path-move is a two-resource transition: AWS's UpdateUser
|
||||
@@ -79,8 +80,8 @@ func VerifyIAMPolicy(store IdentityStore) fiber.Handler {
|
||||
if target := updateUserTargetResource(ctx, store); target != "" {
|
||||
targetCtx := reqCtx
|
||||
targetCtx.Resource = target
|
||||
if !authorizeRequest(identity, targetCtx) {
|
||||
return iamerr.AccessDeniedIAMAction(callerArn(identity), fullAction)
|
||||
if Authorize(identity, targetCtx) != policy.DecisionAllow {
|
||||
return iamerr.AccessDeniedIAMAction(CallerArn(identity), fullAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,20 +90,58 @@ func VerifyIAMPolicy(store IdentityStore) fiber.Handler {
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeRequest reports whether reqCtx is allowed by identity's own
|
||||
// inline policies and, for a session with a session policy attached, the
|
||||
// narrowing session policy as well.
|
||||
func authorizeRequest(identity types.Identity, reqCtx policy.RequestContext) bool {
|
||||
if !policy.EvaluateIdentityPolicies(identity.IdentityPolicies, reqCtx) {
|
||||
return false
|
||||
// Authorize reports how identity's own inline policies and, for a session
|
||||
// with a session policy attached, the narrowing session policy as well,
|
||||
// decide reqCtx.
|
||||
//
|
||||
// A session policy can only narrow, never widen, what the role's identity
|
||||
// policies otherwise allow — matching AWS's permission-boundary semantics
|
||||
// for AssumeRole session policies — so this is an intersection, not the
|
||||
// "either source is independently sufficient" combination VerifyAccess uses
|
||||
// for S3 bucket-policy-vs-identity-policy: an explicit Deny from either
|
||||
// layer here always wins outright, and the result is DecisionAllow only
|
||||
// when both layers (or just the identity layer, absent a session policy)
|
||||
// independently reach DecisionAllow.
|
||||
func Authorize(identity types.Identity, reqCtx policy.RequestContext) policy.Decision {
|
||||
d, sd, hasSessionPolicy := AuthorizeSplit(identity, reqCtx)
|
||||
if d == policy.DecisionDeny {
|
||||
return policy.DecisionDeny
|
||||
}
|
||||
if identity.Session != nil && identity.SessionPolicy != "" {
|
||||
sessionPolicy := []types.PolicyEntry{{PolicyDocument: identity.SessionPolicy}}
|
||||
if !policy.EvaluateIdentityPolicies(sessionPolicy, reqCtx) {
|
||||
return false
|
||||
}
|
||||
if !hasSessionPolicy {
|
||||
return d
|
||||
}
|
||||
return true
|
||||
if sd == policy.DecisionDeny {
|
||||
return policy.DecisionDeny
|
||||
}
|
||||
if d != policy.DecisionAllow || sd != policy.DecisionAllow {
|
||||
return policy.DecisionNoMatch
|
||||
}
|
||||
return policy.DecisionAllow
|
||||
}
|
||||
|
||||
// AuthorizeSplit reports the identity-policy and session-policy decisions
|
||||
// separately, rather than folded together as Authorize does, plus whether a
|
||||
// session policy applied at all.
|
||||
//
|
||||
// The two must stay separable for the S3 data plane, where a *resource*
|
||||
// policy is also in play. A session policy filters everything, including
|
||||
// permissions that came from the bucket policy rather than from the role
|
||||
// with a role carrying no identity policy at all, a bucket policy granting
|
||||
// s3:GetObject and s3:PutObject to that role, and a session policy allowing only
|
||||
// s3:GetObject, the Get succeeds and the Put is denied. Collapsing the two into
|
||||
// one decision here would lose the distinction between "the session policy did
|
||||
// not permit this" (which must deny even against a bucket-policy Allow) and
|
||||
// "the role's own policies did not permit this" (which a bucket-policy
|
||||
// Allow may still grant).
|
||||
func AuthorizeSplit(identity types.Identity, reqCtx policy.RequestContext) (identityDecision, sessionDecision policy.Decision, hasSessionPolicy bool) {
|
||||
identityDecision = policy.EvaluateIdentityPolicies(identity.IdentityPolicies, reqCtx)
|
||||
|
||||
if identity.Session == nil || identity.SessionPolicy == "" {
|
||||
return identityDecision, policy.DecisionNoMatch, false
|
||||
}
|
||||
|
||||
sessionPolicy := []types.PolicyEntry{{PolicyDocument: identity.SessionPolicy}}
|
||||
return identityDecision, policy.EvaluateIdentityPolicies(sessionPolicy, reqCtx), true
|
||||
}
|
||||
|
||||
// resourceForAction resolves the ARN action targets and, when that ARN names
|
||||
@@ -127,7 +166,7 @@ func authorizeRequest(identity types.Identity, reqCtx policy.RequestContext) boo
|
||||
// request still reaches the controller afterward, which reports the
|
||||
// specific NoSuchEntity/MissingValue error if authorization happens to pass
|
||||
// on a wildcard grant, or AccessDenied first if it doesn't.
|
||||
func resourceForAction(ctx fiber.Ctx, store IdentityStore, action string) (string, []types.Tag) {
|
||||
func resourceForAction(ctx fiber.Ctx, store iamutil.IdentityStore, action string) (string, []types.Tag) {
|
||||
switch action {
|
||||
case "CreateUser":
|
||||
return newUserResource(ctx), nil
|
||||
@@ -177,7 +216,7 @@ func newUserResource(ctx fiber.Ctx) string {
|
||||
// used elsewhere — none of this group's actions actually accept an omitted
|
||||
// UserName (the controller layer requires it), so this only guards against
|
||||
// a malformed request reaching here.
|
||||
func existingUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) {
|
||||
func existingUserResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) {
|
||||
userName, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || userName == "" {
|
||||
return "", nil
|
||||
@@ -195,7 +234,7 @@ func existingUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.T
|
||||
// own Arn and Tags. A session (assumed role) has no self IAM user to
|
||||
// resolve, so it falls back to ("", nil), the same lookup-failure fallback
|
||||
// used elsewhere.
|
||||
func getUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) {
|
||||
func getUserResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) {
|
||||
userName, ok := iamutil.RequestParam(ctx, "UserName")
|
||||
if !ok || userName == "" {
|
||||
identity, _ := httpctx.ContextKeyCallerIdentity.Get(ctx).(types.Identity)
|
||||
@@ -216,7 +255,7 @@ func getUserResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) {
|
||||
// AccessKeyId being queried, so the resource-level check is against the IAM
|
||||
// user that owns that key, matching real IAM's resource-type classification
|
||||
// for this action.
|
||||
func accessKeyOwnerResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) {
|
||||
func accessKeyOwnerResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) {
|
||||
accessKeyID, ok := iamutil.RequestParam(ctx, "AccessKeyId")
|
||||
if !ok || accessKeyID == "" {
|
||||
return "", nil
|
||||
@@ -235,7 +274,7 @@ func accessKeyOwnerResource(ctx fiber.Ctx, store IdentityStore) (string, []types
|
||||
// "" when the request doesn't actually relocate the user (neither NewPath
|
||||
// nor NewUserName supplied) or when the source user can't be resolved, the
|
||||
// same fallback used elsewhere when a lookup fails.
|
||||
func updateUserTargetResource(ctx fiber.Ctx, store IdentityStore) string {
|
||||
func updateUserTargetResource(ctx fiber.Ctx, store iamutil.IdentityStore) string {
|
||||
newPath, _ := iamutil.RequestParam(ctx, "NewPath")
|
||||
newUserName, _ := iamutil.RequestParam(ctx, "NewUserName")
|
||||
if newPath == "" && newUserName == "" {
|
||||
@@ -272,7 +311,7 @@ func newRoleResource(ctx fiber.Ctx) string {
|
||||
return iamutil.BuildRoleArn(iamutil.DefaultAccountID, path, roleName)
|
||||
}
|
||||
|
||||
func existingRoleResource(ctx fiber.Ctx, store IdentityStore) (string, []types.Tag) {
|
||||
func existingRoleResource(ctx fiber.Ctx, store iamutil.IdentityStore) (string, []types.Tag) {
|
||||
roleName, ok := iamutil.RequestParam(ctx, "RoleName")
|
||||
if !ok || roleName == "" {
|
||||
return "*", nil
|
||||
@@ -333,21 +372,7 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri
|
||||
if ip := ctx.IP(); ip != "" {
|
||||
condCtx["aws:SourceIp"] = []string{ip}
|
||||
}
|
||||
if arn := callerArn(identity); arn != "" {
|
||||
condCtx["aws:PrincipalArn"] = []string{arn}
|
||||
condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID}
|
||||
}
|
||||
switch {
|
||||
case identity.User != nil:
|
||||
condCtx["aws:username"] = []string{identity.User.UserName}
|
||||
condCtx["aws:userid"] = []string{identity.User.UserID}
|
||||
addPrincipalTagContext(condCtx, identity.User.Tags)
|
||||
case identity.Session != nil:
|
||||
condCtx["aws:userid"] = []string{identity.Session.RoleID + ":" + identity.Session.RoleSessionName}
|
||||
if identity.Role != nil {
|
||||
addPrincipalTagContext(condCtx, identity.Role.Tags)
|
||||
}
|
||||
}
|
||||
maps.Copy(condCtx, IdentityConditionContext(identity))
|
||||
|
||||
for _, tag := range resourceTags {
|
||||
condCtx["iam:ResourceTag/"+tag.Key] = []string{tag.Value}
|
||||
@@ -362,6 +387,59 @@ func requestConditionContext(ctx fiber.Ctx, identity types.Identity, action stri
|
||||
return condCtx
|
||||
}
|
||||
|
||||
// IdentityConditionContext builds the condition keys that describe *who* is
|
||||
// calling — as opposed to the request-derived keys (time, source IP,
|
||||
// transport) that its callers add around it.
|
||||
//
|
||||
// Splitting these out is what lets the standalone-IAM private
|
||||
// evaluate-policy endpoint serve the S3 gateway: the gateway knows the
|
||||
// request but not the identity behind the access key, so it sends only the
|
||||
// request-derived keys and this side fills in the rest from the identity it
|
||||
// resolved. The gateway is never trusted to supply these keys itself, even
|
||||
// though it authenticates as root.
|
||||
func IdentityConditionContext(identity types.Identity) map[string][]string {
|
||||
condCtx := map[string][]string{}
|
||||
if arn := CallerArn(identity); arn != "" {
|
||||
condCtx["aws:PrincipalArn"] = []string{arn}
|
||||
condCtx["aws:PrincipalAccount"] = []string{iamutil.DefaultAccountID}
|
||||
}
|
||||
switch {
|
||||
case identity.User != nil:
|
||||
condCtx["aws:PrincipalType"] = []string{"User"}
|
||||
condCtx["aws:username"] = []string{identity.User.UserName}
|
||||
condCtx["aws:userid"] = []string{identity.User.UserID}
|
||||
addPrincipalTagContext(condCtx, identity.User.Tags)
|
||||
case identity.Session != nil:
|
||||
condCtx["aws:PrincipalType"] = []string{"AssumedRole"}
|
||||
condCtx["aws:userid"] = []string{identity.Session.RoleID + ":" + identity.Session.RoleSessionName}
|
||||
if identity.Role != nil {
|
||||
addPrincipalTagContext(condCtx, identity.Role.Tags)
|
||||
}
|
||||
}
|
||||
return condCtx
|
||||
}
|
||||
|
||||
// IdentityConditionKeyPrefixes lists the condition-key namespaces that
|
||||
// describe the caller or the resource, and that therefore only the IAM
|
||||
// service may populate. handleEvaluatePolicy strips every one of them from
|
||||
// a gateway-supplied context before overlaying its own — an
|
||||
// override-on-collision merge would leave any key the service happens *not*
|
||||
// to set (aws:PrincipalTag/x for an untagged role, say) under the
|
||||
// gateway's control, which is exactly what a StringNotEquals-guarded Allow
|
||||
// keys off.
|
||||
var IdentityConditionKeyPrefixes = []string{
|
||||
"aws:PrincipalArn",
|
||||
"aws:PrincipalAccount",
|
||||
"aws:PrincipalType",
|
||||
"aws:username",
|
||||
"aws:userid",
|
||||
"aws:PrincipalTag/",
|
||||
"aws:ResourceTag/",
|
||||
"iam:ResourceTag/",
|
||||
"aws:RequestTag/",
|
||||
"aws:TagKeys",
|
||||
}
|
||||
|
||||
// addPrincipalTagContext populates aws:PrincipalTag/<key> from tags, the
|
||||
// calling principal's own tags.
|
||||
func addPrincipalTagContext(condCtx map[string][]string, tags []types.Tag) {
|
||||
@@ -390,9 +468,9 @@ func addRequestTagContext(condCtx map[string][]string, ctx fiber.Ctx) {
|
||||
condCtx["aws:TagKeys"] = keys
|
||||
}
|
||||
|
||||
// callerArn identifies identity the way real IAM error messages do: the
|
||||
// CallerArn identifies identity the way real IAM error messages do: the
|
||||
// user's own Arn, or the assumed-role session Arn.
|
||||
func callerArn(identity types.Identity) string {
|
||||
func CallerArn(identity types.Identity) string {
|
||||
if identity.Session != nil {
|
||||
return iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, identity.Session.RoleName, identity.Session.RoleSessionName)
|
||||
}
|
||||
|
||||
@@ -18,10 +18,10 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/internal/sigv4auth"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -34,11 +34,7 @@ const (
|
||||
maxAccessKeyIDLen = 128
|
||||
secretAccessKeyBytes = 30
|
||||
|
||||
// tempAccessKeyIDPrefix marks temporary credentials minted by
|
||||
// AssumeRoleWithWebIdentity, matching AWS's ASIA… convention that
|
||||
// distinguishes them from long-term AKIA… access keys.
|
||||
tempAccessKeyIDPrefix = "ASIA"
|
||||
sessionTokenBytes = 128
|
||||
sessionTokenBytes = 128
|
||||
)
|
||||
|
||||
var accessKeyIDPattern = regexp.MustCompile(`^[\w]+$`)
|
||||
@@ -69,7 +65,7 @@ func GenerateSecretAccessKey() (string, error) {
|
||||
// access key id in the ASIA… format, for credentials minted by
|
||||
// AssumeRoleWithWebIdentity.
|
||||
func GenerateTempAccessKeyID() (string, error) {
|
||||
id, err := generateAWSID(tempAccessKeyIDPrefix, accessKeyIDRandomLen)
|
||||
id, err := generateAWSID(sigv4auth.TempAccessKeyIDPrefix, accessKeyIDRandomLen)
|
||||
if err != nil {
|
||||
debuglogger.Logf("failed to generate temporary IAM access key id: %v", err)
|
||||
return "", err
|
||||
@@ -93,9 +89,10 @@ func GenerateSessionToken() (string, error) {
|
||||
|
||||
// IsTempAccessKeyID reports whether accessKeyID has the ASIA… prefix used
|
||||
// for temporary credentials minted by AssumeRoleWithWebIdentity, as opposed
|
||||
// to a long-term AKIA… access key.
|
||||
// to a long-term AKIA… access key. It delegates to sigv4auth so the S3
|
||||
// gateway, which cannot import this package, shares one definition.
|
||||
func IsTempAccessKeyID(accessKeyID string) bool {
|
||||
return strings.HasPrefix(accessKeyID, tempAccessKeyIDPrefix)
|
||||
return sigv4auth.IsTempAccessKeyID(accessKeyID)
|
||||
}
|
||||
|
||||
// ValidateAccessKeyID checks that accessKeyID fits within the allowed length
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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 iamutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
"github.com/versity/versitygw/internal/sigv4auth"
|
||||
)
|
||||
|
||||
// ErrIdentityNotFound and ErrInvalidSessionToken are returned by
|
||||
// ResolveSessionByToken and ResolveUserIdentity, so a caller that needs to
|
||||
// know *which* failure occurred — the standalone-IAM private endpoints, and
|
||||
// iammiddleware's own sigv4 pipeline, which turn them into S3's own
|
||||
// InvalidAccessKeyId and InvalidToken respectively — can distinguish them.
|
||||
//
|
||||
// The public IAM control plane deliberately collapses both into a single
|
||||
// InvalidClientTokenId: an unauthenticated caller must not learn whether an
|
||||
// access key exists.
|
||||
var (
|
||||
ErrIdentityNotFound = errors.New("identity not found")
|
||||
ErrInvalidSessionToken = errors.New("invalid session token")
|
||||
)
|
||||
|
||||
// IdentityStore resolves an access key id to the session or long-term user
|
||||
// that owns it, and resolves named resources for policy evaluation.
|
||||
// storage.Storer satisfies this directly.
|
||||
type IdentityStore interface {
|
||||
GetSession(ctx context.Context, accessKeyID string) (*types.Session, error)
|
||||
GetRole(ctx context.Context, roleName string) (*types.Role, error)
|
||||
GetUserByAccessKeyID(ctx context.Context, accessKeyID string) (*types.User, error)
|
||||
GetUser(ctx context.Context, username string) (*types.User, error)
|
||||
GetOIDCProvider(ctx context.Context, arn string) (*types.OIDCProvider, error)
|
||||
RecordAccessKeyUsage(ctx context.Context, accessKeyID, service, region string, when time.Time) error
|
||||
}
|
||||
|
||||
// ResolveSessionByToken resolves a temporary (ASIA…) access key to the
|
||||
// session that owns it, requiring token to match the session's stored
|
||||
// SessionToken. The empty-token rejection and the constant-time comparison
|
||||
// both live here rather than in any caller: this is the only function that
|
||||
// may turn a session access key id into a secret, so no caller can be
|
||||
// written that skips them.
|
||||
//
|
||||
// It does not itself verify a SigV4 signature — request-pipeline callers do
|
||||
// that next, so a stolen or guessed access key id plus token is never
|
||||
// sufficient on its own.
|
||||
func ResolveSessionByToken(ctx context.Context, store IdentityStore, accessKeyID, token string) (*types.Identity, string, error) {
|
||||
// No token means there is nothing to resolve the access key against, so
|
||||
// the key is reported as simply not existing rather than as a bad token
|
||||
// — matching real S3, which answers InvalidAccessKeyId for a temporary
|
||||
// access key presented with no X-Amz-Security-Token, and InvalidToken
|
||||
// only once a token is actually present and wrong.
|
||||
if token == "" {
|
||||
return nil, "", ErrIdentityNotFound
|
||||
}
|
||||
|
||||
session, err := store.GetSession(ctx, accessKeyID)
|
||||
if err != nil {
|
||||
return nil, "", ErrIdentityNotFound
|
||||
}
|
||||
|
||||
if !sigv4auth.SecureCompare(token, session.SessionToken) {
|
||||
return nil, "", ErrInvalidSessionToken
|
||||
}
|
||||
|
||||
// A signature-valid, unexpired session still authenticates even if its
|
||||
// role has since been deleted — real STS credentials are self-contained
|
||||
// and don't re-check role existence on every call. What such a session
|
||||
// can no longer do is get any IAM action past the policy middleware:
|
||||
// with Role/IdentityPolicies left unset, EvaluateIdentityPolicies denies
|
||||
// by default, same effective outcome as an explicit rejection here would
|
||||
// have had for every pipeline except GetCallerIdentity, which needs
|
||||
// none of this and must keep working regardless.
|
||||
//
|
||||
// The reloaded role must also still be the *same* role the session was
|
||||
// originally minted against — RoleID and Arn, both captured in the
|
||||
// session at AssumeRoleWithWebIdentity time, must match the freshly
|
||||
// loaded role's own values. Without this check, deleting a role and
|
||||
// recreating one of the same name (necessarily getting a new RoleID)
|
||||
// would let every pre-existing session for the old role silently
|
||||
// inherit whatever policies the new role happens to carry.
|
||||
identity := &types.Identity{
|
||||
Session: session,
|
||||
SessionPolicy: session.Policy,
|
||||
}
|
||||
if role, err := store.GetRole(ctx, session.RoleName); err == nil &&
|
||||
role.RoleID == session.RoleID && role.Arn == session.RoleArn {
|
||||
identity.Role = role
|
||||
identity.IdentityPolicies = role.Policies.Inline
|
||||
}
|
||||
return identity, session.SecretAccessKey, nil
|
||||
}
|
||||
|
||||
// ResolveUserIdentity resolves accessKeyID to its long-term (AKIA…) IAM user
|
||||
// and secret, reporting the ErrIdentityNotFound/ErrInvalidSessionToken
|
||||
// sentinels rather than an opaque API error — callers on the public control
|
||||
// plane that want the opaque error do that translation themselves.
|
||||
//
|
||||
// Temporary (ASIA…) session access keys are rejected here: resolving one
|
||||
// safely requires validating its security token, which this function has no
|
||||
// parameter for. A caller that can supply a token uses ResolveSessionByToken
|
||||
// instead. Silently resolving a session's secret from its access key id
|
||||
// alone, with no token check at all, would let anyone who merely knows the
|
||||
// id impersonate the session.
|
||||
func ResolveUserIdentity(ctx context.Context, store IdentityStore, accessKeyID string) (*types.Identity, string, error) {
|
||||
if sigv4auth.IsTempAccessKeyID(accessKeyID) {
|
||||
return nil, "", ErrInvalidSessionToken
|
||||
}
|
||||
|
||||
user, err := store.GetUserByAccessKeyID(ctx, accessKeyID)
|
||||
if err != nil {
|
||||
return nil, "", ErrIdentityNotFound
|
||||
}
|
||||
|
||||
var keyEntry *types.AccessKeyEntry
|
||||
for i := range user.AccessKeys {
|
||||
if user.AccessKeys[i].AccessKeyId == accessKeyID {
|
||||
keyEntry = &user.AccessKeys[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if keyEntry == nil || keyEntry.Status != AccessKeyStatusActive {
|
||||
return nil, "", ErrIdentityNotFound
|
||||
}
|
||||
|
||||
identity := &types.Identity{
|
||||
User: user,
|
||||
IdentityPolicies: user.Policies.Inline,
|
||||
}
|
||||
return identity, keyEntry.SecretAccessKey, nil
|
||||
}
|
||||
@@ -382,11 +382,11 @@ func VerifyWebIdentityExpiration(claims jwt.MapClaims, now time.Time) error {
|
||||
// web identity token claims beyond exp (already checked separately by
|
||||
// VerifyWebIdentityExpiration): iat and sub must both be present, and nbf
|
||||
// (if present) must not be in the future beyond webIdentityExpLeeway of
|
||||
// clock skew. Confirmed against real AWS (niksis02 profile): a token with
|
||||
// exp but no iat, or with iat but no sub, is rejected with
|
||||
// InvalidIdentityToken "Missing a required claim: <iat|sub>." — without
|
||||
// this check, such a token would otherwise obtain credentials whenever the
|
||||
// role's trust policy doesn't itself require sub via Condition.
|
||||
// clock skew. A token with exp but no iat, or with iat but no sub, is
|
||||
// rejected with InvalidIdentityToken "Missing a required claim:
|
||||
// <iat|sub>." — without this check, such a token would otherwise obtain
|
||||
// credentials whenever the role's trust policy doesn't itself require sub
|
||||
// via Condition.
|
||||
func VerifyWebIdentityRequiredClaims(claims jwt.MapClaims, now time.Time) error {
|
||||
if _, ok := claims["iat"].(float64); !ok {
|
||||
debuglogger.Logf("web identity token has no iat claim")
|
||||
@@ -580,8 +580,8 @@ type jwksCacheEntry struct {
|
||||
keys *jwkSet
|
||||
expiresAt time.Time
|
||||
// lastForcedRefresh is when an unknown-kid lookup last bypassed
|
||||
// expiresAt to force a fetch for this issuer, gating
|
||||
// jwksMinForcedRefreshInterval (see forceRefreshJWKSCache).
|
||||
// expiresAt to force a fetch for this issuer, gated by
|
||||
// jwksMinForcedRefreshInterval.
|
||||
lastForcedRefresh time.Time
|
||||
}
|
||||
|
||||
|
||||
@@ -1,500 +0,0 @@
|
||||
// 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 (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
)
|
||||
|
||||
// ConditionValues decodes the value(s) of a single Condition operator/key
|
||||
// pair. Unlike Action/Resource's string-only StringOrSlice, a Condition
|
||||
// value may also be a bare JSON number or boolean rather than
|
||||
// being re-serialized, so e.g. "5.50" round-trips as "5.50", not "5.5". A
|
||||
// JSON null value or a non-scalar (object/array) element is rejected.
|
||||
type ConditionValues []string
|
||||
|
||||
func (c *ConditionValues) UnmarshalJSON(data []byte) error {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) > 0 && trimmed[0] == '[' {
|
||||
var raws []json.RawMessage
|
||||
if err := json.Unmarshal(trimmed, &raws); err != nil {
|
||||
return err
|
||||
}
|
||||
values := make([]string, len(raws))
|
||||
for i, r := range raws {
|
||||
s, ok := decodeConditionScalar(r)
|
||||
if !ok {
|
||||
return fmt.Errorf("policy: invalid condition value %s", r)
|
||||
}
|
||||
values[i] = s
|
||||
}
|
||||
*c = values
|
||||
return nil
|
||||
}
|
||||
|
||||
s, ok := decodeConditionScalar(trimmed)
|
||||
if !ok {
|
||||
return fmt.Errorf("policy: invalid condition value %s", trimmed)
|
||||
}
|
||||
*c = ConditionValues{s}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeConditionScalar decodes a single JSON scalar (string, number, or
|
||||
// bool) to its string form, rejecting null and any non-scalar (object,
|
||||
// array) value.
|
||||
func decodeConditionScalar(raw json.RawMessage) (string, bool) {
|
||||
trimmed := bytes.TrimSpace(raw)
|
||||
if len(trimmed) == 0 {
|
||||
return "", false
|
||||
}
|
||||
if trimmed[0] == '"' {
|
||||
var s string
|
||||
if err := json.Unmarshal(trimmed, &s); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return s, true
|
||||
}
|
||||
switch string(trimmed) {
|
||||
case "true", "false":
|
||||
return string(trimmed), true
|
||||
case "null":
|
||||
return "", false
|
||||
}
|
||||
var num json.Number
|
||||
if err := json.Unmarshal(trimmed, &num); err != nil {
|
||||
return "", false
|
||||
}
|
||||
return num.String(), true
|
||||
}
|
||||
|
||||
// conditionQualifier is IAM's multivalued-context-key set operator, given as
|
||||
// a "ForAllValues:"/"ForAnyValue:" prefix on a condition operator name.
|
||||
type conditionQualifier int
|
||||
|
||||
const (
|
||||
qualifierNone conditionQualifier = iota
|
||||
qualifierForAllValues
|
||||
qualifierForAnyValue
|
||||
)
|
||||
|
||||
// conditionComparator is a single (policy value, request value) match test
|
||||
// for one condition operator family, e.g. string equality or a numeric
|
||||
// comparison. It never itself accounts for absence, IfExists, negation, or
|
||||
// multivalued aggregation - those are handled by evaluateConditionKey and
|
||||
// aggregate around it.
|
||||
type conditionComparator func(expected, actual string) bool
|
||||
|
||||
// conditionOperatorDef is a recognized condition operator's evaluation
|
||||
// behavior: negate distinguishes a Not-family operator (StringNotEquals,
|
||||
// ArnNotEquals, ...) from its positive counterpart - both share the same
|
||||
// comparator, since "not equal" is just the equality test used differently
|
||||
// (see aggregate), not a different comparison.
|
||||
type conditionOperatorDef struct {
|
||||
compare conditionComparator
|
||||
negate bool
|
||||
}
|
||||
|
||||
// conditionRegistry is every condition operator base name this package
|
||||
// recognizes, except "Null" (handled separately by evaluateNull - it has no
|
||||
// value comparator at all, only a presence check). Populated below from
|
||||
// AWS's documented condition operator reference.
|
||||
var conditionRegistry = map[string]conditionOperatorDef{
|
||||
"StringEquals": {compare: stringExact},
|
||||
"StringNotEquals": {compare: stringExact, negate: true},
|
||||
"StringEqualsIgnoreCase": {compare: stringFold},
|
||||
"StringNotEqualsIgnoreCase": {compare: stringFold, negate: true},
|
||||
"StringLike": {compare: stringLike},
|
||||
"StringNotLike": {compare: stringLike, negate: true},
|
||||
|
||||
"NumericEquals": {compare: numericCompare(func(a, e float64) bool { return a == e })},
|
||||
"NumericNotEquals": {compare: numericCompare(func(a, e float64) bool { return a == e }), negate: true},
|
||||
"NumericLessThan": {compare: numericCompare(func(a, e float64) bool { return a < e })},
|
||||
"NumericLessThanEquals": {compare: numericCompare(func(a, e float64) bool { return a <= e })},
|
||||
"NumericGreaterThan": {compare: numericCompare(func(a, e float64) bool { return a > e })},
|
||||
"NumericGreaterThanEquals": {compare: numericCompare(func(a, e float64) bool { return a >= e })},
|
||||
|
||||
"DateEquals": {compare: dateCompare(func(a, e time.Time) bool { return a.Equal(e) })},
|
||||
"DateNotEquals": {compare: dateCompare(func(a, e time.Time) bool { return a.Equal(e) }), negate: true},
|
||||
"DateLessThan": {compare: dateCompare(func(a, e time.Time) bool { return a.Before(e) })},
|
||||
"DateLessThanEquals": {compare: dateCompare(func(a, e time.Time) bool { return !a.After(e) })},
|
||||
"DateGreaterThan": {compare: dateCompare(func(a, e time.Time) bool { return a.After(e) })},
|
||||
"DateGreaterThanEquals": {compare: dateCompare(func(a, e time.Time) bool { return !a.Before(e) })},
|
||||
|
||||
"Bool": {compare: boolMatch},
|
||||
|
||||
"BinaryEquals": {compare: binaryMatch},
|
||||
|
||||
// ArnEquals and ArnLike behave identically in real AWS (both wildcard
|
||||
// -aware), and are matched here with the same whole-string globMatch
|
||||
// already used for Action/Resource - do not "fix" ArnEquals to a strict
|
||||
// == later, that would diverge from AWS behavior.
|
||||
"ArnEquals": {compare: stringLike},
|
||||
"ArnLike": {compare: stringLike},
|
||||
"ArnNotEquals": {compare: stringLike, negate: true},
|
||||
"ArnNotLike": {compare: stringLike, negate: true},
|
||||
|
||||
"IpAddress": {compare: ipMatch},
|
||||
"NotIpAddress": {compare: ipMatch, negate: true},
|
||||
}
|
||||
|
||||
func stringExact(expected, actual string) bool { return expected == actual }
|
||||
func stringFold(expected, actual string) bool { return strings.EqualFold(expected, actual) }
|
||||
func stringLike(expected, actual string) bool { return globMatch(expected, actual) }
|
||||
|
||||
// numericCompare builds a comparator from a (actual, expected float64) ->
|
||||
// bool test, matching AWS's direction convention (the request's value is
|
||||
// compared against the policy's value). Either operand failing to parse as
|
||||
// a number fails the comparison rather than erroring
|
||||
func numericCompare(op func(actual, expected float64) bool) conditionComparator {
|
||||
return func(expected, actual string) bool {
|
||||
e, eerr := strconv.ParseFloat(expected, 64)
|
||||
a, aerr := strconv.ParseFloat(actual, 64)
|
||||
return eerr == nil && aerr == nil && op(a, e)
|
||||
}
|
||||
}
|
||||
|
||||
// dateCompare builds a comparator from a (actual, expected time.Time) ->
|
||||
// bool test, same direction convention as numericCompare.
|
||||
func dateCompare(op func(actual, expected time.Time) bool) conditionComparator {
|
||||
return func(expected, actual string) bool {
|
||||
e, eok := parseConditionDate(expected)
|
||||
a, aok := parseConditionDate(actual)
|
||||
return eok && aok && op(a, e)
|
||||
}
|
||||
}
|
||||
|
||||
// parseConditionDate parses a Date condition operand in either form AWS
|
||||
// accepts: an RFC 3339 date-time, or Unix epoch seconds (optionally
|
||||
// fractional).
|
||||
func parseConditionDate(s string) (time.Time, bool) {
|
||||
if t, err := time.Parse(time.RFC3339, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t, true
|
||||
}
|
||||
if f, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
sec := int64(f)
|
||||
nsec := int64((f - float64(sec)) * 1e9)
|
||||
return time.Unix(sec, nsec).UTC(), true
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
func boolMatch(expected, actual string) bool {
|
||||
e, eerr := strconv.ParseBool(expected)
|
||||
a, aerr := strconv.ParseBool(actual)
|
||||
return eerr == nil && aerr == nil && e == a
|
||||
}
|
||||
|
||||
func binaryMatch(expected, actual string) bool {
|
||||
e, eerr := base64.StdEncoding.DecodeString(expected)
|
||||
a, aerr := base64.StdEncoding.DecodeString(actual)
|
||||
return eerr == nil && aerr == nil && bytes.Equal(e, a)
|
||||
}
|
||||
|
||||
// ipMatch reports whether actual (an address) falls within cidr (a CIDR
|
||||
// range, or an exact address treated as a /32 or /128), matching IAM's
|
||||
// IpAddress/NotIpAddress condition operators. An unparseable operand on
|
||||
// either side never matches (fails closed) rather than erroring.
|
||||
func ipMatch(cidr, actual string) bool {
|
||||
c := cidr
|
||||
if !strings.Contains(c, "/") {
|
||||
if ip := net.ParseIP(c); ip != nil && ip.To4() != nil {
|
||||
c += "/32"
|
||||
} else {
|
||||
c += "/128"
|
||||
}
|
||||
}
|
||||
_, network, err := net.ParseCIDR(c)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ip := net.ParseIP(actual)
|
||||
return ip != nil && network.Contains(ip)
|
||||
}
|
||||
|
||||
// parsedOperator is a condition operator name decomposed into its set
|
||||
// qualifier, base operator, and IfExists flag.
|
||||
type parsedOperator struct {
|
||||
qualifier conditionQualifier
|
||||
base string
|
||||
ifExists bool
|
||||
}
|
||||
|
||||
// parseOperatorName decomposes name (e.g. "ForAllValues:StringNotEqualsIfExists")
|
||||
// into a parsedOperator, reporting ok=false if the base operator (after
|
||||
// stripping a recognized qualifier prefix and IfExists suffix) isn't one
|
||||
// conditionRegistry recognizes, or is "Null" (Null has no IfExists variant -
|
||||
// "NullIfExists" is rejected here since after suffix-stripping "Null" isn't
|
||||
// itself in conditionRegistry). A bare "Null", optionally qualifier-prefixed, is accepted
|
||||
func parseOperatorName(name string) (parsedOperator, bool) {
|
||||
op := name
|
||||
qualifier := qualifierNone
|
||||
switch {
|
||||
case strings.HasPrefix(op, "ForAllValues:"):
|
||||
qualifier = qualifierForAllValues
|
||||
op = strings.TrimPrefix(op, "ForAllValues:")
|
||||
case strings.HasPrefix(op, "ForAnyValue:"):
|
||||
qualifier = qualifierForAnyValue
|
||||
op = strings.TrimPrefix(op, "ForAnyValue:")
|
||||
}
|
||||
|
||||
if op == "Null" {
|
||||
return parsedOperator{qualifier: qualifier, base: "Null"}, true
|
||||
}
|
||||
|
||||
base := strings.TrimSuffix(op, "IfExists")
|
||||
ifExists := base != op
|
||||
if _, ok := conditionRegistry[base]; !ok {
|
||||
return parsedOperator{}, false
|
||||
}
|
||||
return parsedOperator{qualifier: qualifier, base: base, ifExists: ifExists}, true
|
||||
}
|
||||
|
||||
// conditionShapeValid checks raw (a statement's Condition block) against
|
||||
// IAM's condition grammar for write-time validation: an object of operator
|
||||
// -> (key -> value), where every operator name is recognized by
|
||||
// parseOperatorName. An absent, null, or empty Condition is valid (matches
|
||||
// evaluateCondition's "always matches" contract).
|
||||
func conditionShapeValid(raw json.RawMessage) bool {
|
||||
if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||
return true
|
||||
}
|
||||
var block map[string]map[string]ConditionValues
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
return false
|
||||
}
|
||||
for operator := range block {
|
||||
if _, ok := parseOperatorName(operator); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// conditionVariableOperators is the subset of conditionRegistry that AWS
|
||||
// documents as supporting ${...} policy-variable substitution in a
|
||||
// Condition value: the String family and the Arn family (both ultimately
|
||||
// whole-string comparisons). AWS's policy-variable documentation
|
||||
// specifically excludes Numeric, Date, Boolean, Binary, IP address, and
|
||||
// Null operators - a variable placed there is never substituted, regardless
|
||||
// of document version.
|
||||
var conditionVariableOperators = map[string]bool{
|
||||
"StringEquals": true,
|
||||
"StringNotEquals": true,
|
||||
"StringEqualsIgnoreCase": true,
|
||||
"StringNotEqualsIgnoreCase": true,
|
||||
"StringLike": true,
|
||||
"StringNotLike": true,
|
||||
"ArnEquals": true,
|
||||
"ArnLike": true,
|
||||
"ArnNotEquals": true,
|
||||
"ArnNotLike": true,
|
||||
}
|
||||
|
||||
// evaluateCondition evaluates a policy statement's Condition block against
|
||||
// ctxVars - a "<provider-url>:<claim>" keyed context for trust-policy
|
||||
// evaluation, or an "aws:<GlobalKey>" keyed context for identity-policy
|
||||
// evaluation. An absent or empty Condition always matches. version is the
|
||||
// enclosing document's Version element: a ${...} policy variable in a
|
||||
// Condition value is only ever substituted when version is exactly
|
||||
// Version2012 AND the operator is one of conditionVariableOperators -
|
||||
// AWS requires the 2012-10-17 policy version to use variables at all, and
|
||||
// never expands them for Numeric/Date/Bool/Binary/IP/Null operators even
|
||||
// then. A variable that doesn't qualify is left as literal text, the
|
||||
// same fallback used for an absent/multivalued context key - so it simply
|
||||
// won't match a real condition value, rather than silently expanding into
|
||||
// something AWS itself wouldn't.
|
||||
//
|
||||
// matched reports whether the condition holds; ok reports whether it could
|
||||
// be evaluated at all. ok is false only for a Condition block whose JSON
|
||||
// shape or operator name conditionShapeValid would already reject - i.e.
|
||||
// only for a document stored before that write-time validation existed, or
|
||||
// containing a future operator this package doesn't yet recognize. Callers
|
||||
// MUST treat ok=false as "cannot rule out a hidden Deny" and deny the whole
|
||||
// evaluation, never as a non-match - see EvaluateIdentityPolicies and
|
||||
// EvaluateWebIdentityTrust.
|
||||
func evaluateCondition(raw json.RawMessage, ctxVars map[string][]string, version string) (matched bool, ok bool) {
|
||||
if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
||||
return true, true
|
||||
}
|
||||
|
||||
var block map[string]map[string]ConditionValues
|
||||
if err := json.Unmarshal(raw, &block); err != nil {
|
||||
debuglogger.Logf("policy condition block failed to parse: %v", err)
|
||||
return false, false
|
||||
}
|
||||
|
||||
for operator, kvs := range block {
|
||||
op, recognized := parseOperatorName(operator)
|
||||
if !recognized {
|
||||
debuglogger.Logf("policy condition: unrecognized operator %q", operator)
|
||||
return false, false
|
||||
}
|
||||
for key, expected := range kvs {
|
||||
actual, present := lookupContextValues(ctxVars, key)
|
||||
if version == Version2012 && conditionVariableOperators[op.base] {
|
||||
expected = substituteConditionValues(expected, ctxVars)
|
||||
}
|
||||
if !evaluateConditionKey(op, expected, actual, present) {
|
||||
return false, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true, true
|
||||
}
|
||||
|
||||
// lookupContextValues retrieves ctxVars[key], matching key
|
||||
// case-insensitively: AWS documents condition (and policy-variable) key
|
||||
// *names* as case-insensitive - "aws:SourceIp" and "AWS:SOURCEIP" name the
|
||||
// same key - even though the values held under that key remain
|
||||
// case-sensitive. An exact match is tried first so the common case doesn't
|
||||
// pay for a map scan.
|
||||
func lookupContextValues(ctxVars map[string][]string, key string) ([]string, bool) {
|
||||
if v, ok := ctxVars[key]; ok {
|
||||
return v, true
|
||||
}
|
||||
for k, v := range ctxVars {
|
||||
if strings.EqualFold(k, key) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// policyVariablePattern matches a single "${...}" policy-variable
|
||||
// placeholder, e.g. "${aws:username}".
|
||||
var policyVariablePattern = regexp.MustCompile(`\$\{([A-Za-z0-9_:.\-]+)\}`)
|
||||
|
||||
// substitutePolicyVariables replaces every ${key} placeholder in s with the
|
||||
// single value ctxVars holds for key, looked up the same case-insensitive
|
||||
// way as a Condition key. AWS only allows a single-valued context key to be
|
||||
// used as a policy variable; a placeholder naming an absent or multivalued
|
||||
// key is left as literal text, same as any other substring - so it simply
|
||||
// won't match a real resource ARN or condition value, rather than being
|
||||
// silently dropped and turning a Deny that relies on it into a no-op.
|
||||
func substitutePolicyVariables(s string, ctxVars map[string][]string) string {
|
||||
if !strings.Contains(s, "${") {
|
||||
return s
|
||||
}
|
||||
return policyVariablePattern.ReplaceAllStringFunc(s, func(match string) string {
|
||||
key := match[2 : len(match)-1]
|
||||
values, ok := lookupContextValues(ctxVars, key)
|
||||
if !ok || len(values) != 1 {
|
||||
return match
|
||||
}
|
||||
return values[0]
|
||||
})
|
||||
}
|
||||
|
||||
// substituteConditionValues applies substitutePolicyVariables to every
|
||||
// element of values, so e.g. a Condition of
|
||||
// {"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}} compares
|
||||
// against the requester's own username rather than the literal text.
|
||||
func substituteConditionValues(values ConditionValues, ctxVars map[string][]string) ConditionValues {
|
||||
out := make(ConditionValues, len(values))
|
||||
for i, v := range values {
|
||||
out[i] = substitutePolicyVariables(v, ctxVars)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// evaluateConditionKey evaluates one operator/key pair of an already
|
||||
// -parsed Condition block against actual (ctxVars[key]) and present
|
||||
// (whether key was in ctxVars at all).
|
||||
func evaluateConditionKey(op parsedOperator, expected ConditionValues, actual []string, present bool) bool {
|
||||
if op.base == "Null" {
|
||||
return evaluateNull(expected, present)
|
||||
}
|
||||
entry := conditionRegistry[op.base] // guaranteed present - parseOperatorName already validated op.base
|
||||
|
||||
if op.qualifier == qualifierForAllValues && !present {
|
||||
return true
|
||||
}
|
||||
if entry.negate {
|
||||
if !present {
|
||||
return true
|
||||
}
|
||||
return aggregate(op.qualifier, true, expected, actual, entry.compare)
|
||||
}
|
||||
if !present {
|
||||
return op.ifExists
|
||||
}
|
||||
return aggregate(op.qualifier, false, expected, actual, entry.compare)
|
||||
}
|
||||
|
||||
// evaluateNull implements the Null condition operator: true if expected
|
||||
// (normally exactly one of "true"/"false", case-insensitive) says the key
|
||||
// must be absent ("true") and it is, or must be present ("false") and it
|
||||
// is. A value that's neither "true" nor "false" never satisfies the
|
||||
// condition (fails closed)
|
||||
func evaluateNull(expected ConditionValues, present bool) bool {
|
||||
for _, e := range expected {
|
||||
switch {
|
||||
case strings.EqualFold(e, "true"):
|
||||
if !present {
|
||||
return true
|
||||
}
|
||||
case strings.EqualFold(e, "false"):
|
||||
if present {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// aggregate reports whether expected/actual satisfy a condition-key match
|
||||
// under qualifier's multivalued-context-key semantics. negate selects the
|
||||
// Not-operator family, sharing the same per-pair comparator as its positive
|
||||
// counterpart (see conditionRegistry).
|
||||
func aggregate(qualifier conditionQualifier, negate bool, expected ConditionValues, actual []string, cmp conditionComparator) bool {
|
||||
matchesAny := func(a string) bool {
|
||||
for _, e := range expected {
|
||||
if cmp(e, a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
useForAll := qualifier == qualifierForAllValues || (qualifier == qualifierNone && negate)
|
||||
if useForAll {
|
||||
for _, a := range actual {
|
||||
if ok := matchesAny(a); ok == negate {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true // vacuously true over an empty/absent actual
|
||||
}
|
||||
for _, a := range actual {
|
||||
if ok := matchesAny(a); ok != negate {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false // vacuously false over an empty/absent actual
|
||||
}
|
||||
@@ -1,761 +0,0 @@
|
||||
// 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 (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// evalCondTest is the shared table shape for every TestEvaluateCondition*
|
||||
// function below. wantErr means "evaluateCondition's ok return should be
|
||||
// false" (the block's shape or an operator name couldn't be recognized) -
|
||||
// distinct from want=false, which means the condition was evaluated fine
|
||||
// but didn't match.
|
||||
type evalCondTest struct {
|
||||
name string
|
||||
raw string
|
||||
ctxVars map[string][]string
|
||||
// version is the enclosing document's Version element: a Condition
|
||||
// value's ${...} policy variable is only ever substituted
|
||||
// when this is exactly Version2012. Left "" (no Version) for every
|
||||
// existing case except the ones specifically testing substitution.
|
||||
version string
|
||||
want bool
|
||||
wantErr bool
|
||||
}
|
||||
|
||||
func runEvalCondTests(t *testing.T, tests []evalCondTest) {
|
||||
t.Helper()
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matched, ok := evaluateCondition([]byte(tt.raw), tt.ctxVars, tt.version)
|
||||
wantOk := !tt.wantErr
|
||||
if ok != wantOk {
|
||||
t.Fatalf("evaluateCondition() ok = %v, want %v", ok, wantOk)
|
||||
}
|
||||
if ok && matched != tt.want {
|
||||
t.Errorf("evaluateCondition() matched = %v, want %v", matched, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateCondition(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{name: "empty condition always matches", raw: ``, want: true},
|
||||
{
|
||||
name: "StringEquals matches",
|
||||
raw: `{"StringEquals":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"client1"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringEquals mismatch",
|
||||
raw: `{"StringEquals":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"other"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringEquals missing key fails closed",
|
||||
raw: `{"StringEquals":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringEquals against multivalued context matches any",
|
||||
raw: `{"StringEquals":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"other", "client1"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringEquals against multivalued condition matches any",
|
||||
raw: `{"StringEquals":{"example.com:aud":["client1","client2"]}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"client2"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringNotEquals matches when different",
|
||||
raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"other"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringNotEquals fails when equal",
|
||||
raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"client1"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringNotEquals matches when key absent",
|
||||
raw: `{"StringNotEquals":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringNotEqualsIfExists is accepted and behaves like StringNotEquals",
|
||||
raw: `{"StringNotEqualsIfExists":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"client1"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringLike wildcard matches",
|
||||
raw: `{"StringLike":{"example.com:sub":"user-*"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"user-123"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringLike wildcard mismatch",
|
||||
raw: `{"StringLike":{"example.com:sub":"admin-*"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"user-123"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringLikeIfExists enforces match when key present",
|
||||
raw: `{"StringLikeIfExists":{"example.com:sub":"admin-*"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"user-123"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringLikeIfExists passes when key absent",
|
||||
raw: `{"StringLikeIfExists":{"example.com:sub":"admin-*"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringNotLike matches when pattern doesn't match",
|
||||
raw: `{"StringNotLike":{"example.com:sub":"admin-*"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"user-123"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringEqualsIgnoreCase matches regardless of case",
|
||||
raw: `{"StringEqualsIgnoreCase":{"example.com:sub":"Alice"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"alice"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringEqualsIgnoreCase mismatch",
|
||||
raw: `{"StringEqualsIgnoreCase":{"example.com:sub":"alice"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"bob"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringNotEqualsIgnoreCase matches when different regardless of case",
|
||||
raw: `{"StringNotEqualsIgnoreCase":{"example.com:sub":"Alice"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"bob"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringNotEqualsIgnoreCase fails when equal regardless of case",
|
||||
raw: `{"StringNotEqualsIgnoreCase":{"example.com:sub":"Alice"}}`,
|
||||
ctxVars: map[string][]string{"example.com:sub": {"alice"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "StringEqualsIfExists passes when key absent",
|
||||
raw: `{"StringEqualsIfExists":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "StringEqualsIfExists enforces match when key present",
|
||||
raw: `{"StringEqualsIfExists":{"example.com:aud":"client1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"other"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "multiple operators must all pass",
|
||||
raw: `{"StringEquals":{"example.com:aud":"client1"},"StringLike":{"example.com:sub":"user-*"}}`,
|
||||
ctxVars: map[string][]string{"example.com:aud": {"client1"}, "example.com:sub": {"user-1"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "unrecognized operator fails closed",
|
||||
raw: `{"FooBarOperator":{"example.com:level":"1"}}`,
|
||||
ctxVars: map[string][]string{"example.com:level": {"1"}},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed condition JSON fails closed",
|
||||
raw: `not json`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed condition block shape (operator value not an object) fails closed",
|
||||
raw: `{"StringEquals":"not an object"}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "malformed condition block shape (operator value is an array) fails closed",
|
||||
raw: `{"StringEquals":["not","a","map"]}`,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
// Condition key *names* are case-insensitive in AWS, even
|
||||
// though the values they hold remain case-sensitive.
|
||||
name: "condition key name matches case-insensitively",
|
||||
raw: `{"StringEquals":{"AWS:UserName":"alice"}}`,
|
||||
ctxVars: map[string][]string{"aws:username": {"alice"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "condition key name case-insensitive match still compares values case-sensitively",
|
||||
raw: `{"StringEquals":{"AWS:UserName":"Alice"}}`,
|
||||
ctxVars: map[string][]string{"aws:username": {"alice"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// A policy variable in a Condition value is substituted from
|
||||
// the request context before comparing, the same as a
|
||||
// Resource pattern.
|
||||
name: "policy variable in condition value is substituted under version 2012-10-17",
|
||||
raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}}`,
|
||||
ctxVars: map[string][]string{"aws:username": {"alice"}, "iam:ResourceTag/owner": {"alice"}},
|
||||
version: Version2012,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "policy variable naming an absent key is left literal and so fails to match",
|
||||
raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:nonexistent}"}}`,
|
||||
ctxVars: map[string][]string{"iam:ResourceTag/owner": {"alice"}},
|
||||
version: Version2012,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// Without an explicit 2012-10-17 Version, AWS does not expand
|
||||
// policy variables at all - the "${aws:username}" text is
|
||||
// compared literally and so never matches a real tag value.
|
||||
name: "policy variable is not substituted without version 2012-10-17",
|
||||
raw: `{"StringEquals":{"iam:ResourceTag/owner":"${aws:username}"}}`,
|
||||
ctxVars: map[string][]string{"aws:username": {"alice"}, "iam:ResourceTag/owner": {"alice"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
// AWS never expands policy variables inside Numeric/Date/
|
||||
// Bool/Binary/IP/Null operators, even under version 2012-10-17 -
|
||||
// a NumericEquals comparing aws:EpochTime against a literal
|
||||
// "${aws:EpochTime}" never self-matches.
|
||||
name: "policy variable is not substituted inside NumericEquals even under version 2012-10-17",
|
||||
raw: `{"NumericEquals":{"aws:EpochTime":"${aws:EpochTime}"}}`,
|
||||
ctxVars: map[string][]string{"aws:EpochTime": {"1700000000"}},
|
||||
version: Version2012,
|
||||
want: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateConditionNumeric(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: "NumericEquals matches",
|
||||
raw: `{"NumericEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"5"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericEquals mismatch",
|
||||
raw: `{"NumericEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"6"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "NumericEquals accepts a bare JSON number condition value",
|
||||
raw: `{"NumericEquals":{"s3:max-keys":5}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"5"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericEquals unparseable actual operand fails closed, not an error",
|
||||
raw: `{"NumericEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"not-a-number"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "NumericNotEquals matches when different",
|
||||
raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"6"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericNotEquals fails when equal",
|
||||
raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"5"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "NumericNotEquals matches when key absent",
|
||||
raw: `{"NumericNotEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericLessThan matches",
|
||||
raw: `{"NumericLessThan":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"3"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericLessThan boundary does not match",
|
||||
raw: `{"NumericLessThan":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"5"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "NumericLessThanEquals boundary matches",
|
||||
raw: `{"NumericLessThanEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"5"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericGreaterThan matches",
|
||||
raw: `{"NumericGreaterThan":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"7"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericGreaterThan boundary does not match",
|
||||
raw: `{"NumericGreaterThan":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"5"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "NumericGreaterThanEquals boundary matches",
|
||||
raw: `{"NumericGreaterThanEquals":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{"s3:max-keys": {"5"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NumericGreaterThanEqualsIfExists passes when key absent",
|
||||
raw: `{"NumericGreaterThanEqualsIfExists":{"s3:max-keys":"5"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateConditionDate(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: "DateEquals matches same instant in RFC3339",
|
||||
raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DateEquals matches across RFC3339 vs epoch-seconds formats",
|
||||
raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"1704067200"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DateEquals mismatch",
|
||||
raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "DateNotEquals matches when different",
|
||||
raw: `{"DateNotEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DateNotEquals matches when key absent",
|
||||
raw: `{"DateNotEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DateLessThan matches",
|
||||
raw: `{"DateLessThan":{"aws:CurrentTime":"2024-06-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DateGreaterThan matches",
|
||||
raw: `{"DateGreaterThan":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"2024-06-01T00:00:00Z"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DateGreaterThanEquals boundary matches",
|
||||
raw: `{"DateGreaterThanEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "DateLessThanEquals boundary matches",
|
||||
raw: `{"DateLessThanEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"2024-01-01T00:00:00Z"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Date operator unparseable operand fails closed, not an error",
|
||||
raw: `{"DateEquals":{"aws:CurrentTime":"2024-01-01T00:00:00Z"}}`,
|
||||
ctxVars: map[string][]string{"aws:CurrentTime": {"not-a-date"}},
|
||||
want: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateConditionBool(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: "Bool matches",
|
||||
raw: `{"Bool":{"example.com:admin":"true"}}`,
|
||||
ctxVars: map[string][]string{"example.com:admin": {"true"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Bool mismatch",
|
||||
raw: `{"Bool":{"example.com:admin":"true"}}`,
|
||||
ctxVars: map[string][]string{"example.com:admin": {"false"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Bool absent key fails closed",
|
||||
raw: `{"Bool":{"example.com:admin":"true"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "BoolIfExists passes when key absent",
|
||||
raw: `{"BoolIfExists":{"example.com:admin":"true"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Bool garbage value fails closed, not an error",
|
||||
raw: `{"Bool":{"example.com:admin":"true"}}`,
|
||||
ctxVars: map[string][]string{"example.com:admin": {"yes"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Bool accepts a bare JSON boolean condition value",
|
||||
raw: `{"Bool":{"example.com:admin":true}}`,
|
||||
ctxVars: map[string][]string{"example.com:admin": {"true"}},
|
||||
want: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateConditionBinary(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: "BinaryEquals matches",
|
||||
raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`,
|
||||
ctxVars: map[string][]string{"example.com:token": {"aGVsbG8="}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "BinaryEquals mismatch",
|
||||
raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`,
|
||||
ctxVars: map[string][]string{"example.com:token": {"d29ybGQ="}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "BinaryEquals invalid base64 fails closed, not an error",
|
||||
raw: `{"BinaryEquals":{"example.com:token":"aGVsbG8="}}`,
|
||||
ctxVars: map[string][]string{"example.com:token": {"not-valid-base64!!"}},
|
||||
want: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateConditionArn(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: "ArnLike wildcard matches",
|
||||
raw: `{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`,
|
||||
ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ArnLike cross-account mismatch",
|
||||
raw: `{"ArnLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`,
|
||||
ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::999999999999:role/foo"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ArnEquals behaves identically to ArnLike (wildcard-aware)",
|
||||
raw: `{"ArnEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`,
|
||||
ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ArnNotLike matches a non-matching ARN",
|
||||
raw: `{"ArnNotLike":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`,
|
||||
ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::999999999999:role/foo"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ArnNotEquals fails when the ARN matches",
|
||||
raw: `{"ArnNotEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`,
|
||||
ctxVars: map[string][]string{"aws:PrincipalArn": {"arn:aws:iam::123456789012:role/foo"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ArnNotEquals matches when key absent",
|
||||
raw: `{"ArnNotEquals":{"aws:PrincipalArn":"arn:aws:iam::123456789012:role/*"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateConditionIP(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: "IpAddress CIDR matches",
|
||||
raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`,
|
||||
ctxVars: map[string][]string{"aws:SourceIp": {"10.1.2.3"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "IpAddress CIDR mismatch",
|
||||
raw: `{"IpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`,
|
||||
ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "IpAddress exact address treated as /32",
|
||||
raw: `{"IpAddress":{"aws:SourceIp":"203.0.113.5"}}`,
|
||||
ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NotIpAddress matches an address outside the range",
|
||||
raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`,
|
||||
ctxVars: map[string][]string{"aws:SourceIp": {"203.0.113.5"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NotIpAddress fails for an address inside the range",
|
||||
raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`,
|
||||
ctxVars: map[string][]string{"aws:SourceIp": {"10.1.2.3"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "NotIpAddress matches when key absent",
|
||||
raw: `{"NotIpAddress":{"aws:SourceIp":"10.0.0.0/8"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvaluateConditionNull(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: `Null "true" matches when key absent`,
|
||||
raw: `{"Null":{"aws:username":"true"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: `Null "true" fails when key present`,
|
||||
raw: `{"Null":{"aws:username":"true"}}`,
|
||||
ctxVars: map[string][]string{"aws:username": {"alice"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: `Null "false" fails when key absent`,
|
||||
raw: `{"Null":{"aws:username":"false"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: `Null "false" matches when key present`,
|
||||
raw: `{"Null":{"aws:username":"false"}}`,
|
||||
ctxVars: map[string][]string{"aws:username": {"alice"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "Null garbage value never satisfies",
|
||||
raw: `{"Null":{"aws:username":"maybe"}}`,
|
||||
ctxVars: map[string][]string{"aws:username": {"alice"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ForAllValues:Null is accepted and behaves like plain Null",
|
||||
raw: `{"ForAllValues:Null":{"aws:username":"true"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "NullIfExists is rejected - Null has no IfExists variant",
|
||||
raw: `{"NullIfExists":{"aws:username":"true"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
wantErr: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
func TestEvaluateConditionQualifiers(t *testing.T) {
|
||||
runEvalCondTests(t, []evalCondTest{
|
||||
{
|
||||
name: "unqualified StringNotEquals denies when any actual value matches (pre-existing behavior, unchanged)",
|
||||
raw: `{"StringNotEquals":{"example.com:groups":"banned"}}`,
|
||||
ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ForAllValues:StringNotEquals denies when any actual value matches",
|
||||
raw: `{"ForAllValues:StringNotEquals":{"example.com:groups":"banned"}}`,
|
||||
ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ForAnyValue:StringNotEquals allows when at least one actual value doesn't match",
|
||||
raw: `{"ForAnyValue:StringNotEquals":{"example.com:groups":"banned"}}`,
|
||||
ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ForAllValues:StringEquals matches when every actual value is in the set",
|
||||
raw: `{"ForAllValues:StringEquals":{"example.com:groups":["admin","banned"]}}`,
|
||||
ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ForAllValues:StringEquals fails when one actual value is outside the set",
|
||||
raw: `{"ForAllValues:StringEquals":{"example.com:groups":["admin","banned"]}}`,
|
||||
ctxVars: map[string][]string{"example.com:groups": {"admin", "manager"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "ForAllValues:StringEquals vacuously matches when the key is entirely absent",
|
||||
raw: `{"ForAllValues:StringEquals":{"example.com:groups":"banned"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ForAllValues:StringNotEquals vacuously matches when the key is entirely absent",
|
||||
raw: `{"ForAllValues:StringNotEquals":{"example.com:groups":"banned"}}`,
|
||||
ctxVars: map[string][]string{},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "ForAnyValue:StringEquals matches when at least one actual value is in the set",
|
||||
raw: `{"ForAnyValue:StringEquals":{"example.com:groups":"banned"}}`,
|
||||
ctxVars: map[string][]string{"example.com:groups": {"admin", "banned"}},
|
||||
want: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestConditionValuesUnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want ConditionValues
|
||||
wantErr bool
|
||||
}{
|
||||
{"string", `"alice"`, ConditionValues{"alice"}, false},
|
||||
{"integer number, unquoted", `5`, ConditionValues{"5"}, false},
|
||||
{"decimal number preserves literal text", `5.50`, ConditionValues{"5.50"}, false},
|
||||
{"bool true", `true`, ConditionValues{"true"}, false},
|
||||
{"bool false", `false`, ConditionValues{"false"}, false},
|
||||
{"array of strings", `["a","b"]`, ConditionValues{"a", "b"}, false},
|
||||
{"array mixing string/number/bool", `["a",5,true]`, ConditionValues{"a", "5", "true"}, false},
|
||||
{"null is rejected", `null`, nil, true},
|
||||
{"null array element is rejected", `["a",null]`, nil, true},
|
||||
{"nested array element is rejected", `[["a"]]`, nil, true},
|
||||
{"object element is rejected", `{"a":"b"}`, nil, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var got ConditionValues
|
||||
err := got.UnmarshalJSON([]byte(tt.json))
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("UnmarshalJSON() error = nil, want non-nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("UnmarshalJSON() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("UnmarshalJSON() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOperatorName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
op string
|
||||
wantOk bool
|
||||
wantBase string
|
||||
wantIfExists bool
|
||||
wantQualif conditionQualifier
|
||||
}{
|
||||
{name: "StringEquals", op: "StringEquals", wantOk: true, wantBase: "StringEquals"},
|
||||
{name: "StringEqualsIfExists", op: "StringEqualsIfExists", wantOk: true, wantBase: "StringEquals", wantIfExists: true},
|
||||
{name: "NumericGreaterThanEquals", op: "NumericGreaterThanEquals", wantOk: true, wantBase: "NumericGreaterThanEquals"},
|
||||
{name: "DateLessThanIfExists", op: "DateLessThanIfExists", wantOk: true, wantBase: "DateLessThan", wantIfExists: true},
|
||||
{name: "Bool", op: "Bool", wantOk: true, wantBase: "Bool"},
|
||||
{name: "BoolIfExists", op: "BoolIfExists", wantOk: true, wantBase: "Bool", wantIfExists: true},
|
||||
{name: "BinaryEquals", op: "BinaryEquals", wantOk: true, wantBase: "BinaryEquals"},
|
||||
{name: "ArnLike", op: "ArnLike", wantOk: true, wantBase: "ArnLike"},
|
||||
{name: "IpAddress", op: "IpAddress", wantOk: true, wantBase: "IpAddress"},
|
||||
{name: "Null", op: "Null", wantOk: true, wantBase: "Null"},
|
||||
{name: "ForAllValues:StringEquals", op: "ForAllValues:StringEquals", wantOk: true, wantBase: "StringEquals", wantQualif: qualifierForAllValues},
|
||||
{name: "ForAnyValue:StringNotEqualsIfExists", op: "ForAnyValue:StringNotEqualsIfExists", wantOk: true, wantBase: "StringNotEquals", wantIfExists: true, wantQualif: qualifierForAnyValue},
|
||||
{name: "ForAllValues:Null accepted, qualifier is a no-op", op: "ForAllValues:Null", wantOk: true, wantBase: "Null", wantQualif: qualifierForAllValues},
|
||||
{name: "NullIfExists rejected", op: "NullIfExists", wantOk: false},
|
||||
{name: "unrecognized base", op: "FooBarOperator", wantOk: false},
|
||||
{name: "unrecognized qualifier prefix left as part of the name", op: "ForSomeValues:StringEquals", wantOk: false},
|
||||
{name: "empty string", op: "", wantOk: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := parseOperatorName(tt.op)
|
||||
if ok != tt.wantOk {
|
||||
t.Fatalf("parseOperatorName(%q) ok = %v, want %v", tt.op, ok, tt.wantOk)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if got.base != tt.wantBase || got.ifExists != tt.wantIfExists || got.qualifier != tt.wantQualif {
|
||||
t.Fatalf("parseOperatorName(%q) = %+v, want {base:%q ifExists:%v qualifier:%v}", tt.op, got, tt.wantBase, tt.wantIfExists, tt.wantQualif)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobMatch(t *testing.T) {
|
||||
tests := []struct {
|
||||
pattern, s string
|
||||
want bool
|
||||
}{
|
||||
{pattern: "user-*", s: "user-123", want: true},
|
||||
{pattern: "user-*", s: "admin-123", want: false},
|
||||
{pattern: "user-?23", s: "user-123", want: true},
|
||||
{pattern: "user-?23", s: "user-1123", want: false},
|
||||
{pattern: "*", s: "anything", want: true},
|
||||
{pattern: "exact", s: "exact", want: true},
|
||||
{pattern: "exact", s: "exacts", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := globMatch(tt.pattern, tt.s); got != tt.want {
|
||||
t.Errorf("globMatch(%q, %q) = %v, want %v", tt.pattern, tt.s, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+47
-21
@@ -19,6 +19,7 @@ import (
|
||||
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
"github.com/versity/versitygw/internal/condition"
|
||||
)
|
||||
|
||||
// MaxSessionPolicyBytes is the maximum length, in bytes, of the optional
|
||||
@@ -45,31 +46,53 @@ type RequestContext struct {
|
||||
Condition map[string][]string
|
||||
}
|
||||
|
||||
// EvaluateIdentityPolicies reports whether reqCtx is allowed by documents
|
||||
// (each a user's or role's inline policy entry), using IAM's evaluation
|
||||
// semantics: a statement must cover the action, the resource, and (if
|
||||
// present) its Condition block to be considered at all; an explicit Deny
|
||||
// statement that does so makes the whole evaluation deny regardless of any
|
||||
// Allow found elsewhere (in the same or another document), and absent an
|
||||
// explicit deny, at least one covering Allow statement is required — so an
|
||||
// identity with no matching statement at all is denied by default.
|
||||
// Decision is the tri-state result of evaluating a set of identity policy
|
||||
// documents. A caller combining this with another policy source (e.g. an S3
|
||||
// bucket policy) needs this distinction, not a plain bool, to implement
|
||||
// AWS's real cross-policy precedence: an explicit Deny from either source
|
||||
// wins outright over an Allow from the other, but a NoMatch from one source
|
||||
// leaves the other free to grant access on its own.
|
||||
type Decision int
|
||||
|
||||
const (
|
||||
// DecisionNoMatch means no statement in any document matched reqCtx at
|
||||
// all — neither an Allow nor a Deny.
|
||||
DecisionNoMatch Decision = iota
|
||||
// DecisionAllow means at least one statement matched with Effect Allow,
|
||||
// and no statement matched with Effect Deny.
|
||||
DecisionAllow
|
||||
// DecisionDeny means a statement matched with Effect Deny, or the
|
||||
// evaluation failed closed (unparseable/invalid document, or a
|
||||
// Condition that couldn't be evaluated).
|
||||
DecisionDeny
|
||||
)
|
||||
|
||||
// EvaluateIdentityPolicies reports how documents (each a user's or role's
|
||||
// inline policy entry) decide reqCtx, using IAM's evaluation semantics: a
|
||||
// statement must cover the action, the resource, and (if present) its
|
||||
// Condition block to be considered at all; a matching explicit Deny
|
||||
// statement makes the whole evaluation DecisionDeny regardless of any Allow
|
||||
// found elsewhere (in the same or another document); absent an explicit
|
||||
// deny, at least one covering Allow statement is required for DecisionAllow
|
||||
// — an identity with no matching statement at all gets DecisionNoMatch, not
|
||||
// DecisionAllow.
|
||||
//
|
||||
// A document that fails to parse, or a statement whose Condition block can't
|
||||
// be evaluated (see evaluateCondition's ok return), denies the whole
|
||||
// evaluation rather than being skipped: PutUserPolicy/PutRolePolicy already
|
||||
// reject any policy document that wouldn't parse or whose Condition uses an
|
||||
// A document that fails to parse, or a statement whose Condition block
|
||||
// can't be evaluated, returns DecisionDeny
|
||||
// rather than being skipped: PutUserPolicy/PutRolePolicy already reject any
|
||||
// policy document that wouldn't parse or whose Condition uses an
|
||||
// unrecognized operator, so this only matters for documents written before
|
||||
// that validation existed - and for exactly that legacy-data case, we can't
|
||||
// rule out a hidden Deny inside the part we can't evaluate, so the safe
|
||||
// outcome is to deny rather than silently proceed as if it wasn't there.
|
||||
func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestContext) bool {
|
||||
func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestContext) Decision {
|
||||
allowed := false
|
||||
|
||||
for _, entry := range documents {
|
||||
var doc Document
|
||||
if err := json.Unmarshal([]byte(entry.PolicyDocument), &doc); err != nil {
|
||||
debuglogger.Logf("identity policy document failed to parse: %v", err)
|
||||
return false
|
||||
return DecisionDeny
|
||||
}
|
||||
// PutUserPolicy/PutRolePolicy already reject a document that
|
||||
// wouldn't pass Validate (e.g. both Action and NotAction on one
|
||||
@@ -81,7 +104,7 @@ func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestConte
|
||||
// not just at ingress.
|
||||
if err := doc.Validate(); err != nil {
|
||||
debuglogger.Logf("identity policy document failed validation: %v", err)
|
||||
return false
|
||||
return DecisionDeny
|
||||
}
|
||||
|
||||
for _, stmt := range doc.Statement {
|
||||
@@ -94,10 +117,10 @@ func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestConte
|
||||
if !statementCoversResource(stmt, reqCtx.Resource, reqCtx.Condition, doc.Version) {
|
||||
continue
|
||||
}
|
||||
matched, ok := evaluateCondition(stmt.Condition, reqCtx.Condition, doc.Version)
|
||||
matched, ok := condition.Evaluate(stmt.Condition, reqCtx.Condition, doc.Version)
|
||||
if !ok {
|
||||
debuglogger.Logf("identity policy evaluation: statement condition could not be evaluated, denying")
|
||||
return false
|
||||
return DecisionDeny
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
@@ -105,13 +128,16 @@ func EvaluateIdentityPolicies(documents []types.PolicyEntry, reqCtx RequestConte
|
||||
|
||||
if stmt.Effect == "Deny" {
|
||||
debuglogger.Logf("identity policy evaluation: action %q on resource %q explicitly denied", reqCtx.Action, reqCtx.Resource)
|
||||
return false
|
||||
return DecisionDeny
|
||||
}
|
||||
allowed = true
|
||||
}
|
||||
}
|
||||
|
||||
return allowed
|
||||
if allowed {
|
||||
return DecisionAllow
|
||||
}
|
||||
return DecisionNoMatch
|
||||
}
|
||||
|
||||
// statementCoversResource reports whether stmt's Resource/NotResource
|
||||
@@ -140,9 +166,9 @@ func matchAnyResource(patterns []string, resource string, ctxVars map[string][]s
|
||||
for _, p := range patterns {
|
||||
pattern := p
|
||||
if version == Version2012 {
|
||||
pattern = substitutePolicyVariables(p, ctxVars)
|
||||
pattern = condition.SubstitutePolicyVariables(p, ctxVars)
|
||||
}
|
||||
if globMatch(pattern, resource) {
|
||||
if condition.GlobMatch(pattern, resource) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,31 +33,31 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
name string
|
||||
documents []types.PolicyEntry
|
||||
reqCtx RequestContext
|
||||
want bool
|
||||
want Decision
|
||||
}{
|
||||
{
|
||||
name: "no documents denies by default",
|
||||
documents: nil,
|
||||
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
|
||||
want: false,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "wildcard action allows",
|
||||
documents: policyEntries(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"iam:*","Resource":"*"}]}`),
|
||||
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
|
||||
want: true,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "explicit deny overrides an allow in another document",
|
||||
@@ -66,19 +66,19 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"iam:CreateUser","Resource":"*"}]}`,
|
||||
),
|
||||
reqCtx: RequestContext{Action: "iam:CreateUser", Resource: "*"},
|
||||
want: false,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
// A malformed document might have contained a Deny we can no
|
||||
@@ -87,13 +87,13 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
// A Deny guarded by a Condition operator this package doesn't
|
||||
@@ -106,7 +106,7 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
`{"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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
// Fail-closed on a condition-evaluation error isn't scoped to
|
||||
@@ -115,7 +115,7 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
// A document containing any statement Validate() would
|
||||
@@ -128,73 +128,73 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
// ${aws:username} in Resource must resolve to the requesting
|
||||
@@ -203,19 +203,19 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
// AWS requires Version 2012-10-17 to use policy variables at
|
||||
@@ -224,37 +224,37 @@ func TestEvaluateIdentityPolicies(t *testing.T) {
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionNoMatch,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
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,
|
||||
want: DecisionDeny,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
+14
-13
@@ -20,12 +20,13 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/internal/condition"
|
||||
)
|
||||
|
||||
// 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
|
||||
// since it identifies an S3 canonical user id, the legacy S3 user
|
||||
// identifier, which is not planned to be supported here.
|
||||
var trustPrincipalKeys = map[string]bool{
|
||||
"AWS": true,
|
||||
"Service": true,
|
||||
@@ -175,7 +176,7 @@ func (d Document) ValidateTrust() error {
|
||||
// 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
|
||||
// condition.ShapeValid, shared with the identity-policy side; condition
|
||||
// *keys* and operand *values* are deliberately not validated here, matching
|
||||
// AWS behavior).
|
||||
func (s Statement) ValidateTrust() error {
|
||||
@@ -197,7 +198,7 @@ func (s Statement) ValidateTrust() error {
|
||||
return err
|
||||
}
|
||||
|
||||
if !conditionShapeValid(s.Condition) {
|
||||
if !condition.ShapeValid(s.Condition) {
|
||||
return errTrustSyntax
|
||||
}
|
||||
|
||||
@@ -324,9 +325,9 @@ func validateSharedProviderTenancy(s Statement, federated []string) error {
|
||||
}
|
||||
|
||||
// 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
|
||||
// principal ARN shaped like "arn:aws:iam::<account>:oidc-provider/<url>",
|
||||
// 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
|
||||
@@ -356,23 +357,23 @@ func oidcProviderURLFromFederatedArn(value string) (string, bool) {
|
||||
// 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
|
||||
// condition.ShapeValid/condition.Evaluate 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 {
|
||||
block, err := condition.Parse(raw)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for operator, kvs := range block {
|
||||
op, ok := parseOperatorName(operator)
|
||||
op, ok := condition.ParseOperatorName(operator)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch op.base {
|
||||
switch op.Base {
|
||||
case "StringEquals", "StringLike", "StringEqualsIgnoreCase":
|
||||
default:
|
||||
continue
|
||||
@@ -385,7 +386,7 @@ func conditionScopesClaim(raw json.RawMessage, key string) bool {
|
||||
if v == "" || v == "*" {
|
||||
continue
|
||||
}
|
||||
if op.base == "StringLike" && !hasNonWildcardCharacter(v) {
|
||||
if op.Base == "StringLike" && !hasNonWildcardCharacter(v) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/iamerr"
|
||||
"github.com/versity/versitygw/internal/condition"
|
||||
)
|
||||
|
||||
// MaxDocumentLength is IAM's parameter-level maximum length for a
|
||||
@@ -122,8 +123,8 @@ func (d Document) Validate() error {
|
||||
// no Principal/NotPrincipal, an Action or NotAction (not both) with
|
||||
// vendor-prefixed values, a Resource or NotResource (not both) with
|
||||
// ARN-shaped values, and - if present - a Condition block whose operators
|
||||
// are all recognized (see conditionShapeValid; condition *keys* and operand
|
||||
// *values* are deliberately not validated here, matching AWS behavior).
|
||||
// are all recognized (condition *keys* and operand *values* are
|
||||
// deliberately not validated here, matching AWS behavior).
|
||||
func (s Statement) Validate() error {
|
||||
switch s.Effect {
|
||||
case "Allow", "Deny":
|
||||
@@ -135,7 +136,7 @@ func (s Statement) Validate() error {
|
||||
return errPrincipalNotAllowed
|
||||
}
|
||||
|
||||
if !conditionShapeValid(s.Condition) {
|
||||
if !condition.ShapeValid(s.Condition) {
|
||||
return errSyntax
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/internal/condition"
|
||||
)
|
||||
|
||||
// AssumeRoleWithWebIdentityAction is the sts action name role trust
|
||||
@@ -182,7 +183,7 @@ func EvaluateWebIdentityTrust(document string, lookup ProviderLookup, wctx WebId
|
||||
}
|
||||
anyIssuerMatch = true
|
||||
|
||||
matched, condOk := evaluateCondition(stmt.Condition, ctxVars, doc.Version)
|
||||
matched, condOk := condition.Evaluate(stmt.Condition, ctxVars, doc.Version)
|
||||
if !condOk {
|
||||
debuglogger.Logf("web identity trust evaluation: statement condition could not be evaluated, denying")
|
||||
denied = true
|
||||
@@ -260,7 +261,7 @@ func matchAny(patterns []string, action string) bool {
|
||||
// IAM-style glob ('*' any run of characters, '?' any single character) —
|
||||
// e.g. "sts:*" or "sts:AssumeRole*" both match "sts:AssumeRoleWithWebIdentity".
|
||||
func matchActionPattern(pattern, action string) bool {
|
||||
return globMatch(toLowerASCII(pattern), toLowerASCII(action))
|
||||
return condition.GlobMatch(toLowerASCII(pattern), toLowerASCII(action))
|
||||
}
|
||||
|
||||
func toLowerASCII(s string) string {
|
||||
@@ -272,32 +273,3 @@ func toLowerASCII(s string) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// globMatch implements the small wildcard grammar IAM Action/Resource
|
||||
// patterns use: '*' matches any run of characters (including none), '?'
|
||||
// matches exactly one character, everything else matches literally.
|
||||
func globMatch(pattern, s string) bool {
|
||||
var pi, si, star, match int
|
||||
star = -1
|
||||
for si < len(s) {
|
||||
switch {
|
||||
case pi < len(pattern) && (pattern[pi] == '?' || pattern[pi] == s[si]):
|
||||
pi++
|
||||
si++
|
||||
case pi < len(pattern) && pattern[pi] == '*':
|
||||
star = pi
|
||||
match = si
|
||||
pi++
|
||||
case star != -1:
|
||||
pi = star + 1
|
||||
match++
|
||||
si = match
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
for pi < len(pattern) && pattern[pi] == '*' {
|
||||
pi++
|
||||
}
|
||||
return pi == len(pattern)
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestEvaluateWebIdentityTrust(t *testing.T) {
|
||||
// RequestContext.Condition on the identity-policy side - this
|
||||
// is the most realistic place to exercise the multivalue
|
||||
// aggregation semantics documented on aggregate() in
|
||||
// condition.go. "banned" is present among the claim's values,
|
||||
// internal/condition. "banned" is present among the claim's values,
|
||||
// so unqualified StringNotEquals (pre-existing, unchanged
|
||||
// semantics: fails to match if any actual value matches) fails
|
||||
// to match, and the Allow's condition doesn't hold.
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// 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 private
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/versity/versitygw/debuglogger"
|
||||
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
||||
)
|
||||
|
||||
// Error codes carried in the JSON error body's "code" field. The S3
|
||||
// gateway maps them to distinct S3 errors — CodeNoSuchIdentity to
|
||||
// InvalidAccessKeyId, CodeInvalidToken to InvalidToken — so an end user
|
||||
// gets an accurate diagnosis instead of one catch-all. Without them every
|
||||
// 403 looks identical on the wire, and a gateway whose own IAM-client
|
||||
// credential was rotated would tell the *user* their access key doesn't
|
||||
// exist.
|
||||
const (
|
||||
CodeNoSuchIdentity = "NoSuchIdentity"
|
||||
CodeInvalidToken = "InvalidToken"
|
||||
CodeBadRequest = "BadRequest"
|
||||
)
|
||||
|
||||
// privateAPIError is a minimal local error for failures (like a malformed
|
||||
// request body) that don't map to any of iamerr's AWS-IAM-specific error
|
||||
// codes — this protocol is plain JSON, not the rest of iamapi's
|
||||
// AWS-Query/XML wire format, so there's no need to force every error
|
||||
// through iamerr.APIError's XML-rendering machinery.
|
||||
type privateAPIError struct {
|
||||
status int
|
||||
code string
|
||||
message string
|
||||
}
|
||||
|
||||
func (e *privateAPIError) Error() string { return e.message }
|
||||
func (e *privateAPIError) StatusCode() int { return e.status }
|
||||
func (e *privateAPIError) Code() string { return e.code }
|
||||
|
||||
var (
|
||||
errMalformedRequestBody = &privateAPIError{
|
||||
status: http.StatusBadRequest,
|
||||
code: CodeBadRequest,
|
||||
message: "malformed request body",
|
||||
}
|
||||
errNoSuchIdentity = &privateAPIError{
|
||||
status: http.StatusForbidden,
|
||||
code: CodeNoSuchIdentity,
|
||||
message: "no identity for the given access key id",
|
||||
}
|
||||
errInvalidSessionToken = &privateAPIError{
|
||||
status: http.StatusForbidden,
|
||||
code: CodeInvalidToken,
|
||||
message: "the given session token is missing, invalid, or does not belong to the given access key id",
|
||||
}
|
||||
)
|
||||
|
||||
// mapResolveError translates iamutil's identity-resolution sentinels into
|
||||
// the wire errors this protocol reports. Anything unrecognized falls through
|
||||
// unchanged and renders as a 500, which is the correct signal: it is a fault
|
||||
// in the IAM service, not a problem with the caller's identity.
|
||||
func mapResolveError(err error) error {
|
||||
switch {
|
||||
case errors.Is(err, iamutil.ErrIdentityNotFound):
|
||||
return errNoSuchIdentity
|
||||
case errors.Is(err, iamutil.ErrInvalidSessionToken):
|
||||
return errInvalidSessionToken
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// coder is implemented by errors carrying a stable machine-readable code
|
||||
// for the "code" field of the JSON error body.
|
||||
type coder interface {
|
||||
Code() string
|
||||
}
|
||||
|
||||
// statusCoder is satisfied by both iamerr.APIError (used by
|
||||
// iammiddleware.VerifyRootOnlySigV4) and privateAPIError, so errorHandler
|
||||
// can extract the right HTTP status from either without depending on
|
||||
// iamerr's XML-specific interface methods.
|
||||
type statusCoder interface {
|
||||
StatusCode() int
|
||||
}
|
||||
|
||||
// errorHandler renders any error as a small JSON body with the matching
|
||||
// HTTP status (defaulting to 500 for an error with no known status) and,
|
||||
// where the error carries one, a machine-readable code the S3 gateway
|
||||
// dispatches on.
|
||||
func (p *PrivateAPI) errorHandler(ctx fiber.Ctx, err error) error {
|
||||
status := http.StatusInternalServerError
|
||||
if sc, ok := err.(statusCoder); ok {
|
||||
status = sc.StatusCode()
|
||||
} else {
|
||||
debuglogger.InternalError(err)
|
||||
}
|
||||
|
||||
body := map[string]string{"error": err.Error()}
|
||||
if c, ok := err.(coder); ok {
|
||||
body["code"] = c.Code()
|
||||
}
|
||||
|
||||
ctx.Status(status)
|
||||
return ctx.JSON(body)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
// 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 private
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
||||
"github.com/versity/versitygw/iamapi/policy"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
"github.com/versity/versitygw/internal/sigv4auth"
|
||||
)
|
||||
|
||||
func (p *PrivateAPI) handleDeriveSigningKey(ctx fiber.Ctx) error {
|
||||
var req DeriveSigningKeyRequest
|
||||
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
||||
return errMalformedRequestBody
|
||||
}
|
||||
|
||||
_, secret, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken)
|
||||
if err != nil {
|
||||
return mapResolveError(err)
|
||||
}
|
||||
|
||||
derivedKey := sigv4auth.DeriveKey(secret, req.Date, req.Region, req.Service)
|
||||
|
||||
return ctx.JSON(DeriveSigningKeyResponse{DerivedKey: derivedKey})
|
||||
}
|
||||
|
||||
// handleResolveIdentity answers "does this access key exist, and what
|
||||
// principal is it" for a batch of access key ids, returning no credential
|
||||
// material at all — see ResolveIdentityResponse for why that is what makes
|
||||
// answering for a session, with no session token, safe.
|
||||
func (p *PrivateAPI) handleResolveIdentity(ctx fiber.Ctx) error {
|
||||
var req ResolveIdentityRequest
|
||||
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
||||
return errMalformedRequestBody
|
||||
}
|
||||
|
||||
resolved := resolveIdentityMetadata(ctx.Context(), p.store, req.AccessKeyIDs)
|
||||
|
||||
identities := make([]ResolvedIdentity, len(resolved))
|
||||
for i, r := range resolved {
|
||||
if !r.Found {
|
||||
continue
|
||||
}
|
||||
identities[i] = ResolvedIdentity{
|
||||
Found: true,
|
||||
Kind: identityKindWireValue(r.Kind),
|
||||
PrincipalArn: r.PrincipalArn,
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.JSON(ResolveIdentityResponse{Identities: identities})
|
||||
}
|
||||
|
||||
// identityKindWireValue converts identityKind to its wire representation.
|
||||
func identityKindWireValue(k identityKind) string {
|
||||
if k == identityKindSession {
|
||||
return KindSession
|
||||
}
|
||||
return KindUser
|
||||
}
|
||||
|
||||
func (p *PrivateAPI) handleEvaluatePolicy(ctx fiber.Ctx) error {
|
||||
var req EvaluatePolicyRequest
|
||||
if err := json.Unmarshal(ctx.Body(), &req); err != nil {
|
||||
return errMalformedRequestBody
|
||||
}
|
||||
|
||||
identity, _, err := resolvePrivateIdentity(ctx.Context(), p.store, req.AccessKeyID, req.SessionToken)
|
||||
if err != nil {
|
||||
return mapResolveError(err)
|
||||
}
|
||||
|
||||
condition := conditionContextFor(*identity, req.Condition)
|
||||
|
||||
decisions := make([][]string, len(req.Resources))
|
||||
sessionDecisions := make([][]string, len(req.Resources))
|
||||
hasSessionPolicy := false
|
||||
|
||||
for i, resource := range req.Resources {
|
||||
perAction := make([]string, len(req.Actions))
|
||||
perActionSession := make([]string, len(req.Actions))
|
||||
for j, action := range req.Actions {
|
||||
identityDecision, sessionDecision, hasSession := iammiddleware.AuthorizeSplit(*identity, policy.RequestContext{
|
||||
Action: action,
|
||||
Resource: resource,
|
||||
Condition: condition,
|
||||
})
|
||||
perAction[j] = decisionWireValue(identityDecision)
|
||||
perActionSession[j] = decisionWireValue(sessionDecision)
|
||||
hasSessionPolicy = hasSession
|
||||
}
|
||||
decisions[i] = perAction
|
||||
sessionDecisions[i] = perActionSession
|
||||
}
|
||||
|
||||
resp := EvaluatePolicyResponse{
|
||||
Decisions: decisions,
|
||||
PrincipalArn: iammiddleware.CallerArn(*identity),
|
||||
}
|
||||
if hasSessionPolicy {
|
||||
resp.HasSessionPolicy = true
|
||||
resp.SessionDecisions = sessionDecisions
|
||||
}
|
||||
|
||||
return ctx.JSON(resp)
|
||||
}
|
||||
|
||||
// conditionContextFor combines the request-derived condition keys the S3
|
||||
// gateway observed (source IP, time, transport) with the identity-derived
|
||||
// keys only this service can know (aws:PrincipalArn, aws:username, …).
|
||||
//
|
||||
// Every key in an identity or resource namespace is dropped from the
|
||||
// gateway's contribution first, then this side's own values are laid over
|
||||
// the remainder. Filtering rather than merging matters: an
|
||||
// override-on-collision merge would leave any key this service happens
|
||||
// *not* to set — aws:PrincipalTag/x for an untagged role, say — under the
|
||||
// gateway's control, which is precisely what a StringNotEquals-guarded
|
||||
// Allow keys off. The gateway authenticates as root, so this is defense in
|
||||
// depth rather than a trust boundary, but the layering costs nothing.
|
||||
func conditionContextFor(identity types.Identity, requestKeys map[string][]string) map[string][]string {
|
||||
condition := make(map[string][]string, len(requestKeys))
|
||||
for k, v := range requestKeys {
|
||||
if isIdentityConditionKey(k) {
|
||||
continue
|
||||
}
|
||||
condition[k] = v
|
||||
}
|
||||
maps.Copy(condition, iammiddleware.IdentityConditionContext(identity))
|
||||
return condition
|
||||
}
|
||||
|
||||
// isIdentityConditionKey reports whether key names the caller or the
|
||||
// resource, and so may only be set by this service. Matching is
|
||||
// case-insensitive because policy condition-key lookup is
|
||||
// (iamapi/policy.lookupContextValues) — a caller must not be able to smuggle
|
||||
// "AWS:PrincipalArn" past a case-sensitive filter.
|
||||
func isIdentityConditionKey(key string) bool {
|
||||
for _, prefix := range iammiddleware.IdentityConditionKeyPrefixes {
|
||||
if strings.EqualFold(key, prefix) ||
|
||||
(strings.HasSuffix(prefix, "/") && len(key) > len(prefix) && strings.EqualFold(key[:len(prefix)], prefix)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decisionWireValue converts policy.Decision to its wire representation.
|
||||
func decisionWireValue(d policy.Decision) string {
|
||||
switch d {
|
||||
case policy.DecisionAllow:
|
||||
return DecisionAllow
|
||||
case policy.DecisionDeny:
|
||||
return DecisionDeny
|
||||
default:
|
||||
return DecisionNoMatch
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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 private
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
||||
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
"github.com/versity/versitygw/internal/sigv4auth"
|
||||
)
|
||||
|
||||
// resolvePrivateIdentity resolves accessKeyID — long-term (AKIA…) or
|
||||
// temporary (ASIA…) — to its identity and secret, for the S3 gateway to
|
||||
// authenticate and authorize one of its own data-plane callers.
|
||||
//
|
||||
// sessionToken is required for, and only meaningful to, a temporary access
|
||||
// key; it is what makes resolving a session here safe (see
|
||||
// iamutil.ResolveSessionByToken). Errors are the iamutil.ErrIdentityNotFound
|
||||
// / iamutil.ErrInvalidSessionToken sentinels, so the caller can report which
|
||||
// failure occurred.
|
||||
func resolvePrivateIdentity(ctx context.Context, store iamutil.IdentityStore, accessKeyID, sessionToken string) (*types.Identity, string, error) {
|
||||
if sigv4auth.IsTempAccessKeyID(accessKeyID) {
|
||||
return iamutil.ResolveSessionByToken(ctx, store, accessKeyID, sessionToken)
|
||||
}
|
||||
if sessionToken != "" {
|
||||
// A token alongside a permanent credential is always a caller
|
||||
// error, and accepting it silently would mask a misrouted request.
|
||||
return nil, "", iamutil.ErrInvalidSessionToken
|
||||
}
|
||||
return iamutil.ResolveUserIdentity(ctx, store, accessKeyID)
|
||||
}
|
||||
|
||||
// identityKind labels what sort of principal an access key belongs to, for
|
||||
// callers that need to tell an ephemeral session apart from a long-term user
|
||||
// without holding a session token.
|
||||
type identityKind string
|
||||
|
||||
const (
|
||||
identityKindUser identityKind = "user"
|
||||
identityKindSession identityKind = "session"
|
||||
)
|
||||
|
||||
// resolveIdentityMetadata answers "does this access key exist, and what
|
||||
// principal is it" for each of accessKeyIDs, returning nothing that could
|
||||
// authenticate anyone — no secret, no derived key, no policy. That is what
|
||||
// makes it safe to resolve a temporary (ASIA…) key here with no session
|
||||
// token: knowing a session exists grants nothing, whereas knowing its secret
|
||||
// grants everything.
|
||||
//
|
||||
// It backs the S3 gateway's IAMService.GetUserAccount, whose only real
|
||||
// consumer is auth.CheckIfAccountsExist — validating the principals named in
|
||||
// a bucket policy or ACL, one batch per PutBucketPolicy/PutBucketAcl.
|
||||
// Results are positional: one entry per input, with Found false for keys
|
||||
// that don't resolve, rather than an error for the whole batch.
|
||||
func resolveIdentityMetadata(ctx context.Context, store iamutil.IdentityStore, accessKeyIDs []string) []identityMetadata {
|
||||
out := make([]identityMetadata, len(accessKeyIDs))
|
||||
for i, accessKeyID := range accessKeyIDs {
|
||||
if sigv4auth.IsTempAccessKeyID(accessKeyID) {
|
||||
session, err := store.GetSession(ctx, accessKeyID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[i] = identityMetadata{
|
||||
Found: true,
|
||||
Kind: identityKindSession,
|
||||
PrincipalArn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, session.RoleName, session.RoleSessionName),
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
identity, _, err := iamutil.ResolveUserIdentity(ctx, store, accessKeyID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out[i] = identityMetadata{
|
||||
Found: true,
|
||||
Kind: identityKindUser,
|
||||
PrincipalArn: iammiddleware.CallerArn(*identity),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// identityMetadata is one resolveIdentityMetadata result. The zero value
|
||||
// means "no such access key".
|
||||
type identityMetadata struct {
|
||||
Found bool
|
||||
Kind identityKind
|
||||
PrincipalArn string
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// 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 private
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/versity/versitygw/internal/netutil"
|
||||
)
|
||||
|
||||
const shutDownDuration = time.Second * 10
|
||||
|
||||
// ServeMultiPort binds and serves the private endpoints on every address in
|
||||
// addrs. Each address is checked with netutil.RequireSecureTransport before
|
||||
// binding anything — mTLS (server cert + mandatory client-cert
|
||||
// verification) or a unix socket, nothing else — so a misconfiguration
|
||||
// fails startup instead of silently serving these endpoints in the clear.
|
||||
// tlsOpts is only applied to non-unix-socket addresses, or to a unix
|
||||
// socket address if a server certificate is configured for it too.
|
||||
func (p *PrivateAPI) ServeMultiPort(addrs []string, tlsOpts netutil.TLSOptions) error {
|
||||
if len(addrs) == 0 {
|
||||
return fmt.Errorf("no private listener addresses specified")
|
||||
}
|
||||
|
||||
hasMTLS := tlsOpts.GetCertificate != nil && tlsOpts.ClientCAs != nil && tlsOpts.RequireClientCert
|
||||
for _, addr := range addrs {
|
||||
if err := netutil.RequireSecureTransport(addr, hasMTLS); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var listeners []net.Listener
|
||||
for _, addr := range addrs {
|
||||
var ln net.Listener
|
||||
var err error
|
||||
if netutil.IsUnixSocketPath(addr) && tlsOpts.GetCertificate == nil {
|
||||
ln, err = netutil.NewMultiAddrListener(fiber.NetworkTCP, addr, netutil.ListenerOptions{SocketPerm: p.socketPerm})
|
||||
} else {
|
||||
ln, err = netutil.NewMultiAddrTLSListenerWithOptions(fiber.NetworkTCP, addr, tlsOpts, netutil.ListenerOptions{SocketPerm: p.socketPerm})
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to bind private iam listener %s: %w", addr, err)
|
||||
}
|
||||
listeners = append(listeners, ln)
|
||||
}
|
||||
|
||||
finalListener := netutil.NewMultiListener(listeners...)
|
||||
return p.app.Listener(finalListener, fiber.ListenConfig{DisableStartupMessage: true})
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops the private endpoint listeners.
|
||||
func (p *PrivateAPI) Shutdown() error {
|
||||
return p.app.ShutdownWithTimeout(shutDownDuration)
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
// 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 private
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
||||
"github.com/versity/versitygw/iamapi/internal/iamutil"
|
||||
"github.com/versity/versitygw/iamapi/storage"
|
||||
"github.com/versity/versitygw/iamapi/types"
|
||||
"github.com/versity/versitygw/internal/sigv4auth"
|
||||
)
|
||||
|
||||
var testRoot = iammiddleware.RootCredentials{Access: "AKIDTESTROOT", Secret: "TESTROOTSECRET"}
|
||||
|
||||
// newTestServer builds a fresh file-backed store rooted at t.TempDir() and a
|
||||
// PrivateAPI on top of it — no public control-plane IAMApiServer involved,
|
||||
// since this package's handlers only ever need a populated storage.Storer.
|
||||
func newTestServer(t *testing.T) (*PrivateAPI, storage.Storer) {
|
||||
t.Helper()
|
||||
|
||||
store, err := storage.New(storage.Config{Dir: t.TempDir()})
|
||||
if err != nil {
|
||||
t.Fatalf("storage.New: %v", err)
|
||||
}
|
||||
|
||||
p, err := New(store, testRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return p, store
|
||||
}
|
||||
|
||||
// createTestUser creates a user with the given name, access key, and
|
||||
// (optional) inline policy directly against store — bypassing the public
|
||||
// control-plane API entirely, since it isn't under test here. Arn is set
|
||||
// explicitly (iamutil.BuildUserArn, matching what the control-plane
|
||||
// controller computes before calling storage.CreateUser — storage.CreateUser
|
||||
// itself never populates it) so tests can assert on a realistic principal
|
||||
// ARN in an evaluate-policy response.
|
||||
func createTestUser(t *testing.T, store storage.Storer, userName, accessKeyID, secret, policyDocument string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := store.CreateUser(ctx, types.User{
|
||||
UserName: userName,
|
||||
Path: "/",
|
||||
Arn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", userName),
|
||||
CreateDate: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateUser: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.CreateAccessKey(ctx, storage.CreateAccessKeyInput{
|
||||
UserName: userName,
|
||||
AccessKeyID: accessKeyID,
|
||||
SecretAccessKey: secret,
|
||||
Status: "Active",
|
||||
CreateDate: time.Now().UTC(),
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateAccessKey: %v", err)
|
||||
}
|
||||
|
||||
if policyDocument != "" {
|
||||
if err := store.PutUserPolicy(ctx, storage.PutUserPolicyInput{
|
||||
UserName: userName,
|
||||
PolicyName: "P",
|
||||
PolicyDocument: policyDocument,
|
||||
}); err != nil {
|
||||
t.Fatalf("PutUserPolicy: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// signPrivateRequest signs req as access/secret for the private endpoints'
|
||||
// SigV4 protocol (service "iam", iammiddleware.SigningRegion), mutating its
|
||||
// Authorization/X-Amz-Date headers in place.
|
||||
func signPrivateRequest(t *testing.T, req *http.Request, access, secret string, payloadHash string) {
|
||||
t.Helper()
|
||||
|
||||
signingTime := time.Now().UTC()
|
||||
yyyymmdd := signingTime.Format(sigv4auth.YYYYMMDD)
|
||||
derivedKey := sigv4auth.DeriveKey(secret, yyyymmdd, iammiddleware.SigningRegion, privateService)
|
||||
in := sigv4auth.SigningInputFromRequest(req)
|
||||
in.AccessKeyID = access
|
||||
in.CredentialScope = sigv4auth.BuildCredentialScope(yyyymmdd, iammiddleware.SigningRegion, privateService)
|
||||
in.PayloadHash = payloadHash
|
||||
in.SigningTime = signingTime
|
||||
result := sigv4auth.BuildAndSign(derivedKey, in)
|
||||
req.Header.Set("X-Amz-Date", result.AmzDate)
|
||||
req.Header.Set("Authorization", result.AuthorizationHeader)
|
||||
}
|
||||
|
||||
func doPrivateRequest(t *testing.T, p *PrivateAPI, method, target, access, secret string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, target, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
hash := sigv4auth.PayloadSHA256Hex(body)
|
||||
signPrivateRequest(t, req, access, secret, hash)
|
||||
|
||||
resp, err := p.app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
func TestPrivateAPIDeriveSigningKey(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
|
||||
|
||||
yyyymmdd := time.Now().UTC().Format(sigv4auth.YYYYMMDD)
|
||||
body, _ := json.Marshal(DeriveSigningKeyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
Date: yyyymmdd,
|
||||
Region: "us-east-1",
|
||||
Service: "s3",
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
|
||||
}
|
||||
|
||||
var got DeriveSigningKeyResponse
|
||||
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
want := sigv4auth.DeriveKey("alicesecret", yyyymmdd, "us-east-1", "s3")
|
||||
if !bytes.Equal(got.DerivedKey, want) {
|
||||
t.Errorf("DerivedKey = %x, want %x", got.DerivedKey, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateAPIDeriveSigningKeyRejectsUnknownAccessKey(t *testing.T) {
|
||||
p, _ := newTestServer(t)
|
||||
|
||||
body, _ := json.Marshal(DeriveSigningKeyRequest{
|
||||
AccessKeyID: "AKIADOESNOTEXIST",
|
||||
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
|
||||
Region: "us-east-1",
|
||||
Service: "s3",
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIDeriveSigningKeySessionToken covers every way a temporary
|
||||
// (ASIA…) access key can be presented to derive-signing-key. The security of
|
||||
// the whole session path rests on exactly one thing — that a signing key is
|
||||
// handed out only for a session token matching the one stored — so each
|
||||
// wrong-token shape is pinned, along with the error code that tells the S3
|
||||
// gateway to report InvalidToken rather than InvalidAccessKeyId.
|
||||
func TestPrivateAPIDeriveSigningKeySessionToken(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
role := createTestRole(t, store, "testrole", "")
|
||||
session := createTestSessionForRole(t, store, role, "ASIASOMESESSIONKEY", "sessionsecret", "correct-session-token", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
token string
|
||||
wantStatus int
|
||||
wantCode string
|
||||
}{
|
||||
{name: "no token at all", token: "", wantStatus: http.StatusForbidden, wantCode: CodeNoSuchIdentity},
|
||||
{name: "wrong token", token: "wrong-session-token", wantStatus: http.StatusForbidden, wantCode: CodeInvalidToken},
|
||||
{name: "correct token", token: session.SessionToken, wantStatus: http.StatusOK},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(DeriveSigningKeyRequest{
|
||||
AccessKeyID: session.AccessKeyId,
|
||||
SessionToken: tt.token,
|
||||
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
|
||||
Region: "us-east-1",
|
||||
Service: "s3",
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
|
||||
raw := readBody(t, resp)
|
||||
if resp.StatusCode != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d; body=%s", resp.StatusCode, tt.wantStatus, raw)
|
||||
}
|
||||
if tt.wantCode != "" {
|
||||
var errBody struct{ Code string }
|
||||
if err := json.Unmarshal([]byte(raw), &errBody); err != nil {
|
||||
t.Fatalf("unmarshal error body %s: %v", raw, err)
|
||||
}
|
||||
if errBody.Code != tt.wantCode {
|
||||
t.Fatalf("error code = %q, want %q", errBody.Code, tt.wantCode)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var out DeriveSigningKeyResponse
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
want := sigv4auth.DeriveKey("sessionsecret", time.Now().UTC().Format(sigv4auth.YYYYMMDD), "us-east-1", "s3")
|
||||
if string(out.DerivedKey) != string(want) {
|
||||
t.Errorf("derived key = %x, want %x", out.DerivedKey, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIDeriveSigningKeyRejectsTokenWithPermanentKey confirms a
|
||||
// session token offered alongside a long-term (AKIA…) key is rejected rather
|
||||
// than ignored — accepting it silently would mask a misrouted request.
|
||||
func TestPrivateAPIDeriveSigningKeyRejectsTokenWithPermanentKey(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
|
||||
|
||||
body, _ := json.Marshal(DeriveSigningKeyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
SessionToken: "some-session-token",
|
||||
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
|
||||
Region: "us-east-1",
|
||||
Service: "s3",
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, body)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIEvaluatePolicySessionPolicy confirms the role's own policies
|
||||
// and the session policy are reported *separately*, not folded together.
|
||||
//
|
||||
// The S3 gateway needs them apart because a bucket policy is also in play
|
||||
// there: a session policy filters permissions that came from the bucket
|
||||
// policy too, while the role's own decision does not. See
|
||||
// iammiddleware.AuthorizeSplit.
|
||||
func TestPrivateAPIEvaluatePolicySessionPolicy(t *testing.T) {
|
||||
rolePolicy := `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject","s3:PutObject"],"Resource":"*"}]}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sessionPolicy string
|
||||
action string
|
||||
want string
|
||||
wantSession string
|
||||
wantHasSessionPo bool
|
||||
}{
|
||||
{
|
||||
name: "no session policy: role decision stands alone",
|
||||
action: "s3:GetObject",
|
||||
want: DecisionAllow,
|
||||
},
|
||||
{
|
||||
name: "session policy narrows to a subset",
|
||||
sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`,
|
||||
action: "s3:PutObject",
|
||||
want: DecisionAllow,
|
||||
wantSession: DecisionNoMatch,
|
||||
wantHasSessionPo: true,
|
||||
},
|
||||
{
|
||||
name: "session policy cannot widen beyond the role",
|
||||
sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:*","Resource":"*"}]}`,
|
||||
action: "s3:DeleteObject",
|
||||
want: DecisionNoMatch,
|
||||
wantSession: DecisionAllow,
|
||||
wantHasSessionPo: true,
|
||||
},
|
||||
{
|
||||
name: "session policy explicit deny against the role's allow",
|
||||
sessionPolicy: `{"Version":"2012-10-17","Statement":[{"Effect":"Deny","Action":"s3:GetObject","Resource":"*"}]}`,
|
||||
action: "s3:GetObject",
|
||||
want: DecisionAllow,
|
||||
wantSession: DecisionDeny,
|
||||
wantHasSessionPo: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
role := createTestRole(t, store, "testrole", rolePolicy)
|
||||
session := createTestSessionForRole(t, store, role, "ASIASESSION", "sessionsecret", "tok", tt.sessionPolicy)
|
||||
|
||||
body, _ := json.Marshal(EvaluatePolicyRequest{
|
||||
AccessKeyID: session.AccessKeyId,
|
||||
SessionToken: session.SessionToken,
|
||||
Actions: []string{tt.action},
|
||||
Resources: []string{"*"},
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
|
||||
raw := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var out EvaluatePolicyResponse
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != tt.want {
|
||||
t.Errorf("Decisions = %v, want [[%v]]", out.Decisions, tt.want)
|
||||
}
|
||||
if out.HasSessionPolicy != tt.wantHasSessionPo {
|
||||
t.Errorf("HasSessionPolicy = %v, want %v", out.HasSessionPolicy, tt.wantHasSessionPo)
|
||||
}
|
||||
if tt.wantHasSessionPo {
|
||||
if len(out.SessionDecisions) != 1 || len(out.SessionDecisions[0]) != 1 || out.SessionDecisions[0][0] != tt.wantSession {
|
||||
t.Errorf("SessionDecisions = %v, want [[%v]]", out.SessionDecisions, tt.wantSession)
|
||||
}
|
||||
} else if len(out.SessionDecisions) != 0 {
|
||||
t.Errorf("SessionDecisions = %v, want none when no session policy applies", out.SessionDecisions)
|
||||
}
|
||||
wantArn := iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, session.RoleSessionName)
|
||||
if out.PrincipalArn != wantArn {
|
||||
t.Errorf("PrincipalArn = %q, want %q", out.PrincipalArn, wantArn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIEvaluatePolicyStripsCallerSuppliedIdentityKeys confirms the
|
||||
// gateway cannot influence an identity-namespace condition key by sending
|
||||
// one itself. Stripping rather than overriding matters for keys the service
|
||||
// does not set at all: aws:PrincipalTag/team below has no value for an
|
||||
// untagged user, and a policy that Allows on its *absence* must not be
|
||||
// satisfiable by a value the caller supplied.
|
||||
func TestPrivateAPIEvaluatePolicyStripsCallerSuppliedIdentityKeys(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*",`+
|
||||
`"Condition":{"StringEquals":{"aws:PrincipalTag/team":"admins"}}}]}`)
|
||||
|
||||
body, _ := json.Marshal(EvaluatePolicyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
Actions: []string{"s3:GetObject"},
|
||||
Resources: []string{"*"},
|
||||
Condition: map[string][]string{
|
||||
"aws:PrincipalTag/team": {"admins"},
|
||||
// Case-varied spellings must be stripped too: policy key lookup
|
||||
// is case-insensitive, so a case-sensitive filter would be no
|
||||
// filter at all.
|
||||
"AWS:PrincipalArn": {"arn:aws:iam::000000000000:user/somebodyelse"},
|
||||
},
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
|
||||
raw := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var out EvaluatePolicyResponse
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != DecisionNoMatch {
|
||||
t.Errorf("Decisions = %v, want [[%v]]: a caller-supplied aws:PrincipalTag must not satisfy the condition", out.Decisions, DecisionNoMatch)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIEvaluatePolicyUsesRequestConditionKeys is the counterpart to
|
||||
// the test above: the request-derived keys the gateway *is* the authority
|
||||
// for must reach the policy evaluator intact.
|
||||
func TestPrivateAPIEvaluatePolicyUsesRequestConditionKeys(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*",`+
|
||||
`"Condition":{"IpAddress":{"aws:SourceIp":"10.1.2.0/24"}}}]}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceIP string
|
||||
want string
|
||||
}{
|
||||
{name: "matching source ip", sourceIP: "10.1.2.3", want: DecisionAllow},
|
||||
{name: "non-matching source ip", sourceIP: "10.9.9.9", want: DecisionNoMatch},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(EvaluatePolicyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
Actions: []string{"s3:GetObject"},
|
||||
Resources: []string{"*"},
|
||||
Condition: map[string][]string{"aws:SourceIp": {tt.sourceIP}},
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
|
||||
raw := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var out EvaluatePolicyResponse
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
if len(out.Decisions) != 1 || len(out.Decisions[0]) != 1 || out.Decisions[0][0] != tt.want {
|
||||
t.Errorf("Decisions = %v, want [%v]", out.Decisions, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIResolveIdentity covers the metadata-only endpoint: it must
|
||||
// answer positionally for a whole batch, resolve a session with no token
|
||||
// (the disclosure is harmless, since nothing it returns authenticates
|
||||
// anyone), and label session versus user so the gateway can refuse to
|
||||
// persist a reference to an ephemeral principal.
|
||||
func TestPrivateAPIResolveIdentity(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
|
||||
role := createTestRole(t, store, "testrole", "")
|
||||
session := createTestSessionForRole(t, store, role, "ASIASESSION", "sessionsecret", "tok", "")
|
||||
|
||||
body, _ := json.Marshal(ResolveIdentityRequest{
|
||||
AccessKeyIDs: []string{"AKIAALICE", "AKIADOESNOTEXIST", session.AccessKeyId},
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, ResolveIdentityPath, testRoot.Access, testRoot.Secret, body)
|
||||
raw := readBody(t, resp)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
var out ResolveIdentityResponse
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", raw, err)
|
||||
}
|
||||
|
||||
want := []ResolvedIdentity{
|
||||
{Found: true, Kind: KindUser, PrincipalArn: iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice")},
|
||||
{},
|
||||
{Found: true, Kind: KindSession, PrincipalArn: iamutil.BuildAssumedRoleArn(iamutil.DefaultAccountID, role.RoleName, session.RoleSessionName)},
|
||||
}
|
||||
if len(out.Identities) != len(want) {
|
||||
t.Fatalf("Identities = %+v, want %d entries", out.Identities, len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if out.Identities[i] != want[i] {
|
||||
t.Errorf("Identities[%d] = %+v, want %+v", i, out.Identities[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateAPIEvaluatePolicy(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`)
|
||||
wantArn := iamutil.BuildUserArn(iamutil.DefaultAccountID, "/", "alice")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
action string
|
||||
want string
|
||||
}{
|
||||
{name: "allowed action", action: "s3:GetObject", want: DecisionAllow},
|
||||
{name: "action not granted", action: "s3:PutObject", want: DecisionNoMatch},
|
||||
{name: "explicitly denied action", action: "s3:DeleteObject", want: DecisionDeny},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(EvaluatePolicyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
Actions: []string{tt.action},
|
||||
Resources: []string{"*"},
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
|
||||
}
|
||||
|
||||
var got EvaluatePolicyResponse
|
||||
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
if len(got.Decisions) != 1 || len(got.Decisions[0]) != 1 || got.Decisions[0][0] != tt.want {
|
||||
t.Errorf("Decisions = %v, want [[%v]]", got.Decisions, tt.want)
|
||||
}
|
||||
if got.PrincipalArn != wantArn {
|
||||
t.Errorf("PrincipalArn = %q, want %q", got.PrincipalArn, wantArn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIEvaluatePolicyBatchesMultipleActions confirms multiple
|
||||
// actions supplied in one EvaluatePolicyRequest are each evaluated
|
||||
// independently against the same resource, in a single request, with
|
||||
// Decisions returned in the same order as Actions.
|
||||
func TestPrivateAPIEvaluatePolicyBatchesMultipleActions(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"},{"Effect":"Deny","Action":"s3:DeleteObject","Resource":"*"}]}`)
|
||||
|
||||
body, _ := json.Marshal(EvaluatePolicyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
Actions: []string{"s3:GetObject", "s3:PutObject", "s3:DeleteObject"},
|
||||
Resources: []string{"*"},
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
|
||||
}
|
||||
|
||||
var got EvaluatePolicyResponse
|
||||
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
want := []string{DecisionAllow, DecisionNoMatch, DecisionDeny}
|
||||
if len(got.Decisions) != 1 || len(got.Decisions[0]) != len(want) {
|
||||
t.Fatalf("Decisions = %v, want [%v]", got.Decisions, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got.Decisions[0][i] != want[i] {
|
||||
t.Errorf("Decisions[0][%d] = %v, want %v", i, got.Decisions[0][i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIEvaluatePolicyBatchesMultipleResources confirms several
|
||||
// resources are each evaluated against every action in one request — what
|
||||
// keeps a 1000-key DeleteObjects a single round trip.
|
||||
func TestPrivateAPIEvaluatePolicyBatchesMultipleResources(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret",
|
||||
`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:DeleteObject","Resource":"arn:aws:s3:::b/allowed/*"}]}`)
|
||||
|
||||
body, _ := json.Marshal(EvaluatePolicyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
Actions: []string{"s3:DeleteObject"},
|
||||
Resources: []string{"arn:aws:s3:::b/allowed/one", "arn:aws:s3:::b/denied/two", "arn:aws:s3:::b/allowed/three"},
|
||||
})
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, EvaluatePath, testRoot.Access, testRoot.Secret, body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body=%s", resp.StatusCode, readBody(t, resp))
|
||||
}
|
||||
|
||||
var got EvaluatePolicyResponse
|
||||
if err := json.Unmarshal([]byte(readBody(t, resp)), &got); err != nil {
|
||||
t.Fatalf("unmarshal response: %v", err)
|
||||
}
|
||||
want := []string{DecisionAllow, DecisionNoMatch, DecisionAllow}
|
||||
if len(got.Decisions) != len(want) {
|
||||
t.Fatalf("Decisions = %v, want %d resource entries", got.Decisions, len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if len(got.Decisions[i]) != 1 || got.Decisions[i][0] != want[i] {
|
||||
t.Errorf("Decisions[%d] = %v, want [%v]", i, got.Decisions[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrivateAPIRejectsNonRootCredential confirms a validly-signed request
|
||||
// from a real (non-root) IAM user's own credentials is rejected outright —
|
||||
// only the S3 gateway's own root-equivalent identity may ever call these
|
||||
// endpoints.
|
||||
func TestPrivateAPIRejectsNonRootCredential(t *testing.T) {
|
||||
p, store := newTestServer(t)
|
||||
createTestUser(t, store, "alice", "AKIAALICE", "alicesecret", "")
|
||||
|
||||
body, _ := json.Marshal(DeriveSigningKeyRequest{
|
||||
AccessKeyID: "AKIAALICE",
|
||||
Date: time.Now().UTC().Format(sigv4auth.YYYYMMDD),
|
||||
Region: "us-east-1",
|
||||
Service: "s3",
|
||||
})
|
||||
|
||||
// Signed with alice's own, otherwise-valid credentials — not root.
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, "AKIAALICE", "alicesecret", body)
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusForbidden, readBody(t, resp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateAPIRejectsMalformedBody(t *testing.T) {
|
||||
p, _ := newTestServer(t)
|
||||
|
||||
resp := doPrivateRequest(t, p, http.MethodPost, DerivePath, testRoot.Access, testRoot.Secret, []byte("not json"))
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want %d; body=%s", resp.StatusCode, http.StatusBadRequest, readBody(t, resp))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateAPIRejectsUnsignedRequest(t *testing.T) {
|
||||
p, _ := newTestServer(t)
|
||||
|
||||
body, _ := json.Marshal(DeriveSigningKeyRequest{AccessKeyID: "AKIAX", Date: "20260101", Region: "us-east-1", Service: "s3"})
|
||||
req := httptest.NewRequest(http.MethodPost, DerivePath, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.app.Test(req)
|
||||
if err != nil {
|
||||
t.Fatalf("app.Test: %v", err)
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
t.Errorf("expected an unsigned request to be rejected, got 200")
|
||||
}
|
||||
}
|
||||
|
||||
// createTestRole creates a role with an optional inline permission policy
|
||||
// directly against store, the same way createTestUser bypasses the
|
||||
// control-plane API. Arn and RoleID are set explicitly because
|
||||
// storage.CreateRole doesn't populate them, and iamutil.ResolveSessionByToken
|
||||
// re-checks both against the session before attaching the role's policies.
|
||||
func createTestRole(t *testing.T, store storage.Storer, roleName, policyDocument string) *types.Role {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
role, err := store.CreateRole(ctx, types.Role{
|
||||
RoleName: roleName,
|
||||
Path: "/",
|
||||
RoleID: "AROA" + roleName,
|
||||
Arn: iamutil.BuildRoleArn(iamutil.DefaultAccountID, "/", roleName),
|
||||
CreateDate: time.Now().UTC(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRole: %v", err)
|
||||
}
|
||||
|
||||
if policyDocument != "" {
|
||||
if err := store.PutRolePolicy(ctx, storage.PutRolePolicyInput{
|
||||
RoleName: roleName,
|
||||
PolicyName: "P",
|
||||
PolicyDocument: policyDocument,
|
||||
}); err != nil {
|
||||
t.Fatalf("PutRolePolicy: %v", err)
|
||||
}
|
||||
}
|
||||
return role
|
||||
}
|
||||
|
||||
// createTestSessionForRole creates a session against role as
|
||||
// AssumeRoleWithWebIdentity would, with an optional inline session policy.
|
||||
// RoleID and RoleArn are copied from role so the session survives
|
||||
// iamutil.ResolveSessionByToken's same-role re-check.
|
||||
func createTestSessionForRole(t *testing.T, store storage.Storer, role *types.Role, accessKeyID, secret, token, sessionPolicy string) *types.Session {
|
||||
t.Helper()
|
||||
|
||||
session, err := store.CreateSession(context.Background(), types.Session{
|
||||
AccessKeyId: accessKeyID,
|
||||
SecretAccessKey: secret,
|
||||
SessionToken: token,
|
||||
RoleArn: role.Arn,
|
||||
RoleName: role.RoleName,
|
||||
RoleID: role.RoleID,
|
||||
RoleSessionName: "testsession",
|
||||
CreateDate: time.Now().UTC(),
|
||||
Expiration: time.Now().UTC().Add(time.Hour),
|
||||
Policy: sessionPolicy,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSession: %v", err)
|
||||
}
|
||||
return session
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
// 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 private implements the standalone IAM service's private endpoint
|
||||
// set: derive a SigV4 signing key, evaluate IAM identity policies, and
|
||||
// resolve an access key id to the principal that owns it. All three exist
|
||||
// purely so the S3 gateway can authenticate and authorize its own callers
|
||||
// without ever holding a plaintext secret or a policy document itself.
|
||||
// This is a separate fiber app from the public control-plane
|
||||
// iamapi.IAMApiServer, meant to be served on its own listener(s), never the
|
||||
// public one.
|
||||
//
|
||||
// The endpoints authenticate strictly as the configured root credential
|
||||
// — only the S3 gateway itself, signing as its own IAM-client identity, ever
|
||||
// legitimately calls them. Transport security is enforced by ServeMultiPort,
|
||||
// not by request handling
|
||||
package private
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/logger"
|
||||
"github.com/versity/versitygw/iamapi/internal/iammiddleware"
|
||||
"github.com/versity/versitygw/iamapi/storage"
|
||||
"github.com/versity/versitygw/internal/sigv4auth"
|
||||
)
|
||||
|
||||
const (
|
||||
// These are exported so auth.IAMServiceStandalone (the S3-side client)
|
||||
// shares one source of truth for the routes rather than duplicating the
|
||||
// literal path strings.
|
||||
DerivePath = "/private/derive-signing-key"
|
||||
EvaluatePath = "/private/evaluate-policy"
|
||||
ResolveIdentityPath = "/private/resolve-identity"
|
||||
|
||||
// privateService is the SigV4 credential-scope service name the S3
|
||||
// gateway signs its own requests to these endpoints with. It's an
|
||||
// internal detail — these routes aren't part of any AWS-compatible
|
||||
// API — reusing "iam" is simplest and avoids inventing a new constant
|
||||
// consumers on both sides would have to agree on.
|
||||
privateService = sigv4auth.ServiceIAM
|
||||
)
|
||||
|
||||
// PrivateAPI is the standalone IAM service's private endpoint set
|
||||
type PrivateAPI struct {
|
||||
app *fiber.App
|
||||
store storage.Storer
|
||||
socketPerm os.FileMode
|
||||
quiet bool
|
||||
}
|
||||
|
||||
type PrivateAPIOption func(*PrivateAPI)
|
||||
|
||||
// WithPrivateSocketPerm sets the file-mode permission applied to any
|
||||
// file-backed unix-socket listener address (no effect on TCP addresses or
|
||||
// Linux abstract-namespace sockets).
|
||||
func WithPrivateSocketPerm(perm os.FileMode) PrivateAPIOption {
|
||||
return func(p *PrivateAPI) { p.socketPerm = perm }
|
||||
}
|
||||
|
||||
// WithPrivateQuiet suppresses per-request summary logging, mirroring
|
||||
// iamapi.WithQuiet for the public API. Callers should gate both on the same
|
||||
// flag so the two log streams turn on and off together.
|
||||
func WithPrivateQuiet() PrivateAPIOption {
|
||||
return func(p *PrivateAPI) { p.quiet = true }
|
||||
}
|
||||
|
||||
// New constructs the private endpoint set. root is the identity these
|
||||
// endpoints authenticate every request against.
|
||||
func New(store storage.Storer, root iammiddleware.RootCredentials, opts ...PrivateAPIOption) (*PrivateAPI, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("iamapi/private: storer is required")
|
||||
}
|
||||
|
||||
p := &PrivateAPI{store: store}
|
||||
for _, opt := range opts {
|
||||
opt(p)
|
||||
}
|
||||
|
||||
app := fiber.New(fiber.Config{
|
||||
AppName: "versitygw-iam-private",
|
||||
ServerHeader: "VERSITYGW",
|
||||
ErrorHandler: p.errorHandler,
|
||||
})
|
||||
p.app = app
|
||||
|
||||
if !p.quiet {
|
||||
app.Use("*", logger.New(logger.Config{
|
||||
Format: "${time} | vgw-iam-private | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
|
||||
}))
|
||||
}
|
||||
|
||||
rootAuth := iammiddleware.VerifyRootOnlySigV4(privateService, &root)
|
||||
app.Post(DerivePath, chainHandlers(rootAuth, p.handleDeriveSigningKey))
|
||||
app.Post(EvaluatePath, chainHandlers(rootAuth, p.handleEvaluatePolicy))
|
||||
app.Post(ResolveIdentityPath, chainHandlers(rootAuth, p.handleResolveIdentity))
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// chainHandlers composes handlers into one, calling each in turn and
|
||||
// stopping at the first error.
|
||||
func chainHandlers(handlers ...fiber.Handler) fiber.Handler {
|
||||
return func(ctx fiber.Ctx) error {
|
||||
for _, h := range handlers {
|
||||
if err := h(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// 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 private
|
||||
|
||||
// DeriveSigningKeyRequest is the derive-signing-key request body. Date,
|
||||
// Region, and Service are the request's credential-scope components
|
||||
// (yyyymmdd/region/service), matching sigv4auth.DeriveKey's parameters.
|
||||
//
|
||||
// SessionToken is required when AccessKeyID is a temporary (ASIA…) key and
|
||||
// must be absent otherwise. It is what makes resolving a session here safe:
|
||||
// without it, anyone who learned a session's access key id could ask for
|
||||
// that session's signing key.
|
||||
type DeriveSigningKeyRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SessionToken string `json:"sessionToken,omitempty"`
|
||||
Date string `json:"date"`
|
||||
Region string `json:"region"`
|
||||
Service string `json:"service"`
|
||||
}
|
||||
|
||||
// DeriveSigningKeyResponse carries the derived signing key (kSigning) —
|
||||
// never the underlying secret.
|
||||
type DeriveSigningKeyResponse struct {
|
||||
DerivedKey []byte `json:"derivedKey"`
|
||||
}
|
||||
|
||||
// EvaluatePolicyRequest is the evaluate-policy request body
|
||||
type EvaluatePolicyRequest struct {
|
||||
AccessKeyID string `json:"accessKeyId"`
|
||||
SessionToken string `json:"sessionToken,omitempty"`
|
||||
Actions []string `json:"actions"`
|
||||
Resources []string `json:"resources"`
|
||||
Condition map[string][]string `json:"condition,omitempty"`
|
||||
}
|
||||
|
||||
// ResolveIdentityRequest asks whether each access key id exists and what
|
||||
// principal it names. It carries no session token because the response
|
||||
// carries no credential material
|
||||
type ResolveIdentityRequest struct {
|
||||
AccessKeyIDs []string `json:"accessKeyIds"`
|
||||
}
|
||||
|
||||
// ResolveIdentityResponse answers ResolveIdentityRequest positionally: one
|
||||
// entry per requested access key id, in the same order, with Found false
|
||||
// for one that doesn't resolve. It deliberately carries no secret, no
|
||||
// derived key and no policy — only that makes it safe to answer for a
|
||||
// temporary (ASIA…) key with no session token, since knowing a session
|
||||
// exists grants nothing.
|
||||
type ResolveIdentityResponse struct {
|
||||
Identities []ResolvedIdentity `json:"identities"`
|
||||
}
|
||||
|
||||
// ResolvedIdentity is one ResolveIdentityResponse entry. Kind is
|
||||
// KindUser or KindSession.
|
||||
type ResolvedIdentity struct {
|
||||
Found bool `json:"found"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
PrincipalArn string `json:"principalArn,omitempty"`
|
||||
}
|
||||
|
||||
// Kind values for ResolvedIdentity.Kind — strings on the wire for the same
|
||||
// reason the Decision values below are: self-documenting, and immune to
|
||||
// iota drift between the independently-built gateway and IAM service.
|
||||
const (
|
||||
KindUser = "user"
|
||||
KindSession = "session"
|
||||
)
|
||||
|
||||
// Decision values for each entry of EvaluatePolicyResponse.Decisions.
|
||||
// Deliberately strings, not policy.Decision's int: self-documenting on the
|
||||
// wire, and immune to iota drift between the S3 gateway and standalone IAM
|
||||
// service — two independently-built processes speaking this protocol.
|
||||
const (
|
||||
DecisionAllow = "allow"
|
||||
DecisionDeny = "deny"
|
||||
DecisionNoMatch = "no_match"
|
||||
)
|
||||
|
||||
// EvaluatePolicyResponse carries the full tri-state result for the whole
|
||||
// requested matrix — not just whether each cell is allowed — so the S3
|
||||
// gateway can distinguish an explicit Deny (which must override an
|
||||
// otherwise-allowing bucket policy) from a plain NoMatch (which doesn't),
|
||||
// and can build an AWS-shaped denial message.
|
||||
//
|
||||
// Decisions[i][j] is the decision for the request's Resources[i] and
|
||||
// Actions[j], in the order both were sent. PrincipalArn is the resolved
|
||||
// identity's own ARN, best-effort: "" when it can't be resolved (e.g. a
|
||||
// session whose role no longer exists), in which case the caller falls back
|
||||
// to the access key. It is shared by the whole batch — one request always
|
||||
// evaluates against a single identity.
|
||||
// SessionDecisions is the same matrix evaluated against the caller's
|
||||
// session policy alone, and HasSessionPolicy says whether one applied — the
|
||||
// caller must ignore SessionDecisions when it is false. They are reported
|
||||
// separately from Decisions rather than folded into them because a session
|
||||
// policy filters *everything*, including permissions the S3 gateway's own
|
||||
// bucket policy grants, which this service knows nothing about. See
|
||||
// iammiddleware.AuthorizeSplit.
|
||||
type EvaluatePolicyResponse struct {
|
||||
Decisions [][]string `json:"decisions"`
|
||||
SessionDecisions [][]string `json:"sessionDecisions,omitempty"`
|
||||
HasSessionPolicy bool `json:"hasSessionPolicy,omitempty"`
|
||||
PrincipalArn string `json:"principalArn,omitempty"`
|
||||
}
|
||||
+3
-8
@@ -63,13 +63,14 @@ type IAMApiServer struct {
|
||||
oidcThumbprintAutoFetchDisabled bool
|
||||
}
|
||||
|
||||
func New(store storage.Storer, opts ...Option) (*IAMApiServer, error) {
|
||||
func New(store storage.Storer, root RootCredentials, opts ...Option) (*IAMApiServer, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("iamapi: storer is required")
|
||||
}
|
||||
|
||||
server := &IAMApiServer{
|
||||
store: store,
|
||||
store: store,
|
||||
rootCreds: &root,
|
||||
Router: &IAMApiRouter{
|
||||
store: store,
|
||||
},
|
||||
@@ -162,12 +163,6 @@ func WithOnListen(fn func()) Option {
|
||||
return func(s *IAMApiServer) { s.onListen = fn }
|
||||
}
|
||||
|
||||
func WithRootUserCreds(root RootCredentials) Option {
|
||||
return func(s *IAMApiServer) {
|
||||
s.rootCreds = &root
|
||||
}
|
||||
}
|
||||
|
||||
// WithOIDCThumbprintAutoFetchDisabled disables CreateOpenIDConnectProvider's
|
||||
// TLS auto-fetch fallback for when ThumbprintList is omitted. When set, an
|
||||
// omitted ThumbprintList is rejected with a MissingValue error instead of
|
||||
|
||||
+15
-17
@@ -40,8 +40,7 @@ const vaultRequestTimeout = 10 * time.Second
|
||||
// withRoleCAS/withOIDCProviderCAS run when a version-checked (CAS) write
|
||||
// loses a race against a concurrent writer updating the same entity —
|
||||
// mirroring the 3-attempt collision-retry loops already used elsewhere in
|
||||
// this package for ID generation (see controller.go's CreateUser/CreateRole/
|
||||
// CreateAccessKey).
|
||||
// this package for ID generation.
|
||||
const maxCASRetries = 3
|
||||
|
||||
// errConcurrentModification is withUserCAS/withRoleCAS/withOIDCProviderCAS's
|
||||
@@ -359,7 +358,7 @@ func (s *VaultStore) GetUser(_ context.Context, username string) (*types.User, e
|
||||
// readUserVersion resolves username the same way GetUser does, additionally
|
||||
// returning the KV version the record was read at, so a mutation can write
|
||||
// back with a matching CAS value instead of racing on a blind
|
||||
// delete-then-recreate (see replaceUser).
|
||||
// delete-then-recreate.
|
||||
func (s *VaultStore) readUserVersion(username string) (*types.User, int32, error) {
|
||||
key := caseFoldKey(username)
|
||||
path := s.usersPath() + "/" + key
|
||||
@@ -752,13 +751,12 @@ const recordAccessKeyUsageTimeout = 5 * time.Second
|
||||
|
||||
// RecordAccessKeyUsage updates accessKeyID's GetAccessKeyLastUsed metadata
|
||||
// in its own background goroutine, detached from ctx, and always returns
|
||||
// nil immediately: this runs on the hot path of every authenticated request
|
||||
// (see iammiddleware.recordAccessKeyUsage), and a Vault round trip — plus,
|
||||
// on a CAS conflict, withUserCAS's retry loop — is too expensive to add
|
||||
// synchronously to every one of them. A failure (including one that
|
||||
// exhausts those retries) is only logged, never surfaced: this is purely
|
||||
// informational metadata, and a lost update under concurrent use is
|
||||
// immaterial.
|
||||
// nil immediately: this runs on the hot path of every authenticated
|
||||
// request, and a Vault round trip — plus, on a CAS conflict, withUserCAS's
|
||||
// retry loop — is too expensive to add synchronously to every one of them.
|
||||
// A failure (including one that exhausts those retries) is only logged,
|
||||
// never surfaced: this is purely informational metadata, and a lost update
|
||||
// under concurrent use is immaterial.
|
||||
func (s *VaultStore) RecordAccessKeyUsage(_ context.Context, accessKeyID, service, region string, when time.Time) error {
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), recordAccessKeyUsageTimeout)
|
||||
@@ -1043,7 +1041,7 @@ func (s *VaultStore) GetRole(_ context.Context, roleName string) (*types.Role, e
|
||||
// readRoleVersion is GetRole's counterpart to readUserVersion: it
|
||||
// additionally returns the KV version the record was read at, so a
|
||||
// mutation can write back with a matching CAS value instead of racing on a
|
||||
// blind delete-then-recreate (see replaceRole).
|
||||
// blind delete-then-recreate.
|
||||
func (s *VaultStore) readRoleVersion(roleName string) (*types.Role, int32, error) {
|
||||
key := caseFoldKey(roleName)
|
||||
path := s.rolesPath() + "/" + key
|
||||
@@ -1858,12 +1856,12 @@ func (s *VaultStore) GetSession(_ context.Context, accessKeyID string) (*types.S
|
||||
if err != nil {
|
||||
if vault.IsErrorStatus(err, http.StatusNotFound) {
|
||||
// Either this access key never existed, or Vault's own
|
||||
// delete_version_after TTL (see setSessionTTL) already
|
||||
// soft-deleted the version — confirmed live: Vault answers a
|
||||
// read for a soft-deleted-but-not-yet-destroyed version with
|
||||
// 404, not 200-with-null-data. Either way, best-effort purge
|
||||
// the lingering metadata record now, since Vault doesn't
|
||||
// appear to reclaim it on its own once merely soft-deleted.
|
||||
// delete_version_after TTL already soft-deleted the version —
|
||||
// Vault answers a read for a soft-deleted-but-not-yet-destroyed
|
||||
// version with 404, not 200-with-null-data. Either way,
|
||||
// best-effort purge the lingering metadata record now, since
|
||||
// Vault doesn't appear to reclaim it on its own once merely
|
||||
// soft-deleted.
|
||||
s.purgeSession(accessKeyID)
|
||||
return nil, ErrSessionNotFound
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user