Files
coder/coderd/rbac/authz_internal_test.go
T
Jeremy Ruppel 7e708b24ce perf(coderd/rbac): collapse org authorization to a set-membership test (#27244)
## Problem

Authorization for users who belong to many organizations is slow. On the
list <br>endpoints (`/api/v2/organizations`, `/users`, `/groups`) a user
in hundreds of <br>orgs saw multi-second page loads
<br>([DEVEX-608](https://linear.app/codercom/issue/DEVEX-608/performance-degrades-for-users-in-many-organizations-across-multiple)
<br>/ coder/coder#21890 / Pylon
[#2758](<https://github.com/coder/coder/issues/2758>)). This is
partial-evaluation bound: `rbac.Prepare` <br>scales with the number of
org-scoped roles the subject carries.

## Root cause

The known-org path in `check_org_permissions` indexed an N-entry vote
map by the <br>object's org id:

```rego
vote := allow_map[input.object.org_owner]
```

`input.object.org_owner` is unknown during partial evaluation. Indexing
a map by <br>an unknown key cannot reduce to a single expression, so OPA
emits one residual <br>query per org membership, and
`newPartialAuthorizer` then calls `PrepareForEval` <br>once per
residual, making `Prepare` O(N) in org count. The list endpoints
<br>intentionally use partial eval; the fan-out is in partial eval
itself.

## Change

Test the object's org id for membership in a set that is fully known at
<br>partial-evaluation time, so the query collapses to a single
<br>`organization_id = ANY(ARRAY[...])` residual instead of N residuals:

* The known-org clause only ever votes to allow, tested via
<br>`org_owner in org_ids_with_vote(role_org_votes, 1)`.
* Org-level denies are folded into the org-member level as a ground set
<br>difference (`member_allow - org_deny`), so the unknown org id
appears in only <br>one positive membership test and the decision never
branches on it.
* The per-org vote maps are computed once as memoized zero-arg rules
<br>(`role_org_votes`, `role_member_votes`, `scope_org_votes`,
<br>`scope_member_votes`) instead of through parametrized functions that
OPA <br>re-evaluates at every call site.
* `role_allow`/`scope_allow`, the `any_org` path, and full evaluation
are <br>unchanged in behavior.

Semantics are unchanged (see the equivalence argument below). The only
<br>representational change is that a denied known org's intermediate
`org` vote is <br>now `0` instead of `-1`, compensated by the set
difference and not observable in <br>the final `allow` decision.

## Results

Measured with `BenchmarkRBACManyOrgs` (added on `main` in
coder/coder#27270). Full tables: <br>[B/op and
allocs/op](<https://github.com/coder/coder/pull/27244#issuecomment-4984523720>).

* Residual queries: O(N) -> O(1).
* `Prepare` / `PrepareAndCompile` memory changes from < />quadratic
growth on `main` <br>(176 MiB, 7.08M allocs per op at 100 orgs) to
near-linear (6.5 MiB, 258k <br>allocs), a < />96% reduction at 100 orgs,
with similar wins in time.
* Memoizing the vote maps removed an early single-org regression: at 1
org <br>`Prepare` now allocates < />7% fewer bytes and < />9% fewer
objects than `main`.
* `Authorize` (full evaluation) memory is marginally higher (+1-8%,
largest at <br>1 org) and time-neutral. This is the inherent cost of the
set-membership form <br>that keeps partial evaluation from fanning out;
full evaluation builds an <br>allow set it would not otherwise need.
* `go test ./coderd/rbac/...` passes, including `TestAuthorizeDomain`
(full- vs <br>partial-eval equivalence) and the regosql suite.

A second, independent bottleneck remains (out of scope here): the vote
map is <br>still built in O(N^2) in `check_all_org_permissions`
<br>(`roles[_].by_org_id[org_id]` scans all roles per org). Fixing it
means <br>pre-merging roles' `by_org_id` into one org->perms map in the
OPA input, and is <br>tracked as a follow-up.

## Testing

* `OrgDenyBlocksMember` (`TestAuthorizeLevels`): an org-level deny
blocks a <br>member-allowed action on an owned in-org object, while a
clean org is allowed, <br>including an action-scoped deny.
* `ScopeOrgDenyBlocksMember` (`TestAuthorizeScope`): the same fold at
the scope <br>level.
* The shared harness covers full and partial evaluation and asserts the
partial <br>result compiles to SQL with zero support rules.

<details><summary>Decision log and equivalence argument</summary>

### Why not deny-via-set-membership

The first attempt expressed deny as a second set-membership clause (`:=
-1 if org_owner in deny_set`). That makes `org`/`scope_org`
multi-valued, and the `not org = -1` checks in
`role_allow`/`scope_allow` then cause OPA to emit a
`data.partial.__not__` support rule that regosql cannot compile
(`TestAuthorizeDomain/UserACLList` failed). It failed even when the deny
set was empty, purely because the `-1` clause exists.

### Why not deny-via-enumeration

A follow-up enumerated only the (usually empty) deny set. It compiled
and passed, but it branches on the unknown org id (one ground residual
per denied org), which violates the "do not branch on the unknown" rule
in `coderd/rbac/POLICY.md`.

### Final approach: allow-only + ground set difference

The known-org clause votes only to allow, and the org-level deny gate is
moved into the org-member level as `member_allow - org_deny`, a set
difference over fully-known sets. The unknown org id is used only in
positive `in` tests, so there is no enumeration, no negated membership,
and no branching on the unknown.

### Empty-set residual pruning

A naive set-membership left unsatisfiable residuals (`org_owner in
set()`) for levels with no matching permissions (e.g. the org level for
an org-member role, or scope-org for `ScopeAll`), each still costing a
`PrepareForEval`. Guarding each membership with a ground `count(...) >
0` lets OPA drop those branches, flattening the residual count across
org sizes.

### Memoized vote maps

Profiling the single-org path showed the cost was repeated function
evaluation: the parametrized helpers rebuilt the same vote map for the
org, member, and scope paths on every check. Hoisting the maps into
memoized zero-arg complete rules (which OPA evaluates once per query)
removed that overhead and eliminated the single-org `Prepare`
regression, while composition keeps the policy readable.

### Equivalence (known-org path, `site != -1`)

* A: original `org == 1` <=> `org_owner in org_allow` (unchanged).
* B: original `org != -1 and member == 1` <=> `org_owner not in org_deny
and org_owner in member_allow` <=> `org_owner in (member_allow -
org_deny)` = new `org_member == 1`.

The critical case (`org` denies, member allows): old blocks it via `not
org = -1`; new blocks it because `org_owner` is removed from
`member_allow - org_deny`. Same outcome. Deny-wins aggregation is intact
because `check_all_org_permissions` still nets an org to `-1` via
`to_vote`, landing it in `org_deny`.

</details>

---

This PR was generated by Coder Agents on behalf of @jeremyruppel.
2026-08-06 09:18:45 -04:00

1775 lines
60 KiB
Go

package rbac
import (
"context"
"encoding/json"
"fmt"
"sync"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/rbac/regosql"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/testutil"
)
type fakeObject struct {
Owner uuid.UUID
OrgOwner uuid.UUID
Type string
Allowed bool
}
func (w fakeObject) RBACObject() Object {
return Object{
Owner: w.Owner.String(),
OrgID: w.OrgOwner.String(),
Type: w.Type,
}
}
// objectBomb is a wrapper around an Objecter that calls a function when
// RBACObject is called.
type objectBomb struct {
Objecter
bomb func()
}
func (o *objectBomb) RBACObject() Object {
o.bomb()
return o.Objecter.RBACObject()
}
func TestFilterError(t *testing.T) {
t.Parallel()
_ = objectBomb{}
t.Run("DifferentResourceTypes", func(t *testing.T) {
t.Parallel()
auth := NewAuthorizer(prometheus.NewRegistry())
subject := Subject{
ID: uuid.NewString(),
Roles: RoleIdentifiers{},
Groups: []string{},
Scope: ScopeAll,
}
_, err := Filter(context.Background(), auth, subject, policy.ActionRead, []Object{ResourceUser, ResourceWorkspace})
require.ErrorContains(t, err, "object types must be uniform")
})
t.Run("CancelledContext", func(t *testing.T) {
t.Parallel()
auth := &MockAuthorizer{
AuthorizeFunc: func(ctx context.Context, subject Subject, action policy.Action, object Object) error {
// Authorize func always returns nil, unless the context is canceled.
return ctx.Err()
},
}
subject := Subject{
ID: uuid.NewString(),
Roles: RoleIdentifiers{
RoleOwner(),
},
Groups: []string{},
Scope: ScopeAll,
}
t.Run("SmallSet", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
objects := []Objecter{
ResourceUser,
ResourceUser,
&objectBomb{
Objecter: ResourceUser,
bomb: cancel,
},
ResourceUser,
}
_, err := Filter(ctx, auth, subject, policy.ActionRead, objects)
require.ErrorIs(t, err, context.Canceled)
})
// Triggers Prepared Authorize
t.Run("LargeSet", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
objects := make([]Objecter, 100)
for i := 0; i < 100; i++ {
objects[i] = ResourceUser
}
objects[20] = &objectBomb{
Objecter: ResourceUser,
bomb: cancel,
}
_, err := Filter(ctx, auth, subject, policy.ActionRead, objects)
require.ErrorIs(t, err, context.Canceled)
})
})
}
// TestFilter ensures the filter acts the same as an individual authorize.
// It generates a random set of objects, then runs the Filter batch function
// against the singular Authorize function.
func TestFilter(t *testing.T) {
t.Parallel()
orgIDs := make([]uuid.UUID, 10)
userIDs := make([]uuid.UUID, len(orgIDs))
for i := range orgIDs {
orgIDs[i] = uuid.New()
userIDs[i] = uuid.New()
}
objects := make([]fakeObject, 0, len(userIDs)*len(orgIDs))
for i := range userIDs {
for j := range orgIDs {
objects = append(objects, fakeObject{
Owner: userIDs[i],
OrgOwner: orgIDs[j],
Type: ResourceWorkspace.Type,
Allowed: false,
})
}
}
testCases := []struct {
Name string
Actor Subject
Action policy.Action
ObjectType string
}{
{
Name: "NoRoles",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
},
{
Name: "Admin",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{RoleAuditor(), RoleOwner(), RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
},
{
Name: "OrgAdmin",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{ScopedRoleOrgAdmin(orgIDs[0]), RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
},
{
Name: "OrgMember",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
},
{
Name: "ManyRoles",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{
ScopedRoleOrgAdmin(orgIDs[0]),
ScopedRoleOrgAdmin(orgIDs[1]),
ScopedRoleOrgAdmin(orgIDs[2]),
RoleMember(),
},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
},
{
Name: "SiteMember",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{RoleMember()},
},
ObjectType: ResourceUser.Type,
Action: policy.ActionRead,
},
{
Name: "ReadOrgs",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{
RoleMember(),
},
},
ObjectType: ResourceOrganization.Type,
Action: policy.ActionRead,
},
{
Name: "ScopeApplicationConnect",
Actor: Subject{
ID: userIDs[0].String(),
Roles: RoleIdentifiers{RoleAuditor(), RoleOwner(), RoleMember()},
},
ObjectType: ResourceWorkspace.Type,
Action: policy.ActionRead,
},
}
for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
t.Parallel()
actor := tc.Actor
localObjects := make([]fakeObject, len(objects))
copy(localObjects, objects)
auth := NewAuthorizer(prometheus.NewRegistry())
if actor.Scope == nil {
// Default to ScopeAll
actor.Scope = ScopeAll
}
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// Run auth 1 by 1
var allowedCount int
for i, obj := range localObjects {
obj.Type = tc.ObjectType
err := auth.Authorize(ctx, actor, policy.ActionRead, obj.RBACObject())
obj.Allowed = err == nil
if err == nil {
allowedCount++
}
localObjects[i] = obj
}
// Run by filter
list, err := Filter(ctx, auth, actor, tc.Action, localObjects)
require.NoError(t, err)
require.Equal(t, allowedCount, len(list), "expected number of allowed")
for _, obj := range list {
require.True(t, obj.Allowed, "expected allowed")
}
})
}
}
// TestAuthorizeDomain test the very basic roles that are commonly used.
func TestAuthorizeDomain(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
unusedID := 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)),
Groups: []string{allUsersGroup},
Roles: Roles{
must(RoleByName(RoleMember())),
orgMemberRole(defOrg),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
},
}
testAuthorize(t, "UserACLList", user, []authTestCase{
{
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(defOrg).WithACLUserList(map[string][]policy.Action{
user.ID: ResourceWorkspace.AvailableActions(),
}),
actions: ResourceWorkspace.AvailableActions(),
allow: true,
},
{
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(defOrg).WithACLUserList(map[string][]policy.Action{
user.ID: {policy.WildcardSymbol},
}),
actions: ResourceWorkspace.AvailableActions(),
allow: true,
},
{
// User ACLs only grant permissions in organizations where the
// subject is currently a member.
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{
user.ID: ResourceWorkspace.AvailableActions(),
}),
actions: ResourceWorkspace.AvailableActions(),
allow: false,
},
{
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{
user.ID: {policy.WildcardSymbol},
}),
actions: ResourceWorkspace.AvailableActions(),
allow: false,
},
{
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(unusedID).WithACLUserList(map[string][]policy.Action{
user.ID: {policy.ActionRead, policy.ActionUpdate},
}),
actions: []policy.Action{policy.ActionCreate, policy.ActionDelete},
allow: false,
},
{
// By default users cannot update templates
resource: ResourceTemplate.InOrg(defOrg).WithACLUserList(map[string][]policy.Action{
user.ID: {policy.ActionUpdate},
}),
actions: []policy.Action{policy.ActionUpdate},
allow: true,
},
})
testAuthorize(t, "GroupACLList", user, []authTestCase{
{
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(defOrg).WithGroupACL(map[string][]policy.Action{
allUsersGroup: ResourceWorkspace.AvailableActions(),
}),
actions: ResourceWorkspace.AvailableActions(),
allow: true,
},
{
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(defOrg).WithGroupACL(map[string][]policy.Action{
allUsersGroup: {policy.WildcardSymbol},
}),
actions: ResourceWorkspace.AvailableActions(),
allow: true,
},
{
resource: ResourceWorkspace.WithOwner(unusedID.String()).InOrg(defOrg).WithGroupACL(map[string][]policy.Action{
allUsersGroup: {policy.ActionRead, policy.ActionUpdate},
}),
actions: []policy.Action{policy.ActionCreate, policy.ActionDelete},
allow: false,
},
{
// By default users cannot update templates
resource: ResourceTemplate.InOrg(defOrg).WithGroupACL(map[string][]policy.Action{
allUsersGroup: {policy.ActionUpdate},
}),
actions: []policy.Action{policy.ActionUpdate},
allow: true,
},
})
testAuthorize(t, "Member", user, []authTestCase{
// Org + me
{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},
// No org + me
{resource: ResourceWorkspace.WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.All(), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + other us
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
})
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{{
Identifier: RoleIdentifier{Name: "deny-all"},
// List out deny permissions explicitly
Site: []Permission{
{
Negate: true,
ResourceType: policy.WildcardSymbol,
Action: policy.WildcardSymbol,
},
},
}},
}
testAuthorize(t, "DeletedMember", user, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.All(), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
})
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(ScopedRoleOrgAdmin(defOrg))),
orgMemberRole(defOrg),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
must(RoleByName(RoleMember())),
},
}
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},
{resource: ResourceWorkspace.InOrg(defOrg), actions: workspaceConnect, allow: false},
// No org + me
{resource: ResourceWorkspace.WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.All(), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: workspaceExceptConnect, allow: true},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: workspaceConnect, allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: false},
})
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(RoleOwner())),
must(RoleByName(RoleMember())),
},
}
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},
{resource: ResourceWorkspace.WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.All(), actions: ResourceWorkspace.AvailableActions(), allow: true},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: true},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.InOrg(unusedID), actions: ResourceWorkspace.AvailableActions(), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), actions: ResourceWorkspace.AvailableActions(), allow: true},
})
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeApplicationConnect)),
Roles: Roles{
orgMemberRole(defOrg),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
must(RoleByName(RoleMember())),
},
}
testAuthorize(t, "ApplicationToken", user,
// Create (connect) Actions
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionApplicationConnect}
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), allow: false},
// No org + me
{resource: ResourceWorkspace.WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.All(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
}),
// No ActionApplicationConnect action
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
// Other Objects
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Org + me
{resource: ResourceTemplate.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceTemplate.InOrg(defOrg)},
{resource: ResourceTemplate.WithOwner(user.ID)},
{resource: ResourceTemplate.All()},
// Other org + me
{resource: ResourceTemplate.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceTemplate.InOrg(unusedID)},
// Other org + other user
{resource: ResourceTemplate.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceTemplate.WithOwner("not-me")},
// Other org + other use
{resource: ResourceTemplate.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceTemplate.InOrg(unusedID)},
{resource: ResourceTemplate.WithOwner("not-me")},
}),
)
// In practice this is a token scope on a regular subject
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
{
Identifier: RoleIdentifier{Name: "ReadOnlyOrgAndUser"},
Site: []Permission{},
User: []Permission{
{
Negate: false,
ResourceType: "*",
Action: policy.ActionRead,
},
},
ByOrgID: map[string]OrgPermissions{
defOrg.String(): {
Org: []Permission{{
Negate: false,
ResourceType: "*",
Action: policy.ActionRead,
}},
Member: []Permission{},
},
},
},
},
}
testAuthorize(t, "ReadOnly", user,
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead}
return c
}, []authTestCase{
// Read
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: ResourceWorkspace.WithOwner(user.ID), allow: true},
{resource: ResourceWorkspace.All(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
}),
// Pass non-read actions
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Read
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}))
}
// TestAuthorizeUserACLOrgMembership verifies that user ACL grants require org
// membership, while site-wide roles still authorize independent of ACLs.
func TestAuthorizeUserACLOrgMembership(t *testing.T) {
t.Parallel()
orgID := uuid.New()
// Site template-admin, not a member of orgID.
siteTemplateAdmin := Subject{
ID: "site-template-admin",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(RoleTemplateAdmin())),
},
}
testAuthorize(t, "SiteTemplateAdminNotInOrg", siteTemplateAdmin, []authTestCase{
{
// Authorized by the site role, no ACL needed.
resource: ResourceTemplate.InOrg(orgID),
actions: []policy.Action{policy.ActionUpdate},
allow: true,
},
{
// Redundant ACL entry; still authorized by the site role.
resource: ResourceTemplate.InOrg(orgID).WithACLUserList(map[string][]policy.Action{
siteTemplateAdmin.ID: {policy.ActionUpdate},
}),
actions: []policy.Action{policy.ActionUpdate},
allow: true,
},
})
// Site user-admin (no template perms), not a member of orgID.
siteUserAdmin := Subject{
ID: "site-user-admin",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(RoleUserAdmin())),
},
}
testAuthorize(t, "SiteUserAdminNotInOrg", siteUserAdmin, []authTestCase{
{
// No template role and no ACL entry: denied.
resource: ResourceTemplate.InOrg(orgID),
actions: []policy.Action{policy.ActionUpdate},
allow: false,
},
{
// An ACL grant must not authorize a non-member.
resource: ResourceTemplate.InOrg(orgID).WithACLUserList(map[string][]policy.Action{
siteUserAdmin.ID: {policy.ActionUpdate},
}),
actions: []policy.Action{policy.ActionUpdate},
allow: false,
},
})
// Same site user-admin, now also a member of orgID.
siteUserAdminOrgMember := Subject{
ID: "site-user-admin-org-member",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(RoleUserAdmin())),
orgMemberRole(orgID),
},
}
testAuthorize(t, "SiteUserAdminOrgMember", siteUserAdminOrgMember, []authTestCase{
{
// Org membership alone does not grant template update.
resource: ResourceTemplate.InOrg(orgID),
actions: []policy.Action{policy.ActionUpdate},
allow: false,
},
{
// As an org member, the ACL grant takes effect.
resource: ResourceTemplate.InOrg(orgID).WithACLUserList(map[string][]policy.Action{
siteUserAdminOrgMember.ID: {policy.ActionUpdate},
}),
actions: []policy.Action{policy.ActionUpdate},
allow: true,
},
})
}
// TestAuthorizeWorkspaceAccessCreationBan tests a user who is a member of a
// single organization and holds both organization-workspace-access and
// organization-workspace-creation-ban in that organization. The ban's
// negative permissions must override the workspace create elevation granted
// by workspace-access. Because the ban denies creation in the user's only
// organization, the any_org check used by the UI must also deny creation.
func TestAuthorizeWorkspaceAccessCreationBan(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
user := Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
must(RoleByName(ScopedRoleOrgWorkspaceCreationBan(defOrg))),
},
}
testAuthorize(t, "WorkspaceAccessWithCreationBan", user, []authTestCase{
// any_org create is denied: the only organization vote is the ban's
// negative, so the max vote across all organizations is a deny.
{resource: ResourceWorkspace.AnyOrganization().WithOwner(user.ID), actions: []policy.Action{policy.ActionCreate}, allow: false},
// The banned actions are denied directly in the organization too.
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionCreate, policy.ActionDelete}, allow: false},
// Non-banned workspace actions granted by workspace-access remain.
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionRead, policy.ActionUpdate}, allow: true},
{resource: ResourceWorkspace.AnyOrganization().WithOwner(user.ID), actions: []policy.Action{policy.ActionRead}, allow: true},
})
// The same user, now also a member of a second organization with
// workspace-access and no ban. One permissible organization out of two
// is enough for any_org to allow creation.
secondOrg := uuid.New()
userTwoOrgs := Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(RoleMember())),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
must(RoleByName(ScopedRoleOrgWorkspaceCreationBan(defOrg))),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(secondOrg))),
},
}
testAuthorize(t, "BannedInOneOfTwoOrgs", userTwoOrgs, []authTestCase{
// any_org create is allowed: the second organization votes to allow,
// and the max vote across all organizations wins.
{resource: ResourceWorkspace.AnyOrganization().WithOwner(userTwoOrgs.ID), actions: []policy.Action{policy.ActionCreate}, allow: true},
// The ban still denies creation in the banned organization.
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(userTwoOrgs.ID), actions: []policy.Action{policy.ActionCreate, policy.ActionDelete}, allow: false},
// Creation is allowed in the organization without the ban.
{resource: ResourceWorkspace.InOrg(secondOrg).WithOwner(userTwoOrgs.ID), actions: []policy.Action{policy.ActionCreate, policy.ActionDelete}, allow: true},
})
}
// TestAuthorizeLevels ensures level overrides are acting appropriately
func TestAuthorizeLevels(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
unusedID := uuid.New()
user := Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
must(RoleByName(RoleOwner())),
{
Identifier: RoleIdentifier{Name: "org-deny:", OrganizationID: defOrg},
ByOrgID: map[string]OrgPermissions{
defOrg.String(): {
Org: []Permission{
{
Negate: true,
ResourceType: "*",
Action: "*",
},
},
Member: []Permission{},
},
},
},
{
Identifier: RoleIdentifier{Name: "user-deny-all"},
// List out deny permissions explicitly
User: []Permission{
{
Negate: true,
ResourceType: policy.WildcardSymbol,
Action: policy.WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "AdminAlwaysAllow", user,
cases(func(c authTestCase) authTestCase {
c.actions = ResourceWorkspace.AvailableActions()
c.allow = true
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}))
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
{
Identifier: RoleIdentifier{Name: "site-noise"},
Site: []Permission{
{
Negate: true,
ResourceType: "random",
Action: policy.WildcardSymbol,
},
},
},
must(RoleByName(ScopedRoleOrgAdmin(defOrg))),
{
Identifier: RoleIdentifier{Name: "user-deny-all"},
// List out deny permissions explicitly
User: []Permission{
{
Negate: true,
ResourceType: policy.WildcardSymbol,
Action: policy.WildcardSymbol,
},
},
},
},
}
testAuthorize(t, "OrgAllowAll", user,
cases(func(c authTestCase) authTestCase {
// SSH and app connect are not implied here.
c.actions = slice.Omit(ResourceWorkspace.AvailableActions(), policy.ActionApplicationConnect, policy.ActionSSH)
return c
}, []authTestCase{
// Org + me
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: true},
{resource: ResourceWorkspace.InOrg(defOrg), allow: true},
{resource: ResourceWorkspace.WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.All(), allow: false},
// Other org + me
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
// Other org + other user
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: true},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
// Other org + other use
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
}))
// Org-level deny must block a member-level allow, member-level deny must win
// within an org, and org-level allow must override a member-level deny, all on
// owned in-org objects. These exercise the known-org set-difference gate.
denyOrg := uuid.New() // member allows read; org denies read
memberDenyOrg := uuid.New() // member both allows and denies read (deny wins)
orgAllowOrg := uuid.New() // org allows read; member denies read
user = Subject{
ID: "me",
Scope: must(ExpandScope(ScopeAll)),
Roles: Roles{
{
Identifier: RoleIdentifier{Name: "member-allow", OrganizationID: defOrg},
ByOrgID: map[string]OrgPermissions{
defOrg.String(): {
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
},
},
},
{
Identifier: RoleIdentifier{Name: "member-allow-org-deny", OrganizationID: denyOrg},
ByOrgID: map[string]OrgPermissions{
denyOrg.String(): {
// Org denies only read; member allows read and update, so the
// deny is action-scoped (update stays allowed).
Org: []Permission{{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
Member: []Permission{
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionUpdate},
},
},
},
},
{
Identifier: RoleIdentifier{Name: "member-deny-wins", OrganizationID: memberDenyOrg},
ByOrgID: map[string]OrgPermissions{
memberDenyOrg.String(): {
Member: []Permission{
{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead},
},
},
},
},
{
Identifier: RoleIdentifier{Name: "org-allow-member-deny", OrganizationID: orgAllowOrg},
ByOrgID: map[string]OrgPermissions{
orgAllowOrg.String(): {
Org: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
Member: []Permission{{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
},
},
},
},
}
testAuthorize(t, "OrgDenyBlocksMember", user,
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead}
return c
}, []authTestCase{
// Member level allows the owned, in-org object.
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: true},
// Org-level deny blocks the owned object even though member allows it.
{resource: ResourceWorkspace.InOrg(denyOrg).WithOwner(user.ID), allow: false},
// Member-level deny wins over a member-level allow in the same org.
{resource: ResourceWorkspace.InOrg(memberDenyOrg).WithOwner(user.ID), allow: false},
// Org-level allow overrides a member-level deny, regardless of owner.
{resource: ResourceWorkspace.InOrg(orgAllowOrg).WithOwner(user.ID), allow: true},
{resource: ResourceWorkspace.InOrg(orgAllowOrg).WithOwner("not-me"), allow: true},
// The member grant does not extend to objects the subject does not own.
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: false},
// Not a member of this org at all.
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), allow: false},
}),
// The org-level deny is scoped to the action it names: update stays allowed
// in denyOrg because only read is denied.
[]authTestCase{
{resource: ResourceWorkspace.InOrg(denyOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionUpdate}, allow: true},
},
)
}
func TestAuthorizeScope(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
unusedID := uuid.New()
user := Subject{
ID: "me",
Roles: Roles{must(RoleByName(RoleOwner()))},
Scope: must(ExpandScope(ScopeApplicationConnect)),
}
testAuthorize(t, "Admin_ScopeApplicationConnect", user,
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg), allow: false},
{resource: ResourceWorkspace.WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.All(), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), allow: false},
{resource: ResourceWorkspace.InOrg(unusedID), allow: false},
{resource: ResourceWorkspace.WithOwner("not-me"), allow: false},
}),
// Allowed by scope:
[]authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: []policy.Action{policy.ActionApplicationConnect}, allow: true},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionApplicationConnect}, allow: true},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: []policy.Action{policy.ActionApplicationConnect}, allow: true},
},
)
user = Subject{
ID: "me",
Roles: Roles{
must(RoleByName(RoleMember())),
orgMemberRole(defOrg),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
},
Scope: must(ExpandScope(ScopeApplicationConnect)),
}
testAuthorize(t, "User_ScopeApplicationConnect", user,
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
// Allowed by scope:
[]authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionApplicationConnect}, allow: true},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: []policy.Action{policy.ActionApplicationConnect}, allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: []policy.Action{policy.ActionApplicationConnect}, allow: false},
},
)
workspaceID := uuid.New()
user = Subject{
ID: "me",
Roles: Roles{
must(RoleByName(RoleMember())),
orgMemberRole(defOrg),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
},
Scope: Scope{
Role: Role{
Identifier: RoleIdentifier{Name: "workspace_agent"},
DisplayName: "Workspace Agent",
Site: Permissions(map[string][]policy.Action{
// Only read access for workspaces.
ResourceWorkspace.Type: {policy.ActionRead},
}),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
},
AllowIDList: []AllowListElement{{Type: ResourceWorkspace.Type, ID: workspaceID.String()}},
},
}
testAuthorize(t, "User_WorkspaceAgent", user,
// Test cases without ID
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
// Test all cases with the workspace id
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
c.resource.WithID(workspaceID)
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
// Test cases with random ids. These should always fail from the scope.
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead, policy.ActionCreate, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
c.resource.WithID(uuid.New())
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
// Allowed by scope:
[]authTestCase{
{resource: ResourceWorkspace.WithID(workspaceID).InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionRead}, allow: true},
// The scope will return true, but the user perms return false for resources not owned by the user.
{resource: ResourceWorkspace.WithID(workspaceID).InOrg(defOrg).WithOwner("not-me"), actions: []policy.Action{policy.ActionRead}, allow: false},
{resource: ResourceWorkspace.WithID(workspaceID).InOrg(unusedID).WithOwner("not-me"), actions: []policy.Action{policy.ActionRead}, allow: false},
},
)
// This scope can only create workspaces
user = Subject{
ID: "me",
Roles: Roles{
must(RoleByName(RoleMember())),
orgMemberRole(defOrg),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
},
Scope: Scope{
Role: Role{
Identifier: RoleIdentifier{Name: "create_workspace"},
DisplayName: "Create Workspace",
Site: Permissions(map[string][]policy.Action{
// Only read access for workspaces.
ResourceWorkspace.Type: {policy.ActionCreate},
}),
User: []Permission{},
ByOrgID: map[string]OrgPermissions{},
},
// Empty string allow_list is allowed for actions like 'create'
AllowIDList: []AllowListElement{{
Type: ResourceWorkspace.Type, ID: "",
}},
},
}
testAuthorize(t, "CreatWorkspaceScope", user,
// All these cases will fail because a resource ID is set.
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionCreate, policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
c.resource.ID = uuid.NewString()
return c
}, []authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(defOrg)},
{resource: ResourceWorkspace.WithOwner(user.ID)},
{resource: ResourceWorkspace.All()},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner(user.ID)},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me")},
{resource: ResourceWorkspace.WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me")},
{resource: ResourceWorkspace.InOrg(unusedID)},
{resource: ResourceWorkspace.WithOwner("not-me")},
}),
// Test create allowed by scope:
[]authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionCreate}, allow: true},
// The scope will return true, but the user perms return false for resources not owned by the user.
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("not-me"), actions: []policy.Action{policy.ActionCreate}, allow: false},
{resource: ResourceWorkspace.InOrg(unusedID).WithOwner("not-me"), actions: []policy.Action{policy.ActionCreate}, allow: false},
},
)
meID := uuid.New()
user = Subject{
ID: meID.String(),
Roles: Roles{
must(RoleByName(RoleMember())),
orgMemberRole(defOrg),
must(RoleByName(ScopedRoleOrgWorkspaceAccess(defOrg))),
},
Scope: must(ScopeNoUserData.Expand()),
}
// Test 1: Verify that no_user_data scope prevents accessing user data
testAuthorize(t, "ReadPersonalUser", user,
cases(func(c authTestCase) authTestCase {
c.actions = ResourceUser.AvailableActions()
c.allow = false
c.resource.ID = meID.String()
return c
}, []authTestCase{
{resource: ResourceUser.WithOwner(meID.String()).InOrg(defOrg).WithID(meID)},
}),
)
// Test 2: Verify token can still perform regular member actions that don't involve user data
testAuthorize(t, "NoUserData_CanStillUseRegularPermissions", user,
// Test workspace access - should still work
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead}
c.allow = true
return c
}, []authTestCase{
// Can still read owned workspaces
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
}),
// Test workspace create - should still work
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionCreate}
c.allow = true
return c
}, []authTestCase{
// Can still create workspaces
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID)},
}),
)
// Test 3: Verify token cannot perform actions outside of member role
testAuthorize(t, "NoUserData_CannotExceedMemberRole", user,
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead, policy.ActionUpdate, policy.ActionDelete}
c.allow = false
return c
}, []authTestCase{
// Cannot access other users' workspaces
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner("other-user")},
// Cannot access admin resources
{resource: ResourceOrganization.WithID(defOrg)},
}),
)
// Test setting a scope on the org and the user level
// This is a bit of a contrived example that would not exist in practice.
// It combines a specific organization scope with a user scope to verify
// that both are applied.
// The test uses the `Owner` role, so by default the user can do everything.
user = Subject{
ID: "me",
Roles: Roles{
must(RoleByName(RoleOwner())),
// TODO: There is a __bug__ in the policy.rego. If the user is not a
// member of the organization, the org_scope fails. This happens because
// the org_allow_set uses "org_members".
// This is odd behavior, as without this membership role, the test for
// the workspace fails. Maybe scopes should just assume the user
// is a member.
orgMemberRole(defOrg),
},
Scope: Scope{
Role: Role{
Identifier: RoleIdentifier{
Name: "org-and-user-scope",
OrganizationID: defOrg,
},
DisplayName: "OrgAndUserScope",
Site: nil,
User: Permissions(map[string][]policy.Action{
ResourceUser.Type: {policy.ActionRead},
}),
ByOrgID: map[string]OrgPermissions{
defOrg.String(): {
Org: Permissions(map[string][]policy.Action{
ResourceWorkspace.Type: {policy.ActionRead},
}),
Member: []Permission{},
},
},
},
AllowIDList: []AllowListElement{AllowListAll()},
},
}
testAuthorize(t, "OrgAndUserScope", user,
// Allowed by scope:
[]authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: true, actions: []policy.Action{policy.ActionRead}},
{resource: ResourceUser.WithOwner(user.ID), allow: true, actions: []policy.Action{policy.ActionRead}},
},
// Not allowed by scope:
[]authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), allow: false, actions: []policy.Action{policy.ActionCreate}},
{resource: ResourceUser.WithOwner(user.ID), allow: false, actions: []policy.Action{policy.ActionUpdate}},
},
)
// Scope-level org deny must block a member-level allow, mirroring
// OrgDenyBlocksMember but through the scope's org/member permissions (the
// scope_org_member member_allow - org_deny fold). The roles allow both
// objects, so the scope is the deciding factor.
scopeAllowOrg := uuid.New()
scopeDenyOrg := uuid.New()
user = Subject{
ID: "me",
Roles: Roles{
must(RoleByName(RoleMember())),
{
Identifier: RoleIdentifier{Name: "member-allow-a", OrganizationID: scopeAllowOrg},
ByOrgID: map[string]OrgPermissions{
scopeAllowOrg.String(): {
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
},
},
},
{
Identifier: RoleIdentifier{Name: "member-allow-b", OrganizationID: scopeDenyOrg},
ByOrgID: map[string]OrgPermissions{
scopeDenyOrg.String(): {
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
},
},
},
},
Scope: Scope{
Role: Role{
Identifier: RoleIdentifier{Name: "scope-org-deny"},
ByOrgID: map[string]OrgPermissions{
scopeAllowOrg.String(): {
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
},
scopeDenyOrg.String(): {
Org: []Permission{{Negate: true, ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
Member: []Permission{{ResourceType: ResourceWorkspace.Type, Action: policy.ActionRead}},
},
},
},
AllowIDList: []AllowListElement{AllowListAll()},
},
}
testAuthorize(t, "ScopeOrgDenyBlocksMember", user,
cases(func(c authTestCase) authTestCase {
c.actions = []policy.Action{policy.ActionRead}
return c
}, []authTestCase{
// Scope member-allow permits the owned in-org object.
{resource: ResourceWorkspace.InOrg(scopeAllowOrg).WithOwner(user.ID), allow: true},
// Scope org-level deny blocks it even though scope member allows.
{resource: ResourceWorkspace.InOrg(scopeDenyOrg).WithOwner(user.ID), allow: false},
}))
}
func TestScopeAllowList(t *testing.T) {
t.Parallel()
defOrg := uuid.New()
// Some IDs to use
wid := uuid.New()
gid := uuid.New()
user := Subject{
ID: "me",
Roles: Roles{
must(RoleByName(RoleOwner())),
},
Scope: Scope{
Role: Role{
Identifier: RoleIdentifier{
Name: "AllowList",
OrganizationID: defOrg,
},
DisplayName: "AllowList",
// Allow almost everything
Site: allPermsExcept(ResourceUser),
},
AllowIDList: []AllowListElement{
{Type: ResourceWorkspace.Type, ID: wid.String()},
{Type: ResourceWorkspace.Type, ID: ""}, // Allow to create
{Type: ResourceTemplate.Type, ID: policy.WildcardSymbol},
{Type: ResourceGroup.Type, ID: gid.String()},
// This scope allows all users, but the permissions do not.
{Type: ResourceUser.Type, ID: policy.WildcardSymbol},
},
},
}
testAuthorize(t, "AllowList", user,
// Allowed:
cases(func(c authTestCase) authTestCase {
c.allow = true
return c
},
[]authTestCase{
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID).WithID(wid), actions: []policy.Action{policy.ActionRead}},
// matching on empty id
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionCreate}},
// Template has wildcard ID, so any uuid is allowed, including the empty
{resource: ResourceTemplate.InOrg(defOrg).WithID(uuid.New()), actions: AllActions()},
{resource: ResourceTemplate.InOrg(defOrg).WithID(uuid.New()), actions: AllActions()},
{resource: ResourceTemplate.InOrg(defOrg), actions: AllActions()},
// Group
{resource: ResourceGroup.InOrg(defOrg).WithID(gid), actions: []policy.Action{policy.ActionRead}},
},
),
// Not allowed:
cases(func(c authTestCase) authTestCase {
c.allow = false
return c
},
[]authTestCase{
// Has the scope and allow list, but not the permission
{resource: ResourceUser.WithOwner(user.ID), actions: []policy.Action{policy.ActionRead}},
// `wid` matches on the uuid, but not the type
{resource: ResourceGroup.WithID(wid), actions: []policy.Action{policy.ActionRead}},
// no empty id for the create action
{resource: ResourceGroup.InOrg(defOrg), actions: []policy.Action{policy.ActionCreate}},
},
),
)
// Wildcard type
user = Subject{
ID: "me",
Roles: Roles{
must(RoleByName(RoleOwner())),
},
Scope: Scope{
Role: Role{
Identifier: RoleIdentifier{
Name: "WildcardType",
OrganizationID: defOrg,
},
DisplayName: "WildcardType",
// Allow almost everything
Site: allPermsExcept(ResourceUser),
},
AllowIDList: []AllowListElement{
{Type: policy.WildcardSymbol, ID: wid.String()},
},
},
}
testAuthorize(t, "WildcardType", user,
// Allowed:
cases(func(c authTestCase) authTestCase {
c.allow = true
return c
},
[]authTestCase{
// anything with the id is ok
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID).WithID(wid), actions: []policy.Action{policy.ActionRead}},
{resource: ResourceGroup.InOrg(defOrg).WithID(wid), actions: []policy.Action{policy.ActionRead}},
{resource: ResourceTemplate.InOrg(defOrg).WithID(wid), actions: []policy.Action{policy.ActionRead}},
},
),
// Not allowed:
cases(func(c authTestCase) authTestCase {
c.allow = false
return c
},
[]authTestCase{
// Anything without the id is not allowed
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID), actions: []policy.Action{policy.ActionCreate}},
{resource: ResourceWorkspace.InOrg(defOrg).WithOwner(user.ID).WithID(uuid.New()), actions: []policy.Action{policy.ActionRead}},
},
),
)
}
// cases applies a given function to all test cases. This makes generalities easier to create.
func cases(opt func(c authTestCase) authTestCase, cases []authTestCase) []authTestCase {
if opt == nil {
return cases
}
for i := range cases {
cases[i] = opt(cases[i])
}
return cases
}
type authTestCase struct {
resource Object
actions []policy.Action
allow bool
}
func testAuthorize(t *testing.T, name string, subject Subject, sets ...[]authTestCase) {
t.Helper()
authorizer := NewAuthorizer(prometheus.NewRegistry())
for i, cases := range sets {
for j, c := range cases {
caseName := fmt.Sprintf("%s/Set%d/Case%d", name, i, j)
t.Run(caseName, func(t *testing.T) {
t.Parallel()
for _, a := range c.actions {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
t.Cleanup(cancel)
authError := authorizer.Authorize(ctx, subject, a, c.resource)
d, _ := json.Marshal(map[string]interface{}{
// This is not perfect marshal, but it is good enough for debugging this test.
"subject": authSubject{
ID: subject.ID,
Roles: must(subject.Roles.Expand()),
Groups: subject.Groups,
Scope: must(subject.Scope.Expand()),
},
"object": c.resource,
"action": a,
})
// Logging only
t.Logf("input: %s", string(d))
if authError != nil {
var uerr *UnauthorizedError
if xerrors.As(authError, &uerr) {
t.Logf("internal error: %+v", uerr.Internal().Error())
t.Logf("output: %+v", uerr.Output())
}
}
if c.allow {
assert.NoError(t, authError, "expected no error for testcase action %s", a)
} else {
assert.Error(t, authError, "expected unauthorized")
}
prepared, err := authorizer.Prepare(ctx, subject, a, c.resource.Type)
require.NoError(t, err, "make prepared authorizer")
// For unit testing logging and assertions, we want the PartialAuthorizer
// struct.
partialAuthz, ok := prepared.(*PartialAuthorizer)
require.True(t, ok, "prepared authorizer is partial")
// Ensure the partial can compile to a SQL clause.
// This does not guarantee that the clause is valid SQL.
_, err = Compile(ConfigWithACL(), partialAuthz)
require.NoError(t, err, "compile prepared authorizer")
// Also check the rego policy can form a valid partial query result.
// This ensures we can convert the queries into SQL WHERE clauses in the future.
// If this function returns 'Support' sections, then we cannot convert the query into SQL.
for _, q := range partialAuthz.partialQueries.Queries {
t.Logf("query: %+v", q.String())
}
for _, s := range partialAuthz.partialQueries.Support {
t.Logf("support: %+v", s.String())
}
require.Equal(t, 0, len(partialAuthz.partialQueries.Support), "expected 0 support rules in scope authorizer")
partialErr := partialAuthz.Authorize(ctx, c.resource)
// If '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)")
}
}
}
})
}
}
}
// orgMemberRole returns an organization-member role for RBAC-only tests.
//
// organization-member is now a DB-backed system role (not a built-in role), so
// RoleByName won't resolve it here. Assume the default behavior: workspace
// sharing enabled.
func orgMemberRole(orgID uuid.UUID) Role {
settings := OrgSettings{ShareableWorkspaceOwners: ShareableWorkspaceOwnersEveryone}
perms := OrgMemberPermissions(settings)
return Role{
Identifier: ScopedRoleOrgMember(orgID),
DisplayName: "",
Site: []Permission{},
User: []Permission{},
ByOrgID: map[string]OrgPermissions{
orgID.String(): {
Org: perms.Org,
Member: perms.Member,
},
},
}
}
func must[T any](value T, err error) T {
if err != nil {
panic(err)
}
return value
}
type MockAuthorizer struct {
AuthorizeFunc func(context.Context, Subject, policy.Action, Object) error
}
var _ Authorizer = (*MockAuthorizer)(nil)
func (d *MockAuthorizer) Authorize(ctx context.Context, s Subject, a policy.Action, o Object) error {
return d.AuthorizeFunc(ctx, s, a, o)
}
func (d *MockAuthorizer) Prepare(_ context.Context, subject Subject, action policy.Action, _ string) (PreparedAuthorized, error) {
return &mockPreparedAuthorizer{
Original: d,
Subject: subject,
Action: action,
}, nil
}
var _ PreparedAuthorized = (*mockPreparedAuthorizer)(nil)
// fakePreparedAuthorizer is the prepared version of a FakeAuthorizer. It will
// return the same error as the original FakeAuthorizer.
type mockPreparedAuthorizer struct {
sync.RWMutex
Original *MockAuthorizer
Subject Subject
Action policy.Action
}
func (f *mockPreparedAuthorizer) Authorize(ctx context.Context, object Object) error {
return f.Original.Authorize(ctx, f.Subject, f.Action, object)
}
// CompileToSQL returns a compiled version of the authorizer that will work for
// in memory databases. This fake version will not work against a SQL database.
func (*mockPreparedAuthorizer) CompileToSQL(_ context.Context, _ regosql.ConvertConfig) (string, error) {
return "not a valid sql string", nil
}