diff --git a/coderd/coderd.go b/coderd/coderd.go index 67e7b0eaeae3f..4d394cb9362ae 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -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) diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index a77f6bde5575a..f09d94e8a3b86 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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" @@ -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{ @@ -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 } diff --git a/coderd/roles.go b/coderd/roles.go index cad7430dcb9d4..5c9f16672dd9b 100644 --- a/coderd/roles.go +++ b/coderd/roles.go @@ -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, diff --git a/coderd/roles_test.go b/coderd/roles_test.go index 2c42d129cec4f..b4d79ae6760c4 100644 --- a/coderd/roles_test.go +++ b/coderd/roles_test.go @@ -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", diff --git a/codersdk/roles.go b/codersdk/roles.go index 727c78e2256c1..c67255b435b53 100644 --- a/codersdk/roles.go +++ b/codersdk/roles.go @@ -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) +} diff --git a/codersdk/users.go b/codersdk/users.go index 693277608d5b8..7172a972c5537 100644 --- a/codersdk/users.go +++ b/codersdk/users.go @@ -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"` diff --git a/scripts/apitypings/main.go b/scripts/apitypings/main.go index 4afcecc9cd2d3..88eb51fd6391d 100644 --- a/scripts/apitypings/main.go +++ b/scripts/apitypings/main.go @@ -183,6 +183,28 @@ func (g *Generator) generateAll() (*TypescriptTypes, error) { // type 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. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e30c94e8663e6..81d0747623c16 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 +} + +// From codersdk/users.go:79:6 +export type UserPermissionCheckResponse = Record + // From codersdk/users.go:74:6 export interface UserRoles { readonly roles: string[]