Initial implementation #2

Merged
fyrchik merged 1 commit from fyrchik/policy-engine:init into master 2023-10-23 10:18:07 +00:00
13 changed files with 664 additions and 0 deletions

184
chain.go Normal file
View file

@ -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)
dstepanov-yadro marked this conversation as resolved Outdated

As far as i remember gob requires to register every type, but Chain contains interfaces. Is simple json not good enough?

As far as i remember `gob` requires to register every type, but Chain contains interfaces. Is simple json not good enough?

true, replaced

true, replaced
}
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))
}
dstepanov-yadro marked this conversation as resolved Outdated

obj Request -> req Request

obj Request -> req Request

fixed

fixed
switch c.Op {
default:
panic(fmt.Sprintf("unimplemented: %s", c.Op))
case CondStringEquals:
return val == c.Value
case CondStringNotEquals:
return val != c.Value
dstepanov-yadro marked this conversation as resolved Outdated

Explain please why unknown Object leads to false, but unknown Op leads to panic?

Explain please why unknown Object leads to false, but unknown Op leads to panic?

Made panic for both. Panic is for the development stage, to avoid long debugging sessions after silent misbehaviour.

Made panic for both. Panic is for the development stage, to avoid long debugging sessions after silent misbehaviour.
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)
dkirillov marked this conversation as resolved Outdated

Should the args be in reverse order?
if globMatch(req.Operation(), r.Action[i]) {

Should the args be in reverse order? `if globMatch(req.Operation(), r.Action[i]) {`

Fixed

Fixed

Did you push the fix into init branch in the main repo

Line 154 in c1ac4ad
if globMatch(req.Operation(), r.Action[i]) {

and not to the branch from which this PR is opened https://git.frostfs.info/fyrchik/policy-engine/src/commit/3970569602d100fc47b9b0e51f55586820652f8b/chain.go#L154 ?

Did you push the fix into `init` branch in the main repo https://git.frostfs.info/TrueCloudLab/policy-engine/src/commit/c1ac4ad957262ac4da2dc3bbfcb3b31028d71128/chain.go#L154 and not to the branch from which this PR is opened https://git.frostfs.info/fyrchik/policy-engine/src/commit/3970569602d100fc47b9b0e51f55586820652f8b/chain.go#L154 ?

Yes, probably, fixed

Yes, probably, fixed
}
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
}
dstepanov-yadro marked this conversation as resolved Outdated

NoRuleFound -> AccessDenied

NoRuleFound -> AccessDenied

Agree here, I have postponed having default DENY values for future, to make the result more explicit.

Agree here, I have postponed having default DENY values for future, to make the result more explicit.
func (c *Chain) Match(req Request) (status Status, matched bool) {
for i := range c.Rules {
status, matched := c.Rules[i].Match(req)
if matched {
dstepanov-yadro marked this conversation as resolved Outdated

NoRuleFound -> AccessDenied:

NoRuleFound -> AccessDenied:

There are multiple rules in the chain, AccessDenied implies one of the conditions has matched.

There are multiple rules in the chain, `AccessDenied` implies one of the conditions has matched.

Ah, ok

Ah, ok
return status, true
}
}
return NoRuleFound, false
}

10
chain_names.go Normal file
View file

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

33
chain_test.go Normal file
View file

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

35
error.go Normal file
View file

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

22
glob.go Normal file
View file

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

11
go.mod Normal file
View file

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

17
go.sum Normal file
View file

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

76
inmemory.go Normal file
View file

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

166
inmemory_test.go Normal file
View file

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

12
interface.go Normal file
View file

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

30
policy.go Normal file
View file

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

19
resource.go Normal file
View file

@ -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 {
dstepanov-yadro marked this conversation as resolved Outdated

I think interface can be replaced with struct, because Request looks like data-holder, not behavior.
But it is up to you.

I think interface can be replaced with struct, because Request looks like data-holder, not behavior. But it is up to you.

It is an interfarce because of 2 reasons:

  1. Multiple implementations are possible (S3 and gRPC protocol).
  2. We do not want to depend on frostfs-api here (object) and we do not want to copy all objects attributes just to create this struct.

Maybe I don't understand, what is the exact struct you have in mind?

It is an interfarce because of 2 reasons: 1. Multiple implementations are possible (S3 and gRPC protocol). 2. We do not want to depend on frostfs-api here (object) and we do not want to copy all objects attributes just to create this struct. Maybe I don't understand, what is the exact struct you have in mind?

Something like that:

type Request struct {
	Operation string
	Properties map[string]string
	Resource Resource
}

Multiple implementations are possible (S3 and gRPC protocol).

I belive that both of s3-gw and node will go to the same implementation of this interface.

we do not want to copy all objects attributes

It is not required to copy everything

Something like that: ``` type Request struct { Operation string Properties map[string]string Resource Resource } ``` > Multiple implementations are possible (S3 and gRPC protocol). I belive that both of s3-gw and node will go to the same implementation of this interface. > we do not want to copy all objects attributes It is not required to copy everything

I wanted to avoid allocating Properties map as much as possible. Let's see the prototypes and figure out whether is is better.

I wanted to avoid allocating `Properties` map as much as possible. Let's see the prototypes and figure out whether is is better.

This is not a final implementation.

This is not a final implementation.
// 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
}

49
resource_test.go Normal file
View file

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