diff --git a/coderd/authz/README.md b/coderd/authz/README.md new file mode 100644 index 0000000000000..9f8d5c70b0edd --- /dev/null +++ b/coderd/authz/README.md @@ -0,0 +1,73 @@ +# Authz + +Package `authz` implements AuthoriZation for Coder. + +## Overview + +Authorization defines what **permission** an **subject** has to perform **actions** to **objects**: +- **Permission** is binary: *yes* (allowed) or *no* (denied). +- **Subject** in this case is anything that implements interface `authz.Subject`. +- **Action** here is an enumerated list of actions, but we stick to `Create`, `Read`, `Update`, and `Delete` here. +- **Object** here is anything that implements `authz.Object`. + +## Permission Structure + +A **permission** is a rule that grants or denies access for a **subject** to perform an **action** on a **object**. +A **permission** is always applied at a given **level**: + +- **site** level applies to all objects in a given Coder deployment. +- **org** level applies to all objects that have an organization owner (`org_owner`) +- **user** level applies to all objects that have an owner with the same ID as the subject. + +**Permissions** at a higher **level** always override permissions at a **lower** level. + +The effect of a **permission** can be: +- **positive** (allows) +- **negative** (denies) +- **abstain** (neither allows or denies, not applicable) + +**Negative** permissions **always** override **positive** permissions at the same level. +Both **negative** and **positive** permissions override **abstain** at the same level. + +This can be represented by the following truth table, where Y represents *positive*, N represents *negative*, and _ represents *abstain*: + +| Action | Positive | Negative | Result | +|--------|----------|----------|--------| +| read | Y | _ | Y | +| read | Y | N | N | +| read | _ | _ | _ | +| read | _ | N | Y | + + +## Permission Representation + +**Permissions** are represented in string format as `?...`, where: + +- `sign` can be either `+` or `-`. If it is omitted, sign is assumed to be `+`. +- `level` is either `*`, `site`, `org`, or `user`. +- `object` is any valid resource type. +- `id` is any valid UUID v4. +- `action` is `create`, `read`, `modify`, or `delete`. + +## Example Permissions + +- `+site.*.*.read`: allowed to perform the `read` action against all objects of type `devurl` in a given Coder deployment. +- `-user.workspace.*.create`: user is not allowed to create workspaces. + +## Roles + +A *role* is a set of permissions. When evaluating a role's permission to form an action, all the relevant permissions for the role are combined at each level. Permissions at a higher level override permissions at a lower level. + +The following table shows the per-level role evaluation. +Y indicates that the role provides positive permissions, N indicates the role provides negative permissions, and _ indicates the role does not provide positive or negative permissions. YN_ indicates that the value in the cell does not matter for the access result. + +| Role (example) | Site | Org | User | Result | +|-----------------|------|-----|------|--------| +| site-admin | Y | YN_ | YN_ | Y | +| no-permission | N | YN_ | YN_ | N | +| org-admin | _ | Y | YN_ | Y | +| non-org-member | _ | N | YN_ | N | +| user | _ | _ | Y | Y | +| | _ | _ | N | N | +| unauthenticated | _ | _ | _ | N | + diff --git a/coderd/authz/action.go b/coderd/authz/action.go new file mode 100644 index 0000000000000..eb7360ef1d6c9 --- /dev/null +++ b/coderd/authz/action.go @@ -0,0 +1,11 @@ +package authz + +// Action represents the allowed actions to be done on an object. +type Action string + +const ( + ActionCreate = "create" + ActionRead = "read" + ActionUpdate = "update" + ActionDelete = "delete" +) diff --git a/coderd/authz/authz.go b/coderd/authz/authz.go new file mode 100644 index 0000000000000..7668903e13813 --- /dev/null +++ b/coderd/authz/authz.go @@ -0,0 +1,12 @@ +package authz + +// TODO: Implement Authorize +func Authorize(subj Subject, obj Resource, action Action) error { + // TODO: Expand subject roles into their permissions as appropriate. Apply scopes. + return AuthorizePermissions(subj.ID(), []Permission{}, obj, action) +} + +// AuthorizePermissions runs the authorize function with the raw permissions in a single list. +func AuthorizePermissions(_ string, _ []Permission, _ Resource, _ Action) error { + return nil +} diff --git a/coderd/authz/authz_test.go b/coderd/authz/authz_test.go new file mode 100644 index 0000000000000..3b6586bb2697f --- /dev/null +++ b/coderd/authz/authz_test.go @@ -0,0 +1,258 @@ +package authz_test + +import ( + "math/bits" + "strings" + "testing" + + "github.com/coder/coder/coderd/authz" + "github.com/coder/coder/coderd/authz/authztest" +) + +var nilSet = authztest.Set{nil} + +func TestExhaustiveAuthorize(t *testing.T) { + t.Parallel() + + all := authztest.GroupedPermissions(authztest.AllPermissions()) + roleVariants := permissionVariants(all) + res := authz.ResourceType(authztest.PermObjectType).AsID(authztest.PermObjectID) + + testCases := []struct { + Name string + Objs []authz.Resource + // Action is constant + // Subject comes from roleVariants + Result func(pv string) bool + }{ + { + Name: "User:Org", + Objs: []authz.Resource{ + res.Owner(authztest.PermMe).Org(authztest.PermOrgID), + }, + Result: func(pv string) bool { + return strings.Contains(pv, "+") + }, + }, + { + // All U+/- tests should fail + Name: "NotUser:Org", + Objs: []authz.Resource{ + res.Owner("other").Org(authztest.PermOrgID), + res.Owner("").Org(authztest.PermOrgID), + }, + Result: func(pv string) bool { + if strings.Contains(pv, "U") { + return false + } + return strings.Contains(pv, "+") + }, + }, + { + // All O+/- and U+/- tests should fail + Name: "NotUser:NotOrg", + Objs: []authz.Resource{ + res.Owner(authztest.PermMe).Org("non-mem"), + res.Owner("other").Org("non-mem"), + res.Owner("other").Org(""), + res.Owner("").Org("non-mem"), + res.Owner("").Org(""), + }, + + Result: func(pv string) bool { + if strings.Contains(pv, "U") { + return false + } + if strings.Contains(pv, "O") { + return false + } + return strings.Contains(pv, "+") + }, + }, + // TODO: @emyrk for this one, we should probably pass a custom roles variant + //{ + // // O+, O- no longer pass judgement. Defer to user level judgement (only somewhat tricky case) + // Name: "User:NotOrg", + // Objs: authztest.Objects( + // []string{authztest.PermMe, ""}, + // ), + // Result: func(pv string) bool { + // return strings.Contains(pv, "+") + // }, + // }, + } + + failedTests := make(map[string]int) + //nolint:paralleltest + for _, c := range testCases { + t.Run(c.Name, func(t *testing.T) { + for _, o := range c.Objs { + for name, v := range roleVariants { + v.Each(func(set authztest.Set) { + // TODO: Authz.Permissions does allocations at the moment. We should fix that. + err := authz.AuthorizePermissions( + authztest.PermMe, + set.Permissions(), + o, + authztest.PermAction) + if c.Result(name) && err != nil { + failedTests[name]++ + } else if !c.Result(name) && err == nil { + failedTests[name]++ + } + }) + v.Reset() + } + } + }) + } + // TODO: @emyrk when we implement the correct authorize, we can enable this check. + // for testName, numFailed := range failedTests { + // require.Zero(t, failedTests[testName], fmt.Sprintf("%s: %d tests failed", testName, numFailed)) + // } +} + +func permissionVariants(all authztest.SetGroup) map[string]*authztest.Role { + // an is any noise above the impactful set + an := noiseAbstain + // ln is any noise below the impactful set + ln := noisePositive | noiseNegative | noiseAbstain + + // Cases are X+/- where X indicates the level where the impactful set is. + // The impactful set determines the result. + return map[string]*authztest.Role{ + // Wild + "W+": authztest.NewRole( + pos(all.Wildcard()), + noise(ln, all.Site(), all.Org(), all.User()), + ), + "W-": authztest.NewRole( + neg(all.Wildcard()), + noise(ln, all.Site(), all.Org(), all.User()), + ), + // Site + "S+": authztest.NewRole( + noise(an, all.Wildcard()), + pos(all.Site()), + noise(ln, all.Org(), all.User()), + ), + "S-": authztest.NewRole( + noise(an, all.Wildcard()), + neg(all.Site()), + noise(ln, all.Org(), all.User()), + ), + // Org:* -- Added org:mem noise + "O+": authztest.NewRole( + noise(an, all.Wildcard(), all.Site(), all.OrgMem()), + pos(all.Org()), + noise(ln, all.User()), + ), + "O-": authztest.NewRole( + noise(an, all.Wildcard(), all.Site(), all.OrgMem()), + neg(all.Org()), + noise(ln, all.User()), + ), + // Org:Mem -- Added org:* noise + "M+": authztest.NewRole( + noise(an, all.Wildcard(), all.Site(), all.Org()), + pos(all.OrgMem()), + noise(ln, all.User()), + ), + "M-": authztest.NewRole( + noise(an, all.Wildcard(), all.Site(), all.Org()), + neg(all.OrgMem()), + noise(ln, all.User()), + ), + // User + "U+": authztest.NewRole( + noise(an, all.Wildcard(), all.Site(), all.Org()), + pos(all.User()), + ), + "U-": authztest.NewRole( + noise(an, all.Wildcard(), all.Site(), all.Org()), + neg(all.User()), + ), + // Abstain + "A+": authztest.NewRole( + authztest.Union( + all.Wildcard().Abstain(), + all.Site().Abstain(), + all.Org().Abstain(), + all.OrgMem().Abstain(), + all.User().Abstain(), + ), + all.User().Positive()[:1], + ), + "A-": authztest.NewRole( + authztest.Union( + all.Wildcard().Abstain(), + all.Site().Abstain(), + all.Org().Abstain(), + all.OrgMem().Abstain(), + all.User().Abstain(), + ), + ), + } +} + +// pos returns the positive impactful variant for a given level. It does not +// include noise at any other level but the one given. +func pos(lvl authztest.LevelGroup) *authztest.Role { + return authztest.NewRole( + lvl.Positive(), + authztest.Union(lvl.Abstain()[:1], nilSet), + ) +} + +func neg(lvl authztest.LevelGroup) *authztest.Role { + return authztest.NewRole( + lvl.Negative(), + authztest.Union(lvl.Positive()[:1], nilSet), + authztest.Union(lvl.Abstain()[:1], nilSet), + ) +} + +type noiseBits uint8 + +const ( + _ noiseBits = 1 << iota + noisePositive + noiseNegative + noiseAbstain +) + +func flagMatch(flag, in noiseBits) bool { + return flag&in != 0 +} + +// noise returns the noise permission permutations for a given level. You can +// use this helper function when this level is not impactful. +// The returned role is the permutations including at least one example of +// positive, negative, and neutral permissions. It also includes the set of +// no additional permissions. +func noise(f noiseBits, lvls ...authztest.LevelGroup) *authztest.Role { + rs := make([]authztest.Iterable, 0, len(lvls)) + for _, lvl := range lvls { + sets := make([]authztest.Iterable, 0, bits.OnesCount8(uint8(f))) + + if flagMatch(noisePositive, f) { + sets = append(sets, authztest.Union(lvl.Positive()[:1], nilSet)) + } + if flagMatch(noiseNegative, f) { + sets = append(sets, authztest.Union(lvl.Negative()[:1], nilSet)) + } + if flagMatch(noiseAbstain, f) { + sets = append(sets, authztest.Union(lvl.Abstain()[:1], nilSet)) + } + + rs = append(rs, authztest.NewRole( + sets..., + )) + } + + if len(rs) == 1 { + role, _ := rs[0].(*authztest.Role) + return role + } + return authztest.NewRole(rs...) +} diff --git a/coderd/authz/authztest/README.md b/coderd/authz/authztest/README.md new file mode 100644 index 0000000000000..f20b2ddd54481 --- /dev/null +++ b/coderd/authz/authztest/README.md @@ -0,0 +1,180 @@ +# Authztest +Package `authztest` implements _exhaustive_ unit testing for the `authz` package. + +## Why this exists +The `authz.Authorize` function has three* inputs: +- Subject (for example, a user or API key) +- Object (for example, a workspace or a DevURL) +- Action (for example, read or write). + +**Not including the ruleset, which we're keeping static for the moment.* + +Normally to test a pure function like this, you'd write a table test with all of the permutations by hand, for example: + +```go +func Test_Authorize(t *testing.T) { + .... + testCases := []struct { + name string + subject authz.Subject + resource authz.Object + action authz.Action + expectedError error + }{ + { + name: "site admin can write config", + subject: &User{ID: "admin"}, + object: &authz.ZObject{ + OrgOwner: "default", + ObjectType: authz.ObjectSiteConfig, + }, + expectedError: nil, + }, + ... + } + for _, testCase := range testCases { + t.Run(testCase.Name, func(t *testing.T) { ... }) + } +} +``` + +This approach is problematic because of the cardinality of the RBAC model. + +Recall that the legacy `pkg/access/authorize`: + +- Exposes 8 possible actions, 5 possible site-level roles, 4 possible org-level roles, and 24 possible resource types +- Enforces site-wide versus organization-wide permissions separately + +The new authentication model must maintain backward compatibility with this model, whilst allowing additional features such as: + +- User-level ownership (which means user-level permission enforcement) +- Objects shared between users (which means permissions granular down to resource IDs) +- Custom roles + +The resulting permissions model ([documented in Notion](https://www.notion.so/coderhq/Workspaces-V2-Authz-RBAC-24fd193386eb4cf79a282a2a69e8f917)) results in a large **finite** solution space in the order of **hundreds of millions**. + +We want to have a high level of confidence that changes to the implementation **do not have unintended side-effects**. This means that simply manually writing a set of test cases possibly risks errors slipping through the cracks. + +Instead, we generate (almost) all possible sets of inputs to the library, and ensure that `authz.Authorize` performs as expected. + +The actual investigation of the solution space is [documented in Notion](https://www.notion.so/coderhq/Authz-Exhaustive-Testing-7683ea694c6e4c12ab0124439916b13a), but the crucial take-away of that document is: +- There is a **large** but **finite** number of possible inputs to `authz.Authorize`, +- The solution space can be broken down into 9 groups, and +- Most importantly, *each group has the same expected result.* + + +## Testing Methodology + +We group the search space into a number of groups. Each group corresponds to a set of test cases with the same expected result. Each group consists of a set of **impactful** permissions and a set of **noise** permissions. + +**Impactful** permissions are the top-level permissions that are expected to override anything else, and should be the only inputs that determine the expected result. + +**Noise** is simply a set of additional permissions at a lower level that *should not* be impactful. + +For each group, we take the **impactful set** of permissions, and add **noise**, and combine this into a role. + +We then take the *set cross-product* of the **impactful set** and the **noise**, and assert that the expected access level of that role to perform a given action. + +As some of these sets are quite large, we sample some of the noise to reduce the search space. + +We also perform permutation on the **objects** of the test case, explained in [Object Permutations](#object-permutations) + +**Example:** + +`+site:*:*:create` will always override `-user:resource:*:*`, `-user:*:abc123:*`, `-org:resource:*:create`, and so on. All permutations of those sorts of noise permissions should never change the expected result. + + +## Role Permutations + +Recall that we define a permission as a 4-tuple of `(level, resource_type, resource_id, action)` (for example, `(site, workspace, 123, read)`). + +A `Set` is a slice of permissions. The search space of all possible permissions is too large, so instead this package allows generating more meaningful sets for testing. This is equivalent to pruning in AI problems: a technique to reduce the size of the search space by removing parts that do not have significance. + +This is the final pruned search space used in authz. Each set is represented by a Y, N, or \_. The leftmost set in a row that is not '\_' is the impactful set. The impactful set determines the access result. All other sets are non-impactful, and should include the `` permission. + +The resulting search space for a row is the cross product between all sets in said row. `+` indicates the union of two sets. For example, Y+_ indicates the union of all positive permissions and abstain permissions. + +| Row | * | Site | Org | Org:mem | User | Access | +|-----|------|------|------|---------|------|--------| +| W+ | Y+_ | YN_ | YN_ | YN_ | YN_ | Y | +| W- | N+Y_ | YN_ | YN_ | YN_ | YN_ | N | +| S+ | _ | Y+_ | YN_ | NY_ | NY_ | Y | +| S- | _ | N+Y_ | YN_ | NY_ | NY_ | N | +| O+ | _ | _ | Y+_ | NY_ | NY_ | Y | +| O- | _ | _ | N+Y_ | NY_ | NY_ | N | +| M+ | _ | _ | _ | Y+_ | NY_ | Y | +| M- | _ | _ | _ | N+Y_ | NY_ | N | +| U+ | _ | _ | _ | _ | Y+_ | Y | +| U- | _ | _ | _ | _ | N+Y_ | N | +| A+ | _ | _ | _ | _ | Y+_ | Y | +| A- | _ | _ | _ | _ | _ | N | + +Each row in the above table corresponds to a set of role permutations. + +There are 12 possible groups of role permutations: + +- Case 1 (W+): + - Impactful set: positive wildcard permissions. + - Noise: positive, negative, abstain across site, org, org-member, and user levels. + - Expected result: allow. +- Case 2 (W-): + - Impactful set: negative wildcard permissions. + - Noise: positive, negative, abstain across site, org, org-member, and user levels. + - Expected result: deny. +- Case 3 (S+): + - Impactful set: positive site-level permissions. + - Noise: positive, negative, abstain across org, org-member, and user levels. + - Expected result: allow. +- Case 4 (S-): + - Impactful set: negative site-level permissions. + - Noise: positive, negative, abstain across org, org-member, and user levels. + - Expected result: deny. +- Case 5 (O+): + - Impactful set: positive org-level permissions. + - Noise: positive, negative, abstain across org-member and user levels. + - Expected result: allow. +- Case 6 (O-): + - Impactful set: negative org-level permissions. + - Noise: positive, negative, abstain across org-member and user levels. + - Expected result: deny. +- Case 7 (M+): + - Impactful set: positive org-member permissions. + - Noise: positive, negative, abstain on user level. + - Expected result: allow. +- Case 8 (M-): + - Impactful set: negative org-member permissions. + - Noise: positive, negative, abstain on user level. + - Expected result: deny. +- Case 9 (U+): + - Impactful set: positive user-level permissions. + - Noise: empty set. + - Expected result: allow. +- Case 10 (U-): + - Impactful set: negative user-level permissions. + - Noise: empty set. + - Expected result: deny. +- Case 11 (A+): + - Impactful set: nil permission. + - Noise: positive on user-level. + - Expected result: allow. +- Case 12 (A-): + - Impactful set: nil permission. + - Noise: abstain on user level. + - Expected result: deny. + + +## Object Permutations + +Aside from the test inputs, we also perform permutations on the object. There are 9 possible permuations based on the object, and these 9 test cases all have four distinct possibilities. These are illustrated by the below table: + +| # | Owner | Org-Owner | Result | +|---|---------|-----------|----------------------------------------| +| 1 | `me` | `mem` | Defer | +| 2 | `other` | `mem` | `U+` and `U-` return `false`. | +| 3 | `""` | `mem` | As above. | +| 4 | `me` | `non-mem` | `O+`, `O-`, `U+`, `U-` return `false`. | +| 5 | `other` | `non-mem` | As above. | +| 6 | `other` | `""` | As above. | +| 7 | `""` | `non-mem` | As above. | +| 8 | `""` | `""` | As above. | +| 9 | ` me` | `""` | `O+` and `O-` abstain. Defer to user. | diff --git a/coderd/authz/authztest/doc.go b/coderd/authz/authztest/doc.go new file mode 100644 index 0000000000000..3acc3fcdc1a8c --- /dev/null +++ b/coderd/authz/authztest/doc.go @@ -0,0 +1,2 @@ +// Package authztest is a helper package for generating permissions to test the authz library. +package authztest diff --git a/coderd/authz/authztest/group.go b/coderd/authz/authztest/group.go new file mode 100644 index 0000000000000..585eca6bc0da8 --- /dev/null +++ b/coderd/authz/authztest/group.go @@ -0,0 +1,10 @@ +package authztest + +// PermissionSet defines a set of permissions with the same impact. +type PermissionSet string + +const ( + SetPositive PermissionSet = "j" + SetNegative PermissionSet = "j!" + SetNeutral PermissionSet = "a" +) diff --git a/coderd/authz/authztest/iterator.go b/coderd/authz/authztest/iterator.go new file mode 100644 index 0000000000000..dfee580332f06 --- /dev/null +++ b/coderd/authz/authztest/iterator.go @@ -0,0 +1,82 @@ +package authztest + +import ( + "github.com/coder/coder/coderd/authz" +) + +type Iterable interface { + Iterator() Iterator +} + +type Iterator interface { + Iterable + + Next() bool + Permissions() Set + Reset() + ReturnSize() int + Size() int +} + +// unionIterator is used to merge sets, or a union in set theory. +type unionIterator struct { + // setIdx determines which set the offset is for + setIdx int + // offset is which permission for a given setIdx + offset int + sets []Set + // buffer is used to prevent allocations when `Permissions` is called, as we must + // return a set. + buffer Set + + N int +} + +//nolint:revive +func Union(sets ...Set) *unionIterator { + var n int + for _, s := range sets { + n += len(s) + } + return &unionIterator{ + sets: sets, + buffer: make(Set, 1), + N: n, + } +} + +func (si *unionIterator) Next() bool { + si.offset++ + if si.offset >= len(si.sets[si.setIdx]) { + si.setIdx++ + si.offset = 0 + } + + return si.setIdx < len(si.sets) +} + +func (si *unionIterator) Permissions() Set { + si.buffer[0] = si.Permission() + return si.buffer +} + +func (si unionIterator) Permission() *authz.Permission { + return si.sets[si.setIdx][si.offset] +} + +func (si *unionIterator) Reset() { + si.setIdx = 0 + si.offset = 0 +} + +func (unionIterator) ReturnSize() int { + return 1 +} + +func (si *unionIterator) Size() int { + return si.N +} + +func (si *unionIterator) Iterator() Iterator { + return si +} diff --git a/coderd/authz/authztest/iterator_test.go b/coderd/authz/authztest/iterator_test.go new file mode 100644 index 0000000000000..b0a9e416a142a --- /dev/null +++ b/coderd/authz/authztest/iterator_test.go @@ -0,0 +1,76 @@ +package authztest_test + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/authz" + "github.com/coder/coder/coderd/authz/authztest" + crand "github.com/coder/coder/cryptorand" +) + +func TestUnion(t *testing.T) { + t.Parallel() + + for i := 0; i < 100; i++ { + allPerms := make(authztest.Set, 0) + // 2 - 4 sets + sets := make([]authztest.Set, 1+must(crand.Intn(2))) + for j := range sets { + sets[j] = RandomSet(1 + must(crand.Intn(4))) + allPerms = append(allPerms, sets[j]...) + } + + ui := authztest.Union(sets...).Iterator() + require.Equal(t, len(allPerms), ui.Size(), "union set total") + require.Equal(t, 1, ui.ReturnSize(), "union ret size is 1") + for c := 0; ; c++ { + require.Equal(t, 1, len(ui.Permissions()), "permissions size") + require.Equal(t, allPerms[c], ui.Permissions()[0], "permission order") + if !ui.Next() { + break + } + } + + ui.Reset() + // If the size is 1, next will always return false + if ui.Size() > 1 { + require.True(t, ui.Next(), "reset should make next true again") + } + } +} + +func RandomSet(size int) authztest.Set { + set := make(authztest.Set, 0, size) + for i := 0; i < size; i++ { + p := RandomPermission() + set = append(set, &p) + } + return set +} + +func RandomPermission() authz.Permission { + actions := []authz.Action{ + authz.ActionRead, + authz.ActionCreate, + authz.ActionUpdate, + authz.ActionDelete, + } + return authz.Permission{ + Negate: must(crand.Intn(2))%2 == 0, + Level: authz.PermissionLevels[must(crand.Intn(len(authz.PermissionLevels)))], + OrganizationID: uuid.New().String(), + ResourceType: authz.ResourceWorkspace, + ResourceID: uuid.New().String(), + Action: actions[must(crand.Intn(len(actions)))], + } +} + +func must[r any](v r, err error) r { + if err != nil { + panic(err) + } + return v +} diff --git a/coderd/authz/authztest/level.go b/coderd/authz/authztest/level.go new file mode 100644 index 0000000000000..034b8e6204f03 --- /dev/null +++ b/coderd/authz/authztest/level.go @@ -0,0 +1,98 @@ +package authztest + +import "github.com/coder/coder/coderd/authz" + +type level string + +const ( + LevelWildKey level = "level-wild" + LevelSiteKey level = "level-site" + LevelOrgKey level = "level-org" + LevelOrgMemKey level = "level-org:mem" + // LevelOrgAllKey is a helper to get both org levels above + LevelOrgAllKey level = "level-org:*" + LevelUserKey level = "level-user" +) + +// LevelGroup is all permissions for a given level +type LevelGroup map[PermissionSet]Set + +func (lg LevelGroup) All() Set { + pos := lg.Positive() + neg := lg.Negative() + net := lg.Abstain() + all := make(Set, len(pos)+len(neg)+len(net)) + var i int + i += copy(all[i:], pos) + i += copy(all[i:], neg) + copy(all[i:], net) + return all +} + +func (lg LevelGroup) Positive() Set { + return lg[SetPositive] +} + +func (lg LevelGroup) Negative() Set { + return lg[SetNegative] +} + +func (lg LevelGroup) Abstain() Set { + return lg[SetNeutral] +} + +func GroupedPermissions(perms Set) SetGroup { + groups := make(SetGroup) + allLevelKeys := []level{LevelWildKey, LevelSiteKey, LevelOrgKey, LevelOrgMemKey, LevelOrgAllKey, LevelUserKey} + + for _, l := range allLevelKeys { + groups[l] = make(LevelGroup) + } + + for _, p := range perms { + m := Impact(p) + switch { + case p.Level == authz.LevelSite: + groups[LevelSiteKey][m] = append(groups[LevelSiteKey][m], p) + case p.Level == authz.LevelOrg: + groups[LevelOrgAllKey][m] = append(groups[LevelOrgAllKey][m], p) + if p.OrganizationID == "" || p.OrganizationID == "*" { + groups[LevelOrgKey][m] = append(groups[LevelOrgKey][m], p) + } else { + groups[LevelOrgMemKey][m] = append(groups[LevelOrgMemKey][m], p) + } + case p.Level == authz.LevelUser: + groups[LevelUserKey][m] = append(groups[LevelUserKey][m], p) + case p.Level == authz.LevelWildcard: + groups[LevelWildKey][m] = append(groups[LevelWildKey][m], p) + } + } + + return groups +} + +type SetGroup map[level]LevelGroup + +func (s SetGroup) Wildcard() LevelGroup { + return s[LevelWildKey] +} + +func (s SetGroup) Site() LevelGroup { + return s[LevelSiteKey] +} + +func (s SetGroup) Org() LevelGroup { + return s[LevelOrgKey] +} + +func (s SetGroup) AllOrgs() LevelGroup { + return s[LevelOrgAllKey] +} + +func (s SetGroup) OrgMem() LevelGroup { + return s[LevelOrgMemKey] +} + +func (s SetGroup) User() LevelGroup { + return s[LevelUserKey] +} diff --git a/coderd/authz/authztest/level_test.go b/coderd/authz/authztest/level_test.go new file mode 100644 index 0000000000000..3cd4616556e8e --- /dev/null +++ b/coderd/authz/authztest/level_test.go @@ -0,0 +1,94 @@ +package authztest_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/authz" + "github.com/coder/coder/coderd/authz/authztest" +) + +func TestGroupedPermissions(t *testing.T) { + t.Parallel() + + set := make(authztest.Set, 0) + var total int + for _, lvl := range authz.PermissionLevels { + for _, s := range []bool{true, false} { + for _, a := range []authz.Action{authz.ActionRead, authztest.OtherOption} { + if lvl == authz.LevelOrg { + set = append(set, &authz.Permission{ + Negate: s, + Level: lvl, + OrganizationID: "mem", + ResourceType: authz.ResourceWorkspace, + Action: a, + }) + total++ + } + set = append(set, &authz.Permission{ + Negate: s, + Level: lvl, + ResourceType: authz.ResourceWorkspace, + Action: a, + }) + total++ + } + } + } + + require.Equal(t, total, len(set), "total set size") + grp := authztest.GroupedPermissions(set) + grp.Org() + + cases := []struct { + Name string + Lvl authztest.LevelGroup + ExpPos int + ExpNeg int + ExpAbs int + }{ + { + Name: "Wild", + Lvl: grp.Wildcard(), + ExpPos: 1, ExpNeg: 1, ExpAbs: 2, + }, + { + Name: "Site", + Lvl: grp.Site(), + ExpPos: 1, ExpNeg: 1, ExpAbs: 2, + }, + { + Name: "Org", + Lvl: grp.Org(), + ExpPos: 1, ExpNeg: 1, ExpAbs: 2, + }, + { + Name: "Org:mem", + Lvl: grp.OrgMem(), + ExpPos: 1, ExpNeg: 1, ExpAbs: 2, + }, + { + Name: "Org:*", + Lvl: grp.AllOrgs(), + ExpPos: 2, ExpNeg: 2, ExpAbs: 4, + }, + { + Name: "User", + Lvl: grp.User(), + ExpPos: 1, ExpNeg: 1, ExpAbs: 2, + }, + } + + for _, c := range cases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + require.Equal(t, c.ExpPos+c.ExpNeg+c.ExpAbs, len(c.Lvl.All()), "set size") + require.Equal(t, c.ExpPos, len(c.Lvl.Positive()), "correct num pos") + require.Equal(t, c.ExpNeg, len(c.Lvl.Negative()), "correct num neg") + require.Equal(t, c.ExpAbs, len(c.Lvl.Abstain()), "correct num abs") + }) + } +} diff --git a/coderd/authz/authztest/permissions.go b/coderd/authz/authztest/permissions.go new file mode 100644 index 0000000000000..02f9671d39688 --- /dev/null +++ b/coderd/authz/authztest/permissions.go @@ -0,0 +1,69 @@ +package authztest + +import ( + "github.com/coder/coder/coderd/authz" +) + +const ( + OtherOption = "other" + PermObjectType = "resource" + PermAction = "read" + PermOrgID = "mem" + PermObjectID = "rid" + PermMe = "me" +) + +var ( + levelIDs = []string{"", PermOrgID} + resourceTypes = []authz.ResourceType{PermObjectType, "*", OtherOption} + resourceIDs = []string{PermObjectID, "*", OtherOption} + actions = []authz.Action{PermAction, "*", OtherOption} +) + +// AllPermissions returns all the possible permissions ever. +func AllPermissions() Set { + permissionTypes := []bool{true, false} + all := make(Set, 0, len(permissionTypes)*len(authz.PermissionLevels)*len(levelIDs)*len(resourceTypes)*len(resourceIDs)*len(actions)) + for _, s := range permissionTypes { + for _, l := range authz.PermissionLevels { + for _, t := range resourceTypes { + for _, i := range resourceIDs { + for _, a := range actions { + if l == authz.LevelOrg { + all = append(all, &authz.Permission{ + Negate: s, + Level: l, + OrganizationID: PermOrgID, + ResourceType: t, + ResourceID: i, + Action: a, + }) + } + all = append(all, &authz.Permission{ + Negate: s, + Level: l, + OrganizationID: "", + ResourceType: t, + ResourceID: i, + Action: a, + }) + } + } + } + } + } + return all +} + +// Impact returns the impact (positive, negative, abstain) of p +func Impact(p *authz.Permission) PermissionSet { + if p.ResourceType == OtherOption || + p.ResourceID == OtherOption || + p.Action == OtherOption { + return SetNeutral + } + if p.Negate { + return SetNegative + } + return SetPositive +} diff --git a/coderd/authz/authztest/permissions_test.go b/coderd/authz/authztest/permissions_test.go new file mode 100644 index 0000000000000..22fb3fefe7148 --- /dev/null +++ b/coderd/authz/authztest/permissions_test.go @@ -0,0 +1,18 @@ +package authztest_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/authz/authztest" +) + +func TestAllPermissions(t *testing.T) { + t.Parallel() + + // If this changes, then we might have to fix some other tests. This constant + // is the basis for understanding the permutation counts. + const totalUniquePermissions int = 270 + require.Equal(t, len(authztest.AllPermissions()), totalUniquePermissions, "expected set size") +} diff --git a/coderd/authz/authztest/role.go b/coderd/authz/authztest/role.go new file mode 100644 index 0000000000000..f97f50bfe42bf --- /dev/null +++ b/coderd/authz/authztest/role.go @@ -0,0 +1,88 @@ +package authztest + +import ( + "github.com/coder/coder/coderd/authz" +) + +// Role can print all possible permutations of the given iterators. It represents +// the cross product between all sets given. +type Role struct { + // returnSize is how many permissions are the returned set for the role + returnSize int + // N is the total number of permutations of sets this role will produce. + N int + PermissionSets []Iterator + // This is kinda weird, but the first scan should not move anything. + first bool + + buffer []*authz.Permission +} + +func NewRole(sets ...Iterable) *Role { + setInterfaces := make([]Iterator, 0, len(sets)) + var retSize int + size := 1 + for _, s := range sets { + v := s.Iterator() + setInterfaces = append(setInterfaces, v) + retSize += v.ReturnSize() + // size is the cross product of all Iterator sets + size *= v.Size() + } + return &Role{ + returnSize: retSize, + N: size, + PermissionSets: setInterfaces, + buffer: make([]*authz.Permission, retSize), + } +} + +func (r *Role) Iterator() Iterator { + return r +} + +func (r *Role) ReturnSize() int { + return r.returnSize +} + +func (r *Role) Size() int { + return r.N +} + +// Permissions returns the set of permissions for the role for a given permutation generated by 'Next()' +func (r *Role) Permissions() Set { + var i int + for _, ps := range r.PermissionSets { + i += copy(r.buffer[i:], ps.Permissions()) + } + return r.buffer +} + +func (r *Role) Each(ea func(set Set)) { + ea(r.Permissions()) + for r.Next() { + ea(r.Permissions()) + } +} + +// Next will grab the next cross-product permutation of all permissions of r. +// When Next() returns false, the role would be Reset() +func (r *Role) Next() bool { + for i := range r.PermissionSets { + if r.PermissionSets[i].Next() { + break + } + + r.PermissionSets[i].Reset() + if i == len(r.PermissionSets)-1 { + return false + } + } + return true +} + +func (r *Role) Reset() { + for _, ps := range r.PermissionSets { + ps.Reset() + } +} diff --git a/coderd/authz/authztest/role_test.go b/coderd/authz/authztest/role_test.go new file mode 100644 index 0000000000000..b630d7aaffba6 --- /dev/null +++ b/coderd/authz/authztest/role_test.go @@ -0,0 +1,60 @@ +package authztest_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/authz/authztest" + crand "github.com/coder/coder/cryptorand" +) + +func TestNewRole(t *testing.T) { + t.Parallel() + + for i := 0; i < 50; i++ { + sets := make([]authztest.Iterable, 1+(i%4)) + total := 1 + for j := range sets { + size := 1 + must(crand.Intn(3)) + if i < 5 { + // Enforce 1 size sets for some cases + size = 1 + } + sets[j] = RandomSet(size) + total *= size + } + + crossProduct := authztest.NewRole(sets...) + t.Run("CrossProduct", func(t *testing.T) { + require.Equal(t, total, crossProduct.Size(), "correct N") + require.Equal(t, len(sets), crossProduct.ReturnSize(), "return size") + var c int + crossProduct.Each(func(set authztest.Set) { + require.Equal(t, crossProduct.ReturnSize(), len(set), "each set is correct size") + c++ + }) + require.Equal(t, total, c, "each run N times") + + if crossProduct.Size() > 1 { + crossProduct.Reset() + require.Truef(t, crossProduct.Next(), "reset should always make this true") + } + }) + + t.Run("NestedRoles", func(t *testing.T) { + merged := authztest.NewRole(sets[0]) + for i := 1; i < len(sets); i++ { + merged = authztest.NewRole(sets[i], merged) + } + + require.Equal(t, crossProduct.Size(), merged.Size()) + var c int + merged.Each(func(set authztest.Set) { + require.Equal(t, merged.ReturnSize(), len(set), "each set is correct size") + c++ + }) + require.Equal(t, merged.Size(), c, "each run N times") + }) + } +} diff --git a/coderd/authz/authztest/set.go b/coderd/authz/authztest/set.go new file mode 100644 index 0000000000000..4b669effa5a82 --- /dev/null +++ b/coderd/authz/authztest/set.go @@ -0,0 +1,41 @@ +package authztest + +import ( + "strings" + + "github.com/coder/coder/coderd/authz" +) + +type Set []*authz.Permission + +var _ Iterable = (Set)(nil) + +// Permissions is a helper function to get the Permissions as non-pointers. +// permissions are omitted +func (s Set) Permissions() []authz.Permission { + perms := make([]authz.Permission, 0, len(s)) + for i := range s { + if s[i] != nil { + perms = append(perms, *s[i]) + } + } + return perms +} + +func (s Set) Iterator() Iterator { + return Union(s) +} + +func (s Set) String() string { + var str strings.Builder + sep := "" + for _, v := range s { + if v == nil { + continue + } + _, _ = str.WriteString(sep) + _, _ = str.WriteString(v.String()) + sep = ", " + } + return str.String() +} diff --git a/coderd/authz/authztest/set_test.go b/coderd/authz/authztest/set_test.go new file mode 100644 index 0000000000000..88e8dd8276822 --- /dev/null +++ b/coderd/authz/authztest/set_test.go @@ -0,0 +1,74 @@ +package authztest_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/authz" + "github.com/coder/coder/coderd/authz/authztest" + crand "github.com/coder/coder/cryptorand" +) + +func TestSet(t *testing.T) { + t.Parallel() + + t.Run("Simple", func(t *testing.T) { + t.Parallel() + + for i := 0; i < 10; i++ { + set := RandomSet(i) + require.Equal(t, i, len(set), "set size") + require.Equal(t, i, len(set.Permissions()), "set size") + perms := set.Permissions() + for i, p := range set { + require.Equal(t, *p, perms[i]) + } + } + }) + + t.Run("NilPerms", func(t *testing.T) { + t.Parallel() + + for i := 0; i < 100; i++ { + set := RandomSet(i) + // Set some nils + nilCount := 0 + for i := 0; i < len(set); i++ { + if must(crand.Bool()) { + set[i] = nil + nilCount++ + } + } + require.Equal(t, i-nilCount, len(set.Permissions())) + } + }) + + t.Run("String", func(t *testing.T) { + t.Parallel() + + set := authztest.Set{ + &authz.Permission{ + Negate: false, + Level: authz.LevelOrg, + OrganizationID: "1234", + ResourceType: authz.ResourceWorkspace, + ResourceID: "1234", + Action: authz.ActionRead, + }, + nil, + &authz.Permission{ + Negate: true, + Level: authz.LevelSite, + OrganizationID: "", + ResourceType: authz.ResourceWorkspace, + ResourceID: "*", + Action: authz.ActionRead, + }, + } + + require.Equal(t, + "+org:1234.workspace.1234.read, -site.workspace.*.read", + set.String(), "exp string") + }) +} diff --git a/coderd/authz/example_test.go b/coderd/authz/example_test.go new file mode 100644 index 0000000000000..989673f27f239 --- /dev/null +++ b/coderd/authz/example_test.go @@ -0,0 +1,72 @@ +package authz_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/authz" +) + +// TestExample gives some examples on how to use the authz library. +// This serves to test syntax more than functionality. +func TestExample(t *testing.T) { + t.Parallel() + + // user will become an authn object, and can even be a database.User if it + // fulfills the interface. Until then, use a placeholder. + user := authz.SubjectTODO{ + UserID: "alice", + // No site perms + Site: []authz.Role{}, + Org: map[string][]authz.Role{ + // Admin of org "default". + "default": {{Permissions: must(authz.ParsePermissions("+org.*.*.*"))}}, + }, + User: []authz.Role{ + // Site user role + {Permissions: must(authz.ParsePermissions("+user.*.*.*"))}, + }, + } + + // TODO: Uncomment all assertions when implementation is done. + + //nolint:paralleltest + t.Run("ReadAllWorkspaces", func(t *testing.T) { + // To read all workspaces on the site + err := authz.Authorize(user, authz.ResourceWorkspace, authz.ActionRead) + var _ = err + // require.Error(t, err, "this user cannot read all workspaces") + }) + + //nolint:paralleltest + t.Run("ReadOrgWorkspaces", func(t *testing.T) { + // To read all workspaces on the org 'default' + err := authz.Authorize(user, authz.ResourceWorkspace.Org("default"), authz.ActionRead) + require.NoError(t, err, "this user can read all org workspaces in 'default'") + }) + + //nolint:paralleltest + t.Run("ReadMyWorkspace", func(t *testing.T) { + // Note 'database.Workspace' could fulfill the object interface and be passed in directly + err := authz.Authorize(user, authz.ResourceWorkspace.Org("default").Owner(user.UserID), authz.ActionRead) + require.NoError(t, err, "this user can their workspace") + + err = authz.Authorize(user, authz.ResourceWorkspace.Org("default").Owner(user.UserID).AsID("1234"), authz.ActionRead) + require.NoError(t, err, "this user can read workspace '1234'") + }) + + //nolint:paralleltest + t.Run("CreateNewSiteUser", func(t *testing.T) { + err := authz.Authorize(user, authz.ResourceUser, authz.ActionCreate) + var _ = err + // require.Error(t, err, "this user cannot create new users") + }) +} + +func must[r any](v r, err error) r { + if err != nil { + panic(err) + } + return v +} diff --git a/coderd/authz/object.go b/coderd/authz/object.go new file mode 100644 index 0000000000000..2c26e9e768bb3 --- /dev/null +++ b/coderd/authz/object.go @@ -0,0 +1,71 @@ +package authz + +type Resource interface { + ID() string + ResourceType() ResourceType +} + +type UserResource interface { + Resource + OwnerID() string +} + +type OrgResource interface { + Resource + OrgOwnerID() string +} + +var _ Resource = (*zObject)(nil) +var _ UserResource = (*zObject)(nil) +var _ OrgResource = (*zObject)(nil) + +// zObject is used to create objects for authz checks when you have none in +// hand to run the check on. +// An example is if you want to list all workspaces, you can create a zObject +// that represents the set of workspaces you are trying to get access too. +// Do not export this type, as it can be created from a resource type constant. +type zObject struct { + id string + owner string + orgOwner string + + // objectType is "workspace", "project", "devurl", etc + objectType ResourceType + // TODO: SharedUsers? +} + +func (z zObject) ID() string { + return z.id +} + +func (z zObject) ResourceType() ResourceType { + return z.objectType +} + +func (z zObject) OwnerID() string { + return z.owner +} + +func (z zObject) OrgOwnerID() string { + return z.orgOwner +} + +// Org adds an org OwnerID to the resource +//nolint:revive +func (z zObject) Org(orgID string) zObject { + z.orgOwner = orgID + return z +} + +// Owner adds an OwnerID to the resource +//nolint:revive +func (z zObject) Owner(id string) zObject { + z.owner = id + return z +} + +//nolint:revive +func (z zObject) AsID(id string) zObject { + z.id = id + return z +} diff --git a/coderd/authz/permission.go b/coderd/authz/permission.go new file mode 100644 index 0000000000000..ebcb05c34c9df --- /dev/null +++ b/coderd/authz/permission.go @@ -0,0 +1,111 @@ +package authz + +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" +) + +type PermLevel string + +const ( + LevelWildcard PermLevel = "*" + LevelSite PermLevel = "site" + LevelOrg PermLevel = "org" + LevelUser PermLevel = "user" +) + +var PermissionLevels = [4]PermLevel{LevelWildcard, LevelSite, LevelOrg, LevelUser} + +type Permission struct { + // Negate makes this a negative permission + Negate bool + Level PermLevel + // OrganizationID is used for identifying a particular org. + // org:1234 + OrganizationID string + + ResourceType ResourceType + ResourceID string + Action Action +} + +// String returns the ... string formatted permission. +func (p Permission) String() string { + sign := "+" + if p.Negate { + sign = "-" + } + levelID := "" + if p.OrganizationID != "" { + levelID = ":" + p.OrganizationID + } + + return fmt.Sprintf("%s%s%s.%s.%s.%s", + sign, p.Level, levelID, p.ResourceType, p.ResourceID, p.Action) +} + +func ParsePermissions(perms string) ([]Permission, error) { + permList := strings.Split(perms, ",") + parsed := make([]Permission, 0, len(permList)) + for _, permStr := range permList { + p, err := ParsePermission(strings.TrimSpace(permStr)) + if err != nil { + return nil, xerrors.Errorf("perm '%s': %w", permStr, err) + } + parsed = append(parsed, p) + } + return parsed, nil +} + +func ParsePermission(perm string) (Permission, error) { + parts := strings.Split(perm, ".") + if len(parts) != 4 { + return Permission{}, xerrors.Errorf("permission expects 4 parts, got %d", len(parts)) + } + + level, resType, resID, act := parts[0], parts[1], parts[2], parts[3] + + if len(level) < 2 { + return Permission{}, xerrors.Errorf("permission level is too short: '%s'", parts[0]) + } + sign := level[0] + levelParts := strings.Split(level[1:], ":") + if len(levelParts) > 2 { + return Permission{}, xerrors.Errorf("unsupported level format") + } + + var permission Permission + + switch sign { + case '+': + case '-': + permission.Negate = true + default: + return Permission{}, xerrors.Errorf("sign must be +/-") + } + + switch PermLevel(strings.ToLower(levelParts[0])) { + case LevelWildcard: + permission.Level = LevelWildcard + case LevelSite: + permission.Level = LevelSite + case LevelOrg: + permission.Level = LevelOrg + case LevelUser: + permission.Level = LevelUser + default: + return Permission{}, xerrors.Errorf("'%s' is an unsupported level", levelParts[0]) + } + + if len(levelParts) > 1 { + permission.OrganizationID = levelParts[1] + } + + // might want to check if these are valid resource types and actions. + permission.ResourceType = ResourceType(resType) + permission.ResourceID = resID + permission.Action = Action(act) + return permission, nil +} diff --git a/coderd/authz/permission_test.go b/coderd/authz/permission_test.go new file mode 100644 index 0000000000000..fd8f161e6573d --- /dev/null +++ b/coderd/authz/permission_test.go @@ -0,0 +1,147 @@ +package authz_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/authz" +) + +func TestPermissionString(t *testing.T) { + t.Parallel() + + testCases := []struct { + Name string + Permission authz.Permission + Expected string + }{ + { + Name: "BasicPositive", + Permission: authz.Permission{ + Negate: false, + Level: authz.LevelSite, + OrganizationID: "", + ResourceType: authz.ResourceWorkspace, + ResourceID: "*", + Action: authz.ActionRead, + }, + Expected: "+site.workspace.*.read", + }, + { + Name: "BasicNegative", + Permission: authz.Permission{ + Negate: true, + Level: authz.LevelUser, + OrganizationID: "", + ResourceType: authz.ResourceDevURL, + ResourceID: "1234", + Action: authz.ActionCreate, + }, + Expected: "-user.devurl.1234.create", + }, + { + Name: "OrgID", + Permission: authz.Permission{ + Negate: true, + Level: authz.LevelOrg, + OrganizationID: "default", + ResourceType: authz.ResourceProject, + ResourceID: "456", + Action: authz.ActionUpdate, + }, + Expected: "-org:default.project.456.update", + }, + } + + for _, c := range testCases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, c.Expected, c.Permission.String()) + perm, err := authz.ParsePermission(c.Expected) + require.NoError(t, err, "parse perm string") + require.Equal(t, c.Permission, perm, "expected perm") + + perms, err := authz.ParsePermissions(c.Expected) + require.NoError(t, err, "parse perms string") + require.Equal(t, c.Permission, perms[0], "expected perm") + require.Len(t, perms, 1, "expect 1 perm") + }) + } +} + +func TestParsePermissions(t *testing.T) { + t.Parallel() + + testCases := []struct { + Name string + Str string + Permissions []authz.Permission + ErrStr string + }{ + { + Name: "NoSign", + Str: "site.*.*.*", + ErrStr: "sign must be +/-", + }, + { + Name: "BadLevel", + Str: "+unknown.*.*.*", + ErrStr: "unsupported level", + }, + { + Name: "NotEnoughParts", + Str: "+*.*.*", + ErrStr: "permission expects 4 parts", + }, + { + Name: "ShortLevel", + Str: "*.*.*.*", + ErrStr: "permission level is too short", + }, + { + Name: "BadLevelID", + Str: "org:1234:extra.*.*.*", + ErrStr: "unsupported level format", + }, + { + Name: "GoodSet", + Str: "+org:1234.workspace.5678.read, -site.*.*.create", + Permissions: []authz.Permission{ + { + Negate: false, + Level: "org", + OrganizationID: "1234", + ResourceType: authz.ResourceWorkspace, + ResourceID: "5678", + Action: authz.ActionRead, + }, + { + Negate: true, + Level: "site", + OrganizationID: "", + ResourceType: "*", + ResourceID: "*", + Action: authz.ActionCreate, + }, + }, + }, + } + + for _, c := range testCases { + c := c + t.Run(c.Name, func(t *testing.T) { + t.Parallel() + perms, err := authz.ParsePermissions(c.Str) + if c.ErrStr != "" { + require.Error(t, err) + require.Contains(t, err.Error(), c.ErrStr, "exp error") + } else { + require.NoError(t, err, "parse error") + require.Equal(t, c.Permissions, perms, "exp perms") + } + }) + } +} diff --git a/coderd/authz/resources.go b/coderd/authz/resources.go new file mode 100644 index 0000000000000..dd519838415a6 --- /dev/null +++ b/coderd/authz/resources.go @@ -0,0 +1,45 @@ +package authz + +// ResourceType defines the list of available resources for authz. +type ResourceType string + +const ( + ResourceWorkspace ResourceType = "workspace" + ResourceProject ResourceType = "project" + ResourceDevURL ResourceType = "devurl" + ResourceUser ResourceType = "user" +) + +func (ResourceType) ID() string { + return "" +} + +func (t ResourceType) ResourceType() ResourceType { + return t +} + +// Org adds an org OwnerID to the resource +//nolint:revive +func (r ResourceType) Org(orgID string) zObject { + return zObject{ + orgOwner: orgID, + objectType: r, + } +} + +// Owner adds an OwnerID to the resource +//nolint:revive +func (r ResourceType) Owner(id string) zObject { + return zObject{ + owner: id, + objectType: r, + } +} + +//nolint:revive +func (r ResourceType) AsID(id string) zObject { + return zObject{ + id: id, + objectType: r, + } +} diff --git a/coderd/authz/role.go b/coderd/authz/role.go new file mode 100644 index 0000000000000..5e1987033ad74 --- /dev/null +++ b/coderd/authz/role.go @@ -0,0 +1,5 @@ +package authz + +type Role struct { + Permissions []Permission +} diff --git a/coderd/authz/subject.go b/coderd/authz/subject.go new file mode 100644 index 0000000000000..7e55394d5ebd0 --- /dev/null +++ b/coderd/authz/subject.go @@ -0,0 +1,69 @@ +package authz + +import "context" + +// Subject is the actor that is attempting to do some action on some object or +// set of objects. +type Subject interface { + // ID is the ID for the given actor. If it matches the OwnerID ID of the + // object, we can assume the object is owned by this subject. + ID() string + + SiteRoles() ([]Role, error) + // OrgRoles only need to be returned for the organization in question. + // This is because users typically belong to more than 1 organization, + // and grabbing all the roles for all orgs is excessive. + OrgRoles(ctx context.Context, orgID string) ([]Role, error) + UserRoles() ([]Role, error) + + // Scopes can limit the roles above. + Scopes() ([]Permission, error) +} + +// SubjectTODO is a placeholder until we get an actual actor struct in place. +// This will come with the Authn epic. +// TODO: @emyrk delete this data structure when authn exists +type SubjectTODO struct { + UserID string `json:"user_id"` + + Site []Role `json:"site_roles"` + Org map[string][]Role `json:"org_roles"` + User []Role `json:"user_roles"` +} + +func (s SubjectTODO) ID() string { + return s.UserID +} + +func (s SubjectTODO) SiteRoles() ([]Role, error) { + return s.Site, nil +} + +func (s SubjectTODO) OrgRoles(_ context.Context, orgID string) ([]Role, error) { + v, ok := s.Org[orgID] + if !ok { + // Members not in an org return the negative perm + return []Role{{ + Permissions: []Permission{ + { + Negate: true, + Level: "*", + OrganizationID: "", + ResourceType: "*", + ResourceID: "*", + Action: "*", + }, + }, + }}, nil + } + + return v, nil +} + +func (s SubjectTODO) UserRoles() ([]Role, error) { + return s.User, nil +} + +func (SubjectTODO) Scopes() ([]Permission, error) { + return []Permission{}, nil +}