feat: Check permissions endpoint (#1389)

* feat: Check permissions endpoint

Allows FE to query backend for permission capabilities.
Batch requests supported
This commit is contained in:
Steven Masley
2022-05-12 15:56:23 -05:00
committed by GitHub
parent 75a5877c1d
commit 64e408c954
8 changed files with 282 additions and 20 deletions
+2
View File
@@ -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)
+39 -2
View File
@@ -13,6 +13,7 @@ import (
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"math/big"
"net"
@@ -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"
@@ -212,14 +215,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{
@@ -230,6 +233,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
}
+43
View File
@@ -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 {
if v.Object.ResourceType == "" {
httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{
Message: "'resource_type' must be defined",
})
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,
+86 -13
View File
@@ -12,6 +12,91 @@ import (
"github.com/coder/coder/codersdk"
)
func TestPermissionCheck(t *testing.T) {
t.Parallel()
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))
// 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",
},
}
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,
},
},
{
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()
@@ -20,19 +105,7 @@ func TestListRoles(t *testing.T) {
// 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)
orgAdminUser, err := orgAdmin.User(ctx, codersdk.Me)
require.NoError(t, err)
// 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)},
},
)
require.NoError(t, err, "update org member roles")
orgAdmin := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID, rbac.RoleOrgAdmin(admin.OrganizationID))
otherOrg, err := client.CreateOrganization(ctx, admin.UserID, codersdk.CreateOrganizationRequest{
Name: "other",
+13
View File
@@ -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
View File
@@ -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"`
+22
View File
@@ -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.
+27 -5
View File
@@ -12,7 +12,7 @@ export interface AgentGitSSHKey {
readonly private_key: string
}
// From codersdk/users.go:100:6
// From codersdk/users.go:150:6
export interface AuthMethods {
readonly password: boolean
readonly github: boolean
@@ -44,7 +44,7 @@ export interface CreateFirstUserResponse {
readonly organization_id: string
}
// From codersdk/users.go:95:6
// From codersdk/users.go:145:6
export interface CreateOrganizationRequest {
readonly name: string
}
@@ -101,7 +101,7 @@ export interface CreateWorkspaceRequest {
readonly parameter_values?: CreateParameterRequest[]
}
// From codersdk/users.go:91:6
// From codersdk/users.go:141:6
export interface GenerateAPIKeyResponse {
readonly key: string
}
@@ -119,13 +119,13 @@ export interface GoogleInstanceIdentityToken {
readonly json_web_token: string
}
// From codersdk/users.go:80:6
// From codersdk/users.go:130:6
export interface LoginWithPasswordRequest {
readonly email: string
readonly password: string
}
// From codersdk/users.go:86:6
// From codersdk/users.go:136:6
export interface LoginWithPasswordResponse {
readonly session_token: string
}
@@ -315,6 +315,28 @@ export interface User {
readonly roles: Role[]
}
// From codersdk/users.go:95:6
export interface UserPermissionCheck {
readonly object: UserPermissionCheckObject
readonly action: string
}
// From codersdk/users.go:111:6
export interface UserPermissionCheckObject {
readonly resource_type: string
readonly owner_id?: string
readonly organization_id?: string
readonly resource_id?: string
}
// From codersdk/users.go:84:6
export interface UserPermissionCheckRequest {
readonly checks: Record<string, UserPermissionCheck>
}
// From codersdk/users.go:79:6
export type UserPermissionCheckResponse = Record<string, boolean>
// From codersdk/users.go:74:6
export interface UserRoles {
readonly roles: string[]