chore: Move scope into the same auth call (#4162)

Scopes now are enforced in the same Authorize call as the roles. 
Vs 2 `Authorize()` calls
This commit is contained in:
Steven Masley
2022-09-23 11:07:30 -04:00
committed by GitHub
parent 4183c5e1d0
commit 2e30d0512e
4 changed files with 307 additions and 239 deletions

View File

@ -3,6 +3,7 @@ package rbac
import (
"context"
_ "embed"
"fmt"
"github.com/open-policy-agent/opa/rego"
"go.opentelemetry.io/otel/attribute"
@ -70,12 +71,20 @@ var _ Authorizer = (*RegoAuthorizer)(nil)
//go:embed policy.rego
var policy string
const (
rolesOkCheck = "role_ok"
scopeOkCheck = "scope_ok"
)
func NewAuthorizer() (*RegoAuthorizer, error) {
ctx := context.Background()
query, err := rego.New(
// allowed is the `allow` field from the prepared query. This is the field to check if authorization is
// granted.
rego.Query("data.authz.allow"),
// Bind the results to 2 variables for easy checking later.
rego.Query(
fmt.Sprintf("%s := data.authz.role_allow "+
"%s := data.authz.scope_allow",
rolesOkCheck, scopeOkCheck),
),
rego.Module("policy.rego", policy),
).PrepareForEval(ctx)
@ -88,6 +97,7 @@ func NewAuthorizer() (*RegoAuthorizer, error) {
type authSubject struct {
ID string `json:"id"`
Roles []Role `json:"roles"`
Scope Role `json:"scope"`
}
// ByRoleName will expand all roleNames into roles before calling Authorize().
@ -99,22 +109,14 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa
return err
}
err = a.Authorize(ctx, subjectID, roles, action, object)
scopeRole, err := ScopeRole(scope)
if err != nil {
return err
}
// If the scope isn't "any", we need to check with the scope's role as well.
if scope != ScopeAll {
scopeRole, err := ScopeRole(scope)
if err != nil {
return err
}
err = a.Authorize(ctx, subjectID, []Role{scopeRole}, action, object)
if err != nil {
return err
}
err = a.Authorize(ctx, subjectID, roles, scopeRole, action, object)
if err != nil {
return err
}
return nil
@ -122,7 +124,7 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa
// Authorize allows passing in custom Roles.
// This is really helpful for unit testing, as we can create custom roles to exercise edge cases.
func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, action Action, object Object) error {
func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, scope Role, action Action, object Object) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
@ -130,6 +132,7 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [
"subject": authSubject{
ID: subjectID,
Roles: roles,
Scope: scope,
},
"object": object,
"action": action,
@ -140,16 +143,36 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [
return ForbiddenWithInternal(xerrors.Errorf("eval rego: %w", err), input, results)
}
if !results.Allowed() {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
}
// We expect only the 2 bindings for scopes and roles checks.
if len(results) == 1 && len(results[0].Bindings) == 2 {
roleCheck, ok := results[0].Bindings[rolesOkCheck].(bool)
if !ok || !roleCheck {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
}
return nil
scopeCheck, ok := results[0].Bindings[scopeOkCheck].(bool)
if !ok || !scopeCheck {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
}
// This is purely defensive programming. The two above checks already
// check for 'true' expressions. This is just a sanity check to make
// sure we don't add non-boolean expressions to our query.
// This is super cheap to do, and just adds in some extra safety for
// programmer error.
for _, exp := range results[0].Expressions {
if b, ok := exp.Value.(bool); !ok || !b {
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
}
}
return nil
}
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
}
// Prepare will partially execute the rego policy leaving the object fields unknown (except for the type).
// This will vastly speed up performance if batch authorization on the same type of objects is needed.
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) {
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Role, action Action, objectType string) (*PartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
@ -170,5 +193,10 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string,
return nil, err
}
return a.Prepare(ctx, subjectID, roles, scope, action, objectType)
scopeRole, err := ScopeRole(scope)
if err != nil {
return nil, err
}
return a.Prepare(ctx, subjectID, roles, scopeRole, action, objectType)
}

View File

@ -20,6 +20,7 @@ type subject struct {
// by name. This allows us to test custom roles that do not exist in the product,
// but test edge cases of the implementation.
Roles []Role `json:"roles"`
Scope Role `json:"scope"`
}
type fakeObject struct {
@ -231,6 +232,7 @@ func TestAuthorizeDomain(t *testing.T) {
user = subject{
UserID: "me",
Scope: must(ScopeRole(ScopeAll)),
Roles: []Role{{
Name: "deny-all",
// List out deny permissions explicitly
@ -271,6 +273,7 @@ func TestAuthorizeDomain(t *testing.T) {
user = subject{
UserID: "me",
Scope: must(ScopeRole(ScopeAll)),
Roles: []Role{
must(RoleByName(RoleOrgAdmin(defOrg))),
must(RoleByName(RoleMember())),
@ -304,6 +307,7 @@ func TestAuthorizeDomain(t *testing.T) {
user = subject{
UserID: "me",
Scope: must(ScopeRole(ScopeAll)),
Roles: []Role{
must(RoleByName(RoleOwner())),
must(RoleByName(RoleMember())),
@ -335,90 +339,108 @@ func TestAuthorizeDomain(t *testing.T) {
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: true},
})
// In practice this is a token scope on a regular subject.
// So this unit test does not represent a practical role. It is just
// testing the capabilities of the RBAC system.
user = subject{
UserID: "me",
Scope: must(ScopeRole(ScopeApplicationConnect)),
Roles: []Role{
{
Name: "WorkspaceToken",
// This is at the site level to prevent the token from losing access if the user
// is kicked from the org
Site: []Permission{
{
Negate: false,
ResourceType: ResourceWorkspace.Type,
Action: ActionRead,
},
},
},
must(RoleByName(RoleOrgMember(defOrg))),
must(RoleByName(RoleMember())),
},
}
testAuthorize(t, "WorkspaceToken", user,
// Read Actions
testAuthorize(t, "ApplicationToken", user,
// Create (connect) Actions
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionRead}
c.actions = []Action{ActionCreate}
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg), allow: false},
{resource: ResourceWorkspace.WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspaceApplicationConnect.WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.All(), allow: true},
{resource: ResourceWorkspaceApplicationConnect.All(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID), allow: true},
{resource: ResourceWorkspace.InOrg(unuseID), allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID).WithOwner(user.UserID), allow: false},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: true},
{resource: ResourceWorkspaceApplicationConnect.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspace.InOrg(unuseID), allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: true},
{resource: ResourceWorkspaceApplicationConnect.WithOwner("not-me"), allow: false},
}),
// Not read actions
// Not create actions
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionCreate, ActionUpdate, ActionDelete}
c.actions = []Action{ActionRead, ActionUpdate, ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner(user.UserID)},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.UserID)},
{resource: ResourceWorkspaceApplicationConnect.WithOwner(user.UserID)},
{resource: ResourceWorkspace.All()},
{resource: ResourceWorkspaceApplicationConnect.All()},
// Other org + me
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(unuseID)},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID).WithOwner(user.UserID)},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID)},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspaceApplicationConnect.WithOwner("not-me")},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unuseID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unuseID)},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID).WithOwner("not-me")},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unuseID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspaceApplicationConnect.WithOwner("not-me")},
}),
// Other Objects
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionCreate, ActionRead, ActionUpdate, ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Org + me
{resource: ResourceTemplate.InOrg(defOrg).WithOwner(user.UserID)},
{resource: ResourceTemplate.InOrg(defOrg)},
{resource: ResourceTemplate.WithOwner(user.UserID)},
{resource: ResourceTemplate.All()},
// Other org + me
{resource: ResourceTemplate.InOrg(unuseID).WithOwner(user.UserID)},
{resource: ResourceTemplate.InOrg(unuseID)},
// Other org + other user
{resource: ResourceTemplate.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceTemplate.WithOwner("not-me")},
// Other org + other use
{resource: ResourceTemplate.InOrg(unuseID).WithOwner("not-me")},
{resource: ResourceTemplate.InOrg(unuseID)},
{resource: ResourceTemplate.WithOwner("not-me")},
}),
)
// In practice this is a token scope on a regular subject
user = subject{
UserID: "me",
Scope: must(ScopeRole(ScopeAll)),
Roles: []Role{
{
Name: "ReadOnlyOrgAndUser",
@ -511,6 +533,7 @@ func TestAuthorizeLevels(t *testing.T) {
user := subject{
UserID: "me",
Scope: must(ScopeRole(ScopeAll)),
Roles: []Role{
must(RoleByName(RoleOwner())),
{
@ -571,6 +594,7 @@ func TestAuthorizeLevels(t *testing.T) {
user = subject{
UserID: "me",
Scope: must(ScopeRole(ScopeAll)),
Roles: []Role{
{
Name: "site-noise",
@ -634,27 +658,69 @@ func TestAuthorizeScope(t *testing.T) {
unusedID := uuid.New()
user := subject{
UserID: "me",
Roles: []Role{},
Roles: []Role{must(RoleByName(RoleOwner()))},
Scope: must(ScopeRole(ScopeApplicationConnect)),
}
user.Roles = []Role{must(ScopeRole(ScopeApplicationConnect))}
testAuthorize(t, "Admin_ScopeApplicationConnect", user, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.All(), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: allActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: allActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: allActions(), allow: false},
testAuthorize(t, "Admin_ScopeApplicationConnect", user,
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionRead, ActionUpdate, ActionDelete}
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg), allow: false},
{resource: ResourceWorkspace.WithOwner(user.UserID), allow: false},
{resource: ResourceWorkspace.All(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
}),
// Allowed by scope:
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner("not-me"), actions: []Action{ActionCreate}, allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner(user.UserID), actions: []Action{ActionCreate}, allow: true},
})
[]authTestCase{
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner("not-me"), actions: []Action{ActionCreate}, allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner(user.UserID), actions: []Action{ActionCreate}, allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unusedID).WithOwner("not-me"), actions: []Action{ActionCreate}, allow: true},
},
)
user = subject{
UserID: "me",
Roles: []Role{
must(RoleByName(RoleMember())),
must(RoleByName(RoleOrgMember(defOrg))),
},
Scope: must(ScopeRole(ScopeApplicationConnect)),
}
testAuthorize(t, "User_ScopeApplicationConnect", user,
cases(func(c authTestCase) authTestCase {
c.actions = []Action{ActionRead, ActionUpdate, ActionDelete}
c.allow = false
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.UserID)},
{resource: ResourceWorkspace.All()},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.UserID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
// Allowed by scope:
[]authTestCase{
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner(user.UserID), actions: []Action{ActionCreate}, allow: true},
{resource: ResourceWorkspaceApplicationConnect.InOrg(defOrg).WithOwner("not-me"), actions: []Action{ActionCreate}, allow: false},
{resource: ResourceWorkspaceApplicationConnect.InOrg(unusedID).WithOwner("not-me"), actions: []Action{ActionCreate}, allow: false},
},
)
}
// cases applies a given function to all test cases. This makes generalities easier to create.
@ -691,7 +757,7 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, a, c.resource)
authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, subject.Scope, a, c.resource)
// Logging only
if authError != nil {
@ -716,41 +782,28 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes
assert.Error(t, authError, "expected unauthorized")
}
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, ScopeAll, a, c.resource.Type)
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, subject.Scope, a, c.resource.Type)
require.NoError(t, err, "make prepared authorizer")
// Also check the rego policy can form a valid partial query result.
// This ensures we can convert the queries into SQL WHERE clauses in the future.
// If this function returns 'Support' sections, then we cannot convert the query into SQL.
if len(partialAuthz.mainAuthorizer.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.mainAuthorizer.input)
t.Logf("input: %s", string(d))
for _, q := range partialAuthz.mainAuthorizer.partialQueries.Queries {
t.Logf("query: %+v", q.String())
}
for _, s := range partialAuthz.mainAuthorizer.partialQueries.Support {
t.Logf("support: %+v", s.String())
}
d, _ := json.Marshal(partialAuthz.input)
t.Logf("input: %s", string(d))
for _, q := range partialAuthz.partialQueries.Queries {
t.Logf("query: %+v", q.String())
}
if partialAuthz.scopeAuthorizer != nil {
if len(partialAuthz.scopeAuthorizer.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.scopeAuthorizer.input)
t.Logf("scope input: %s", string(d))
for _, q := range partialAuthz.scopeAuthorizer.partialQueries.Queries {
t.Logf("scope query: %+v", q.String())
}
for _, s := range partialAuthz.scopeAuthorizer.partialQueries.Support {
t.Logf("scope support: %+v", s.String())
}
}
require.Equal(t, 0, len(partialAuthz.scopeAuthorizer.partialQueries.Support), "expected 0 support rules in scope authorizer")
for _, s := range partialAuthz.partialQueries.Support {
t.Logf("support: %+v", s.String())
}
require.Equal(t, 0, len(partialAuthz.partialQueries.Support), "expected 0 support rules in scope authorizer")
partialErr := partialAuthz.Authorize(ctx, c.resource)
if authError != nil {
assert.Error(t, partialErr, "partial error blocked valid request (false negative)")
assert.Error(t, partialErr, "partial allowed invalid request (false positive)")
} else {
assert.NoError(t, partialErr, "partial allowed invalid request (false positive)")
assert.NoError(t, partialErr, "partial error blocked valid request (false negative)")
}
}
})

