diff --git a/cmd/neofs-cli/modules/acl.go b/cmd/neofs-cli/modules/acl.go deleted file mode 100644 index 101e48ec7..000000000 --- a/cmd/neofs-cli/modules/acl.go +++ /dev/null @@ -1,31 +0,0 @@ -package cmd - -import ( - "fmt" - - "github.com/spf13/cobra" -) - -// aclCmd represents the acl command -var aclCmd = &cobra.Command{ - Use: "acl", - Short: "Operations with Access Control Lists", - Long: `Operations with Access Control Lists`, - Run: func(cmd *cobra.Command, args []string) { - fmt.Println("acl called") - }, -} - -func init() { - rootCmd.AddCommand(aclCmd) - - // Here you will define your flags and configuration settings. - - // Cobra supports Persistent Flags which will work for this command - // and all subcommands, e.g.: - // aclCmd.PersistentFlags().String("foo", "", "A help for foo") - - // Cobra supports local flags which will only run when this command - // is called directly, e.g.: - // aclCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") -} diff --git a/cmd/neofs-cli/modules/acl/extended/create.go b/cmd/neofs-cli/modules/acl/extended/create.go new file mode 100644 index 000000000..8d6aa40d5 --- /dev/null +++ b/cmd/neofs-cli/modules/acl/extended/create.go @@ -0,0 +1,275 @@ +package extended + +import ( + "bytes" + "crypto/ecdsa" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "strings" + + "github.com/flynn-archive/go-shlex" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" + cid "github.com/nspcc-dev/neofs-sdk-go/container/id" + "github.com/nspcc-dev/neofs-sdk-go/eacl" + "github.com/spf13/cobra" +) + +var createCmd = &cobra.Command{ + Use: "create", + Short: "Create extended ACL from the text representation", + Long: `Create extended ACL from the text representation. + +Rule consist of these blocks: [ ...] [ ...] + +Action is 'allow' or 'deny'. + +Operation is an object service verb: 'get', 'head', 'put', 'search', 'delete', 'getrange', or 'getrangehash'. + +Filter consists of : + Typ is 'obj' for object applied filter or 'req' for request applied filter. + Key is a valid unicode string corresponding to object or request header key. + Match is '==' for matching and '!=' for non-matching filter. + Value is a valid unicode string corresponding to object or request header value. + +Target is + 'user' for container owner, + 'system' for Storage nodes in container and Inner Ring nodes, + 'others' for all other request senders, + 'pubkey:,,...' for exact request sender, where is a hex-encoded 33-byte public key. + +When both '--rule' and '--file' arguments are used, '--rule' records will be placed higher in resulting extended ACL table. +`, + Example: `neofs-cli acl extended create --cid EutHBsdT1YCzHxjCfQHnLPL1vFrkSyLSio4vkphfnEk -f rules.txt --out table.json +neofs-cli acl extended create --cid EutHBsdT1YCzHxjCfQHnLPL1vFrkSyLSio4vkphfnEk -r 'allow get obj:Key=Value others' -r 'deny put others'`, + Run: createEACL, +} + +func init() { + createCmd.Flags().StringArrayP("rule", "r", nil, "extended ACL table record to apply") + createCmd.Flags().StringP("file", "f", "", "read list of extended ACL table records from from text file") + createCmd.Flags().StringP("out", "o", "", "save JSON formatted extended ACL table in file") + createCmd.Flags().StringP("cid", "", "", "container ID") + + _ = cobra.MarkFlagFilename(createCmd.Flags(), "file") + _ = cobra.MarkFlagFilename(createCmd.Flags(), "out") +} + +func createEACL(cmd *cobra.Command, _ []string) { + rules, _ := cmd.Flags().GetStringArray("rule") + fileArg, _ := cmd.Flags().GetString("file") + outArg, _ := cmd.Flags().GetString("out") + cidArg, _ := cmd.Flags().GetString("cid") + + containerID := cid.New() + if err := containerID.Parse(cidArg); err != nil { + cmd.Printf("invalid container ID: %v", err) + return + } + + rulesFile, err := getRulesFromFile(fileArg) + if err != nil { + cmd.Printf("can't read rules from file : %v", err) + return + } + + rules = append(rules, rulesFile...) + if len(rules) == 0 { + cmd.Println("no extended ACL rules has been provided") + return + } + + tb := eacl.NewTable() + + for _, ruleStr := range rules { + r, err := shlex.Split(ruleStr) + if err != nil { + cmd.Printf("can't parse rule '%s': %v)", ruleStr, err) + return + } + + err = parseTable(tb, r) + if err != nil { + cmd.Printf("can't create extended ACL record from rule '%s': %v", ruleStr, err) + return + } + } + + tb.SetCID(containerID) + + data, err := tb.MarshalJSON() + if err != nil { + cmd.Println(err) + return + } + + buf := new(bytes.Buffer) + err = json.Indent(buf, data, "", " ") + if err != nil { + cmd.Println(err) + return + } + + if len(outArg) == 0 { + cmd.Println(buf) + return + } + + err = ioutil.WriteFile(outArg, buf.Bytes(), 0644) + if err != nil { + cmd.Println(err) + } +} + +func getRulesFromFile(filename string) ([]string, error) { + if len(filename) == 0 { + return nil, nil + } + + data, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + return strings.Split(strings.TrimSpace(string(data)), "\n"), nil +} + +// parseTable parses eACL table from the following form: +// [ ...] [ ...] +// +// Examples: +// allow get req:X-Header=123 obj:Attr=value others:0xkey1,key2 system:key3 user:key4 +func parseTable(tb *eacl.Table, args []string) error { + if len(args) < 2 { + return errors.New("at least 2 arguments must be provided") + } + + var action eacl.Action + if !action.FromString(strings.ToUpper(args[0])) { + return errors.New("invalid action (expected 'allow' or 'deny')") + } + + ops, err := parseOperations(args[1]) + if err != nil { + return err + } + + r, err := parseRecord(args[2:]) + if err != nil { + return err + } + + r.SetAction(action) + + for _, op := range ops { + r := *r + r.SetOperation(op) + tb.AddRecord(&r) + } + + return nil +} + +func parseRecord(args []string) (*eacl.Record, error) { + r := new(eacl.Record) + for i := range args { + ss := strings.SplitN(args[i], ":", 2) + + switch prefix := strings.ToLower(ss[0]); prefix { + case "req", "obj": // filters + if len(ss) != 2 { + return nil, fmt.Errorf("invalid filter or target: %s", args[i]) + } + + i := strings.Index(ss[1], "=") + if i < 0 { + return nil, fmt.Errorf("invalid filter key-value pair: %s", ss[1]) + } + + var key, value string + var op eacl.Match + + if 0 < i && ss[1][i-1] == '!' { + key = ss[1][:i-1] + op = eacl.MatchStringNotEqual + } else { + key = ss[1][:i] + op = eacl.MatchStringEqual + } + + value = ss[1][i+1:] + + typ := eacl.HeaderFromRequest + if ss[0] == "obj" { + typ = eacl.HeaderFromObject + } + + r.AddFilter(typ, op, key, value) + case "others", "system", "user", "pubkey": // targets + var err error + + var pubs []ecdsa.PublicKey + if len(ss) == 2 { + pubs, err = parseKeyList(ss[1]) + if err != nil { + return nil, err + } + } + + var role eacl.Role + if prefix != "pubkey" { + role, err = roleFromString(prefix) + if err != nil { + return nil, err + } + } + + eacl.AddFormedTarget(r, role, pubs...) + + default: + return nil, fmt.Errorf("invalid prefix: %s", ss[0]) + } + } + + return r, nil +} + +func roleFromString(s string) (eacl.Role, error) { + var r eacl.Role + if !r.FromString(strings.ToUpper(s)) { + return r, fmt.Errorf("unexpected role %s", s) + } + + return r, nil +} + +// parseKeyList parses list of hex-encoded public keys separated by comma. +func parseKeyList(s string) ([]ecdsa.PublicKey, error) { + ss := strings.Split(s, ",") + pubs := make([]ecdsa.PublicKey, len(ss)) + for i := range ss { + st := strings.TrimPrefix(ss[i], "0x") + pub, err := keys.NewPublicKeyFromString(st) + if err != nil { + return nil, fmt.Errorf("invalid public key '%s': %w", ss[i], err) + } + + pubs[i] = ecdsa.PublicKey(*pub) + } + + return pubs, nil +} + +func parseOperations(s string) ([]eacl.Operation, error) { + ss := strings.Split(s, ",") + ops := make([]eacl.Operation, len(ss)) + + for i := range ss { + if !ops[i].FromString(strings.ToUpper(ss[i])) { + return nil, fmt.Errorf("invalid operation: %s", ss[i]) + } + } + + return ops, nil +} diff --git a/cmd/neofs-cli/modules/acl/extended/create_test.go b/cmd/neofs-cli/modules/acl/extended/create_test.go new file mode 100644 index 000000000..88da39533 --- /dev/null +++ b/cmd/neofs-cli/modules/acl/extended/create_test.go @@ -0,0 +1,91 @@ +package extended + +import ( + "strings" + "testing" + + "github.com/nspcc-dev/neofs-sdk-go/eacl" + "github.com/stretchr/testify/require" +) + +func TestParseTable(t *testing.T) { + tests := [...]struct { + name string // test name + rule string // input extended ACL rule + jsonRecord string // produced record after successfull parsing + }{ + { + name: "valid rule with multiple filters", + rule: "deny get obj:a=b req:c=d others", + jsonRecord: `{"operation":"GET","action":"DENY","filters":[{"headerType":"OBJECT","matchType":"STRING_EQUAL","key":"a","value":"b"},{"headerType":"REQUEST","matchType":"STRING_EQUAL","key":"c","value":"d"}],"targets":[{"role":"OTHERS","keys":[]}]}`, + }, + { + name: "valid rule without filters", + rule: "allow put user", + jsonRecord: `{"operation":"PUT","action":"ALLOW","filters":[],"targets":[{"role":"USER","keys":[]}]}`, + }, + { + name: "valid rule with public key", + rule: "deny getrange pubkey:036410abb260bbbda89f61c0cad65a4fa15ac5cb83b3c3abf8aee403856fcf65ed", + jsonRecord: `{"operation":"GETRANGE","action":"DENY","filters":[],"targets":[{"role":"ROLE_UNSPECIFIED","keys":["A2QQq7Jgu72on2HAytZaT6FaxcuDs8Or+K7kA4Vvz2Xt"]}]}`, + }, + { + name: "missing action", + rule: "get obj:a=b others", + }, + { + name: "invalid action", + rule: "permit get obj:a=b others", + }, + { + name: "missing op", + rule: "deny obj:a=b others", + }, + { + name: "invalid op action", + rule: "deny look obj:a=b others", + }, + { + name: "invalid filter type", + rule: "deny get invalid:a=b others", + }, + { + name: "invalid target group", + rule: "deny get obj:a=b helpers", + }, + { + name: "invalid public key", + rule: "deny get obj:a=b pubkey:0123", + }, + } + + eaclTable := eacl.NewTable() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + ss := strings.Split(test.rule, " ") + err := parseTable(eaclTable, ss) + ok := len(test.jsonRecord) > 0 + require.Equal(t, ok, err == nil, err) + if ok { + expectedRecord := eacl.NewRecord() + err = expectedRecord.UnmarshalJSON([]byte(test.jsonRecord)) + require.NoError(t, err) + + actualRecord := eaclTable.Records()[len(eaclTable.Records())-1] + + equalRecords(t, expectedRecord, actualRecord) + } + }) + } +} + +func equalRecords(t *testing.T, r1, r2 *eacl.Record) { + d1, err := r1.Marshal() + require.NoError(t, err) + + d2, err := r2.Marshal() + require.NoError(t, err) + + require.Equal(t, d1, d2) +} diff --git a/cmd/neofs-cli/modules/acl/extended/root.go b/cmd/neofs-cli/modules/acl/extended/root.go new file mode 100644 index 000000000..673652549 --- /dev/null +++ b/cmd/neofs-cli/modules/acl/extended/root.go @@ -0,0 +1,12 @@ +package extended + +import "github.com/spf13/cobra" + +var Cmd = &cobra.Command{ + Use: "extended", + Short: "Operations with Extended Access Control Lists", +} + +func init() { + Cmd.AddCommand(createCmd) +} diff --git a/cmd/neofs-cli/modules/acl/root.go b/cmd/neofs-cli/modules/acl/root.go new file mode 100644 index 000000000..0fc3ecea3 --- /dev/null +++ b/cmd/neofs-cli/modules/acl/root.go @@ -0,0 +1,15 @@ +package acl + +import ( + "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/modules/acl/extended" + "github.com/spf13/cobra" +) + +var Cmd = &cobra.Command{ + Use: "acl", + Short: "Operations with Access Control Lists", +} + +func init() { + Cmd.AddCommand(extended.Cmd) +} diff --git a/cmd/neofs-cli/modules/root.go b/cmd/neofs-cli/modules/root.go index e33746305..fed23d573 100644 --- a/cmd/neofs-cli/modules/root.go +++ b/cmd/neofs-cli/modules/root.go @@ -15,6 +15,7 @@ import ( "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/nspcc-dev/neo-go/pkg/util" "github.com/nspcc-dev/neo-go/pkg/wallet" + "github.com/nspcc-dev/neofs-node/cmd/neofs-cli/modules/acl" "github.com/nspcc-dev/neofs-node/misc" "github.com/nspcc-dev/neofs-node/pkg/network" "github.com/nspcc-dev/neofs-sdk-go/client" @@ -120,6 +121,8 @@ func init() { // Cobra also supports local flags, which will only run // when this action is called directly. rootCmd.Flags().Bool("version", false, "Application version and NeoFS API compatibility") + + rootCmd.AddCommand(acl.Cmd) } func entryPoint(cmd *cobra.Command, _ []string) { diff --git a/go.mod b/go.mod index 26acff9be..cad1d7039 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/nspcc-dev/neofs-node go 1.16 require ( + github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 github.com/google/go-github/v39 v39.2.0 github.com/google/uuid v1.2.0 github.com/hashicorp/golang-lru v0.5.4 diff --git a/go.sum b/go.sum index 9d7820678..cb1ccb2d2 100644 --- a/go.sum +++ b/go.sum @@ -126,6 +126,7 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.12.0/go.mod h1:ELkj/draVOlAH/xkhN6mQ50Qd0MPOk5AAr3maGEBuJM= +github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BMXYYRWTLOJKlh+lOBt6nUQgXAfB7oVIQt5cNreqSLI= github.com/flynn-archive/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:rZfgFAXFS/z/lEd6LJmf9HVZ1LkgYiHx5pHhV5DR16M= github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=