diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index ee7ddb25cae60..51ad87b8fbe09 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -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) } diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 24bb5a90ea468..d5c254ecfc3c2 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -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: + []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: - {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(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)") } } }) diff --git a/coderd/rbac/partial.go b/coderd/rbac/partial.go index 59e68c202d94b..8cdc330352c08 100644 --- a/coderd/rbac/partial.go +++ b/coderd/rbac/partial.go @@ -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) { - 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 -} +var _ PreparedAuthorized = (*PartialAuthorizer)(nil) -// Authorize authorizes a single object using the partially prepared queries. -func (a subPartialAuthorizer) Authorize(ctx context.Context, object Object) error { +func (pa *PartialAuthorizer) 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 } diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index 4b94eafa91eb5..fb7f61c3a711a 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -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 +}