diff --git a/go.mod b/go.mod index ffd55cf..6fd7840 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,19 @@ module git.frostfs.info/TrueCloudLab/policy-engine go 1.20 -require github.com/stretchr/testify v1.8.1 +require ( + github.com/nspcc-dev/neo-go v0.103.1 + github.com/stretchr/testify v1.8.4 +) require ( github.com/davecgh/go-spew v1.1.1 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect + github.com/hashicorp/golang-lru v0.6.0 // indirect + github.com/mr-tron/base58 v1.2.0 // indirect + github.com/nspcc-dev/rfc6979 v0.2.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.14.0 // indirect + golang.org/x/text v0.13.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2ec90f7..449a10e 100644 --- a/go.sum +++ b/go.sum @@ -1,17 +1,25 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.0/go.mod h1:sQl2p6Y26YV+ZOcSTP6thNdn47hh8kt6rqSlvmrXFAc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 h1:YLtO71vCjJRCBcrPMtQ9nqBsqpA1m5sE92cU+pd5Mcc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1/go.mod h1:hyedUtir6IdtD/7lIxGeCxkaw7y45JueMRL4DIyJDKs= +github.com/hashicorp/golang-lru v0.6.0 h1:uL2shRDx7RTrOrTCUZEGP/wJUFiUI8QT6E7z5o8jga4= +github.com/hashicorp/golang-lru v0.6.0/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/nspcc-dev/neo-go v0.103.1 h1:BfRBceHUu8jSc1KQy7CzmQ/pa+xzAmgcyteGf0/IGgM= +github.com/nspcc-dev/neo-go v0.103.1/go.mod h1:MD7MPiyshUwrE5n1/LzxeandbItaa/iLW/bJb6gNs/U= +github.com/nspcc-dev/rfc6979 v0.2.0 h1:3e1WNxrN60/6N0DW7+UYisLeZJyfqZTNOjeV/toYvOE= +github.com/nspcc-dev/rfc6979 v0.2.0/go.mod h1:exhIh1PdpDC5vQmyEsGvc4YDM/lyQp/452QxGq/UEso= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/iam/converter.go b/iam/converter.go index d6642f3..4704dcd 100644 --- a/iam/converter.go +++ b/iam/converter.go @@ -1,16 +1,27 @@ package iam import ( + "errors" "fmt" "strconv" "strings" "time" policyengine "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" ) const ( RequestOwnerProperty = "Owner" + + ResourceFullPathProperty = "FullPath" +) + +const ( + CondKeyAWSPrincipalARN = "aws:PrincipalArn" + CondKeyS3Delimiter = "s3:delimiter" + CondKeyS3Prefix = "s3:prefix" + CondKeyS3VersionID = "s3:VersionId" ) const ( @@ -52,7 +63,30 @@ const ( CondArnNotLike string = "ArnNotLike" ) -func (p Policy) ToChain() (*policyengine.Chain, error) { +const ( + arnIAMPrefix = "arn:aws:iam::" + s3ResourcePrefix = "arn:aws:s3:::" +) + +// ErrInvalidPrincipalFormat occurs when principal has unknown/unsupported format. +var ErrInvalidPrincipalFormat = errors.New("invalid principal format") + +type ChainType string + +const ( + S3ChainType ChainType = "s3" + NativeChainType ChainType = "native" +) + +type UserResolver interface { + GetUserKey(account, user string) (*keys.PublicKey, error) +} + +func (p Policy) ToChain(typ ChainType, resolver UserResolver) (*policyengine.Chain, error) { + if !isValidChainType(typ) { + return nil, fmt.Errorf("unknown chain type '%s'", typ) + } + if err := p.Validate(GeneralPolicyType); err != nil { return nil, err } @@ -72,8 +106,15 @@ func (p Policy) ToChain() (*policyengine.Chain, error) { principals = []string{Wildcard} op = policyengine.CondStringLike } else { - for _, principal := range statementPrincipal { - principals = append(principals, principal...) + for principalType, principal := range statementPrincipal { + if principalType != AWSPrincipalType { + return nil, fmt.Errorf("unsupported principal type '%s'", principalType) + } + parsedPrincipal, err := formPrincipal(principal, resolver) + if err != nil { + return nil, fmt.Errorf("parse principal '%s': %w", typ, err) + } + principals = append(principals, parsedPrincipal...) } op = policyengine.CondStringEquals @@ -92,7 +133,7 @@ func (p Policy) ToChain() (*policyengine.Chain, error) { }) } - conds, err := statement.Conditions.ToChainCondition() + conds, err := statement.Conditions.ToChainCondition(resolver) if err != nil { return nil, err } @@ -102,7 +143,9 @@ func (p Policy) ToChain() (*policyengine.Chain, error) { ruleAction := policyengine.Actions{Inverted: actionInverted, Names: action} resource, resourceInverted := statement.resource() - ruleResource := policyengine.Resources{Inverted: resourceInverted, Names: resource} + names, extraConditions := formResourceNamesAndConditions(typ, resource) + ruleResource := policyengine.Resources{Inverted: resourceInverted, Names: names} + conditions = append(conditions, extraConditions...) r := policyengine.Rule{ Status: status, @@ -118,7 +161,7 @@ func (p Policy) ToChain() (*policyengine.Chain, error) { } //nolint:funlen -func (c Conditions) ToChainCondition() ([]policyengine.Condition, error) { +func (c Conditions) ToChainCondition(resolver UserResolver) ([]policyengine.Condition, error) { var conditions []policyengine.Condition var convertValue convertFunction @@ -203,6 +246,13 @@ func (c Conditions) ToChainCondition() ([]policyengine.Condition, error) { return nil, err } + if key == CondKeyAWSPrincipalARN { + if converted, err = formPrincipalOwner(converted, resolver); err != nil { + return nil, fmt.Errorf("handle %s: %w", CondKeyAWSPrincipalARN, err) + } + key = RequestOwnerProperty + } + conditions = append(conditions, policyengine.Condition{ Op: condType, Object: policyengine.ObjectRequest, @@ -234,3 +284,103 @@ func dateConvertFunction(val string) (string, error) { return strconv.FormatInt(parsed.UTC().Unix(), 10), nil } + +func isValidChainType(chainType ChainType) bool { + return chainType == S3ChainType || chainType == NativeChainType +} + +func formPrincipal(principal []string, resolver UserResolver) ([]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 UserResolver) (string, error) { + account, user, err := parsePrincipalAsIAMUser(principal) + if err != nil { + return "", err + } + + key, err := resolver.GetUserKey(account, user) + if err != nil { + return "", fmt.Errorf("resolve user: %w", err) + } + + return key.Address(), nil +} + +func parsePrincipalAsIAMUser(principal string) (string, string, 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 formResourceNamesAndConditions(chainType ChainType, names []string) ([]string, []policyengine.Condition) { + switch chainType { + case S3ChainType: + return formS3ResourceNamesAndConditions(names) + case NativeChainType: + return formNativeResourceNamesAndConditions(names) + } + + panic("unknown chain type") // this must not ever happen +} + +func formNativeResourceNamesAndConditions(names []string) ([]string, []policyengine.Condition) { + res := make([]string, len(names)) + resCond := make([]policyengine.Condition, len(names)) + + for i := range names { + // we user ::: instead of :: because of node + // https://git.frostfs.info/TrueCloudLab/frostfs-node/src/commit/78cfb6aea86c01df34a534020dc63cefbad61da0/pkg/services/object/acl/ape_request.go#L42 + res[i] = "native:::object/*" + + resCond[i] = policyengine.Condition{ + Op: policyengine.CondStringLike, + Object: policyengine.ObjectResource, + Key: ResourceFullPathProperty, + Value: strings.TrimPrefix(names[i], s3ResourcePrefix), + } + } + + return res, resCond +} + +func formS3ResourceNamesAndConditions(names []string) ([]string, []policyengine.Condition) { + res := make([]string, len(names)) + for i := range names { + res[i] = strings.TrimPrefix(names[i], s3ResourcePrefix) + } + + return res, nil +} diff --git a/iam/converter_test.go b/iam/converter_test.go index 3de9cb5..8940601 100644 --- a/iam/converter_test.go +++ b/iam/converter_test.go @@ -1,23 +1,58 @@ package iam import ( + "errors" + "strconv" "testing" policyengine "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/nspcc-dev/neo-go/pkg/crypto/keys" "github.com/stretchr/testify/require" ) +type mockUserResolver struct { + users map[string]*keys.PublicKey +} + +func newMockUserResolver(t *testing.T, accountUsers []string) *mockUserResolver { + m := make(map[string]*keys.PublicKey, len(accountUsers)) + for _, user := range accountUsers { + key, err := keys.NewPrivateKey() + require.NoError(t, err) + m[user] = key.PublicKey() + } + + return &mockUserResolver{users: m} +} + +func (m *mockUserResolver) GetUserKey(account, user string) (*keys.PublicKey, error) { + key, ok := m.users[account+"/"+user] + if !ok { + return nil, errors.New("not found") + } + + return key, nil +} + func TestConverters(t *testing.T) { + namespace := "root" + userName := "JohnDoe" + user := namespace + "/" + userName + principal := "arn:aws:iam::" + namespace + ":user/" + userName + resource := "DOC-EXAMPLE-BUCKET/*" + + mockResolver := newMockUserResolver(t, []string{user}) + t.Run("valid policy", func(t *testing.T) { p := Policy{ Version: "2012-10-17", Statement: []Statement{{ Principal: map[PrincipalType][]string{ - AWSPrincipalType: {"arn:aws:iam::111122223333:user/JohnDoe"}, + AWSPrincipalType: {principal}, }, Effect: AllowEffect, Action: []string{"s3:PutObject"}, - Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Resource: []string{"arn:aws:s3:::" + resource}, Conditions: map[string]Condition{ CondStringEquals: { "s3:RequestObjectTag/Department": {"Finance"}, @@ -30,14 +65,14 @@ func TestConverters(t *testing.T) { { Status: policyengine.Allow, Actions: policyengine.Actions{Names: p.Statement[0].Action}, - Resources: policyengine.Resources{Names: p.Statement[0].Resource}, + Resources: policyengine.Resources{Names: []string{resource}}, Any: true, Condition: []policyengine.Condition{ { Op: policyengine.CondStringEquals, Object: policyengine.ObjectRequest, Key: RequestOwnerProperty, - Value: "arn:aws:iam::111122223333:user/JohnDoe", + Value: mockResolver.users[user].Address(), }, { Op: policyengine.CondStringEquals, @@ -49,7 +84,48 @@ func TestConverters(t *testing.T) { }, }} - chain, err := p.ToChain() + chain, err := p.ToChain(S3ChainType, mockResolver) + require.NoError(t, err) + require.Equal(t, expected, chain) + }) + + t.Run("valid native policy", func(t *testing.T) { + p := Policy{ + Version: "2012-10-17", + Statement: []Statement{{ + Principal: map[PrincipalType][]string{ + AWSPrincipalType: {principal}, + }, + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::" + resource}, + }}, + } + + expected := &policyengine.Chain{Rules: []policyengine.Rule{ + { + Status: policyengine.Allow, + Actions: policyengine.Actions{Names: p.Statement[0].Action}, + Resources: policyengine.Resources{Names: []string{"native:::object/*"}}, + Any: true, + Condition: []policyengine.Condition{ + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectRequest, + Key: RequestOwnerProperty, + Value: mockResolver.users[user].Address(), + }, + { + Op: policyengine.CondStringLike, + Object: policyengine.ObjectResource, + Key: ResourceFullPathProperty, + Value: resource, + }, + }, + }, + }} + + chain, err := p.ToChain(NativeChainType, mockResolver) require.NoError(t, err) require.Equal(t, expected, chain) }) @@ -59,11 +135,11 @@ func TestConverters(t *testing.T) { Version: "2012-10-17", Statement: []Statement{{ NotPrincipal: map[PrincipalType][]string{ - AWSPrincipalType: {"arn:aws:iam::111122223333:user/JohnDoe"}, + AWSPrincipalType: {principal}, }, Effect: DenyEffect, NotAction: []string{"s3:PutObject"}, - NotResource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + NotResource: []string{"arn:aws:s3:::" + resource}, }}, } @@ -71,20 +147,20 @@ func TestConverters(t *testing.T) { { Status: policyengine.AccessDenied, Actions: policyengine.Actions{Inverted: true, Names: p.Statement[0].NotAction}, - Resources: policyengine.Resources{Inverted: true, Names: p.Statement[0].NotResource}, + Resources: policyengine.Resources{Inverted: true, Names: []string{resource}}, Any: true, Condition: []policyengine.Condition{ { Op: policyengine.CondStringNotEquals, Object: policyengine.ObjectRequest, Key: RequestOwnerProperty, - Value: "arn:aws:iam::111122223333:user/JohnDoe", + Value: mockResolver.users[user].Address(), }, }, }, }} - chain, err := p.ToChain() + chain, err := p.ToChain(S3ChainType, mockResolver) require.NoError(t, err) require.Equal(t, expected, chain) }) @@ -94,7 +170,7 @@ func TestConverters(t *testing.T) { Version: "2012-10-17", Statement: []Statement{{ Principal: map[PrincipalType][]string{ - "dummy": {"arn:aws:iam::111122223333:user/JohnDoe"}, + "dummy": {principal}, }, Effect: AllowEffect, Action: []string{"s3:PutObject"}, @@ -102,7 +178,7 @@ func TestConverters(t *testing.T) { }}, } - _, err := p.ToChain() + _, err := p.ToChain(S3ChainType, mockResolver) require.Error(t, err) }) @@ -111,14 +187,14 @@ func TestConverters(t *testing.T) { Version: "2012-10-17", Statement: []Statement{{ Principal: map[PrincipalType][]string{ - AWSPrincipalType: {"arn:aws:iam::111122223333:user/JohnDoe"}, + AWSPrincipalType: {principal}, }, Effect: AllowEffect, Action: []string{"s3:PutObject"}, }}, } - _, err := p.ToChain() + _, err := p.ToChain(S3ChainType, mockResolver) require.Error(t, err) }) @@ -129,7 +205,7 @@ func TestConverters(t *testing.T) { Principal: map[PrincipalType][]string{Wildcard: nil}, Effect: AllowEffect, Action: []string{"s3:PutObject"}, - Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Resource: []string{"arn:aws:s3:::" + resource}, Conditions: Conditions{ CondStringEquals: {"key1": {"val0", "val1"}}, CondStringNotEquals: {"key2": {"val2"}}, @@ -147,7 +223,7 @@ func TestConverters(t *testing.T) { CondIPAddress: {"key14": {"val14"}}, CondNotIPAddress: {"key15": {"val15"}}, CondArnEquals: {"key16": {"val16"}}, - CondArnLike: {"key17": {"val17"}}, + CondArnLike: {CondKeyAWSPrincipalARN: {principal}}, CondArnNotEquals: {"key18": {"val18"}}, CondArnNotLike: {"key19": {"val19"}}, }, @@ -158,7 +234,7 @@ func TestConverters(t *testing.T) { { Status: policyengine.Allow, Actions: policyengine.Actions{Names: p.Statement[0].Action}, - Resources: policyengine.Resources{Names: p.Statement[0].Resource}, + Resources: policyengine.Resources{Names: []string{resource}}, Any: true, Condition: []policyengine.Condition{ { @@ -272,8 +348,8 @@ func TestConverters(t *testing.T) { { Op: policyengine.CondStringLike, Object: policyengine.ObjectRequest, - Key: "key17", - Value: "val17", + Key: RequestOwnerProperty, + Value: mockResolver.users[user].Address(), }, { Op: policyengine.CondStringNotEquals, @@ -291,7 +367,7 @@ func TestConverters(t *testing.T) { }, }} - chain, err := p.ToChain() + chain, err := p.ToChain(S3ChainType, mockResolver) require.NoError(t, err) for i, rule := range chain.Rules { @@ -304,3 +380,57 @@ func TestConverters(t *testing.T) { } }) } + +func TestName(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 := 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) + }) + } +}