mirror of
https://github.com/versity/versitygw.git
synced 2026-08-17 20:56:21 +00:00
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.
566 lines
21 KiB
Go
566 lines
21 KiB
Go
// Copyright 2026 Versity Software
|
|
// This file is licensed under the Apache License, Version 2.0
|
|
// (the "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing,
|
|
// software distributed under the License is distributed on an
|
|
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
// KIND, either express or implied. See the License for the
|
|
// specific language governing permissions and limitations
|
|
// under the License.
|
|
|
|
// Package condition implements AWS IAM's policy Condition grammar and
|
|
// evaluation semantics: the operator registry (StringEquals, IpAddress,
|
|
// DateGreaterThan, ...), the ForAllValues/ForAnyValue/IfExists modifiers,
|
|
// and ${...} policy-variable substitution. It has no knowledge of any
|
|
// particular policy type (identity, trust, or resource-based) — callers
|
|
// supply a statement's raw Condition block, a request's context-key values,
|
|
// and the enclosing document's Version, and get back whether the condition
|
|
// holds. This lets both iamapi/policy (IAM identity/trust policies) and
|
|
// auth (S3 bucket policies) share one implementation and one AWS-verified
|
|
// behavior, rather than maintaining two.
|
|
package condition
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/versity/versitygw/debuglogger"
|
|
)
|
|
|
|
// Values decodes the value(s) of a single Condition operator/key pair.
|
|
// Unlike Action/Resource's string-only representation, a Condition value may
|
|
// also be a bare JSON number or boolean rather than a string, 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 Values []string
|
|
|
|
func (c *Values) 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 = Values{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
|
|
}
|
|
|
|
// Block is a statement's Condition object, decoded to operator name -> key
|
|
// -> value(s).
|
|
type Block map[string]map[string]Values
|
|
|
|
// Qualifier is IAM's multivalued-context-key set operator, given as a
|
|
// "ForAllValues:"/"ForAnyValue:" prefix on a condition operator name.
|
|
type Qualifier int
|
|
|
|
const (
|
|
QualifierNone Qualifier = 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 is a plain string comparison, not a base64-decode-then-
|
|
// compare: AWS's own IAM condition-operator reference documents the
|
|
// request context value as itself the base64 text (the same string
|
|
// that appears in the policy on a match), never the decoded raw bytes
|
|
// - live-verified via iam:SimulateCustomPolicy, which also rejects a
|
|
// non-base64 binary-typed context value outright. Do not "fix" this to
|
|
// decode either side.
|
|
"BinaryEquals": {compare: stringExact},
|
|
|
|
// 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
|
|
}
|
|
|
|
// normalizeIPOrCIDR appends a full-length prefix ("/32" or "/128") to s when
|
|
// it names a bare address rather than a CIDR range, so a single address and
|
|
// its equivalent /32 or /128 range are always handled the same way.
|
|
func normalizeIPOrCIDR(s string) string {
|
|
if strings.Contains(s, "/") {
|
|
return s
|
|
}
|
|
if ip := net.ParseIP(s); ip != nil && ip.To4() != nil {
|
|
return s + "/32"
|
|
}
|
|
return s + "/128"
|
|
}
|
|
|
|
// ParseIPOrCIDR reports whether s is a valid IP address or CIDR range, for
|
|
// write-time validation of an IP-semantic condition key's value (e.g. AWS
|
|
// rejects PutBucketPolicy for a non-IP aws:SourceIp value with "Invalid IP
|
|
// address in Conditions", independent of which operator wraps it).
|
|
func ParseIPOrCIDR(s string) bool {
|
|
_, _, err := net.ParseCIDR(normalizeIPOrCIDR(s))
|
|
return err == nil
|
|
}
|
|
|
|
// 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 {
|
|
_, network, err := net.ParseCIDR(normalizeIPOrCIDR(cidr))
|
|
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 Qualifier
|
|
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
|
|
}
|
|
|
|
// Parse decodes raw (a statement's Condition block) into a Block, validating
|
|
// only its JSON shape and that every operator name is one ParseOperatorName
|
|
// recognizes - not condition key names, which are meaningful only to a
|
|
// specific policy type (IAM identity policies accept arbitrary custom/tag
|
|
// keys; S3 bucket policies validate against AWS's fixed key catalogue) and
|
|
// so are the caller's responsibility. An absent, null, or empty raw decodes
|
|
// to a nil Block with no error, matching Evaluate's "always matches"
|
|
// contract for a statement with no Condition at all.
|
|
func Parse(raw json.RawMessage) (Block, error) {
|
|
if len(raw) == 0 || string(bytes.TrimSpace(raw)) == "null" {
|
|
return nil, nil
|
|
}
|
|
var block Block
|
|
if err := json.Unmarshal(raw, &block); err != nil {
|
|
return nil, err
|
|
}
|
|
for operator := range block {
|
|
if _, ok := ParseOperatorName(operator); !ok {
|
|
return nil, fmt.Errorf("policy: unrecognized condition operator %q", operator)
|
|
}
|
|
}
|
|
return block, nil
|
|
}
|
|
|
|
// ShapeValid reports whether raw (a statement's Condition block) satisfies
|
|
// Parse without error - write-time validation of the condition grammar
|
|
// alone (operator names), with no opinion on condition keys.
|
|
func ShapeValid(raw json.RawMessage) bool {
|
|
_, err := Parse(raw)
|
|
return err == nil
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
|
|
// Evaluate evaluates a policy statement's Condition block against ctxVars -
|
|
// context-key values keyed however the caller's policy type documents them
|
|
// (e.g. "aws:<GlobalKey>" for IAM identity/S3 bucket policies,
|
|
// "<provider-url>:<claim>" for trust-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 "2012-10-17" 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 Parse would
|
|
// already reject - i.e. only for a document stored before 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.
|
|
func Evaluate(raw json.RawMessage, ctxVars map[string][]string, version string) (matched bool, ok bool) {
|
|
block, err := Parse(raw)
|
|
if err != nil {
|
|
debuglogger.Logf("policy condition block failed to parse: %v", err)
|
|
return false, false
|
|
}
|
|
|
|
for operator, kvs := range block {
|
|
op, _ := ParseOperatorName(operator) // Parse already validated every operator name
|
|
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
|
|
}
|
|
|
|
// version2012 is AWS's "2012-10-17" policy-document version string, the
|
|
// only one that enables ${...} policy-variable substitution. Duplicated
|
|
// here (rather than imported) since this package has no dependency on any
|
|
// specific policy type's Version constants.
|
|
const version2012 = "2012-10-17"
|
|
|
|
// 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 Values, ctxVars map[string][]string) Values {
|
|
out := make(Values, 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 Values, 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 Values, 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 Qualifier, negate bool, expected Values, 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
|
|
}
|
|
|
|
// GlobMatch implements the small wildcard grammar IAM Action/Resource/Arn
|
|
// 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)
|
|
}
|