mirror of
https://github.com/coder/coder.git
synced 2026-11-03 05:55:24 +08:00
## 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.
1775 lines
60 KiB
Go
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
|
|
}
|