mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
feat: add template RBAC/groups (#4235)
This commit is contained in:
@ -3,7 +3,6 @@ package rbac
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/open-policy-agent/opa/rego"
|
||||
@ -15,8 +14,8 @@ import (
|
||||
)
|
||||
|
||||
type Authorizer interface {
|
||||
ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error
|
||||
PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error)
|
||||
ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, object Object) error
|
||||
PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, objectType string) (PreparedAuthorized, error)
|
||||
}
|
||||
|
||||
type PreparedAuthorized interface {
|
||||
@ -27,7 +26,7 @@ type PreparedAuthorized interface {
|
||||
// Filter takes in a list of objects, and will filter the list removing all
|
||||
// the elements the subject does not have permission for. All objects must be
|
||||
// of the same type.
|
||||
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, action Action, objects []O) ([]O, error) {
|
||||
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, groups []string, action Action, objects []O) ([]O, error) {
|
||||
ctx, span := tracing.StartSpan(ctx, trace.WithAttributes(
|
||||
attribute.String("subject_id", subjID),
|
||||
attribute.StringSlice("subject_roles", subjRoles),
|
||||
@ -52,7 +51,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub
|
||||
if rbacObj.Type != objectType {
|
||||
return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, rbacObj)
|
||||
}
|
||||
err := auth.ByRoleName(ctx, subjID, subjRoles, scope, action, o.RBACObject())
|
||||
err := auth.ByRoleName(ctx, subjID, subjRoles, scope, groups, action, o.RBACObject())
|
||||
if err == nil {
|
||||
filtered = append(filtered, o)
|
||||
}
|
||||
@ -60,7 +59,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, action, objectType)
|
||||
prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, groups, action, objectType)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("prepare: %w", err)
|
||||
}
|
||||
@ -95,21 +94,11 @@ var (
|
||||
query rego.PreparedEvalQuery
|
||||
)
|
||||
|
||||
const (
|
||||
rolesOkCheck = "role_ok"
|
||||
scopeOkCheck = "scope_ok"
|
||||
)
|
||||
|
||||
func NewAuthorizer() *RegoAuthorizer {
|
||||
queryOnce.Do(func() {
|
||||
var err error
|
||||
query, err = rego.New(
|
||||
// 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.Query("data.authz.allow"),
|
||||
rego.Module("policy.rego", policy),
|
||||
).PrepareForEval(context.Background())
|
||||
if err != nil {
|
||||
@ -120,15 +109,16 @@ func NewAuthorizer() *RegoAuthorizer {
|
||||
}
|
||||
|
||||
type authSubject struct {
|
||||
ID string `json:"id"`
|
||||
Roles []Role `json:"roles"`
|
||||
Scope Role `json:"scope"`
|
||||
ID string `json:"id"`
|
||||
Roles []Role `json:"roles"`
|
||||
Groups []string `json:"groups"`
|
||||
Scope Role `json:"scope"`
|
||||
}
|
||||
|
||||
// ByRoleName will expand all roleNames into roles before calling Authorize().
|
||||
// This is the function intended to be used outside this package.
|
||||
// The role is fetched from the builtin map located in memory.
|
||||
func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error {
|
||||
func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, object Object) error {
|
||||
roles, err := RolesByNames(roleNames)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -139,7 +129,7 @@ func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNa
|
||||
return err
|
||||
}
|
||||
|
||||
err = a.Authorize(ctx, subjectID, roles, scopeRole, action, object)
|
||||
err = a.Authorize(ctx, subjectID, roles, scopeRole, groups, action, object)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -149,12 +139,16 @@ 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, scope Role, action Action, object Object) error {
|
||||
func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, object Object) error {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
input := map[string]interface{}{
|
||||
"subject": authSubject{
|
||||
ID: subjectID,
|
||||
Roles: roles,
|
||||
Scope: scope,
|
||||
ID: subjectID,
|
||||
Roles: roles,
|
||||
Groups: groups,
|
||||
Scope: scope,
|
||||
},
|
||||
"object": object,
|
||||
"action": action,
|
||||
@ -165,37 +159,19 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [
|
||||
return ForbiddenWithInternal(xerrors.Errorf("eval rego: %w", err), 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)
|
||||
}
|
||||
|
||||
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
|
||||
if !results.Allowed() {
|
||||
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
|
||||
}
|
||||
return ForbiddenWithInternal(xerrors.Errorf("policy disallows request"), input, results)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 Role, action Action, objectType string) (*PartialAuthorizer, error) {
|
||||
auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, action, objectType)
|
||||
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, action Action, objectType string) (*PartialAuthorizer, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, groups, action, objectType)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("new partial authorizer: %w", err)
|
||||
}
|
||||
@ -203,7 +179,10 @@ func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Rol
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) {
|
||||
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, groups []string, action Action, objectType string) (PreparedAuthorized, error) {
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
roles, err := RolesByNames(roleNames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -214,5 +193,5 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string,
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return a.Prepare(ctx, subjectID, roles, scopeRole, action, objectType)
|
||||
return a.Prepare(ctx, subjectID, roles, scopeRole, groups, action, objectType)
|
||||
}
|
||||
|
@ -19,8 +19,9 @@ type subject struct {
|
||||
// For the unit test we want to pass in the roles directly, instead of just
|
||||
// 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"`
|
||||
Roles []Role `json:"roles"`
|
||||
Groups []string `json:"groups"`
|
||||
Scope Role `json:"scope"`
|
||||
}
|
||||
|
||||
type fakeObject struct {
|
||||
@ -41,7 +42,8 @@ func (w fakeObject) RBACObject() Object {
|
||||
func TestFilterError(t *testing.T) {
|
||||
t.Parallel()
|
||||
auth := NewAuthorizer()
|
||||
_, err := Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, ActionRead, []Object{ResourceUser, ResourceWorkspace})
|
||||
|
||||
_, err := Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace})
|
||||
require.ErrorContains(t, err, "object types must be uniform")
|
||||
}
|
||||
|
||||
@ -169,7 +171,7 @@ func TestFilter(t *testing.T) {
|
||||
var allowedCount int
|
||||
for i, obj := range localObjects {
|
||||
obj.Type = tc.ObjectType
|
||||
err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, scope, ActionRead, obj.RBACObject())
|
||||
err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, scope, []string{}, ActionRead, obj.RBACObject())
|
||||
obj.Allowed = err == nil
|
||||
if err == nil {
|
||||
allowedCount++
|
||||
@ -178,7 +180,7 @@ func TestFilter(t *testing.T) {
|
||||
}
|
||||
|
||||
// Run by filter
|
||||
list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, scope, tc.Action, localObjects)
|
||||
list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, scope, []string{}, tc.Action, localObjects)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, allowedCount, len(list), "expected number of allowed")
|
||||
for _, obj := range list {
|
||||
@ -193,15 +195,82 @@ func TestAuthorizeDomain(t *testing.T) {
|
||||
t.Parallel()
|
||||
defOrg := uuid.New()
|
||||
unuseID := uuid.New()
|
||||
allUsersGroup := "Everyone"
|
||||
|
||||
user := subject{
|
||||
UserID: "me",
|
||||
Scope: must(ScopeRole(ScopeAll)),
|
||||
Groups: []string{allUsersGroup},
|
||||
Roles: []Role{
|
||||
must(RoleByName(RoleMember())),
|
||||
must(RoleByName(RoleOrgMember(defOrg))),
|
||||
},
|
||||
}
|
||||
|
||||
testAuthorize(t, "UserACLList", user, []authTestCase{
|
||||
{
|
||||
resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{
|
||||
user.UserID: allActions(),
|
||||
}),
|
||||
actions: allActions(),
|
||||
allow: true,
|
||||
},
|
||||
{
|
||||
resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{
|
||||
user.UserID: {WildcardSymbol},
|
||||
}),
|
||||
actions: allActions(),
|
||||
allow: true,
|
||||
},
|
||||
{
|
||||
resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(unuseID).WithACLUserList(map[string][]Action{
|
||||
user.UserID: {ActionRead, ActionUpdate},
|
||||
}),
|
||||
actions: []Action{ActionCreate, ActionDelete},
|
||||
allow: false,
|
||||
},
|
||||
{
|
||||
// By default users cannot update templates
|
||||
resource: ResourceTemplate.InOrg(defOrg).WithACLUserList(map[string][]Action{
|
||||
user.UserID: {ActionUpdate},
|
||||
}),
|
||||
actions: []Action{ActionUpdate},
|
||||
allow: true,
|
||||
},
|
||||
})
|
||||
|
||||
testAuthorize(t, "GroupACLList", user, []authTestCase{
|
||||
{
|
||||
resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{
|
||||
allUsersGroup: allActions(),
|
||||
}),
|
||||
actions: allActions(),
|
||||
allow: true,
|
||||
},
|
||||
{
|
||||
resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{
|
||||
allUsersGroup: {WildcardSymbol},
|
||||
}),
|
||||
actions: allActions(),
|
||||
allow: true,
|
||||
},
|
||||
{
|
||||
resource: ResourceWorkspace.WithOwner(unuseID.String()).InOrg(defOrg).WithGroupACL(map[string][]Action{
|
||||
allUsersGroup: {ActionRead, ActionUpdate},
|
||||
}),
|
||||
actions: []Action{ActionCreate, ActionDelete},
|
||||
allow: false,
|
||||
},
|
||||
{
|
||||
// By default users cannot update templates
|
||||
resource: ResourceTemplate.InOrg(defOrg).WithGroupACL(map[string][]Action{
|
||||
allUsersGroup: {ActionUpdate},
|
||||
}),
|
||||
actions: []Action{ActionUpdate},
|
||||
allow: true,
|
||||
},
|
||||
})
|
||||
|
||||
testAuthorize(t, "Member", user, []authTestCase{
|
||||
// Org + me
|
||||
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.UserID), actions: allActions(), allow: true},
|
||||
@ -743,9 +812,6 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes
|
||||
for _, cases := range sets {
|
||||
for i, c := range cases {
|
||||
c := c
|
||||
if c.resource.Type != "application_connect" {
|
||||
continue
|
||||
}
|
||||
caseName := fmt.Sprintf("%s/%d", name, i)
|
||||
t.Run(caseName, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
@ -753,23 +819,21 @@ 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, subject.Scope, a, c.resource)
|
||||
authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, subject.Scope, subject.Groups, a, c.resource)
|
||||
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"subject": subject,
|
||||
"object": c.resource,
|
||||
"action": a,
|
||||
})
|
||||
|
||||
// Logging only
|
||||
t.Logf("input: %s", string(d))
|
||||
if authError != nil {
|
||||
var uerr *UnauthorizedError
|
||||
xerrors.As(authError, &uerr)
|
||||
d, _ := json.Marshal(uerr.Input())
|
||||
t.Logf("input: %s", string(d))
|
||||
t.Logf("internal error: %+v", uerr.Internal().Error())
|
||||
t.Logf("output: %+v", uerr.Output())
|
||||
} else {
|
||||
d, _ := json.Marshal(map[string]interface{}{
|
||||
"subject": subject,
|
||||
"object": c.resource,
|
||||
"action": a,
|
||||
})
|
||||
t.Log(string(d))
|
||||
}
|
||||
|
||||
if c.allow {
|
||||
@ -778,19 +842,17 @@ 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, subject.Scope, a, c.resource.Type)
|
||||
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, subject.Scope, subject.Groups, a, c.resource.Type)
|
||||
require.NoError(t, err, "make prepared authorizer")
|
||||
|
||||
// Ensure the partial can compile to a SQL clause.
|
||||
// This does not guarantee that the clause is valid SQL.
|
||||
_, err = Compile(partialAuthz.partialQueries)
|
||||
_, err = Compile(partialAuthz)
|
||||
require.NoError(t, err, "compile 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.
|
||||
d, _ := json.Marshal(partialAuthz.input)
|
||||
t.Logf("input: %s", string(d))
|
||||
for _, q := range partialAuthz.partialQueries.Queries {
|
||||
t.Logf("query: %+v", q.String())
|
||||
}
|
||||
|
@ -63,8 +63,8 @@ var (
|
||||
return Role{
|
||||
Name: owner,
|
||||
DisplayName: "Owner",
|
||||
Site: permissions(map[Object][]Action{
|
||||
ResourceWildcard: {WildcardSymbol},
|
||||
Site: permissions(map[string][]Action{
|
||||
ResourceWildcard.Type: {WildcardSymbol},
|
||||
}),
|
||||
}
|
||||
},
|
||||
@ -74,15 +74,15 @@ var (
|
||||
return Role{
|
||||
Name: member,
|
||||
DisplayName: "",
|
||||
Site: permissions(map[Object][]Action{
|
||||
Site: permissions(map[string][]Action{
|
||||
// All users can read all other users and know they exist.
|
||||
ResourceUser: {ActionRead},
|
||||
ResourceRoleAssignment: {ActionRead},
|
||||
ResourceUser.Type: {ActionRead},
|
||||
ResourceRoleAssignment.Type: {ActionRead},
|
||||
// All users can see the provisioner daemons.
|
||||
ResourceProvisionerDaemon: {ActionRead},
|
||||
ResourceProvisionerDaemon.Type: {ActionRead},
|
||||
}),
|
||||
User: permissions(map[Object][]Action{
|
||||
ResourceWildcard: {WildcardSymbol},
|
||||
User: permissions(map[string][]Action{
|
||||
ResourceWildcard.Type: {WildcardSymbol},
|
||||
}),
|
||||
}
|
||||
},
|
||||
@ -94,11 +94,11 @@ var (
|
||||
return Role{
|
||||
Name: auditor,
|
||||
DisplayName: "Auditor",
|
||||
Site: permissions(map[Object][]Action{
|
||||
Site: permissions(map[string][]Action{
|
||||
// Should be able to read all template details, even in orgs they
|
||||
// are not in.
|
||||
ResourceTemplate: {ActionRead},
|
||||
ResourceAuditLog: {ActionRead},
|
||||
ResourceTemplate.Type: {ActionRead},
|
||||
ResourceAuditLog.Type: {ActionRead},
|
||||
}),
|
||||
}
|
||||
},
|
||||
@ -107,13 +107,13 @@ var (
|
||||
return Role{
|
||||
Name: templateAdmin,
|
||||
DisplayName: "Template Admin",
|
||||
Site: permissions(map[Object][]Action{
|
||||
ResourceTemplate: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
Site: permissions(map[string][]Action{
|
||||
ResourceTemplate.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
// CRUD all files, even those they did not upload.
|
||||
ResourceFile: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
ResourceWorkspace: {ActionRead},
|
||||
ResourceFile.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
ResourceWorkspace.Type: {ActionRead},
|
||||
// CRUD to provisioner daemons for now.
|
||||
ResourceProvisionerDaemon: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
ResourceProvisionerDaemon.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
}),
|
||||
}
|
||||
},
|
||||
@ -122,11 +122,11 @@ var (
|
||||
return Role{
|
||||
Name: userAdmin,
|
||||
DisplayName: "User Admin",
|
||||
Site: permissions(map[Object][]Action{
|
||||
ResourceRoleAssignment: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
ResourceUser: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
Site: permissions(map[string][]Action{
|
||||
ResourceRoleAssignment.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
ResourceUser.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
// Full perms to manage org members
|
||||
ResourceOrganizationMember: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
ResourceOrganizationMember.Type: {ActionCreate, ActionRead, ActionUpdate, ActionDelete},
|
||||
}),
|
||||
}
|
||||
},
|
||||
@ -168,13 +168,12 @@ var (
|
||||
Action: ActionRead,
|
||||
},
|
||||
{
|
||||
// All org members can read templates in the org
|
||||
ResourceType: ResourceTemplate.Type,
|
||||
// Can read available roles.
|
||||
ResourceType: ResourceOrgRoleAssignment.Type,
|
||||
Action: ActionRead,
|
||||
},
|
||||
{
|
||||
// Can read available roles.
|
||||
ResourceType: ResourceOrgRoleAssignment.Type,
|
||||
ResourceType: ResourceGroup.Type,
|
||||
Action: ActionRead,
|
||||
},
|
||||
},
|
||||
@ -390,14 +389,14 @@ func roleSplit(role string) (name string, orgID string, err error) {
|
||||
|
||||
// permissions is just a helper function to make building roles that list out resources
|
||||
// and actions a bit easier.
|
||||
func permissions(perms map[Object][]Action) []Permission {
|
||||
func permissions(perms map[string][]Action) []Permission {
|
||||
list := make([]Permission, 0, len(perms))
|
||||
for k, actions := range perms {
|
||||
for _, act := range actions {
|
||||
act := act
|
||||
list = append(list, Permission{
|
||||
Negate: false,
|
||||
ResourceType: k.Type,
|
||||
ResourceType: k,
|
||||
Action: act,
|
||||
})
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ func BenchmarkRBACFilter(b *testing.B) {
|
||||
benchCases := []struct {
|
||||
Name string
|
||||
Roles []string
|
||||
Groups []string
|
||||
UserID uuid.UUID
|
||||
Scope rbac.Scope
|
||||
}{
|
||||
@ -87,7 +88,7 @@ func BenchmarkRBACFilter(b *testing.B) {
|
||||
b.Run(c.Name, func(b *testing.B) {
|
||||
objects := benchmarkSetup(orgs, users, b.N)
|
||||
b.ResetTimer()
|
||||
allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Scope, rbac.ActionRead, objects)
|
||||
allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Scope, c.Groups, rbac.ActionRead, objects)
|
||||
require.NoError(b, err)
|
||||
var _ = allowed
|
||||
})
|
||||
@ -96,11 +97,17 @@ func BenchmarkRBACFilter(b *testing.B) {
|
||||
|
||||
func benchmarkSetup(orgs []uuid.UUID, users []uuid.UUID, size int) []rbac.Object {
|
||||
// Create a "random" but deterministic set of objects.
|
||||
aclList := map[string][]rbac.Action{
|
||||
uuid.NewString(): {rbac.ActionRead, rbac.ActionUpdate},
|
||||
uuid.NewString(): {rbac.ActionCreate},
|
||||
}
|
||||
objectList := make([]rbac.Object, size)
|
||||
for i := range objectList {
|
||||
objectList[i] = rbac.ResourceWorkspace.
|
||||
InOrg(orgs[i%len(orgs)]).
|
||||
WithOwner(users[i%len(users)].String())
|
||||
WithOwner(users[i%len(users)].String()).
|
||||
WithACLUserList(aclList).
|
||||
WithGroupACL(aclList)
|
||||
}
|
||||
|
||||
return objectList
|
||||
@ -111,6 +118,7 @@ type authSubject struct {
|
||||
Name string
|
||||
UserID string
|
||||
Roles []string
|
||||
Groups []string
|
||||
}
|
||||
|
||||
func TestRolePermissions(t *testing.T) {
|
||||
@ -227,8 +235,8 @@ func TestRolePermissions(t *testing.T) {
|
||||
Actions: []rbac.Action{rbac.ActionRead},
|
||||
Resource: rbac.ResourceTemplate.InOrg(orgID),
|
||||
AuthorizeMap: map[bool][]authSubject{
|
||||
true: {owner, orgMemberMe, orgAdmin, templateAdmin},
|
||||
false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin},
|
||||
true: {owner, orgAdmin, templateAdmin},
|
||||
false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin, orgMemberMe},
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -242,7 +250,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "MyFile",
|
||||
Actions: []rbac.Action{rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
|
||||
Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
|
||||
Resource: rbac.ResourceFile.WithOwner(currentUser.String()),
|
||||
AuthorizeMap: map[bool][]authSubject{
|
||||
true: {owner, memberMe, orgMemberMe, templateAdmin},
|
||||
@ -348,6 +356,19 @@ func TestRolePermissions(t *testing.T) {
|
||||
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "AllUsersGroupACL",
|
||||
Actions: []rbac.Action{rbac.ActionRead},
|
||||
Resource: rbac.ResourceTemplate.InOrg(orgID).WithGroupACL(
|
||||
map[string][]rbac.Action{
|
||||
orgID.String(): {rbac.ActionRead},
|
||||
}),
|
||||
|
||||
AuthorizeMap: map[bool][]authSubject{
|
||||
true: {owner, orgAdmin, orgMemberMe, templateAdmin},
|
||||
false: {memberMe, otherOrgAdmin, otherOrgMember, userAdmin},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range testCases {
|
||||
@ -365,7 +386,7 @@ func TestRolePermissions(t *testing.T) {
|
||||
delete(remainingSubjs, subj.Name)
|
||||
msg := fmt.Sprintf("%s as %q doing %q on %q", c.Name, subj.Name, action, c.Resource.Type)
|
||||
// TODO: scopey
|
||||
err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAll, action, c.Resource)
|
||||
err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAll, subj.Groups, action, c.Resource)
|
||||
if result {
|
||||
assert.NoError(t, err, fmt.Sprintf("Should pass: %s", msg))
|
||||
} else {
|
||||
|
@ -54,6 +54,14 @@ var (
|
||||
Type: "template",
|
||||
}
|
||||
|
||||
// ResourceGroup CRUD. Org admins only.
|
||||
// create/delete = Make or delete a new group.
|
||||
// update = Update the name or members of a group.
|
||||
// read = Read groups and their members.
|
||||
ResourceGroup = Object{
|
||||
Type: "group",
|
||||
}
|
||||
|
||||
ResourceFile = Object{
|
||||
Type: "file",
|
||||
}
|
||||
@ -152,7 +160,9 @@ type Object struct {
|
||||
|
||||
// Type is "workspace", "project", "app", etc
|
||||
Type string `json:"type"`
|
||||
// TODO: SharedUsers?
|
||||
|
||||
ACLUserList map[string][]Action ` json:"acl_user_list"`
|
||||
ACLGroupList map[string][]Action ` json:"acl_group_list"`
|
||||
}
|
||||
|
||||
func (z Object) RBACObject() Object {
|
||||
@ -162,26 +172,53 @@ func (z Object) RBACObject() Object {
|
||||
// All returns an object matching all resources of the same type.
|
||||
func (z Object) All() Object {
|
||||
return Object{
|
||||
Owner: "",
|
||||
OrgID: "",
|
||||
Type: z.Type,
|
||||
Owner: "",
|
||||
OrgID: "",
|
||||
Type: z.Type,
|
||||
ACLUserList: map[string][]Action{},
|
||||
ACLGroupList: map[string][]Action{},
|
||||
}
|
||||
}
|
||||
|
||||
// InOrg adds an org OwnerID to the resource
|
||||
func (z Object) InOrg(orgID uuid.UUID) Object {
|
||||
return Object{
|
||||
Owner: z.Owner,
|
||||
OrgID: orgID.String(),
|
||||
Type: z.Type,
|
||||
Owner: z.Owner,
|
||||
OrgID: orgID.String(),
|
||||
Type: z.Type,
|
||||
ACLUserList: z.ACLUserList,
|
||||
ACLGroupList: z.ACLGroupList,
|
||||
}
|
||||
}
|
||||
|
||||
// WithOwner adds an OwnerID to the resource
|
||||
func (z Object) WithOwner(ownerID string) Object {
|
||||
return Object{
|
||||
Owner: ownerID,
|
||||
OrgID: z.OrgID,
|
||||
Type: z.Type,
|
||||
Owner: ownerID,
|
||||
OrgID: z.OrgID,
|
||||
Type: z.Type,
|
||||
ACLUserList: z.ACLUserList,
|
||||
ACLGroupList: z.ACLGroupList,
|
||||
}
|
||||
}
|
||||
|
||||
// WithACLUserList adds an ACL list to a given object
|
||||
func (z Object) WithACLUserList(acl map[string][]Action) Object {
|
||||
return Object{
|
||||
Owner: z.Owner,
|
||||
OrgID: z.OrgID,
|
||||
Type: z.Type,
|
||||
ACLUserList: acl,
|
||||
ACLGroupList: z.ACLGroupList,
|
||||
}
|
||||
}
|
||||
|
||||
func (z Object) WithGroupACL(groups map[string][]Action) Object {
|
||||
return Object{
|
||||
Owner: z.Owner,
|
||||
OrgID: z.OrgID,
|
||||
Type: z.Type,
|
||||
ACLUserList: z.ACLUserList,
|
||||
ACLGroupList: groups,
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ type PartialAuthorizer struct {
|
||||
var _ PreparedAuthorized = (*PartialAuthorizer)(nil)
|
||||
|
||||
func (pa *PartialAuthorizer) Compile() (AuthorizeFilter, error) {
|
||||
filter, err := Compile(pa.partialQueries)
|
||||
filter, err := Compile(pa)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("compile: %w", err)
|
||||
}
|
||||
@ -99,7 +99,7 @@ EachQueryLoop:
|
||||
// inspect this any further. But just in case, we will verify each expression
|
||||
// did resolve to 'true'. This is purely defensive programming.
|
||||
for _, exp := range results[0].Expressions {
|
||||
if exp.String() != "true" {
|
||||
if v, ok := exp.Value.(bool); !ok || !v {
|
||||
continue EachQueryLoop
|
||||
}
|
||||
}
|
||||
@ -110,15 +110,16 @@ EachQueryLoop:
|
||||
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) {
|
||||
func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, scope Role, groups []string, 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,
|
||||
ID: subjectID,
|
||||
Roles: roles,
|
||||
Scope: scope,
|
||||
Groups: groups,
|
||||
},
|
||||
"object": map[string]string{
|
||||
"type": objectType,
|
||||
@ -129,11 +130,13 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, s
|
||||
// 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.Query("data.authz.allow = true"),
|
||||
rego.Module("policy.rego", policy),
|
||||
rego.Unknowns([]string{
|
||||
"input.object.owner",
|
||||
"input.object.org_owner",
|
||||
"input.object.acl_user_list",
|
||||
"input.object.acl_group_list",
|
||||
}),
|
||||
rego.Input(input),
|
||||
).Partial(ctx)
|
||||
|
@ -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.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
|
||||
# opa eval --format=pretty 'data.authz.allow' -d policy.rego -i input.json
|
||||
# opa eval --partial --format=pretty 'data.authz.allow' -d policy.rego --unknowns input.object.owner --unknowns input.object.org_owner --unknowns input.object.acl_user_list --unknowns input.object.acl_group_list -i input.json
|
||||
|
||||
#
|
||||
# This policy is specifically constructed to compress to a set of queries if the
|
||||
@ -119,9 +119,13 @@ org_mem := true {
|
||||
input.object.org_owner in org_members
|
||||
}
|
||||
|
||||
org_ok {
|
||||
org_mem
|
||||
}
|
||||
|
||||
# If the object has no organization, then the user is also considered part of
|
||||
# the non-existent org.
|
||||
org_mem := true {
|
||||
org_ok {
|
||||
input.object.org_owner == ""
|
||||
}
|
||||
|
||||
@ -156,7 +160,6 @@ user_allow(roles) := num {
|
||||
# Allow query:
|
||||
# data.authz.role_allow = true data.authz.scope_allow = true
|
||||
|
||||
default role_allow = false
|
||||
role_allow {
|
||||
site = 1
|
||||
}
|
||||
@ -171,12 +174,10 @@ role_allow {
|
||||
not 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
|
||||
org_ok
|
||||
user = 1
|
||||
}
|
||||
|
||||
|
||||
default scope_allow = false
|
||||
scope_allow {
|
||||
scope_site = 1
|
||||
}
|
||||
@ -191,6 +192,48 @@ scope_allow {
|
||||
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
|
||||
org_ok
|
||||
scope_user = 1
|
||||
}
|
||||
|
||||
# ACL for users
|
||||
acl_allow {
|
||||
# Should you have to be a member of the org too?
|
||||
perms := input.object.acl_user_list[input.subject.id]
|
||||
# Either the input action or wildcard
|
||||
[input.action, "*"][_] in perms
|
||||
}
|
||||
|
||||
# ACL for groups
|
||||
acl_allow {
|
||||
# If there is no organization owner, the object cannot be owned by an
|
||||
# org_scoped team.
|
||||
org_mem
|
||||
group := input.subject.groups[_]
|
||||
perms := input.object.acl_group_list[group]
|
||||
# Either the input action or wildcard
|
||||
[input.action, "*"][_] in perms
|
||||
}
|
||||
|
||||
# ACL for 'all_users' special group
|
||||
acl_allow {
|
||||
org_mem
|
||||
perms := input.object.acl_group_list[input.object.org_owner]
|
||||
[input.action, "*"][_] in perms
|
||||
}
|
||||
|
||||
###############
|
||||
# Final Allow
|
||||
# The role or the ACL must allow the action. Scopes can be used to limit,
|
||||
# so scope_allow must always be true.
|
||||
|
||||
allow {
|
||||
role_allow
|
||||
scope_allow
|
||||
}
|
||||
|
||||
# ACL list must also have the scope_allow to pass
|
||||
allow {
|
||||
acl_allow
|
||||
scope_allow
|
||||
}
|
||||
|
@ -1,13 +1,13 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/open-policy-agent/opa/ast"
|
||||
"github.com/open-policy-agent/opa/rego"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
@ -16,6 +16,9 @@ type TermType string
|
||||
const (
|
||||
VarTypeJsonbTextArray TermType = "jsonb-text-array"
|
||||
VarTypeText TermType = "text"
|
||||
VarTypeBoolean TermType = "boolean"
|
||||
// VarTypeSkip means this variable does not exist to use.
|
||||
VarTypeSkip TermType = "skip"
|
||||
)
|
||||
|
||||
type SQLColumn struct {
|
||||
@ -79,19 +82,54 @@ func DefaultConfig() SQLConfig {
|
||||
}
|
||||
}
|
||||
|
||||
func NoACLConfig() SQLConfig {
|
||||
return SQLConfig{
|
||||
Variables: []SQLColumn{
|
||||
{
|
||||
RegoMatch: regexp.MustCompile(`^input\.object\.acl_group_list\.?(.*)$`),
|
||||
ColumnSelect: "",
|
||||
Type: VarTypeSkip,
|
||||
},
|
||||
{
|
||||
RegoMatch: regexp.MustCompile(`^input\.object\.acl_user_list\.?(.*)$`),
|
||||
ColumnSelect: "",
|
||||
Type: VarTypeSkip,
|
||||
},
|
||||
{
|
||||
RegoMatch: regexp.MustCompile(`^input\.object\.org_owner$`),
|
||||
ColumnSelect: "organization_id :: text",
|
||||
Type: VarTypeText,
|
||||
},
|
||||
{
|
||||
RegoMatch: regexp.MustCompile(`^input\.object\.owner$`),
|
||||
ColumnSelect: "owner_id :: text",
|
||||
Type: VarTypeText,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type AuthorizeFilter interface {
|
||||
// RegoString is used in debugging to see the original rego expression.
|
||||
RegoString() string
|
||||
// SQLString returns the SQL expression that can be used in a WHERE clause.
|
||||
SQLString(cfg SQLConfig) string
|
||||
Expression
|
||||
// Eval is required for the fake in memory database to work. The in memory
|
||||
// database can use this function to filter the results.
|
||||
Eval(object Object) bool
|
||||
}
|
||||
|
||||
// expressionTop handles Eval(object Object) for in memory expressions
|
||||
type expressionTop struct {
|
||||
Expression
|
||||
Auth *PartialAuthorizer
|
||||
}
|
||||
|
||||
func (e expressionTop) Eval(object Object) bool {
|
||||
return e.Auth.Authorize(context.Background(), object) == nil
|
||||
}
|
||||
|
||||
// Compile will convert a rego query AST into our custom types. The output is
|
||||
// an AST that can be used to generate SQL.
|
||||
func Compile(partialQueries *rego.PartialQueries) (Expression, error) {
|
||||
func Compile(pa *PartialAuthorizer) (AuthorizeFilter, error) {
|
||||
partialQueries := pa.partialQueries
|
||||
if len(partialQueries.Support) > 0 {
|
||||
return nil, xerrors.Errorf("cannot convert support rules, expect 0 found %d", len(partialQueries.Support))
|
||||
}
|
||||
@ -128,11 +166,15 @@ func Compile(partialQueries *rego.PartialQueries) (Expression, error) {
|
||||
}
|
||||
builder.WriteString(partialQueries.Queries[i].String())
|
||||
}
|
||||
return expOr{
|
||||
exp := expOr{
|
||||
base: base{
|
||||
Rego: builder.String(),
|
||||
},
|
||||
Expressions: result,
|
||||
}
|
||||
return expressionTop{
|
||||
Expression: &exp,
|
||||
Auth: pa,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -218,21 +260,22 @@ func processTerms(expected int, terms []*ast.Term) ([]Term, error) {
|
||||
}
|
||||
|
||||
func processTerm(term *ast.Term) (Term, error) {
|
||||
base := base{Rego: term.String()}
|
||||
termBase := base{Rego: term.String()}
|
||||
switch v := term.Value.(type) {
|
||||
case ast.Boolean:
|
||||
return &termBoolean{
|
||||
base: base,
|
||||
base: termBase,
|
||||
Value: bool(v),
|
||||
}, nil
|
||||
case ast.Ref:
|
||||
obj := &termObject{
|
||||
base: base,
|
||||
Variables: []termVariable{},
|
||||
base: termBase,
|
||||
Path: []Term{},
|
||||
}
|
||||
var idx int
|
||||
// A ref is a set of terms. If the first term is a var, then the
|
||||
// following terms are the path to the value.
|
||||
isRef := true
|
||||
var builder strings.Builder
|
||||
for _, term := range v {
|
||||
if idx == 0 {
|
||||
@ -241,15 +284,37 @@ func processTerm(term *ast.Term) (Term, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := term.Value.(ast.Ref); ok {
|
||||
_, newRef := term.Value.(ast.Ref)
|
||||
if newRef ||
|
||||
// This is an unfortunate hack. To fix this, we need to rewrite
|
||||
// our SQL config as a path ([]string{"input", "object", "acl_group"}).
|
||||
// In the rego AST, there is no difference between selecting
|
||||
// a field by a variable, and selecting a field by a literal (string).
|
||||
// This was a misunderstanding.
|
||||
// Example (these are equivalent by AST):
|
||||
// input.object.acl_group_list['4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75']
|
||||
// input.object.acl_group_list.organization_id
|
||||
//
|
||||
// This is not equivalent
|
||||
// input.object.acl_group_list[input.object.organization_id]
|
||||
//
|
||||
// If this becomes even more hairy, we should fix the sql config.
|
||||
builder.String() == "input.object.acl_group_list" ||
|
||||
builder.String() == "input.object.acl_user_list" {
|
||||
if !newRef {
|
||||
isRef = false
|
||||
}
|
||||
// New obj
|
||||
obj.Variables = append(obj.Variables, termVariable{
|
||||
base: base,
|
||||
obj.Path = append(obj.Path, termVariable{
|
||||
base: base{
|
||||
Rego: builder.String(),
|
||||
},
|
||||
Name: builder.String(),
|
||||
})
|
||||
builder.Reset()
|
||||
idx = 0
|
||||
}
|
||||
|
||||
if builder.Len() != 0 {
|
||||
builder.WriteString(".")
|
||||
}
|
||||
@ -257,20 +322,31 @@ func processTerm(term *ast.Term) (Term, error) {
|
||||
idx++
|
||||
}
|
||||
|
||||
obj.Variables = append(obj.Variables, termVariable{
|
||||
base: base,
|
||||
Name: builder.String(),
|
||||
})
|
||||
if isRef {
|
||||
obj.Path = append(obj.Path, termVariable{
|
||||
base: base{
|
||||
Rego: builder.String(),
|
||||
},
|
||||
Name: builder.String(),
|
||||
})
|
||||
} else {
|
||||
obj.Path = append(obj.Path, termString{
|
||||
base: base{
|
||||
Rego: fmt.Sprintf("%q", builder.String()),
|
||||
},
|
||||
Value: builder.String(),
|
||||
})
|
||||
}
|
||||
return obj, nil
|
||||
case ast.Var:
|
||||
return &termVariable{
|
||||
Name: trimQuotes(v.String()),
|
||||
base: base,
|
||||
base: termBase,
|
||||
}, nil
|
||||
case ast.String:
|
||||
return &termString{
|
||||
Value: trimQuotes(v.String()),
|
||||
base: base,
|
||||
base: termBase,
|
||||
}, nil
|
||||
case ast.Set:
|
||||
slice := v.Slice()
|
||||
@ -285,7 +361,7 @@ func processTerm(term *ast.Term) (Term, error) {
|
||||
|
||||
return &termSet{
|
||||
Value: set,
|
||||
base: base,
|
||||
base: termBase,
|
||||
}, nil
|
||||
default:
|
||||
return nil, xerrors.Errorf("invalid term: %T not supported, %q", v, term.String())
|
||||
@ -306,7 +382,10 @@ func (b base) RegoString() string {
|
||||
//
|
||||
// Eg: neq(input.object.org_owner, "") AND input.object.org_owner == "foo"
|
||||
type Expression interface {
|
||||
AuthorizeFilter
|
||||
// RegoString is used in debugging to see the original rego expression.
|
||||
RegoString() string
|
||||
// SQLString returns the SQL expression that can be used in a WHERE clause.
|
||||
SQLString(cfg SQLConfig) string
|
||||
}
|
||||
|
||||
type expAnd struct {
|
||||
@ -326,15 +405,6 @@ func (t expAnd) SQLString(cfg SQLConfig) string {
|
||||
return "(" + strings.Join(exprs, " AND ") + ")"
|
||||
}
|
||||
|
||||
func (t expAnd) Eval(object Object) bool {
|
||||
for _, expr := range t.Expressions {
|
||||
if !expr.Eval(object) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type expOr struct {
|
||||
base
|
||||
Expressions []Expression
|
||||
@ -352,15 +422,6 @@ func (t expOr) SQLString(cfg SQLConfig) string {
|
||||
return "(" + strings.Join(exprs, " OR ") + ")"
|
||||
}
|
||||
|
||||
func (t expOr) Eval(object Object) bool {
|
||||
for _, expr := range t.Expressions {
|
||||
if expr.Eval(object) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Operator joins terms together to form an expression.
|
||||
// Operators are also expressions.
|
||||
//
|
||||
@ -384,14 +445,6 @@ func (t opEqual) SQLString(cfg SQLConfig) string {
|
||||
return fmt.Sprintf("%s %s %s", t.Terms[0].SQLString(cfg), op, t.Terms[1].SQLString(cfg))
|
||||
}
|
||||
|
||||
func (t opEqual) Eval(object Object) bool {
|
||||
a, b := t.Terms[0].EvalTerm(object), t.Terms[1].EvalTerm(object)
|
||||
if t.Not {
|
||||
return a != b
|
||||
}
|
||||
return a == b
|
||||
}
|
||||
|
||||
// opInternalMember2 is checking if the first term is a member of the second term.
|
||||
// The second term is a set or list.
|
||||
type opInternalMember2 struct {
|
||||
@ -400,20 +453,6 @@ type opInternalMember2 struct {
|
||||
Haystack Term
|
||||
}
|
||||
|
||||
func (t opInternalMember2) Eval(object Object) bool {
|
||||
a, b := t.Needle.EvalTerm(object), t.Haystack.EvalTerm(object)
|
||||
bset, ok := b.([]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
for _, elem := range bset {
|
||||
if a == elem {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t opInternalMember2) SQLString(cfg SQLConfig) string {
|
||||
if haystack, ok := t.Haystack.(*termObject); ok {
|
||||
// This is a special case where the haystack is a jsonb array.
|
||||
@ -425,9 +464,14 @@ func (t opInternalMember2) SQLString(cfg SQLConfig) string {
|
||||
// having to add more "if" branches here.
|
||||
// But until we need more cases, our basic type system is ok, and
|
||||
// this is the only case we need to handle.
|
||||
if haystack.SQLType(cfg) == VarTypeJsonbTextArray {
|
||||
sqlType := haystack.SQLType(cfg)
|
||||
if sqlType == VarTypeJsonbTextArray {
|
||||
return fmt.Sprintf("%s ? %s", haystack.SQLString(cfg), t.Needle.SQLString(cfg))
|
||||
}
|
||||
|
||||
if sqlType == VarTypeSkip {
|
||||
return "true"
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s = ANY(%s)", t.Needle.SQLString(cfg), t.Haystack.SQLString(cfg))
|
||||
@ -440,9 +484,7 @@ func (t opInternalMember2) SQLString(cfg SQLConfig) string {
|
||||
type Term interface {
|
||||
RegoString() string
|
||||
SQLString(cfg SQLConfig) string
|
||||
// Eval will evaluate the term
|
||||
// Terms can eval to any type. The operator/expression will type check.
|
||||
EvalTerm(object Object) interface{}
|
||||
SQLType(cfg SQLConfig) TermType
|
||||
}
|
||||
|
||||
type termString struct {
|
||||
@ -450,10 +492,6 @@ type termString struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
func (t termString) EvalTerm(_ Object) interface{} {
|
||||
return t.Value
|
||||
}
|
||||
|
||||
func (t termString) SQLString(_ SQLConfig) string {
|
||||
return "'" + t.Value + "'"
|
||||
}
|
||||
@ -471,14 +509,7 @@ func (termString) SQLType(_ SQLConfig) TermType {
|
||||
// term type.
|
||||
type termObject struct {
|
||||
base
|
||||
Variables []termVariable
|
||||
}
|
||||
|
||||
func (t termObject) EvalTerm(obj Object) interface{} {
|
||||
if len(t.Variables) == 0 {
|
||||
return t.Variables[0].EvalTerm(obj)
|
||||
}
|
||||
panic("no nested structures are supported yet")
|
||||
Path []Term
|
||||
}
|
||||
|
||||
func (t termObject) SQLType(cfg SQLConfig) TermType {
|
||||
@ -486,30 +517,30 @@ func (t termObject) SQLType(cfg SQLConfig) TermType {
|
||||
// is the resulting type. This is correct for our use case.
|
||||
// Solving this more generally requires a full type system, which is
|
||||
// excessive for our mostly static policy.
|
||||
return t.Variables[0].SQLType(cfg)
|
||||
return t.Path[0].SQLType(cfg)
|
||||
}
|
||||
|
||||
func (t termObject) SQLString(cfg SQLConfig) string {
|
||||
if len(t.Variables) == 1 {
|
||||
return t.Variables[0].SQLString(cfg)
|
||||
if len(t.Path) == 1 {
|
||||
return t.Path[0].SQLString(cfg)
|
||||
}
|
||||
// Combine the last 2 variables into 1 variable.
|
||||
end := t.Variables[len(t.Variables)-1]
|
||||
before := t.Variables[len(t.Variables)-2]
|
||||
end := t.Path[len(t.Path)-1]
|
||||
before := t.Path[len(t.Path)-2]
|
||||
|
||||
// Recursively solve the SQLString by removing the last nested reference.
|
||||
// This continues until we have a single variable.
|
||||
return termObject{
|
||||
base: t.base,
|
||||
Variables: append(
|
||||
t.Variables[:len(t.Variables)-2],
|
||||
Path: append(
|
||||
t.Path[:len(t.Path)-2],
|
||||
termVariable{
|
||||
base: base{
|
||||
Rego: before.base.Rego + "[" + end.base.Rego + "]",
|
||||
Rego: before.RegoString() + "[" + end.RegoString() + "]",
|
||||
},
|
||||
// Convert the end to SQL string. We evaluate each term
|
||||
// one at a time.
|
||||
Name: before.Name + "." + end.SQLString(cfg),
|
||||
Name: before.RegoString() + "." + end.SQLString(cfg),
|
||||
},
|
||||
),
|
||||
}.SQLString(cfg)
|
||||
@ -520,19 +551,6 @@ type termVariable struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (t termVariable) EvalTerm(obj Object) interface{} {
|
||||
switch t.Name {
|
||||
case "input.object.org_owner":
|
||||
return obj.OrgID
|
||||
case "input.object.owner":
|
||||
return obj.Owner
|
||||
case "input.object.type":
|
||||
return obj.Type
|
||||
default:
|
||||
return fmt.Sprintf("'Unknown variable %s'", t.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func (t termVariable) SQLType(cfg SQLConfig) TermType {
|
||||
if col := t.ColumnConfig(cfg); col != nil {
|
||||
return col.Type
|
||||
@ -576,13 +594,15 @@ type termSet struct {
|
||||
Value []Term
|
||||
}
|
||||
|
||||
func (t termSet) EvalTerm(obj Object) interface{} {
|
||||
set := make([]interface{}, 0, len(t.Value))
|
||||
for _, term := range t.Value {
|
||||
set = append(set, term.EvalTerm(obj))
|
||||
func (t termSet) SQLType(cfg SQLConfig) TermType {
|
||||
if len(t.Value) == 0 {
|
||||
return VarTypeText
|
||||
}
|
||||
|
||||
return set
|
||||
// Without a full type system, let's just assume the type of the first var
|
||||
// is the resulting type. This is correct for our use case.
|
||||
// Solving this more generally requires a full type system, which is
|
||||
// excessive for our mostly static policy.
|
||||
return t.Value[0].SQLType(cfg)
|
||||
}
|
||||
|
||||
func (t termSet) SQLString(cfg SQLConfig) string {
|
||||
@ -599,11 +619,11 @@ type termBoolean struct {
|
||||
Value bool
|
||||
}
|
||||
|
||||
func (t termBoolean) Eval(_ Object) bool {
|
||||
return t.Value
|
||||
func (termBoolean) SQLType(SQLConfig) TermType {
|
||||
return VarTypeBoolean
|
||||
}
|
||||
|
||||
func (t termBoolean) EvalTerm(_ Object) interface{} {
|
||||
func (t termBoolean) Eval(_ Object) bool {
|
||||
return t.Value
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
package rbac
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/open-policy-agent/opa/ast"
|
||||
@ -11,17 +12,10 @@ import (
|
||||
|
||||
func TestCompileQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
opts := ast.ParserOptions{
|
||||
AllFutureKeywords: true,
|
||||
}
|
||||
|
||||
t.Run("EmptyQuery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
must(ast.ParseBody("")),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t, ""))
|
||||
require.NoError(t, err, "compile empty")
|
||||
|
||||
require.Equal(t, "true", expression.RegoString(), "empty query is rego 'true'")
|
||||
@ -30,12 +24,7 @@ func TestCompileQuery(t *testing.T) {
|
||||
|
||||
t.Run("TrueQuery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
must(ast.ParseBody("true")),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t, "true"))
|
||||
require.NoError(t, err, "compile")
|
||||
|
||||
require.Equal(t, "true", expression.RegoString(), "true query is rego 'true'")
|
||||
@ -44,49 +33,118 @@ func TestCompileQuery(t *testing.T) {
|
||||
|
||||
t.Run("ACLIn", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list.allUsers`, opts),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t, `"*" in input.object.acl_group_list.allUsers`))
|
||||
require.NoError(t, err, "compile")
|
||||
|
||||
require.Equal(t, `internal.member_2("*", input.object.acl_group_list.allUsers)`, expression.RegoString(), "convert to internal_member")
|
||||
require.Equal(t, `group_acl->allUsers ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in")
|
||||
require.Equal(t, `group_acl->'allUsers' ? '*'`, expression.SQLString(DefaultConfig()), "jsonb in")
|
||||
})
|
||||
|
||||
t.Run("Complex", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts),
|
||||
ast.MustParseBodyWithOpts(`input.object.org_owner in {"a", "b", "c"}`, opts),
|
||||
ast.MustParseBodyWithOpts(`input.object.org_owner != ""`, opts),
|
||||
ast.MustParseBodyWithOpts(`"read" in input.object.acl_group_list.allUsers`, opts),
|
||||
ast.MustParseBodyWithOpts(`"read" in input.object.acl_user_list.me`, opts),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`input.object.org_owner != ""`,
|
||||
`input.object.org_owner in {"a", "b", "c"}`,
|
||||
`input.object.org_owner != ""`,
|
||||
`"read" in input.object.acl_group_list.allUsers`,
|
||||
`"read" in input.object.acl_user_list.me`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `(organization_id :: text != '' OR `+
|
||||
`organization_id :: text = ANY(ARRAY ['a','b','c']) OR `+
|
||||
`organization_id :: text != '' OR `+
|
||||
`group_acl->allUsers ? 'read' OR `+
|
||||
`user_acl->me ? 'read')`,
|
||||
`group_acl->'allUsers' ? 'read' OR `+
|
||||
`user_acl->'me' ? 'read')`,
|
||||
expression.SQLString(DefaultConfig()), "complex")
|
||||
})
|
||||
|
||||
t.Run("SetDereference", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(®o.PartialQueries{
|
||||
Queries: []ast.Body{
|
||||
ast.MustParseBodyWithOpts(`"*" in input.object.acl_group_list[input.object.org_owner]`, opts),
|
||||
},
|
||||
Support: []*ast.Module{},
|
||||
})
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"*" in input.object.acl_group_list[input.object.org_owner]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `group_acl->organization_id :: text ? '*'`,
|
||||
expression.SQLString(DefaultConfig()), "set dereference")
|
||||
})
|
||||
|
||||
t.Run("JsonbLiteralDereference", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `group_acl->'4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75' ? '*'`,
|
||||
expression.SQLString(DefaultConfig()), "literal dereference")
|
||||
})
|
||||
|
||||
t.Run("NoACLColumns", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"*" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
require.Equal(t, `true`,
|
||||
expression.SQLString(NoACLConfig()), "literal dereference")
|
||||
})
|
||||
}
|
||||
|
||||
func TestEvalQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("GroupACL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expression, err := Compile(partialQueries(t,
|
||||
`"read" in input.object.acl_group_list["4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75"]`,
|
||||
))
|
||||
require.NoError(t, err, "compile")
|
||||
|
||||
result := expression.Eval(Object{
|
||||
Owner: "not-me",
|
||||
OrgID: "random",
|
||||
Type: "workspace",
|
||||
ACLUserList: map[string][]Action{},
|
||||
ACLGroupList: map[string][]Action{
|
||||
"4d30d4a8-b87d-45ac-b0d4-51b2e68e7e75": {"read"},
|
||||
},
|
||||
})
|
||||
require.True(t, result, "eval")
|
||||
})
|
||||
}
|
||||
|
||||
func partialQueries(t *testing.T, queries ...string) *PartialAuthorizer {
|
||||
opts := ast.ParserOptions{
|
||||
AllFutureKeywords: true,
|
||||
}
|
||||
|
||||
astQueries := make([]ast.Body, 0, len(queries))
|
||||
for _, q := range queries {
|
||||
astQueries = append(astQueries, ast.MustParseBodyWithOpts(q, opts))
|
||||
}
|
||||
|
||||
prepareQueries := make([]rego.PreparedEvalQuery, 0, len(queries))
|
||||
for _, q := range astQueries {
|
||||
var prepped rego.PreparedEvalQuery
|
||||
var err error
|
||||
if q.String() == "" {
|
||||
prepped, err = rego.New(
|
||||
rego.Query("true"),
|
||||
).PrepareForEval(context.Background())
|
||||
} else {
|
||||
prepped, err = rego.New(
|
||||
rego.ParsedQuery(q),
|
||||
).PrepareForEval(context.Background())
|
||||
}
|
||||
require.NoError(t, err, "prepare query")
|
||||
prepareQueries = append(prepareQueries, prepped)
|
||||
}
|
||||
return &PartialAuthorizer{
|
||||
partialQueries: ®o.PartialQueries{
|
||||
Queries: astQueries,
|
||||
Support: []*ast.Module{},
|
||||
},
|
||||
preparedQueries: prepareQueries,
|
||||
input: nil,
|
||||
alwaysTrue: false,
|
||||
}
|
||||
}
|
||||
|
@ -19,8 +19,8 @@ var builtinScopes map[Scope]Role = map[Scope]Role{
|
||||
ScopeAll: {
|
||||
Name: fmt.Sprintf("Scope_%s", ScopeAll),
|
||||
DisplayName: "All operations",
|
||||
Site: permissions(map[Object][]Action{
|
||||
ResourceWildcard: {WildcardSymbol},
|
||||
Site: permissions(map[string][]Action{
|
||||
ResourceWildcard.Type: {WildcardSymbol},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
User: []Permission{},
|
||||
@ -29,8 +29,8 @@ var builtinScopes map[Scope]Role = map[Scope]Role{
|
||||
ScopeApplicationConnect: {
|
||||
Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect),
|
||||
DisplayName: "Ability to connect to applications",
|
||||
Site: permissions(map[Object][]Action{
|
||||
ResourceWorkspaceApplicationConnect: {ActionCreate},
|
||||
Site: permissions(map[string][]Action{
|
||||
ResourceWorkspaceApplicationConnect.Type: {ActionCreate},
|
||||
}),
|
||||
Org: map[string][]Permission{},
|
||||
User: []Permission{},
|
||||
|
Reference in New Issue
Block a user