mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user