View File

@ -11,59 +11,6 @@ import (
)
type PartialAuthorizer struct {
// mainAuthorizer is used for the user's roles. It is always not-nil.
mainAuthorizer *subPartialAuthorizer
// scopeAuthorizer is used for the API key scope. It may be nil.
scopeAuthorizer *subPartialAuthorizer
}
var _ PreparedAuthorized = (*PartialAuthorizer)(nil)
func (pa *PartialAuthorizer) Authorize(ctx context.Context, object Object) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
err := pa.mainAuthorizer.Authorize(ctx, object)
if err != nil {
return err
}
if pa.scopeAuthorizer != nil {
return pa.scopeAuthorizer.Authorize(ctx, object)
}
return nil
}
func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
pAuth, err := newSubPartialAuthorizer(ctx, subjectID, roles, action, objectType)
if err != nil {
return nil, err
}
var scopeAuth *subPartialAuthorizer
if scope != ScopeAll {
scopeRole, err := ScopeRole(scope)
if err != nil {
return nil, xerrors.Errorf("unknown scope %q", scope)
}
scopeAuth, err = newSubPartialAuthorizer(ctx, subjectID, []Role{scopeRole}, action, objectType)
if err != nil {
return nil, err
}
}
return &PartialAuthorizer{
mainAuthorizer: pAuth,
scopeAuthorizer: scopeAuth,
}, nil
}
type subPartialAuthorizer struct {
// partialQueries is mainly used for unit testing to assert our rego policy
// can always be compressed into a set of queries.
partialQueries *rego.PartialQueries
@ -78,73 +25,13 @@ type subPartialAuthorizer struct {
alwaysTrue bool
}
func newSubPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*subPartialAuthorizer, error) {
var _ PreparedAuthorized = (*PartialAuthorizer)(nil)
func (pa *PartialAuthorizer) Authorize(ctx context.Context, object Object) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
input := map[string]interface{}{
"subject": authSubject{
ID: subjectID,
Roles: roles,
},
"object": map[string]string{
"type": objectType,
},
"action": action,
}
// Run the rego policy with a few unknown fields. This should simplify our
// policy to a set of queries.
partialQueries, err := rego.New(
rego.Query("true = data.authz.allow"),
rego.Module("policy.rego", policy),
rego.Unknowns([]string{
"input.object.owner",
"input.object.org_owner",
}),
rego.Input(input),
).Partial(ctx)
if err != nil {
return nil, xerrors.Errorf("prepare: %w", err)
}
pAuth := &subPartialAuthorizer{
partialQueries: partialQueries,
preparedQueries: []rego.PreparedEvalQuery{},
input: input,
}
// Prepare each query to optimize the runtime when we iterate over the objects.
preparedQueries := make([]rego.PreparedEvalQuery, 0, len(partialQueries.Queries))
for _, q := range partialQueries.Queries {
if q.String() == "" {
// No more work needed. An empty query is the same as
// 'WHERE true'
// This is likely an admin. We don't even need to use rego going
// forward.
pAuth.alwaysTrue = true
preparedQueries = []rego.PreparedEvalQuery{}
break
}
results, err := rego.New(
rego.ParsedQuery(q),
).PrepareForEval(ctx)
if err != nil {
return nil, xerrors.Errorf("prepare query %s: %w", q.String(), err)
}
preparedQueries = append(preparedQueries, results)
}
pAuth.preparedQueries = preparedQueries
return pAuth, nil
}
// Authorize authorizes a single object using the partially prepared queries.
func (a subPartialAuthorizer) Authorize(ctx context.Context, object Object) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
if a.alwaysTrue {
if pa.alwaysTrue {
return nil
}
@ -159,7 +46,7 @@ func (a subPartialAuthorizer) Authorize(ctx context.Context, object Object) erro
// all boolean expressions. In the above 1st example, there are 2.
// These expressions within a single query are `AND` together by rego.
EachQueryLoop:
for _, q := range a.preparedQueries {
for _, q := range pa.preparedQueries {
// We need to eval each query with the newly known fields.
results, err := q.Eval(ctx, rego.EvalInput(map[string]interface{}{
"object": object,
@ -204,5 +91,67 @@ EachQueryLoop:
return nil
}
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), a.input, nil)
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), pa.input, nil)
}
func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, scope Role, action Action, objectType string) (*PartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
input := map[string]interface{}{
"subject": authSubject{
ID: subjectID,
Roles: roles,
Scope: scope,
},
"object": map[string]string{
"type": objectType,
},
"action": action,
}
// Run the rego policy with a few unknown fields. This should simplify our
// policy to a set of queries.
partialQueries, err := rego.New(
rego.Query("data.authz.role_allow = true data.authz.scope_allow = true"),
rego.Module("policy.rego", policy),
rego.Unknowns([]string{
"input.object.owner",
"input.object.org_owner",
}),
rego.Input(input),
).Partial(ctx)
if err != nil {
return nil, xerrors.Errorf("prepare: %w", err)
}
pAuth := &PartialAuthorizer{
partialQueries: partialQueries,
preparedQueries: []rego.PreparedEvalQuery{},
input: input,
}
// Prepare each query to optimize the runtime when we iterate over the objects.
preparedQueries := make([]rego.PreparedEvalQuery, 0, len(partialQueries.Queries))
for _, q := range partialQueries.Queries {
if q.String() == "" {
// No more work needed. An empty query is the same as
// 'WHERE true'
// This is likely an admin. We don't even need to use rego going
// forward.
pAuth.alwaysTrue = true
preparedQueries = []rego.PreparedEvalQuery{}
break
}
results, err := rego.New(
rego.ParsedQuery(q),
).PrepareForEval(ctx)
if err != nil {
return nil, xerrors.Errorf("prepare query %s: %w", q.String(), err)
}
preparedQueries = append(preparedQueries, results)
}
pAuth.preparedQueries = preparedQueries
return pAuth, nil
}

