From e8e940e05bd8a87f3975bed4a1d08be50d649d59 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 24 Jul 2024 20:55:23 -0500 Subject: [PATCH 1/8] chore: authz 'any_org' to return if at least 1 org has perms Allows checking if a user can do an action in any organization, rather than a specific one. Allows asking general questions on the UI to determine which elements to show. --- coderd/rbac/astvalue.go | 4 +++ coderd/rbac/authz.go | 7 +++++ coderd/rbac/authz_internal_test.go | 45 ++++++++++++++++++++++++++++++ coderd/rbac/authz_test.go | 2 +- coderd/rbac/input.json | 22 +++------------ coderd/rbac/object.go | 6 ++++ coderd/rbac/policy.rego | 34 ++++++++++++++++++++-- 7 files changed, 99 insertions(+), 21 deletions(-) diff --git a/coderd/rbac/astvalue.go b/coderd/rbac/astvalue.go index 9549eb1ed7be8..12afe03402b25 100644 --- a/coderd/rbac/astvalue.go +++ b/coderd/rbac/astvalue.go @@ -124,6 +124,10 @@ func (z Object) regoValue() ast.Value { ast.StringTerm("org_owner"), ast.StringTerm(z.OrgID), }, + [2]*ast.Term{ + ast.StringTerm("any_org"), + ast.BooleanTerm(z.AnyOrg), + }, [2]*ast.Term{ ast.StringTerm("type"), ast.StringTerm(z.Type), diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 224e153a8b4b7..46f92cb6b8c7b 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -11,6 +11,7 @@ import ( "time" "github.com/ammario/tlru" + "github.com/google/uuid" "github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/rego" "github.com/prometheus/client_golang/prometheus" @@ -387,6 +388,12 @@ func (a RegoAuthorizer) authorize(ctx context.Context, subject Subject, action p return xerrors.Errorf("subject must have a scope") } + // The caller should use either 1 or the other (or none). + // Using "AnyOrg" and an OrgID is a contradiction. + if object.AnyOrg && (object.OrgID == "" || object.OrgID == uuid.Nil.String()) { + return xerrors.Errorf("object cannot have 'any_org' and an 'org_id' specified, values are mutually exclusive") + } + astV, err := regoInputValue(subject, action, object) if err != nil { return xerrors.Errorf("convert input to value: %w", err) diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index 79fe9af67a607..f85449c0f3edf 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -1171,3 +1171,48 @@ func (f *mockPreparedAuthorizer) Authorize(ctx context.Context, object Object) e func (*mockPreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) { return "not a valid sql string", nil } + +func TestAnyOrgCheck(t *testing.T) { + t.Parallel() + + ctx := context.Background() + var authz Authorizer = NewAuthorizer(prometheus.NewRegistry()) + //authz = &coderdtest.RecordingAuthorizer{ + // Wrapped: authz, + //} + + roles := Roles(must(RoleIdentifiers{ + ScopedRoleOrgTemplateAdmin(uuid.New()), + }.Expand())) + + subj := Subject{ + FriendlyName: "Alice", + ID: uuid.NewString(), + Roles: roles, + Groups: []string{}, + Scope: ScopeAll, + } + d, _ := json.Marshal(subj) + fmt.Println(string(d)) + + r := ResourceWorkspace + r.OrgID = "any" + err := authz.Authorize(ctx, subj, policy.ActionRead, r) + fmt.Println(err) + + prep, err := authz.Prepare(ctx, subj, policy.ActionRead, ResourceWorkspace.Type) + require.NoError(t, err) + + pa := prep.(*PartialAuthorizer) + // 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. + for _, q := range pa.partialQueries.Queries { + t.Logf("query: %+v", q.String()) + } + for _, s := range pa.partialQueries.Support { + t.Logf("support: %+v", s.String()) + } + + fmt.Println(prep) +} diff --git a/coderd/rbac/authz_test.go b/coderd/rbac/authz_test.go index 0c46096c74e6f..6934391d6ed53 100644 --- a/coderd/rbac/authz_test.go +++ b/coderd/rbac/authz_test.go @@ -314,7 +314,7 @@ func BenchmarkCacher(b *testing.B) { } } -func TestCacher(t *testing.T) { +func TestCache(t *testing.T) { t.Parallel() t.Run("NoCache", func(t *testing.T) { diff --git a/coderd/rbac/input.json b/coderd/rbac/input.json index 5e464168ac5ac..749cc10c924c3 100644 --- a/coderd/rbac/input.json +++ b/coderd/rbac/input.json @@ -1,10 +1,10 @@ { - "action": "never-match-action", + "action": "create", "object": { "id": "9046b041-58ed-47a3-9c3a-de302577875a", "owner": "00000000-0000-0000-0000-000000000000", - "org_owner": "bf7b72bd-a2b1-4ef2-962c-1d698e0483f6", - "type": "workspace", + "org_owner": "00000000-0000-0000-0000-000000000000", + "type": "template", "acl_user_list": { "f041847d-711b-40da-a89a-ede39f70dc7f": ["create"] }, @@ -12,21 +12,7 @@ }, "subject": { "id": "10d03e62-7703-4df5-a358-4f76577d4e2f", - "roles": [ - { - "name": "owner", - "display_name": "Owner", - "site": [ - { - "negate": false, - "resource_type": "*", - "action": "*" - } - ], - "org": {}, - "user": [] - } - ], + "roles": [{"name":"organization-template-admin:5750d635-82e0-4681-bd44-815b18669d65","display_name":"Organization Template Admin","site":[],"org":{"5750d635-82e0-4681-bd44-815b18669d65":[{"negate":false,"resource_type":"file","action":"create"},{"negate":false,"resource_type":"file","action":"read"},{"negate":false,"resource_type":"group","action":"read"},{"negate":false,"resource_type":"organization_member","action":"read"},{"negate":false,"resource_type":"template","action":"create"},{"negate":false,"resource_type":"template","action":"read"},{"negate":false,"resource_type":"template","action":"update"},{"negate":false,"resource_type":"template","action":"delete"},{"negate":false,"resource_type":"template","action":"view_insights"},{"negate":false,"resource_type":"workspace","action":"read"}]},"user":[]}], "groups": ["b617a647-b5d0-4cbe-9e40-26f89710bf18"], "scope": { "name": "Scope_all", diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index dfd8ab6b55b23..b1fd4432f4e8a 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -23,6 +23,12 @@ type Object struct { Owner string `json:"owner"` // OrgID specifies which org the object is a part of. OrgID string `json:"org_owner"` + // AnyOrg will disregard the org_owner when checking for permissions + // Use this to ask, "Can the actor do this action on any org?" when + // the exact organization is not important or known. + // E.g: The UI should show a "create template" button if the user + // can create a template in any org. + AnyOrg bool `json:"any_org"` // Type is "workspace", "project", "app", etc Type string `json:"type"` diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index a6f3e62b73453..d1715b7fd0b60 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -92,8 +92,18 @@ org := org_allow(input.subject.roles) default scope_org := 0 scope_org := org_allow([input.scope]) -org_allow(roles) := num { - allow := { id: num | +# org_allow_set is a helper function that iterates over all orgs that the actor +# is a member of. For each organization it returns the numerical allow value +# for the given object + action iff the object is in the organization. +# The resulting value is a map that looks something like: +# {"10d03e62-7703-4df5-a358-4f76577d4e2f": 1, "5750d635-82e0-4681-bd44-815b18669d65": 1} +# The caller can use this output[] to get the final allow value. +# +# The reason we calculate this for all orgs, and not just the input.object.org_owner +# is that sometimes the input.object.org_owner is unknown. In those cases +# we have a list of org_ids that can we use in a SQL 'WHERE' clause. +org_allow_set(roles) := allow_set { + allow_set := { id: num | id := org_members[_] set := { x | perm := roles[_].org[id][_] @@ -103,6 +113,13 @@ org_allow(roles) := num { } num := number(set) } +} + +org_allow(roles) := num { + # If the object has "any_org" set to true, then use the other + # org_allow block. + not input.object.any_org + allow := org_allow_set(roles) # Return only the org value of the input's org. # The reason why we do not do this up front, is that we need to make sure @@ -112,6 +129,19 @@ org_allow(roles) := num { num := allow[input.object.org_owner] } +# This block states if "object.any_org" is set to true, then disregard the +# organization id the object is associated with. Instead, we check if the user +# can do the action on any organization. +# This is useful for UI elements when we want to conclude, "Can the user create +# a new template in any organization?" +# It is easier than iterating over every organization the user is apart of. +org_allow(roles) := num { + input.object.any_org + allow := org_allow_set(roles) + + num := count(allow) +} + # 'org_mem' is set to true if the user is an org member org_mem := true { input.object.org_owner != "" From 3ea90202dfafc3e120e880e637f64822a943642c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 24 Jul 2024 21:15:45 -0500 Subject: [PATCH 2/8] more strict, add comments to policy --- coderd/rbac/policy.rego | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index d1715b7fd0b60..a4ef992e35231 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -139,7 +139,21 @@ org_allow(roles) := num { input.object.any_org allow := org_allow_set(roles) - num := count(allow) + + # allow is a map of {"": }. We only care about values + # that are 1, and ignore the rest. + num := number([ + keep | + # for every value in the mapping + value := allow[_] + # only keep values > 0. + # 1 = allow, 0 = abstain, -1 = deny + # we need explicit allows + value > 0 + # result set is a set of [true,false,...] + # which "number()" will convert to a number. + keep := true + ]) } # 'org_mem' is set to true if the user is an org member From 3b0fae0fa5a3d4b8bba2edc4d4ea70137cf0231c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 24 Jul 2024 22:39:14 -0500 Subject: [PATCH 3/8] add unit tests and extend to /authcheck api --- coderd/apidoc/docs.go | 4 + coderd/apidoc/swagger.json | 4 + coderd/authorize.go | 1 + coderd/rbac/astvalue.go | 2 +- coderd/rbac/authz.go | 6 +- coderd/rbac/authz_internal_test.go | 90 +++--- coderd/rbac/input.json | 441 +++++++++++++++++++++++++++-- coderd/rbac/object.go | 25 +- coderd/rbac/policy.rego | 18 +- coderd/rbac/roles_test.go | 14 + codersdk/authorization.go | 3 + docs/api/authorization.md | 2 + docs/api/schemas.md | 5 + site/src/api/typesGenerated.ts | 1 + 14 files changed, 530 insertions(+), 86 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 01579c0c659a2..cc47a3eabdd46 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8482,6 +8482,10 @@ const docTemplate = `{ "description": "AuthorizationObject can represent a \"set\" of objects, such as: all workspaces in an organization, all workspaces owned by me, all workspaces across the entire product.", "type": "object", "properties": { + "any_org": { + "description": "AnyOrgOwner (optional) will disregard the org_owner when checking for permissions.\nThis cannot be set to true if the OrganizationID is set.", + "type": "boolean" + }, "organization_id": { "description": "OrganizationID (optional) adds the set constraint to all resources owned by a given organization.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a9b61c05f18e4..f0e69285bf5de 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7543,6 +7543,10 @@ "description": "AuthorizationObject can represent a \"set\" of objects, such as: all workspaces in an organization, all workspaces owned by me, all workspaces across the entire product.", "type": "object", "properties": { + "any_org": { + "description": "AnyOrgOwner (optional) will disregard the org_owner when checking for permissions.\nThis cannot be set to true if the OrganizationID is set.", + "type": "boolean" + }, "organization_id": { "description": "OrganizationID (optional) adds the set constraint to all resources owned by a given organization.", "type": "string" diff --git a/coderd/authorize.go b/coderd/authorize.go index 2f16fb8ceb720..478a92c2870b1 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -170,6 +170,7 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { Owner: v.Object.OwnerID, OrgID: v.Object.OrganizationID, Type: string(v.Object.ResourceType), + AnyOrgOwner: v.Object.AnyOrgOwner, } if obj.Owner == "me" { obj.Owner = auth.ID diff --git a/coderd/rbac/astvalue.go b/coderd/rbac/astvalue.go index 12afe03402b25..e2fcedbd439f3 100644 --- a/coderd/rbac/astvalue.go +++ b/coderd/rbac/astvalue.go @@ -126,7 +126,7 @@ func (z Object) regoValue() ast.Value { }, [2]*ast.Term{ ast.StringTerm("any_org"), - ast.BooleanTerm(z.AnyOrg), + ast.BooleanTerm(z.AnyOrgOwner), }, [2]*ast.Term{ ast.StringTerm("type"), diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index 46f92cb6b8c7b..ca43bc71f305a 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -182,7 +182,7 @@ func Filter[O Objecter](ctx context.Context, auth Authorizer, subject Subject, a for _, o := range objects { rbacObj := o.RBACObject() if rbacObj.Type != objectType { - return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, rbacObj) + return nil, xerrors.Errorf("object types must be uniform across the set (%s), found %s", objectType, rbacObj.Type) } err := auth.Authorize(ctx, subject, action, o.RBACObject()) if err == nil { @@ -389,8 +389,8 @@ func (a RegoAuthorizer) authorize(ctx context.Context, subject Subject, action p } // The caller should use either 1 or the other (or none). - // Using "AnyOrg" and an OrgID is a contradiction. - if object.AnyOrg && (object.OrgID == "" || object.OrgID == uuid.Nil.String()) { + // Using "AnyOrgOwner" and an OrgID is a contradiction. + if object.AnyOrgOwner && !(object.OrgID == "" || object.OrgID == uuid.Nil.String()) { return xerrors.Errorf("object cannot have 'any_org' and an 'org_id' specified, values are mutually exclusive") } diff --git a/coderd/rbac/authz_internal_test.go b/coderd/rbac/authz_internal_test.go index f85449c0f3edf..a9de3c56cb26a 100644 --- a/coderd/rbac/authz_internal_test.go +++ b/coderd/rbac/authz_internal_test.go @@ -291,6 +291,22 @@ func TestAuthorizeDomain(t *testing.T) { unuseID := uuid.New() allUsersGroup := "Everyone" + // orphanedUser has no organization + orphanedUser := Subject{ + ID: "me", + Scope: must(ExpandScope(ScopeAll)), + Groups: []string{}, + Roles: Roles{ + must(RoleByName(RoleMember())), + }, + } + testAuthorize(t, "OrphanedUser", orphanedUser, []authTestCase{ + {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(orphanedUser.ID), actions: ResourceWorkspace.AvailableActions(), allow: false}, + + // Orphaned user cannot create workspaces in any organization + {resource: ResourceWorkspace.AnyOrganization().WithOwner(orphanedUser.ID), actions: []policy.Action{policy.ActionCreate}, allow: false}, + }) + user := Subject{ ID: "me", Scope: must(ExpandScope(ScopeAll)), @@ -370,6 +386,10 @@ func TestAuthorizeDomain(t *testing.T) { {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true}, {resource: ResourceWorkspace.InOrg(defOrg), actions: ResourceWorkspace.AvailableActions(), allow: false}, + // AnyOrganization using a user scoped permission + {resource: ResourceWorkspace.AnyOrganization().WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true}, + {resource: ResourceTemplate.AnyOrganization(), actions: []policy.Action{policy.ActionCreate}, allow: false}, + {resource: ResourceWorkspace.WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true}, {resource: ResourceWorkspace.All(), actions: ResourceWorkspace.AvailableActions(), allow: false}, @@ -443,6 +463,8 @@ func TestAuthorizeDomain(t *testing.T) { workspaceExceptConnect := slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH) workspaceConnect := []policy.Action{policy.ActionApplicationConnect, policy.ActionSSH} testAuthorize(t, "OrgAdmin", user, []authTestCase{ + {resource: ResourceTemplate.AnyOrganization(), actions: []policy.Action{policy.ActionCreate}, allow: true}, + // Org + me {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true}, {resource: ResourceWorkspace.InOrg(defOrg), actions: workspaceExceptConnect, allow: true}, @@ -479,6 +501,9 @@ func TestAuthorizeDomain(t *testing.T) { } testAuthorize(t, "SiteAdmin", user, []authTestCase{ + // Similar to an orphaned user, but has site level perms + {resource: ResourceTemplate.AnyOrganization(), actions: []policy.Action{policy.ActionCreate}, allow: true}, + // Org + me {resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true}, {resource: ResourceWorkspace.InOrg(defOrg), actions: ResourceWorkspace.AvailableActions(), allow: true}, @@ -1078,9 +1103,10 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes t.Logf("input: %s", string(d)) if authError != nil { var uerr *UnauthorizedError - xerrors.As(authError, &uerr) - t.Logf("internal error: %+v", uerr.Internal().Error()) - t.Logf("output: %+v", uerr.Output()) + if xerrors.As(authError, &uerr) { + t.Logf("internal error: %+v", uerr.Internal().Error()) + t.Logf("output: %+v", uerr.Output()) + } } if c.allow { @@ -1115,10 +1141,15 @@ func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTes 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 allowed invalid request (false positive)") - } else { - assert.NoError(t, partialErr, "partial error blocked valid request (false negative)") + // If 'AnyOrgOwner' is true, a partial eval does not make sense. + // Run the partial eval to ensure no panics, but the actual authz + // response does not matter. + if !c.resource.AnyOrgOwner { + if authError != nil { + assert.Error(t, partialErr, "partial allowed invalid request (false positive)") + } else { + assert.NoError(t, partialErr, "partial error blocked valid request (false negative)") + } } } }) @@ -1171,48 +1202,3 @@ func (f *mockPreparedAuthorizer) Authorize(ctx context.Context, object Object) e func (*mockPreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) { return "not a valid sql string", nil } - -func TestAnyOrgCheck(t *testing.T) { - t.Parallel() - - ctx := context.Background() - var authz Authorizer = NewAuthorizer(prometheus.NewRegistry()) - //authz = &coderdtest.RecordingAuthorizer{ - // Wrapped: authz, - //} - - roles := Roles(must(RoleIdentifiers{ - ScopedRoleOrgTemplateAdmin(uuid.New()), - }.Expand())) - - subj := Subject{ - FriendlyName: "Alice", - ID: uuid.NewString(), - Roles: roles, - Groups: []string{}, - Scope: ScopeAll, - } - d, _ := json.Marshal(subj) - fmt.Println(string(d)) - - r := ResourceWorkspace - r.OrgID = "any" - err := authz.Authorize(ctx, subj, policy.ActionRead, r) - fmt.Println(err) - - prep, err := authz.Prepare(ctx, subj, policy.ActionRead, ResourceWorkspace.Type) - require.NoError(t, err) - - pa := prep.(*PartialAuthorizer) - // 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. - for _, q := range pa.partialQueries.Queries { - t.Logf("query: %+v", q.String()) - } - for _, s := range pa.partialQueries.Support { - t.Logf("support: %+v", s.String()) - } - - fmt.Println(prep) -} diff --git a/coderd/rbac/input.json b/coderd/rbac/input.json index 749cc10c924c3..d60cb21ff5761 100644 --- a/coderd/rbac/input.json +++ b/coderd/rbac/input.json @@ -1,32 +1,425 @@ { - "action": "create", - "object": { - "id": "9046b041-58ed-47a3-9c3a-de302577875a", - "owner": "00000000-0000-0000-0000-000000000000", - "org_owner": "00000000-0000-0000-0000-000000000000", - "type": "template", - "acl_user_list": { - "f041847d-711b-40da-a89a-ede39f70dc7f": ["create"] - }, - "acl_group_list": {} + "action":"delete", + "object":{ + "id":"", + "owner":"not-me", + "org_owner":"", + "any_org":false, + "type":"workspace", + "acl_user_list":null, + "acl_group_list":null }, - "subject": { - "id": "10d03e62-7703-4df5-a358-4f76577d4e2f", - "roles": [{"name":"organization-template-admin:5750d635-82e0-4681-bd44-815b18669d65","display_name":"Organization Template Admin","site":[],"org":{"5750d635-82e0-4681-bd44-815b18669d65":[{"negate":false,"resource_type":"file","action":"create"},{"negate":false,"resource_type":"file","action":"read"},{"negate":false,"resource_type":"group","action":"read"},{"negate":false,"resource_type":"organization_member","action":"read"},{"negate":false,"resource_type":"template","action":"create"},{"negate":false,"resource_type":"template","action":"read"},{"negate":false,"resource_type":"template","action":"update"},{"negate":false,"resource_type":"template","action":"delete"},{"negate":false,"resource_type":"template","action":"view_insights"},{"negate":false,"resource_type":"workspace","action":"read"}]},"user":[]}], - "groups": ["b617a647-b5d0-4cbe-9e40-26f89710bf18"], - "scope": { - "name": "Scope_all", - "display_name": "All operations", - "site": [ + "subject":{ + "id":"me", + "roles":[ + { + "name":"organization-admin:2bae3e1a-b73a-4c63-a97f-1d75199aa3bb", + "display_name":"Organization Admin", + "site":[ + { + "negate":false, + "resource_type":"user", + "action":"read" + } + ], + "org":{ + "2bae3e1a-b73a-4c63-a97f-1d75199aa3bb":[ + { + "negate":false, + "resource_type":"api_key", + "action":"*" + }, + { + "negate":false, + "resource_type":"assign_org_role", + "action":"*" + }, + { + "negate":false, + "resource_type":"audit_log", + "action":"*" + }, + { + "negate":false, + "resource_type":"debug_info", + "action":"*" + }, + { + "negate":false, + "resource_type":"deployment_config", + "action":"*" + }, + { + "negate":false, + "resource_type":"deployment_stats", + "action":"*" + }, + { + "negate":false, + "resource_type":"file", + "action":"*" + }, + { + "negate":false, + "resource_type":"group", + "action":"*" + }, + { + "negate":false, + "resource_type":"license", + "action":"*" + }, + { + "negate":false, + "resource_type":"oauth2_app", + "action":"*" + }, + { + "negate":false, + "resource_type":"oauth2_app_code_token", + "action":"*" + }, + { + "negate":false, + "resource_type":"oauth2_app_secret", + "action":"*" + }, + { + "negate":false, + "resource_type":"organization", + "action":"*" + }, + { + "negate":false, + "resource_type":"organization_member", + "action":"*" + }, + { + "negate":false, + "resource_type":"provisioner_daemon", + "action":"*" + }, + { + "negate":false, + "resource_type":"provisioner_keys", + "action":"*" + }, + { + "negate":false, + "resource_type":"replicas", + "action":"*" + }, + { + "negate":false, + "resource_type":"system", + "action":"*" + }, + { + "negate":false, + "resource_type":"tailnet_coordinator", + "action":"*" + }, + { + "negate":false, + "resource_type":"template", + "action":"*" + }, + { + "negate":false, + "resource_type":"user", + "action":"*" + }, + { + "negate":false, + "resource_type":"workspace_proxy", + "action":"*" + }, + { + "negate":false, + "resource_type":"workspace", + "action":"delete" + }, + { + "negate":false, + "resource_type":"workspace", + "action":"start" + }, + { + "negate":false, + "resource_type":"workspace", + "action":"stop" + }, + { + "negate":false, + "resource_type":"workspace", + "action":"create" + }, + { + "negate":false, + "resource_type":"workspace", + "action":"read" + }, + { + "negate":false, + "resource_type":"workspace", + "action":"update" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"read" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"delete" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"create" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"update" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"stop" + } + ] + }, + "user":[ + + ] + }, + { + "name":"member", + "display_name":"Member", + "site":[ + { + "negate":false, + "resource_type":"assign_role", + "action":"read" + }, + { + "negate":false, + "resource_type":"oauth2_app", + "action":"read" + }, + { + "negate":false, + "resource_type":"provisioner_daemon", + "action":"read" + }, + { + "negate":false, + "resource_type":"workspace_proxy", + "action":"read" + } + ], + "org":{ + + }, + "user":[ + { + "negate":false, + "resource_type":"api_key", + "action":"*" + }, + { + "negate":false, + "resource_type":"assign_org_role", + "action":"*" + }, + { + "negate":false, + "resource_type":"assign_role", + "action":"*" + }, + { + "negate":false, + "resource_type":"audit_log", + "action":"*" + }, + { + "negate":false, + "resource_type":"debug_info", + "action":"*" + }, + { + "negate":false, + "resource_type":"deployment_config", + "action":"*" + }, + { + "negate":false, + "resource_type":"deployment_stats", + "action":"*" + }, + { + "negate":false, + "resource_type":"file", + "action":"*" + }, + { + "negate":false, + "resource_type":"group", + "action":"*" + }, + { + "negate":false, + "resource_type":"license", + "action":"*" + }, + { + "negate":false, + "resource_type":"oauth2_app", + "action":"*" + }, + { + "negate":false, + "resource_type":"oauth2_app_code_token", + "action":"*" + }, + { + "negate":false, + "resource_type":"oauth2_app_secret", + "action":"*" + }, + { + "negate":false, + "resource_type":"organization", + "action":"*" + }, + { + "negate":false, + "resource_type":"provisioner_daemon", + "action":"*" + }, + { + "negate":false, + "resource_type":"provisioner_keys", + "action":"*" + }, + { + "negate":false, + "resource_type":"replicas", + "action":"*" + }, + { + "negate":false, + "resource_type":"system", + "action":"*" + }, + { + "negate":false, + "resource_type":"tailnet_coordinator", + "action":"*" + }, + { + "negate":false, + "resource_type":"template", + "action":"*" + }, + { + "negate":false, + "resource_type":"workspace", + "action":"*" + }, + { + "negate":false, + "resource_type":"workspace_proxy", + "action":"*" + }, + { + "negate":false, + "resource_type":"provisioner_daemon", + "action":"read" + }, + { + "negate":false, + "resource_type":"provisioner_daemon", + "action":"create" + }, + { + "negate":false, + "resource_type":"provisioner_daemon", + "action":"read" + }, + { + "negate":false, + "resource_type":"provisioner_daemon", + "action":"update" + }, + { + "negate":false, + "resource_type":"user", + "action":"read" + }, + { + "negate":false, + "resource_type":"user", + "action":"read_personal" + }, + { + "negate":false, + "resource_type":"user", + "action":"update_personal" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"read" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"delete" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"create" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"update" + }, + { + "negate":false, + "resource_type":"workspace_dormant", + "action":"stop" + } + ] + } + ], + "groups":null, + "scope":{ + "name":{ + "Name":"Scope_all", + "OrganizationID":"00000000-0000-0000-0000-000000000000" + }, + "display_name":"All operations", + "site":[ { - "negate": false, - "resource_type": "*", - "action": "*" + "negate":false, + "resource_type":"*", + "action":"*" } ], - "org": {}, - "user": [], - "allow_list": ["*"] + "org":{ + + }, + "user":[ + + ], + "allow_list":[ + "*" + ] } } } diff --git a/coderd/rbac/object.go b/coderd/rbac/object.go index b1fd4432f4e8a..4f42de94a4c52 100644 --- a/coderd/rbac/object.go +++ b/coderd/rbac/object.go @@ -23,12 +23,12 @@ type Object struct { Owner string `json:"owner"` // OrgID specifies which org the object is a part of. OrgID string `json:"org_owner"` - // AnyOrg will disregard the org_owner when checking for permissions + // AnyOrgOwner will disregard the org_owner when checking for permissions // Use this to ask, "Can the actor do this action on any org?" when // the exact organization is not important or known. // E.g: The UI should show a "create template" button if the user // can create a template in any org. - AnyOrg bool `json:"any_org"` + AnyOrgOwner bool `json:"any_org"` // Type is "workspace", "project", "app", etc Type string `json:"type"` @@ -121,6 +121,7 @@ func (z Object) All() Object { Type: z.Type, ACLUserList: map[string][]policy.Action{}, ACLGroupList: map[string][]policy.Action{}, + AnyOrgOwner: z.AnyOrgOwner, } } @@ -132,6 +133,7 @@ func (z Object) WithIDString(id string) Object { Type: z.Type, ACLUserList: z.ACLUserList, ACLGroupList: z.ACLGroupList, + AnyOrgOwner: z.AnyOrgOwner, } } @@ -143,6 +145,7 @@ func (z Object) WithID(id uuid.UUID) Object { Type: z.Type, ACLUserList: z.ACLUserList, ACLGroupList: z.ACLGroupList, + AnyOrgOwner: z.AnyOrgOwner, } } @@ -155,6 +158,21 @@ func (z Object) InOrg(orgID uuid.UUID) Object { Type: z.Type, ACLUserList: z.ACLUserList, ACLGroupList: z.ACLGroupList, + // InOrg implies AnyOrgOwner is false + AnyOrgOwner: false, + } +} + +func (z Object) AnyOrganization() Object { + return Object{ + ID: z.ID, + Owner: z.Owner, + // AnyOrgOwner cannot have an org owner also set. + OrgID: "", + Type: z.Type, + ACLUserList: z.ACLUserList, + ACLGroupList: z.ACLGroupList, + AnyOrgOwner: true, } } @@ -167,6 +185,7 @@ func (z Object) WithOwner(ownerID string) Object { Type: z.Type, ACLUserList: z.ACLUserList, ACLGroupList: z.ACLGroupList, + AnyOrgOwner: z.AnyOrgOwner, } } @@ -179,6 +198,7 @@ func (z Object) WithACLUserList(acl map[string][]policy.Action) Object { Type: z.Type, ACLUserList: acl, ACLGroupList: z.ACLGroupList, + AnyOrgOwner: z.AnyOrgOwner, } } @@ -190,5 +210,6 @@ func (z Object) WithGroupACL(groups map[string][]policy.Action) Object { Type: z.Type, ACLUserList: z.ACLUserList, ACLGroupList: groups, + AnyOrgOwner: z.AnyOrgOwner, } } diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index a4ef992e35231..cff8f5998431a 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -93,8 +93,8 @@ default scope_org := 0 scope_org := org_allow([input.scope]) # org_allow_set is a helper function that iterates over all orgs that the actor -# is a member of. For each organization it returns the numerical allow value -# for the given object + action iff the object is in the organization. +# is a member of. For each organization it sets the numerical allow value +# for the given object + action if the object is in the organization. # The resulting value is a map that looks something like: # {"10d03e62-7703-4df5-a358-4f76577d4e2f": 1, "5750d635-82e0-4681-bd44-815b18669d65": 1} # The caller can use this output[] to get the final allow value. @@ -136,7 +136,7 @@ org_allow(roles) := num { # a new template in any organization?" # It is easier than iterating over every organization the user is apart of. org_allow(roles) := num { - input.object.any_org + input.object.any_org # if this is false, this code block is not used allow := org_allow_set(roles) @@ -148,7 +148,8 @@ org_allow(roles) := num { value := allow[_] # only keep values > 0. # 1 = allow, 0 = abstain, -1 = deny - # we need explicit allows + # We only need 1 explicit allow to allow the action. + # deny's and abstains are intentionally ignored. value > 0 # result set is a set of [true,false,...] # which "number()" will convert to a number. @@ -157,11 +158,19 @@ org_allow(roles) := num { } # 'org_mem' is set to true if the user is an org member +# If 'any_org' is set to true, use the other block to determine org membership. org_mem := true { + not input.object.any_org input.object.org_owner != "" input.object.org_owner in org_members } +org_mem := true { + input.object.any_org + count(org_members) > 0 +} + + org_ok { org_mem } @@ -170,6 +179,7 @@ org_ok { # the non-existent org. org_ok { input.object.org_owner == "" + not input.object.any_org } # User is the same as the site, except it only applies if the user owns the object and diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index 81dacafbf78da..e3f353537c93c 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -590,6 +590,20 @@ func TestRolePermissions(t *testing.T) { false: {}, }, }, + // AnyOrganization tests + { + Name: "CreateTemplateAnyOrg", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceTemplate.AnyOrganization(), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, orgAdmin, otherOrgAdmin}, + false: { + userAdmin, memberMe, + orgMemberMe, orgAuditor, orgUserAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, + }, + }, + }, } // We expect every permission to be tested above. diff --git a/codersdk/authorization.go b/codersdk/authorization.go index c3cff7abed149..834c36f260ac7 100644 --- a/codersdk/authorization.go +++ b/codersdk/authorization.go @@ -54,6 +54,9 @@ type AuthorizationObject struct { // are using this option, you should also set the owner ID and organization ID // if possible. Be as specific as possible using all the fields relevant. ResourceID string `json:"resource_id,omitempty"` + // AnyOrgOwner (optional) will disregard the org_owner when checking for permissions. + // This cannot be set to true if the OrganizationID is set. + AnyOrgOwner bool `json:"any_org"` } // AuthCheck allows the authenticated user to check if they have the given permissions diff --git a/docs/api/authorization.md b/docs/api/authorization.md index 94f8772183d0d..19b6f75821440 100644 --- a/docs/api/authorization.md +++ b/docs/api/authorization.md @@ -22,6 +22,7 @@ curl -X POST http://coder-server:8080/api/v2/authcheck \ "property1": { "action": "create", "object": { + "any_org": true, "organization_id": "string", "owner_id": "string", "resource_id": "string", @@ -31,6 +32,7 @@ curl -X POST http://coder-server:8080/api/v2/authcheck \ "property2": { "action": "create", "object": { + "any_org": true, "organization_id": "string", "owner_id": "string", "resource_id": "string", diff --git a/docs/api/schemas.md b/docs/api/schemas.md index e79e27377b324..cb2cd57c78209 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -744,6 +744,7 @@ { "action": "create", "object": { + "any_org": true, "organization_id": "string", "owner_id": "string", "resource_id": "string", @@ -774,6 +775,7 @@ AuthorizationCheck is used to check if the currently authenticated user (or the ```json { + "any_org": true, "organization_id": "string", "owner_id": "string", "resource_id": "string", @@ -787,6 +789,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Name | Type | Required | Restrictions | Description | | ----------------- | ---------------------------------------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `any_org` | boolean | false | | Any org (optional) will disregard the org_owner when checking for permissions. This cannot be set to true if the OrganizationID is set. | | `organization_id` | string | false | | Organization ID (optional) adds the set constraint to all resources owned by a given organization. | | `owner_id` | string | false | | Owner ID (optional) adds the set constraint to all resources owned by a given user. | | `resource_id` | string | false | | Resource ID (optional) reduces the set to a singular resource. This assigns a resource ID to the resource type, eg: a single workspace. The rbac library will not fetch the resource from the database, so if you are using this option, you should also set the owner ID and organization ID if possible. Be as specific as possible using all the fields relevant. | @@ -800,6 +803,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "property1": { "action": "create", "object": { + "any_org": true, "organization_id": "string", "owner_id": "string", "resource_id": "string", @@ -809,6 +813,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "property2": { "action": "create", "object": { + "any_org": true, "organization_id": "string", "owner_id": "string", "resource_id": "string", diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e0de1a184d6fc..ee56be35e68c3 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -145,6 +145,7 @@ export interface AuthorizationObject { readonly owner_id?: string; readonly organization_id?: string; readonly resource_id?: string; + readonly any_org: boolean; } // From codersdk/authorization.go From 6b3e053378bd151f3a43a7737d4ea122c3b9b7b4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 25 Jul 2024 14:12:45 -0500 Subject: [PATCH 4/8] make field optional --- codersdk/authorization.go | 2 +- site/src/api/typesGenerated.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codersdk/authorization.go b/codersdk/authorization.go index 834c36f260ac7..49c9634739963 100644 --- a/codersdk/authorization.go +++ b/codersdk/authorization.go @@ -56,7 +56,7 @@ type AuthorizationObject struct { ResourceID string `json:"resource_id,omitempty"` // AnyOrgOwner (optional) will disregard the org_owner when checking for permissions. // This cannot be set to true if the OrganizationID is set. - AnyOrgOwner bool `json:"any_org"` + AnyOrgOwner bool `json:"any_org,omitempty"` } // AuthCheck allows the authenticated user to check if they have the given permissions diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ee56be35e68c3..878c6dade46f7 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -145,7 +145,7 @@ export interface AuthorizationObject { readonly owner_id?: string; readonly organization_id?: string; readonly resource_id?: string; - readonly any_org: boolean; + readonly any_org?: boolean; } // From codersdk/authorization.go From 9cd9c823bb589b405887a2f71b3a65863420c8c3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 26 Jul 2024 08:55:14 -0500 Subject: [PATCH 5/8] add unit tests for create a workspace in any org --- coderd/rbac/policy.rego | 1 - coderd/rbac/roles_test.go | 13 +++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/coderd/rbac/policy.rego b/coderd/rbac/policy.rego index cff8f5998431a..bf7a38c3cc194 100644 --- a/coderd/rbac/policy.rego +++ b/coderd/rbac/policy.rego @@ -170,7 +170,6 @@ org_mem := true { count(org_members) > 0 } - org_ok { org_mem } diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index e3f353537c93c..f0a2972b59816 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -604,6 +604,19 @@ func TestRolePermissions(t *testing.T) { }, }, }, + { + Name: "CreateWorkspaceAnyOrg", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceWorkspace.AnyOrganization().WithOwner(currentUser.String()), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, orgAdmin, otherOrgAdmin, orgMemberMe}, + false: { + memberMe, userAdmin, templateAdmin, + orgAuditor, orgUserAdmin, orgTemplateAdmin, + otherOrgMember, otherOrgAuditor, otherOrgUserAdmin, otherOrgTemplateAdmin, + }, + }, + }, } // We expect every permission to be tested above. From ad298a86e53435a122ac75ae0c34fb35ad4b11dc Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 26 Jul 2024 09:00:34 -0500 Subject: [PATCH 6/8] use const over uuid.Nil.String() --- coderd/rbac/authz.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index ca43bc71f305a..ff4f9ce2371d4 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -11,7 +11,6 @@ import ( "time" "github.com/ammario/tlru" - "github.com/google/uuid" "github.com/open-policy-agent/opa/ast" "github.com/open-policy-agent/opa/rego" "github.com/prometheus/client_golang/prometheus" @@ -390,7 +389,8 @@ func (a RegoAuthorizer) authorize(ctx context.Context, subject Subject, action p // The caller should use either 1 or the other (or none). // Using "AnyOrgOwner" and an OrgID is a contradiction. - if object.AnyOrgOwner && !(object.OrgID == "" || object.OrgID == uuid.Nil.String()) { + // An empty uuid or a nil uuid means "no org owner". + if object.AnyOrgOwner && !(object.OrgID == "" || object.OrgID == "00000000-0000-0000-0000-000000000000") { return xerrors.Errorf("object cannot have 'any_org' and an 'org_id' specified, values are mutually exclusive") } From f5c4f9ac493cf5c2b6bae78b4caef5c4d9dbe7f9 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 26 Jul 2024 13:19:56 -0500 Subject: [PATCH 7/8] make fmt --- coderd/authorize.go | 6 +- coderd/rbac/input.json | 443 +++-------------------------------------- 2 files changed, 35 insertions(+), 414 deletions(-) diff --git a/coderd/authorize.go b/coderd/authorize.go index 478a92c2870b1..802cb5ea15e9b 100644 --- a/coderd/authorize.go +++ b/coderd/authorize.go @@ -167,9 +167,9 @@ func (api *API) checkAuthorization(rw http.ResponseWriter, r *http.Request) { } obj := rbac.Object{ - Owner: v.Object.OwnerID, - OrgID: v.Object.OrganizationID, - Type: string(v.Object.ResourceType), + Owner: v.Object.OwnerID, + OrgID: v.Object.OrganizationID, + Type: string(v.Object.ResourceType), AnyOrgOwner: v.Object.AnyOrgOwner, } if obj.Owner == "me" { diff --git a/coderd/rbac/input.json b/coderd/rbac/input.json index d60cb21ff5761..5e464168ac5ac 100644 --- a/coderd/rbac/input.json +++ b/coderd/rbac/input.json @@ -1,425 +1,46 @@ { - "action":"delete", - "object":{ - "id":"", - "owner":"not-me", - "org_owner":"", - "any_org":false, - "type":"workspace", - "acl_user_list":null, - "acl_group_list":null + "action": "never-match-action", + "object": { + "id": "9046b041-58ed-47a3-9c3a-de302577875a", + "owner": "00000000-0000-0000-0000-000000000000", + "org_owner": "bf7b72bd-a2b1-4ef2-962c-1d698e0483f6", + "type": "workspace", + "acl_user_list": { + "f041847d-711b-40da-a89a-ede39f70dc7f": ["create"] + }, + "acl_group_list": {} }, - "subject":{ - "id":"me", - "roles":[ + "subject": { + "id": "10d03e62-7703-4df5-a358-4f76577d4e2f", + "roles": [ { - "name":"organization-admin:2bae3e1a-b73a-4c63-a97f-1d75199aa3bb", - "display_name":"Organization Admin", - "site":[ + "name": "owner", + "display_name": "Owner", + "site": [ { - "negate":false, - "resource_type":"user", - "action":"read" + "negate": false, + "resource_type": "*", + "action": "*" } ], - "org":{ - "2bae3e1a-b73a-4c63-a97f-1d75199aa3bb":[ - { - "negate":false, - "resource_type":"api_key", - "action":"*" - }, - { - "negate":false, - "resource_type":"assign_org_role", - "action":"*" - }, - { - "negate":false, - "resource_type":"audit_log", - "action":"*" - }, - { - "negate":false, - "resource_type":"debug_info", - "action":"*" - }, - { - "negate":false, - "resource_type":"deployment_config", - "action":"*" - }, - { - "negate":false, - "resource_type":"deployment_stats", - "action":"*" - }, - { - "negate":false, - "resource_type":"file", - "action":"*" - }, - { - "negate":false, - "resource_type":"group", - "action":"*" - }, - { - "negate":false, - "resource_type":"license", - "action":"*" - }, - { - "negate":false, - "resource_type":"oauth2_app", - "action":"*" - }, - { - "negate":false, - "resource_type":"oauth2_app_code_token", - "action":"*" - }, - { - "negate":false, - "resource_type":"oauth2_app_secret", - "action":"*" - }, - { - "negate":false, - "resource_type":"organization", - "action":"*" - }, - { - "negate":false, - "resource_type":"organization_member", - "action":"*" - }, - { - "negate":false, - "resource_type":"provisioner_daemon", - "action":"*" - }, - { - "negate":false, - "resource_type":"provisioner_keys", - "action":"*" - }, - { - "negate":false, - "resource_type":"replicas", - "action":"*" - }, - { - "negate":false, - "resource_type":"system", - "action":"*" - }, - { - "negate":false, - "resource_type":"tailnet_coordinator", - "action":"*" - }, - { - "negate":false, - "resource_type":"template", - "action":"*" - }, - { - "negate":false, - "resource_type":"user", - "action":"*" - }, - { - "negate":false, - "resource_type":"workspace_proxy", - "action":"*" - }, - { - "negate":false, - "resource_type":"workspace", - "action":"delete" - }, - { - "negate":false, - "resource_type":"workspace", - "action":"start" - }, - { - "negate":false, - "resource_type":"workspace", - "action":"stop" - }, - { - "negate":false, - "resource_type":"workspace", - "action":"create" - }, - { - "negate":false, - "resource_type":"workspace", - "action":"read" - }, - { - "negate":false, - "resource_type":"workspace", - "action":"update" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"read" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"delete" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"create" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"update" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"stop" - } - ] - }, - "user":[ - - ] - }, - { - "name":"member", - "display_name":"Member", - "site":[ - { - "negate":false, - "resource_type":"assign_role", - "action":"read" - }, - { - "negate":false, - "resource_type":"oauth2_app", - "action":"read" - }, - { - "negate":false, - "resource_type":"provisioner_daemon", - "action":"read" - }, - { - "negate":false, - "resource_type":"workspace_proxy", - "action":"read" - } - ], - "org":{ - - }, - "user":[ - { - "negate":false, - "resource_type":"api_key", - "action":"*" - }, - { - "negate":false, - "resource_type":"assign_org_role", - "action":"*" - }, - { - "negate":false, - "resource_type":"assign_role", - "action":"*" - }, - { - "negate":false, - "resource_type":"audit_log", - "action":"*" - }, - { - "negate":false, - "resource_type":"debug_info", - "action":"*" - }, - { - "negate":false, - "resource_type":"deployment_config", - "action":"*" - }, - { - "negate":false, - "resource_type":"deployment_stats", - "action":"*" - }, - { - "negate":false, - "resource_type":"file", - "action":"*" - }, - { - "negate":false, - "resource_type":"group", - "action":"*" - }, - { - "negate":false, - "resource_type":"license", - "action":"*" - }, - { - "negate":false, - "resource_type":"oauth2_app", - "action":"*" - }, - { - "negate":false, - "resource_type":"oauth2_app_code_token", - "action":"*" - }, - { - "negate":false, - "resource_type":"oauth2_app_secret", - "action":"*" - }, - { - "negate":false, - "resource_type":"organization", - "action":"*" - }, - { - "negate":false, - "resource_type":"provisioner_daemon", - "action":"*" - }, - { - "negate":false, - "resource_type":"provisioner_keys", - "action":"*" - }, - { - "negate":false, - "resource_type":"replicas", - "action":"*" - }, - { - "negate":false, - "resource_type":"system", - "action":"*" - }, - { - "negate":false, - "resource_type":"tailnet_coordinator", - "action":"*" - }, - { - "negate":false, - "resource_type":"template", - "action":"*" - }, - { - "negate":false, - "resource_type":"workspace", - "action":"*" - }, - { - "negate":false, - "resource_type":"workspace_proxy", - "action":"*" - }, - { - "negate":false, - "resource_type":"provisioner_daemon", - "action":"read" - }, - { - "negate":false, - "resource_type":"provisioner_daemon", - "action":"create" - }, - { - "negate":false, - "resource_type":"provisioner_daemon", - "action":"read" - }, - { - "negate":false, - "resource_type":"provisioner_daemon", - "action":"update" - }, - { - "negate":false, - "resource_type":"user", - "action":"read" - }, - { - "negate":false, - "resource_type":"user", - "action":"read_personal" - }, - { - "negate":false, - "resource_type":"user", - "action":"update_personal" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"read" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"delete" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"create" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"update" - }, - { - "negate":false, - "resource_type":"workspace_dormant", - "action":"stop" - } - ] + "org": {}, + "user": [] } ], - "groups":null, - "scope":{ - "name":{ - "Name":"Scope_all", - "OrganizationID":"00000000-0000-0000-0000-000000000000" - }, - "display_name":"All operations", - "site":[ + "groups": ["b617a647-b5d0-4cbe-9e40-26f89710bf18"], + "scope": { + "name": "Scope_all", + "display_name": "All operations", + "site": [ { - "negate":false, - "resource_type":"*", - "action":"*" + "negate": false, + "resource_type": "*", + "action": "*" } ], - "org":{ - - }, - "user":[ - - ], - "allow_list":[ - "*" - ] + "org": {}, + "user": [], + "allow_list": ["*"] } } } From 40a8786cc7d4e1c76b27a32b9e949f5a0a7cf776 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 26 Jul 2024 13:24:26 -0500 Subject: [PATCH 8/8] add unit test --- coderd/rbac/roles_test.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/coderd/rbac/roles_test.go b/coderd/rbac/roles_test.go index f0a2972b59816..225e5eb9d311e 100644 --- a/coderd/rbac/roles_test.go +++ b/coderd/rbac/roles_test.go @@ -591,6 +591,19 @@ func TestRolePermissions(t *testing.T) { }, }, // AnyOrganization tests + { + Name: "CreateOrgMember", + Actions: []policy.Action{policy.ActionCreate}, + Resource: rbac.ResourceOrganizationMember.AnyOrganization(), + AuthorizeMap: map[bool][]hasAuthSubjects{ + true: {owner, userAdmin, orgAdmin, otherOrgAdmin, orgUserAdmin, otherOrgUserAdmin}, + false: { + memberMe, templateAdmin, + orgTemplateAdmin, orgMemberMe, orgAuditor, + otherOrgMember, otherOrgAuditor, otherOrgTemplateAdmin, + }, + }, + }, { Name: "CreateTemplateAnyOrg", Actions: []policy.Action{policy.ActionCreate},