From 0ba69891970dae4863c66069fce258d3e3a080e4 Mon Sep 17 00:00:00 2001 From: Denis Kirillov Date: Fri, 4 Apr 2025 18:04:58 +0300 Subject: [PATCH] [#680] Move policy engine converter to s3-gw Signed-off-by: Denis Kirillov --- api/handler/acl.go | 25 +- api/handler/acl_test.go | 8 +- api/handler/api.go | 36 +- api/handler/delete.go | 2 +- api/handler/handlers_test.go | 22 +- api/handler/put.go | 4 +- api/handler/put_test.go | 2 +- cmd/s3-gw/app.go | 18 +- cmd/s3-gw/app_settings.go | 19 + config/config.env | 2 + config/config.yaml | 3 + docs/configuration.md | 35 +- internal/logs/logs.go | 1 + pkg/policy-engine/common/converter.go | 427 ++++ pkg/policy-engine/common/policy.go | 338 +++ pkg/policy-engine/common/policy_test.go | 479 ++++ pkg/policy-engine/common/resolver.go | 15 + pkg/policy-engine/converter.go | 118 + pkg/policy-engine/v2/iam/converter_native.go | 420 ++++ pkg/policy-engine/v2/iam/converter_s3.go | 269 +++ pkg/policy-engine/v2/iam/converter_test.go | 2132 ++++++++++++++++++ 21 files changed, 4325 insertions(+), 50 deletions(-) create mode 100644 pkg/policy-engine/common/converter.go create mode 100644 pkg/policy-engine/common/policy.go create mode 100644 pkg/policy-engine/common/policy_test.go create mode 100644 pkg/policy-engine/common/resolver.go create mode 100644 pkg/policy-engine/converter.go create mode 100644 pkg/policy-engine/v2/iam/converter_native.go create mode 100644 pkg/policy-engine/v2/iam/converter_s3.go create mode 100644 pkg/policy-engine/v2/iam/converter_test.go diff --git a/api/handler/acl.go b/api/handler/acl.go index ff8324af9..1104b2a22 100644 --- a/api/handler/acl.go +++ b/api/handler/acl.go @@ -19,6 +19,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs" + s3common "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine/common" engineiam "git.frostfs.info/TrueCloudLab/policy-engine/iam" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" "github.com/nspcc-dev/neo-go/pkg/crypto/keys" @@ -177,7 +178,7 @@ func (h *handler) putBucketACLAPEHandler(w http.ResponseWriter, r *http.Request, } chainRules := bucketCannedACLToAPERules(cannedACL, reqInfo, bktInfo.CID) - if err = h.ape.SaveACLChains(bktInfo.CID.EncodeToString(), chainRules); err != nil { + if err = h.policyEngine.APE.SaveACLChains(bktInfo.CID.EncodeToString(), chainRules); err != nil { h.logAndSendError(ctx, w, "failed to add morph rule chains", reqInfo, err) return } @@ -248,7 +249,7 @@ func (h *handler) GetBucketPolicyStatusHandler(w http.ResponseWriter, r *http.Re return } - jsonPolicy, err := h.ape.GetBucketPolicy(reqInfo.Namespace, bktInfo.CID) + jsonPolicy, err := h.policyEngine.APE.GetBucketPolicy(reqInfo.Namespace, bktInfo.CID) if err != nil { if strings.Contains(err.Error(), "not found") { err = fmt.Errorf("%w: %s", errors.GetAPIError(errors.ErrNoSuchBucketPolicy), err.Error()) @@ -293,7 +294,7 @@ func (h *handler) GetBucketPolicyHandler(w http.ResponseWriter, r *http.Request) return } - jsonPolicy, err := h.ape.GetBucketPolicy(reqInfo.Namespace, bktInfo.CID) + jsonPolicy, err := h.policyEngine.APE.GetBucketPolicy(reqInfo.Namespace, bktInfo.CID) if err != nil { if strings.Contains(err.Error(), "not found") { err = fmt.Errorf("%w: %s", errors.GetAPIError(errors.ErrNoSuchBucketPolicy), err.Error()) @@ -323,7 +324,7 @@ func (h *handler) DeleteBucketPolicyHandler(w http.ResponseWriter, r *http.Reque } chainIDs := []chain.ID{getBucketChainID(chain.S3, bktInfo), getBucketChainID(chain.Ingress, bktInfo)} - if err = h.ape.DeleteBucketPolicy(reqInfo.Namespace, bktInfo.CID, chainIDs); err != nil { + if err = h.policyEngine.APE.DeleteBucketPolicy(reqInfo.Namespace, bktInfo.CID, chainIDs); err != nil { h.logAndSendError(ctx, w, "failed to delete policy from storage", reqInfo, err) return } @@ -360,7 +361,7 @@ func (h *handler) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Request) return } - var bktPolicy engineiam.Policy + var bktPolicy s3common.Policy if err = json.Unmarshal(jsonPolicy, &bktPolicy); err != nil { h.logAndSendError(ctx, w, "could not parse bucket policy", reqInfo, err) return @@ -372,7 +373,7 @@ func (h *handler) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Request) return } - if len(stat.NotPrincipal) != 0 && stat.Effect == engineiam.AllowEffect { + if len(stat.NotPrincipal) != 0 && stat.Effect == s3common.AllowEffect { h.logAndSendError(ctx, w, "invalid NotPrincipal", reqInfo, errors.GetAPIError(errors.ErrMalformedPolicyNotPrincipal)) return } @@ -385,14 +386,14 @@ func (h *handler) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Request) } } - s3Chain, err := engineiam.ConvertToS3Chain(bktPolicy, h.frostfsid) + s3Chain, err := h.policyEngine.Converter.ToS3Chain(bktPolicy, h.frostfsid) if err != nil { h.logAndSendError(ctx, w, "could not convert s3 policy to chain policy", reqInfo, err) return } s3Chain.ID = getBucketChainID(chain.S3, bktInfo) - nativeChain, err := engineiam.ConvertToNativeChain(bktPolicy, h.nativeResolver(reqInfo.Namespace, bktInfo)) + nativeChain, err := h.policyEngine.Converter.ToNativeChain(bktPolicy, h.nativeResolver(reqInfo.Namespace, bktInfo)) if err == nil { nativeChain.ID = getBucketChainID(chain.Ingress, bktInfo) } else if !stderrors.Is(err, engineiam.ErrActionsNotApplicable) { @@ -407,7 +408,7 @@ func (h *handler) PutBucketPolicyHandler(w http.ResponseWriter, r *http.Request) chainsToSave = append(chainsToSave, nativeChain) } - if err = h.ape.PutBucketPolicy(reqInfo.Namespace, bktInfo.CID, jsonPolicy, chainsToSave); err != nil { + if err = h.policyEngine.APE.PutBucketPolicy(reqInfo.Namespace, bktInfo.CID, jsonPolicy, chainsToSave); err != nil { h.logAndSendError(ctx, w, "failed to update policy in contract", reqInfo, err) return } @@ -419,15 +420,15 @@ type nativeResolver struct { bktInfo *data.BucketInfo } -func (n *nativeResolver) GetBucketInfo(bucket string) (*engineiam.BucketInfo, error) { +func (n *nativeResolver) GetBucketInfo(bucket string) (*s3common.BucketInfo, error) { if n.bktInfo.Name != bucket { return nil, fmt.Errorf("invalid bucket %s: %w", bucket, errors.GetAPIError(errors.ErrMalformedPolicy)) } - return &engineiam.BucketInfo{Namespace: n.namespace, Container: n.bktInfo.CID.EncodeToString()}, nil + return &s3common.BucketInfo{Namespace: n.namespace, Container: n.bktInfo.CID.EncodeToString()}, nil } -func (h *handler) nativeResolver(ns string, bktInfo *data.BucketInfo) engineiam.NativeResolver { +func (h *handler) nativeResolver(ns string, bktInfo *data.BucketInfo) s3common.NativeResolver { return &nativeResolver{ FrostFSID: h.frostfsid, namespace: ns, diff --git a/api/handler/acl_test.go b/api/handler/acl_test.go index 2533ca560..eab081afa 100644 --- a/api/handler/acl_test.go +++ b/api/handler/acl_test.go @@ -189,14 +189,14 @@ func TestDeleteBucketWithPolicy(t *testing.T) { putBucketPolicy(hc, bktName, newPolicy) - require.Len(t, hc.h.ape.(*apeMock).policyMap, 1) - require.Len(t, hc.h.ape.(*apeMock).chainMap[engine.ContainerTarget(bi.CID.EncodeToString())], 4) + require.Len(t, hc.h.policyEngine.APE.(*apeMock).policyMap, 1) + require.Len(t, hc.h.policyEngine.APE.(*apeMock).chainMap[engine.ContainerTarget(bi.CID.EncodeToString())], 4) hc.owner = bi.Owner deleteBucket(t, hc, bktName, http.StatusNoContent) - require.Empty(t, hc.h.ape.(*apeMock).policyMap) - chains, err := hc.h.ape.(*apeMock).ListChains(engine.ContainerTarget(bi.CID.EncodeToString())) + require.Empty(t, hc.h.policyEngine.APE.(*apeMock).policyMap) + chains, err := hc.h.policyEngine.APE.(*apeMock).ListChains(engine.ContainerTarget(bi.CID.EncodeToString())) require.NoError(t, err) require.Empty(t, chains) } diff --git a/api/handler/api.go b/api/handler/api.go index 15cbd19c4..f88038644 100644 --- a/api/handler/api.go +++ b/api/handler/api.go @@ -12,6 +12,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-mfa/mfa" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/layer" + policyengine "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/netmap" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" @@ -20,12 +21,12 @@ import ( type ( handler struct { - log *zap.Logger - obj *layer.Layer - cfg Config - ape APE - frostfsid FrostFSID - mfa *mfa.Manager + log *zap.Logger + obj *layer.Layer + cfg Config + policyEngine PolicyEngine + frostfsid FrostFSID + mfa *mfa.Manager } // Config contains data which handler needs to keep. @@ -54,6 +55,11 @@ type ( GetUserKey(account, name string) (string, error) } + PolicyEngine struct { + APE APE + Converter *policyengine.Converter + } + // APE is Access Policy Engine that needs to save policy and acl info to different places. APE interface { PutBucketPolicy(ns string, cnrID cid.ID, policy []byte, chains []*chain.Chain) error @@ -73,14 +79,14 @@ const ( var _ api.Handler = (*handler)(nil) // New creates new api.Handler using given logger and client. -func New(log *zap.Logger, obj *layer.Layer, cfg Config, storage APE, ffsid FrostFSID, mfaMgr *mfa.Manager) (api.Handler, error) { +func New(log *zap.Logger, obj *layer.Layer, cfg Config, policyEngine PolicyEngine, ffsid FrostFSID, mfaMgr *mfa.Manager) (api.Handler, error) { switch { case obj == nil: return nil, errors.New("empty FrostFS Object Layer") case log == nil: return nil, errors.New("empty logger") - case storage == nil: - return nil, errors.New("empty policy storage") + case policyEngine.APE == nil || policyEngine.Converter == nil: + return nil, errors.New("empty policy engine") case ffsid == nil: return nil, errors.New("empty frostfsid") case mfaMgr == nil: @@ -88,12 +94,12 @@ func New(log *zap.Logger, obj *layer.Layer, cfg Config, storage APE, ffsid Frost } return &handler{ - log: log, - obj: obj, - cfg: cfg, - ape: storage, - frostfsid: ffsid, - mfa: mfaMgr, + log: log, + obj: obj, + cfg: cfg, + policyEngine: policyEngine, + frostfsid: ffsid, + mfa: mfaMgr, }, nil } diff --git a/api/handler/delete.go b/api/handler/delete.go index 685a94b35..e17b9deaa 100644 --- a/api/handler/delete.go +++ b/api/handler/delete.go @@ -345,7 +345,7 @@ func (h *handler) DeleteBucketHandler(w http.ResponseWriter, r *http.Request) { getBucketCannedChainID(chain.S3, bktInfo.CID), getBucketCannedChainID(chain.Ingress, bktInfo.CID), } - if err = h.ape.DeleteBucketPolicy(reqInfo.Namespace, bktInfo.CID, chainIDs); err != nil { + if err = h.policyEngine.APE.DeleteBucketPolicy(reqInfo.Namespace, bktInfo.CID, chainIDs); err != nil { h.logAndSendError(ctx, w, "failed to delete policy from storage", reqInfo, err) return } diff --git a/api/handler/handlers_test.go b/api/handler/handlers_test.go index f51844aca..493cda8ab 100644 --- a/api/handler/handlers_test.go +++ b/api/handler/handlers_test.go @@ -26,6 +26,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/resolver" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/creds/accessbox" intmfa "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/frostfs/mfa" + policyengine "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/service/tree" bearertest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer/test" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" @@ -287,10 +288,15 @@ func prepareHandlerContextBase(config *handlerConfig, log *zap.Logger) (*handler placementPolicies: make(map[string]netmap.PlacementPolicy), } h := &handler{ - log: log, - obj: layer.NewLayer(ctx, log, tp, layerCfg), - cfg: cfg, - ape: newAPEMock(), + log: log, + obj: layer.NewLayer(ctx, log, tp, layerCfg), + cfg: cfg, + policyEngine: PolicyEngine{ + APE: newAPEMock(), + Converter: policyengine.NewConverter(policyengine.Config{ + VersionFetcher: apeConverterMock{version: policyengine.V1}, + }), + }, frostfsid: newFrostfsIDMock(), } @@ -377,6 +383,14 @@ func getMinCacheConfig(logger *zap.Logger) *layer.CachesConfig { } } +type apeConverterMock struct { + version policyengine.ConverterVersion +} + +func (a apeConverterMock) ConverterVersion() policyengine.ConverterVersion { + return a.version +} + type apeMock struct { chainMap map[engine.Target][]*chain.Chain policyMap map[string][]byte diff --git a/api/handler/put.go b/api/handler/put.go index 12093ed89..c2004e083 100644 --- a/api/handler/put.go +++ b/api/handler/put.go @@ -860,7 +860,7 @@ func (h *handler) createBucketHandlerPolicy(w http.ResponseWriter, r *http.Reque zap.Stringer("container_id", bktInfo.CID), logs.TagField(logs.TagExternalStorage)) chains := bucketCannedACLToAPERules(cannedACL, reqInfo, bktInfo.CID) - if err = h.ape.SaveACLChains(bktInfo.CID.EncodeToString(), chains); err != nil { + if err = h.policyEngine.APE.SaveACLChains(bktInfo.CID.EncodeToString(), chains); err != nil { cleanErr := h.cleanupBucketCreation(ctx, reqInfo, bktInfo, boxData, chains) h.logAndSendError(ctx, w, "failed to add morph rule chain", reqInfo, err, zap.NamedError("cleanup_error", cleanErr)) return @@ -913,7 +913,7 @@ func (h *handler) cleanupBucketCreation(ctx context.Context, reqInfo *middleware chainIDs[i] = c.ID } - if err := h.ape.DeleteBucketPolicy(reqInfo.Namespace, bktInfo.CID, chainIDs); err != nil { + if err := h.policyEngine.APE.DeleteBucketPolicy(reqInfo.Namespace, bktInfo.CID, chainIDs); err != nil { return fmt.Errorf("delete bucket acl policy: %w", err) } diff --git a/api/handler/put_test.go b/api/handler/put_test.go index 66f4e4297..d83bc30cc 100644 --- a/api/handler/put_test.go +++ b/api/handler/put_test.go @@ -1006,7 +1006,7 @@ func TestCreateBucketWithoutPermissions(t *testing.T) { hc := prepareHandlerContext(t) bktName := "bkt-name" - hc.h.ape.(*apeMock).err = errors.New("no permissions") + hc.h.policyEngine.APE.(*apeMock).err = errors.New("no permissions") box, _ := createAccessBox(t) createBucketAssertS3Error(hc, bktName, box, apierr.ErrInternalError) diff --git a/cmd/s3-gw/app.go b/cmd/s3-gw/app.go index 9cba11b3a..57e3d39c4 100644 --- a/cmd/s3-gw/app.go +++ b/cmd/s3-gw/app.go @@ -44,6 +44,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/version" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/wallet" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/metrics" + policyengine "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/service/tree" v2container "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/api/container" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container" @@ -149,6 +150,7 @@ type ( removeOnReplaceTimeout time.Duration corsCopiesNumbers []uint32 lifecycleCopiesNumbers []uint32 + apeConvertersVersion policyengine.ConverterVersion } maxClientsConfig struct { @@ -407,6 +409,7 @@ func (s *appSettings) update(v *viper.Viper, log *zap.Logger) { removeOnReplaceTimeout := fetchRemoveOnReplaceTimeout(v) corsCopiesNumbers := fetchCopiesNumbers(log, v, cfgCORSCopiesNumbers) lifecycleCopiesNumbers := fetchCopiesNumbers(log, v, cfgLifecycleCopiesNumbers) + apeConvertersVersion := fetchAPEConvertersVersion(log, v) s.mu.Lock() defer s.mu.Unlock() @@ -445,6 +448,7 @@ func (s *appSettings) update(v *viper.Viper, log *zap.Logger) { s.removeOnReplaceTimeout = removeOnReplaceTimeout s.corsCopiesNumbers = corsCopiesNumbers s.lifecycleCopiesNumbers = lifecycleCopiesNumbers + s.apeConvertersVersion = apeConvertersVersion } func (s *appSettings) prepareVHSNamespaces(v *viper.Viper, log *zap.Logger, defaultNamespaces []string) map[string]bool { @@ -718,6 +722,12 @@ func (s *appSettings) LifecycleCopiesNumbers() []uint32 { return s.lifecycleCopiesNumbers } +func (s *appSettings) ConverterVersion() policyengine.ConverterVersion { + s.mu.RLock() + defer s.mu.RUnlock() + return s.apeConvertersVersion +} + func (a *App) initAPI(ctx context.Context, rpcCli *rpcclient.Client) { a.initLayer(ctx, rpcCli) @@ -1294,7 +1304,13 @@ func (a *App) initMfaManager(ctx context.Context) *mfa.Manager { func (a *App) initHandler(ctx context.Context) { var err error manager := a.initMfaManager(ctx) - a.api, err = handler.New(a.log, a.obj, a.settings, a.policyStorage, a.frostfsid, manager) + policyEngine := handler.PolicyEngine{ + APE: a.policyStorage, + Converter: policyengine.NewConverter(policyengine.Config{ + VersionFetcher: a.settings, + }), + } + a.api, err = handler.New(a.log, a.obj, a.settings, policyEngine, a.frostfsid, manager) if err != nil { a.log.Fatal(logs.CouldNotInitializeAPIHandler, zap.Error(err), logs.TagField(logs.TagApp)) } diff --git a/cmd/s3-gw/app_settings.go b/cmd/s3-gw/app_settings.go index 2174579bd..e92d1f94d 100644 --- a/cmd/s3-gw/app_settings.go +++ b/cmd/s3-gw/app_settings.go @@ -19,6 +19,7 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/logs" internalnet "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/net" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/internal/version" + policyengine "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/netmap" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/pool" "github.com/spf13/pflag" @@ -81,6 +82,8 @@ const ( defaultRemoveOnReplaceTimeout = 30 * time.Second defaultRemoveOnReplaceQueue = 10000 + + defaultFeaturesAPEConvertersVersion = policyengine.V1 ) var ( @@ -291,6 +294,9 @@ const ( cfgRemoveOnReplaceTimeout = "features.remove_on_replace.timeout" cfgRemoveOnReplaceQueue = "features.remove_on_replace.queue" + // APE. + cfgFeaturesAPEConvertersVersion = "features.ape.converters_version" + // FrostfsID. cfgFrostfsIDContract = "frostfsid.contract" cfgFrostfsIDValidationEnabled = "frostfsid.validation.enabled" @@ -660,6 +666,19 @@ func fetchCopiesNumbers(l *zap.Logger, v *viper.Viper, param string) []uint32 { return result } +func fetchAPEConvertersVersion(l *zap.Logger, v *viper.Viper) policyengine.ConverterVersion { + val := v.GetString(cfgFeaturesAPEConvertersVersion) + switch ver := policyengine.ConverterVersion(val); ver { + case policyengine.V1, policyengine.V2: + return ver + default: + l.Warn(logs.InvalidAPEConvertersVersion, zap.String("version", val), + zap.String("default", string(defaultFeaturesAPEConvertersVersion)), + logs.TagField(logs.TagApp)) + return defaultFeaturesAPEConvertersVersion + } +} + type KludgeParams struct { UseDefaultXMLNS bool BypassContentEncodingCheckInChunks bool diff --git a/config/config.env b/config/config.env index 40d61d10c..e6c6f2013 100644 --- a/config/config.env +++ b/config/config.env @@ -236,6 +236,8 @@ S3_GW_FEATURES_REMOVE_ON_REPLACE_ENABLED=false S3_GW_FEATURES_REMOVE_ON_REPLACE_TIMEOUT=30s # Buffer size for objects to delete. If buffer is full creation new unversioned object won't remove old one. Lifecycler will do that. S3_GW_FEATURES_REMOVE_ON_REPLACE_QUEUE=10000 +#Version of Access Policy converters (Supported versions: `v1`, `v2`). Need to transform AWS IAM policy to supported FrostFS chain format. +S3_GW_FEATURES_APE_CONVERTERS_VERSION=v1 # ReadTimeout is the maximum duration for reading the entire # request, including the body. A zero or negative value means diff --git a/config/config.yaml b/config/config.yaml index d841c79c6..aa8ed7f53 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -277,6 +277,9 @@ features: timeout: 30s # Buffer size for objects to delete. If buffer is full creation new unversioned object won't remove old one. Lifecycler will do that. queue: 10000 + ape: + # Version of Access Policy converters (Supported versions: `v1`, `v2`). Need to transform AWS IAM policy to supported FrostFS chain format. + converters_version: v1 web: # ReadTimeout is the maximum duration for reading the entire diff --git a/docs/configuration.md b/docs/configuration.md index e34b7848b..084fed52d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -753,16 +753,31 @@ features: enabled: false timeout: 30s queue: 10000 + ape: + converters_version: v1 ``` -| Parameter | Type | SIGHUP reload | Default value | Description | -|-----------------------------|-------------|---------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------| -| `md5.enabled` | `bool` | yes | `false` | Flag to enable return MD5 checksum in ETag headers and fields. | -| `policy.deny_by_default` | `bool` | yes | `false` | Enable denying access for request that doesn't match any policy chain rules. | -| `tree_pool_netmap_support` | `bool` | no | `false` | Enable using new version of tree pool, which uses netmap to select nodes, for requests to tree service. | -| `remove_on_replace.enabled` | `bool` | yes | `false` | Enable removing old object during PUT operation in unversioned/suspened bucket. | -| `remove_on_replace.timeout` | `durations` | yes | `30s` | Timeout to one delete operation in background. | -| `remove_on_replace.queue` | `int` | false | `10000` | Buffer size for objects to delete. If buffer is full creation new unversioned object won't remove old one. Lifecycler will do that. | +| Parameter | Type | SIGHUP reload | Default value | Description | +|-----------------------------|--------------------------|---------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------| +| `md5.enabled` | `bool` | yes | `false` | Flag to enable return MD5 checksum in ETag headers and fields. | +| `policy.deny_by_default` | `bool` | yes | `false` | Enable denying access for request that doesn't match any policy chain rules. | +| `tree_pool_netmap_support` | `bool` | no | `false` | Enable using new version of tree pool, which uses netmap to select nodes, for requests to tree service. | +| `remove_on_replace.enabled` | `bool` | yes | `false` | Enable removing old object during PUT operation in unversioned/suspened bucket. | +| `remove_on_replace.timeout` | `durations` | yes | `30s` | Timeout to one delete operation in background. | +| `remove_on_replace.queue` | `int` | false | `10000` | Buffer size for objects to delete. If buffer is full creation new unversioned object won't remove old one. Lifecycler will do that. | +| `ape` | [[]APE](#ape-subsection) | | | Access Policy Engine configuration. | + +#### `ape` subsection + +```yaml +features: + ape: + converters_version: v1 +``` + +| Parameter | Type | SIGHUP reload | Default value | Description | +|----------------------|----------|---------------|---------------|-------------------------------------------------------------------------------------------------------------------------------------------| +| `converters_version` | `string` | yes | `v1` | Version of Access Policy converters (Supported versions: `v1`, `v2`). Need to transform AWS IAM policy to supported FrostFS chain format. | # `web` section @@ -979,8 +994,8 @@ encryption: ```yaml contracts: - container: - name: container.frostfs + container: + name: container.frostfs ``` | Parameter | Type | SIGHUP reload | Default value | Description | diff --git a/internal/logs/logs.go b/internal/logs/logs.go index ba6970dc5..761720ba5 100644 --- a/internal/logs/logs.go +++ b/internal/logs/logs.go @@ -98,6 +98,7 @@ const ( InitRPCClientFailed = "init rpc client failed" CouldNotFetchMFAContainerInfo = "couldn't fetch mfa container info" CouldNotInitMFAClient = "couldn't init MFA client" + InvalidAPEConvertersVersion = "invalid ape converters version, default will be used" ) // Datapath. diff --git a/pkg/policy-engine/common/converter.go b/pkg/policy-engine/common/converter.go new file mode 100644 index 000000000..c2ae5db88 --- /dev/null +++ b/pkg/policy-engine/common/converter.go @@ -0,0 +1,427 @@ +package common + +import ( + "errors" + "fmt" + "net/netip" + "strconv" + "strings" + "time" + "unicode/utf8" + + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" + "git.frostfs.info/TrueCloudLab/policy-engine/schema/common" + "github.com/nspcc-dev/neo-go/pkg/encoding/fixedn" +) + +const ( + S3ActionAbortMultipartUpload = "s3:AbortMultipartUpload" + S3ActionCreateBucket = "s3:CreateBucket" + S3ActionDeleteBucket = "s3:DeleteBucket" + S3ActionDeleteBucketPolicy = "s3:DeleteBucketPolicy" + S3ActionDeleteObject = "s3:DeleteObject" + S3ActionDeleteObjectTagging = "s3:DeleteObjectTagging" + S3ActionDeleteObjectVersion = "s3:DeleteObjectVersion" + S3ActionDeleteObjectVersionTagging = "s3:DeleteObjectVersionTagging" + S3ActionGetBucketACL = "s3:GetBucketAcl" + S3ActionGetBucketCORS = "s3:GetBucketCORS" + S3ActionGetBucketLocation = "s3:GetBucketLocation" + S3ActionGetBucketNotification = "s3:GetBucketNotification" + S3ActionGetBucketObjectLockConfiguration = "s3:GetBucketObjectLockConfiguration" + S3ActionGetBucketPolicy = "s3:GetBucketPolicy" + S3ActionGetBucketPolicyStatus = "s3:GetBucketPolicyStatus" + S3ActionGetBucketTagging = "s3:GetBucketTagging" + S3ActionGetBucketVersioning = "s3:GetBucketVersioning" + S3ActionGetLifecycleConfiguration = "s3:GetLifecycleConfiguration" + S3ActionGetObject = "s3:GetObject" + S3ActionGetObjectACL = "s3:GetObjectAcl" + S3ActionGetObjectAttributes = "s3:GetObjectAttributes" + S3ActionGetObjectLegalHold = "s3:GetObjectLegalHold" + S3ActionGetObjectRetention = "s3:GetObjectRetention" + S3ActionGetObjectTagging = "s3:GetObjectTagging" + S3ActionGetObjectVersion = "s3:GetObjectVersion" + S3ActionGetObjectVersionACL = "s3:GetObjectVersionAcl" + S3ActionGetObjectVersionAttributes = "s3:GetObjectVersionAttributes" + S3ActionGetObjectVersionTagging = "s3:GetObjectVersionTagging" + S3ActionListAllMyBuckets = "s3:ListAllMyBuckets" + S3ActionListBucket = "s3:ListBucket" + S3ActionListBucketMultipartUploads = "s3:ListBucketMultipartUploads" + S3ActionListBucketVersions = "s3:ListBucketVersions" + S3ActionListMultipartUploadParts = "s3:ListMultipartUploadParts" + S3ActionPutBucketACL = "s3:PutBucketAcl" + S3ActionPutBucketCORS = "s3:PutBucketCORS" + S3ActionPutBucketNotification = "s3:PutBucketNotification" + S3ActionPutBucketObjectLockConfiguration = "s3:PutBucketObjectLockConfiguration" + S3ActionPutBucketPolicy = "s3:PutBucketPolicy" + S3ActionPutBucketTagging = "s3:PutBucketTagging" + S3ActionPutBucketVersioning = "s3:PutBucketVersioning" + S3ActionPutLifecycleConfiguration = "s3:PutLifecycleConfiguration" + S3ActionPutObject = "s3:PutObject" + S3ActionPutObjectACL = "s3:PutObjectAcl" + S3ActionPutObjectLegalHold = "s3:PutObjectLegalHold" + S3ActionPutObjectRetention = "s3:PutObjectRetention" + S3ActionPutObjectTagging = "s3:PutObjectTagging" + S3ActionPutObjectVersionACL = "s3:PutObjectVersionAcl" + S3ActionPutObjectVersionTagging = "s3:PutObjectVersionTagging" + S3ActionPatchObject = "s3:PatchObject" + S3ActionPutBucketPublicAccessBlock = "s3:PutBucketPublicAccessBlock" + S3ActionGetBucketPublicAccessBlock = "s3:GetBucketPublicAccessBlock" +) + +const ( + CondKeyAWSPrincipalARN = "aws:PrincipalArn" + CondKeyAWSSourceIP = "aws:SourceIp" + CondKeyAWSPrincipalTagPrefix = "aws:PrincipalTag/" + CondKeyAWSRequestTagPrefix = "aws:RequestTag/" + CondKeyAWSResourceTagPrefix = "aws:ResourceTag/" + UserClaimTagPrefix = "tag-" +) + +const ( + // String condition operators. + CondStringEquals string = "StringEquals" + CondStringNotEquals string = "StringNotEquals" + CondStringEqualsIgnoreCase string = "StringEqualsIgnoreCase" + CondStringNotEqualsIgnoreCase string = "StringNotEqualsIgnoreCase" + CondStringLike string = "StringLike" + CondStringNotLike string = "StringNotLike" + + // Numeric condition operators. + CondNumericEquals string = "NumericEquals" + CondNumericNotEquals string = "NumericNotEquals" + CondNumericLessThan string = "NumericLessThan" + CondNumericLessThanEquals string = "NumericLessThanEquals" + CondNumericGreaterThan string = "NumericGreaterThan" + CondNumericGreaterThanEquals string = "NumericGreaterThanEquals" + + // Date condition operators. + CondDateEquals string = "DateEquals" + CondDateNotEquals string = "DateNotEquals" + CondDateLessThan string = "DateLessThan" + CondDateLessThanEquals string = "DateLessThanEquals" + CondDateGreaterThan string = "DateGreaterThan" + CondDateGreaterThanEquals string = "DateGreaterThanEquals" + + // Bolean condition operators. + CondBool string = "Bool" + + // IP address condition operators. + CondIPAddress string = "IpAddress" + CondNotIPAddress string = "NotIpAddress" + + // ARN condition operators. + CondArnEquals string = "ArnEquals" + CondArnLike string = "ArnLike" + CondArnNotEquals string = "ArnNotEquals" + CondArnNotLike string = "ArnNotLike" + + // Custom condition operators. + CondSliceContains string = "SliceContains" +) + +const ( + ArnIAMPrefix = "arn:aws:iam::" + S3ResourcePrefix = "arn:aws:s3:::" + S3ActionPrefix = "s3:" + IamActionPrefix = "iam:" +) + +var ( + // ErrInvalidPrincipalFormat occurs when principal has unknown/unsupported format. + ErrInvalidPrincipalFormat = errors.New("invalid principal format") + + // ErrInvalidResourceFormat occurs when resource has unknown/unsupported format. + ErrInvalidResourceFormat = errors.New("invalid resource format") + + // ErrInvalidActionFormat occurs when action has unknown/unsupported format. + ErrInvalidActionFormat = errors.New("invalid action format") + + // ErrActionsNotApplicable occurs when failed to convert any actions. + ErrActionsNotApplicable = errors.New("actions not applicable") +) + +type FormPrincipalConditionFunc func(string) chain.Condition + +type TransformConditionFunc func(gr GroupedConditions) (GroupedConditions, error) + +func ConvertToChainConditions(c Conditions, transformer TransformConditionFunc) ([]GroupedConditions, error) { + conditions, err := ConvertToChainCondition(c) + if err != nil { + return nil, err + } + + for i := range conditions { + if conditions[i], err = transformer(conditions[i]); err != nil { + return nil, fmt.Errorf("transform condition: %w", err) + } + } + + return conditions, nil +} + +type GroupedConditions struct { + Conditions []chain.Condition + Any bool +} + +func ConvertToChainCondition(c Conditions) ([]GroupedConditions, error) { + var grouped []GroupedConditions + + for op, KVs := range c { + condType, convertValue, err := getConditionTypeAndConverter(op) + if err != nil { + return nil, err + } + + for key, values := range KVs { + group := GroupedConditions{ + Conditions: make([]chain.Condition, len(values)), + Any: len(values) > 1, + } + + for i, val := range values { + converted, err := convertValue(val) + if err != nil { + return nil, err + } + + group.Conditions[i] = chain.Condition{ + Op: condType, + Kind: chain.KindRequest, + Key: transformKey(key), + Value: converted, + } + } + grouped = append(grouped, group) + } + } + + return grouped, nil +} + +func transformKey(key string) string { + tagName, isTag := strings.CutPrefix(key, CondKeyAWSPrincipalTagPrefix) + if isTag { + return fmt.Sprintf(common.PropertyKeyFormatFrostFSIDUserClaim, UserClaimTagPrefix+tagName) + } + + switch key { + case CondKeyAWSSourceIP: + return common.PropertyKeyFrostFSSourceIP + } + + return key +} + +func getConditionTypeAndConverter(op string) (chain.ConditionType, convertFunction, error) { + switch { + case strings.HasPrefix(op, "String"): + switch op { + case CondStringEquals: + return chain.CondStringEquals, noConvertFunction, nil + case CondStringNotEquals: + return chain.CondStringNotEquals, noConvertFunction, nil + case CondStringEqualsIgnoreCase: + return chain.CondStringEqualsIgnoreCase, noConvertFunction, nil + case CondStringNotEqualsIgnoreCase: + return chain.CondStringNotEqualsIgnoreCase, noConvertFunction, nil + case CondStringLike: + return chain.CondStringLike, noConvertFunction, nil + case CondStringNotLike: + return chain.CondStringNotLike, noConvertFunction, nil + default: + return 0, nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } + case strings.HasPrefix(op, "Arn"): + switch op { + case CondArnEquals: + return chain.CondStringEquals, noConvertFunction, nil + case CondArnNotEquals: + return chain.CondStringNotEquals, noConvertFunction, nil + case CondArnLike: + return chain.CondStringLike, noConvertFunction, nil + case CondArnNotLike: + return chain.CondStringNotLike, noConvertFunction, nil + default: + return 0, nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } + case strings.HasPrefix(op, "Numeric"): + return numericConditionTypeAndConverter(op) + case strings.HasPrefix(op, "Date"): + switch op { + case CondDateEquals: + return chain.CondStringEquals, dateConvertFunction, nil + case CondDateNotEquals: + return chain.CondStringNotEquals, dateConvertFunction, nil + case CondDateLessThan: + return chain.CondStringLessThan, dateConvertFunction, nil + case CondDateLessThanEquals: + return chain.CondStringLessThanEquals, dateConvertFunction, nil + case CondDateGreaterThan: + return chain.CondStringGreaterThan, dateConvertFunction, nil + case CondDateGreaterThanEquals: + return chain.CondStringGreaterThanEquals, dateConvertFunction, nil + default: + return 0, nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } + case op == CondBool: + return chain.CondStringEqualsIgnoreCase, noConvertFunction, nil + case op == CondIPAddress: + return chain.CondIPAddress, IPConvertFunction, nil + case op == CondNotIPAddress: + return chain.CondNotIPAddress, IPConvertFunction, nil + case op == CondSliceContains: + return chain.CondSliceContains, noConvertFunction, nil + default: + return 0, nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } +} + +func numericConditionTypeAndConverter(op string) (chain.ConditionType, convertFunction, error) { + switch op { + case CondNumericEquals: + return chain.CondNumericEquals, numericConvertFunction, nil + case CondNumericNotEquals: + return chain.CondNumericNotEquals, numericConvertFunction, nil + case CondNumericLessThan: + return chain.CondNumericLessThan, numericConvertFunction, nil + case CondNumericLessThanEquals: + return chain.CondNumericLessThanEquals, numericConvertFunction, nil + case CondNumericGreaterThan: + return chain.CondNumericGreaterThan, numericConvertFunction, nil + case CondNumericGreaterThanEquals: + return chain.CondNumericGreaterThanEquals, numericConvertFunction, nil + default: + return 0, nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } +} + +type convertFunction func(string) (string, error) + +func noConvertFunction(val string) (string, error) { + return val, nil +} + +func numericConvertFunction(val string) (string, error) { + if _, err := fixedn.Fixed8FromString(val); err == nil { + return val, nil + } + + return "", fmt.Errorf("invalid numeric value: '%s'", val) +} + +func IPConvertFunction(val string) (string, error) { + if _, err := netip.ParsePrefix(val); err != nil { + if _, err = netip.ParseAddr(val); err != nil { + return "", err + } + val += "/32" + } + + return val, nil +} + +func dateConvertFunction(val string) (string, error) { + if _, err := strconv.ParseInt(val, 10, 64); err == nil { + return val, nil + } + + parsed, err := time.Parse(time.RFC3339, val) + if err != nil { + return "", err + } + + return strconv.FormatInt(parsed.UTC().Unix(), 10), nil +} + +func ParsePrincipalAsIAMUser(principal string) (account string, user string, err error) { + if !strings.HasPrefix(principal, ArnIAMPrefix) { + return "", "", ErrInvalidPrincipalFormat + } + + // iam arn format arn:aws:iam:::user/ + iamResource := strings.TrimPrefix(principal, ArnIAMPrefix) + sepIndex := strings.Index(iamResource, ":user/") + if sepIndex < 0 { + return "", "", ErrInvalidPrincipalFormat + } + + account = iamResource[:sepIndex] + user = iamResource[sepIndex+6:] + if len(user) == 0 { + return "", "", ErrInvalidPrincipalFormat + } + + userNameIndex := strings.LastIndexByte(user, '/') + if userNameIndex > -1 { + user = user[userNameIndex+1:] + if len(user) == 0 { + return "", "", ErrInvalidPrincipalFormat + } + } + + return account, user, nil +} + +func ValidateResource(resource string) error { + if resource == Wildcard { + return nil + } + + if !strings.HasPrefix(resource, S3ResourcePrefix) && !strings.HasPrefix(resource, ArnIAMPrefix) { + return ErrInvalidResourceFormat + } + + index := strings.IndexByte(resource, Wildcard[0]) + if index != -1 && index != utf8.RuneCountInString(resource)-1 { + return ErrInvalidResourceFormat + } + + return nil +} + +func ValidateAction(action string) (bool, error) { + isIAM := strings.HasPrefix(action, IamActionPrefix) + if !strings.HasPrefix(action, S3ActionPrefix) && !isIAM { + return false, ErrInvalidActionFormat + } + + index := strings.IndexByte(action, Wildcard[0]) + if index != -1 && index != utf8.RuneCountInString(action)-1 { + return false, ErrInvalidActionFormat + } + + return isIAM, nil +} + +func SplitGroupedConditions(groupedConditions []GroupedConditions) [][]chain.Condition { + var orConditions []chain.Condition + commonConditions := make([]chain.Condition, 0, len(groupedConditions)) + for _, grouped := range groupedConditions { + if grouped.Any { + orConditions = append(orConditions, grouped.Conditions...) + } else { + commonConditions = append(commonConditions, grouped.Conditions...) + } + } + + if len(orConditions) == 0 { + return [][]chain.Condition{commonConditions} + } + + res := make([][]chain.Condition, len(orConditions)) + for i, condition := range orConditions { + res[i] = append([]chain.Condition{condition}, commonConditions...) + } + + return res +} + +func FormStatus(statement Statement) chain.Status { + status := chain.AccessDenied + if statement.Effect == AllowEffect { + status = chain.Allow + } + + return status +} diff --git a/pkg/policy-engine/common/policy.go b/pkg/policy-engine/common/policy.go new file mode 100644 index 000000000..e33998fc5 --- /dev/null +++ b/pkg/policy-engine/common/policy.go @@ -0,0 +1,338 @@ +package common + +import ( + "encoding/json" + "errors" + "fmt" +) + +type ( + // Policy grammar https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_grammar.html + Policy struct { + Version string `json:"Version,omitempty"` + ID string `json:"Id,omitempty"` + Statement Statements `json:"Statement"` + } + + Statements []Statement + + Statement struct { + ID string `json:"Id,omitempty"` + SID string `json:"Sid,omitempty"` + Principal Principal `json:"Principal,omitempty"` + NotPrincipal Principal `json:"NotPrincipal,omitempty"` + Effect Effect `json:"Effect"` + Action Action `json:"Action,omitempty"` + NotAction Action `json:"NotAction,omitempty"` + Resource Resource `json:"Resource,omitempty"` + NotResource Resource `json:"NotResource,omitempty"` + Conditions Conditions `json:"Condition,omitempty"` + } + + Principal map[PrincipalType][]string + + Effect string + + Action []string + + Resource []string + + Conditions map[string]Condition + + Condition map[string][]string + + PolicyType int + + PrincipalType string +) + +const policyVersion = "2012-10-17" + +const ( + GeneralPolicyType PolicyType = iota + IdentityBasedPolicyType + ResourceBasedPolicyType +) + +const Wildcard = "*" + +const ( + AllowEffect Effect = "Allow" + DenyEffect Effect = "Deny" +) + +func (e Effect) IsValid() bool { + return e == AllowEffect || e == DenyEffect +} + +const ( + AWSPrincipalType PrincipalType = "AWS" + FederatedPrincipalType PrincipalType = "Federated" + ServicePrincipalType PrincipalType = "Service" + CanonicalUserPrincipalType PrincipalType = "CanonicalUser" +) + +func (p PrincipalType) IsValid() bool { + return p == AWSPrincipalType || p == FederatedPrincipalType || + p == ServicePrincipalType || p == CanonicalUserPrincipalType +} + +func (s *Statements) UnmarshalJSON(data []byte) error { + var list []Statement + if err := json.Unmarshal(data, &list); err == nil { + *s = list + return nil + } + + var elem Statement + if err := json.Unmarshal(data, &elem); err != nil { + return err + } + + *s = []Statement{elem} + + return nil +} + +func (p *Principal) UnmarshalJSON(data []byte) error { + *p = make(Principal) + + var str string + + if err := json.Unmarshal(data, &str); err == nil { + if str != Wildcard { + return errors.New("invalid IAM string principal") + } + (*p)[Wildcard] = nil + return nil + } + + m := make(map[PrincipalType]any) + if err := json.Unmarshal(data, &m); err != nil { + return err + } + + val, ok := m[AWSPrincipalType] + if ok { + str, ok = val.(string) + if ok && str == Wildcard && len(m) == 1 { + (*p)[Wildcard] = nil + return nil + } + } + + for key, val := range m { + element, ok := val.(string) + if ok { + (*p)[key] = []string{element} + continue + } + + list, ok := val.([]any) + if !ok { + return errors.New("invalid principal format") + } + + resList := make([]string, len(list)) + for i := range list { + val, ok := list[i].(string) + if !ok { + return errors.New("invalid principal format") + } + resList[i] = val + } + + (*p)[key] = resList + } + + return nil +} + +func (a *Action) UnmarshalJSON(data []byte) error { + var list []string + if err := json.Unmarshal(data, &list); err == nil { + *a = list + return nil + } + + var elem string + if err := json.Unmarshal(data, &elem); err != nil { + return err + } + + *a = []string{elem} + + return nil +} + +func (r *Resource) UnmarshalJSON(data []byte) error { + var list []string + if err := json.Unmarshal(data, &list); err == nil { + *r = list + return nil + } + + var elem string + if err := json.Unmarshal(data, &elem); err != nil { + return err + } + + *r = []string{elem} + + return nil +} + +func (c *Condition) UnmarshalJSON(data []byte) error { + *c = make(Condition) + + m := make(map[string]any) + if err := json.Unmarshal(data, &m); err != nil { + return err + } + + for key, val := range m { + element, ok := val.(string) + if ok { + (*c)[key] = []string{element} + continue + } + + list, ok := val.([]any) + if !ok { + return errors.New("invalid principal format") + } + + resList := make([]string, len(list)) + for i := range list { + val, ok := list[i].(string) + if !ok { + return errors.New("invalid principal format") + } + resList[i] = val + } + + (*c)[key] = resList + } + + return nil +} + +func (p Policy) Validate(typ PolicyType) error { + if err := p.validate(); err != nil { + return err + } + + switch typ { + case IdentityBasedPolicyType: + return p.validateIdentityBased() + case ResourceBasedPolicyType: + return p.validateResourceBased() + default: + return nil + } +} + +func (p Policy) validate() error { + if p.Version != policyVersion { + return fmt.Errorf("invalid policy version, expected '%s', actual: '%s'", policyVersion, p.Version) + } + + if len(p.Statement) == 0 { + return errors.New("'Statement' is missing") + } + + sids := make(map[string]struct{}, len(p.Statement)) + for _, statement := range p.Statement { + if _, ok := sids[statement.SID]; ok && statement.SID != "" { + return fmt.Errorf("duplicate 'SID': %s", statement.SID) + } + sids[statement.SID] = struct{}{} + if !statement.Effect.IsValid() { + return fmt.Errorf("unknown effect: '%s'", statement.Effect) + } + if len(statement.Action) != 0 && len(statement.NotAction) != 0 { + return errors.New("'Actions' and 'NotAction' are mutually exclusive") + } + if statement.Resource != nil && statement.NotResource != nil { + return errors.New("'Resources' and 'NotResource' are mutually exclusive") + } + if len(statement.Resource) == 0 && len(statement.NotResource) == 0 { + return errors.New("one of 'Resources'/'NotResource' must be provided") + } + if len(statement.Principal) != 0 && len(statement.NotPrincipal) != 0 { + return errors.New("'Principal' and 'NotPrincipal' are mutually exclusive") + } + if len(statement.NotPrincipal) != 0 && statement.Effect != DenyEffect { + return errors.New("using 'NotPrincipal' with effect 'Allow' is not supported") + } + + principal, _ := statement.GetPrincipal() + if err := principal.validate(); err != nil { + return err + } + } + + return nil +} + +func (p Policy) validateIdentityBased() error { + if len(p.ID) != 0 { + return errors.New("'Id' is not allowed for identity-based policy") + } + + for _, statement := range p.Statement { + if len(statement.Principal) != 0 || len(statement.NotPrincipal) != 0 { + return errors.New("'Principal' and 'NotPrincipal' are not allowed for identity-based policy") + } + } + + return nil +} + +func (p Policy) validateResourceBased() error { + for _, statement := range p.Statement { + if len(statement.Principal) == 0 && len(statement.NotPrincipal) == 0 { + return errors.New("'Principal' or 'NotPrincipal' must be provided for resource-based policy") + } + } + + return nil +} + +func (s Statement) GetPrincipal() (Principal, bool) { + if len(s.NotPrincipal) != 0 { + return s.NotPrincipal, true + } + + return s.Principal, false +} + +func (s Statement) GetAction() (Action, bool) { + if len(s.NotAction) != 0 { + return s.NotAction, true + } + + return s.Action, false +} + +func (s Statement) GetResource() (Resource, bool) { + if len(s.NotResource) != 0 { + return s.NotResource, true + } + + return s.Resource, false +} + +func (p Principal) validate() error { + if _, ok := p[Wildcard]; ok && len(p) == 1 { + return nil + } + + for key := range p { + if !key.IsValid() { + return fmt.Errorf("unknown principal type: '%s'", key) + } + } + + return nil +} diff --git a/pkg/policy-engine/common/policy_test.go b/pkg/policy-engine/common/policy_test.go new file mode 100644 index 000000000..ab877ae87 --- /dev/null +++ b/pkg/policy-engine/common/policy_test.go @@ -0,0 +1,479 @@ +package common + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnmarshalIAMPolicy(t *testing.T) { + t.Run("simple fields", func(t *testing.T) { + policy := `{ + "Version": "2012-10-17", + "Id": "PutObjPolicy", + "Statement": { + "Sid": "DenyObjectsThatAreNotSSEKMS", + "Principal": "*", + "Effect": "Deny", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::DOC-EXAMPLE-BUCKET/*", + "Condition": { + "Null": { + "s3:x-amz-server-side-encryption-aws-kms-key-id": "true" + } + } + } +}` + + expected := Policy{ + Version: "2012-10-17", + ID: "PutObjPolicy", + Statement: []Statement{{ + SID: "DenyObjectsThatAreNotSSEKMS", + Principal: map[PrincipalType][]string{ + "*": nil, + }, + Effect: DenyEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Conditions: map[string]Condition{ + "Null": { + "s3:x-amz-server-side-encryption-aws-kms-key-id": {"true"}, + }, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + }) + + t.Run("complex fields", func(t *testing.T) { + policy := `{ + "Version": "2012-10-17", + "Statement": [{ + "Principal":{ + "AWS":[ + "arn:aws:iam::111122223333:user/JohnDoe" + ] + }, + "Effect": "Allow", + "Action": [ + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::DOC-EXAMPLE-BUCKET/*" + ], + "Condition": { + "StringEquals": { + "s3:RequestObjectTag/Department": ["Finance"] + } + } + }] +}` + + expected := Policy{ + Version: "2012-10-17", + Statement: []Statement{{ + Principal: map[PrincipalType][]string{ + AWSPrincipalType: {"arn:aws:iam::111122223333:user/JohnDoe"}, + }, + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Conditions: map[string]Condition{ + "StringEquals": { + "s3:RequestObjectTag/Department": {"Finance"}, + }, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + + raw, err := json.Marshal(expected) + require.NoError(t, err) + require.JSONEq(t, policy, string(raw)) + }) + + t.Run("check principal AWS", func(t *testing.T) { + policy := `{ + "Statement": [{ + "Principal":{ + "AWS":"arn:aws:iam::111122223333:user/JohnDoe" + } + }] +}` + + expected := Policy{ + Statement: []Statement{{ + Principal: map[PrincipalType][]string{ + AWSPrincipalType: {"arn:aws:iam::111122223333:user/JohnDoe"}, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + }) + + t.Run("native example", func(t *testing.T) { + policy := ` +{ + "Version": "xyz", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "native:*", + "s3:PutObject", + "s3:GetObject" + ], + "Resource": ["*"], + "Principal": {"FrostFS": ["did:frostfs:039e3ee771a223361fe7862f532e9511b57baaae3c3e2622682e99d0e660f7671"]}, + "Condition": {"StringEquals": {"native::object::attribute": "iamuser-admin"}} + } + ] +}` + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + }) + + t.Run("condition array", func(t *testing.T) { + policy := ` +{ + "Statement": [{ + "Condition": {"StringLike": {"ec2:InstanceType": ["t1.*", "t2.*", "m3.*"]}} + }] +}` + + expected := Policy{ + Statement: []Statement{{ + Conditions: map[string]Condition{ + "StringLike": {"ec2:InstanceType": {"t1.*", "t2.*", "m3.*"}}, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + }) + + t.Run("'Not*' fields", func(t *testing.T) { + policy := ` +{ + "Id": "PutObjPolicy", + "Statement": [{ + "NotPrincipal": {"AWS":["arn:aws:iam::111122223333:user/Alice"]}, + "Effect": "Deny", + "NotAction": "s3:PutObject", + "NotResource": "arn:aws:s3:::DOC-EXAMPLE-BUCKET/*" + }] +}` + + expected := Policy{ + ID: "PutObjPolicy", + Statement: []Statement{{ + NotPrincipal: map[PrincipalType][]string{ + AWSPrincipalType: {"arn:aws:iam::111122223333:user/Alice"}, + }, + Effect: DenyEffect, + NotAction: []string{"s3:PutObject"}, + NotResource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + }) +} + +func TestValidatePolicies(t *testing.T) { + for _, tc := range []struct { + name string + policy Policy + typ PolicyType + isValid bool + }{ + { + name: "valid permission boundaries", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:*", "cloudwatch:*", "ec2:*"}, + Resource: []string{Wildcard}, + }}, + }, + typ: GeneralPolicyType, + isValid: true, + }, + { + name: "general invalid effect", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: "dummy", + Action: []string{"s3:*", "cloudwatch:*", "ec2:*"}, + Resource: []string{Wildcard}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "general invalid principal block", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:*", "cloudwatch:*", "ec2:*"}, + Resource: []string{Wildcard}, + Principal: map[PrincipalType][]string{Wildcard: nil}, + NotPrincipal: map[PrincipalType][]string{Wildcard: nil}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "general invalid not principal", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:*", "cloudwatch:*", "ec2:*"}, + Resource: []string{Wildcard}, + NotPrincipal: map[PrincipalType][]string{AWSPrincipalType: {"arn:aws:iam::111122223333:user/Alice"}}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "general invalid principal type", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:*", "cloudwatch:*", "ec2:*"}, + Resource: []string{Wildcard}, + NotPrincipal: map[PrincipalType][]string{"dummy": {"arn:aws:iam::111122223333:user/Alice"}}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "general invalid action block", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:*", "cloudwatch:*", "ec2:*"}, + NotAction: []string{"iam:*"}, + Resource: []string{Wildcard}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "general invalid resource block", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Resource: []string{Wildcard}, + NotResource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "invalid resource block", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Resource: []string{}, + NotResource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "missing resource block", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "missing statement block", + policy: Policy{}, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "duplicate sid", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{ + { + SID: "sid", + Effect: AllowEffect, + Action: []string{"s3:*"}, + Resource: []string{Wildcard}, + }, + { + SID: "sid", + Effect: AllowEffect, + Action: []string{"cloudwatch:*"}, + Resource: []string{Wildcard}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "missing version", + policy: Policy{ + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:*"}, + Resource: []string{Wildcard}, + }}, + }, + typ: GeneralPolicyType, + isValid: false, + }, + { + name: "identity based valid", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{Wildcard}, + }}, + }, + typ: IdentityBasedPolicyType, + isValid: true, + }, + { + name: "identity based invalid because of id presence", + policy: Policy{ + ID: "some-id", + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{Wildcard}, + }}, + }, + typ: IdentityBasedPolicyType, + isValid: false, + }, + { + name: "identity based invalid because of principal presence", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{Wildcard}, + Principal: map[PrincipalType][]string{AWSPrincipalType: {"arn:aws:iam::111122223333:user/Alice"}}, + }}, + }, + typ: IdentityBasedPolicyType, + isValid: false, + }, + { + name: "identity based invalid because of not principal presence", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{Wildcard}, + NotPrincipal: map[PrincipalType][]string{AWSPrincipalType: {"arn:aws:iam::111122223333:user/Alice"}}, + }}, + }, + typ: IdentityBasedPolicyType, + isValid: false, + }, + { + name: "resource based valid principal", + policy: Policy{ + Version: policyVersion, + Statement: []Statement{{ + Effect: DenyEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{Wildcard}, + Principal: map[PrincipalType][]string{AWSPrincipalType: {"arn:aws:iam::111122223333:user/Alice"}}, + }}, + }, + typ: ResourceBasedPolicyType, + isValid: true, + }, + { + name: "resource based valid not principal", + policy: Policy{ + ID: "some-id", + Version: policyVersion, + Statement: []Statement{{ + Effect: DenyEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{Wildcard}, + NotPrincipal: map[PrincipalType][]string{AWSPrincipalType: {"arn:aws:iam::111122223333:user/Alice"}}, + }}, + }, + typ: ResourceBasedPolicyType, + isValid: true, + }, + { + name: "resource based invalid missing principal", + policy: Policy{ + ID: "some-id", + Version: policyVersion, + Statement: []Statement{{ + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{Wildcard}, + }}, + }, + typ: ResourceBasedPolicyType, + isValid: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + err := tc.policy.Validate(tc.typ) + if tc.isValid { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} diff --git a/pkg/policy-engine/common/resolver.go b/pkg/policy-engine/common/resolver.go new file mode 100644 index 000000000..357eadf08 --- /dev/null +++ b/pkg/policy-engine/common/resolver.go @@ -0,0 +1,15 @@ +package common + +type NativeResolver interface { + GetUserKey(account, name string) (string, error) + GetBucketInfo(bucket string) (*BucketInfo, error) +} + +type BucketInfo struct { + Namespace string + Container string +} + +type S3Resolver interface { + GetUserAddress(account, user string) (string, error) +} diff --git a/pkg/policy-engine/converter.go b/pkg/policy-engine/converter.go new file mode 100644 index 000000000..688611c69 --- /dev/null +++ b/pkg/policy-engine/converter.go @@ -0,0 +1,118 @@ +package policyengine + +import ( + "fmt" + + s3common "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine/common" + v2 "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine/v2/iam" + v1 "git.frostfs.info/TrueCloudLab/policy-engine/iam" + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" +) + +type Converter struct { + versionFetcher VersionFetcher +} + +type Config struct { + VersionFetcher VersionFetcher +} + +type VersionFetcher interface { + ConverterVersion() ConverterVersion +} + +type ConverterVersion string + +const ( + V1 ConverterVersion = "v1" + V2 ConverterVersion = "v2" +) + +func NewConverter(cfg Config) *Converter { + return &Converter{ + versionFetcher: cfg.VersionFetcher, + } +} + +func (c *Converter) ToS3Chain(p s3common.Policy, resolver s3common.S3Resolver) (*chain.Chain, error) { + switch v := c.versionFetcher.ConverterVersion(); v { + case V1: + return v1.ConvertToS3Chain(commonPolicyToV1(p), resolver) + case V2: + return v2.ConvertToS3Chain(p, resolver) + default: + return nil, fmt.Errorf("unknown converter version: %s", v) + } +} + +func (c *Converter) ToNativeChain(p s3common.Policy, resolver s3common.NativeResolver) (*chain.Chain, error) { + switch v := c.versionFetcher.ConverterVersion(); v { + case V1: + return v1.ConvertToNativeChain(commonPolicyToV1(p), nativeResolverV1Wrapper{resolver: resolver}) + case V2: + return v2.ConvertToNativeChain(p, resolver) + default: + return nil, fmt.Errorf("unknown converter version: %s", v) + } +} + +func commonPolicyToV1(p s3common.Policy) v1.Policy { + res := v1.Policy{ + Version: p.Version, + ID: p.ID, + Statement: make(v1.Statements, len(p.Statement)), + } + + for i, statement := range p.Statement { + res.Statement[i] = v1.Statement{ + ID: statement.ID, + SID: statement.SID, + Principal: make(map[v1.PrincipalType][]string, len(statement.Principal)), + NotPrincipal: make(map[v1.PrincipalType][]string, len(statement.NotPrincipal)), + Effect: v1.Effect(statement.Effect), + Action: v1.Action(statement.Action), + NotAction: v1.Action(statement.NotAction), + Resource: v1.Resource(statement.Resource), + NotResource: v1.Resource(statement.NotResource), + Conditions: make(map[string]v1.Condition, len(statement.Conditions)), + } + + for k, principal := range statement.Principal { + res.Statement[i].Principal[v1.PrincipalType(k)] = principal + } + for k, principal := range statement.NotPrincipal { + res.Statement[i].NotPrincipal[v1.PrincipalType(k)] = principal + } + + for k, condition := range statement.Conditions { + res.Statement[i].Conditions[k] = v1.Condition(condition) + } + } + + return res +} + +type nativeResolverV1Wrapper struct { + resolver s3common.NativeResolver +} + +func (w nativeResolverV1Wrapper) GetUserKey(account, name string) (string, error) { + res, err := w.resolver.GetUserKey(account, name) + if err != nil { + return "", err + } + + return res, nil +} + +func (w nativeResolverV1Wrapper) GetBucketInfo(bucket string) (*v1.BucketInfo, error) { + res, err := w.resolver.GetBucketInfo(bucket) + if err != nil { + return nil, err + } + + return &v1.BucketInfo{ + Namespace: res.Namespace, + Container: res.Container, + }, nil +} diff --git a/pkg/policy-engine/v2/iam/converter_native.go b/pkg/policy-engine/v2/iam/converter_native.go new file mode 100644 index 000000000..f286e0e1e --- /dev/null +++ b/pkg/policy-engine/v2/iam/converter_native.go @@ -0,0 +1,420 @@ +package iam + +import ( + "errors" + "fmt" + "strings" + + s3common "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine/common" + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" + "git.frostfs.info/TrueCloudLab/policy-engine/schema/native" +) + +const PropertyKeyFilePath = "FilePath" + +var actionToNativeOpMap = map[string][]string{ + s3common.S3ActionAbortMultipartUpload: {native.MethodGetContainer, native.MethodDeleteObject, native.MethodHeadObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionCreateBucket: {native.MethodGetContainer, native.MethodPutContainer, native.MethodSetContainerEACL, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionDeleteBucket: {native.MethodGetContainer, native.MethodDeleteContainer, native.MethodSearchObject, native.MethodHeadObject, native.MethodGetObject}, + s3common.S3ActionDeleteBucketPolicy: {native.MethodGetContainer}, + s3common.S3ActionDeleteObject: {native.MethodGetContainer, native.MethodDeleteObject, native.MethodPutObject, native.MethodHeadObject, native.MethodGetObject, native.MethodRangeObject}, + s3common.S3ActionDeleteObjectTagging: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionDeleteObjectVersion: {native.MethodGetContainer, native.MethodDeleteObject, native.MethodPutObject, native.MethodHeadObject, native.MethodGetObject, native.MethodRangeObject}, + s3common.S3ActionDeleteObjectVersionTagging: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionGetBucketACL: {native.MethodGetContainer, native.MethodGetContainerEACL, native.MethodGetObject}, + s3common.S3ActionGetBucketCORS: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionGetBucketLocation: {native.MethodGetContainer}, + s3common.S3ActionGetBucketNotification: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionGetBucketObjectLockConfiguration: {native.MethodGetContainer, native.MethodGetObject}, + s3common.S3ActionGetBucketPolicy: {native.MethodGetContainer}, + s3common.S3ActionGetBucketPolicyStatus: {native.MethodGetContainer}, + s3common.S3ActionGetBucketTagging: {native.MethodGetContainer, native.MethodGetObject}, + s3common.S3ActionGetBucketVersioning: {native.MethodGetContainer, native.MethodGetObject}, + s3common.S3ActionGetLifecycleConfiguration: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionGetObject: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject, native.MethodSearchObject, native.MethodRangeObject, native.MethodHashObject}, + s3common.S3ActionGetObjectACL: {native.MethodGetContainer, native.MethodGetContainerEACL, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionGetObjectAttributes: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionGetObjectLegalHold: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject}, + s3common.S3ActionGetObjectRetention: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject}, + s3common.S3ActionGetObjectTagging: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject}, + s3common.S3ActionGetObjectVersion: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject, native.MethodSearchObject, native.MethodRangeObject, native.MethodHashObject}, + s3common.S3ActionGetObjectVersionACL: {native.MethodGetContainer, native.MethodGetContainerEACL, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionGetObjectVersionAttributes: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionGetObjectVersionTagging: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject}, + s3common.S3ActionListAllMyBuckets: {native.MethodListContainers, native.MethodGetContainer}, + s3common.S3ActionListBucket: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject, native.MethodSearchObject, native.MethodRangeObject, native.MethodHashObject}, + s3common.S3ActionListBucketMultipartUploads: {native.MethodGetContainer, native.MethodGetObject}, + s3common.S3ActionListBucketVersions: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject, native.MethodSearchObject, native.MethodRangeObject, native.MethodHashObject}, + s3common.S3ActionListMultipartUploadParts: {native.MethodGetContainer, native.MethodGetObject}, + s3common.S3ActionPutBucketACL: {native.MethodGetContainer, native.MethodSetContainerEACL, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutBucketCORS: {native.MethodGetContainer, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutBucketNotification: {native.MethodGetContainer, native.MethodHeadObject, native.MethodDeleteObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutBucketObjectLockConfiguration: {native.MethodGetContainer, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutBucketPolicy: {native.MethodGetContainer}, + s3common.S3ActionPutBucketTagging: {native.MethodGetContainer, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutBucketVersioning: {native.MethodGetContainer, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutLifecycleConfiguration: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject, native.MethodPutObject, native.MethodDeleteObject}, + s3common.S3ActionPutObject: {native.MethodGetContainer, native.MethodPutObject, native.MethodGetObject, native.MethodHeadObject, native.MethodRangeObject}, + s3common.S3ActionPutObjectACL: {native.MethodGetContainer, native.MethodGetContainerEACL, native.MethodSetContainerEACL, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionPutObjectLegalHold: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutObjectRetention: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutObjectTagging: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPutObjectVersionACL: {native.MethodGetContainer, native.MethodGetContainerEACL, native.MethodSetContainerEACL, native.MethodGetObject, native.MethodHeadObject}, + s3common.S3ActionPutObjectVersionTagging: {native.MethodGetContainer, native.MethodHeadObject, native.MethodGetObject, native.MethodPutObject}, + s3common.S3ActionPatchObject: {native.MethodGetContainer, native.MethodGetObject, native.MethodHeadObject, native.MethodPatchObject, native.MethodPutObject, native.MethodRangeObject}, + s3common.S3ActionPutBucketPublicAccessBlock: {native.MethodGetContainer, native.MethodPutObject, native.MethodDeleteObject, native.MethodGetObject}, + s3common.S3ActionGetBucketPublicAccessBlock: {native.MethodGetContainer, native.MethodGetObject}, +} + +var containerNativeOperations = map[string]struct{}{ + native.MethodPutContainer: {}, + native.MethodDeleteContainer: {}, + native.MethodGetContainer: {}, + native.MethodListContainers: {}, + native.MethodSetContainerEACL: {}, + native.MethodGetContainerEACL: {}, +} + +var objectNativeOperations = map[string]struct{}{ + native.MethodGetObject: {}, + native.MethodPutObject: {}, + native.MethodHeadObject: {}, + native.MethodDeleteObject: {}, + native.MethodSearchObject: {}, + native.MethodRangeObject: {}, + native.MethodHashObject: {}, +} + +var errConditionKeyNotApplicable = errors.New("condition key is not applicable") + +func ConvertToNativeChain(p s3common.Policy, resolver s3common.NativeResolver) (*chain.Chain, error) { + if err := p.Validate(s3common.ResourceBasedPolicyType); err != nil { + return nil, err + } + + var engineChain chain.Chain + + for _, statement := range p.Statement { + status := s3common.FormStatus(statement) + if status != chain.Allow { + // Most s3 methods share the same native operations. Deny rules must not affect shared native operations, + // therefore this code skips all deny rules for native protocol. Deny is applied for s3 protocol only, in this case. + continue + } + + action, actionInverted := statement.GetAction() + nativeActions, err := formNativeActionNames(action) + if err != nil { + return nil, err + } + ruleAction := chain.Actions{Inverted: actionInverted, Names: nativeActions} + if len(ruleAction.Names) == 0 { + continue + } + + resource, resourceInverted := statement.GetResource() + groupedResources, err := formNativeResourceNamesAndConditions(resource, resolver, getActionTypes(nativeActions)) + if err != nil { + return nil, err + } + + groupedConditions, err := convertToNativeChainCondition(statement.Conditions, resolver) + if err != nil { + if errors.Is(err, errConditionKeyNotApplicable) { + continue + } + return nil, err + } + splitConditions := s3common.SplitGroupedConditions(groupedConditions) + + principals, principalCondFn, err := getNativePrincipalsAndConditionFunc(statement, resolver) + if err != nil { + return nil, err + } + + for _, groupedResource := range groupedResources { + for _, principal := range principals { + for _, conditions := range splitConditions { + var principalCondition []chain.Condition + if principal != s3common.Wildcard { + principalCondition = []chain.Condition{principalCondFn(principal)} + } + + ruleConditions := append(principalCondition, groupedResource.Conditions...) + + r := chain.Rule{ + Status: status, + Actions: ruleAction, + Resources: chain.Resources{ + Inverted: resourceInverted, + Names: groupedResource.Names, + }, + Condition: append(ruleConditions, conditions...), + } + engineChain.Rules = append(engineChain.Rules, r) + } + } + } + } + + if len(engineChain.Rules) == 0 { + return nil, s3common.ErrActionsNotApplicable + } + + return &engineChain, nil +} + +func getActionTypes(nativeActions []string) ActionTypes { + var res ActionTypes + for _, action := range nativeActions { + if res.Object && res.Container { + break + } + + _, isObj := objectNativeOperations[action] + _, isCnr := containerNativeOperations[action] + + res.Object = res.Object || isObj || action == s3common.Wildcard + res.Container = res.Container || isCnr || action == s3common.Wildcard + } + + return res +} + +func getNativePrincipalsAndConditionFunc(statement s3common.Statement, resolver s3common.NativeResolver) ([]string, s3common.FormPrincipalConditionFunc, error) { + var principals []string + var op chain.ConditionType + statementPrincipal, inverted := statement.GetPrincipal() + if _, ok := statementPrincipal[s3common.Wildcard]; ok { // this can be true only if 'inverted' false + principals = []string{s3common.Wildcard} + op = chain.CondStringLike + } else { + for principalType, principal := range statementPrincipal { + if principalType != s3common.AWSPrincipalType { + return nil, nil, fmt.Errorf("unsupported principal type '%s'", principalType) + } + parsedPrincipal, err := formNativePrincipal(principal, resolver) + if err != nil { + return nil, nil, fmt.Errorf("parse principal: %w", err) + } + principals = append(principals, parsedPrincipal...) + } + + op = chain.CondStringEquals + if inverted { + op = chain.CondStringNotEquals + } + } + + return principals, func(principal string) chain.Condition { + return chain.Condition{ + Op: op, + Kind: chain.KindRequest, + Key: native.PropertyKeyActorPublicKey, + Value: principal, + } + }, nil +} + +func convertToNativeChainCondition(c s3common.Conditions, resolver s3common.NativeResolver) ([]s3common.GroupedConditions, error) { + return s3common.ConvertToChainConditions(c, func(gr s3common.GroupedConditions) (s3common.GroupedConditions, error) { + res := s3common.GroupedConditions{ + Conditions: make([]chain.Condition, 0, len(gr.Conditions)), + Any: gr.Any, + } + + for i := range gr.Conditions { + switch { + case gr.Conditions[i].Key == condKeyAWSMFAPresent: + return s3common.GroupedConditions{}, errConditionKeyNotApplicable + case gr.Conditions[i].Key == s3common.CondKeyAWSPrincipalARN: + gr.Conditions[i].Key = native.PropertyKeyActorPublicKey + val, err := formPrincipalKey(gr.Conditions[i].Value, resolver) + if err != nil { + return s3common.GroupedConditions{}, err + } + gr.Conditions[i].Value = val + res.Conditions = append(res.Conditions, gr.Conditions[i]) + case strings.HasPrefix(gr.Conditions[i].Key, s3common.CondKeyAWSRequestTagPrefix) || + strings.HasPrefix(gr.Conditions[i].Key, s3common.CondKeyAWSResourceTagPrefix): + // Tags exist only in S3 requests, so native protocol should not process such conditions. + continue + default: + res.Conditions = append(res.Conditions, gr.Conditions[i]) + } + } + + return res, nil + }) +} + +type GroupedResources struct { + Names []string + Conditions []chain.Condition +} + +type ActionTypes struct { + Object bool + Container bool +} + +func formNativeResourceNamesAndConditions(names []string, resolver s3common.NativeResolver, actionTypes ActionTypes) ([]GroupedResources, error) { + if !actionTypes.Object && !actionTypes.Container { + return nil, s3common.ErrActionsNotApplicable + } + + res := make([]GroupedResources, 0, len(names)) + + combined := make(map[string]struct{}) + + for _, resource := range names { + if err := s3common.ValidateResource(resource); err != nil { + return nil, err + } + + if resource == s3common.Wildcard { + res = res[:0] + return append(res, formWildcardNativeResource(actionTypes)), nil + } + + if !strings.HasPrefix(resource, s3common.S3ResourcePrefix) { + continue + } + + var bkt, obj string + s3Resource := strings.TrimPrefix(resource, s3common.S3ResourcePrefix) + if s3Resource == s3common.Wildcard { + res = res[:0] + return append(res, formWildcardNativeResource(actionTypes)), nil + } + + if sepIndex := strings.Index(s3Resource, "/"); sepIndex < 0 { + bkt = s3Resource + } else { + bkt = s3Resource[:sepIndex] + obj = s3Resource[sepIndex+1:] + if len(obj) == 0 { + obj = s3common.Wildcard + } + } + + bktInfo, err := resolver.GetBucketInfo(bkt) + if err != nil { + return nil, err + } + + if obj == s3common.Wildcard && actionTypes.Object { // this corresponds to arn:aws:s3:::BUCKET/ or arn:aws:s3:::BUCKET/* + combined[fmt.Sprintf(native.ResourceFormatNamespaceContainerObjects, bktInfo.Namespace, bktInfo.Container)] = struct{}{} + combined[fmt.Sprintf(native.ResourceFormatNamespaceContainer, bktInfo.Namespace, bktInfo.Container)] = struct{}{} + continue + } + if obj == "" && actionTypes.Container { // this corresponds to arn:aws:s3:::BUCKET + combined[fmt.Sprintf(native.ResourceFormatNamespaceContainer, bktInfo.Namespace, bktInfo.Container)] = struct{}{} + continue + } + + res = append(res, GroupedResources{ + Names: []string{ + fmt.Sprintf(native.ResourceFormatNamespaceContainerObjects, bktInfo.Namespace, bktInfo.Container), + fmt.Sprintf(native.ResourceFormatNamespaceContainer, bktInfo.Namespace, bktInfo.Container), + }, + Conditions: []chain.Condition{ + { + Op: chain.CondStringLike, + Kind: chain.KindResource, + Key: PropertyKeyFilePath, + Value: obj, + }, + }, + }) + } + + if len(combined) != 0 { + gr := GroupedResources{Names: make([]string, 0, len(combined))} + for key := range combined { + gr.Names = append(gr.Names, key) + } + + res = append(res, gr) + } + + return res, nil +} + +func formWildcardNativeResource(actionTypes ActionTypes) GroupedResources { + groupedNames := make([]string, 0, 2) + if actionTypes.Object { + groupedNames = append(groupedNames, native.ResourceFormatAllObjects) + } + if actionTypes.Container { + groupedNames = append(groupedNames, native.ResourceFormatAllContainers) + } + + return GroupedResources{Names: groupedNames} +} + +func formNativePrincipal(principal []string, resolver s3common.NativeResolver) ([]string, error) { + res := make([]string, len(principal)) + + var err error + for i := range principal { + if res[i], err = formPrincipalKey(principal[i], resolver); err != nil { + return nil, err + } + } + + return res, nil +} + +func formPrincipalKey(principal string, resolver s3common.NativeResolver) (string, error) { + account, user, err := s3common.ParsePrincipalAsIAMUser(principal) + if err != nil { + return "", err + } + + key, err := resolver.GetUserKey(account, user) + if err != nil { + return "", fmt.Errorf("get user key: %w", err) + } + + return key, nil +} + +func formNativeActionNames(names []string) ([]string, error) { + uniqueActions := make(map[string]struct{}, len(names)) + + for _, action := range names { + if action == s3common.Wildcard { + return []string{s3common.Wildcard}, nil + } + + isIAM, err := s3common.ValidateAction(action) + if err != nil { + return nil, err + } + + if isIAM { + continue + } + + if action[len(s3common.S3ActionPrefix):] == s3common.Wildcard { + return []string{s3common.Wildcard}, nil + } + + nativeActions := actionToNativeOpMap[action] + if len(nativeActions) == 0 { + return nil, s3common.ErrActionsNotApplicable + } + + for _, nativeAction := range nativeActions { + uniqueActions[nativeAction] = struct{}{} + } + } + + res := make([]string, 0, len(uniqueActions)) + for key := range uniqueActions { + res = append(res, key) + } + + return res, nil +} diff --git a/pkg/policy-engine/v2/iam/converter_s3.go b/pkg/policy-engine/v2/iam/converter_s3.go new file mode 100644 index 000000000..f58fd4d53 --- /dev/null +++ b/pkg/policy-engine/v2/iam/converter_s3.go @@ -0,0 +1,269 @@ +package iam + +import ( + "fmt" + "strings" + + s3common "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine/common" + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" + "git.frostfs.info/TrueCloudLab/policy-engine/schema/s3" +) + +const condKeyAWSMFAPresent = "aws:MultiFactorAuthPresent" + +var actionToS3OpMap = map[string][]string{ + s3common.S3ActionAbortMultipartUpload: {s3common.S3ActionAbortMultipartUpload}, + s3common.S3ActionCreateBucket: {s3common.S3ActionCreateBucket}, + s3common.S3ActionDeleteBucket: {s3common.S3ActionDeleteBucket}, + s3common.S3ActionDeleteBucketPolicy: {s3common.S3ActionDeleteBucketPolicy}, + s3common.S3ActionDeleteObjectTagging: {s3common.S3ActionDeleteObjectTagging}, + s3common.S3ActionGetBucketLocation: {s3common.S3ActionGetBucketLocation}, + s3common.S3ActionGetBucketNotification: {s3common.S3ActionGetBucketNotification}, + s3common.S3ActionGetBucketPolicy: {s3common.S3ActionGetBucketPolicy}, + s3common.S3ActionGetBucketPolicyStatus: {s3common.S3ActionGetBucketPolicyStatus}, + s3common.S3ActionGetBucketTagging: {s3common.S3ActionGetBucketTagging}, + s3common.S3ActionGetBucketVersioning: {s3common.S3ActionGetBucketVersioning}, + s3common.S3ActionGetObjectAttributes: {s3common.S3ActionGetObjectAttributes}, + s3common.S3ActionGetObjectLegalHold: {s3common.S3ActionGetObjectLegalHold}, + s3common.S3ActionGetObjectRetention: {s3common.S3ActionGetObjectRetention}, + s3common.S3ActionGetObjectTagging: {s3common.S3ActionGetObjectTagging}, + s3common.S3ActionPutBucketNotification: {s3common.S3ActionPutBucketNotification}, + s3common.S3ActionPutBucketPolicy: {s3common.S3ActionPutBucketPolicy}, + s3common.S3ActionPutBucketVersioning: {s3common.S3ActionPutBucketVersioning}, + s3common.S3ActionPutObjectLegalHold: {s3common.S3ActionPutObjectLegalHold}, + s3common.S3ActionPutObjectRetention: {s3common.S3ActionPutObjectRetention}, + s3common.S3ActionPutObjectTagging: {s3common.S3ActionPutObjectTagging}, + s3common.S3ActionPatchObject: {s3common.S3ActionPatchObject}, + + s3common.S3ActionListAllMyBuckets: {"s3:ListBuckets"}, + s3common.S3ActionListBucket: {"s3:HeadBucket", "s3:GetBucketLocation", "s3:ListObjectsV1", "s3:ListObjectsV2"}, + s3common.S3ActionListBucketVersions: {"s3:ListBucketObjectVersions"}, + s3common.S3ActionListBucketMultipartUploads: {"s3:ListMultipartUploads"}, + s3common.S3ActionGetBucketObjectLockConfiguration: {"s3:GetBucketObjectLockConfig"}, + s3common.S3ActionGetLifecycleConfiguration: {"s3:GetBucketLifecycle"}, + s3common.S3ActionGetBucketACL: {"s3:GetBucketACL"}, + s3common.S3ActionGetBucketCORS: {"s3:GetBucketCors"}, + s3common.S3ActionPutBucketTagging: {"s3:PutBucketTagging", "s3:DeleteBucketTagging"}, + s3common.S3ActionPutBucketObjectLockConfiguration: {"s3:PutBucketObjectLockConfig"}, + s3common.S3ActionPutLifecycleConfiguration: {"s3:PutBucketLifecycle", "s3:DeleteBucketLifecycle"}, + s3common.S3ActionPutBucketACL: {"s3:PutBucketACL"}, + s3common.S3ActionPutBucketCORS: {"s3:PutBucketCors", "s3:DeleteBucketCors"}, + + s3common.S3ActionListMultipartUploadParts: {"s3:ListParts"}, + s3common.S3ActionGetObjectACL: {"s3:GetObjectACL"}, + s3common.S3ActionGetObject: {"s3:GetObject", "s3:HeadObject"}, + s3common.S3ActionGetObjectVersion: {"s3:GetObject", "s3:HeadObject"}, + s3common.S3ActionGetObjectVersionACL: {"s3:GetObjectACL"}, + s3common.S3ActionGetObjectVersionAttributes: {"s3:GetObjectAttributes"}, + s3common.S3ActionGetObjectVersionTagging: {"s3:GetObjectTagging"}, + s3common.S3ActionPutObjectACL: {"s3:PutObjectACL"}, + s3common.S3ActionPutObjectVersionACL: {"s3:PutObjectACL"}, + s3common.S3ActionPutObjectVersionTagging: {"s3:PutObjectTagging"}, + s3common.S3ActionPutObject: { + "s3:PutObject", "s3:PostObject", "s3:CopyObject", + "s3:UploadPart", "s3:UploadPartCopy", "s3:CreateMultipartUpload", "s3:CompleteMultipartUpload", + }, + s3common.S3ActionDeleteObjectVersionTagging: {"s3:DeleteObjectTagging"}, + s3common.S3ActionDeleteObject: {"s3:DeleteObject", "s3:DeleteMultipleObjects"}, + s3common.S3ActionDeleteObjectVersion: {"s3:DeleteObject", "s3:DeleteMultipleObjects"}, + s3common.S3ActionPutBucketPublicAccessBlock: {"s3:PutPublicAccessBlock", "s3:DeletePublicAccessBlock"}, + s3common.S3ActionGetBucketPublicAccessBlock: {"s3:GetPublicAccessBlock"}, +} + +func ConvertToS3Chain(p s3common.Policy, resolver s3common.S3Resolver) (*chain.Chain, error) { + if err := p.Validate(s3common.ResourceBasedPolicyType); err != nil { + return nil, err + } + + var engineChain chain.Chain + + for _, statement := range p.Statement { + status := s3common.FormStatus(statement) + + actions, actionInverted := statement.GetAction() + s3Actions, err := formS3ActionNames(actions) + if err != nil { + return nil, err + } + ruleAction := chain.Actions{Inverted: actionInverted, Names: s3Actions} + if len(ruleAction.Names) == 0 { + continue + } + + resources, resourceInverted := statement.GetResource() + if err := validateS3ResourceNames(resources); err != nil { + return nil, err + } + ruleResource := chain.Resources{Inverted: resourceInverted, Names: resources} + + groupedConditions, err := convertToS3ChainCondition(statement.Conditions, resolver) + if err != nil { + return nil, err + } + splitConditions := s3common.SplitGroupedConditions(groupedConditions) + + principals, principalCondFn, err := getS3PrincipalsAndConditionFunc(statement, resolver) + if err != nil { + return nil, err + } + + for _, principal := range principals { + for _, conditions := range splitConditions { + var principalCondition []chain.Condition + if principal != s3common.Wildcard { + principalCondition = []chain.Condition{principalCondFn(principal)} + } + + r := chain.Rule{ + Status: status, + Actions: ruleAction, + Resources: ruleResource, + Condition: append(principalCondition, conditions...), + } + engineChain.Rules = append(engineChain.Rules, r) + } + } + } + + if len(engineChain.Rules) == 0 { + return nil, s3common.ErrActionsNotApplicable + } + + return &engineChain, nil +} + +func getS3PrincipalsAndConditionFunc(statement s3common.Statement, resolver s3common.S3Resolver) ([]string, s3common.FormPrincipalConditionFunc, error) { + var principals []string + var op chain.ConditionType + statementPrincipal, inverted := statement.GetPrincipal() + if _, ok := statementPrincipal[s3common.Wildcard]; ok { // this can be true only if 'inverted' false + principals = []string{s3common.Wildcard} + op = chain.CondStringLike + } else { + for principalType, principal := range statementPrincipal { + if principalType != s3common.AWSPrincipalType { + return nil, nil, fmt.Errorf("unsupported principal type '%s'", principalType) + } + parsedPrincipal, err := formS3Principal(principal, resolver) + if err != nil { + return nil, nil, fmt.Errorf("parse principal: %w", err) + } + principals = append(principals, parsedPrincipal...) + } + + op = chain.CondStringEquals + if inverted { + op = chain.CondStringNotEquals + } + } + + return principals, func(principal string) chain.Condition { + return chain.Condition{ + Op: op, + Kind: chain.KindRequest, + Key: s3.PropertyKeyOwner, + Value: principal, + } + }, nil +} + +func convertToS3ChainCondition(c s3common.Conditions, resolver s3common.S3Resolver) ([]s3common.GroupedConditions, error) { + return s3common.ConvertToChainConditions(c, func(gr s3common.GroupedConditions) (s3common.GroupedConditions, error) { + for i := range gr.Conditions { + switch { + case gr.Conditions[i].Key == s3common.CondKeyAWSPrincipalARN: + gr.Conditions[i].Key = s3.PropertyKeyOwner + val, err := formPrincipalOwner(gr.Conditions[i].Value, resolver) + if err != nil { + return s3common.GroupedConditions{}, err + } + gr.Conditions[i].Value = val + + case gr.Conditions[i].Key == condKeyAWSMFAPresent: + gr.Conditions[i].Key = s3.PropertyKeyAccessBoxAttrMFA + case strings.HasPrefix(gr.Conditions[i].Key, s3common.CondKeyAWSResourceTagPrefix): + gr.Conditions[i].Kind = chain.KindResource + } + } + + return gr, nil + }) +} + +func formS3Principal(principal []string, resolver s3common.S3Resolver) ([]string, error) { + res := make([]string, len(principal)) + + var err error + for i := range principal { + if res[i], err = formPrincipalOwner(principal[i], resolver); err != nil { + return nil, err + } + } + + return res, nil +} + +func formPrincipalOwner(principal string, resolver s3common.S3Resolver) (string, error) { + account, user, err := s3common.ParsePrincipalAsIAMUser(principal) + if err != nil { + return "", err + } + + address, err := resolver.GetUserAddress(account, user) + if err != nil { + return "", fmt.Errorf("get user address: %w", err) + } + + return address, nil +} + +func validateS3ResourceNames(names []string) error { + for i := range names { + if err := s3common.ValidateResource(names[i]); err != nil { + return err + } + } + + return nil +} + +func formS3ActionNames(names []string) ([]string, error) { + uniqueActions := make(map[string]struct{}, len(names)) + + for _, action := range names { + if action == s3common.Wildcard { + return []string{s3common.Wildcard}, nil + } + + isIAM, err := s3common.ValidateAction(action) + if err != nil { + return nil, err + } + + if isIAM { + uniqueActions[action] = struct{}{} + continue + } + + if action[len(s3common.S3ActionPrefix):] == s3common.Wildcard { + uniqueActions[action] = struct{}{} + continue + } + + s3Actions := actionToS3OpMap[action] + if len(s3Actions) == 0 { + return nil, s3common.ErrActionsNotApplicable + } + + for _, s3Action := range s3Actions { + uniqueActions[s3Action] = struct{}{} + } + } + + res := make([]string, 0, len(uniqueActions)) + for key := range uniqueActions { + res = append(res, key) + } + + return res, nil +} diff --git a/pkg/policy-engine/v2/iam/converter_test.go b/pkg/policy-engine/v2/iam/converter_test.go new file mode 100644 index 000000000..5366fa158 --- /dev/null +++ b/pkg/policy-engine/v2/iam/converter_test.go @@ -0,0 +1,2132 @@ +package iam + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "testing" + + s3common "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/pkg/policy-engine/common" + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine" + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine/inmemory" + "git.frostfs.info/TrueCloudLab/policy-engine/pkg/resource/testutil" + "git.frostfs.info/TrueCloudLab/policy-engine/schema/common" + "git.frostfs.info/TrueCloudLab/policy-engine/schema/native" + "git.frostfs.info/TrueCloudLab/policy-engine/schema/s3" + "github.com/stretchr/testify/require" +) + +type mockUserResolver struct { + users map[string]string + containers map[string]string + namespace string +} + +func newMockUserResolver(accountUsers []string, buckets []string, namespace string) *mockUserResolver { + userMap := make(map[string]string, len(accountUsers)) + for _, user := range accountUsers { + userMap[user] = user + "/resolvedValue" + } + + containerMap := make(map[string]string, len(buckets)) + for _, bkt := range buckets { + containerMap[bkt] = bkt + "/resolvedValues" + } + + return &mockUserResolver{users: userMap, containers: containerMap, namespace: namespace} +} + +func (m *mockUserResolver) GetUserAddress(account, user string) (string, error) { + key, ok := m.users[account+"/"+user] + if !ok { + return "", errors.New("not found") + } + + return key, nil +} + +func (m *mockUserResolver) GetUserKey(account, user string) (string, error) { + key, ok := m.users[account+"/"+user] + if !ok { + return "", errors.New("not found") + } + + return key, nil +} + +func (m *mockUserResolver) GetBucketInfo(bkt string) (*s3common.BucketInfo, error) { + cnr, ok := m.containers[bkt] + if !ok { + return nil, errors.New("not found") + } + + return &s3common.BucketInfo{Container: cnr, Namespace: m.namespace}, nil +} + +func TestConverters(t *testing.T) { + namespace := "root" + userName := "JohnDoe" + user := namespace + "/" + userName + principal := "arn:aws:iam::" + namespace + ":user/" + userName + bktName := "DOC-EXAMPLE-BUCKET" + objName := "object-name" + resource := fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName) + s3GetObjectAction := "s3:GetObject" + s3HeadObjectAction := "s3:HeadObject" + + mockResolver := newMockUserResolver([]string{user}, []string{bktName}, namespace) + + t.Run("valid policy", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal}, + }, + Effect: s3common.AllowEffect, + Action: []string{s3GetObjectAction}, + Resource: []string{resource}, + Conditions: map[string]s3common.Condition{ + s3common.CondStringEquals: { + "s3:RequestObjectTag/Department": {"Finance"}, + }, + }, + }}, + } + + expected := &chain.Chain{Rules: []chain.Rule{ + { + Status: chain.Allow, + Actions: chain.Actions{Names: []string{s3GetObjectAction, s3HeadObjectAction}}, + Resources: chain.Resources{Names: []string{resource}}, + Condition: []chain.Condition{ + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: s3.PropertyKeyOwner, + Value: mockResolver.users[user], + }, + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: "s3:RequestObjectTag/Department", + Value: "Finance", + }, + }, + }, + }} + + s3Chain, err := ConvertToS3Chain(p, mockResolver) + require.NoError(t, err) + assertChainsEqual(t, expected, s3Chain) + }) + + t.Run("valid native policy", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal}, + }, + Effect: s3common.AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{resource}, + }}, + } + + expected := &chain.Chain{Rules: []chain.Rule{ + { + Status: chain.Allow, + Actions: chain.Actions{Names: []string{native.MethodGetContainer, native.MethodPutObject, + native.MethodGetObject, native.MethodHeadObject, native.MethodRangeObject}}, + Resources: chain.Resources{Names: []string{ + fmt.Sprintf(native.ResourceFormatNamespaceContainerObjects, namespace, mockResolver.containers[bktName]), + fmt.Sprintf(native.ResourceFormatNamespaceContainer, namespace, mockResolver.containers[bktName])}, + }, + Condition: []chain.Condition{ + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: native.PropertyKeyActorPublicKey, + Value: mockResolver.users[user], + }, + }, + }, + }} + + nativeChain, err := ConvertToNativeChain(p, mockResolver) + require.NoError(t, err) + assertChainsEqual(t, expected, nativeChain) + }) + + t.Run("valid inverted policy", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + NotPrincipal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal}, + }, + Effect: s3common.DenyEffect, + NotAction: []string{s3GetObjectAction}, + NotResource: []string{resource}, + }}, + } + + expected := &chain.Chain{Rules: []chain.Rule{ + { + Status: chain.AccessDenied, + Actions: chain.Actions{Inverted: true, Names: []string{s3GetObjectAction, s3HeadObjectAction}}, + Resources: chain.Resources{Inverted: true, Names: []string{resource}}, + Condition: []chain.Condition{ + { + Op: chain.CondStringNotEquals, + Kind: chain.KindRequest, + Key: s3.PropertyKeyOwner, + Value: mockResolver.users[user], + }, + }, + }, + }} + + s3Chain, err := ConvertToS3Chain(p, mockResolver) + require.NoError(t, err) + assertChainsEqual(t, expected, s3Chain) + }) + + t.Run("valid native policy map action", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal}, + }, + Effect: s3common.AllowEffect, + Action: []string{"s3:DeleteObject", "s3:DeleteBucket"}, + Resource: []string{ + fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName, objName), + fmt.Sprintf(s3.ResourceFormatS3Bucket, bktName), + }, + }}, + } + + expected := &chain.Chain{Rules: []chain.Rule{ + { + Status: chain.Allow, + Actions: chain.Actions{Names: []string{ + native.MethodGetContainer, native.MethodDeleteContainer, + native.MethodSearchObject, native.MethodHeadObject, + native.MethodDeleteObject, native.MethodPutObject, + native.MethodGetObject, native.MethodRangeObject, + }}, + Resources: chain.Resources{Names: []string{ + fmt.Sprintf(native.ResourceFormatNamespaceContainerObjects, namespace, mockResolver.containers[bktName]), + fmt.Sprintf(native.ResourceFormatNamespaceContainer, namespace, mockResolver.containers[bktName]), + }}, + Condition: []chain.Condition{ + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: native.PropertyKeyActorPublicKey, + Value: mockResolver.users[user], + }, + { + Op: chain.CondStringLike, + Kind: chain.KindResource, + Key: PropertyKeyFilePath, + Value: objName, + }, + }, + }, + { + Status: chain.Allow, + Actions: chain.Actions{Names: []string{ + native.MethodGetContainer, native.MethodDeleteContainer, + native.MethodSearchObject, native.MethodHeadObject, + native.MethodDeleteObject, native.MethodPutObject, + native.MethodGetObject, native.MethodRangeObject, + }}, + Resources: chain.Resources{Names: []string{ + fmt.Sprintf(native.ResourceFormatNamespaceContainer, namespace, mockResolver.containers[bktName]), + }}, + Condition: []chain.Condition{{ + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: native.PropertyKeyActorPublicKey, + Value: mockResolver.users[user], + }}, + }, + }} + + nativeChain, err := ConvertToNativeChain(p, mockResolver) + require.NoError(t, err) + assertChainsEqual(t, expected, nativeChain) + }) + + t.Run("invalid policy (unsupported principal type)", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + "dummy": {principal}, + }, + Effect: s3common.AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + }}, + } + + _, err := ConvertToS3Chain(p, mockResolver) + require.Error(t, err) + }) + + t.Run("invalid policy (missing resource)", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal}, + }, + Effect: s3common.AllowEffect, + Action: []string{"s3:PutObject"}, + }}, + } + + _, err := ConvertToS3Chain(p, mockResolver) + require.Error(t, err) + }) + + t.Run("invalid policy (not applicable native actions)", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal}, + }, + Effect: s3common.AllowEffect, + Action: []string{"s3:AbortMultipartUpload"}, + Resource: []string{"arn:aws:s3:::" + resource}, + }}, + } + + _, err := ConvertToNativeChain(p, mockResolver) + require.Error(t, err) + }) + + t.Run("invalid policy (missing s3 actions)", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal}, + }, + Effect: s3common.AllowEffect, + Resource: []string{"arn:aws:s3:::" + resource}, + }}, + } + + _, err := ConvertToS3Chain(p, mockResolver) + require.Error(t, err) + }) + + t.Run("valid mixed iam/s3 actions", func(t *testing.T) { + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{s3common.AWSPrincipalType: {principal}}, + Effect: s3common.AllowEffect, + Action: []string{"s3:DeleteObject", "iam:*"}, + Resource: []string{"*"}, + }}, + } + + s3Expected := &chain.Chain{Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{"s3:DeleteObject", "s3:DeleteMultipleObjects", "iam:*"}}, + Resources: chain.Resources{Names: []string{"*"}}, + Condition: []chain.Condition{{ + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: s3.PropertyKeyOwner, + Value: mockResolver.users[user], + }}, + }}} + + s3Chain, err := ConvertToS3Chain(p, mockResolver) + require.NoError(t, err) + assertChainsEqual(t, s3Expected, s3Chain) + + nativeExpected := &chain.Chain{Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{native.MethodGetContainer, native.MethodDeleteObject, native.MethodPutObject, native.MethodHeadObject, native.MethodGetObject, native.MethodRangeObject}}, + Resources: chain.Resources{Names: []string{native.ResourceFormatAllObjects, native.ResourceFormatAllContainers}}, + Condition: []chain.Condition{{ + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: native.PropertyKeyActorPublicKey, + Value: mockResolver.users[user], + }}, + }}} + + nativeChain, err := ConvertToNativeChain(p, mockResolver) + require.NoError(t, err) + assertChainsEqual(t, nativeExpected, nativeChain) + }) +} + +func TestConvertToChainCondition(t *testing.T) { + principal := "arn:aws:iam::namespace:user/userName" + + conditions := s3common.Conditions{ + s3common.CondStringEquals: {"key1": {"val0", "val1"}}, + s3common.CondStringNotEquals: {"key2": {"val2"}}, + s3common.CondStringEqualsIgnoreCase: {"key3": {"val3"}}, + s3common.CondStringNotEqualsIgnoreCase: {"key4": {"val4"}}, + s3common.CondStringLike: {"key5": {"val5"}}, + s3common.CondStringNotLike: {"key6": {"val6"}}, + s3common.CondDateEquals: {"key7": {"2006-01-02T15:04:05+07:00"}}, + s3common.CondDateNotEquals: {"key8": {"2006-01-02T15:04:05Z"}}, + s3common.CondDateLessThan: {"key9": {"2006-01-02T15:04:05+06:00"}}, + s3common.CondDateLessThanEquals: {"key10": {"2006-01-02T15:04:05+03:00"}}, + s3common.CondDateGreaterThan: {"key11": {"2006-01-02T15:04:05-01:00"}}, + s3common.CondDateGreaterThanEquals: {"key12": {"2006-01-02T15:04:05-03:00"}}, + s3common.CondBool: {"key13": {"True"}}, + s3common.CondArnEquals: {"key16": {"val16"}}, + s3common.CondArnLike: {s3common.CondKeyAWSPrincipalARN: {principal}}, + s3common.CondArnNotEquals: {"key18": {"val18"}}, + s3common.CondArnNotLike: {"key19": {"val19"}}, + s3common.CondNumericEquals: {"key20": {"-20"}}, + s3common.CondNumericNotEquals: {"key21": {"+21"}}, + s3common.CondNumericLessThan: {"key22": {"0"}}, + s3common.CondNumericLessThanEquals: {"key23": {"23.23"}}, + s3common.CondNumericGreaterThan: {"key24": {"-24.24"}}, + s3common.CondNumericGreaterThanEquals: {"key25": {"+25.25"}}, + } + + expectedCondition := []s3common.GroupedConditions{ + { + Any: true, + Conditions: []chain.Condition{ + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: "key1", + Value: "val0", + }, + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: "key1", + Value: "val1", + }, + }, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringNotEquals, + Kind: chain.KindRequest, + Key: "key2", + Value: "val2", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringEqualsIgnoreCase, + Kind: chain.KindRequest, + Key: "key3", + Value: "val3", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringNotEqualsIgnoreCase, + Kind: chain.KindRequest, + Key: "key4", + Value: "val4", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringLike, + Kind: chain.KindRequest, + Key: "key5", + Value: "val5", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringNotLike, + Kind: chain.KindRequest, + Key: "key6", + Value: "val6", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: "key7", + Value: "1136189045", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringNotEquals, + Kind: chain.KindRequest, + Key: "key8", + Value: "1136214245", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringLessThan, + Kind: chain.KindRequest, + Key: "key9", + Value: "1136192645", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringLessThanEquals, + Kind: chain.KindRequest, + Key: "key10", + Value: "1136203445", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringGreaterThan, + Kind: chain.KindRequest, + Key: "key11", + Value: "1136217845", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringGreaterThanEquals, + Kind: chain.KindRequest, + Key: "key12", + Value: "1136225045", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringEqualsIgnoreCase, + Kind: chain.KindRequest, + Key: "key13", + Value: "True", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: "key16", + Value: "val16", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringLike, + Kind: chain.KindRequest, + Key: s3common.CondKeyAWSPrincipalARN, + Value: principal, + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringNotEquals, + Kind: chain.KindRequest, + Key: "key18", + Value: "val18", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondStringNotLike, + Kind: chain.KindRequest, + Key: "key19", + Value: "val19", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondNumericEquals, + Kind: chain.KindRequest, + Key: "key20", + Value: "-20", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondNumericNotEquals, + Kind: chain.KindRequest, + Key: "key21", + Value: "+21", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondNumericLessThan, + Kind: chain.KindRequest, + Key: "key22", + Value: "0", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondNumericLessThanEquals, + Kind: chain.KindRequest, + Key: "key23", + Value: "23.23", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondNumericGreaterThan, + Kind: chain.KindRequest, + Key: "key24", + Value: "-24.24", + }}, + }, + { + Conditions: []chain.Condition{{ + Op: chain.CondNumericGreaterThanEquals, + Kind: chain.KindRequest, + Key: "key25", + Value: "+25.25", + }}, + }, + } + + actualCondition, err := s3common.ConvertToChainCondition(conditions) + require.NoError(t, err) + require.ElementsMatch(t, expectedCondition, actualCondition) + + invalidConditions := []s3common.Condition{ + {"key1": {"invalid"}}, + {"key2": {"1 2"}}, + {"key3": {"0x12f"}}, + {"key4": {"0b1010"}}, + {"key5": {"+Inf"}}, + {"key6": {"-Inf"}}, + {"key7": {"inf"}}, + {"key8": {"NaN"}}, + {"key9": {"nan"}}, + } + + for _, cond := range invalidConditions { + _, err = s3common.ConvertToChainCondition(s3common.Conditions{s3common.CondNumericEquals: cond}) + require.Error(t, err) + } +} + +func TestIPConditions(t *testing.T) { + t.Run("ip converters", func(t *testing.T) { + for _, tc := range []struct { + ip string + error bool + expected string + }{ + {ip: "203.0.113.0/24", expected: "203.0.113.0/24"}, + {ip: "203.0.113.1", expected: "203.0.113.1/32"}, + {ip: "203.0.113.1/", error: true}, + {ip: "203.0.113.1/33", error: true}, + {ip: "192.168.0.1/24", expected: "192.168.0.1/24"}, + {ip: "10.10.0.1/24", expected: "10.10.0.1/24"}, + {ip: "172.16.0.1/24", expected: "172.16.0.1/24"}, + {ip: "2001:DB8:1234:5678::/64", expected: "2001:DB8:1234:5678::/64"}, + {ip: "2001:DB8:1234:5678::", expected: "2001:DB8:1234:5678::/32"}, + {ip: "2001:DB8:1234:5678::/", error: true}, + {ip: "2001:DB8:1234:5678::/129", error: true}, + {ip: "FC00::/64", expected: "FC00::/64"}, + } { + t.Run("", func(t *testing.T) { + actual, err := s3common.IPConvertFunction(tc.ip) + if tc.error { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tc.expected, actual) + }) + } + }) + + t.Run("chain converters", func(t *testing.T) { + for _, tc := range []struct { + name string + policy string + }{ + { + name: "wildcard principal", + policy: `{"Version":"2012-10-17", +"Statement":{"Effect":"Allow","Principal": "*","Action":"s3:*","Resource":"*","Condition": {"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}}} +}`, + }, + { + name: "wildcard AWS principal", + policy: `{"Version":"2012-10-17", +"Statement":{"Effect":"Allow","Principal": {"AWS":"*"},"Action":"s3:*","Resource":"*","Condition": {"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}}} +}`, + }, + } { + var p s3common.Policy + err := json.Unmarshal([]byte(tc.policy), &p) + require.NoError(t, err) + + s3Expected := &chain.Chain{ + Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{"s3:*"}}, + Resources: chain.Resources{Names: []string{s3common.Wildcard}}, + Condition: []chain.Condition{{ + Op: chain.CondIPAddress, + Kind: chain.KindRequest, + Key: common.PropertyKeyFrostFSSourceIP, + Value: "203.0.113.0/24", + }}, + }}, + } + + s3Chain, err := ConvertToS3Chain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Equal(t, s3Expected, s3Chain) + + nativeExpected := &chain.Chain{ + Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{s3common.Wildcard}}, + Resources: chain.Resources{Names: []string{native.ResourceFormatAllObjects, native.ResourceFormatAllContainers}}, + Condition: []chain.Condition{{ + Op: chain.CondIPAddress, + Kind: chain.KindRequest, + Key: common.PropertyKeyFrostFSSourceIP, + Value: "203.0.113.0/24", + }}, + }}, + } + + nativeChain, err := ConvertToNativeChain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Equal(t, nativeExpected, nativeChain) + } + }) + + t.Run("matching rules", func(t *testing.T) { + for _, tc := range []struct { + name string + policy string + }{ + { + name: "wildcard principal", + policy: `{"Version":"2012-10-17", +"Statement":{"Effect":"Allow","Principal": "*","Action":"s3:*","Resource":"*","Condition": {"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}}} +}`, + }, + { + name: "wildcard AWS principal", + policy: `{"Version":"2012-10-17", +"Statement":{"Effect":"Allow","Principal": {"AWS":"*"},"Action":"s3:*","Resource":"*","Condition": {"IpAddress": {"aws:SourceIp": "203.0.113.0/24"}}} +}`, + }, + } { + var p s3common.Policy + err := json.Unmarshal([]byte(tc.policy), &p) + require.NoError(t, err) + + s3Chain, err := ConvertToS3Chain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + + s := inmemory.NewInMemory() + _, _, err = s.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(""), s3Chain) + require.NoError(t, err) + + req := testutil.NewRequest("s3:CreateBucket", testutil.NewResource(fmt.Sprintf(s3.ResourceFormatS3Bucket, "bkt"), nil), + map[string]string{common.PropertyKeyFrostFSSourceIP: "203.0.113.128"}) + status, _, err := s.IsAllowed(chain.S3, engine.NewRequestTargetWithNamespace(""), req) + require.NoError(t, err) + require.Equal(t, chain.Allow.String(), status.String()) + + req = testutil.NewRequest("s3:CreateBucket", testutil.NewResource(fmt.Sprintf(s3.ResourceFormatS3Bucket, "bkt"), nil), + map[string]string{common.PropertyKeyFrostFSSourceIP: "203.0.114.0"}) + status, _, err = s.IsAllowed(chain.S3, engine.NewRequestTargetWithNamespace(""), req) + require.NoError(t, err) + require.Equal(t, chain.NoRuleFound.String(), status.String()) + } + }) +} + +func TestParsePrincipalARN(t *testing.T) { + for i, tc := range []struct { + principal string + account string + user string + error bool + }{ + { + principal: "arn:aws:iam::root:user/user", + account: "root", + user: "user", + error: false, + }, + { + principal: "arn:aws:iam::root:user/path/user/user2", + account: "root", + user: "user2", + error: false, + }, + { + principal: "arn:aws:iam::root:user/", + error: true, + }, + { + principal: "root:user/name", + error: true, + }, + { + principal: "arn:aws:iam::root:user", + error: true, + }, + { + principal: "arn:aws:iam::root:name", + error: true, + }, + { + principal: "arn:aws:iam::root:user/path/user/", + error: true, + }, + } { + t.Run(strconv.Itoa(i), func(t *testing.T) { + account, user, err := s3common.ParsePrincipalAsIAMUser(tc.principal) + if tc.error { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.account, account) + require.Equal(t, tc.user, user) + }) + } +} + +func TestComplexNativeConditions(t *testing.T) { + namespace := "root" + userName1, userName2 := "user1", "user2" + user1, user2 := namespace+"/"+userName1, namespace+"/"+userName2 + principal1 := "arn:aws:iam::" + namespace + ":user/" + userName1 + principal2 := "arn:aws:iam::" + namespace + ":user/" + userName2 + bktName1, bktName2, bktName3 := "bktName", "bktName2", "bktName3" + objName1 := "objName1" + resource1 := bktName1 + "/" + objName1 + resource2 := bktName2 + "/*" + resource3 := bktName3 + "/*" + action := "PutObject" + + key1, key2 := "key1", "key2" + val0, val1, val2 := "val0", "val1", "val2" + + mockResolver := newMockUserResolver([]string{user1, user2}, []string{bktName1, bktName2, bktName3}, "") + nativeResource1 := fmt.Sprintf(native.ResourceFormatRootContainerObjects, mockResolver.containers[bktName1]) + nativeResource1cnr := fmt.Sprintf(native.ResourceFormatRootContainer, mockResolver.containers[bktName1]) + nativeResource2 := fmt.Sprintf(native.ResourceFormatRootContainerObjects, mockResolver.containers[bktName2]) + nativeResource2cnr := fmt.Sprintf(native.ResourceFormatRootContainer, mockResolver.containers[bktName2]) + nativeResource3 := fmt.Sprintf(native.ResourceFormatRootContainerObjects, mockResolver.containers[bktName3]) + nativeResource3cnr := fmt.Sprintf(native.ResourceFormatRootContainer, mockResolver.containers[bktName3]) + + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal1, principal2}, + }, + Effect: s3common.AllowEffect, + Action: []string{"s3:" + action}, + Resource: []string{"arn:aws:s3:::" + resource1, "arn:aws:s3:::" + resource2, "arn:aws:s3:::" + resource3}, + Conditions: map[string]s3common.Condition{ + s3common.CondStringEquals: {key1: {val0, val1}}, + s3common.CondStringLike: {key2: {val2}}, + }, + }}, + } + + expectedStatus := chain.Allow + expectedActions := chain.Actions{Names: actionToNativeOpMap["s3:"+action]} + expectedResource1 := chain.Resources{Names: []string{nativeResource1, nativeResource1cnr}} + expectedResource23 := chain.Resources{Names: []string{nativeResource2, nativeResource2cnr, nativeResource3, nativeResource3cnr}} + + user1Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: native.PropertyKeyActorPublicKey, Value: mockResolver.users[user1]} + user2Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: native.PropertyKeyActorPublicKey, Value: mockResolver.users[user2]} + objectName1Condition := chain.Condition{Op: chain.CondStringLike, Kind: chain.KindResource, Key: PropertyKeyFilePath, Value: objName1} + key1val0Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: key1, Value: val0} + key1val1Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: key1, Value: val1} + key2val2Condition := chain.Condition{Op: chain.CondStringLike, Kind: chain.KindRequest, Key: key2, Value: val2} + + expected := &chain.Chain{Rules: []chain.Rule{ + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource1, + Condition: []chain.Condition{ + user1Condition, + objectName1Condition, + key1val0Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource1, + Condition: []chain.Condition{ + user1Condition, + objectName1Condition, + key1val1Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource1, + Condition: []chain.Condition{ + user2Condition, + objectName1Condition, + key1val0Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource1, + Condition: []chain.Condition{ + user2Condition, + objectName1Condition, + key1val1Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource23, + Condition: []chain.Condition{ + user1Condition, + key1val0Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource23, + Condition: []chain.Condition{ + user1Condition, + key1val1Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource23, + Condition: []chain.Condition{ + user2Condition, + key1val0Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResource23, + Condition: []chain.Condition{ + user2Condition, + key1val1Condition, + key2val2Condition, + }, + }, + }} + + nativeChain, err := ConvertToNativeChain(p, mockResolver) + require.NoError(t, err) + requireChainRulesMatch(t, expected.Rules, nativeChain.Rules) + + s := inmemory.NewInMemory() + _, _, err = s.MorphRuleChainStorage().AddMorphRuleChain("name", engine.NamespaceTarget("ns"), nativeChain) + require.NoError(t, err) + + for _, tc := range []struct { + name string + action string + resource string + resourceMap map[string]string + requestMap map[string]string + status chain.Status + }{ + { + name: "bucket resource1, all conditions matched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName2], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: "any-object-name", + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.Allow, + }, + { + name: "bucket resource3, all conditions matched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName3], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: "any-object-name", + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.Allow, + }, + { + name: "bucket resource, user condition mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName2], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: "any-object-name", + }, + requestMap: map[string]string{ + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket resource, key2 condition mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName3], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: "any-object-name", + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: mockResolver.users[user1], + key1: val0, + key2: val0, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket resource, key1 condition mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName2], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: "any-object-name", + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: mockResolver.users[user1], + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, all conditions matched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName1], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: objName1, + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.Allow, + }, + { + name: "bucket/object resource, user condition mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName1], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: objName1, + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: "dummy", + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, key1 condition mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName1], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: objName1, + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: "dummy", + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, key2 condition mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName1], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: objName1, + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: "dummy", + key1: val0, + key2: val0, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, object filepath condition mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, mockResolver.containers[bktName1], "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: "any-object-name", + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "resource mismatched", + action: action, + resource: fmt.Sprintf(native.ResourceFormatRootContainerObject, "some-cid", "some-oid"), + resourceMap: map[string]string{ + PropertyKeyFilePath: objName1, + }, + requestMap: map[string]string{ + native.PropertyKeyActorPublicKey: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := testutil.NewRequest(tc.action, testutil.NewResource(tc.resource, tc.resourceMap), tc.requestMap) + status, _, err := s.IsAllowed("name", engine.NewRequestTargetWithNamespace("ns"), req) + require.NoError(t, err) + require.Equal(t, tc.status.String(), status.String()) + }) + } +} + +func TestComplexS3Conditions(t *testing.T) { + namespace := "root" + userName1, userName2 := "user1", "user2" + user1, user2 := namespace+"/"+userName1, namespace+"/"+userName2 + principal1 := "arn:aws:iam::" + namespace + ":user/" + userName1 + principal2 := "arn:aws:iam::" + namespace + ":user/" + userName2 + bktName1, bktName2, bktName3 := "bktName", "bktName2", "bktName3" + objName1 := "objName1" + resource1 := fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName1, objName1) + resource2 := fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName2) + resource3 := fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName3) + action := "s3:DeleteObject" + action2 := "s3:DeleteMultipleObjects" + + key1, key2 := "key1", "key2" + val0, val1, val2 := "val0", "val1", "val2" + + mockResolver := newMockUserResolver([]string{user1, user2}, []string{bktName1, bktName2, bktName3}, "") + + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{{ + Principal: map[s3common.PrincipalType][]string{ + s3common.AWSPrincipalType: {principal1, principal2}, + }, + Effect: s3common.DenyEffect, + Action: []string{action}, + Resource: []string{resource1, resource2, resource3}, + Conditions: map[string]s3common.Condition{ + s3common.CondStringEquals: {key1: {val0, val1}}, + s3common.CondStringLike: {key2: {val2}}, + }, + }}, + } + + expectedStatus := chain.AccessDenied + expectedActions := chain.Actions{Names: []string{action, action2}} + expectedResources := chain.Resources{Names: []string{resource1, resource2, resource3}} + + user1Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: s3.PropertyKeyOwner, Value: mockResolver.users[user1]} + user2Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: s3.PropertyKeyOwner, Value: mockResolver.users[user2]} + key1val0Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: key1, Value: val0} + key1val1Condition := chain.Condition{Op: chain.CondStringEquals, Kind: chain.KindRequest, Key: key1, Value: val1} + key2val2Condition := chain.Condition{Op: chain.CondStringLike, Kind: chain.KindRequest, Key: key2, Value: val2} + + expected := &chain.Chain{Rules: []chain.Rule{ + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResources, + Condition: []chain.Condition{ + user1Condition, + key1val0Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResources, + Condition: []chain.Condition{ + user1Condition, + key1val1Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResources, + Condition: []chain.Condition{ + user2Condition, + key1val0Condition, + key2val2Condition, + }, + }, + { + Status: expectedStatus, + Actions: expectedActions, + Resources: expectedResources, + Condition: []chain.Condition{ + user2Condition, + key1val1Condition, + key2val2Condition, + }, + }, + }} + + s3Chain, err := ConvertToS3Chain(p, mockResolver) + require.NoError(t, err) + requireChainRulesMatch(t, expected.Rules, s3Chain.Rules) + + s := inmemory.NewInMemory() + _, _, err = s.MorphRuleChainStorage().AddMorphRuleChain("name", engine.NamespaceTarget("ns"), s3Chain) + require.NoError(t, err) + + for _, tc := range []struct { + name string + action string + resource string + resourceMap map[string]string + requestMap map[string]string + status chain.Status + }{ + { + name: "bucket resource1, all conditions matched", + action: action, + resource: resource1, + requestMap: map[string]string{ + s3.PropertyKeyOwner: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.AccessDenied, + }, + { + name: "bucket resource3, all conditions matched", + action: action, + resource: fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName3, "some-obj"), + requestMap: map[string]string{ + s3.PropertyKeyOwner: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.AccessDenied, + }, + { + name: "bucket resource, user condition mismatched", + action: action, + resource: fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName2, "some-obj"), + requestMap: map[string]string{ + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket resource, key2 condition mismatched", + action: action, + resource: fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName3, "some-obj"), + requestMap: map[string]string{ + s3.PropertyKeyOwner: mockResolver.users[user1], + key1: val0, + key2: val0, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket resource, key1 condition mismatched", + action: action, + resource: fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName2, "some-obj"), + requestMap: map[string]string{ + s3.PropertyKeyOwner: mockResolver.users[user1], + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, all conditions matched", + action: action, + resource: resource1, + requestMap: map[string]string{ + s3.PropertyKeyOwner: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.AccessDenied, + }, + { + name: "bucket/object resource, resource mismatched", + action: action, + resource: fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName1, "some-obj"), + requestMap: map[string]string{ + s3.PropertyKeyOwner: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, user condition mismatched", + action: action, + resource: resource1, + requestMap: map[string]string{ + s3.PropertyKeyOwner: "dummy", + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, key1 condition mismatched", + action: action, + resource: resource1, + requestMap: map[string]string{ + s3.PropertyKeyOwner: "dummy", + key2: val2, + }, + status: chain.NoRuleFound, + }, + { + name: "bucket/object resource, key2 condition mismatched", + action: action, + resource: resource1, + requestMap: map[string]string{ + s3.PropertyKeyOwner: "dummy", + key1: val0, + key2: val0, + }, + status: chain.NoRuleFound, + }, + { + name: "resource mismatched", + action: action, + resource: fmt.Sprintf(s3.ResourceFormatS3BucketObject, "some-bkt", "some-obj"), + requestMap: map[string]string{ + s3.PropertyKeyOwner: mockResolver.users[user1], + key1: val0, + key2: val2, + }, + status: chain.NoRuleFound, + }, + } { + t.Run(tc.name, func(t *testing.T) { + req := testutil.NewRequest(tc.action, testutil.NewResource(tc.resource, tc.resourceMap), tc.requestMap) + status, _, err := s.IsAllowed("name", engine.NewRequestTargetWithNamespace("ns"), req) + require.NoError(t, err) + require.Equal(t, tc.status.String(), status.String()) + }) + } +} + +func TestS3BucketResource(t *testing.T) { + namespace := "ns" + bktName1, bktName2 := "bucket1", "bucket2" + chainName := chain.Name("name") + + mockResolver := newMockUserResolver([]string{}, []string{}, "") + + p := s3common.Policy{ + Version: "2012-10-17", + Statement: []s3common.Statement{ + { + Principal: map[s3common.PrincipalType][]string{s3common.Wildcard: nil}, + Effect: s3common.DenyEffect, + Action: []string{"s3:ListBucket"}, + Resource: []string{fmt.Sprintf(s3.ResourceFormatS3Bucket, bktName1)}, + }, + { + Principal: map[s3common.PrincipalType][]string{s3common.Wildcard: nil}, + Effect: s3common.AllowEffect, + Action: []string{"*"}, + Resource: []string{s3.ResourceFormatS3All}, + }, + }, + } + + s3Chain, err := ConvertToS3Chain(p, mockResolver) + require.NoError(t, err) + + s := inmemory.NewInMemory() + _, _, err = s.MorphRuleChainStorage().AddMorphRuleChain(chainName, engine.NamespaceTarget(namespace), s3Chain) + require.NoError(t, err) + + // check we match just "bucket1" resource + req := testutil.NewRequest("s3:HeadBucket", testutil.NewResource(fmt.Sprintf(s3.ResourceFormatS3Bucket, bktName1), nil), nil) + status, _, err := s.IsAllowed(chainName, engine.NewRequestTargetWithNamespace(namespace), req) + require.NoError(t, err) + require.Equal(t, chain.AccessDenied.String(), status.String()) + + // check we match just "bucket2" resource + req = testutil.NewRequest("s3:HeadBucket", testutil.NewResource(fmt.Sprintf(s3.ResourceFormatS3Bucket, bktName2), nil), nil) + status, _, err = s.IsAllowed(chainName, engine.NewRequestTargetWithNamespace(namespace), req) + require.NoError(t, err) + require.Equal(t, chain.Allow.String(), status.String()) + + // check we also match "bucket2/object" resource + req = testutil.NewRequest("s3:PutObject", testutil.NewResource(fmt.Sprintf(s3.ResourceFormatS3BucketObject, bktName2, "object"), nil), nil) + status, _, err = s.IsAllowed(chainName, engine.NewRequestTargetWithNamespace(namespace), req) + require.NoError(t, err) + require.Equal(t, chain.Allow.String(), status.String()) +} + +func TestWildcardConverters(t *testing.T) { + for _, tc := range []struct { + name string + policy string + }{ + { + name: "wildcard principal", + policy: `{"Version":"2012-10-17","Statement":{"Effect":"Allow", "Principal": "*", "Action":"s3:*","Resource":"*"}}`, + }, + { + name: "wildcard AWS principal", + policy: `{"Version":"2012-10-17","Statement":{"Effect":"Allow", "Principal": {"AWS":"*"}, "Action":"s3:*","Resource":"*"}}`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + var p s3common.Policy + err := json.Unmarshal([]byte(tc.policy), &p) + require.NoError(t, err) + + s3Expected := &chain.Chain{ + Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{"s3:*"}}, + Resources: chain.Resources{Names: []string{s3common.Wildcard}}, + }}, + } + + s3Chain, err := ConvertToS3Chain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Equal(t, s3Expected, s3Chain) + + nativeExpected := &chain.Chain{ + Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{s3common.Wildcard}}, + Resources: chain.Resources{Names: []string{native.ResourceFormatAllObjects, native.ResourceFormatAllContainers}}, + }}, + } + + nativeChain, err := ConvertToNativeChain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Equal(t, nativeExpected, nativeChain) + }) + } +} + +func TestWildcardObjectsConverters(t *testing.T) { + for _, tc := range []struct { + name string + policy string + }{ + { + name: "wildcard principal", + policy: `{"Version":"2012-10-17","Statement":{"Effect":"Allow", "Principal": "*", "Action":"s3:*","Resource":"arn:aws:s3:::bucket/*"}}`, + }, + { + name: "wildcard AWS principal", + policy: `{"Version":"2012-10-17","Statement":{"Effect":"Allow", "Principal": {"AWS":"*"}, "Action":"s3:*","Resource":"arn:aws:s3:::bucket/*"}}`, + }, + } { + var p s3common.Policy + err := json.Unmarshal([]byte(tc.policy), &p) + require.NoError(t, err) + + s3Expected := &chain.Chain{ + Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{"s3:*"}}, + Resources: chain.Resources{Names: []string{"arn:aws:s3:::bucket/*"}}, + }}, + } + + s3Chain, err := ConvertToS3Chain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Equal(t, s3Expected, s3Chain) + + mockResolver := newMockUserResolver(nil, []string{"bucket"}, "") + + nativeExpected := &chain.Chain{ + Rules: []chain.Rule{{ + Status: chain.Allow, + Actions: chain.Actions{Names: []string{s3common.Wildcard}}, + Resources: chain.Resources{Names: []string{ + fmt.Sprintf(native.ResourceFormatRootContainer, mockResolver.containers["bucket"]), + fmt.Sprintf(native.ResourceFormatRootContainerObjects, mockResolver.containers["bucket"]), + }}, + }}, + } + + nativeChain, err := ConvertToNativeChain(p, mockResolver) + require.NoError(t, err) + assertChainsEqual(t, nativeExpected, nativeChain) + } +} + +func TestDisableNativeDeny(t *testing.T) { + for _, tc := range []struct { + name string + policy string + }{ + { + name: "wildcard principal", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": [ "arn:aws:s3:::test-bucket/*" ] + } + ] +} +`, + }, + { + name: "wildcard AWS principal", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Principal": {"AWS":"*"}, + "Action": "s3:*", + "Resource": [ "arn:aws:s3:::test-bucket/*" ] + } + ] +} +`, + }, + } { + var p s3common.Policy + err := json.Unmarshal([]byte(tc.policy), &p) + require.NoError(t, err) + + _, err = ConvertToNativeChain(p, newMockUserResolver(nil, nil, "")) + require.ErrorIs(t, err, s3common.ErrActionsNotApplicable) + } +} + +func TestFromActions(t *testing.T) { + t.Run("s3 actions", func(t *testing.T) { + for _, tc := range []struct { + action string + res []string + err bool + }{ + { + action: "withoutPrefix", + err: true, + }, + { + action: "s3:*Object", + err: true, + }, + { + action: "*", + res: []string{s3common.Wildcard}, + }, + { + action: "s3:PutObject", + res: []string{"s3:PutObject", "s3:PostObject", "s3:CopyObject", + "s3:UploadPart", "s3:UploadPartCopy", "s3:CreateMultipartUpload", "s3:CompleteMultipartUpload"}, + }, + { + action: "s3:Put*", + err: true, + }, + { + action: "s3:*", + res: []string{"s3:*"}, + }, + { + action: "s3:", + err: true, + }, + { + action: "iam:ListAccessKeys", + res: []string{"iam:ListAccessKeys"}, + }, + { + action: "iam:*", + res: []string{"iam:*"}, + }, + } { + t.Run("", func(t *testing.T) { + actions, err := formS3ActionNames([]string{tc.action}) + if tc.err { + require.Error(t, err) + } else { + require.NoError(t, err) + require.ElementsMatch(t, tc.res, actions) + } + }) + } + }) + + t.Run("native actions", func(t *testing.T) { + for _, tc := range []struct { + action string + res []string + err bool + }{ + { + action: "withoutPrefix", + err: true, + }, + { + action: "s3:*Object", + err: true, + }, + { + action: "*", + res: []string{s3common.Wildcard}, + }, + { + action: "s3:PutObject", + res: []string{native.MethodGetContainer, native.MethodPutObject, + native.MethodGetObject, native.MethodHeadObject, native.MethodRangeObject}, + }, + { + action: "s3:Put*", + err: true, + }, + { + action: "s3:*", + res: []string{s3common.Wildcard}, + }, + { + action: "s3:", + err: true, + }, + { + action: "iam:ListAccessKeys", + res: []string{}, + }, + { + action: "iam:*", + res: []string{}, + }, + } { + t.Run("", func(t *testing.T) { + actions, err := formNativeActionNames([]string{tc.action}) + if tc.err { + require.Error(t, err) + } else { + require.NoError(t, err) + require.ElementsMatch(t, tc.res, actions) + } + }) + } + }) +} + +func TestPrincipalParsing(t *testing.T) { + for _, tc := range []struct { + principal string + expectedAccount string + expectedUser string + err bool + }{ + { + principal: "withoutPrefix", + err: true, + }, + { + principal: "*", + err: true, + }, + { + principal: "arn:aws:iam:::dummy", + err: true, + }, + { + principal: "arn:aws:iam::", + err: true, + }, + { + principal: "arn:aws:iam:::dummy/test", + err: true, + }, + { + principal: "arn:aws:iam:::user/", + err: true, + }, + { + principal: "arn:aws:iam:::user/user/", + err: true, + }, + { + principal: "arn:aws:iam:::user/name", + expectedUser: "name", + }, + { + principal: "arn:aws:iam:::user/path/name", + expectedUser: "name", + }, + { + principal: "arn:aws:iam::root:user/path/name", + expectedAccount: "root", + expectedUser: "name", + }, + } { + t.Run("", func(t *testing.T) { + account, user, err := s3common.ParsePrincipalAsIAMUser(tc.principal) + if tc.err { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedAccount, account) + require.Equal(t, tc.expectedUser, user) + }) + } +} + +func TestResourceParsing(t *testing.T) { + for _, tc := range []struct { + resource string + err bool + }{ + {resource: "withoutPrefixAnd", err: true}, + {resource: "arn:aws:s3:::*/obj", err: true}, + {resource: "arn:aws:s3:::bkt/*"}, + {resource: "arn:aws:s3:::bkt"}, + {resource: "arn:aws:s3:::bkt/"}, + {resource: "arn:aws:s3:::*"}, + {resource: "*"}, + } { + t.Run("", func(t *testing.T) { + err := s3common.ValidateResource(tc.resource) + if tc.err { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestTagsConditions(t *testing.T) { + expectedS3Conditions := []chain.Condition{ + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: fmt.Sprintf(common.PropertyKeyFormatFrostFSIDUserClaim, "tag-department"), + Value: "hr", + }, + { + Op: chain.CondStringEquals, + Kind: chain.KindResource, + Key: fmt.Sprintf(s3.PropertyKeyFormatResourceTag, "owner"), + Value: "hr-admin", + }, + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: fmt.Sprintf(s3.PropertyKeyFormatRequestTag, "scope"), + Value: "*", + }, + } + + expectedNativeConditions := []chain.Condition{ + { + Op: chain.CondStringEquals, + Kind: chain.KindRequest, + Key: fmt.Sprintf(common.PropertyKeyFormatFrostFSIDUserClaim, "tag-department"), + Value: "hr", + }, + } + + for _, tc := range []struct { + name string + policy string + }{ + { + name: "wildcard principal", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:PutObjectTagging", + "Resource": "*", + "Condition": { + "StringEquals": { + "aws:PrincipalTag/department": "hr", + "aws:ResourceTag/owner": "hr-admin", + "aws:RequestTag/scope": "*" + } + } + } + ] +} +`, + }, + { + name: "wildcard AWS principal", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS":"*"}, + "Action": "s3:PutObjectTagging", + "Resource": "*", + "Condition": { + "StringEquals": { + "aws:PrincipalTag/department": "hr", + "aws:ResourceTag/owner": "hr-admin", + "aws:RequestTag/scope": "*" + } + } + } + ] +} +`, + }, + } { + var p s3common.Policy + err := json.Unmarshal([]byte(tc.policy), &p) + require.NoError(t, err) + + s3Chain, err := ConvertToS3Chain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Len(t, s3Chain.Rules, 1) + require.ElementsMatch(t, expectedS3Conditions, s3Chain.Rules[0].Condition) + + nativeChain, err := ConvertToNativeChain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Len(t, nativeChain.Rules, 1) + require.ElementsMatch(t, expectedNativeConditions, nativeChain.Rules[0].Condition) + } +} + +func TestMFACondition(t *testing.T) { + for _, tc := range []struct { + name string + policy string + expectedValue string + }{ + { + name: "wildcard principal, mfa present true", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:PutObject", + "Resource": "*", + "Condition": { + "Bool": { + "aws:MultiFactorAuthPresent": "true" + } + } + } + ] +} +`, + expectedValue: "true", + }, + { + name: "wildcard AWS principal, mfa present true", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS":"*"}, + "Action": "s3:PutObject", + "Resource": "*", + "Condition": { + "Bool": { + "aws:MultiFactorAuthPresent": "true" + } + } + } + ] +} +`, + expectedValue: "true", + }, + { + name: "wildcard principal, mfa present false", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": "*", + "Action": "s3:GetObject", + "Resource": "*", + "Condition": { + "Bool": { + "aws:MultiFactorAuthPresent": "false" + } + } + } + ] +} +`, + expectedValue: "false", + }, + { + name: "wildcard AWS principal, mfa present false", + policy: ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": {"AWS":"*"}, + "Action": "s3:GetObject", + "Resource": "*", + "Condition": { + "Bool": { + "aws:MultiFactorAuthPresent": "false" + } + } + } + ] +} +`, + expectedValue: "false", + }, + } { + expectedConditions := []chain.Condition{ + { + Op: chain.CondStringEqualsIgnoreCase, + Kind: chain.KindRequest, + Key: s3.PropertyKeyAccessBoxAttrMFA, + Value: tc.expectedValue, + }, + } + + var p s3common.Policy + err := json.Unmarshal([]byte(tc.policy), &p) + require.NoError(t, err) + + s3Chain, err := ConvertToS3Chain(p, newMockUserResolver(nil, nil, "")) + require.NoError(t, err) + require.Len(t, s3Chain.Rules, 1) + require.ElementsMatch(t, expectedConditions, s3Chain.Rules[0].Condition) + + _, err = ConvertToNativeChain(p, newMockUserResolver(nil, nil, "")) + require.ErrorIs(t, err, s3common.ErrActionsNotApplicable) + } +} + +func requireChainRulesMatch(t *testing.T, expected, actual []chain.Rule) { + require.Equal(t, len(expected), len(actual), "length of chain rules differ") + + seen := make(map[int]int) + for i, expRule := range expected { + for j, actRule := range actual { + if _, ok := seen[j]; ok { + continue + } + + if areRulesMatched(expRule, actRule) { + seen[j] = i + break + } + } + } + + require.Len(t, seen, len(expected), "expected unique rules") +} + +func areRulesMatched(rule1, rule2 chain.Rule) bool { + if rule1.Status != rule2.Status || + rule1.Any != rule2.Any || + rule1.Resources.Inverted != rule2.Resources.Inverted || + len(rule1.Resources.Names) != len(rule2.Resources.Names) || + rule1.Actions.Inverted != rule2.Actions.Inverted || + len(rule1.Actions.Names) != len(rule2.Actions.Names) { + return false + } + + seen := make(map[int]struct{}) + for _, name1 := range rule1.Resources.Names { + for j, name2 := range rule2.Resources.Names { + if _, ok := seen[j]; ok { + continue + } + if name1 == name2 { + seen[j] = struct{}{} + break + } + } + } + if len(seen) != len(rule1.Resources.Names) { + return false + } + + seen = make(map[int]struct{}) + for _, name1 := range rule1.Actions.Names { + for j, name2 := range rule2.Actions.Names { + if _, ok := seen[j]; ok { + continue + } + if name1 == name2 { + seen[j] = struct{}{} + break + } + } + } + if len(seen) != len(rule1.Actions.Names) { + return false + } + + seen = make(map[int]struct{}) + for _, cond1 := range rule1.Condition { + for j, cond2 := range rule2.Condition { + if _, ok := seen[j]; ok { + continue + } + if cond1 == cond2 { + seen[j] = struct{}{} + break + } + } + } + + return len(seen) == len(rule1.Condition) +} + +func assertChainsEqual(t *testing.T, chain1, chain2 *chain.Chain) { + require.Equal(t, string(chain1.ID), string(chain2.ID)) + require.Equal(t, chain1.MatchType, chain2.MatchType) + require.Equal(t, len(chain1.Rules), len(chain2.Rules)) + + for i, rule := range chain1.Rules { + require.Equal(t, rule.Any, chain2.Rules[i].Any) + require.Equal(t, rule.Resources.Inverted, chain2.Rules[i].Resources.Inverted) + require.ElementsMatch(t, rule.Resources.Names, chain2.Rules[i].Resources.Names) + require.Equal(t, rule.Status, chain2.Rules[i].Status) + require.ElementsMatch(t, rule.Condition, chain2.Rules[i].Condition) + require.Equal(t, rule.Actions.Inverted, chain2.Rules[i].Actions.Inverted) + require.ElementsMatch(t, rule.Actions.Names, chain2.Rules[i].Actions.Names) + } +} + +func TestProcessDenyFirst(t *testing.T) { + identityBasedPolicyStr := ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "AWS": [ "arn:aws:iam::root:user/user-name" ] + }, + "Action": ["s3:PutObject" ], + "Resource": "arn:aws:s3:::*" + } + ] +} +` + + resourceBasedPolicyStr := ` +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Principal": "*", + "Action": "s3:*", + "Resource": [ "arn:aws:s3:::test-bucket/*" ] + } + ] +} +` + + var identityPolicy s3common.Policy + err := json.Unmarshal([]byte(identityBasedPolicyStr), &identityPolicy) + require.NoError(t, err) + + var resourcePolicy s3common.Policy + err = json.Unmarshal([]byte(resourceBasedPolicyStr), &resourcePolicy) + require.NoError(t, err) + + mockResolver := newMockUserResolver([]string{"root/user-name"}, []string{"test-bucket"}, "") + + identityNativePolicy, err := ConvertToS3Chain(identityPolicy, mockResolver) + require.NoError(t, err) + identityNativePolicy.MatchType = chain.MatchTypeFirstMatch + + resourceNativePolicy, err := ConvertToS3Chain(resourcePolicy, mockResolver) + require.NoError(t, err) + + s := inmemory.NewInMemory() + + target := engine.NamespaceTarget("ns") + + _, _, err = s.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, target, identityNativePolicy) + require.NoError(t, err) + + _, _, err = s.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, target, resourceNativePolicy) + require.NoError(t, err) + + resource := testutil.NewResource("arn:aws:s3:::test-bucket/object", nil) + request := testutil.NewRequest("s3:PutObject", resource, map[string]string{s3.PropertyKeyOwner: mockResolver.users["root/user-name"]}) + + status, found, err := s.IsAllowed(chain.S3, engine.NewRequestTarget("ns", ""), request) + require.NoError(t, err) + require.True(t, found) + require.Equal(t, chain.AccessDenied, status) +}