[#4] Add IAM policy unmarshaler

This commit is contained in:
Denis Kirillov 2023-10-19 16:15:21 +03:00
parent 5ebb2e694c
commit 88cf807951
5 changed files with 847 additions and 30 deletions

227
iam/converter.go Normal file
View file

@ -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
}

270
iam/converter_test.go Normal file
View file

@ -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)
}
})
}

177
iam/policy.go Normal file
View file

@ -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
}

173
iam/policy_test.go Normal file
View file

@ -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)
})
}

View file

@ -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"`
// }