[#2] Initial implementation

Signed-off-by: Evgenii Stratonikov <e.stratonikov@yadro.com>
This commit is contained in:
Evgenii Stratonikov 2023-10-17 17:10:48 +03:00
parent af4da29b1b
commit 5ebb2e694c
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)
}
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
}

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 {
// 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,
}
}