mirror of
https://github.com/pgsty/minio.git
synced 2026-11-09 17:21:51 +03:00
633c9bfe09
The submitter binding compared the UpdatedAt of the identity record, so any rewrite of the record ended every running job of the credential with "changed since submission": a service account's description, name or later expiry, and the hourly LDAP group refresh of every LDAP-backed service account and STS credential whose groups changed. Compare only what identifies the credential: its type, access key and parent, the revocation boundary of the record, and the keyed binding of its secret and session token. Status, expiry, groups and policies are read again on every start and action. A deletion advances the revocation boundary, so a user created again under the access key with the same secret still fails the binding, and adding an expiry to a service account turns it into a temporary credential, which still ends the job. UpdatedAt stays in the binding for diagnosis. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Feng Ruohang <rh@vonng.com>
1006 lines
34 KiB
Go
1006 lines
34 KiB
Go
// Copyright (c) 2026 PGSTY
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/minio/minio-go/v7/pkg/tags"
|
|
"github.com/minio/minio/internal/arn"
|
|
"github.com/minio/minio/internal/auth"
|
|
"github.com/pgsty/silo-pkg/v3/policy"
|
|
"github.com/pgsty/silo-pkg/v3/policy/condition"
|
|
"github.com/puzpuzpuz/xsync/v3"
|
|
)
|
|
|
|
// A batch job acts on objects long after the request that submitted it has
|
|
// gone. It therefore records who submitted it and acts only as that
|
|
// principal:
|
|
//
|
|
// - StartBatchJob binds the authenticated credential to the job
|
|
// (batchJobSubmitter). Nothing in the submitted definition names it.
|
|
// - Every start of the job, and every object it touches, resolves the
|
|
// binding against IAM again. A credential that is gone, disabled,
|
|
// expired or revoked fails the job, and so does one whose secret,
|
|
// session token or parent changed since submission; a reused access key
|
|
// does not match the binding. Edits that leave the credential itself as
|
|
// it was (a name, a description, a later expiry of an expiring
|
|
// credential, the LDAP groups) keep the job: its policies, groups,
|
|
// status and expiry are read again each time.
|
|
// - Each action is evaluated with the submitter's policies and session
|
|
// policy but without a request. A Deny that may apply and would need
|
|
// request context the job does not have (source address, transport,
|
|
// headers and so on) refuses the action instead of being skipped; an
|
|
// Allow that would need it grants nothing to the job.
|
|
//
|
|
// A job without a binding was queued by an earlier release. It is never
|
|
// authorized, whoever submitted it, and has to be submitted again.
|
|
|
|
//go:generate msgp -file $GOFILE -unexported
|
|
|
|
// batchJobSubmitter is the server-assigned identity of the credential that
|
|
// submitted a batch job.
|
|
type batchJobSubmitter struct {
|
|
Version int `msg:"v"`
|
|
Type string `msg:"t"`
|
|
AccessKey string `msg:"ak"`
|
|
// ParentUser is the parent of a derived credential.
|
|
ParentUser string `msg:"pu"`
|
|
// UpdatedAt is the revision of the identity record at submission. It is
|
|
// recorded for diagnosis only: an edit that keeps the credential, such as
|
|
// a description, a later expiry or an LDAP group refresh, changes it and
|
|
// must not end the job. RevokedBefore is the revocation boundary of the
|
|
// record, which edits keep and a deletion advances, so a credential
|
|
// created again under the access key does not match. Both are unset for
|
|
// root.
|
|
UpdatedAt time.Time `msg:"ua"`
|
|
RevokedBefore time.Time `msg:"rb"`
|
|
// Binding is a keyed fingerprint of the credential itself, see
|
|
// batchSubmitterBinding.
|
|
Binding []byte `msg:"bd"`
|
|
}
|
|
|
|
const batchJobSubmitterVersion = 1
|
|
|
|
// Submitter types. A credential is classified in the order IsAllowed
|
|
// evaluates it.
|
|
const (
|
|
batchSubmitterRoot = "root"
|
|
batchSubmitterUser = "user"
|
|
batchSubmitterTemporary = "temporary"
|
|
batchSubmitterService = "service-account"
|
|
)
|
|
|
|
//msgp:ignore batchJobAuthzError
|
|
|
|
// batchJobAuthzError refuses a batch job, or one of its actions, for its
|
|
// submitter. It ends the job: it is persisted as the job's failure and is
|
|
// never retried.
|
|
type batchJobAuthzError struct {
|
|
reason string
|
|
}
|
|
|
|
func (e batchJobAuthzError) Error() string {
|
|
return e.reason
|
|
}
|
|
|
|
func isBatchJobAuthzError(err error) bool {
|
|
var e batchJobAuthzError
|
|
return errors.As(err, &e)
|
|
}
|
|
|
|
var (
|
|
errBatchJobResubmit = batchJobAuthzError{reason: "the batch job does not record who submitted it; submit it again"}
|
|
errBatchJobChanged = batchJobAuthzError{reason: "the credential that submitted the batch job has changed since submission; submit it again"}
|
|
errBatchJobInvalid = batchJobAuthzError{reason: "the credential that submitted the batch job is no longer valid"}
|
|
)
|
|
|
|
func batchSubmitterType(cred auth.Credentials) string {
|
|
switch {
|
|
case cred.IsTemp():
|
|
return batchSubmitterTemporary
|
|
case cred.IsServiceAccount():
|
|
return batchSubmitterService
|
|
default:
|
|
return batchSubmitterUser
|
|
}
|
|
}
|
|
|
|
// batchSubmitterBinding is a fingerprint of one credential keyed with its own
|
|
// secret. It changes when the secret, the session token or the parent
|
|
// changes, and a new credential that reuses the access key does not have it.
|
|
func batchSubmitterBinding(typ string, cred auth.Credentials) []byte {
|
|
mac := hmac.New(sha256.New, []byte(cred.SecretKey))
|
|
for _, s := range []string{"silo batch-job submitter v1", typ, cred.AccessKey, cred.ParentUser, cred.SessionToken} {
|
|
var n [8]byte
|
|
binary.BigEndian.PutUint64(n[:], uint64(len(s)))
|
|
mac.Write(n[:])
|
|
mac.Write([]byte(s))
|
|
}
|
|
return mac.Sum(nil)
|
|
}
|
|
|
|
// identityRecord returns the stored identity of a non-root access key.
|
|
func (sys *IAMSys) identityRecord(accessKey string) (UserIdentity, bool, error) {
|
|
if !sys.Initialized() {
|
|
return UserIdentity{}, false, errServerNotInitialized
|
|
}
|
|
u, ok := sys.store.GetUser(accessKey)
|
|
return u, ok, nil
|
|
}
|
|
|
|
// newBatchJobSubmitter binds the credential that authenticated a
|
|
// StartBatchJob request.
|
|
func newBatchJobSubmitter(cred auth.Credentials) (*batchJobSubmitter, error) {
|
|
root := globalActiveCred
|
|
if cred.AccessKey == "" {
|
|
return nil, errBatchJobInvalid
|
|
}
|
|
if cred.AccessKey == root.AccessKey {
|
|
if subtle.ConstantTimeCompare([]byte(cred.SecretKey), []byte(root.SecretKey)) != 1 {
|
|
return nil, errBatchJobChanged
|
|
}
|
|
return &batchJobSubmitter{
|
|
Version: batchJobSubmitterVersion,
|
|
Type: batchSubmitterRoot,
|
|
AccessKey: root.AccessKey,
|
|
Binding: batchSubmitterBinding(batchSubmitterRoot, root),
|
|
}, nil
|
|
}
|
|
u, ok, err := globalIAMSys.identityRecord(cred.AccessKey)
|
|
if err != nil {
|
|
return nil, batchUnavailable("IAM identity lookup", err)
|
|
}
|
|
// The record must be the one the request was authenticated with.
|
|
if !ok || u.Credentials.AccessKey != cred.AccessKey || u.Credentials.ParentUser != cred.ParentUser ||
|
|
subtle.ConstantTimeCompare([]byte(u.Credentials.SecretKey), []byte(cred.SecretKey)) != 1 ||
|
|
subtle.ConstantTimeCompare([]byte(u.Credentials.SessionToken), []byte(cred.SessionToken)) != 1 {
|
|
return nil, errBatchJobChanged
|
|
}
|
|
typ := batchSubmitterType(u.Credentials)
|
|
return &batchJobSubmitter{
|
|
Version: batchJobSubmitterVersion,
|
|
Type: typ,
|
|
AccessKey: u.Credentials.AccessKey,
|
|
ParentUser: u.Credentials.ParentUser,
|
|
UpdatedAt: u.UpdatedAt,
|
|
RevokedBefore: u.RevokedBefore,
|
|
Binding: batchSubmitterBinding(typ, u.Credentials),
|
|
}, nil
|
|
}
|
|
|
|
//msgp:ignore batchJobPrincipal batchAuthzRequest
|
|
|
|
// batchJobPrincipal is a submitter resolved against current IAM state.
|
|
type batchJobPrincipal struct {
|
|
ctx context.Context
|
|
typ string
|
|
accessKey string
|
|
parentUser string
|
|
groups []string
|
|
claims map[string]any
|
|
owner bool
|
|
temp bool // evaluated as a temporary credential
|
|
service bool // evaluated as a service account
|
|
}
|
|
|
|
// principal resolves the binding against current IAM state. It fails if the
|
|
// credential no longer exists, is no longer valid, or is no longer the one
|
|
// that was bound.
|
|
func (s *batchJobSubmitter) principal(ctx context.Context) (batchJobPrincipal, error) {
|
|
if s == nil || s.AccessKey == "" {
|
|
return batchJobPrincipal{}, errBatchJobResubmit
|
|
}
|
|
if s.Version != batchJobSubmitterVersion {
|
|
return batchJobPrincipal{}, batchJobAuthzError{reason: fmt.Sprintf("unsupported batch job submitter version %d; submit the job again", s.Version)}
|
|
}
|
|
if !batchIAMReady() {
|
|
return batchJobPrincipal{}, batchUnavailable("IAM is not initialized", errServerNotInitialized)
|
|
}
|
|
root := globalActiveCred
|
|
if s.Type == batchSubmitterRoot {
|
|
if s.AccessKey != root.AccessKey || !hmac.Equal(s.Binding, batchSubmitterBinding(batchSubmitterRoot, root)) {
|
|
return batchJobPrincipal{}, errBatchJobChanged
|
|
}
|
|
if !globalAPIConfig.permitRootAccess() {
|
|
return batchJobPrincipal{}, errBatchJobInvalid
|
|
}
|
|
return batchJobPrincipal{ctx: ctx, typ: batchSubmitterRoot, accessKey: root.AccessKey, owner: true}, nil
|
|
}
|
|
switch s.Type {
|
|
case batchSubmitterUser, batchSubmitterTemporary, batchSubmitterService:
|
|
default:
|
|
return batchJobPrincipal{}, errBatchJobResubmit
|
|
}
|
|
if s.AccessKey == root.AccessKey {
|
|
return batchJobPrincipal{}, errBatchJobChanged
|
|
}
|
|
u, ok, err := globalIAMSys.CheckKey(ctx, s.AccessKey)
|
|
if err != nil {
|
|
return batchJobPrincipal{}, batchUnavailable("IAM credential lookup", err)
|
|
}
|
|
if !ok {
|
|
// Deleted, disabled, expired or revoked.
|
|
return batchJobPrincipal{}, errBatchJobInvalid
|
|
}
|
|
cred := u.Credentials
|
|
typ := batchSubmitterType(cred)
|
|
// The binding covers the secret, the session token and the parent, so a
|
|
// rotated or reissued credential does not match it. The rest of the
|
|
// identity record (UpdatedAt) changes with edits that keep the
|
|
// credential and is not compared; what those edits decide, status,
|
|
// expiry, groups and policies, is evaluated below and per action.
|
|
if typ != s.Type || cred.AccessKey != s.AccessKey || cred.ParentUser != s.ParentUser ||
|
|
!u.RevokedBefore.Equal(s.RevokedBefore) ||
|
|
!hmac.Equal(s.Binding, batchSubmitterBinding(typ, cred)) {
|
|
return batchJobPrincipal{}, errBatchJobChanged
|
|
}
|
|
if cred.IsExpired() || cred.Status == auth.AccountOff {
|
|
return batchJobPrincipal{}, errBatchJobInvalid
|
|
}
|
|
p := batchJobPrincipal{
|
|
ctx: ctx,
|
|
typ: typ,
|
|
accessKey: cred.AccessKey,
|
|
parentUser: cred.ParentUser,
|
|
groups: cred.Groups,
|
|
claims: map[string]any{},
|
|
temp: cred.IsTemp(),
|
|
service: !cred.IsTemp() && cred.IsServiceAccount(),
|
|
}
|
|
if typ != batchSubmitterUser {
|
|
// Verify the token as a request would, with the current signing keys.
|
|
jwtClaims, err := extractJWTClaims(u)
|
|
if err != nil {
|
|
if errors.Is(err, errTokenSigningKeyUnavailable) {
|
|
return batchJobPrincipal{}, batchUnavailable("token signing key", err)
|
|
}
|
|
return batchJobPrincipal{}, errBatchJobInvalid
|
|
}
|
|
p.claims = jwtClaims.Map()
|
|
if err := decodeBatchSessionPolicy(p.claims); err != nil {
|
|
return batchJobPrincipal{}, errBatchJobInvalid
|
|
}
|
|
}
|
|
p.owner = cred.ParentUser == root.AccessKey && cred.AccessKey != siteReplicatorSvcAcc
|
|
if p.owner && !globalAPIConfig.permitRootAccess() {
|
|
return batchJobPrincipal{}, errBatchJobInvalid
|
|
}
|
|
if _, ok := p.claims[policy.SessionPolicyName]; ok {
|
|
p.owner = false
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// decodeBatchSessionPolicy decodes a session policy claim for evaluation.
|
|
// A request decodes it while verifying its token, except when an
|
|
// authorization plugin is configured; a batch job decodes it always, so the
|
|
// session policy also limits what the plugin allows.
|
|
func decodeBatchSessionPolicy(claims map[string]any) error {
|
|
sp, ok := claims[policy.SessionPolicyName]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
s, ok := sp.(string)
|
|
if !ok {
|
|
return errAuthentication
|
|
}
|
|
b, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return errAuthentication
|
|
}
|
|
claims[sessionPolicyNameExtracted] = string(b)
|
|
return nil
|
|
}
|
|
|
|
// batchAuthzRequest is one action a batch job is about to take.
|
|
type batchAuthzRequest struct {
|
|
action policy.Action
|
|
bucket string
|
|
object string // empty for a bucket action
|
|
// versionID is the version an object version action addresses.
|
|
versionID string
|
|
// tags are the current tags of the object version acted on.
|
|
tags string
|
|
// tagsUnknown is set when the job does not read the version it acts
|
|
// on, so s3:ExistingObjectTag is context it does not have.
|
|
tagsUnknown bool
|
|
// values are request values the job supplies itself, such as the
|
|
// listing prefix or the encryption and retention it writes, keyed by
|
|
// condition key name.
|
|
values map[string][]string
|
|
}
|
|
|
|
// batchAuthzMarker marks the condition values of a batch job for an
|
|
// authorization plugin. Requests cannot set s3:authType.
|
|
const batchAuthzMarker = "batch-job"
|
|
|
|
// Condition keys a batch job always provides, as a request would: identity,
|
|
// time, the addressed version and the stored tags. Keys a request takes from
|
|
// itself are provided only when the job sets them (see batchAuthzRequest
|
|
// values); every other key is request context the job does not have.
|
|
var batchServerConditionKeys = map[string]bool{
|
|
condition.AWSCurrentTime.ToKey().String(): true,
|
|
condition.AWSEpochTime.ToKey().String(): true,
|
|
condition.AWSPrincipalType.ToKey().String(): true,
|
|
condition.AWSUserID.ToKey().String(): true,
|
|
condition.AWSUsername.ToKey().String(): true,
|
|
condition.AWSGroups.ToKey().String(): true,
|
|
condition.S3VersionID.ToKey().String(): true,
|
|
condition.ExistingObjectTag.ToKey().String(): true,
|
|
}
|
|
|
|
// Request keys a job may supply, by the name they have in condition values.
|
|
var batchRequestConditionKeys = map[string]condition.KeyName{
|
|
condition.S3Prefix.Name(): condition.S3Prefix,
|
|
condition.S3XAmzServerSideEncryption.Name(): condition.S3XAmzServerSideEncryption,
|
|
condition.S3XAmzServerSideEncryptionAwsKmsKeyID.Name(): condition.S3XAmzServerSideEncryptionAwsKmsKeyID,
|
|
condition.S3ObjectLockMode.Name(): condition.S3ObjectLockMode,
|
|
condition.S3ObjectLockRetainUntilDate.Name(): condition.S3ObjectLockRetainUntilDate,
|
|
condition.S3ObjectLockLegalHold.Name(): condition.S3ObjectLockLegalHold,
|
|
condition.RequestObjectTagKeys.Name(): condition.RequestObjectTagKeys,
|
|
}
|
|
|
|
// suppliedConditionKeys returns the condition keys whose values req provides.
|
|
func (req batchAuthzRequest) suppliedConditionKeys() map[string]bool {
|
|
supplied := make(map[string]bool, len(batchServerConditionKeys)+len(req.values))
|
|
for k := range batchServerConditionKeys {
|
|
supplied[k] = true
|
|
}
|
|
if req.tagsUnknown {
|
|
delete(supplied, condition.ExistingObjectTag.ToKey().String())
|
|
}
|
|
for name := range req.values {
|
|
if key, ok := batchRequestConditionKeys[name]; ok {
|
|
supplied[key.ToKey().String()] = true
|
|
}
|
|
if strings.HasPrefix(name, condition.RequestObjectTag.Name()+"/") {
|
|
supplied[condition.RequestObjectTag.ToKey().String()] = true
|
|
}
|
|
}
|
|
return supplied
|
|
}
|
|
|
|
func batchKeySupplied(supplied map[string]bool, name string) bool {
|
|
if i := strings.IndexByte(name, '/'); i >= 0 {
|
|
name = name[:i]
|
|
}
|
|
if strings.HasPrefix(name, "jwt:") || strings.HasPrefix(name, "ldap:") {
|
|
// Claims of the verified token, as a request has them.
|
|
return true
|
|
}
|
|
return supplied[name]
|
|
}
|
|
|
|
// conditionValues are the values a request with the same identity would
|
|
// carry, without any value taken from the request itself.
|
|
func (p batchJobPrincipal) conditionValues(req batchAuthzRequest) map[string][]string {
|
|
now := UTCNow()
|
|
username := p.accessKey
|
|
if p.temp || p.service {
|
|
username = p.parentUser
|
|
}
|
|
principalType := "User"
|
|
if len(p.claims) > 0 {
|
|
principalType = "AssumedRole"
|
|
}
|
|
if username == globalActiveCred.AccessKey {
|
|
principalType = "Account"
|
|
}
|
|
// Identity and time, then the claims over them, as a request has them
|
|
// (an LDAP session's short username replaces its parent DN).
|
|
values := map[string][]string{
|
|
"CurrentTime": {now.Format(time.RFC3339)},
|
|
"EpochTime": {strconv.FormatInt(now.Unix(), 10)},
|
|
"principaltype": {principalType},
|
|
"userid": {username},
|
|
"username": {username},
|
|
}
|
|
for k, v := range p.claims {
|
|
if s, ok := v.(string); ok {
|
|
values[strings.ToLower(strings.TrimPrefix(k, "ldap"))] = []string{s}
|
|
}
|
|
}
|
|
// No claim stands for a value of the object or of the job, which
|
|
// follow and always win.
|
|
for k := range values {
|
|
if k == "versionid" || k == "prefix" || k == "authtype" ||
|
|
strings.HasPrefix(k, "existingobjecttag/") || strings.HasPrefix(k, "requestobjecttag") ||
|
|
strings.HasPrefix(k, "x-amz-") || strings.HasPrefix(k, "object-lock-") {
|
|
delete(values, k)
|
|
}
|
|
}
|
|
// For an authorization plugin: this is a batch job, not a request.
|
|
values["authType"] = []string{batchAuthzMarker}
|
|
if req.versionID != "" {
|
|
values["versionid"] = []string{req.versionID}
|
|
}
|
|
if req.tags != "" {
|
|
if t, err := tags.ParseObjectTags(req.tags); err == nil {
|
|
for k, v := range t.ToMap() {
|
|
values[pathJoin("ExistingObjectTag", k)] = []string{v}
|
|
}
|
|
}
|
|
}
|
|
for k, v := range req.values {
|
|
values[k] = v
|
|
}
|
|
if grpsVal, ok := p.claims["groups"]; ok {
|
|
if grpsIs, ok := grpsVal.([]any); ok {
|
|
grps := []string{}
|
|
for _, gI := range grpsIs {
|
|
if g, ok := gI.(string); ok {
|
|
grps = append(grps, g)
|
|
}
|
|
}
|
|
if len(grps) > 0 {
|
|
values["groups"] = grps
|
|
}
|
|
}
|
|
}
|
|
if _, ok := values["groups"]; !ok && len(p.groups) > 0 {
|
|
values["groups"] = p.groups
|
|
}
|
|
return values
|
|
}
|
|
|
|
// authorize evaluates one action of the job as its submitter.
|
|
func (p batchJobPrincipal) authorize(req batchAuthzRequest) error {
|
|
args := policy.Args{
|
|
AccountName: p.accessKey,
|
|
Groups: p.groups,
|
|
Action: req.action,
|
|
BucketName: req.bucket,
|
|
ObjectName: req.object,
|
|
ConditionValues: p.conditionValues(req),
|
|
IsOwner: p.owner,
|
|
Claims: p.claims,
|
|
}
|
|
return p.authorizeArgs(args, &req)
|
|
}
|
|
|
|
// authorizeArgs uses the same policy snapshot for request-context checks and
|
|
// the decision. No boolean IAM evaluator re-reads and silently drops policies.
|
|
func (p batchJobPrincipal) authorizeArgs(args policy.Args, req *batchAuthzRequest) error {
|
|
denied := batchJobAuthzError{reason: "the batch job submitter is not allowed " + string(args.Action)}
|
|
if req != nil {
|
|
denied.reason = batchDeniedReason(*req)
|
|
}
|
|
ctx := p.ctx
|
|
if ctx == nil {
|
|
ctx = GlobalContext
|
|
}
|
|
plugin := newGlobalAuthZPluginFn()
|
|
var snap *batchPolicySnapshot
|
|
if plugin == nil && !args.IsOwner {
|
|
snap = globalIAMSys.store.batchSnapshot(ctx)
|
|
defer snap.release()
|
|
}
|
|
mapped, session, err := globalIAMSys.batchLocalPolicies(p, args, plugin == nil, snap)
|
|
if err != nil {
|
|
if errors.Is(err, errNoSuchPolicy) || errors.Is(err, errAuthentication) || errors.Is(err, errNoSuchUser) || errors.Is(err, errNoSuchGroup) {
|
|
return denied
|
|
}
|
|
return batchUnavailable("IAM policy lookup", err)
|
|
}
|
|
// sessionNarrowed: the session policy lost an Allow below, so the
|
|
// narrowed policy decides in its place.
|
|
sessionNarrowed := false
|
|
if req != nil {
|
|
supplied := req.suppliedConditionKeys()
|
|
var refuse bool
|
|
if mapped, _, refuse = batchContextFree(mapped, args, supplied); !refuse {
|
|
session, sessionNarrowed, refuse = batchContextFree(session, args, supplied)
|
|
}
|
|
if refuse {
|
|
return batchJobAuthzError{reason: denied.reason + ": a policy statement depends on request context that a batch job does not have"}
|
|
}
|
|
}
|
|
sessionDenies := func() bool {
|
|
has, allowed := p.sessionPolicyDecision(args)
|
|
if has && allowed && sessionNarrowed {
|
|
// As sessionPolicyDecision evaluates it, without its dropped
|
|
// Allows. This can only turn an allow into a deny.
|
|
spArgs := args
|
|
spArgs.IsOwner, spArgs.DenyOnly = false, false
|
|
allowed = session.IsAllowed(spArgs)
|
|
}
|
|
return has && !allowed
|
|
}
|
|
if plugin != nil {
|
|
ok, err := plugin.IsAllowedContext(ctx, args)
|
|
if err != nil {
|
|
return batchUnavailable("authorization plugin", err)
|
|
}
|
|
if !ok {
|
|
return denied
|
|
}
|
|
// The plugin does not see a session policy; it only limits further.
|
|
if sessionDenies() {
|
|
return denied
|
|
}
|
|
return nil
|
|
}
|
|
if args.IsOwner {
|
|
return nil
|
|
}
|
|
if p.service {
|
|
if p.claims[parentClaim] != p.parentUser {
|
|
return denied
|
|
}
|
|
if _, ok := p.claims[iamPolicyClaimNameSA()].(string); !ok {
|
|
return denied
|
|
}
|
|
}
|
|
if sessionDenies() {
|
|
return denied
|
|
}
|
|
ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey
|
|
if !ownerDerived {
|
|
if p.service {
|
|
args.AccountName = p.parentUser
|
|
}
|
|
if mapped == nil || !mapped.IsAllowed(args) {
|
|
return denied
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// batchContextFree returns pol without the Allow statements that may apply
|
|
// to args but need request context the job does not supply (see
|
|
// batchStatementNeedsRequest), and whether it dropped any. Such an Allow
|
|
// grants nothing to the job. It is dropped rather than evaluated, because
|
|
// evaluated without the context it could grant what a request would not:
|
|
// IfExists and Null conditions hold for an absent key. Dropping an Allow can
|
|
// only narrow the decision. Dropping a Deny that needs the context could
|
|
// allow what a request with that context would not, so refuse reports one,
|
|
// and the job refuses the action.
|
|
func batchContextFree(pol *policy.Policy, args policy.Args, supplied map[string]bool) (out *policy.Policy, dropped, refuse bool) {
|
|
if pol == nil {
|
|
return nil, false, false
|
|
}
|
|
var kept []policy.Statement
|
|
for i, st := range pol.Statements {
|
|
if !batchStatementNeedsRequest(st, args, supplied) {
|
|
if dropped {
|
|
kept = append(kept, st)
|
|
}
|
|
continue
|
|
}
|
|
if st.Effect != policy.Allow {
|
|
return pol, false, true
|
|
}
|
|
if !dropped {
|
|
kept = append(make([]policy.Statement, 0, len(pol.Statements)-1), pol.Statements[:i]...)
|
|
dropped = true
|
|
}
|
|
}
|
|
if !dropped {
|
|
return pol, false, false
|
|
}
|
|
// A new policy: pol's statement index refers to pol's own statements, and
|
|
// pol may be a cached policy document.
|
|
return &policy.Policy{ID: pol.ID, Version: pol.Version, Statements: kept}, true, false
|
|
}
|
|
|
|
func batchDeniedReason(req batchAuthzRequest) string {
|
|
resource := req.bucket
|
|
if req.object != "" {
|
|
resource = pathJoin(req.bucket, req.object)
|
|
}
|
|
if req.versionID != "" {
|
|
resource += " (versionId " + req.versionID + ")"
|
|
}
|
|
return fmt.Sprintf("the batch job submitter is not allowed %s on %s", req.action, resource)
|
|
}
|
|
|
|
// sessionPolicyDecision evaluates the session policy the way IsAllowed would
|
|
// for this credential.
|
|
func (p batchJobPrincipal) sessionPolicyDecision(args policy.Args) (bool, bool) {
|
|
switch {
|
|
case p.temp:
|
|
return isAllowedBySessionPolicy(args)
|
|
case p.service:
|
|
if p.claims[iamPolicyClaimNameSA()] == inheritedPolicyType {
|
|
return false, false
|
|
}
|
|
return isAllowedBySessionPolicyForServiceAccount(args)
|
|
}
|
|
return false, false
|
|
}
|
|
|
|
// batchIAMLoadTimeout bounds one storage read of a lazy IAM load during
|
|
// batch authorization, as MergePolicies bounds a policy document load.
|
|
const batchIAMLoadTimeout = 5 * time.Second
|
|
|
|
// batchPolicySnapshot reads the mappings and policy documents of a submitter
|
|
// at one cache revision while cached authentication and authorization
|
|
// proceed.
|
|
//
|
|
// It holds the store's writer mutex, so no writer changes the cache between
|
|
// its reads, and the cache read lock, which it releases around each storage
|
|
// read: a lazy mapping load or a policy document load. Request
|
|
// authentication then neither waits for storage nor queues behind a writer
|
|
// waiting for the cache lock, which taking the write lock back after each
|
|
// read would cause. It writes nothing to the cache but the lazily loaded
|
|
// mappings, which the request path stores under the read lock as well.
|
|
//
|
|
//msgp:ignore batchPolicySnapshot
|
|
type batchPolicySnapshot struct {
|
|
*IAMStoreSys
|
|
ctx context.Context
|
|
cache *iamCache
|
|
mu sync.Mutex
|
|
reads int // storage reads in progress; the read lock is released while any
|
|
}
|
|
|
|
// batchSnapshot takes the snapshot; release it with release. rlock and
|
|
// runlock are the cache's read lock (IAMStorageAPI), not IAMStoreSys.lock,
|
|
// which also takes writeMu.
|
|
func (store *IAMStoreSys) batchSnapshot(ctx context.Context) *batchPolicySnapshot {
|
|
store.writeMu.Lock()
|
|
return &batchPolicySnapshot{IAMStoreSys: store, ctx: ctx, cache: store.rlock()}
|
|
}
|
|
|
|
func (s *batchPolicySnapshot) release() {
|
|
s.runlock()
|
|
s.writeMu.Unlock()
|
|
}
|
|
|
|
// read runs one storage read without the cache lock, bounded by the job's
|
|
// context and batchIAMLoadTimeout. Concurrent reads (the group fallback of
|
|
// policyDBGetGroups) release the lock before the first and take it back
|
|
// after the last.
|
|
func (s *batchPolicySnapshot) read(fn func(ctx context.Context) error) error {
|
|
s.mu.Lock()
|
|
if s.reads == 0 {
|
|
s.runlock()
|
|
}
|
|
s.reads++
|
|
s.mu.Unlock()
|
|
defer func() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.reads--
|
|
if s.reads == 0 {
|
|
s.rlock()
|
|
}
|
|
}()
|
|
ctx, cancel := context.WithTimeout(s.ctx, batchIAMLoadTimeout)
|
|
defer cancel()
|
|
return fn(ctx)
|
|
}
|
|
|
|
func (s *batchPolicySnapshot) loadMappedPolicy(_ context.Context, name string, userType IAMUserType, isGroup bool, m *xsync.MapOf[string, MappedPolicy]) error {
|
|
return s.read(func(ctx context.Context) error {
|
|
return s.IAMStoreSys.loadMappedPolicy(ctx, name, userType, isGroup, m)
|
|
})
|
|
}
|
|
|
|
// loadGroup would write the cache under the read lock. The snapshot resolves
|
|
// the policies of users, which never loads a group.
|
|
func (s *batchPolicySnapshot) loadGroup(context.Context, string, map[string]GroupInfo) error {
|
|
return errors.New("batch authorization does not load groups")
|
|
}
|
|
|
|
func (s *batchPolicySnapshot) loadPolicyDoc(name string, out map[string]PolicyDoc) error {
|
|
return s.read(func(ctx context.Context) error { return s.IAMStoreSys.loadPolicyDoc(ctx, name, out) })
|
|
}
|
|
|
|
// batchLocalPolicies returns every local policy that can take part in the
|
|
// decision on args: mapped, the mapped policies merged as IsAllowed would
|
|
// merge them, when withMapped is set (no authorization plugin), and session,
|
|
// the session policy. Each is nil when it takes no part.
|
|
func (sys *IAMSys) batchLocalPolicies(p batchJobPrincipal, args policy.Args, withMapped bool, snap *batchPolicySnapshot) (mapped, session *policy.Policy, err error) {
|
|
if withMapped && args.IsOwner {
|
|
// IsAllowed does not evaluate any policy for the owner.
|
|
return nil, nil, nil
|
|
}
|
|
if withMapped {
|
|
// The caller holds snap through evaluation. Lazy mapping loads and
|
|
// selected documents therefore belong to one cache revision.
|
|
cache := snap.cache
|
|
mappedPolicies := func(name string) ([]string, error) {
|
|
names, _, err := cache.policyDBGet(snap, name, false, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
groups, err := cache.policyDBGetGroups(snap, len(names) > 0, args.Groups...)
|
|
return append(names, groups...), err
|
|
}
|
|
var names []string
|
|
ownerDerived := (p.temp || p.service) && p.parentUser == globalActiveCred.AccessKey
|
|
switch {
|
|
case p.typ == batchSubmitterRoot || ownerDerived:
|
|
case p.temp || p.service:
|
|
if roleArn := args.GetRoleArn(); roleArn != "" {
|
|
a, err := arn.Parse(roleArn)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
names = append(names, newMappedPolicy(sys.rolesMap[a]).toSlice()...)
|
|
} else {
|
|
parent, err := mappedPolicies(p.parentUser)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
names = append(names, parent...)
|
|
// The claimed policies apply only when nothing is mapped.
|
|
if len(parent) == 0 {
|
|
if claimed, ok := policy.GetPoliciesFromClaims(args.Claims, iamPolicyClaimNameOpenID()); ok {
|
|
names = append(names, claimed.ToSlice()...)
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
own, err := mappedPolicies(p.accessKey)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
names = append(names, own...)
|
|
}
|
|
var selected []policy.Policy
|
|
for _, name := range names {
|
|
doc, ok := cache.iamPolicyDocsMap[name]
|
|
if !ok {
|
|
loaded := make(map[string]PolicyDoc)
|
|
err := snap.loadPolicyDoc(name, loaded)
|
|
if errors.Is(err, errNoSuchPolicy) {
|
|
// Confirmed absent, as when a mapping still names a
|
|
// deleted policy: it takes no part in the decision, as
|
|
// in MergePolicies. Any other failure could hide a Deny.
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
doc, ok = loaded[name]
|
|
if !ok {
|
|
return nil, nil, fmt.Errorf("load IAM policy %q returned no document", name)
|
|
}
|
|
}
|
|
selected = append(selected, doc.Policy)
|
|
}
|
|
merged := policy.MergePolicies(selected...)
|
|
mapped = &merged
|
|
}
|
|
if p.temp || p.service {
|
|
if p.service && p.claims[iamPolicyClaimNameSA()] == inheritedPolicyType {
|
|
return mapped, nil, nil
|
|
}
|
|
if sp, ok := p.claims[sessionPolicyNameExtracted]; ok {
|
|
s, ok := sp.(string)
|
|
if !ok {
|
|
return nil, nil, errAuthentication
|
|
}
|
|
parsed, err := policy.ParseConfig(strings.NewReader(s))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
session = parsed
|
|
}
|
|
}
|
|
return mapped, session, nil
|
|
}
|
|
|
|
// batchStatementNeedsRequest reports whether a statement may apply to args
|
|
// and depends on request context that the job does not supply: a condition
|
|
// key, or a policy variable in a resource or a condition value. Applying
|
|
// such a statement without its value could grant (Allow) or drop (Deny)
|
|
// what a request with that context would not (see batchContextFree).
|
|
//
|
|
// Resources are matched first and without conditions; a statement is taken
|
|
// to apply whenever it might. A statement whose resources rule args out
|
|
// does not apply, whatever its conditions need. One whose resources hold a
|
|
// variable the job does not supply might match, so it may apply.
|
|
func batchStatementNeedsRequest(st policy.Statement, args policy.Args, supplied map[string]bool) bool {
|
|
if (!st.Actions.Match(args.Action) && !st.Actions.IsEmpty()) || st.NotActions.Match(args.Action) {
|
|
return false
|
|
}
|
|
for _, resources := range []policy.ResourceSet{st.Resources, st.NotResources} {
|
|
for r := range resources {
|
|
if batchUnsuppliedVariable(r.Pattern, supplied) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
if !batchStatementMayApply(st, args) {
|
|
return false
|
|
}
|
|
for key := range st.Conditions.Keys() {
|
|
if !batchKeySupplied(supplied, key.String()) {
|
|
return true
|
|
}
|
|
}
|
|
// Policy variables in condition values.
|
|
if len(st.Conditions) > 0 {
|
|
data, err := json.Marshal(st.Conditions)
|
|
if err != nil || batchUnsuppliedVariable(string(data), supplied) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// batchUnsuppliedVariable reports whether s holds a policy variable, ${key},
|
|
// whose value the job does not supply.
|
|
func batchUnsuppliedVariable(s string, supplied map[string]bool) bool {
|
|
for {
|
|
i := strings.Index(s, "${")
|
|
if i < 0 {
|
|
return false
|
|
}
|
|
s = s[i+2:]
|
|
j := strings.IndexByte(s, '}')
|
|
if j < 0 {
|
|
return false
|
|
}
|
|
if !batchKeySupplied(supplied, s[:j]) {
|
|
return true
|
|
}
|
|
s = s[j+1:]
|
|
}
|
|
}
|
|
|
|
// batchStatementMayApply matches the resources of a statement, in both forms
|
|
// a bucket request can take. A statement whose resources the evaluator
|
|
// translates (S3 Tables and vectors actions cover S3 data actions) is taken
|
|
// to apply.
|
|
func batchStatementMayApply(st policy.Statement, args policy.Args) bool {
|
|
for _, actions := range []policy.ActionSet{st.Actions, st.NotActions} {
|
|
for action := range actions {
|
|
if policy.TableAction(action).IsValid() || policy.VectorsAction(action).IsValid() {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
for _, resources := range []policy.ResourceSet{st.Resources, st.NotResources} {
|
|
for r := range resources {
|
|
if r.Type != policy.ResourceARNS3 && r.Type != policy.ResourceARNAll {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
resources := []string{args.BucketName + "/" + strings.TrimPrefix(args.ObjectName, "/")}
|
|
if args.ObjectName == "" {
|
|
resources = []string{args.BucketName, args.BucketName + "/"}
|
|
}
|
|
for _, resource := range resources {
|
|
if len(st.Resources) > 0 && !st.Resources.Match(resource, args.ConditionValues) {
|
|
continue
|
|
}
|
|
if len(st.NotResources) > 0 && st.NotResources.Match(resource, args.ConditionValues) {
|
|
continue
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// authorize resolves the submitter and evaluates each request.
|
|
func (s *batchJobSubmitter) authorize(ctx context.Context, reqs ...batchAuthzRequest) error {
|
|
p, err := s.principal(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, req := range reqs {
|
|
if err := p.authorize(req); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// startAuthzRequests are the bucket actions a job needs before it acts on any
|
|
// object: the listings it walks. Object actions are authorized per object.
|
|
func (j BatchJobRequest) startAuthzRequests() []batchAuthzRequest {
|
|
list := func(bucket string, prefixes []string) []batchAuthzRequest {
|
|
if len(prefixes) == 0 {
|
|
prefixes = []string{""}
|
|
}
|
|
var reqs []batchAuthzRequest
|
|
for _, prefix := range prefixes {
|
|
values := map[string][]string{condition.S3Prefix.Name(): {prefix}}
|
|
reqs = append(reqs,
|
|
batchAuthzRequest{action: policy.ListBucketAction, bucket: bucket, values: values},
|
|
batchAuthzRequest{action: policy.ListBucketVersionsAction, bucket: bucket, values: values},
|
|
)
|
|
}
|
|
return reqs
|
|
}
|
|
// The same order as Type() and the job pool.
|
|
switch {
|
|
case j.Replicate != nil:
|
|
if !j.Replicate.RemoteToLocal() {
|
|
return list(j.Replicate.Source.Bucket, j.Replicate.Source.Prefix.F())
|
|
}
|
|
case j.KeyRotate != nil:
|
|
return list(j.KeyRotate.Bucket, []string{j.KeyRotate.Prefix})
|
|
case j.Expire != nil:
|
|
return list(j.Expire.Bucket, batchCanonicalPrefixes(j.Expire.Prefix.F()))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// jobSections counts the job types a definition holds. A job is one of them.
|
|
func (j BatchJobRequest) jobSections() int {
|
|
n := 0
|
|
for _, set := range []bool{j.Replicate != nil, j.KeyRotate != nil, j.Expire != nil} {
|
|
if set {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
var errBatchJobSections = batchJobAuthzError{reason: "a batch job must define exactly one job type"}
|
|
|
|
// refuse ends the job for its submitter.
|
|
func (ri *batchJobInfo) refuse(err error) {
|
|
ri.mu.Lock()
|
|
defer ri.mu.Unlock()
|
|
if ri.FailureReason == "" {
|
|
ri.FailureReason = err.Error()
|
|
}
|
|
ri.Complete = false
|
|
ri.Failed = true
|
|
ri.PausedReason = ""
|
|
}
|
|
|
|
func (ri *batchJobInfo) refused() bool {
|
|
ri.mu.RLock()
|
|
defer ri.mu.RUnlock()
|
|
return ri.FailureReason != ""
|
|
}
|
|
|
|
// startAuthorized checks the submitter of a job that is about to run. It
|
|
// returns done for a job that must not run now: one refused before, which
|
|
// stays refused, or one refused now, which is persisted as failed first.
|
|
// An error that is not a decision leaves the job to a later start.
|
|
func (ri *batchJobInfo) startAuthorized(ctx context.Context, api ObjectLayer, job BatchJobRequest) (done bool, err error) {
|
|
if !ri.refused() {
|
|
err := job.Submitter.authorize(ctx, job.startAuthzRequests()...)
|
|
if err == nil && job.jobSections() != 1 {
|
|
err = errBatchJobSections
|
|
}
|
|
if err == nil {
|
|
ri.mu.Lock()
|
|
ri.PausedReason = ""
|
|
ri.mu.Unlock()
|
|
// Persist the report version before acting. Older binaries must not
|
|
// resume an authorization-bearing job with server authority.
|
|
if err := ri.updateAfter(ctx, api, 0, job); err != nil {
|
|
return true, batchUnavailable("saving job progress", err)
|
|
}
|
|
return false, nil
|
|
}
|
|
if isBatchJobUnavailable(err) {
|
|
return true, ri.savePaused(ctx, api, job, err)
|
|
}
|
|
if !isBatchJobAuthzError(err) {
|
|
return true, err
|
|
}
|
|
ri.refuse(err)
|
|
batchLogIf(ctx, fmt.Errorf("batch job %s refused: %w", job.ID, err))
|
|
}
|
|
globalBatchJobsMetrics.save(job.ID, ri)
|
|
if err := ri.updateAfter(ctx, api, 0, job); err != nil {
|
|
return true, batchUnavailable("saving job refusal", err)
|
|
}
|
|
return true, nil
|
|
}
|