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.

View File

@ -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,omitempty"`
}
// AuthCheck allows the authenticated user to check if they have the given permissions

View File

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

5
docs/api/schemas.md generated
View File

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

View File

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