diff --git a/cmd/frostfs-cli/modules/bearer/create.go b/cmd/frostfs-cli/modules/bearer/create.go index 8a7500db3..d94b39207 100644 --- a/cmd/frostfs-cli/modules/bearer/create.go +++ b/cmd/frostfs-cli/modules/bearer/create.go @@ -15,10 +15,12 @@ import ( eaclSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/eacl" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user" "github.com/spf13/cobra" + "github.com/spf13/viper" ) const ( eaclFlag = "eacl" + apeFlag = "ape" issuedAtFlag = "issued-at" notValidBeforeFlag = "not-valid-before" ownerFlag = "owner" @@ -37,10 +39,17 @@ In this case --` + commonflags.RPC + ` flag should be specified and the epoch in is set to current epoch + n. `, Run: createToken, + PersistentPreRun: func(cmd *cobra.Command, _ []string) { + ff := cmd.Flags() + + _ = viper.BindPFlag(commonflags.WalletPath, ff.Lookup(commonflags.WalletPath)) + _ = viper.BindPFlag(commonflags.Account, ff.Lookup(commonflags.Account)) + }, } func init() { - createCmd.Flags().StringP(eaclFlag, "e", "", "Path to the extended ACL table (mutually exclusive with --impersonate flag)") + createCmd.Flags().StringP(eaclFlag, "e", "", "Path to the extended ACL table (mutually exclusive with --impersonate and --ape flag)") + createCmd.Flags().StringP(apeFlag, "a", "", "Path to the JSON-encoded APE override (mutually exclusive with --impersonate and --eacl flag)") createCmd.Flags().StringP(issuedAtFlag, "i", "+0", "Epoch to issue token at") createCmd.Flags().StringP(notValidBeforeFlag, "n", "+0", "Not valid before epoch") createCmd.Flags().StringP(commonflags.ExpireAt, "x", "", "The last active epoch for the token") @@ -49,10 +58,13 @@ func init() { createCmd.Flags().Bool(jsonFlag, false, "Output token in JSON") createCmd.Flags().Bool(impersonateFlag, false, "Mark token as impersonate to consider the token signer as the request owner (mutually exclusive with --eacl flag)") createCmd.Flags().StringP(commonflags.RPC, commonflags.RPCShorthand, commonflags.RPCDefault, commonflags.RPCUsage) + createCmd.Flags().StringP(commonflags.WalletPath, commonflags.WalletPathShorthand, commonflags.WalletPathDefault, commonflags.WalletPathUsage) + createCmd.Flags().StringP(commonflags.Account, commonflags.AccountShorthand, commonflags.AccountDefault, commonflags.AccountUsage) - createCmd.MarkFlagsMutuallyExclusive(eaclFlag, impersonateFlag) + createCmd.MarkFlagsMutuallyExclusive(eaclFlag, apeFlag, impersonateFlag) _ = cobra.MarkFlagFilename(createCmd.Flags(), eaclFlag) + _ = cobra.MarkFlagFilename(createCmd.Flags(), apeFlag) _ = cobra.MarkFlagRequired(createCmd.Flags(), commonflags.ExpireAt) _ = cobra.MarkFlagRequired(createCmd.Flags(), ownerFlag) @@ -119,6 +131,14 @@ func createToken(cmd *cobra.Command, _ []string) { b.SetEACLTable(*table) } + apePath, _ := cmd.Flags().GetString(apeFlag) + if apePath != "" { + var apeOverride bearer.APEOverride + raw, err := os.ReadFile(apePath) + commonCmd.ExitOnErr(cmd, "can't read APE rules: %w", err) + commonCmd.ExitOnErr(cmd, "can't parse APE rules: %w", json.Unmarshal(raw, &apeOverride)) + b.SetAPEOverride(apeOverride) + } var data []byte toJSON, _ := cmd.Flags().GetBool(jsonFlag) diff --git a/cmd/frostfs-cli/modules/bearer/generate_override.go b/cmd/frostfs-cli/modules/bearer/generate_override.go new file mode 100644 index 000000000..482c0027e --- /dev/null +++ b/cmd/frostfs-cli/modules/bearer/generate_override.go @@ -0,0 +1,115 @@ +package bearer + +import ( + "errors" + "fmt" + "os" + + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/commonflags" + parseutil "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/modules/util" + commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common" + apeSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" + cidSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + apechain "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" + "github.com/spf13/cobra" +) + +var ( + errChainIDCannotBeEmpty = errors.New("chain id cannot be empty") + errRuleIsNotParsed = errors.New("rule is not passed") +) + +const ( + chainIDFlag = "chain-id" + chainIDHexFlag = "chain-id-hex" + ruleFlag = "rule" + pathFlag = "path" + outputFlag = "output" +) + +var generateAPEOverrideCmd = &cobra.Command{ + Use: "generate-ape-override", + Short: "Generate APE override.", + Long: `Generate APE override by target and APE chains. Util command. + +Generated APE override can be dumped to a file in JSON format that is passed to +"create" command. +`, + Run: genereateAPEOverride, +} + +func genereateAPEOverride(cmd *cobra.Command, _ []string) { + c := parseChain(cmd) + + targetCID, _ := cmd.Flags().GetString(commonflags.CIDFlag) + var cid cidSDK.ID + commonCmd.ExitOnErr(cmd, "invalid cid format: %w", cid.DecodeString(targetCID)) + + override := &bearer.APEOverride{ + Target: apeSDK.ChainTarget{ + TargetType: apeSDK.TargetTypeContainer, + Name: targetCID, + }, + Chains: []apeSDK.Chain{ + { + Raw: c.Bytes(), + }, + }, + } + + overrideMarshalled, err := override.MarshalJSON() + commonCmd.ExitOnErr(cmd, "failed to marshal APE override: %w", err) + + outputPath, _ := cmd.Flags().GetString(outputFlag) + if outputPath != "" { + err := os.WriteFile(outputPath, []byte(overrideMarshalled), 0o644) + commonCmd.ExitOnErr(cmd, "dump error: %w", err) + } else { + fmt.Print("\n") + fmt.Println(string(overrideMarshalled)) + } +} + +func init() { + ff := generateAPEOverrideCmd.Flags() + + ff.StringP(commonflags.CIDFlag, "", "", "Target container ID.") + _ = cobra.MarkFlagRequired(createCmd.Flags(), commonflags.CIDFlag) + + ff.StringArray(ruleFlag, []string{}, "Rule statement") + ff.String(pathFlag, "", "Path to encoded chain in JSON or binary format") + ff.String(chainIDFlag, "", "Assign ID to the parsed chain") + ff.Bool(chainIDHexFlag, false, "Flag to parse chain ID as hex") + + ff.String(outputFlag, "", "Output path to dump result JSON-encoded APE override") + _ = cobra.MarkFlagFilename(createCmd.Flags(), outputFlag) +} + +func parseChainID(cmd *cobra.Command) apechain.ID { + chainID, _ := cmd.Flags().GetString(chainIDFlag) + if chainID == "" { + commonCmd.ExitOnErr(cmd, "read chain id error: %w", + errChainIDCannotBeEmpty) + } + return apechain.ID(chainID) +} + +func parseChain(cmd *cobra.Command) *apechain.Chain { + chain := new(apechain.Chain) + + if rules, _ := cmd.Flags().GetStringArray(ruleFlag); len(rules) > 0 { + commonCmd.ExitOnErr(cmd, "parser error: %w", parseutil.ParseAPEChain(chain, rules)) + } else if encPath, _ := cmd.Flags().GetString(pathFlag); encPath != "" { + commonCmd.ExitOnErr(cmd, "decode binary or json error: %w", parseutil.ParseAPEChainBinaryOrJSON(chain, encPath)) + } else { + commonCmd.ExitOnErr(cmd, "parser error: %w", errRuleIsNotParsed) + } + + chain.ID = parseChainID(cmd) + + cmd.Println("Parsed chain:") + parseutil.PrintHumanReadableAPEChain(cmd, chain) + + return chain +} diff --git a/cmd/frostfs-cli/modules/bearer/root.go b/cmd/frostfs-cli/modules/bearer/root.go index 200d096ac..fa6aef6fb 100644 --- a/cmd/frostfs-cli/modules/bearer/root.go +++ b/cmd/frostfs-cli/modules/bearer/root.go @@ -11,4 +11,5 @@ var Cmd = &cobra.Command{ func init() { Cmd.AddCommand(createCmd) + Cmd.AddCommand(generateAPEOverrideCmd) } diff --git a/cmd/frostfs-node/object.go b/cmd/frostfs-node/object.go index 9f34896bc..b035895a0 100644 --- a/cmd/frostfs-node/object.go +++ b/cmd/frostfs-node/object.go @@ -473,6 +473,7 @@ func createAPEService(c *cfg, splitSvc *objectService.TransportSplitter) *object objectAPE.NewStorageEngineHeaderProvider(c.cfgObject.cfgLocalStorage.localStorage, c.cfgObject.getSvc), c.shared.frostfsidClient, c.netMapSource, + c.cfgNetmap.state, c.cfgObject.cnrSource, c.binPublicKey, ), diff --git a/cmd/frostfs-node/tree.go b/cmd/frostfs-node/tree.go index 49ecb6fdc..daaaa64a2 100644 --- a/cmd/frostfs-node/tree.go +++ b/cmd/frostfs-node/tree.go @@ -66,6 +66,7 @@ func initTreeService(c *cfg) { tree.WithAuthorizedKeys(treeConfig.AuthorizedKeys()), tree.WithMetrics(c.metricsCollector.TreeService()), tree.WithAPERouter(c.cfgObject.cfgAccessPolicyEngine.accessPolicyEngine), + tree.WithNetmapState(c.cfgNetmap.state), ) c.cfgGRPC.performAndSave(func(_ string, _ net.Listener, s *grpc.Server) { diff --git a/pkg/ape/router/single_pass.go b/pkg/ape/router/single_pass.go new file mode 100644 index 000000000..c0c78db76 --- /dev/null +++ b/pkg/ape/router/single_pass.go @@ -0,0 +1,55 @@ +package router + +import ( + "fmt" + + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" + cidSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + apechain "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" +) + +func newTarget(ct ape.ChainTarget) (engine.Target, error) { + var target engine.Target + switch ct.TargetType { + case ape.TargetTypeContainer: + var cid cidSDK.ID + err := cid.DecodeString(ct.Name) + if err != nil { + return target, fmt.Errorf("invalid cid format: %s", target.Name) + } + target.Type = engine.Container + case ape.TargetTypeGroup: + target.Type = engine.Group + case ape.TargetTypeNamespace: + target.Type = engine.Namespace + case ape.TargetTypeUser: + target.Type = engine.User + default: + return target, fmt.Errorf("unsupported target type: %v", ct.TargetType) + } + target.Name = ct.Name + return target, nil +} + +// SingleUseRouterWithBearerTokenChains creates chain router with inmemory storage implementation and +// fed with APE chains defined in Bearer token. +func SingleUseRouterWithBearerTokenChains(overrides []bearer.APEOverride) (engine.ChainRouter, error) { + storage := inmemory.NewInmemoryMorphRuleChainStorage() + for _, override := range overrides { + target, err := newTarget(override.Target) + if err != nil { + return nil, err + } + for i := range override.Chains { + chain := new(apechain.Chain) + if err := chain.DecodeBytes(override.Chains[i].Raw); err != nil { + return nil, fmt.Errorf("invalid ape chain: %w", err) + } + _, _, _ = storage.AddMorphRuleChain(apechain.Ingress, target, chain) + } + } + return engine.NewDefaultChainRouter(storage), nil +} diff --git a/pkg/services/object/acl/v2/service.go b/pkg/services/object/acl/v2/service.go index 1f1c275cf..3e128836f 100644 --- a/pkg/services/object/acl/v2/service.go +++ b/pkg/services/object/acl/v2/service.go @@ -118,6 +118,7 @@ func (w *wrappedGetObjectStream) Context() context.Context { SenderKey: w.requestInfo.SenderKey(), Role: w.requestInfo.RequestRole(), SoftAPECheck: w.requestInfo.IsSoftAPECheck(), + BearerToken: w.requestInfo.Bearer(), }) } @@ -143,6 +144,7 @@ func (w *wrappedRangeStream) Context() context.Context { SenderKey: w.requestInfo.SenderKey(), Role: w.requestInfo.RequestRole(), SoftAPECheck: w.requestInfo.IsSoftAPECheck(), + BearerToken: w.requestInfo.Bearer(), }) } @@ -168,6 +170,7 @@ func (w *wrappedSearchStream) Context() context.Context { SenderKey: w.requestInfo.SenderKey(), Role: w.requestInfo.RequestRole(), SoftAPECheck: w.requestInfo.IsSoftAPECheck(), + BearerToken: w.requestInfo.Bearer(), }) } @@ -479,6 +482,7 @@ func requestContext(ctx context.Context, reqInfo RequestInfo) context.Context { SenderKey: reqInfo.SenderKey(), Role: reqInfo.RequestRole(), SoftAPECheck: reqInfo.IsSoftAPECheck(), + BearerToken: reqInfo.Bearer(), }) } diff --git a/pkg/services/object/ape/checker.go b/pkg/services/object/ape/checker.go index c39f922f7..ee71e6e1d 100644 --- a/pkg/services/object/ape/checker.go +++ b/pkg/services/object/ape/checker.go @@ -2,13 +2,17 @@ package ape import ( "context" + "crypto/ecdsa" "errors" "fmt" objectV2 "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/router" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container" frostfsidcore "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/frostfsid" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/netmap" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user" @@ -23,16 +27,18 @@ type checkerImpl struct { headerProvider HeaderProvider frostFSIDClient frostfsidcore.SubjectProvider nm netmap.Source + st netmap.State cnrSource container.Source nodePK []byte } -func NewChecker(chainRouter policyengine.ChainRouter, headerProvider HeaderProvider, frostFSIDClient frostfsidcore.SubjectProvider, nm netmap.Source, cnrSource container.Source, nodePK []byte) Checker { +func NewChecker(chainRouter policyengine.ChainRouter, headerProvider HeaderProvider, frostFSIDClient frostfsidcore.SubjectProvider, nm netmap.Source, st netmap.State, cnrSource container.Source, nodePK []byte) Checker { return &checkerImpl{ chainRouter: chainRouter, headerProvider: headerProvider, frostFSIDClient: frostFSIDClient, nm: nm, + st: st, cnrSource: cnrSource, nodePK: nodePK, } @@ -67,9 +73,69 @@ type Prm struct { // If true, object headers will not retrieved from storage engine. WithoutHeaderRequest bool + + // The request's bearer token. It is used in order to check APE overrides with the token. + BearerToken *bearer.Token } -var errMissingOID = errors.New("object ID is not set") +var ( + errMissingOID = errors.New("object ID is not set") + errInvalidTargetType = errors.New("bearer token defines non-container target override") + errBearerExpired = errors.New("bearer token has expired") + errBearerInvalidSignature = errors.New("bearer token has invalid signature") + errBearerInvalidContainerID = errors.New("bearer token was created for another container") + errBearerNotSignedByOwner = errors.New("bearer token is not signed by the container owner") + errBearerInvalidOwner = errors.New("bearer token owner differs from the request sender") +) + +// isValidBearer checks whether bearer token was correctly signed by authorized +// entity. This method might be defined on whole ACL service because it will +// require fetching current epoch to check lifetime. +func isValidBearer(token *bearer.Token, ownerCnr user.ID, containerID cid.ID, publicKey *keys.PublicKey, st netmap.State) error { + if token == nil { + return nil + } + + // 1. First check token lifetime. Simplest verification. + if token.InvalidAt(st.CurrentEpoch()) { + return errBearerExpired + } + + // 2. Then check if bearer token is signed correctly. + if !token.VerifySignature() { + return errBearerInvalidSignature + } + + // 3. Then check if container is either empty or equal to the container in the request. + apeOverride := token.APEOverride() + if apeOverride.Target.TargetType != ape.TargetTypeContainer { + return errInvalidTargetType + } + + var targetCnr cid.ID + err := targetCnr.DecodeString(apeOverride.Target.Name) + if err != nil { + return fmt.Errorf("invalid cid format: %s", apeOverride.Target.Name) + } + if !containerID.Equals(targetCnr) { + return errBearerInvalidContainerID + } + + // 4. Then check if container owner signed this token. + if !bearer.ResolveIssuer(*token).Equals(ownerCnr) { + return errBearerNotSignedByOwner + } + + // 5. Then check if request sender has rights to use this token. + var usrSender user.ID + user.IDFromKey(&usrSender, (ecdsa.PublicKey)(*publicKey)) + + if !token.AssertUser(usrSender) { + return errBearerInvalidOwner + } + + return nil +} // CheckAPE checks if a request or a response is permitted creating an ape request and passing // it to chain router. @@ -99,6 +165,26 @@ func (c *checkerImpl) CheckAPE(ctx context.Context, prm Prm) error { return err } + if prm.BearerToken != nil && !prm.BearerToken.Impersonate() { + if err := isValidBearer(prm.BearerToken, prm.ContainerOwner, prm.Container, pub, c.st); err != nil { + return fmt.Errorf("bearer token validation error: %w", err) + } + btRouter, err := router.SingleUseRouterWithBearerTokenChains([]bearer.APEOverride{prm.BearerToken.APEOverride()}) + if err != nil { + return err + } + status, found, err := btRouter.IsAllowed(apechain.Ingress, policyengine.NewRequestTargetWithContainer(prm.Container.EncodeToString()), r) + if err != nil { + return err + } + if found && status == apechain.Allow { + return nil + } + if status != apechain.NoRuleFound { + return fmt.Errorf("bearer token: method %s: %s", prm.Method, status) + } + } + rt := policyengine.NewRequestTargetExtended(prm.Namespace, prm.Container.EncodeToString(), fmt.Sprintf("%s:%s", prm.Namespace, pub.Address()), nil) status, ruleFound, err := c.chainRouter.IsAllowed(apechain.Ingress, rt, r) if err != nil { diff --git a/pkg/services/object/ape/checker_test.go b/pkg/services/object/ape/checker_test.go index ddb336127..09954602c 100644 --- a/pkg/services/object/ape/checker_test.go +++ b/pkg/services/object/ape/checker_test.go @@ -2,6 +2,7 @@ package ape import ( "context" + "crypto/ecdsa" "encoding/hex" "errors" "fmt" @@ -12,6 +13,8 @@ import ( "git.frostfs.info/TrueCloudLab/frostfs-contract/frostfsid/client" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container" frostfsidcore "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/frostfsid" + apeSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/checksum" containerSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" @@ -228,170 +231,249 @@ func (f *frostfsIDProviderMock) GetSubjectExtended(key util.Uint160) (*client.Su return v, nil } +var apeCheckTestCases = []struct { + name string + container string + object *string + methods []string + header testHeader + containerRules []chain.Rule + expectAPEErr bool +}{ + { + name: "oid required requests are allowed", + container: containerID, + object: stringPtr(objectID), + methods: methodsRequiredOID, + containerRules: []chain.Rule{ + { + Status: chain.Allow, + Actions: chain.Actions{Names: methodsRequiredOID}, + Resources: chain.Resources{ + Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, + }, + }, + }, + }, + { + name: "oid optional requests are allowed", + container: containerID, + methods: methodsOptionalOID, + containerRules: []chain.Rule{ + { + Status: chain.Allow, + Actions: chain.Actions{Names: methodsOptionalOID}, + Resources: chain.Resources{ + Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)}, + }, + }, + }, + }, + { + name: "oid required requests are denied", + container: containerID, + object: stringPtr(objectID), + methods: methodsRequiredOID, + containerRules: []chain.Rule{ + { + Status: chain.AccessDenied, + Actions: chain.Actions{Names: methodsRequiredOID}, + Resources: chain.Resources{ + Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, + }, + }, + }, + expectAPEErr: true, + }, + { + name: "oid required requests are denied by an attribute", + container: containerID, + object: stringPtr(objectID), + methods: methodsRequiredOID, + header: testHeader{ + headerObjSDK: &headerObjectSDKParams{ + attributes: []struct { + key string + val string + }{ + { + key: "attr1", + val: "attribute_value", + }, + }, + }, + fromHeaderProvider: true, + }, + containerRules: []chain.Rule{ + { + Status: chain.AccessDenied, + Actions: chain.Actions{Names: methodsRequiredOID}, + Resources: chain.Resources{ + Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, + }, + Any: true, + Condition: []chain.Condition{ + { + Op: chain.CondStringLike, + Kind: chain.KindResource, + Key: "attr1", + Value: "attribute*", + }, + }, + }, + }, + expectAPEErr: true, + }, + { + name: "oid required requests are denied by sender", + container: containerID, + object: stringPtr(objectID), + methods: methodsRequiredOID, + header: testHeader{ + headerObjSDK: &headerObjectSDKParams{ + attributes: []struct { + key string + val string + }{ + { + key: "attr1", + val: "attribute_value", + }, + }, + }, + fromHeaderProvider: true, + }, + containerRules: []chain.Rule{ + { + Status: chain.AccessDenied, + Actions: chain.Actions{Names: methodsRequiredOID}, + Resources: chain.Resources{ + Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, + }, + Any: true, + Condition: []chain.Condition{ + { + Op: chain.CondStringLike, + Kind: chain.KindRequest, + Key: nativeschema.PropertyKeyActorPublicKey, + Value: senderKey, + }, + }, + }, + }, + expectAPEErr: true, + }, + { + name: "optional oid requests reached quota limit by an attribute", + container: containerID, + methods: methodsOptionalOID, + header: testHeader{ + headerObjSDK: &headerObjectSDKParams{ + payloadSize: 1000, + }, + fromRequestResponseHeader: true, + }, + containerRules: []chain.Rule{ + { + Status: chain.QuotaLimitReached, + Actions: chain.Actions{Names: methodsOptionalOID}, + Resources: chain.Resources{ + Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)}, + }, + Any: true, + Condition: []chain.Condition{ + { + Op: chain.CondStringEquals, + Kind: chain.KindResource, + Key: nativeschema.PropertyKeyObjectPayloadLength, + Value: "1000", + }, + }, + }, + }, + expectAPEErr: true, + }, +} + +type stMock struct{} + +func (m *stMock) CurrentEpoch() uint64 { + return 8 +} + +func TestAPECheck_BearerTokenOverrides(t *testing.T) { + for _, test := range apeCheckTestCases { + t.Run(test.name, func(t *testing.T) { + chain := chain.Chain{ + Rules: test.containerRules, + MatchType: chain.MatchTypeFirstMatch, + } + chainSDK := apeSDK.Chain{ + Raw: chain.Bytes(), + } + bt := new(bearer.Token) + bt.SetIat(1) + bt.SetExp(10) + bt.SetAPEOverride(bearer.APEOverride{ + Target: apeSDK.ChainTarget{ + TargetType: apeSDK.TargetTypeContainer, + Name: test.container, + }, + Chains: []apeSDK.Chain{chainSDK}, + }) + bt.Sign(senderPrivateKey.PrivateKey) + var cnrOwner user.ID + user.IDFromKey(&cnrOwner, (ecdsa.PublicKey)(*senderPrivateKey.PublicKey())) + + for _, method := range test.methods { + t.Run(method, func(t *testing.T) { + headerProvider := newHeaderProviderMock() + frostfsidProvider := newFrostfsIDProviderMock(t) + + cnr := newContainerIDSDK(t, test.container) + obj := newObjectIDSDK(t, test.object) + + ls := inmemory.NewInmemoryLocalStorage() + ms := inmemory.NewInmemoryMorphRuleChainStorage() + r := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls) + + checker := NewChecker(r, headerProvider, frostfsidProvider, nil, &stMock{}, nil, nil) + + prm := Prm{ + Method: method, + Container: cnr, + Object: obj, + Role: role, + ContainerOwner: cnrOwner, + SenderKey: senderKey, + BearerToken: bt, + } + + var headerObjSDK *objectSDK.Object + if test.header.headerObjSDK != nil { + headerObjSDK = newHeaderObjectSDK(cnr, obj, test.header.headerObjSDK) + if test.header.fromHeaderProvider { + require.NotNil(t, obj, "oid is required if a header is expected to be found in header provider") + headerProvider.addHeader(cnr, *obj, headerObjSDK) + } else if test.header.fromRequestResponseHeader { + prm.Header = headerObjSDK.ToV2().GetHeader() + } + } + + err := checker.CheckAPE(context.Background(), prm) + if test.expectAPEErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } + }) + } +} + func TestAPECheck(t *testing.T) { - for _, test := range []struct { - name string - container string - object *string - methods []string - header testHeader - containerRules []chain.Rule - expectAPEErr bool - }{ - { - name: "oid required requests are allowed", - container: containerID, - object: stringPtr(objectID), - methods: methodsRequiredOID, - containerRules: []chain.Rule{ - { - Status: chain.Allow, - Actions: chain.Actions{Names: methodsRequiredOID}, - Resources: chain.Resources{ - Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, - }, - }, - }, - }, - { - name: "oid optional requests are allowed", - container: containerID, - methods: methodsOptionalOID, - containerRules: []chain.Rule{ - { - Status: chain.Allow, - Actions: chain.Actions{Names: methodsOptionalOID}, - Resources: chain.Resources{ - Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)}, - }, - }, - }, - }, - { - name: "oid required requests are denied", - container: containerID, - object: stringPtr(objectID), - methods: methodsRequiredOID, - containerRules: []chain.Rule{ - { - Status: chain.AccessDenied, - Actions: chain.Actions{Names: methodsRequiredOID}, - Resources: chain.Resources{ - Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, - }, - }, - }, - expectAPEErr: true, - }, - { - name: "oid required requests are denied by an attribute", - container: containerID, - object: stringPtr(objectID), - methods: methodsRequiredOID, - header: testHeader{ - headerObjSDK: &headerObjectSDKParams{ - attributes: []struct { - key string - val string - }{ - { - key: "attr1", - val: "attribute_value", - }, - }, - }, - fromHeaderProvider: true, - }, - containerRules: []chain.Rule{ - { - Status: chain.AccessDenied, - Actions: chain.Actions{Names: methodsRequiredOID}, - Resources: chain.Resources{ - Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, - }, - Any: true, - Condition: []chain.Condition{ - { - Op: chain.CondStringLike, - Kind: chain.KindResource, - Key: "attr1", - Value: "attribute*", - }, - }, - }, - }, - expectAPEErr: true, - }, - { - name: "oid required requests are denied by sender", - container: containerID, - object: stringPtr(objectID), - methods: methodsRequiredOID, - header: testHeader{ - headerObjSDK: &headerObjectSDKParams{ - attributes: []struct { - key string - val string - }{ - { - key: "attr1", - val: "attribute_value", - }, - }, - }, - fromHeaderProvider: true, - }, - containerRules: []chain.Rule{ - { - Status: chain.AccessDenied, - Actions: chain.Actions{Names: methodsRequiredOID}, - Resources: chain.Resources{ - Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObject, containerID, objectID)}, - }, - Any: true, - Condition: []chain.Condition{ - { - Op: chain.CondStringLike, - Kind: chain.KindRequest, - Key: nativeschema.PropertyKeyActorPublicKey, - Value: senderKey, - }, - }, - }, - }, - expectAPEErr: true, - }, - { - name: "optional oid requests reached quota limit by an attribute", - container: containerID, - methods: methodsOptionalOID, - header: testHeader{ - headerObjSDK: &headerObjectSDKParams{ - payloadSize: 1000, - }, - fromRequestResponseHeader: true, - }, - containerRules: []chain.Rule{ - { - Status: chain.QuotaLimitReached, - Actions: chain.Actions{Names: methodsOptionalOID}, - Resources: chain.Resources{ - Names: []string{fmt.Sprintf(nativeschema.ResourceFormatRootContainerObjects, containerID)}, - }, - Any: true, - Condition: []chain.Condition{ - { - Op: chain.CondStringEquals, - Kind: chain.KindResource, - Key: nativeschema.PropertyKeyObjectPayloadLength, - Value: "1000", - }, - }, - }, - }, - expectAPEErr: true, - }, - } { + for _, test := range apeCheckTestCases { t.Run(test.name, func(t *testing.T) { for _, method := range test.methods { t.Run(method, func(t *testing.T) { @@ -411,7 +493,7 @@ func TestAPECheck(t *testing.T) { router := policyengine.NewDefaultChainRouterWithLocalOverrides(ms, ls) - checker := NewChecker(router, headerProvider, frostfsidProvider, nil, nil, nil) + checker := NewChecker(router, headerProvider, frostfsidProvider, nil, &stMock{}, nil, nil) prm := Prm{ Method: method, @@ -544,7 +626,7 @@ func TestPutECChunk(t *testing.T) { }, } - checker := NewChecker(router, headerProvider, frostfsidProvider, nm, cs, node1Key.PublicKey().Bytes()) + checker := NewChecker(router, headerProvider, frostfsidProvider, nm, &stMock{}, cs, node1Key.PublicKey().Bytes()) ecParentID := oidtest.ID() chunkHeader := newHeaderObjectSDK(cnr, obj, nil).ToV2().GetHeader() @@ -586,7 +668,7 @@ func TestPutECChunk(t *testing.T) { t.Run("access allowed for non container node", func(t *testing.T) { otherKey, err := keys.NewPrivateKey() require.NoError(t, err) - checker = NewChecker(router, headerProvider, frostfsidProvider, nm, cs, otherKey.PublicKey().Bytes()) + checker = NewChecker(router, headerProvider, frostfsidProvider, nm, &stMock{}, cs, otherKey.PublicKey().Bytes()) prm := Prm{ Method: nativeschema.MethodPutObject, Container: cnr, diff --git a/pkg/services/object/ape/service.go b/pkg/services/object/ape/service.go index 63c2a9909..609b117d0 100644 --- a/pkg/services/object/ape/service.go +++ b/pkg/services/object/ape/service.go @@ -13,6 +13,7 @@ import ( getsvc "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/get" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/object/util" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/util/logger" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" @@ -88,6 +89,8 @@ type getStreamBasicChecker struct { role string softAPECheck bool + + bearerToken *bearer.Token } func (g *getStreamBasicChecker) Send(resp *objectV2.GetResponse) error { @@ -107,6 +110,7 @@ func (g *getStreamBasicChecker) Send(resp *objectV2.GetResponse) error { ContainerOwner: g.containerOwner, Role: g.role, SoftAPECheck: g.softAPECheck, + BearerToken: g.bearerToken, } if err := g.apeChecker.CheckAPE(g.Context(), prm); err != nil { @@ -149,6 +153,7 @@ func (c *Service) Get(request *objectV2.GetRequest, stream objectSvc.GetObjectSt ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, WithoutHeaderRequest: true, + BearerToken: reqCtx.BearerToken, }) if err != nil { return toStatusErr(err) @@ -161,6 +166,7 @@ func (c *Service) Get(request *objectV2.GetRequest, stream objectSvc.GetObjectSt senderKey: reqCtx.SenderKey, role: nativeSchemaRole(reqCtx.Role), softAPECheck: reqCtx.SoftAPECheck, + bearerToken: reqCtx.BearerToken, }) } @@ -192,6 +198,7 @@ func (p *putStreamBasicChecker) Send(ctx context.Context, request *objectV2.PutR ContainerOwner: reqCtx.ContainerOwner, Role: nativeSchemaRole(reqCtx.Role), SoftAPECheck: reqCtx.SoftAPECheck, + BearerToken: reqCtx.BearerToken, } if err := p.apeChecker.CheckAPE(ctx, prm); err != nil { @@ -236,6 +243,7 @@ func (c *Service) Head(ctx context.Context, request *objectV2.HeadRequest) (*obj ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, WithoutHeaderRequest: true, + BearerToken: reqCtx.BearerToken, }) if err != nil { return nil, toStatusErr(err) @@ -275,6 +283,7 @@ func (c *Service) Head(ctx context.Context, request *objectV2.HeadRequest) (*obj SenderKey: hex.EncodeToString(reqCtx.SenderKey), ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, + BearerToken: reqCtx.BearerToken, }) if err != nil { return nil, toStatusErr(err) @@ -303,6 +312,7 @@ func (c *Service) Search(request *objectV2.SearchRequest, stream objectSvc.Searc SenderKey: hex.EncodeToString(reqCtx.SenderKey), ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, + BearerToken: reqCtx.BearerToken, }) if err != nil { return toStatusErr(err) @@ -331,6 +341,7 @@ func (c *Service) Delete(ctx context.Context, request *objectV2.DeleteRequest) ( SenderKey: hex.EncodeToString(reqCtx.SenderKey), ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, + BearerToken: reqCtx.BearerToken, }) if err != nil { return nil, toStatusErr(err) @@ -364,6 +375,7 @@ func (c *Service) GetRange(request *objectV2.GetRangeRequest, stream objectSvc.G SenderKey: hex.EncodeToString(reqCtx.SenderKey), ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, + BearerToken: reqCtx.BearerToken, }) if err != nil { return toStatusErr(err) @@ -392,6 +404,7 @@ func (c *Service) GetRangeHash(ctx context.Context, request *objectV2.GetRangeHa SenderKey: hex.EncodeToString(reqCtx.SenderKey), ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, + BearerToken: reqCtx.BearerToken, } if err = c.apeChecker.CheckAPE(ctx, prm); err != nil { @@ -430,6 +443,7 @@ func (c *Service) PutSingle(ctx context.Context, request *objectV2.PutSingleRequ SenderKey: hex.EncodeToString(reqCtx.SenderKey), ContainerOwner: reqCtx.ContainerOwner, SoftAPECheck: reqCtx.SoftAPECheck, + BearerToken: reqCtx.BearerToken, } if err = c.apeChecker.CheckAPE(ctx, prm); err != nil { diff --git a/pkg/services/object/request_context.go b/pkg/services/object/request_context.go index 6a0965b40..95d4c9d93 100644 --- a/pkg/services/object/request_context.go +++ b/pkg/services/object/request_context.go @@ -1,6 +1,7 @@ package object import ( + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/acl" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user" ) @@ -20,4 +21,6 @@ type RequestContext struct { Role acl.Role SoftAPECheck bool + + BearerToken *bearer.Token } diff --git a/pkg/services/tree/ape.go b/pkg/services/tree/ape.go index eabc02bd7..475567c5f 100644 --- a/pkg/services/tree/ape.go +++ b/pkg/services/tree/ape.go @@ -2,18 +2,25 @@ package tree import ( "context" + "crypto/ecdsa" "encoding/hex" + "errors" "fmt" "net" "strings" "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/converter" aperequest "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/request" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/router" core "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/container" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/core/netmap" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/ape" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/bearer" apistatus "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/client/status" cnrSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container" "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/acl" cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/user" apechain "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine" commonschema "git.frostfs.info/TrueCloudLab/policy-engine/schema/common" @@ -22,20 +29,25 @@ import ( "google.golang.org/grpc/peer" ) -func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid cid.ID, operation acl.Op, role acl.Role, publicKey *keys.PublicKey) error { - namespace := "" - cntNamespace, hasNamespace := strings.CutSuffix(cnrSDK.ReadDomain(container.Value).Zone(), ".ns") - if hasNamespace { - namespace = cntNamespace - } +var ( + errInvalidTargetType = errors.New("bearer token defines non-container target override") + errBearerExpired = errors.New("bearer token has expired") + errBearerInvalidSignature = errors.New("bearer token has invalid signature") + errBearerInvalidContainerID = errors.New("bearer token was created for another container") + errBearerNotSignedByOwner = errors.New("bearer token is not signed by the container owner") + errBearerInvalidOwner = errors.New("bearer token owner differs from the request sender") +) +func (s *Service) newAPERequest(ctx context.Context, namespace string, + cid cid.ID, operation acl.Op, role acl.Role, publicKey *keys.PublicKey, +) (aperequest.Request, error) { schemaMethod, err := converter.SchemaMethodFromACLOperation(operation) if err != nil { - return apeErr(err) + return aperequest.Request{}, err } schemaRole, err := converter.SchemaRoleFromACLRole(role) if err != nil { - return apeErr(err) + return aperequest.Request{}, err } reqProps := map[string]string{ nativeschema.PropertyKeyActorPublicKey: hex.EncodeToString(publicKey.Bytes()), @@ -43,7 +55,7 @@ func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid c } reqProps, err = s.fillWithUserClaimTags(reqProps, publicKey) if err != nil { - return apeErr(err) + return aperequest.Request{}, err } if p, ok := peer.FromContext(ctx); ok { if tcpAddr, ok := p.Addr.(*net.TCPAddr); ok { @@ -58,11 +70,96 @@ func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid c resourceName = fmt.Sprintf(nativeschema.ResourceFormatNamespaceContainerObjects, namespace, cid.EncodeToString()) } - request := aperequest.NewRequest( + return aperequest.NewRequest( schemaMethod, aperequest.NewResource(resourceName, make(map[string]string)), reqProps, - ) + ), nil +} + +// isValidBearer checks whether bearer token was correctly signed by authorized +// entity. This method might be defined on whole ACL service because it will +// require fetching current epoch to check lifetime. +func isValidBearer(token *bearer.Token, ownerCnr user.ID, cntID cid.ID, publicKey *keys.PublicKey, st netmap.State) error { + if token == nil { + return nil + } + + // 1. First check token lifetime. Simplest verification. + if token.InvalidAt(st.CurrentEpoch()) { + return errBearerExpired + } + + // 2. Then check if bearer token is signed correctly. + if !token.VerifySignature() { + return errBearerInvalidSignature + } + + // 3. Then check if container is either empty or equal to the container in the request. + apeOverride := token.APEOverride() + if apeOverride.Target.TargetType != ape.TargetTypeContainer { + return errInvalidTargetType + } + + var targetCnr cid.ID + err := targetCnr.DecodeString(apeOverride.Target.Name) + if err != nil { + return fmt.Errorf("invalid cid format: %s", apeOverride.Target.Name) + } + if !cntID.Equals(targetCnr) { + return errBearerInvalidContainerID + } + + // 4. Then check if container owner signed this token. + if !bearer.ResolveIssuer(*token).Equals(ownerCnr) { + return errBearerNotSignedByOwner + } + + // 5. Then check if request sender has rights to use this token. + var usrSender user.ID + user.IDFromKey(&usrSender, (ecdsa.PublicKey)(*publicKey)) + + if !token.AssertUser(usrSender) { + return errBearerInvalidOwner + } + + return nil +} + +func (s *Service) checkAPE(ctx context.Context, bt *bearer.Token, + container *core.Container, cid cid.ID, operation acl.Op, role acl.Role, publicKey *keys.PublicKey, +) error { + namespace := "" + cntNamespace, hasNamespace := strings.CutSuffix(cnrSDK.ReadDomain(container.Value).Zone(), ".ns") + if hasNamespace { + namespace = cntNamespace + } + + request, err := s.newAPERequest(ctx, namespace, cid, operation, role, publicKey) + if err != nil { + return apeErr(err) + } + + if bt != nil && !bt.Impersonate() { + if err := isValidBearer(bt, container.Value.Owner(), cid, publicKey, s.state); err != nil { + return fmt.Errorf("bearer validation error: %w", err) + } + btRouter, err := router.SingleUseRouterWithBearerTokenChains([]bearer.APEOverride{bt.APEOverride()}) + if err != nil { + return apeErr(err) + } + status, found, err := btRouter.IsAllowed(apechain.Ingress, engine.NewRequestTargetWithContainer(cid.EncodeToString()), request) + if err != nil { + return apeErr(err) + } + if found && status == apechain.Allow { + return nil + } + if status != apechain.NoRuleFound { + err = fmt.Errorf("access to operation %s is denied by access policy engine (bearer token): %s", request.Operation(), status.String()) + return apeErr(err) + } + } rt := engine.NewRequestTargetExtended(namespace, cid.EncodeToString(), fmt.Sprintf("%s:%s", namespace, publicKey.Address()), nil) status, found, err := s.router.IsAllowed(apechain.Ingress, rt, request) @@ -72,7 +169,7 @@ func (s *Service) checkAPE(ctx context.Context, container *core.Container, cid c if found && status == apechain.Allow { return nil } - err = fmt.Errorf("access to operation %s is denied by access policy engine: %s", schemaMethod, status.String()) + err = fmt.Errorf("access to operation %s is denied by access policy engine: %s", request.Operation(), status.String()) return apeErr(err) } diff --git a/pkg/services/tree/options.go b/pkg/services/tree/options.go index a99ba6046..ea5539938 100644 --- a/pkg/services/tree/options.go +++ b/pkg/services/tree/options.go @@ -29,6 +29,7 @@ type cfg struct { log *logger.Logger key *ecdsa.PrivateKey rawPub []byte + state netmap.State nmSource netmap.Source cnrSource ContainerSource frostfsidSubjectProvider frostfsidcore.SubjectProvider @@ -156,3 +157,9 @@ func WithAPERouter(router policyengine.ChainRouter) Option { c.router = router } } + +func WithNetmapState(state netmap.State) Option { + return func(c *cfg) { + c.state = state + } +} diff --git a/pkg/services/tree/signature.go b/pkg/services/tree/signature.go index 0445ccfab..58cab659f 100644 --- a/pkg/services/tree/signature.go +++ b/pkg/services/tree/signature.go @@ -84,7 +84,7 @@ func (s *Service) verifyClient(ctx context.Context, req message, cid cidSDK.ID, // FIXME(@aarifullin): tree service temporiraly performs APE checks on // object verbs, because tree verbs have not been introduced yet. if basicACL == 0x0 { - return s.checkAPE(ctx, cnr, cid, op, role, pubKey) + return s.checkAPE(ctx, bt, cnr, cid, op, role, pubKey) } if !basicACL.IsOpAllowed(op, role) {