diff --git a/chain.go b/chain.go new file mode 100644 index 0000000..3a51d79 --- /dev/null +++ b/chain.go @@ -0,0 +1,184 @@ +package policyengine + +import ( + "encoding/json" + "fmt" + "strings" +) + +// Engine ... +type Engine interface { + // IsAllowed returns status for the operation after all checks. + // The second return value signifies whether a matching rule was found. + IsAllowed(name Name, namespace string, r Request) (Status, bool) +} + +type Chain struct { + Rules []Rule +} + +func (c *Chain) Bytes() []byte { + data, err := json.Marshal(c) + if err != nil { + panic(err) + } + return data +} + +func (c *Chain) DecodeBytes(b []byte) error { + return json.Unmarshal(b, c) +} + +type Rule struct { + Status Status + // Actions the operation is applied to. + Action []string + // List of the resources the operation is applied to. + Resource []string + // True iff individual conditions must be combined with the logical OR. + // By default AND is used, so _each_ condition must pass. + Any bool + Condition []Condition +} + +type Condition struct { + Op ConditionType + Object ObjectType + Key string + Value string +} + +type ObjectType byte + +const ( + ObjectResource ObjectType = iota + ObjectRequest + ObjectActor +) + +// TODO @fyrchik: replace string with int-like type. +type ConditionType string + +// TODO @fyrchik: reduce the number of conditions. +// Everything from here should be expressable, but we do not need them all. +// https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition_operators.html +const ( + // String condition operators. + CondStringEquals ConditionType = "StringEquals" + CondStringNotEquals ConditionType = "StringNotEquals" + CondStringEqualsIgnoreCase ConditionType = "StringEqualsIgnoreCase" + CondStringNotEqualsIgnoreCase ConditionType = "StringNotEqualsIgnoreCase" + CondStringLike ConditionType = "StringLike" + CondStringNotLike ConditionType = "StringNotLike" + + // Numeric condition operators. + CondNumericEquals ConditionType = "NumericEquals" + CondNumericNotEquals ConditionType = "NumericNotEquals" + CondNumericLessThan ConditionType = "NumericLessThan" + CondNumericLessThanEquals ConditionType = "NumericLessThanEquals" + CondNumericGreaterThan ConditionType = "NumericGreaterThan" + CondNumericGreaterThanEquals ConditionType = "NumericGreaterThanEquals" + + // Date condition operators. + CondDateEquals ConditionType = "DateEquals" + CondDateNotEquals ConditionType = "DateNotEquals" + CondDateLessThan ConditionType = "DateLessThan" + CondDateLessThanEquals ConditionType = "DateLessThanEquals" + CondDateGreaterThan ConditionType = "DateGreaterThan" + CondDateGreaterThanEquals ConditionType = "DateGreaterThanEquals" + + // Bolean condition operators. + CondBool ConditionType = "Bool" + + // IP address condition operators. + CondIPAddress ConditionType = "IpAddress" + CondNotIPAddress ConditionType = "NotIpAddress" + + // ARN condition operators. + CondArnEquals ConditionType = "ArnEquals" + CondArnLike ConditionType = "ArnLike" + CondArnNotEquals ConditionType = "ArnNotEquals" + CondArnNotLike ConditionType = "ArnNotLike" +) + +func (c *Condition) Match(req Request) bool { + var val string + switch c.Object { + case ObjectResource: + val = req.Resource().Property(c.Key) + case ObjectRequest: + val = req.Property(c.Key) + default: + panic(fmt.Sprintf("unknown condition type: %d", c.Object)) + } + + switch c.Op { + default: + panic(fmt.Sprintf("unimplemented: %s", c.Op)) + case CondStringEquals: + return val == c.Value + case CondStringNotEquals: + return val != c.Value + case CondStringEqualsIgnoreCase: + return strings.EqualFold(val, c.Value) + case CondStringNotEqualsIgnoreCase: + return !strings.EqualFold(val, c.Value) + case CondStringLike: + return globMatch(val, c.Value) + case CondStringNotLike: + return !globMatch(val, c.Value) + } +} + +func (r *Rule) Match(req Request) (status Status, matched bool) { + found := len(r.Resource) == 0 + for i := range r.Resource { + if globMatch(req.Resource().Name(), r.Resource[i]) { + found = true + break + } + } + if !found { + return NoRuleFound, false + } + for i := range r.Action { + if globMatch(req.Operation(), r.Action[i]) { + return r.matchCondition(req) + } + } + return NoRuleFound, false +} + +func (r *Rule) matchCondition(obj Request) (status Status, matched bool) { + if r.Any { + return r.matchAny(obj) + } + return r.matchAll(obj) +} +func (r *Rule) matchAny(obj Request) (status Status, matched bool) { + for i := range r.Condition { + if r.Condition[i].Match(obj) { + + return r.Status, true + } + } + return NoRuleFound, false +} +func (r *Rule) matchAll(obj Request) (status Status, matched bool) { + for i := range r.Condition { + if !r.Condition[i].Match(obj) { + return NoRuleFound, false + } + } + return r.Status, true +} + +func (c *Chain) Match(req Request) (status Status, matched bool) { + for i := range c.Rules { + status, matched := c.Rules[i].Match(req) + if matched { + return status, true + } + } + return NoRuleFound, false +} diff --git a/chain_names.go b/chain_names.go new file mode 100644 index 0000000..637b967 --- /dev/null +++ b/chain_names.go @@ -0,0 +1,10 @@ +package policyengine + +// Name represents the place in the request lifecycle where policy is applied. +type Name string + +const ( + // Ingress represents chains applied when crossing user/storage network boundary. + // It is not applied when talking between nodes. + Ingress Name = "ingress" +) diff --git a/chain_test.go b/chain_test.go new file mode 100644 index 0000000..204ddf0 --- /dev/null +++ b/chain_test.go @@ -0,0 +1,33 @@ +package policyengine + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEncodeDecode(t *testing.T) { + expected := Chain{ + Rules: []Rule{ + { + Status: Allow, + Action: []string{ + "native::PutObject", + }, + Resource: []string{"*"}, + Condition: []Condition{ + { + Op: CondStringEquals, + Key: "Name", + Value: "NNS", + }, + }, + }, + }, + } + data := expected.Bytes() + + var actual Chain + require.NoError(t, actual.DecodeBytes(data)) + require.Equal(t, expected, actual) +} diff --git a/error.go b/error.go new file mode 100644 index 0000000..e834b10 --- /dev/null +++ b/error.go @@ -0,0 +1,35 @@ +package policyengine + +import "fmt" + +// Status is the status for policy application +type Status byte + +const ( + Allow Status = iota + NoRuleFound + AccessDenied + QuotaLimitReached + last +) + +// Valid returns true if the status is valid. +func (s Status) Valid() bool { + return s < last +} + +// String implements the fmt.Stringer interface. +func (s Status) String() string { + switch s { + case Allow: + return "Allowed" + case NoRuleFound: + return "NoRuleFound" + case AccessDenied: + return "Access denied" + case QuotaLimitReached: + return "Quota limit reached" + default: + return fmt.Sprintf("Denied with status: %d", s) + } +} diff --git a/glob.go b/glob.go new file mode 100644 index 0000000..41a28d3 --- /dev/null +++ b/glob.go @@ -0,0 +1,22 @@ +package policyengine + +import ( + "strings" + "unicode/utf8" +) + +// Matches s against the pattern. +// ? in pattern correspond to any symbol. +// * in pattern correspond to any sequence of symbols. +// Currently only '*' in the suffix is supported. +func globMatch(s, pattern string) bool { + index := strings.IndexByte(pattern, '*') + switch index { + default: + panic("unimplemented") + case -1: + return pattern == s + case utf8.RuneCountInString(pattern) - 1: + return strings.HasPrefix(s, pattern[:len(pattern)-1]) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ffd55cf --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module git.frostfs.info/TrueCloudLab/policy-engine + +go 1.20 + +require github.com/stretchr/testify v1.8.1 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2ec90f7 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +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/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= +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/inmemory.go b/inmemory.go new file mode 100644 index 0000000..ca1055f --- /dev/null +++ b/inmemory.go @@ -0,0 +1,76 @@ +package policyengine + +type inmemory struct { + namespace map[Name][]chain + resource map[Name][]chain + local map[Name][]*Chain +} + +type chain struct { + object string + chain *Chain +} + +// NewInMemory returns new inmemory instance of chain storage. +func NewInMemory() CachedChainStorage { + return &inmemory{ + namespace: make(map[Name][]chain), + resource: make(map[Name][]chain), + local: make(map[Name][]*Chain), + } +} + +// TODO параметры для actor (IP) +// TODO +func (s *inmemory) IsAllowed(name Name, namespace string, r Request) (Status, bool) { + var ruleFound bool + if local, ok := s.local[name]; ok { + for _, c := range local { + if status, matched := c.Match(r); matched && status != Allow { + return status, true + } + } + } + if cs, ok := s.namespace[name]; ok { + status, ok := matchArray(cs, namespace, r) + if ok && status != Allow { + return status, true + } + ruleFound = ruleFound || ok + } + if cs, ok := s.resource[name]; ok { + status, ok := matchArray(cs, r.Resource().Name(), r) + if ok { + return status, true + } + ruleFound = ruleFound || ok + } + if ruleFound { + return Allow, true + } + return NoRuleFound, false +} + +func matchArray(cs []chain, object string, r Request) (Status, bool) { + for _, c := range cs { + if !globMatch(object, c.object) { + continue + } + if status, matched := c.chain.Match(r); matched { + return status, true + } + } + return NoRuleFound, false +} + +func (s *inmemory) AddResourceChain(name Name, resource string, c *Chain) { + s.resource[name] = append(s.resource[name], chain{resource, c}) +} + +func (s *inmemory) AddNameSpaceChain(name Name, namespace string, c *Chain) { + s.namespace[name] = append(s.namespace[name], chain{namespace, c}) +} + +func (s *inmemory) AddOverride(name Name, c *Chain) { + s.local[name] = append(s.local[name], c) +} diff --git a/inmemory_test.go b/inmemory_test.go new file mode 100644 index 0000000..8a63621 --- /dev/null +++ b/inmemory_test.go @@ -0,0 +1,166 @@ +package policyengine + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInmemory(t *testing.T) { + const ( + object = "native::object::abc/xyz" + container = "native::object::abc/*" + namespace = "Tenant1" + actor1 = "owner1" + actor2 = "owner2" + ) + + s := NewInMemory() + + // Object which was put via S3. + res := newResource(object, map[string]string{"FromS3": "true"}) + // Request initiating from the trusted subnet and actor. + reqGood := newRequest("native::object::put", res, map[string]string{ + "SourceIP": "10.1.1.12", + "Actor": actor1, + }) + + status, ok := s.IsAllowed(Ingress, namespace, reqGood) + require.Equal(t, NoRuleFound, status) + require.False(t, ok) + + s.AddNameSpaceChain(Ingress, namespace, &Chain{ + Rules: []Rule{ + { // Restrict to remove ANY object from the namespace. + Status: AccessDenied, + Action: []string{"native::object::delete"}, + Resource: []string{"native::object::*"}, + }, + { // Allow to put object only from the trusted subnet AND trusted actor, deny otherwise. + Status: AccessDenied, + Action: []string{"native::object::put"}, + Resource: []string{"native::object::*"}, + Any: true, + Condition: []Condition{ + { + Op: CondStringNotLike, + Object: ObjectRequest, + Key: "SourceIP", + Value: "10.1.1.*", + }, + { + Op: CondStringNotEquals, + Object: ObjectRequest, + Key: "Actor", + Value: actor1, + }, + }, + }, + }, + }) + + s.AddResourceChain(Ingress, container, &Chain{ + Rules: []Rule{ + { // Allow to actor2 to get objects from the specific container only if they have `Department=HR` attribute. + Status: Allow, + Action: []string{"native::object::get"}, + Resource: []string{"native::object::abc/*"}, + Condition: []Condition{ + { + Op: CondStringEquals, + Object: ObjectResource, + Key: "Department", + Value: "HR", + }, + { + Op: CondStringEquals, + Object: ObjectRequest, + Key: "Actor", + Value: actor2, + }, + }, + }, + }, + }) + + t.Run("bad subnet, namespace deny", func(t *testing.T) { + // Request initiating from the untrusted subnet. + reqBadIP := newRequest("native::object::put", res, map[string]string{ + "SourceIP": "10.122.1.20", + "Actor": actor1, + }) + status, ok := s.IsAllowed(Ingress, namespace, reqBadIP) + require.Equal(t, AccessDenied, status) + require.True(t, ok) + }) + t.Run("bad actor, namespace deny", func(t *testing.T) { + // Request initiating from the untrusted actor. + reqBadActor := newRequest("native::object::put", res, map[string]string{ + "SourceIP": "10.1.1.13", + "Actor": actor2, + }) + status, ok := s.IsAllowed(Ingress, namespace, reqBadActor) + require.Equal(t, AccessDenied, status) + require.True(t, ok) + }) + t.Run("bad object, container deny", func(t *testing.T) { + objGood := newResource("native::object::abc/id1", map[string]string{"Department": "HR"}) + objBadAttr := newResource("native::object::abc/id2", map[string]string{"Department": "Support"}) + + status, ok := s.IsAllowed(Ingress, namespace, newRequest("native::object::get", objGood, map[string]string{ + "SourceIP": "10.1.1.14", + "Actor": actor2, + })) + require.Equal(t, Allow, status) + require.True(t, ok) + + status, ok = s.IsAllowed(Ingress, namespace, newRequest("native::object::get", objBadAttr, map[string]string{ + "SourceIP": "10.1.1.14", + "Actor": actor2, + })) + require.Equal(t, NoRuleFound, status) + require.False(t, ok) + }) + t.Run("bad operation, namespace deny", func(t *testing.T) { + // Request with the forbidden operation. + reqBadOperation := newRequest("native::object::delete", res, map[string]string{ + "SourceIP": "10.1.1.12", + "Actor": actor1, + }) + status, ok := s.IsAllowed(Ingress, namespace, reqBadOperation) + require.Equal(t, AccessDenied, status) + require.True(t, ok) + }) + t.Run("good", func(t *testing.T) { + status, ok = s.IsAllowed(Ingress, namespace, reqGood) + require.Equal(t, NoRuleFound, status) + require.False(t, ok) + + t.Run("quota on a different container", func(t *testing.T) { + s.AddOverride(Ingress, &Chain{ + Rules: []Rule{{ + Status: QuotaLimitReached, + Action: []string{"native::object::put"}, + Resource: []string{"native::object::cba/*"}, + }}, + }) + + status, ok = s.IsAllowed(Ingress, namespace, reqGood) + require.Equal(t, NoRuleFound, status) + require.False(t, ok) + }) + t.Run("quota on the request container", func(t *testing.T) { + s.AddOverride(Ingress, &Chain{ + Rules: []Rule{{ + Status: QuotaLimitReached, + Action: []string{"native::object::put"}, + Resource: []string{"native::object::abc/*"}, + }}, + }) + + status, ok = s.IsAllowed(Ingress, namespace, reqGood) + require.Equal(t, QuotaLimitReached, status) + require.True(t, ok) + }) + }) +} diff --git a/interface.go b/interface.go new file mode 100644 index 0000000..1ea8640 --- /dev/null +++ b/interface.go @@ -0,0 +1,12 @@ +package policyengine + +// CachedChainStorage ... +type CachedChainStorage interface { + Engine + // Adds a policy chain used for all operations with a specific resource. + AddResourceChain(name Name, resource string, c *Chain) + // Adds a policy chain used for all operations in the namespace. + AddNameSpaceChain(name Name, namespace string, c *Chain) + // Adds a local policy chain used for all operations with this service. + AddOverride(name Name, c *Chain) +} diff --git a/policy.go b/policy.go new file mode 100644 index 0000000..5987c4b --- /dev/null +++ b/policy.go @@ -0,0 +1,30 @@ +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"` +// } diff --git a/resource.go b/resource.go new file mode 100644 index 0000000..94187cd --- /dev/null +++ b/resource.go @@ -0,0 +1,19 @@ +package policyengine + +// Request represents generic named resource (bucket, container etc.). +// Name is resource depenent but should be globally unique for any given +// type of resource. +type Request interface { + // Name is the operation name, such as Object.Put. Must not include wildcards. + Operation() string + // Property returns request properties, such as IP address of the origin. + Property(string) string + // Resource returns resource the operation is applied to. + Resource() Resource +} + +// Resource represents the resource operation is applied to. +type Resource interface { + Name() string + Property(string) string +} diff --git a/resource_test.go b/resource_test.go new file mode 100644 index 0000000..ed68e3a --- /dev/null +++ b/resource_test.go @@ -0,0 +1,49 @@ +package policyengine + +type resource struct { + name string + properties map[string]string +} + +func (r *resource) Name() string { + return r.name +} + +func (r *resource) Property(name string) string { + return r.properties[name] +} + +func newResource(name string, properties map[string]string) *resource { + if properties == nil { + properties = make(map[string]string) + } + return &resource{name: name, properties: properties} +} + +type request struct { + operation string + properties map[string]string + resource *resource +} + +var _ Request = (*request)(nil) + +func (r *request) Operation() string { + return r.operation +} + +func (r *request) Resource() Resource { + return r.resource +} + +func (r *request) Property(name string) string { + return r.properties[name] +} + +func newRequest(op string, r *resource, properties map[string]string) *request { + return &request{ + operation: op, + properties: properties, + resource: r, + } +}