Files
seaweedfs/weed/s3api/s3tables/permissions.go
T
Chris LuandClaude Opus 5.5 8e577b8685 s3tables: match legacy subject patterns in table bucket policy principals
A policy written before issuer scoping can name role sessions by a pattern
over their bare OIDC subjects. Match such a pattern against the caller's
subject claim once that subject resolves to the session's scoped principal
under the single configured issuer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 04:17:13 -08:00

875 lines
29 KiB
Go

package s3tables
import (
"bytes"
"encoding/json"
"fmt"
"math"
"net"
"strconv"
"strings"
"time"
"github.com/seaweedfs/seaweedfs/weed/glog"
"github.com/seaweedfs/seaweedfs/weed/iam/sts"
"github.com/seaweedfs/seaweedfs/weed/s3api/policy_engine"
"github.com/seaweedfs/seaweedfs/weed/s3api/s3_constants"
"github.com/seaweedfs/seaweedfs/weed/util/wildcard"
)
// Permission represents a specific action permission
type Permission string
// IAM Policy structures for evaluation
type PolicyDocument struct {
Version string `json:"Version"`
Statement []Statement `json:"Statement"`
}
// UnmarshalJSON handles both single statement object and array of statements
// AWS allows {"Statement": {...}} or {"Statement": [{...}]}
func (pd *PolicyDocument) UnmarshalJSON(data []byte) error {
type Alias PolicyDocument
aux := &struct {
Statement json.RawMessage `json:"Statement"`
*Alias
}{
Alias: (*Alias)(pd),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
var statement interface{}
if len(aux.Statement) > 0 {
if err := json.Unmarshal(aux.Statement, &statement); err != nil {
return err
}
}
// Handle Statement as either a single object or array
raws := []json.RawMessage{aux.Statement}
switch s := statement.(type) {
case map[string]interface{}:
// Single statement object - unmarshal to one Statement
stmtData, err := json.Marshal(s)
if err != nil {
return fmt.Errorf("failed to marshal single statement: %w", err)
}
var stmt Statement
if err := json.Unmarshal(stmtData, &stmt); err != nil {
return fmt.Errorf("failed to unmarshal single statement: %w", err)
}
pd.Statement = []Statement{stmt}
case []interface{}:
// Array of statements - normal handling
stmtData, err := json.Marshal(s)
if err != nil {
return fmt.Errorf("failed to marshal statement array: %w", err)
}
if err := json.Unmarshal(stmtData, &pd.Statement); err != nil {
return fmt.Errorf("failed to unmarshal statement array: %w", err)
}
if err := json.Unmarshal(aux.Statement, &raws); err != nil {
return err
}
case nil:
// No statements
pd.Statement = []Statement{}
default:
return fmt.Errorf("Statement must be an object or array, got %T", statement)
}
// The re-marshal above keeps only the last of a repeated key, so the
// Condition keys are counted from the original bytes.
for i := range pd.Statement {
if i < len(raws) {
pd.Statement[i].nullCondition, pd.Statement[i].splitCondition = conditionKeys(raws[i])
}
}
return nil
}
type Statement struct {
Effect string `json:"Effect"` // "Allow" or "Deny"
Principal interface{} `json:"Principal"` // Can be string, []string, or map
Action interface{} `json:"Action"` // Can be string or []string
Resource interface{} `json:"Resource"` // Can be string or []string
Condition map[string]map[string]interface{} `json:"Condition,omitempty"`
// nullCondition records a literal "Condition": null, which decodes the
// same as no Condition.
nullCondition bool
// splitCondition records more than one Condition key, in any spelling.
// Those objects decode into one map in which an operator they share keeps
// only the last one's keys.
splitCondition bool
}
// conditionKeys walks a statement object's keys, which a map would collapse,
// and reports a null Condition and a Condition under more than one key.
func conditionKeys(data []byte) (null, split bool) {
dec := json.NewDecoder(bytes.NewReader(data))
if tok, err := dec.Token(); err != nil || tok != json.Delim('{') {
return false, false
}
conditions := 0
for dec.More() {
key, err := dec.Token()
if err != nil {
break
}
var raw json.RawMessage
if err := dec.Decode(&raw); err != nil {
break
}
if name, _ := key.(string); strings.EqualFold(name, "Condition") {
conditions++
null = null || string(raw) == "null"
}
}
return null, conditions > 1
}
// SplitCondition reports a statement with more than one Condition key, which
// the evaluator never grants on and always vetoes with.
func (s *Statement) SplitCondition() bool {
return s.splitCondition
}
type PolicyContext struct {
Namespace string
TableName string
TableBucketName string
IdentityActions []string
RequestTags map[string]string
ResourceTags map[string]string
TableBucketTags map[string]string
TagKeys []string
SSEAlgorithm string
KMSKeyArn string
StorageClass string
DefaultAllow bool
// TrustedIssuers lists the OIDC issuers the deployment is configured to
// trust, used to re-anchor owners/principals written before issuer-scoped
// account IDs. A bare subject is only resolvable when exactly one issuer
// is configured; more than one leaves it ambiguous, so it must not match.
TrustedIssuers []string
// IsLocalAccountID reports whether an account ID belongs to a configured
// local identity. Such owner strings were never OIDC subjects and must not
// be re-anchored; a nil predicate exempts nothing beyond the built-in
// reserved, ARN, and scoped-id checks.
IsLocalAccountID func(string) bool
// Subject is the caller's OIDC subject, matched against subject patterns
// written before issuer scoping once it is shown to hash to the principal.
Subject string
}
// CheckPermissionWithContext checks permission with optional resource and condition context.
func CheckPermissionWithContext(operation, principal, owner, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
// Deny access if identities are empty
if principal == "" || owner == "" {
return false
}
// Admin always has permission.
if principal == s3_constants.AccountAdminId && adminCaller(operation, ctx) {
return true
}
glog.V(2).Infof("S3Tables: CheckPermission operation=%s principal=%s owner=%s", operation, principal, owner)
return checkPermission(operation, principal, owner, resourcePolicy, resourceARN, ctx)
}
// CheckTableOrBucketPermission combines a table or namespace policy with its
// table bucket's policy into one decision: a matching Deny on either policy
// vetoes the request even when the other policy would allow it.
func CheckTableOrBucketPermission(operation, principal, resourceOwner, resourcePolicy, resourceARN, bucketOwner, bucketPolicy, bucketARN string, ctx *PolicyContext) bool {
if principal == "" {
return false
}
if principal == s3_constants.AccountAdminId && adminCaller(operation, ctx) {
return true
}
if matchesDenyStatement(operation, principal, resourcePolicy, resourceARN, ctx) ||
matchesDenyStatement(operation, principal, bucketPolicy, bucketARN, ctx) {
return false
}
// Each side fails closed when its owner is unknown, matching
// CheckPermissionWithContext; an ownerless side contributes nothing.
resourceOK := resourceOwner != "" &&
checkPermission(operation, principal, resourceOwner, resourcePolicy, resourceARN, ctx)
bucketOK := bucketOwner != "" &&
checkPermission(operation, principal, bucketOwner, bucketPolicy, bucketARN, ctx)
return resourceOK || bucketOK
}
// validatePolicyDocument rejects a policy the evaluator could not honor: one
// that does not parse, whose actions and resources name the vector service,
// whose conditions the evaluator cannot compare, or whose Allow carries a null
// Condition. Any of them stored here would be a rule the writer believes is
// enforced — vector requests authorize their s3tables action on a s3tables
// ARN, an unusable condition is skipped on an Allow and vetoes everything on a
// Deny, and a null Condition reads as none, so the Allow grants unconditionally.
func validatePolicyDocument(raw string) error {
var doc PolicyDocument
if err := json.Unmarshal([]byte(raw), &doc); err != nil {
return fmt.Errorf("invalid policy document: %w", err)
}
for _, stmt := range doc.Statement {
// A null Condition on a Deny only widens the veto, so it stays allowed.
if stmt.Effect == "Allow" && stmt.nullCondition {
return fmt.Errorf("invalid policy document: an Allow statement's Condition must not be null")
}
if stmt.splitCondition {
return fmt.Errorf("invalid policy document: a statement's Condition must be under one key, spelled Condition")
}
for operator, keys := range stmt.Condition {
if _, err := policy_engine.GetConditionEvaluator(operator); err != nil {
return fmt.Errorf("invalid policy document: condition operator %q is not supported", operator)
}
// An operator with no keys holds for every request.
if len(keys) == 0 {
return fmt.Errorf("invalid policy document: condition %s needs at least one key", operator)
}
for key, value := range keys {
if !tableConditionKey(key) {
return fmt.Errorf("invalid policy document: condition key %q is not supported in a table policy", key)
}
if !ConditionValue(operator, value) {
return fmt.Errorf("invalid policy document: condition %s on %q needs values %s can read (a number for Numeric*, an RFC 3339 time for Date*, true or false for Bool and Null, an IP or CIDR for IpAddress)", operator, key, operator)
}
}
}
for _, action := range statementActions(stmt.Action) {
if strings.HasPrefix(strings.ToLower(action), "s3vectors:") {
return fmt.Errorf("action %q is not enforceable in a resource policy", action)
}
}
for _, resource := range statementActions(stmt.Resource) {
if strings.EqualFold(arnService(resource), "s3vectors") {
return fmt.Errorf("resource %q is not enforceable in a resource policy", resource)
}
}
}
return nil
}
// ConditionValue reports whether operator's evaluator can read v: a JSON
// type it can compare, and a value it parses rather than skips. A null
// value would panic it, an empty list never matches, and an unreadable
// value is skipped — dropping a Deny or matching a negated Allow.
func ConditionValue(operator string, v interface{}) bool {
list, ok := v.([]interface{})
if !ok {
list = []interface{}{v}
}
base, qualified := strings.CutPrefix(operator, "ForAllValues:")
if !qualified {
base, _ = strings.CutPrefix(operator, "ForAnyValue:")
}
for _, item := range list {
switch item.(type) {
case string, float64, bool:
default:
return false
}
if !conditionValueReadable(base, item) {
return false
}
}
return len(list) > 0
}
// conditionValueReadable reports whether the evaluator for base parses item
// the way it reads it. An operator without a case here is refused, so one
// added to the policy engine cannot be accepted before it is checked.
func conditionValueReadable(base string, item interface{}) bool {
switch base {
case "StringEquals", "StringNotEquals", "StringLike", "StringNotLike",
"StringEqualsIgnoreCase", "StringNotEqualsIgnoreCase", "ArnEquals", "ArnLike":
return true
case "NumericEquals", "NumericNotEquals", "NumericLessThan", "NumericLessThanEquals",
"NumericGreaterThan", "NumericGreaterThanEquals":
switch v := item.(type) {
case float64:
return true
case string:
f, err := strconv.ParseFloat(v, 64)
return err == nil && !math.IsNaN(f) && !math.IsInf(f, 0)
}
case "DateEquals", "DateNotEquals", "DateLessThan", "DateLessThanEquals",
"DateGreaterThan", "DateGreaterThanEquals":
if v, ok := item.(string); ok {
_, err := time.Parse(time.RFC3339, v)
return err == nil
}
case "Bool", "Null":
switch v := item.(type) {
case bool:
return true
case string:
return strings.EqualFold(v, "true") || strings.EqualFold(v, "false")
}
case "IpAddress", "NotIpAddress":
if v, ok := item.(string); ok {
if _, _, err := net.ParseCIDR(v); err == nil {
return true
}
return net.ParseIP(v) != nil
}
}
return false
}
// arnService returns the service field of an ARN in any partition, so a
// resource naming s3vectors is caught for aws-cn, aws-us-gov, and friends —
// not just the commercial partition.
func arnService(resource string) string {
parts := strings.SplitN(resource, ":", 4)
if len(parts) == 4 && parts[0] == "arn" {
return parts[2]
}
return ""
}
func statementActions(spec interface{}) []string {
switch a := spec.(type) {
case string:
return []string{a}
case []interface{}:
var actions []string
for _, item := range a {
if str, ok := item.(string); ok {
actions = append(actions, str)
}
}
return actions
}
return nil
}
// deniedByPolicy reports whether another applicable resource policy vetoes the
// request. The admin account is exempt, matching CheckPermissionWithContext.
func deniedByPolicy(operation, principal, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
return (principal != s3_constants.AccountAdminId || !adminCaller(operation, ctx)) &&
matchesDenyStatement(operation, principal, resourcePolicy, resourceARN, ctx)
}
// adminCaller reports whether the admin principal bypass applies. Trusted
// transports carry no action list, while an identified caller always has one
// — possibly empty — and holds the bypass only when the identity administers
// the operation's service: a principal that merely normalizes to the admin
// id does not bypass policy denies.
func adminCaller(operation string, ctx *PolicyContext) bool {
return ctx == nil || ctx.IdentityActions == nil || hasAdminActionFor(operation, ctx.IdentityActions)
}
// matchesDenyStatement reports whether the JSON policy carries a statement
// matching this request with Effect Deny. Malformed policies return false;
// the caller's normal evaluation still denies them. A Deny whose condition
// cannot be evaluated applies: it was written to protect the resource, and
// dropping it would widen access.
func matchesDenyStatement(operation, principal, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
if resourcePolicy == "" {
return false
}
fullAction := operation
if !strings.Contains(operation, ":") {
fullAction = "s3tables:" + operation
}
var policy PolicyDocument
if err := json.Unmarshal([]byte(resourcePolicy), &policy); err != nil {
return false
}
for _, stmt := range policy.Statement {
if stmt.Effect != "Deny" ||
!matchesDenyCaller(stmt.Principal, principal, ctx) ||
!matchesAction(stmt.Action, fullAction) ||
(resourceARN != "" && !matchesResource(stmt.Resource, resourceARN)) {
continue
}
matched, usable := matchesConditions(stmt.Condition, ctx)
if !usable || stmt.splitCondition {
glog.Warningf("S3Tables: policy on %q has a Deny condition that cannot be evaluated; denying %s for %s until the policy is replaced", resourceARN, fullAction, principal)
return true
}
if matched {
return true
}
}
return false
}
func checkPermission(operation, principal, owner, resourcePolicy, resourceARN string, ctx *PolicyContext) bool {
// An explicit Deny in the resource policy vetoes owner and identity
// grants as well as allows within the same document.
if matchesDenyStatement(operation, principal, resourcePolicy, resourceARN, ctx) {
return false
}
// Owner always has permission.
if principal == owner || matchesLegacySubject(owner, principal, ctx) {
return true
}
if hasIdentityPermission(operation, ctx) {
return true
}
// If no policy is provided, use default allow if enabled
if resourcePolicy == "" {
if ctx != nil && ctx.DefaultAllow {
return true
}
return false
}
// Normalize operation to full IAM-style action name (e.g., "s3tables:CreateTableBucket")
// if not already prefixed
fullAction := operation
if !strings.Contains(operation, ":") {
fullAction = "s3tables:" + operation
}
// Parse and evaluate policy
var policy PolicyDocument
if err := json.Unmarshal([]byte(resourcePolicy), &policy); err != nil {
return false
}
// Evaluate policy statements
// Default is deny, so we need an explicit allow
hasAllow := false
for _, stmt := range policy.Statement {
// Check if principal matches
if !matchesCaller(stmt.Principal, principal, ctx) {
continue
}
// Check if action matches (using normalized full action name)
if !matchesAction(stmt.Action, fullAction) {
continue
}
// Check if resource matches (if resourceARN specified and Resource field exists)
if resourceARN != "" && !matchesResource(stmt.Resource, resourceARN) {
continue
}
// A condition that cannot be evaluated never grants, nor does a null
// or split one stored before validatePolicyDocument refused it.
if stmt.Effect == "Allow" && (stmt.nullCondition || stmt.splitCondition) {
continue
}
if matched, usable := matchesConditions(stmt.Condition, ctx); !usable || !matched {
continue
}
// Statement matches - check effect
switch stmt.Effect {
case "Allow":
hasAllow = true
case "Deny":
// Explicit deny always wins
return false
}
}
if hasAllow {
return true
}
// If no statement matched, use default allow if enabled
if ctx != nil && ctx.DefaultAllow {
return true
}
return false
}
func hasIdentityPermission(operation string, ctx *PolicyContext) bool {
if ctx == nil || len(ctx.IdentityActions) == 0 {
return false
}
fullAction := operation
if !strings.Contains(operation, ":") {
fullAction = "s3tables:" + operation
}
if hasAdminActionFor(operation, ctx.IdentityActions) {
return true
}
candidates := []string{operation, fullAction}
if ctx.TableBucketName != "" {
candidates = append(candidates, operation+":"+ctx.TableBucketName, fullAction+":"+ctx.TableBucketName)
}
for _, action := range ctx.IdentityActions {
for _, candidate := range candidates {
if action == candidate {
return true
}
if strings.ContainsAny(action, "*?") && wildcard.MatchesWildcard(action, candidate) {
return true
}
}
}
return false
}
// hasAdminAction reports whether the action list grants blanket admin access.
// Legacy static identities may use broad markers or s3 wildcards; s3:* is treated
// as s3tables:* so shared admin policies still permit table access.
func hasAdminAction(actions []string) bool {
for _, action := range actions {
if action == "*" || action == s3_constants.ACTION_ADMIN || action == "s3:*" || action == "s3tables:*" {
return true
}
}
return false
}
// hasAdminActionFor reports whether the action list administers the
// operation's own service: the global admin markers always apply, while a
// service wildcard covers only that service — s3tables:* never reaches an
// s3vectors operation.
func hasAdminActionFor(operation string, actions []string) bool {
if !strings.Contains(operation, ":") {
operation = "s3tables:" + operation
}
serviceWildcard := operation[:strings.Index(operation, ":")] + ":*"
for _, action := range actions {
if action == "*" || action == s3_constants.ACTION_ADMIN || action == "s3:*" || action == serviceWildcard {
return true
}
}
return false
}
// matchesCaller matches a statement's Principal against the caller's scoped
// principal or, for policies written before issuer scoping, a bare subject
// that resolves to it under the deployment's single trusted issuer.
func matchesCaller(principalSpec interface{}, principal string, ctx *PolicyContext) bool {
return matchCaller(principalSpec, principal, ctx, false)
}
// matchesDenyCaller is matchesCaller for deny evaluation: an ARN-scoped
// statement also reaches a caller stored under the ARN's trailing name, so a
// deny cannot be ducked through normalization the way an allow must not grant
// through it.
func matchesDenyCaller(principalSpec interface{}, principal string, ctx *PolicyContext) bool {
return matchCaller(principalSpec, principal, ctx, true)
}
func matchCaller(principalSpec interface{}, principal string, ctx *PolicyContext, forDeny bool) bool {
if matchPrincipal(principalSpec, principal, forDeny) {
return true
}
switch p := principalSpec.(type) {
case string:
return matchesLegacyPrincipal(p, principal, ctx)
case []interface{}:
for _, item := range p {
if str, ok := item.(string); ok && matchesLegacyPrincipal(str, principal, ctx) {
return true
}
}
case map[string]interface{}:
if aws, ok := p["AWS"]; ok {
return matchCaller(aws, principal, ctx, forDeny)
}
}
return false
}
// matchesLegacySubject reports whether a bare subject stored before issuer
// scoping belongs to the caller. Re-anchoring is only sound when exactly one
// OIDC issuer is configured: the stored subject is hashed under that issuer
// and compared to the caller's scoped principal. With multiple issuers a bare
// subject is ambiguous — matching it under the caller's own issuer would let
// a same-sub identity from any configured issuer claim it.
func matchesLegacySubject(legacy, principal string, ctx *PolicyContext) bool {
if ctx == nil || len(ctx.TrustedIssuers) != 1 {
return false
}
// The reserved accounts, ARN values, and already issuer-scoped ids are never
// OIDC subjects — or, for strings that could be either a legacy subject or a
// scoped id, resolving to scoped fails closed. A configured local account
// id is excluded the same way once the caller supplies the predicate. Any
// of them could otherwise be claimed by a token asserting that string as
// sub — including the admin account or a victim's scoped id.
if legacy == s3_constants.AccountAdminId || legacy == s3_constants.AccountAnonymousId ||
strings.HasPrefix(legacy, "arn:") || sts.IsParentUserID(legacy) ||
(ctx.IsLocalAccountID != nil && ctx.IsLocalAccountID(legacy)) {
return false
}
return sts.ComputeParentUser(legacy, ctx.TrustedIssuers[0]) == principal
}
// matchesLegacyPrincipal is matchesLegacySubject for a policy principal, which
// may also be a subject pattern: it matches the caller's subject only when that
// subject itself resolves to the principal.
func matchesLegacyPrincipal(spec, principal string, ctx *PolicyContext) bool {
if matchesLegacySubject(spec, principal, ctx) {
return true
}
return ctx != nil && strings.ContainsAny(spec, "*?") && !strings.HasPrefix(spec, "arn:") &&
wildcard.MatchesWildcard(spec, ctx.Subject) && matchesLegacySubject(ctx.Subject, principal, ctx)
}
// matchesPrincipal checks if the principal matches the statement's principal
func matchesPrincipal(principalSpec interface{}, principal string) bool {
return matchPrincipal(principalSpec, principal, false)
}
func matchPrincipal(principalSpec interface{}, principal string, forDeny bool) bool {
if principalSpec == nil {
return false
}
switch p := principalSpec.(type) {
case string:
return matchPrincipalString(p, principal, forDeny)
case []interface{}:
// Array of principals
for _, item := range p {
if str, ok := item.(string); ok && matchPrincipalString(str, principal, forDeny) {
return true
}
}
case map[string]interface{}:
// AWS-style principal with service prefix, e.g., {"AWS": "arn:aws:iam::..."}
// For S3 Tables, we primarily care about the AWS key
if aws, ok := p["AWS"]; ok {
return matchPrincipal(aws, principal, forDeny)
}
}
return false
}
// matchPrincipalString reports whether a single principal spec matches the
// caller. An ARN-scoped statement reaches a caller recorded under the ARN's
// trailing name only for denies: normalization drops the ARN's account and
// path, so an allow matched that way could grant a same-named caller the
// statement never verified, while a deny must still land so it cannot be
// ducked through the same collapse.
func matchPrincipalString(spec, principal string, forDeny bool) bool {
if spec == "*" || spec == principal {
return true
}
if wildcard.MatchesWildcard(spec, principal) {
return true
}
return forDeny && strings.HasPrefix(spec, "arn:") && !strings.HasPrefix(principal, "arn:") &&
wildcard.MatchesWildcard(normalizePrincipalID(spec), principal)
}
// matchesAction checks if the action matches the statement's action
func matchesAction(actionSpec interface{}, action string) bool {
if actionSpec == nil {
return false
}
switch a := actionSpec.(type) {
case string:
// Direct match or wildcard
return matchesActionPattern(a, action)
case []interface{}:
// Array of actions
for _, item := range a {
if str, ok := item.(string); ok {
if matchesActionPattern(str, action) {
return true
}
}
}
}
return false
}
// matchesActionPattern checks if an action matches a pattern (supports wildcards)
// This uses the policy_engine.MatchesWildcard function for full wildcard support,
// including middle wildcards (e.g., "s3tables:Get*Table") for complete IAM compatibility.
func matchesActionPattern(pattern, action string) bool {
if pattern == "*" {
return true
}
// Exact match
if pattern == action {
return true
}
// Wildcard match using policy engine's wildcard matcher
// Supports both * (any sequence) and ? (single character) anywhere in the pattern
return wildcard.MatchesWildcard(pattern, action)
}
// matchesConditions reports whether every condition holds for ctx, and
// whether the block can be evaluated at all: one unsupported operator, keyless
// operator or uncomparable value leaves it unusable, and the caller decides
// what that means for the statement's Effect. Every operator is checked
// before answering, so the verdict does not depend on map iteration order.
func matchesConditions(conditions map[string]map[string]interface{}, ctx *PolicyContext) (matched, usable bool) {
if len(conditions) == 0 {
return true, true
}
matched = true
for operator, conditionValues := range conditions {
ok, valid := matchesConditionOperator(operator, conditionValues, ctx)
if !valid {
return false, false
}
matched = matched && ok
}
return matched, true
}
func matchesConditionOperator(operator string, conditionValues map[string]interface{}, ctx *PolicyContext) (matched, usable bool) {
evaluator, err := policy_engine.GetConditionEvaluator(operator)
if err != nil {
return false, false
}
// With no keys the loop below would hold for every request; a stored
// keyless operator is unusable, so it never lets an Allow grant.
if len(conditionValues) == 0 {
return false, false
}
for _, value := range conditionValues {
if !ConditionValue(operator, value) {
return false, false
}
}
if ctx == nil {
return false, true
}
for key, value := range conditionValues {
contextVals := getConditionContextValues(key, ctx)
if !evaluator.Evaluate(value, contextVals) {
return false, true
}
}
return true, true
}
// tableConditionKey reports whether getConditionContextValues supplies key.
func tableConditionKey(key string) bool {
switch key {
case "s3tables:namespace", "s3tables:tableName", "s3tables:tableBucketName",
"s3tables:SSEAlgorithm", "s3tables:KMSKeyArn", "s3tables:StorageClass", "aws:TagKeys":
return true
}
for _, prefix := range []string{"aws:RequestTag/", "aws:ResourceTag/", "s3tables:TableBucketTag/", "s3vectors:VectorBucketTag/"} {
if tagKey, ok := strings.CutPrefix(key, prefix); ok {
return tagKey != ""
}
}
return false
}
func getConditionContextValues(key string, ctx *PolicyContext) []string {
switch key {
case "s3tables:namespace":
return []string{ctx.Namespace}
case "s3tables:tableName":
return []string{ctx.TableName}
case "s3tables:tableBucketName":
return []string{ctx.TableBucketName}
case "s3tables:SSEAlgorithm":
return []string{ctx.SSEAlgorithm}
case "s3tables:KMSKeyArn":
return []string{ctx.KMSKeyArn}
case "s3tables:StorageClass":
return []string{ctx.StorageClass}
case "aws:TagKeys":
return ctx.TagKeys
}
if strings.HasPrefix(key, "aws:RequestTag/") {
tagKey := strings.TrimPrefix(key, "aws:RequestTag/")
if val, ok := ctx.RequestTags[tagKey]; ok {
return []string{val}
}
}
if strings.HasPrefix(key, "aws:ResourceTag/") {
tagKey := strings.TrimPrefix(key, "aws:ResourceTag/")
if val, ok := ctx.ResourceTags[tagKey]; ok {
return []string{val}
}
}
tagKey, ok := strings.CutPrefix(key, "s3tables:TableBucketTag/")
if !ok {
tagKey, ok = strings.CutPrefix(key, "s3vectors:VectorBucketTag/")
}
if ok {
if val, found := ctx.TableBucketTags[tagKey]; found {
return []string{val}
}
}
return nil
}
// matchesResource checks if the resource ARN matches the statement's resource specification
// Returns true if resource matches or if Resource is not specified (implicit match)
func matchesResource(resourceSpec interface{}, resourceARN string) bool {
// If no Resource is specified, match all resources (implicit *)
if resourceSpec == nil {
return true
}
switch r := resourceSpec.(type) {
case string:
// Direct match or wildcard
return matchesResourcePattern(r, resourceARN)
case []interface{}:
// Array of resources - match if any matches
for _, item := range r {
if str, ok := item.(string); ok {
if matchesResourcePattern(str, resourceARN) {
return true
}
}
}
}
return false
}
// matchesResourcePattern checks if a resource ARN matches a pattern (supports wildcards)
func matchesResourcePattern(pattern, resourceARN string) bool {
if pattern == "*" {
return true
}
// Exact match
if pattern == resourceARN {
return true
}
// Wildcard match using policy engine's wildcard matcher
return wildcard.MatchesWildcard(pattern, resourceARN)
}
// AuthError represents an authorization error
type AuthError struct {
Operation string
Principal string
Message string
}
func (e *AuthError) Error() string {
return "unauthorized: " + e.Principal + " is not permitted to perform " + e.Operation + ": " + e.Message
}
// NewAuthError creates a new authorization error
func NewAuthError(operation, principal, message string) *AuthError {
return &AuthError{
Operation: operation,
Principal: principal,
Message: message,
}
}