-
Notifications
You must be signed in to change notification settings - Fork 0
RBAC in REST API using Go DEV Community
Role Based Access Control, or RBAC.
Might be one of the more interesting challenge I've faced. It is:
- Needed by business
- Complicated
- But quite rewarding to solve
- While felt over-engineered at the same time
Imagine a scenario where you have 4 roles inside your company, which have access to 1 type of resource.
Let's say:
Manager
Ops
CS
Client
These roles have different kind of interaction with one type of resource. Let's say it's an Inquiry
, where:
-
Client
can submitNew
Inquiry
-
CS
viewsInquiry
fromClient
an then assign it to anOps
-
Client
can only view his ownInquiry
-
CS
can only viewInquiry
with status =New
-
Ops
can only viewInquiry
assigned to them -
Manager
can view allInquiry
which have been assigned toOps
This is what RBAC
is, where we try to Control
Access
to a resource/action Based
on Role
Hence the name: Role Based Access Control
The easiest way to implement this is to filter resource access through Frontend
:
- As a
Client
I must ONLY be able to view myInquiry
- As a
CS
I must ONLY be able to viewNew
Inquiry
- As an
Ops
I must ONLY be able to viewInquiry
assigned to me - As a
Manager
I must ONLY be able to viewInquiry
assigned toOps
Neat right? Looks like some JIRA
stories for Frontend
.
BUT NOT SO FAST...
If there's no restriction enforced by Backend
, then:
- Let's say
Ops A
knows how to construct a query, therefore - All
Inquiry
data can be leaked just by queryingGET /inquiries
- There could be sensitive / confidential data which must never be able to seen by other than specified role
- e.g:
Client's
profile, address, phone, etc...
This is why RBAC
must be implemented by Backend
In typical REST API
we usually creates endpoint based on a Resource
(hence the R
from REST
... J.K. It's not!)
Example:
'GET /inquiries?param1={v1}¶m2={v2}...' # Get list of inquiries based on some query parameter
'POST /inquiries' # Create a new inquiry
'POST /inquiries/{id}/assign' # Assign an inquiry to a someone
Enter fullscreen mode Exit fullscreen mode
With Inquiry
data structure:
{
"id": "INQ-0001",
"created_by": "[email protected]",
"status": "Assigned",
"assignee": "[email protected]"
}
Enter fullscreen mode Exit fullscreen mode
And we want to enforce RBAC
on these endpoints
Now we have:
- Four roles
- Lots of rules
- Three endpoints, and
-
Inquiry
data structure
Next we'll have to map permission to a role based on guideline below:
what's my role? what resource am I trying to access? at what endpoint? is it allowed? if it is, what's the rule?
We'll use YAML
to structure our rule
client:
inquiry:
get:
allow: true
ensure:
query:
- key: created_by
operator: "="
value: "ctx.email"
create:
allow: true
assign:
allow: false
Enter fullscreen mode Exit fullscreen mode
Now, we have a rule for Client
, how do we read it?
* If I am a `client`, want to access `inquiry`:
* For endpoint `get`, I am allowed, only when `url query`
* Contains a key named `create_by`
* And the value must be `=` to value of `ctx.email`
* For endpoint `create`, I am allowed, without restriction
* For endpoint `assign`, I am not allowed to access
Enter fullscreen mode Exit fullscreen mode
cs:
inquiry:
get:
allow: true
enforce:
query:
- key: status
value: "New"
create:
allow: false
assign:
allow: true
Enter fullscreen mode Exit fullscreen mode
We read it as:
* If I am a `cs`, want to access `inquiry`:
* For endpoint `get`, I am allowed, but `url query`
* Will be enforced with a key named `status`
* And the value is `status=New`
* For endpoint `create`, I am not allowed to access
* For endpoint `assign`, I am allowed, without restriction
Enter fullscreen mode Exit fullscreen mode
ops:
inquiry:
get:
allow: true
ensure:
query:
- key: assignee
operator: "="
value: "ctx.email"
create:
allow: false
assign:
allow: false
Enter fullscreen mode Exit fullscreen mode
We read it as:
* If I am an `ops`, want to access `inquiry`:
* For endpoint `get`, I am allowed, only when `url query`
* Contains a key name `assignee`
* And the value must be `=` to value of `ctx.email`
* For endpoint `create`, I am not allowed to access
* For endpoint `assign`, I am not allowed to access
Enter fullscreen mode Exit fullscreen mode
manager:
inquiry:
get:
allow: true
enforce:
query:
- key: status
value: "Assigned"
create:
allow: false
assign:
allow: true
Enter fullscreen mode Exit fullscreen mode
We read it as:
* If I am a `manager`, want to access `inquiry`:
* For endpoint `get`, I am allowed, but `url query`
* Will be enforced with a key named `status`
* And the value is `status=Assigned`
* For endpoint `create`, I am not allowed to access
* For endpoint `assign`, I am allowed, without restriction
Enter fullscreen mode Exit fullscreen mode
ctx
iscontext.Context
object in golang which we usually pass around from end to end
Now comes the part where we should code the RBAC engine to check those rules we have listed.
Rule is the smallest unit in our YAML
It is used to compare actual
incoming request, VS expectation
we have set in Rule
YAML
example:
key: status
operator: "="
value: "New"
--------
key: created_by
operator: "="
value: "ctx.email"
Enter fullscreen mode Exit fullscreen mode
Go code equivalent of a Rule
:
// Rule of a permission
type Rule struct {
Key string `yaml:"key"`
Operator string `yaml:"operator"`
Value string `yaml:"value"`
}
Enter fullscreen mode Exit fullscreen mode
From the YAML
we have 2 types of Rule
-
Rule.Value
={a string}
e.g:status=New
-
Rule.Value
=ctx.{field}
e.g:created_by=ctx.email
For the first type, we take the rule value as is
But for the 2nd type, we have to take the expected
value from context
object, splitted by .
So we have to prepare a method
, owned by Rule
, to get expected
value, from context
.
Let's name it FromContext
// FromContext get actual rule.Value from ctx if rule.Value starts with ctx
// otherwise, return rule.Value as is
func (rule Rule) FromContext(ctx context.Context) interface{} {
...
}
Enter fullscreen mode Exit fullscreen mode
Next, we write unit tests
scenario to ensure FromContext
behave as we wanted it to be:
// Semi BDD style unit testing
// I think the code is self explanatory, we just wanted to:
// call rule.FromContext(ctx), and
// want it to either panics, or
// produce a correct result
func TestRule_FromContext(t *testing.T) {
tests := []struct {
given string
then string
rule rbac.Rule
ctx func() context.Context
want interface{}
panics bool
}{{
given: "Non ctx rule.Value", then: "return value should be rule.Value as is",
rule: rbac.Rule{Value: "something"},
ctx: func() context.Context { return context.Background() },
want: "something",
}, {
given: "rule.Value with ctx", then: "return value should be taken from ctx",
rule: rbac.Rule{Value: "ctx.email"},
ctx: func() context.Context {
return context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
},
want: "[email protected]",
}, {
given: "rule.Value with deep nested ctx", then: "return value should be taken from ctx",
rule: rbac.Rule{Value: "ctx.access.id"},
ctx: func() context.Context {
return context.WithValue(context.Background(), rbac.ContextKey("access"), map[string]interface{}{
"id": "IDX-0001",
})
},
want: "IDX-0001",
}, {
given: "rule.Value with deep nested ctx, but at 4th level its not a map", then: "code should panic",
rule: rbac.Rule{Value: "ctx.access.id.name"},
ctx: func() context.Context {
return context.WithValue(context.Background(), rbac.ContextKey("access"), map[string]interface{}{
"id": "IDX-0001",
})
},
panics: true,
}, {
given: "rule.Value with deep nested ctx, but does not exists", then: "code should panic",
rule: rbac.Rule{Value: "ctx.something.not.exists"},
ctx: func() context.Context {
return context.Background()
},
panics: true,
}}
for _, tt := range tests {
t.Run(tt.given, func(t *testing.T) {
if !tt.panics {
got := tt.rule.FromContext(tt.ctx())
assert.Equal(t, tt.want, got, tt.then)
} else {
assert.Panics(t, func() {
tt.rule.FromContext(tt.ctx())
}, tt.given)
}
})
}
}
Enter fullscreen mode Exit fullscreen mode
Next, we actually write the code to satisfy these test scenario
// FromContext get actual rule.Value from ctx if rule.Value starts with ctx
// otherwise, return rule.Value as is
func (rule Rule) FromContext(ctx context.Context) interface{} {
if !strings.HasPrefix(rule.Value, "ctx") {
return rule.Value
}
paths := strings.Split(rule.Value, ".")
var ctxval interface{}
// starts from 1, as we exclude the ctx part
for i := 1; i < len(paths); i++ {
ctxkey := paths[i]
//Get current context index
if i == 1 {
ctxval = ctx.Value(ContextKey(ctxkey))
} else {
// if rule.Value is nested more than 1 level, we assume the context value is of type map[string]interface{}
// otherwise, panic
var ok bool
kvp := ctxval.(map[string]interface{})
ctxval, ok = kvp[ctxkey]
if !ok || ctxval == nil {
ctxval = nil
}
}
}
return ctxval
}
Enter fullscreen mode Exit fullscreen mode
Running the test (from VS Code)
Running tool: /usr/local/opt/go/libexec/bin/go test -timeout 30s github.com/bastianrob/go-experiences/rbac -run ^(TestRule_FromContext)$
ok github.com/bastianrob/go-experiences/rbac
Success: Tests passed.
Enter fullscreen mode Exit fullscreen mode
Now imagine we have to actually check whether a Request
complies with given Rule
or not
Let's make another method
for Rule
and name it Comply
// Comply checks does request value complies with our rule
func (rule Rule) Comply(expected, actual interface{}) bool {
...
}
Enter fullscreen mode Exit fullscreen mode
We write another unit tests
scenario to ensure Comply
behave as we wanted it to be:
func TestRule_Comply(t *testing.T) {
type args struct {
expected interface{}
actual interface{}
}
tests := []struct {
given string
then string
rule rbac.Rule
args args
want bool
}{{
given: "With rule: actual must be = expected", then: "query complies with our rule",
rule: rbac.Rule{
Operator: "=",
},
args: args{
expected: "something",
actual: "something",
},
want: true,
}, {
given: "With rule: actual must be != expected", then: "query complies with our rule",
rule: rbac.Rule{
Operator: "!=",
},
args: args{
expected: "something",
actual: "another",
},
want: true,
}, {
given: "With rule operator not known", then: "query does not complies",
rule: rbac.Rule{
Operator: "unknwon",
},
want: false,
}}
for _, tt := range tests {
t.Run(tt.given, func(t *testing.T) {
got := tt.rule.Comply(tt.args.expected, tt.args.actual)
assert.Equal(t, tt.want, got, tt.then)
})
}
}
Enter fullscreen mode Exit fullscreen mode
Again, we actually write the code to satisfy these test scenario
// Comply checks does request value complies with our rule
func (rule Rule) Comply(expected, actual interface{}) bool {
switch rule.Operator {
case "!=":
return !reflect.DeepEqual(expected, actual)
case "=":
return reflect.DeepEqual(expected, actual)
}
// doesn't comply if we don't recognize the rule operator
return false
}
Enter fullscreen mode Exit fullscreen mode
Running the test (from VS Code)
Running tool: /usr/local/opt/go/libexec/bin/go test -timeout 30s github.com/bastianrob/go-experiences/rbac -run ^(TestRule_Comply)$
ok github.com/bastianrob/go-experiences/rbac
Success: Tests passed.
Enter fullscreen mode Exit fullscreen mode
Next, we have Ensurer
and Enforcer
. As the name implies:
-
Ensurer
ensure that request must complies with all the rules attached inEnsurer
-
Enforcer
enforce that no matter what you request, we'll always enforce all the rules attached inEnforcer
- Both can be targeted to either
query
,header
, orpath
- And both contains list of
Rules
we have defined in the previous point
// Ensurer data model
// Can either ensure query, header, or path
type Ensurer struct {
Query []Rule `yaml:"query"`
Header []Rule `yaml:"header"`
Path []Rule `yaml:"path"`
}
// Enforcer structure is just like an Ensurer
type Enforcer Ensurer
Enter fullscreen mode Exit fullscreen mode
For this example, we'll only implement Query
Ensurer
and Enforcer
// QueryComplies check whether query request complies with rules
func (ens Ensurer) QueryComplies(r *http.Request) error {
...
}
// QueryComplies enforce query request from rule
func (enf Enforcer) QueryComplies(r *http.Request) error {
...
}
Enter fullscreen mode Exit fullscreen mode
Then, we write unit tests
to ensure QueryComplies
behave as we wanted it to be:
func TestEnsurer_QueryComplies(t *testing.T) {
type args struct {
method string
url string
}
tests := []struct {
given string
then string
ensurer rbac.Ensurer
context func() context.Context
args args
wantErr bool
}{{
given: "Query: id=0001&name=John and Rule: id=0001&name=ctx.name and ctx.name=John",
then: "QueryComplies must not return error",
args: args{
// query: {id: "0001", name: "John"}
url: "http://api.example.com/resources?id=0001&name=John",
},
ensurer: rbac.Ensurer{
Query: []rbac.Rule{
// id IS 0001, and name EQUALS to value stored in context.name
{Key: "id", Operator: "=", Value: "0001"},
{Key: "name", Operator: "=", Value: "ctx.name"},
},
},
context: func() context.Context {
// we give the context.name = "John"
return context.WithValue(context.Background(), rbac.ContextKey("name"), "John")
},
}}
for _, tt := range tests {
t.Run(tt.given, func(t *testing.T) {
r, _ := http.NewRequest(tt.args.method, tt.args.url, nil)
r = r.WithContext(tt.context())
err := tt.ensurer.QueryComplies(r)
if tt.wantErr {
assert.Error(t, err, tt.given)
} else {
assert.NoError(t, err, tt.given)
}
})
}
}
func TestEnforcer_QueryComplies(t *testing.T) {
type args struct {
method string
url string
}
tests := []struct {
given string
then string
enforcer rbac.Enforcer
context func() context.Context
args args
want map[string]string
wantErr bool
}{{
given: "Query: id=nil&name=nil and Rule: id=0001&name=ctx.name and ctx.name=John",
then: "QueryComplies must not return error, and query must be re-written by enforcer",
args: args{
url: "http://api.example.com/resources?id=nil&name=nil",
},
enforcer: rbac.Enforcer{
Query: []rbac.Rule{
// query: {id: "0001", name: "John"}
{Key: "id", Value: "0001"},
{Key: "name", Value: "ctx.name"},
},
},
context: func() context.Context {
// we give the context.name = "John"
return context.WithValue(context.Background(), rbac.ContextKey("name"), "John")
},
want: map[string]string{
"id": "0001",
"name": "John",
},
}}
for _, tt := range tests {
t.Run(tt.given, func(t *testing.T) {
r, _ := http.NewRequest(tt.args.method, tt.args.url, nil)
r = r.WithContext(tt.context())
err := tt.enforcer.QueryComplies(r)
if tt.wantErr {
assert.Error(t, err, tt.given)
} else {
assert.NoError(t, err, tt.given)
assert.Equal(t, len(tt.want), len(r.URL.Query()))
for key, val := range tt.want {
assert.Equal(t, val, r.URL.Query().Get(key), tt.then)
}
}
})
}
}
Enter fullscreen mode Exit fullscreen mode
Then, we write actual QueryComplies
code which satisfy our unit tests
:
// QueryComplies check whether query request complies with rules
func (ens Ensurer) QueryComplies(r *http.Request) error {
if ens.Query == nil || len(ens.Query) <= 0 {
return nil
}
ctx := r.Context()
for _, rule := range ens.Query {
actual := r.URL.Query().Get(rule.Key)
expected := rule.FromContext(ctx)
if !rule.Comply(expected, actual) {
return fmt.Errorf("Query rule violation: ensure '%s' %s '%v', instead got: '%s'",
rule.Key, rule.Operator, expected, actual)
}
}
// all query complies with rules
return nil
}
// QueryComplies enforce query request from rule
func (enf Enforcer) QueryComplies(r *http.Request) error {
q := r.URL.Query()
ctx := r.Context()
for _, rule := range enf.Query {
expected := rule.FromContext(ctx)
valueStr, isString := expected.(string)
if !isString {
return ErrNotString
}
q.Set(rule.Key, valueStr)
}
r.URL.RawQuery = q.Encode()
// all query enforced with rules
return nil
}
Enter fullscreen mode Exit fullscreen mode
And the test result is:
Running tool: /usr/local/opt/go/libexec/bin/go test -timeout 30s github.com/bastianrob/go-experiences/rbac -run ^(TestEnsurer_QueryComplies)$
ok github.com/bastianrob/go-experiences/rbac 0.014s
Success: Tests passed.
--------
Running tool: /usr/local/opt/go/libexec/bin/go test -timeout 30s github.com/bastianrob/go-experiences/rbac -run ^(TestEnforcer_QueryComplies)$
ok github.com/bastianrob/go-experiences/rbac 0.014s
Success: Tests passed.
Enter fullscreen mode Exit fullscreen mode
Now we have:
-
Ensurer
andEnforcer
- Each have their own set of
Rules
- Lastly we need to stitch them all together in an
RBAC
object
// Error collection
var (
ErrNotString = errors.New("Expected value is not a string")
ErrNoRole = errors.New("You have no role assigned to you")
ErrRoleUnknown = errors.New("You have an unknown role assigned to you")
ErrForbidden = errors.New("You are not allowed to access specified resource")
)
// Permission of an endpoint
type Permission struct {
Allow bool `yaml:"allow"`
Ensure Ensurer `yaml:"ensure,omitempty"`
Enforce Enforcer `yaml:"enforce,omitempty"`
}
// Endpoint is a map of {endpoint: permission}
type Endpoint map[string]Permission
// Resource is a map of {resource: endpoint}
type Resource map[string]Endpoint
// RBAC is a map of {role: resource}
type RBAC map[string]Resource
// FromFile creates a new RBAC object from .yaml file
func FromFile(path string) *RBAC {
f, err := ioutil.ReadFile(path)
if err != nil {
return nil
}
rbac := &RBAC{}
err = yaml.Unmarshal(f, rbac)
if err != nil {
return nil
}
return rbac
}
Enter fullscreen mode Exit fullscreen mode
Now we have an actual object called RBAC
, we can parse YAML
rule into RBAC
using FromFile
factory function.
Next, we have to write Authorize
method for RBAC
// Authorize a request based on its role, resource, and endpoint
func (rbac RBAC) Authorize(r *http.Request, role, resource, endpoint string) error {
...
}
Enter fullscreen mode Exit fullscreen mode
And we write a unit tests
for Authorize
:
func TestRBAC_Authorize(t *testing.T) {
rbo := rbac.FromFile("./test.yaml")
fmt.Printf("%+v", rbo)
type args struct {
req func() *http.Request
role string
resource string
endpoint string
}
tests := []struct {
given, when, then string
args args
wantErr bool
queryResult map[string]string
}{
// As a client
{
given: "Role is Client & email = [email protected]",
when: "[email protected]", then: "is allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/[email protected]", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "client",
resource: "inquiry",
endpoint: "get",
},
}, {
given: "Role is Client & email = [email protected]",
when: "[email protected]", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/[email protected]", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "client",
resource: "inquiry",
endpoint: "get",
},
wantErr: true,
}, {
given: "Role is Client & email = [email protected]",
when: "query is not given", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "client",
resource: "inquiry",
endpoint: "get",
},
wantErr: true,
}, {
given: "Role is Client & email = [email protected]",
when: "trying to create", then: "is allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "client",
resource: "inquiry",
endpoint: "create",
},
}, {
given: "Role is Client & email = [email protected]",
when: "trying to assign", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries/INQ-0001/assign", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "client",
resource: "inquiry",
endpoint: "assign",
},
wantErr: true,
},
// As CS
{
given: "Role is CS",
when: "query is not given", then: "status=New is enforced",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "cs",
resource: "inquiry",
endpoint: "get",
},
queryResult: map[string]string{
"status": "New",
},
}, {
given: "Role is CS",
when: "query ?status is given", then: "status=New is still enforced",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/inquiries?status=Assigned", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "cs",
resource: "inquiry",
endpoint: "get",
},
queryResult: map[string]string{
"status": "New",
},
}, {
given: "Role is CS",
when: "trying to create", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "cs",
resource: "inquiry",
endpoint: "create",
},
wantErr: true,
}, {
given: "Role is CS",
when: "trying to assign", then: "is allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries/INQ-0001/assign", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "cs",
resource: "inquiry",
endpoint: "assign",
},
},
// As an Ops
{
given: "Role is Ops & email = [email protected]",
when: "[email protected]", then: "is allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/[email protected]", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "ops",
resource: "inquiry",
endpoint: "get",
},
}, {
given: "Role is Ops & email = [email protected]",
when: "[email protected]", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/[email protected]", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "ops",
resource: "inquiry",
endpoint: "get",
},
wantErr: true,
}, {
given: "Role is Ops & email = [email protected]",
when: "query is not supplied", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "ops",
resource: "inquiry",
endpoint: "get",
},
wantErr: true,
}, {
given: "Role is Ops & email = [email protected]",
when: "trying to created", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "ops",
resource: "inquiry",
endpoint: "create",
},
wantErr: true,
}, {
given: "Role is Ops & email = [email protected]",
when: "trying to assign", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries/INQ-0001/assign", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "ops",
resource: "inquiry",
endpoint: "assign",
},
wantErr: true,
},
// As a manager
{
given: "Role is Manager",
when: "query is not given", then: "status=Assigned is enforced",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "manager",
resource: "inquiry",
endpoint: "get",
},
queryResult: map[string]string{
"status": "Assigned",
},
}, {
given: "Role is Manager",
when: "query ?status is given", then: "status=Assigned is still enforced",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("", "http://api.example.com/inquiries?status=New", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "manager",
resource: "inquiry",
endpoint: "get",
},
queryResult: map[string]string{
"status": "Assigned",
},
}, {
given: "Role is Manager",
when: "trying to create", then: "is not allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "manager",
resource: "inquiry",
endpoint: "create",
},
wantErr: true,
}, {
given: "Role is Manager",
when: "trying to assign", then: "is allowed",
args: args{
req: func() *http.Request {
req, _ := http.NewRequest("POST", "http://api.example.com/inquiries/INQ-0001/assign", nil)
ctx := context.WithValue(context.Background(), rbac.ContextKey("email"), "[email protected]")
return req.WithContext(ctx)
},
role: "manager",
resource: "inquiry",
endpoint: "assign",
},
},
}
for _, tt := range tests {
t.Run(tt.given, func(t *testing.T) {
req := tt.args.req()
got := rbo.Authorize(req, tt.args.role, tt.args.resource, tt.args.endpoint)
if tt.wantErr {
assert.Error(t, got, "when: %s, then: %s", tt.when, tt.then)
} else {
assert.NoError(t, got, "when: %s, then: %s", tt.when, tt.then)
for key, val := range tt.queryResult {
assert.Equal(t, val, req.URL.Query().Get(key), "when: %s, then: %s", tt.when, tt.then)
}
}
})
}
}
Enter fullscreen mode Exit fullscreen mode
Last, we write Authorize
code to satisf our unit tests
// Authorize a request based on its role, resource, and endpoint
func (rbac RBAC) Authorize(r *http.Request, role, resource, endpoint string) error {
permission, exists := rbac[role][resource][endpoint]
if !exists {
return ErrRoleUnknown
}
if !permission.Allow {
return ErrForbidden
}
// Ensure query compliance
err := permission.Ensure.QueryComplies(r)
if err != nil {
return err
}
// Enforce query compliance
err = permission.Enforce.QueryComplies(r)
if err != nil {
return err
}
return nil
}
Enter fullscreen mode Exit fullscreen mode
And the test results shows:
Running tool: /usr/local/opt/go/libexec/bin/go test -timeout 30s github.com/bastianrob/go-experiences/rbac -run ^(TestRBAC_Authorize)$
ok github.com/bastianrob/go-experiences/rbac (cached)
Success: Tests passed.
Enter fullscreen mode Exit fullscreen mode
We see a lot of ctx
being thrown around so, let me explain it in chronologically, from end-to-end
* Think of `ctx` as browser `cookies`
* As a user logged in our application, we respond to them by setting `cookie` into the browser
* This `cookie` is typically a string of encrypted data about logged in user's profile (think `JWT`)
* This `cookie` will always get sent to server for each and every request to our domain
* At the start of the request, our code have to:
* Check existence of `cookie`
* Returns 401 if not exists (unauthorized)
* Proceed if exists
* Unwrap the `cookie` value, which in our case typpically contains `role` and `email` of the logged in user
* And then, we set all of the unwrapped `cookie` value into HTTP request `context`
* In this case we set `ctx.name`
* Then, we call the `RBAC` engine to `Authorize` the request
* `RBAC` ensures the request satisfy all rules inside the `Ensurer`
* `RBAC` enforce all rules inside the `Enforcer` by rewriting the HTTP request object
* If HTTP request pass all the rules in `RBAC` engine, we then proceed to pass the HTTP request to `Business Service Layer`
* Because `RBAC` engine relies heavily on the HTTP query, `Business Service Layer` must be written to always converts the HTTP query into actual `Database Query`. e.g:
* `/[email protected]` must be translated to `SELECT * FROM inquiries WHERE created_by = @url.query.created_by`
* `/inquiries?status=New` must be translated to `SELECT * FROM inquiries WHERE status = @url.query.status`
Enter fullscreen mode Exit fullscreen mode
Let us take a journey and assume position as each of the roles
client:
inquiry:
get:
allow: true
ensure:
query:
- key: created_by
operator: "="
value: "ctx.email"
create:
allow: true
assign:
allow: false
Enter fullscreen mode Exit fullscreen mode
* I am currently logged in as `[email protected]`
* I am trying to access `GET /inquiries`
* `RBAC` states: `ensure query: created_by = ctx.email`
* `GET /inquiries` doesn't have any query, therefore rule is not satisfied
* Returned as `Unauthorized`
* I am trying to access `GET /[email protected]`
* `RBAC` states: `ensure query: created_by = ctx.email`
* `GET /[email protected]` have the `created_by` query and will be compared against `ctx.email`
* `ctx.email` is `[email protected]`, but supplied query is `[email protected]`, therefore rule is not satisfied
* Returned as `Unauthorized`
Enter fullscreen mode Exit fullscreen mode
So now as a Client
even though I can read and construct a query, I still can't get any data from other people
cs:
inquiry:
get:
allow: true
enforce:
query:
- key: status
value: "New"
create:
allow: false
assign:
allow: true
Enter fullscreen mode Exit fullscreen mode
* I am currently logged in as `[email protected]`
* I am trying to access `GET /inquiries`
* `RBAC` states: `enforce query: status = New`
* `GET /inquiries` doesn't have any query, but `Enforcer` will forcefully write it as `?status=New`
* I am trying to access `GET /inquiries?status=Assigned`
* `RBAC` states: `enforce query: status = New`
* `GET /inquiries?status=Assigned` have the `status` query and the value is `Assigned`
* `Enforcer` doesn't care with the value requested, and will forcefully re-write it as `?status=New`
Enter fullscreen mode Exit fullscreen mode
So now as a CS
even though I can read and construct a query, I can only get inquiries
with status=New
ops:
inquiry:
get:
allow: true
ensure:
query:
- key: assignee
operator: "="
value: "ctx.email"
create:
allow: false
assign:
allow: false
Enter fullscreen mode Exit fullscreen mode
* I am currently logged in as `[email protected]`
* I am trying to access `GET /inquiries`
* `RBAC` states: `ensure query: assignee = ctx.email`
* `GET /inquiries` doesn't have any query, therefore rule is not satisfied
* Returned as `Unauthorized`
* I am trying to access `GET /[email protected]`
* `RBAC` states: `ensure query: status = ctx.email`
* `GET /[email protected]` have the `created_by` query and will be compared against `ctx.email`
* `ctx.email` is `[email protected]`, but supplied query is `[email protected]`, therefore rule is not satisfied
* Returned as `Unauthorized`
Enter fullscreen mode Exit fullscreen mode
So now as an Ops
even though I can read and construct a query, I still can't get any data from any other Ops
manager:
inquiry:
get:
allow: true
enforce:
query:
- key: status
value: "Assigned"
create:
allow: false
assign:
allow: true
Enter fullscreen mode Exit fullscreen mode
* I am currently logged in as `[email protected]`
* I am trying to access `GET /inquiries`
* `RBAC` states: `enforce query: status = Assigned`
* `GET /inquiries` doesn't have any query, but `Enforcer` will forcefully write it as `?status=Assigned`
* I am trying to access `GET /inquiries?status=New`
* `RBAC` states: `enforce query: status = Assigned`
* `GET /inquiries?status=New` have the `status` query and the value is `New`
* `Enforcer` doesn't care with the value requested, and will will forcefully re-write it as `?status=Assigned`
Enter fullscreen mode Exit fullscreen mode
So now as a Manager
even though I can read and construct a query, I can only get inquiries
with status=Assigned