View File

@ -2,8 +2,8 @@ package authz
import future.keywords
# A great playground: https://play.openpolicyagent.org/
# Helpful cli commands to debug.
# opa eval --format=pretty 'data.authz.allow = true' -d policy.rego -i input.json
# opa eval --partial --format=pretty 'data.authz.allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner -i input.json
# opa eval --format=pretty 'data.authz.role_allow data.authz.scope_allow' -d policy.rego -i input.json
# opa eval --partial --format=pretty 'data.authz.role_allow = true data.authz.scope_allow = true' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner -i input.json
#
# This policy is specifically constructed to compress to a set of queries if the
@ -64,11 +64,15 @@ number(set) = c {
# from [-1, 1]. The number corresponds to "negative", "abstain", and "positive"
# for the given level. See the 'allow' rules for how these numbers are used.
default site = 0
site := num {
site := site_allow(input.subject.roles)
default scope_site := 0
scope_site := site_allow([input.subject.scope])
site_allow(roles) := num {
# allow is a set of boolean values without duplicates.
allow := { x |
# Iterate over all site permissions in all roles
perm := input.subject.roles[_].site[_]
perm := roles[_].site[_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
# x is either 'true' or 'false' if a matching permission exists.
@ -85,11 +89,15 @@ org_members := { orgID |
# org is the same as 'site' except we need to iterate over each organization
# that the actor is a member of.
default org = 0
org := num {
org := org_allow(input.subject.roles)
default scope_org := 0
scope_org := org_allow([input.scope])
org_allow(roles) := num {
allow := { id: num |
id := org_members[_]
set := { x |
perm := input.subject.roles[_].org[id][_]
perm := roles[_].org[id][_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
x := bool_flip(perm.negate)
@ -120,11 +128,15 @@ org_mem := true {
# User is the same as the site, except it only applies if the user owns the object and
# the user is apart of the org (if the object has an org).
default user = 0
user := num {
user := user_allow(input.subject.roles)
default user_scope := 0
scope_user := user_allow([input.scope])
user_allow(roles) := num {
input.object.owner != ""
input.subject.id = input.object.owner
allow := { x |
perm := input.subject.roles[_].user[_]
perm := roles[_].user[_]
perm.action in [input.action, "*"]
perm.resource_type in [input.object.type, "*"]
x := bool_flip(perm.negate)
@ -136,19 +148,25 @@ user := num {
# Authorization looks for any `allow` statement that is true. Multiple can be true!
# Note that the absence of `allow` means "unauthorized".
# An explicit `"allow": true` is required.
#
# Scope is also applied. The default scope is "wildcard:wildcard" allowing
# all actions. If the scope is not "1", then the action is not authorized.
#
#
# Allow query:
# data.authz.role_allow = true data.authz.scope_allow = true
default allow = false
allow {
default role_allow = false
role_allow {
site = 1
}
allow {
role_allow {
not site = -1
org = 1
}
allow {
role_allow {
not site = -1
not org = -1
# If we are not a member of an org, and the object has an org, then we are
@ -156,3 +174,23 @@ allow {
org_mem
user = 1
}
default scope_allow = false
scope_allow {
scope_site = 1
}
scope_allow {
not scope_site = -1
scope_org = 1
}
scope_allow {
not scope_site = -1
not scope_org = -1
# If we are not a member of an org, and the object has an org, then we are
# not authorized. This is an "implied -1" for not being in the org.
org_mem
scope_user = 1
}