mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-11-14 07:48:41 +01:00
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>
875 lines
29 KiB
Go
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,
|
|
}
|
|
}
|