chore: authz 'any_org' to return if at least 1 org has perms (#14009)

* 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.

* more strict, add comments to policy
* add unit tests and extend to /authcheck api
* make field optional
This commit is contained in:
Steven Masley
2024-07-29 19:58:48 -05:00
committed by GitHub
parent b7102b39af
commit 3209c863b8
14 changed files with 196 additions and 14 deletions

4
coderd/apidoc/docs.go generated
View File

@ -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"

View File

@ -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"

View File

@ -167,9 +167,10 @@ 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" {
obj.Owner = auth.ID

View File

@ -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.AnyOrgOwner),
},
[2]*ast.Term{
ast.StringTerm("type"),
ast.StringTerm(z.Type),

View File

@ -181,7 +181,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 {
@ -387,6 +387,13 @@ 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 "AnyOrgOwner" and an OrgID is a contradiction.
// 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")
}
astV, err := regoInputValue(subject, action, object)
if err != nil {
return xerrors.Errorf("convert input to value: %w", err)

View File

@ -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)")
}
}
}
})

View File

@ -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) {

View File

@ -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"`
// 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.
AnyOrgOwner bool `json:"any_org"`
// Type is "workspace", "project", "app", etc
Type string `json:"type"`
@ -115,6 +121,7 @@ func (z Object) All() Object {
Type: z.Type,
ACLUserList: map[string][]policy.Action{},
ACLGroupList: map[string][]policy.Action{},
AnyOrgOwner: z.AnyOrgOwner,
}
}
@ -126,6 +133,7 @@ func (z Object) WithIDString(id string) Object {
Type: z.Type,
ACLUserList: z.ACLUserList,
ACLGroupList: z.ACLGroupList,
AnyOrgOwner: z.AnyOrgOwner,
}
}
@ -137,6 +145,7 @@ func (z Object) WithID(id uuid.UUID) Object {
Type: z.Type,
ACLUserList: z.ACLUserList,
ACLGroupList: z.ACLGroupList,
AnyOrgOwner: z.AnyOrgOwner,
}
}
@ -149,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,
}
}
@ -161,6 +185,7 @@ func (z Object) WithOwner(ownerID string) Object {
Type: z.Type,
ACLUserList: z.ACLUserList,
ACLGroupList: z.ACLGroupList,
AnyOrgOwner: z.AnyOrgOwner,
}
}
@ -173,6 +198,7 @@ func (z Object) WithACLUserList(acl map[string][]policy.Action) Object {
Type: z.Type,
ACLUserList: acl,
ACLGroupList: z.ACLGroupList,
AnyOrgOwner: z.AnyOrgOwner,
}
}
@ -184,5 +210,6 @@ func (z Object) WithGroupACL(groups map[string][]policy.Action) Object {
Type: z.Type,
ACLUserList: z.ACLUserList,
ACLGroupList: groups,
AnyOrgOwner: z.AnyOrgOwner,
}
}

View File

@ -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 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[<object.org_owner>] 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,12 +129,47 @@ 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 # if this is false, this code block is not used
allow := org_allow_set(roles)
# allow is a map of {"<org_id>": <number>}. 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 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.
keep := true
])
}
# '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
}
@ -126,6 +178,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

View File

@ -590,6 +590,46 @@ func TestRolePermissions(t *testing.T) {
false: {},
},
},
// 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},
Resource: rbac.ResourceTemplate.AnyOrganization(),
AuthorizeMap: map[bool][]hasAuthSubjects{
true: {owner, templateAdmin, orgTemplateAdmin, otherOrgTemplateAdmin, orgAdmin, otherOrgAdmin},
false: {
userAdmin, memberMe,
orgMemberMe, orgAuditor, orgUserAdmin,
otherOrgMember, otherOrgAuditor, otherOrgUserAdmin,
},
},
},
{
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.