mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
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:
@ -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)
|
||||
}
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user