mirror of
https://github.com/coder/coder.git
synced 2025-07-15 22:20:27 +00:00
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:
4
coderd/apidoc/docs.go
generated
4
coderd/apidoc/docs.go
generated
@ -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"
|
||||
|
4
coderd/apidoc/swagger.json
generated
4
coderd/apidoc/swagger.json
generated
@ -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"
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
|
@ -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)
|
||||
|
@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
Reference in New Issue
Block a user