diff --git a/iam/converter.go b/iam/converter.go new file mode 100644 index 0000000..8ddc22a --- /dev/null +++ b/iam/converter.go @@ -0,0 +1,227 @@ +package iam + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + policyengine "git.frostfs.info/TrueCloudLab/policy-engine" +) + +const ( + FrostFSPrincipal = "FrostFS" + + RequestOwnerProperty = "Owner" +) + +const ( + // String condition operators. + CondStringEquals string = "StringEquals" + CondStringNotEquals string = "StringNotEquals" + CondStringEqualsIgnoreCase string = "StringEqualsIgnoreCase" + CondStringNotEqualsIgnoreCase string = "StringNotEqualsIgnoreCase" + CondStringLike string = "StringLike" + CondStringNotLike string = "StringNotLike" + + // Numeric condition operators. + CondNumericEquals string = "NumericEquals" + CondNumericNotEquals string = "NumericNotEquals" + CondNumericLessThan string = "NumericLessThan" + CondNumericLessThanEquals string = "NumericLessThanEquals" + CondNumericGreaterThan string = "NumericGreaterThan" + CondNumericGreaterThanEquals string = "NumericGreaterThanEquals" + + // Date condition operators. + CondDateEquals string = "DateEquals" + CondDateNotEquals string = "DateNotEquals" + CondDateLessThan string = "DateLessThan" + CondDateLessThanEquals string = "DateLessThanEquals" + CondDateGreaterThan string = "DateGreaterThan" + CondDateGreaterThanEquals string = "DateGreaterThanEquals" + + // Bolean condition operators. + CondBool string = "Bool" + + // IP address condition operators. + CondIPAddress string = "IpAddress" + CondNotIPAddress string = "NotIpAddress" + + // ARN condition operators. + CondArnEquals string = "ArnEquals" + CondArnLike string = "ArnLike" + CondArnNotEquals string = "ArnNotEquals" + CondArnNotLike string = "ArnNotLike" +) + +func (p Policy) ToChain() (*policyengine.Chain, error) { + var chain policyengine.Chain + + for _, statement := range p.Statement { + status := policyengine.AccessDenied + if statement.Effect == AllowEffect { + status = policyengine.Allow + } + + if len(statement.Principal) != 1 { + return nil, errors.New("currently supported exactly one principal type") + } + + var principals []string + var op policyengine.ConditionType + if _, ok := statement.Principal[Wildcard]; ok { + principals = []string{Wildcard} + op = policyengine.CondStringLike + } else if frostfsPrincipals, ok := statement.Principal[FrostFSPrincipal]; ok { + principals = frostfsPrincipals + op = policyengine.CondStringEquals + } else { + return nil, errors.New("currently supported only FrostFS or all (wildcard) principals") + } + + var conditions []policyengine.Condition + for _, principal := range principals { + conditions = append(conditions, policyengine.Condition{ + Op: op, + Object: policyengine.ObjectRequest, + Key: RequestOwnerProperty, + Value: principal, + }) + } + + conds, err := statement.Conditions.ToChainCondition() + if err != nil { + return nil, err + } + conditions = append(conditions, conds...) + + r := policyengine.Rule{ + Status: status, + Action: statement.Action, + Resource: statement.Resource, + Any: true, + Condition: conditions, + } + chain.Rules = append(chain.Rules, r) + } + + return &chain, nil +} + +func (c Conditions) ToChainCondition() ([]policyengine.Condition, error) { + var conditions []policyengine.Condition + + var convertValue convertFunction + + for op, KVs := range c { + var condType policyengine.ConditionType + + switch { + case strings.HasPrefix(op, "String"): + convertValue = noConvertFunction + switch op { + case CondStringEquals: + condType = policyengine.CondStringEquals + case CondStringNotEquals: + condType = policyengine.CondStringNotEquals + case CondStringEqualsIgnoreCase: + condType = policyengine.CondStringEqualsIgnoreCase + case CondStringNotEqualsIgnoreCase: + condType = policyengine.CondStringNotEqualsIgnoreCase + case CondStringLike: + condType = policyengine.CondStringLike + case CondStringNotLike: + condType = policyengine.CondStringNotLike + default: + return nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } + case strings.HasPrefix(op, "Arn"): + convertValue = noConvertFunction + switch op { + case CondArnEquals: + condType = policyengine.CondStringEquals + case CondArnNotEquals: + condType = policyengine.CondStringNotEquals + case CondArnLike: + condType = policyengine.CondStringLike + case CondArnNotLike: + condType = policyengine.CondStringNotLike + default: + return nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } + case strings.HasPrefix(op, "Numeric"): + // TODO + case strings.HasPrefix(op, "Date"): + convertValue = dateConvertFunction + switch op { + case CondDateEquals: + condType = policyengine.CondStringEquals + case CondDateNotEquals: + condType = policyengine.CondStringNotEquals + case CondDateLessThan: + condType = policyengine.CondStringLessThan + case CondDateLessThanEquals: + condType = policyengine.CondStringLessThanEquals + case CondDateGreaterThan: + condType = policyengine.CondStringGreaterThan + case CondDateGreaterThanEquals: + condType = policyengine.CondStringGreaterThanEquals + default: + return nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } + case op == CondBool: + convertValue = noConvertFunction + condType = policyengine.CondStringEqualsIgnoreCase + case op == CondIPAddress: + // todo consider using converters + // "203.0.113.0/24" -> "203.0.113.*", + // "2001:DB8:1234:5678::/64" -> "2001:DB8:1234:5678:*" + // or having specific condition type for IP + convertValue = noConvertFunction + condType = policyengine.CondStringLike + case op == CondNotIPAddress: + convertValue = noConvertFunction + condType = policyengine.CondStringNotLike + default: + return nil, fmt.Errorf("unsupported condition operator: '%s'", op) + } + + for key, values := range KVs { + for _, val := range values { + converted, err := convertValue(val) + if err != nil { + return nil, err + } + + conditions = append(conditions, policyengine.Condition{ + Op: condType, + Object: policyengine.ObjectRequest, + Key: key, + Value: converted, + }) + } + } + } + + return conditions, nil +} + +type convertFunction func(string) (string, error) + +func noConvertFunction(val string) (string, error) { + return val, nil +} + +func dateConvertFunction(val string) (string, error) { + if _, err := strconv.ParseInt(val, 10, 64); err == nil { + return val, nil + } + + parsed, err := time.Parse(time.RFC3339, val) + if err != nil { + return "", err + } + + return strconv.FormatInt(parsed.UTC().Unix(), 10), nil +} diff --git a/iam/converter_test.go b/iam/converter_test.go new file mode 100644 index 0000000..0407bad --- /dev/null +++ b/iam/converter_test.go @@ -0,0 +1,270 @@ +package iam + +import ( + "testing" + + policyengine "git.frostfs.info/TrueCloudLab/policy-engine" + "github.com/stretchr/testify/require" +) + +func TestConverters(t *testing.T) { + t.Run("valid policy", func(t *testing.T) { + p := Policy{ + Version: "2012-10-17", + Statement: []Statement{{ + Principal: map[string][]string{ + FrostFSPrincipal: {"arn:aws:iam::111122223333:user/JohnDoe"}, + }, + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Conditions: map[string]Condition{ + CondStringEquals: { + "s3:RequestObjectTag/Department": {"Finance"}, + }, + }, + }}, + } + + expected := &policyengine.Chain{Rules: []policyengine.Rule{ + { + Status: policyengine.Allow, + Action: p.Statement[0].Action, + Resource: p.Statement[0].Resource, + Any: true, + Condition: []policyengine.Condition{ + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectRequest, + Key: RequestOwnerProperty, + Value: "arn:aws:iam::111122223333:user/JohnDoe", + }, + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectRequest, + Key: "s3:RequestObjectTag/Department", + Value: "Finance", + }, + }, + }, + }} + + chain, err := p.ToChain() + require.NoError(t, err) + require.Equal(t, expected, chain) + }) + + t.Run("invalid policy (unsupported principal type)", func(t *testing.T) { + p := Policy{ + Version: "2012-10-17", + Statement: []Statement{{ + Principal: map[string][]string{ + "AWS": {"arn:aws:iam::111122223333:user/JohnDoe"}, + }, + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + }}, + } + + _, err := p.ToChain() + require.Error(t, err) + }) + + t.Run("invalid policy (missing principal)", func(t *testing.T) { + p := Policy{ + Version: "2012-10-17", + Statement: []Statement{{ + Principal: map[string][]string{}, + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + }}, + } + + _, err := p.ToChain() + require.Error(t, err) + }) + + t.Run("check policy conditions", func(t *testing.T) { + p := Policy{ + Version: "2012-10-17", + Statement: []Statement{{ + Principal: map[string][]string{"*": nil}, + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Conditions: Conditions{ + CondStringEquals: {"key1": {"val0", "val1"}}, + CondStringNotEquals: {"key2": {"val2"}}, + CondStringEqualsIgnoreCase: {"key3": {"val3"}}, + CondStringNotEqualsIgnoreCase: {"key4": {"val4"}}, + CondStringLike: {"key5": {"val5"}}, + CondStringNotLike: {"key6": {"val6"}}, + CondDateEquals: {"key7": {"2006-01-02T15:04:05+07:00"}}, + CondDateNotEquals: {"key8": {"2006-01-02T15:04:05Z"}}, + CondDateLessThan: {"key9": {"2006-01-02T15:04:05+06:00"}}, + CondDateLessThanEquals: {"key10": {"2006-01-02T15:04:05+03:00"}}, + CondDateGreaterThan: {"key11": {"2006-01-02T15:04:05-01:00"}}, + CondDateGreaterThanEquals: {"key12": {"2006-01-02T15:04:05-03:00"}}, + CondBool: {"key13": {"True"}}, + CondIPAddress: {"key14": {"val14"}}, + CondNotIPAddress: {"key15": {"val15"}}, + CondArnEquals: {"key16": {"val16"}}, + CondArnLike: {"key17": {"val17"}}, + CondArnNotEquals: {"key18": {"val18"}}, + CondArnNotLike: {"key19": {"val19"}}, + }, + }}, + } + + expected := &policyengine.Chain{Rules: []policyengine.Rule{ + { + Status: policyengine.Allow, + Action: p.Statement[0].Action, + Resource: p.Statement[0].Resource, + Any: true, + Condition: []policyengine.Condition{ + { + Op: policyengine.CondStringLike, + Object: policyengine.ObjectRequest, + Key: RequestOwnerProperty, + Value: "*", + }, + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectRequest, + Key: "key1", + Value: "val0", + }, + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectRequest, + Key: "key1", + Value: "val1", + }, + { + Op: policyengine.CondStringNotEquals, + Object: policyengine.ObjectRequest, + Key: "key2", + Value: "val2", + }, + { + Op: policyengine.CondStringEqualsIgnoreCase, + Object: policyengine.ObjectRequest, + Key: "key3", + Value: "val3", + }, + { + Op: policyengine.CondStringNotEqualsIgnoreCase, + Object: policyengine.ObjectRequest, + Key: "key4", + Value: "val4", + }, + { + Op: policyengine.CondStringLike, + Object: policyengine.ObjectRequest, + Key: "key5", + Value: "val5", + }, + { + Op: policyengine.CondStringNotLike, + Object: policyengine.ObjectRequest, + Key: "key6", + Value: "val6", + }, + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectRequest, + Key: "key7", + Value: "1136189045", + }, + { + Op: policyengine.CondStringNotEquals, + Object: policyengine.ObjectRequest, + Key: "key8", + Value: "1136214245", + }, + { + Op: policyengine.CondStringLessThan, + Object: policyengine.ObjectRequest, + Key: "key9", + Value: "1136192645", + }, + { + Op: policyengine.CondStringLessThanEquals, + Object: policyengine.ObjectRequest, + Key: "key10", + Value: "1136203445", + }, + { + Op: policyengine.CondStringGreaterThan, + Object: policyengine.ObjectRequest, + Key: "key11", + Value: "1136217845", + }, + { + Op: policyengine.CondStringGreaterThanEquals, + Object: policyengine.ObjectRequest, + Key: "key12", + Value: "1136225045", + }, + { + Op: policyengine.CondStringEqualsIgnoreCase, + Object: policyengine.ObjectRequest, + Key: "key13", + Value: "True", + }, + { + Op: policyengine.CondStringLike, + Object: policyengine.ObjectRequest, + Key: "key14", + Value: "val14", + }, + { + Op: policyengine.CondStringNotLike, + Object: policyengine.ObjectRequest, + Key: "key15", + Value: "val15", + }, + { + Op: policyengine.CondStringEquals, + Object: policyengine.ObjectRequest, + Key: "key16", + Value: "val16", + }, + { + Op: policyengine.CondStringLike, + Object: policyengine.ObjectRequest, + Key: "key17", + Value: "val17", + }, + { + Op: policyengine.CondStringNotEquals, + Object: policyengine.ObjectRequest, + Key: "key18", + Value: "val18", + }, + { + Op: policyengine.CondStringNotLike, + Object: policyengine.ObjectRequest, + Key: "key19", + Value: "val19", + }, + }, + }, + }} + + chain, err := p.ToChain() + require.NoError(t, err) + + for i, rule := range chain.Rules { + expectedRule := expected.Rules[i] + require.Equal(t, expectedRule.Action, rule.Action) + require.Equal(t, expectedRule.Any, rule.Any) + require.Equal(t, expectedRule.Resource, rule.Resource) + require.Equal(t, expectedRule.Status, rule.Status) + require.ElementsMatch(t, expectedRule.Condition, rule.Condition) + } + }) +} diff --git a/iam/policy.go b/iam/policy.go new file mode 100644 index 0000000..852ce33 --- /dev/null +++ b/iam/policy.go @@ -0,0 +1,177 @@ +package iam + +import ( + "encoding/json" + "errors" +) + +type ( + // Policy grammar https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_grammar.html + // Currently 'NotPrincipal', 'NotAction' and 'NotResource' are not supported (so cannot be unmarshalled). + Policy struct { + Version string `json:"Version,omitempty"` + ID string `json:"Id,omitempty"` + Statement Statements `json:"Statement"` + } + + Statements []Statement + + Statement struct { + SID string `json:"Sid,omitempty"` + Principal Principal `json:"Principal,omitempty"` + Effect Effect `json:"Effect"` + Action Action `json:"Action"` + Resource Resource `json:"Resource"` + Conditions Conditions `json:"Condition,omitempty"` + } + + Principal map[string][]string + + Effect string + + Action []string + + Resource []string + + Conditions map[string]Condition + + Condition map[string][]string +) + +const Wildcard = "*" + +const ( + AllowEffect Effect = "Allow" + DenyEffect Effect = "Deny" +) + +func (s *Statements) UnmarshalJSON(data []byte) error { + var list []Statement + if err := json.Unmarshal(data, &list); err == nil { + *s = list + return nil + } + + var elem Statement + if err := json.Unmarshal(data, &elem); err != nil { + return err + } + + *s = []Statement{elem} + + return nil +} + +func (p *Principal) UnmarshalJSON(data []byte) error { + *p = make(Principal) + + var str string + + if err := json.Unmarshal(data, &str); err == nil { + if str != Wildcard { + return errors.New("invalid IAM string principal") + } + (*p)[Wildcard] = nil + return nil + } + + m := make(map[string]interface{}) + if err := json.Unmarshal(data, &m); err != nil { + return err + } + + for key, val := range m { + element, ok := val.(string) + if ok { + (*p)[key] = []string{element} + continue + } + + list, ok := val.([]interface{}) + if !ok { + return errors.New("invalid principal format") + } + + resList := make([]string, len(list)) + for i := range list { + val, ok := list[i].(string) + if !ok { + return errors.New("invalid principal format") + } + resList[i] = val + } + + (*p)[key] = resList + } + + return nil +} + +func (a *Action) UnmarshalJSON(data []byte) error { + var list []string + if err := json.Unmarshal(data, &list); err == nil { + *a = list + return nil + } + + var elem string + if err := json.Unmarshal(data, &elem); err != nil { + return err + } + + *a = []string{elem} + + return nil +} + +func (r *Resource) UnmarshalJSON(data []byte) error { + var list []string + if err := json.Unmarshal(data, &list); err == nil { + *r = list + return nil + } + + var elem string + if err := json.Unmarshal(data, &elem); err != nil { + return err + } + + *r = []string{elem} + + return nil +} + +func (c *Condition) UnmarshalJSON(data []byte) error { + *c = make(Condition) + + m := make(map[string]interface{}) + if err := json.Unmarshal(data, &m); err != nil { + return err + } + + for key, val := range m { + element, ok := val.(string) + if ok { + (*c)[key] = []string{element} + continue + } + + list, ok := val.([]interface{}) + if !ok { + return errors.New("invalid principal format") + } + + resList := make([]string, len(list)) + for i := range list { + val, ok := list[i].(string) + if !ok { + return errors.New("invalid principal format") + } + resList[i] = val + } + + (*c)[key] = resList + } + + return nil +} diff --git a/iam/policy_test.go b/iam/policy_test.go new file mode 100644 index 0000000..4ffc536 --- /dev/null +++ b/iam/policy_test.go @@ -0,0 +1,173 @@ +package iam + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUnmarshalIAMPolicy(t *testing.T) { + t.Run("simple fields", func(t *testing.T) { + policy := `{ + "Version": "2012-10-17", + "Id": "PutObjPolicy", + "Statement": { + "Sid": "DenyObjectsThatAreNotSSEKMS", + "Principal": "*", + "Effect": "Deny", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::DOC-EXAMPLE-BUCKET/*", + "Condition": { + "Null": { + "s3:x-amz-server-side-encryption-aws-kms-key-id": "true" + } + } + } +}` + + expected := Policy{ + Version: "2012-10-17", + ID: "PutObjPolicy", + Statement: []Statement{{ + SID: "DenyObjectsThatAreNotSSEKMS", + Principal: map[string][]string{ + "*": nil, + }, + Effect: DenyEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Conditions: map[string]Condition{ + "Null": { + "s3:x-amz-server-side-encryption-aws-kms-key-id": {"true"}, + }, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + }) + + t.Run("complex fields", func(t *testing.T) { + policy := `{ + "Version": "2012-10-17", + "Statement": [{ + "Principal":{ + "AWS":[ + "arn:aws:iam::111122223333:user/JohnDoe" + ] + }, + "Effect": "Allow", + "Action": [ + "s3:PutObject" + ], + "Resource": [ + "arn:aws:s3:::DOC-EXAMPLE-BUCKET/*" + ], + "Condition": { + "StringEquals": { + "s3:RequestObjectTag/Department": ["Finance"] + } + } + }] +}` + + expected := Policy{ + Version: "2012-10-17", + Statement: []Statement{{ + Principal: map[string][]string{ + "AWS": {"arn:aws:iam::111122223333:user/JohnDoe"}, + }, + Effect: AllowEffect, + Action: []string{"s3:PutObject"}, + Resource: []string{"arn:aws:s3:::DOC-EXAMPLE-BUCKET/*"}, + Conditions: map[string]Condition{ + "StringEquals": { + "s3:RequestObjectTag/Department": {"Finance"}, + }, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + + raw, err := json.Marshal(expected) + require.NoError(t, err) + require.JSONEq(t, policy, string(raw)) + }) + + t.Run("check principal AWS", func(t *testing.T) { + policy := `{ + "Statement": [{ + "Principal":{ + "AWS":"arn:aws:iam::111122223333:user/JohnDoe" + } + }] +}` + + expected := Policy{ + Statement: []Statement{{ + Principal: map[string][]string{ + "AWS": {"arn:aws:iam::111122223333:user/JohnDoe"}, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + }) + + t.Run("native example", func(t *testing.T) { + policy := ` +{ + "Version": "xyz", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "native:*", + "s3:PutObject", + "s3:GetObject" + ], + "Resource": ["*"], + "Principal": {"FrostFS": ["did:frostfs:039e3ee771a223361fe7862f532e9511b57baaae3c3e2622682e99d0e660f7671"]}, + "Condition": {"StringEquals": {"native::object::attribute": "iamuser-admin"}} + } + ] +}` + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + }) + + t.Run("condition array", func(t *testing.T) { + policy := ` +{ + "Statement": [{ + "Condition": {"StringLike": {"ec2:InstanceType": ["t1.*", "t2.*", "m3.*"]}} + }] +}` + + expected := Policy{ + Statement: []Statement{{ + Conditions: map[string]Condition{ + "StringLike": {"ec2:InstanceType": {"t1.*", "t2.*", "m3.*"}}, + }, + }}, + } + + var p Policy + err := json.Unmarshal([]byte(policy), &p) + require.NoError(t, err) + require.Equal(t, expected, p) + }) +} diff --git a/policy.go b/policy.go deleted file mode 100644 index 5987c4b..0000000 --- a/policy.go +++ /dev/null @@ -1,30 +0,0 @@ -package policyengine - -//{ -// "Version": "xyz", -// "Policy": [ -// { -// "Effect": "Allow", -// "Action": [ -// "native:*", -// "s3:PutObject", -// "s3:GetObject" -// ], -// "Resource": ["*"], -// "Principal": ["did:frostfs:039e3ee771a223361fe7862f532e9511b57baaae3c3e2622682e99d0e660f7671"], -// "Condition": [ {"StringEquals": {"native::object::attribute", "iamuser-admin"}] -// } -// ] -//} - -// type Policy struct { -// Rules []Rule `json:"Policy"` -// } - -// type AWSRule struct { -// Effect string `json:"Effect"` -// Action []string `json:"Action"` -// Resource []string `json:"Resource"` -// Principal []string `json:"Principal"` -// Condition []Condition `json:"Condition"` -// }