From 66848d32888fda0eb6a37ef2b7c02dd9f54db124 Mon Sep 17 00:00:00 2001 From: Airat Arifullin Date: Tue, 31 Oct 2023 11:55:42 +0300 Subject: [PATCH] [#770] cli: Add methods to work with APE rules via control svc * Add methods to frostfs-cli * Implement rpc in control service Signed-off-by: Airat Arifullin --- cmd/frostfs-cli/modules/control/add_rule.go | 90 +++++++++ cmd/frostfs-cli/modules/control/get_rule.go | 69 +++++++ cmd/frostfs-cli/modules/control/list_rules.go | 72 +++++++ .../modules/control/remove_rule.go | 72 +++++++ cmd/frostfs-cli/modules/control/root.go | 8 + cmd/frostfs-cli/modules/util/ape.go | 179 ++++++++++++++++++ cmd/frostfs-cli/modules/util/ape_test.go | 129 +++++++++++++ pkg/services/control/rpc.go | 56 ++++++ pkg/services/control/server/policy_engine.go | 140 +++++++++++++- pkg/services/control/server/server.go | 6 + pkg/services/object/acl/v2/service.go | 4 - 11 files changed, 813 insertions(+), 12 deletions(-) create mode 100644 cmd/frostfs-cli/modules/control/add_rule.go create mode 100644 cmd/frostfs-cli/modules/control/get_rule.go create mode 100644 cmd/frostfs-cli/modules/control/list_rules.go create mode 100644 cmd/frostfs-cli/modules/control/remove_rule.go create mode 100644 cmd/frostfs-cli/modules/util/ape.go create mode 100644 cmd/frostfs-cli/modules/util/ape_test.go diff --git a/cmd/frostfs-cli/modules/control/add_rule.go b/cmd/frostfs-cli/modules/control/add_rule.go new file mode 100644 index 000000000..0c6732108 --- /dev/null +++ b/cmd/frostfs-cli/modules/control/add_rule.go @@ -0,0 +1,90 @@ +package control + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + + "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/client" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/commonflags" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/key" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/modules/util" + commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/control" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + ape "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/spf13/cobra" +) + +const ( + ruleFlag = "rule" +) + +var addRuleCmd = &cobra.Command{ + Use: "add-rule", + Short: "Add local override", + Long: "Add local APE rule to a node with following format:\n[:action_detail] [ ...] ", + Example: `allow Object.Get * +deny Object.Get EbxzAdz5LB4uqxuz6crWKAumBNtZyK2rKsqQP7TdZvwr/* +deny:QuotaLimitReached Object.Put Object.Resource:Department=HR * +`, + Run: addRule, +} + +func prettyJSONFormat(cmd *cobra.Command, serializedChain []byte) string { + wr := bytes.NewBufferString("") + err := json.Indent(wr, serializedChain, "", " ") + commonCmd.ExitOnErr(cmd, "%w", err) + return wr.String() +} + +func addRule(cmd *cobra.Command, _ []string) { + pk := key.Get(cmd) + + var cnr cid.ID + cidStr, _ := cmd.Flags().GetString(commonflags.CIDFlag) + commonCmd.ExitOnErr(cmd, "can't decode container ID: %w", cnr.DecodeString(cidStr)) + + rawCID := make([]byte, sha256.Size) + cnr.Encode(rawCID) + + rule, _ := cmd.Flags().GetString(ruleFlag) + + chain := new(ape.Chain) + commonCmd.ExitOnErr(cmd, "parser error: %w", util.ParseAPEChain(chain, []string{rule})) + serializedChain := chain.Bytes() + + cmd.Println("Container ID: " + cidStr) + cmd.Println("Parsed chain:\n" + prettyJSONFormat(cmd, serializedChain)) + + req := &control.AddChainLocalOverrideRequest{ + Body: &control.AddChainLocalOverrideRequest_Body{ + ContainerId: rawCID, + Chain: serializedChain, + }, + } + + signRequest(cmd, pk, req) + + cli := getClient(cmd, pk) + + var resp *control.AddChainLocalOverrideResponse + var err error + err = cli.ExecRaw(func(client *client.Client) error { + resp, err = control.AddChainLocalOverride(client, req) + return err + }) + commonCmd.ExitOnErr(cmd, "rpc error: %w", err) + + verifyResponse(cmd, resp.GetSignature(), resp.GetBody()) + + cmd.Println("Rule has been added. Chain id: ", resp.GetBody().GetChainId()) +} + +func initControlAddRuleCmd() { + initControlFlags(addRuleCmd) + + ff := addRuleCmd.Flags() + ff.String(commonflags.CIDFlag, "", commonflags.CIDFlagUsage) + ff.String(ruleFlag, "", "Rule statement") +} diff --git a/cmd/frostfs-cli/modules/control/get_rule.go b/cmd/frostfs-cli/modules/control/get_rule.go new file mode 100644 index 000000000..df2bf94fe --- /dev/null +++ b/cmd/frostfs-cli/modules/control/get_rule.go @@ -0,0 +1,69 @@ +package control + +import ( + "crypto/sha256" + + "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/client" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/commonflags" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/key" + commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/control" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + policyengine "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/spf13/cobra" +) + +var getRuleCmd = &cobra.Command{ + Use: "get-rule", + Short: "Get local override", + Long: "Get local APE override of the node", + Run: getRule, +} + +func getRule(cmd *cobra.Command, _ []string) { + pk := key.Get(cmd) + + var cnr cid.ID + cidStr, _ := cmd.Flags().GetString(commonflags.CIDFlag) + commonCmd.ExitOnErr(cmd, "can't decode container ID: %w", cnr.DecodeString(cidStr)) + + rawCID := make([]byte, sha256.Size) + cnr.Encode(rawCID) + + chainID, _ := cmd.Flags().GetString(chainIDFlag) + + req := &control.GetChainLocalOverrideRequest{ + Body: &control.GetChainLocalOverrideRequest_Body{ + ContainerId: rawCID, + ChainId: chainID, + }, + } + + signRequest(cmd, pk, req) + + cli := getClient(cmd, pk) + + var resp *control.GetChainLocalOverrideResponse + var err error + err = cli.ExecRaw(func(client *client.Client) error { + resp, err = control.GetChainLocalOverride(client, req) + return err + }) + commonCmd.ExitOnErr(cmd, "rpc error: %w", err) + + verifyResponse(cmd, resp.GetSignature(), resp.GetBody()) + + var chain policyengine.Chain + commonCmd.ExitOnErr(cmd, "decode error: %w", chain.DecodeBytes(resp.GetBody().GetChain())) + + // TODO (aarifullin): make pretty-formatted output for chains. + cmd.Println("Parsed chain:\n" + prettyJSONFormat(cmd, chain.Bytes())) +} + +func initControGetRuleCmd() { + initControlFlags(getRuleCmd) + + ff := getRuleCmd.Flags() + ff.String(commonflags.CIDFlag, "", commonflags.CIDFlagUsage) + ff.String(chainIDFlag, "", "Chain id") +} diff --git a/cmd/frostfs-cli/modules/control/list_rules.go b/cmd/frostfs-cli/modules/control/list_rules.go new file mode 100644 index 000000000..b3d7a5b9c --- /dev/null +++ b/cmd/frostfs-cli/modules/control/list_rules.go @@ -0,0 +1,72 @@ +package control + +import ( + "crypto/sha256" + + "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/client" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/commonflags" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/key" + commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/control" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + policyengine "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/spf13/cobra" +) + +var listRulesCmd = &cobra.Command{ + Use: "list-rules", + Short: "List local overrides", + Long: "List local APE overrides of the node", + Run: listRules, +} + +func listRules(cmd *cobra.Command, _ []string) { + pk := key.Get(cmd) + + var cnr cid.ID + cidStr, _ := cmd.Flags().GetString(commonflags.CIDFlag) + commonCmd.ExitOnErr(cmd, "can't decode container ID: %w", cnr.DecodeString(cidStr)) + + rawCID := make([]byte, sha256.Size) + cnr.Encode(rawCID) + + req := &control.ListChainLocalOverridesRequest{ + Body: &control.ListChainLocalOverridesRequest_Body{ + ContainerId: rawCID, + }, + } + + signRequest(cmd, pk, req) + + cli := getClient(cmd, pk) + + var resp *control.ListChainLocalOverridesResponse + var err error + err = cli.ExecRaw(func(client *client.Client) error { + resp, err = control.ListChainLocalOverrides(client, req) + return err + }) + commonCmd.ExitOnErr(cmd, "rpc error: %w", err) + + verifyResponse(cmd, resp.GetSignature(), resp.GetBody()) + + chains := resp.GetBody().GetChains() + if len(chains) == 0 { + cmd.Println("Local overrides are not defined for the container.") + return + } + + for _, c := range chains { + // TODO (aarifullin): make pretty-formatted output for chains. + var chain policyengine.Chain + commonCmd.ExitOnErr(cmd, "decode error: %w", chain.DecodeBytes(c)) + cmd.Println("Parsed chain:\n" + prettyJSONFormat(cmd, chain.Bytes())) + } +} + +func initControlListRulesCmd() { + initControlFlags(listRulesCmd) + + ff := listRulesCmd.Flags() + ff.String(commonflags.CIDFlag, "", commonflags.CIDFlagUsage) +} diff --git a/cmd/frostfs-cli/modules/control/remove_rule.go b/cmd/frostfs-cli/modules/control/remove_rule.go new file mode 100644 index 000000000..de064536b --- /dev/null +++ b/cmd/frostfs-cli/modules/control/remove_rule.go @@ -0,0 +1,72 @@ +package control + +import ( + "crypto/sha256" + + "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/rpc/client" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/commonflags" + "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/frostfs-cli/internal/key" + commonCmd "git.frostfs.info/TrueCloudLab/frostfs-node/cmd/internal/common" + "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/services/control" + cid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/container/id" + "github.com/spf13/cobra" +) + +const ( + chainIDFlag = "chain-id" +) + +var removeRuleCmd = &cobra.Command{ + Use: "remove-rule", + Short: "Remove local override", + Long: "Remove local APE override of the node", + Run: removeRule, +} + +func removeRule(cmd *cobra.Command, _ []string) { + pk := key.Get(cmd) + + var cnr cid.ID + cidStr, _ := cmd.Flags().GetString(commonflags.CIDFlag) + commonCmd.ExitOnErr(cmd, "can't decode container ID: %w", cnr.DecodeString(cidStr)) + + rawCID := make([]byte, sha256.Size) + cnr.Encode(rawCID) + + chainID, _ := cmd.Flags().GetString(chainIDFlag) + + req := &control.RemoveChainLocalOverrideRequest{ + Body: &control.RemoveChainLocalOverrideRequest_Body{ + ContainerId: rawCID, + ChainId: chainID, + }, + } + + signRequest(cmd, pk, req) + + cli := getClient(cmd, pk) + + var resp *control.RemoveChainLocalOverrideResponse + var err error + err = cli.ExecRaw(func(client *client.Client) error { + resp, err = control.RemoveChainLocalOverride(client, req) + return err + }) + commonCmd.ExitOnErr(cmd, "rpc error: %w", err) + + verifyResponse(cmd, resp.GetSignature(), resp.GetBody()) + + if resp.GetBody().Removed { + cmd.Println("Rule has been removed.") + } else { + cmd.Println("Rule has not been removed.") + } +} + +func initControlRemoveRuleCmd() { + initControlFlags(removeRuleCmd) + + ff := removeRuleCmd.Flags() + ff.String(commonflags.CIDFlag, "", commonflags.CIDFlagUsage) + ff.String(chainIDFlag, "", "Chain id") +} diff --git a/cmd/frostfs-cli/modules/control/root.go b/cmd/frostfs-cli/modules/control/root.go index 015676185..f7c846eba 100644 --- a/cmd/frostfs-cli/modules/control/root.go +++ b/cmd/frostfs-cli/modules/control/root.go @@ -34,6 +34,10 @@ func init() { shardsCmd, synchronizeTreeCmd, irCmd, + addRuleCmd, + removeRuleCmd, + listRulesCmd, + getRuleCmd, ) initControlHealthCheckCmd() @@ -42,4 +46,8 @@ func init() { initControlShardsCmd() initControlSynchronizeTreeCmd() initControlIRCmd() + initControlAddRuleCmd() + initControlRemoveRuleCmd() + initControlListRulesCmd() + initControGetRuleCmd() } diff --git a/cmd/frostfs-cli/modules/util/ape.go b/cmd/frostfs-cli/modules/util/ape.go new file mode 100644 index 000000000..881546450 --- /dev/null +++ b/cmd/frostfs-cli/modules/util/ape.go @@ -0,0 +1,179 @@ +package util + +import ( + "errors" + "fmt" + "strings" + + policyengine "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/flynn-archive/go-shlex" +) + +var ( + errInvalidStatementFormat = errors.New("invalid statement format") + errInvalidConditionFormat = errors.New("invalid condition format") + errUnknownAction = errors.New("action is not recognized") + errUnknownOperation = errors.New("operation is not recognized") + errUnknownActionDetail = errors.New("action detail is not recognized") + errUnknownBinaryOperator = errors.New("binary operator is not recognized") + errUnknownCondObjectType = errors.New("condition object type is not recognized") +) + +// ParseAPEChain parses APE chain rules. +func ParseAPEChain(chain *policyengine.Chain, rules []string) error { + if len(rules) == 0 { + return errors.New("no APE rules provided") + } + + for _, rule := range rules { + r := new(policyengine.Rule) + if err := ParseAPERule(r, rule); err != nil { + return err + } + chain.Rules = append(chain.Rules, *r) + } + + return nil +} + +// ParseAPERule parses access-policy-engine statement from the following form: +// [:action_detail] [ ...] +// +// Examples: +// deny Object.Put * +// deny:QuotaLimitReached Object.Put * +// allow Object.Put * +// allow Object.Get Object.Resource:Department=HR Object.Request:Actor=ownerA * +// +//nolint:godot +func ParseAPERule(r *policyengine.Rule, rule string) error { + lexemes, err := shlex.Split(rule) + if err != nil { + return fmt.Errorf("can't parse rule '%s': %v", rule, err) + } + return parseRuleLexemes(r, lexemes) +} + +func parseRuleLexemes(r *policyengine.Rule, lexemes []string) error { + if len(lexemes) < 2 { + return errInvalidStatementFormat + } + + var err error + r.Status, err = parseStatus(lexemes[0]) + if err != nil { + return err + } + + r.Action, err = parseAction(lexemes[1]) + if err != nil { + return err + } + + r.Condition, err = parseConditions(lexemes[2 : len(lexemes)-1]) + if err != nil { + return err + } + + r.Resource, err = parseResource(lexemes[len(lexemes)-1]) + return err +} + +func parseStatus(lexeme string) (policyengine.Status, error) { + action, expression, found := strings.Cut(lexeme, ":") + switch action = strings.ToLower(action); action { + case "deny": + if !found { + return policyengine.AccessDenied, nil + } else if strings.EqualFold(expression, "QuotaLimitReached") { + return policyengine.QuotaLimitReached, nil + } else { + return 0, fmt.Errorf("%w: %s", errUnknownActionDetail, expression) + } + case "allow": + if found { + return 0, errUnknownActionDetail + } + return policyengine.Allow, nil + default: + return 0, errUnknownAction + } +} + +func parseAction(lexeme string) ([]string, error) { + switch strings.ToLower(lexeme) { + case "object.put": + return []string{"native:PutObject"}, nil + case "object.get": + return []string{"native:GetObject"}, nil + case "object.head": + return []string{"native:HeadObject"}, nil + case "object.delete": + return []string{"native:DeleteObject"}, nil + case "object.search": + return []string{"native:SearchObject"}, nil + case "object.range": + return []string{"native:RangeObject"}, nil + case "object.hash": + return []string{"native:HashObject"}, nil + default: + } + return nil, fmt.Errorf("%w: %s", errUnknownOperation, lexeme) +} + +func parseResource(lexeme string) ([]string, error) { + return []string{fmt.Sprintf("native:::object/%s", lexeme)}, nil +} + +const ( + ObjectResource = "object.resource" + ObjectRequest = "object.request" + ObjectActor = "object.actor" +) + +var typeToCondObject = map[string]policyengine.ObjectType{ + ObjectResource: policyengine.ObjectResource, + ObjectRequest: policyengine.ObjectRequest, + ObjectActor: policyengine.ObjectActor, +} + +func parseConditions(lexemes []string) ([]policyengine.Condition, error) { + conds := make([]policyengine.Condition, 0) + + for _, lexeme := range lexemes { + typ, expression, found := strings.Cut(lexeme, ":") + typ = strings.ToLower(typ) + + objType, ok := typeToCondObject[typ] + if ok { + if !found { + return nil, fmt.Errorf("%w: %s", errInvalidConditionFormat, lexeme) + } + + var lhs, rhs string + var binExpFound bool + + var cond policyengine.Condition + cond.Object = objType + + lhs, rhs, binExpFound = strings.Cut(expression, "!=") + if !binExpFound { + lhs, rhs, binExpFound = strings.Cut(expression, "=") + if !binExpFound { + return nil, fmt.Errorf("%w: %s", errUnknownBinaryOperator, expression) + } + cond.Op = policyengine.CondStringEquals + } else { + cond.Op = policyengine.CondStringNotEquals + } + + cond.Key, cond.Value = lhs, rhs + + conds = append(conds, cond) + } else { + return nil, fmt.Errorf("%w: %s", errUnknownCondObjectType, typ) + } + } + + return conds, nil +} diff --git a/cmd/frostfs-cli/modules/util/ape_test.go b/cmd/frostfs-cli/modules/util/ape_test.go new file mode 100644 index 000000000..e71544a9b --- /dev/null +++ b/cmd/frostfs-cli/modules/util/ape_test.go @@ -0,0 +1,129 @@ +package util + +import ( + "testing" + + policyengine "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/stretchr/testify/require" +) + +func TestParseAPERule(t *testing.T) { + tests := [...]struct { + name string + rule string + expectErr error + expectRule policyengine.Rule + }{ + { + name: "Valid allow rule", + rule: "allow Object.Put *", + expectRule: policyengine.Rule{ + Status: policyengine.Allow, + Action: []string{"native:PutObject"}, + Resource: []string{"native:::object/*"}, + Condition: []policyengine.Condition{}, + }, + }, + { + name: "Valid deny rule", + rule: "deny Object.Put *", + expectRule: policyengine.Rule{ + Status: policyengine.AccessDenied, + Action: []string{"native:PutObject"}, + Resource: []string{"native:::object/*"}, + Condition: []policyengine.Condition{}, + }, + }, + { + name: "Valid deny rule with action detail", + rule: "deny:QuotaLimitReached Object.Put *", + expectRule: policyengine.Rule{ + Status: policyengine.QuotaLimitReached, + Action: []string{"native:PutObject"}, + Resource: []string{"native:::object/*"}, + Condition: []policyengine.Condition{}, + }, + }, + { + name: "Valid allow rule with conditions", + rule: "allow Object.Get Object.Resource:Department=HR Object.Request:Actor!=ownerA *", + expectRule: policyengine.Rule{ + Status: policyengine.Allow, + Action: []string{"native:GetObject"}, + Resource: []string{"native:::object/*"}, + Condition: []policyengine.Condition{ + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectResource, + Key: "Department", + Value: "HR", + }, + { + Op: policyengine.CondStringNotEquals, + Object: policyengine.ObjectRequest, + Key: "Actor", + Value: "ownerA", + }, + }, + }, + }, + { + name: "Valid rule with conditions with action detail", + rule: "deny:QuotaLimitReached Object.Get Object.Resource:Department=HR Object.Request:Actor!=ownerA *", + expectRule: policyengine.Rule{ + Status: policyengine.QuotaLimitReached, + Action: []string{"native:GetObject"}, + Resource: []string{"native:::object/*"}, + Condition: []policyengine.Condition{ + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectResource, + Key: "Department", + Value: "HR", + }, + { + Op: policyengine.CondStringNotEquals, + Object: policyengine.ObjectRequest, + Key: "Actor", + Value: "ownerA", + }, + }, + }, + }, + { + name: "Invalid rule with unknown action", + rule: "permit Object.Put *", + expectErr: errUnknownAction, + }, + { + name: "Invalid rule with unknown operation", + rule: "allow Object.PutOut *", + expectErr: errUnknownOperation, + }, + { + name: "Invalid rule with unknown action detail", + rule: "deny:UnknownActionDetail Object.Put *", + expectErr: errUnknownActionDetail, + }, + { + name: "Invalid rule with unknown condition binary operator", + rule: "deny Object.Put Object.Resource:Department