Thanks to visit codestin.com
Credit goes to github.com

Skip to content

feat: Check permissions endpoint #1389

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 12, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ func New(options *Options) (http.Handler, func()) {
r.Put("/roles", api.putUserRoles)
r.Get("/roles", api.userRoles)

r.Post("/authorization", api.checkPermissions)

r.Post("/keys", api.postAPIKey)
r.Route("/organizations", func(r chi.Router) {
r.Post("/", api.postOrganizationsByUser)
Expand Down
41 changes: 39 additions & 2 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
Expand All @@ -24,6 +25,8 @@ import (
"testing"
"time"

"github.com/coder/coder/coderd/rbac"

"cloud.google.com/go/compute/metadata"
"github.com/fullsailor/pkcs7"
"github.com/golang-jwt/jwt"
Expand Down Expand Up @@ -197,14 +200,14 @@ func CreateFirstUser(t *testing.T, client *codersdk.Client) codersdk.CreateFirst
}

// CreateAnotherUser creates and authenticates a new user.
func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uuid.UUID) *codersdk.Client {
func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uuid.UUID, roles ...string) *codersdk.Client {
req := codersdk.CreateUserRequest{
Email: namesgenerator.GetRandomName(1) + "@coder.com",
Username: randomUsername(),
Password: "testpass",
OrganizationID: organizationID,
}
_, err := client.CreateUser(context.Background(), req)
user, err := client.CreateUser(context.Background(), req)
require.NoError(t, err)

login, err := client.LoginWithPassword(context.Background(), codersdk.LoginWithPasswordRequest{
Expand All @@ -215,6 +218,40 @@ func CreateAnotherUser(t *testing.T, client *codersdk.Client, organizationID uui

other := codersdk.New(client.URL)
other.SessionToken = login.SessionToken

if len(roles) > 0 {
// Find the roles for the org vs the site wide roles
orgRoles := make(map[string][]string)
var siteRoles []string

for _, roleName := range roles {
roleName := roleName
orgID, ok := rbac.IsOrgRole(roleName)
if ok {
orgRoles[orgID] = append(orgRoles[orgID], roleName)
} else {
siteRoles = append(siteRoles, roleName)
}
}
// Update the roles
for _, r := range user.Roles {
siteRoles = append(siteRoles, r.Name)
}
// TODO: @emyrk switch "other" to "client" when we support updating other
// users.
_, err := other.UpdateUserRoles(context.Background(), user.ID, codersdk.UpdateRoles{Roles: siteRoles})
require.NoError(t, err, "update site roles")

// Update org roles
for orgID, roles := range orgRoles {
organizationID, err := uuid.Parse(orgID)
require.NoError(t, err, fmt.Sprintf("parse org id %q", orgID))
// TODO: @Emyrk add the member to the organization if they do not already belong.
_, err = other.UpdateOrganizationMemberRoles(context.Background(), organizationID, user.ID,
codersdk.UpdateRoles{Roles: append(roles, rbac.RoleOrgMember(organizationID))})
require.NoError(t, err, "update org membership roles")
}
}
return other
}

Expand Down
43 changes: 43 additions & 0 deletions coderd/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,49 @@ func (*api) assignableOrgRoles(rw http.ResponseWriter, r *http.Request) {
httpapi.Write(rw, http.StatusOK, convertRoles(roles))
}

func (api *api) checkPermissions(rw http.ResponseWriter, r *http.Request) {
roles := httpmw.UserRoles(r)
user := httpmw.UserParam(r)
if user.ID != roles.ID {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
// TODO: @Emyrk in the future we could have an rbac check here.
// If the user can masquerade/impersonate as the user passed in,
// we could allow this or something like that.
Message: "only allowed to check permissions on yourself",
})
return
}

var params codersdk.UserPermissionCheckRequest
if !httpapi.Read(rw, r, &params) {
return
}

response := make(codersdk.UserPermissionCheckResponse)
for k, v := range params.Checks {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems odd to echo user-specified keys back in the response. Do we require this endpoint to check for N permissions?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It's a batch request, so the map key is to return the right true/false for each permission check. So the keys will likely be human friendly names such as read-my-workspace, the response will return "read-my-workspace": true

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand, but what is the use-case that attempting to fetch the workspace and checking the status code wouldn't catch?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is primarily for deciding which UI options to show.

Currently the admin Manage dropdown would use this to determine if it should show, and what buttons.

This is probably not going to be exactly how it's done, but something like this. The manage dropdown has 4 different options, you can do a permission check for each one and conditionally show the ui elements the user can access. If any return true, you show the Manage button.

{
   "manage-all-organizations": true,
   "manage-all-users": true,
   "read-all-auditlogs": true,
   "modify-deployment-settings: false,
}

In the past, the UI used the user's roles, but that means the UI has to know what an admin can and cannot do. In this world, if we ever support custom roles, that becomes impossible. This is the new approach, where the UI comes up with actions associated with UI elements, and queries the backend to handle the Authorize() check. Keeping it centralized in 1 place where RBAC logic is done.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this standard amongst other products with RBAC? Something feels fishy to me which isn't enough to prevent this, but maybe is sign to look for validation with other products.

GitHub appears to display UI options based off role, but I know that diverges from our abstraction a bit.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I like most on not being attached to the role itself but the permission is in the future, if we want, we can create custom roles without having to update the front end at all.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this standard amongst other products with RBAC?

Kubernetes has SubjectAccessReview and SelfSubjectAccessReview API endpoints, for example: https://kubernetes.io/docs/reference/access-authn-authz/authorization/#checking-api-access

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@spikecurtis good example that is exactly what we are doing here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kylecarbs What do you think now?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I still feel it's odd to echo the key-value pairs, but I don't think it should block progress! Seems fine to me.

if v.Object.ResourceType == "" {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "'resource_type' must be defined",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is resource type required? It has a ? in the typescript

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it is. @Emyrk is that possible to have this reflected in the TS types?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, fixed.Ah

})
return
}

if v.Object.OwnerID == "me" {
v.Object.OwnerID = roles.ID.String()
}
err := api.Authorizer.AuthorizeByRoleName(r.Context(), roles.ID.String(), roles.Roles, rbac.Action(v.Action),
rbac.Object{
ResourceID: v.Object.ResourceID,
Owner: v.Object.OwnerID,
OrgID: v.Object.OrganizationID,
Type: v.Object.ResourceType,
})
response[k] = err == nil
}

httpapi.Write(rw, http.StatusOK, response)
}

func convertRole(role rbac.Role) codersdk.Role {
return codersdk.Role{
DisplayName: role.DisplayName,
Expand Down
97 changes: 85 additions & 12 deletions coderd/roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,100 @@ import (
"github.com/coder/coder/codersdk"
)

func TestListRoles(t *testing.T) {
func TestPermissionCheck(t *testing.T) {
t.Parallel()

ctx := context.Background()
client := coderdtest.New(t, nil)
// Create admin, member, and org admin
admin := coderdtest.CreateFirstUser(t, client)
member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleOrgAdmin(admin.OrganizationID))

orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
orgAdminUser, err := orgAdmin.User(ctx, codersdk.Me)
require.NoError(t, err)
// With admin, member, and org admin
const (
allUsers = "read-all-users"
readOrgWorkspaces = "read-org-workspaces"
myself = "read-myself"
myWorkspace = "read-my-workspace"
)
params := map[string]codersdk.UserPermissionCheck{
allUsers: {
Object: codersdk.UserPermissionCheckObject{
ResourceType: "users",
},
Action: "read",
},
myself: {
Object: codersdk.UserPermissionCheckObject{
ResourceType: "users",
OwnerID: "me",
},
Action: "read",
},
myWorkspace: {
Object: codersdk.UserPermissionCheckObject{
ResourceType: "workspaces",
OwnerID: "me",
},
Action: "read",
},
readOrgWorkspaces: {
Object: codersdk.UserPermissionCheckObject{
ResourceType: "workspaces",
OrganizationID: admin.OrganizationID.String(),
},
Action: "read",
},
}

// TODO: @emyrk switch this to the admin when getting non-personal users is
// supported. `client.UpdateOrganizationMemberRoles(...)`
_, err = orgAdmin.UpdateOrganizationMemberRoles(ctx, admin.OrganizationID, orgAdminUser.ID,
codersdk.UpdateRoles{
Roles: []string{rbac.RoleOrgMember(admin.OrganizationID), rbac.RoleOrgAdmin(admin.OrganizationID)},
testCases := []struct {
Name string
Client *codersdk.Client
Check codersdk.UserPermissionCheckResponse
}{
{
Name: "Admin",
Client: client,
Check: map[string]bool{
allUsers: true, myself: true, myWorkspace: true, readOrgWorkspaces: true,
},
},
)
require.NoError(t, err, "update org member roles")
{
Name: "Member",
Client: member,
Check: map[string]bool{
allUsers: false, myself: true, myWorkspace: true, readOrgWorkspaces: false,
},
},
{
Name: "OrgAdmin",
Client: orgAdmin,
Check: map[string]bool{
allUsers: false, myself: true, myWorkspace: true, readOrgWorkspaces: true,
},
},
}

for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
resp, err := c.Client.CheckPermissions(context.Background(), codersdk.UserPermissionCheckRequest{Checks: params})
require.NoError(t, err, "check perms")
require.Equal(t, resp, c.Check)
})
}
}

func TestListRoles(t *testing.T) {
t.Parallel()

ctx := context.Background()
client := coderdtest.New(t, nil)
// Create admin, member, and org admin
admin := coderdtest.CreateFirstUser(t, client)
member := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleOrgAdmin(admin.OrganizationID))

otherOrg, err := client.CreateOrganization(ctx, admin.UserID, codersdk.CreateOrganizationRequest{
Name: "other",
Expand Down
13 changes: 13 additions & 0 deletions codersdk/roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,16 @@ func (c *Client) ListOrganizationRoles(ctx context.Context, org uuid.UUID) ([]Ro
var roles []Role
return roles, json.NewDecoder(res.Body).Decode(&roles)
}

func (c *Client) CheckPermissions(ctx context.Context, checks UserPermissionCheckRequest) (UserPermissionCheckResponse, error) {
res, err := c.request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/users/%s/authorization", uuidOrMe(Me)), checks)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, readBodyAsError(res)
}
var roles UserPermissionCheckResponse
return roles, json.NewDecoder(res.Body).Decode(&roles)
}
50 changes: 50 additions & 0 deletions codersdk/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,56 @@ type UserRoles struct {
OrganizationRoles map[uuid.UUID][]string `json:"organization_roles"`
}

type UserPermissionCheckResponse map[string]bool

// UserPermissionCheckRequest is a structure instead of a map because
// go-playground/validate can only validate structs. If you attempt to pass
// a map into 'httpapi.Read', you will get an invalid type error.
type UserPermissionCheckRequest struct {
// Checks is a map keyed with an arbitrary string to a permission check.
// The key can be any string that is helpful to the caller, and allows
// multiple permission checks to be run in a single request.
// The key ensures that each permission check has the same key in the
// response.
Checks map[string]UserPermissionCheck `json:"checks"`
}

// UserPermissionCheck is used to check if a user can do a given action
// to a given set of objects.
type UserPermissionCheck struct {
// Object can represent a "set" of objects, such as:
// - All workspaces in an organization
// - All workspaces owned by me
// - All workspaces across the entire product
// When defining an object, use the most specific language when possible to
// produce the smallest set. Meaning to set as many fields on 'Object' as
// you can. Example, if you want to check if you can update all workspaces
// owned by 'me', try to also add an 'OrganizationID' to the settings.
// Omitting the 'OrganizationID' could produce the incorrect value, as
// workspaces have both `user` and `organization` owners.
Object UserPermissionCheckObject `json:"object"`
// Action can be 'create', 'read', 'update', or 'delete'
Action string `json:"action"`
}

type UserPermissionCheckObject struct {
// ResourceType is the name of the resource.
// './coderd/rbac/object.go' has the list of valid resource types.
ResourceType string `json:"resource_type"`
// OwnerID (optional) is a user_id. It adds the set constraint to all resources owned
// by a given user.
OwnerID string `json:"owner_id,omitempty"`
// OrganizationID (optional) is an organization_id. It adds the set constraint to
// all resources owned by a given organization.
OrganizationID string `json:"organization_id,omitempty"`
// ResourceID (optional) reduces the set to a singular resource. This assigns
// a resource ID to the resource type, eg: a single workspace.
// The rbac library will not fetch the resource from the database, so if you
// are using this option, you should also set the 'OwnerID' and 'OrganizationID'
// if possible. Be as specific as possible using all the fields relevant.
ResourceID string `json:"resource_id,omitempty"`
}

// LoginWithPasswordRequest enables callers to authenticate with email and password.
type LoginWithPasswordRequest struct {
Email string `json:"email" validate:"required,email"`
Expand Down
22 changes: 22 additions & 0 deletions scripts/apitypings/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,28 @@ func (g *Generator) generateAll() (*TypescriptTypes, error) {
// type <Name> string
// These are enums. Store to expand later.
enums[obj.Name()] = obj
case *types.Map:
// Declared maps that are not structs are still valid codersdk objects.
// Handle them custom by calling 'typescriptType' directly instead of
// iterating through each struct field.
// These types support no json/typescript tags.
// These are **NOT** enums, as a map in Go would never be used for an enum.
ts, err := g.typescriptType(obj.Type().Underlying())
if err != nil {
return nil, xerrors.Errorf("(map) generate %q: %w", obj.Name(), err)
}

var str strings.Builder
_, _ = str.WriteString(g.posLine(obj))
if ts.AboveTypeLine != "" {
str.WriteString(ts.AboveTypeLine)
str.WriteRune('\n')
}
// Use similar output syntax to enums.
str.WriteString(fmt.Sprintf("export type %s = %s\n", obj.Name(), ts.ValueType))
structs[obj.Name()] = str.String()
case *types.Array, *types.Slice:
// TODO: @emyrk if you need this, follow the same design as "*types.Map" case.
}
case *types.Var:
// TODO: Are any enums var declarations? This is also codersdk.Me.
Expand Down
Loading