feat: add API key scopes and application_connect scope (#4067)

This commit is contained in:
Dean Sheather
2022-09-19 17:39:02 +00:00
committed by GitHub
parent adad347902
commit 29d804e692
42 changed files with 476 additions and 88 deletions
+30 -10
View File
@@ -13,8 +13,8 @@ import (
)
type Authorizer interface {
ByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, object Object) error
PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, objectType string) (PreparedAuthorized, error)
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)
}
type PreparedAuthorized interface {
@@ -24,7 +24,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, action Action, objects []O) ([]O, error) {
func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, subjRoles []string, scope Scope, action Action, objects []O) ([]O, error) {
ctx, span := tracing.StartSpan(ctx, trace.WithAttributes(
attribute.String("subject_id", subjID),
attribute.StringSlice("subject_roles", subjRoles),
@@ -39,7 +39,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subjID string, sub
objectType := objects[0].RBACObject().Type
filtered := make([]O, 0)
prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, action, objectType)
prepared, err := auth.PrepareByRoleName(ctx, subjID, subjRoles, scope, action, objectType)
if err != nil {
return nil, xerrors.Errorf("prepare: %w", err)
}
@@ -63,6 +63,8 @@ type RegoAuthorizer struct {
query rego.PreparedEvalQuery
}
var _ Authorizer = (*RegoAuthorizer)(nil)
// Load the policy from policy.rego in this directory.
//
//go:embed policy.rego
@@ -91,13 +93,31 @@ type authSubject struct {
// 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, action Action, object Object) error {
func (a RegoAuthorizer) ByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, object Object) error {
roles, err := RolesByNames(roleNames)
if err != nil {
return err
}
return a.Authorize(ctx, subjectID, roles, action, object)
err = a.Authorize(ctx, subjectID, roles, action, object)
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
}
}
return nil
}
// Authorize allows passing in custom Roles.
@@ -129,11 +149,11 @@ func (a RegoAuthorizer) Authorize(ctx context.Context, subjectID string, roles [
// 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, action Action, objectType string) (*PartialAuthorizer, error) {
func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Role, scope Scope, action Action, objectType string) (*PartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
auth, err := newPartialAuthorizer(ctx, subjectID, roles, action, objectType)
auth, err := newPartialAuthorizer(ctx, subjectID, roles, scope, action, objectType)
if err != nil {
return nil, xerrors.Errorf("new partial authorizer: %w", err)
}
@@ -141,7 +161,7 @@ func (RegoAuthorizer) Prepare(ctx context.Context, subjectID string, roles []Rol
return auth, nil
}
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, action Action, objectType string) (PreparedAuthorized, error) {
func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string, roleNames []string, scope Scope, action Action, objectType string) (PreparedAuthorized, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
@@ -150,5 +170,5 @@ func (a RegoAuthorizer) PrepareByRoleName(ctx context.Context, subjectID string,
return nil, err
}
return a.Prepare(ctx, subjectID, roles, action, objectType)
return a.Prepare(ctx, subjectID, roles, scope, action, objectType)
}
+74 -12
View File
@@ -3,6 +3,7 @@ package rbac
import (
"context"
"encoding/json"
"fmt"
"testing"
"github.com/google/uuid"
@@ -13,7 +14,6 @@ import (
"github.com/coder/coder/testutil"
)
// subject is required because rego needs
type subject struct {
UserID string `json:"id"`
// For the unit test we want to pass in the roles directly, instead of just
@@ -42,7 +42,7 @@ func TestFilterError(t *testing.T) {
auth, err := NewAuthorizer()
require.NoError(t, err)
_, err = Filter(context.Background(), auth, uuid.NewString(), []string{}, ActionRead, []Object{ResourceUser, ResourceWorkspace})
_, err = Filter(context.Background(), auth, uuid.NewString(), []string{}, ScopeAll, ActionRead, []Object{ResourceUser, ResourceWorkspace})
require.ErrorContains(t, err, "object types must be uniform")
}
@@ -75,6 +75,7 @@ func TestFilter(t *testing.T) {
SubjectID string
Roles []string
Action Action
Scope Scope
ObjectType string
}{
{
@@ -139,6 +140,13 @@ func TestFilter(t *testing.T) {
ObjectType: ResourceOrganization.Type,
Action: ActionRead,
},
{
Name: "ScopeApplicationConnect",
SubjectID: userIDs[0].String(),
Roles: []string{RoleOrgMember(orgIDs[0]), "auditor", RoleOwner(), RoleMember()},
ObjectType: ResourceWorkspace.Type,
Action: ActionRead,
},
}
for _, tc := range testCases {
@@ -154,11 +162,16 @@ func TestFilter(t *testing.T) {
auth, err := NewAuthorizer()
require.NoError(t, err, "new auth")
scope := ScopeAll
if tc.Scope != "" {
scope = tc.Scope
}
// Run auth 1 by 1
var allowedCount int
for i, obj := range localObjects {
obj.Type = tc.ObjectType
err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, ActionRead, obj.RBACObject())
err := auth.ByRoleName(ctx, tc.SubjectID, tc.Roles, scope, ActionRead, obj.RBACObject())
obj.Allowed = err == nil
if err == nil {
allowedCount++
@@ -167,7 +180,7 @@ func TestFilter(t *testing.T) {
}
// Run by filter
list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, tc.Action, localObjects)
list, err := Filter(ctx, auth, tc.SubjectID, tc.Roles, scope, tc.Action, localObjects)
require.NoError(t, err)
require.Equal(t, allowedCount, len(list), "expected number of allowed")
for _, obj := range list {
@@ -614,6 +627,36 @@ func TestAuthorizeLevels(t *testing.T) {
}))
}
func TestAuthorizeScope(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
unusedID := uuid.New()
user := subject{
UserID: "me",
Roles: []Role{},
}
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},
// 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},
})
}
// cases applies a given function to all test cases. This makes generalities easier to create.
func cases(opt func(c authTestCase) authTestCase, cases []authTestCase) []authTestCase {
if opt == nil {
@@ -636,13 +679,20 @@ func testAuthorize(t *testing.T, name string, subject subject, sets ...[]authTes
authorizer, err := NewAuthorizer()
require.NoError(t, err)
for _, cases := range sets {
for _, c := range cases {
t.Run(name, func(t *testing.T) {
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()
for _, a := range c.actions {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
authError := authorizer.Authorize(ctx, subject.UserID, subject.Roles, a, c.resource)
// Logging only
if authError != nil {
var uerr *UnauthorizedError
@@ -666,23 +716,35 @@ 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, a, c.resource.Type)
partialAuthz, err := authorizer.Prepare(ctx, subject.UserID, subject.Roles, ScopeAll, 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.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.input)
if len(partialAuthz.mainAuthorizer.partialQueries.Support) > 0 {
d, _ := json.Marshal(partialAuthz.mainAuthorizer.input)
t.Logf("input: %s", string(d))
for _, q := range partialAuthz.partialQueries.Queries {
for _, q := range partialAuthz.mainAuthorizer.partialQueries.Queries {
t.Logf("query: %+v", q.String())
}
for _, s := range partialAuthz.partialQueries.Support {
for _, s := range partialAuthz.mainAuthorizer.partialQueries.Support {
t.Logf("support: %+v", s.String())
}
}
require.Equal(t, 0, len(partialAuthz.partialQueries.Support), "expected 0 support rules")
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")
}
partialErr := partialAuthz.Authorize(ctx, c.resource)
if authError != nil {
+26 -2
View File
@@ -33,28 +33,33 @@ func BenchmarkRBACFilter(b *testing.B) {
Name string
Roles []string
UserID uuid.UUID
Scope rbac.Scope
}{
{
Name: "NoRoles",
Roles: []string{},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "Admin",
// Give some extra roles that an admin might have
Roles: []string{rbac.RoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "OrgAdmin",
Roles: []string{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgAdmin(orgs[0]), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "OrgMember",
// Member of 2 orgs
Roles: []string{rbac.RoleOrgMember(orgs[0]), rbac.RoleOrgMember(orgs[1]), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "ManyRoles",
@@ -66,6 +71,14 @@ func BenchmarkRBACFilter(b *testing.B) {
rbac.RoleMember(),
},
UserID: users[0],
Scope: rbac.ScopeAll,
},
{
Name: "AdminWithScope",
// Give some extra roles that an admin might have
Roles: []string{rbac.RoleOrgMember(orgs[0]), "auditor", rbac.RoleOwner(), rbac.RoleMember()},
UserID: users[0],
Scope: rbac.ScopeApplicationConnect,
},
}
@@ -77,7 +90,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, rbac.ActionRead, objects)
allowed, err := rbac.Filter(context.Background(), authorizer, c.UserID.String(), c.Roles, c.Scope, rbac.ActionRead, objects)
require.NoError(b, err)
var _ = allowed
})
@@ -184,6 +197,16 @@ func TestRolePermissions(t *testing.T) {
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin},
},
},
{
Name: "MyWorkspaceInOrgAppConnect",
// When creating the WithID won't be set, but it does not change the result.
Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionRead, rbac.ActionUpdate, rbac.ActionDelete},
Resource: rbac.ResourceWorkspaceApplicationConnect.InOrg(orgID).WithOwner(currentUser.String()),
AuthorizeMap: map[bool][]authSubject{
true: {owner, orgAdmin, orgMemberMe},
false: {memberMe, otherOrgAdmin, otherOrgMember, templateAdmin, userAdmin},
},
},
{
Name: "Templates",
Actions: []rbac.Action{rbac.ActionCreate, rbac.ActionUpdate, rbac.ActionDelete},
@@ -335,7 +358,8 @@ func TestRolePermissions(t *testing.T) {
for _, subj := range subjs {
delete(remainingSubjs, subj.Name)
msg := fmt.Sprintf("%s as %q doing %q on %q", c.Name, subj.Name, action, c.Resource.Type)
err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, action, c.Resource)
// TODO: scopey
err := auth.ByRoleName(context.Background(), subj.UserID, subj.Roles, rbac.ScopeAll, action, c.Resource)
if result {
assert.NoError(t, err, fmt.Sprintf("Should pass: %s", msg))
} else {
+9
View File
@@ -31,6 +31,15 @@ var (
Type: "workspace_execution",
}
// ResourceWorkspaceApplicationConnect CRUD. Org + User owner
// create = connect to an application
// read = ?
// update = ?
// delete = ?
ResourceWorkspaceApplicationConnect = Object{
Type: "application_connect",
}
// ResourceAuditLog
// read = access audit log
ResourceAuditLog = Object{
+56 -3
View File
@@ -11,6 +11,59 @@ 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
@@ -25,7 +78,7 @@ type PartialAuthorizer struct {
alwaysTrue bool
}
func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*PartialAuthorizer, error) {
func newSubPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, action Action, objectType string) (*subPartialAuthorizer, error) {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
@@ -55,7 +108,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, a
return nil, xerrors.Errorf("prepare: %w", err)
}
pAuth := &PartialAuthorizer{
pAuth := &subPartialAuthorizer{
partialQueries: partialQueries,
preparedQueries: []rego.PreparedEvalQuery{},
input: input,
@@ -87,7 +140,7 @@ func newPartialAuthorizer(ctx context.Context, subjectID string, roles []Role, a
}
// Authorize authorizes a single object using the partially prepared queries.
func (a PartialAuthorizer) Authorize(ctx context.Context, object Object) error {
func (a subPartialAuthorizer) Authorize(ctx context.Context, object Object) error {
ctx, span := tracing.StartSpan(ctx)
defer span.End()
+46
View File
@@ -0,0 +1,46 @@
package rbac
import (
"fmt"
"golang.org/x/xerrors"
)
type Scope string
const (
ScopeAll Scope = "all"
ScopeApplicationConnect Scope = "application_connect"
)
var builtinScopes map[Scope]Role = map[Scope]Role{
// ScopeAll is a special scope that allows access to all resources. During
// authorize checks it is usually not used directly and skips scope checks.
ScopeAll: {
Name: fmt.Sprintf("Scope_%s", ScopeAll),
DisplayName: "All operations",
Site: permissions(map[Object][]Action{
ResourceWildcard: {WildcardSymbol},
}),
Org: map[string][]Permission{},
User: []Permission{},
},
ScopeApplicationConnect: {
Name: fmt.Sprintf("Scope_%s", ScopeApplicationConnect),
DisplayName: "Ability to connect to applications",
Site: permissions(map[Object][]Action{
ResourceWorkspaceApplicationConnect: {ActionCreate},
}),
Org: map[string][]Permission{},
User: []Permission{},
},
}
func ScopeRole(scope Scope) (Role, error) {
role, ok := builtinScopes[scope]
if !ok {
return Role{}, xerrors.Errorf("no scope named %q", scope)
}
return role, nil
}