Skip to content

Commit

Permalink
feat: Add organizations endpoint for users (#50)
Browse files Browse the repository at this point in the history
* feat: Add organizations endpoint for users

This moves the /user endpoint to /users/me instead. This
will reduce code duplication.

This adds /users/<name>/organizations to list organizations
a user has access to. It doesn't contain the permissions a
user has over the organizations, but that will come in a future
contribution.

* Fix requested changes

* Fix tests

* Fix timeout

* Add test for UserOrgs

* Add test for userparam getting

* Add test for NoUser
  • Loading branch information
kylecarbs committed Jan 23, 2022
1 parent 50d8151 commit 8be2456
Show file tree
Hide file tree
Showing 20 changed files with 597 additions and 244 deletions.
2 changes: 2 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,7 @@ ignore:
# This is generated code.
- database/models.go
- database/query.sql.go
# All coderd tests fail if this doesn't work.
- database/databasefake
- peerbroker/proto
- provisionersdk/proto
19 changes: 11 additions & 8 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,18 @@ func New(options *Options) http.Handler {
Message: "👋",
})
})
r.Post("/user", users.createInitialUser)
r.Post("/login", users.loginWithPassword)
// Require an API key and authenticated user for this group.
r.Group(func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
httpmw.ExtractUser(options.Database),
)
r.Get("/user", users.authenticatedUser)
r.Route("/users", func(r chi.Router) {
r.Post("/", users.createInitialUser)

r.Group(func(r chi.Router) {
r.Use(
httpmw.ExtractAPIKey(options.Database, nil),
httpmw.ExtractUserParam(options.Database),
)
r.Get("/{user}", users.user)
r.Get("/{user}/organizations", users.userOrganizations)
})
})
})
r.NotFound(site.Handler().ServeHTTP)
Expand Down
51 changes: 33 additions & 18 deletions coderd/coderdtest/coderdtest.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/cryptorand"
"github.com/coder/coder/database"
"github.com/coder/coder/database/databasefake"
"github.com/coder/coder/database/postgres"
Expand All @@ -27,7 +28,37 @@ type Server struct {
URL *url.URL
}

// New constructs a new coderd test instance.
// RandomInitialUser generates a random initial user and authenticates
// it with the client on the Server struct.
func (s *Server) RandomInitialUser(t *testing.T) coderd.CreateInitialUserRequest {
username, err := cryptorand.String(12)
require.NoError(t, err)
password, err := cryptorand.String(12)
require.NoError(t, err)
organization, err := cryptorand.String(12)
require.NoError(t, err)

req := coderd.CreateInitialUserRequest{
Email: "testuser@coder.com",
Username: username,
Password: password,
Organization: organization,
}
_, err = s.Client.CreateInitialUser(context.Background(), req)
require.NoError(t, err)

login, err := s.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: "testuser@coder.com",
Password: password,
})
require.NoError(t, err)
err = s.Client.SetSessionToken(login.SessionToken)
require.NoError(t, err)
return req
}

// New constructs a new coderd test instance. This returned Server
// should contain no side-effects.
func New(t *testing.T) Server {
// This can be hotswapped for a live database instance.
db := databasefake.New()
Expand All @@ -54,24 +85,8 @@ func New(t *testing.T) Server {
require.NoError(t, err)
t.Cleanup(srv.Close)

client := codersdk.New(serverURL)
_, err = client.CreateInitialUser(context.Background(), coderd.CreateUserRequest{
Email: "testuser@coder.com",
Username: "testuser",
Password: "testpassword",
})
require.NoError(t, err)

login, err := client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: "testuser@coder.com",
Password: "testpassword",
})
require.NoError(t, err)
err = client.SetSessionToken(login.SessionToken)
require.NoError(t, err)

return Server{
Client: client,
Client: codersdk.New(serverURL),
URL: serverURL,
}
}
3 changes: 2 additions & 1 deletion coderd/coderdtest/coderdtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,6 @@ func TestMain(m *testing.M) {
}

func TestNew(t *testing.T) {
_ = coderdtest.New(t)
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
}
25 changes: 25 additions & 0 deletions coderd/organizations.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package coderd

import (
"time"

"github.com/coder/coder/database"
)

// Organization is the JSON representation of a Coder organization.
type Organization struct {
ID string `json:"id" validate:"required"`
Name string `json:"name" validate:"required"`
CreatedAt time.Time `json:"created_at" validate:"required"`
UpdatedAt time.Time `json:"updated_at" validate:"required"`
}

// convertOrganization consumes the database representation and outputs an API friendly representation.
func convertOrganization(organization database.Organization) Organization {
return Organization{
ID: organization.ID,
Name: organization.Name,
CreatedAt: organization.CreatedAt,
UpdatedAt: organization.UpdatedAt,
}
}
102 changes: 70 additions & 32 deletions coderd/users.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package coderd

import (
"context"
"crypto/sha256"
"database/sql"
"errors"
Expand All @@ -11,6 +10,7 @@ import (

"github.com/go-chi/render"
"github.com/google/uuid"
"golang.org/x/xerrors"

"github.com/coder/coder/coderd/userpassword"
"github.com/coder/coder/cryptorand"
Expand All @@ -27,11 +27,12 @@ type User struct {
Username string `json:"username" validate:"required"`
}

// CreateUserRequest enables callers to create a new user.
type CreateUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
// CreateInitialUserRequest enables callers to create a new user.
type CreateInitialUserRequest struct {
Email string `json:"email" validate:"required,email"`
Username string `json:"username" validate:"required,username"`
Password string `json:"password" validate:"required"`
Organization string `json:"organization" validate:"required,username"`
}

// LoginWithPasswordRequest enables callers to authenticate with email and password.
Expand All @@ -51,7 +52,7 @@ type users struct {

// Creates the initial user for a Coder deployment.
func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) {
var createUser CreateUserRequest
var createUser CreateInitialUserRequest
if !httpapi.Read(rw, r, &createUser) {
return
}
Expand All @@ -70,19 +71,6 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) {
})
return
}
_, err = users.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{
Email: createUser.Email,
Username: createUser.Username,
})
if errors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get user: %s", err.Error()),
})
return
}
hashedPassword, err := userpassword.Hash(createUser.Password)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Expand All @@ -91,28 +79,57 @@ func (users *users) createInitialUser(rw http.ResponseWriter, r *http.Request) {
return
}

user, err := users.Database.InsertUser(context.Background(), database.InsertUserParams{
ID: uuid.NewString(),
Email: createUser.Email,
HashedPassword: []byte(hashedPassword),
Username: createUser.Username,
LoginType: database.LoginTypeBuiltIn,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
// Create the user, organization, and membership to the user.
var user database.User
err = users.Database.InTx(func(s database.Store) error {
user, err = users.Database.InsertUser(r.Context(), database.InsertUserParams{
ID: uuid.NewString(),
Email: createUser.Email,
HashedPassword: []byte(hashedPassword),
Username: createUser.Username,
LoginType: database.LoginTypeBuiltIn,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
if err != nil {
return xerrors.Errorf("create user: %w", err)
}
organization, err := users.Database.InsertOrganization(r.Context(), database.InsertOrganizationParams{
ID: uuid.NewString(),
Name: createUser.Organization,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
})
if err != nil {
return xerrors.Errorf("create organization: %w", err)
}
_, err = users.Database.InsertOrganizationMember(r.Context(), database.InsertOrganizationMemberParams{
OrganizationID: organization.ID,
UserID: user.ID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
Roles: []string{"organization-admin"},
})
if err != nil {
return xerrors.Errorf("create organization member: %w", err)
}
return nil
})
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("create user: %s", err.Error()),
Message: err.Error(),
})
return
}

render.Status(r, http.StatusCreated)
render.JSON(rw, r, user)
}

// Returns the currently authenticated user.
func (*users) authenticatedUser(rw http.ResponseWriter, r *http.Request) {
user := httpmw.User(r)
// Returns the parameterized user requested. All validation
// is completed in the middleware for this route.
func (*users) user(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)

render.JSON(rw, r, User{
ID: user.ID,
Expand All @@ -122,6 +139,27 @@ func (*users) authenticatedUser(rw http.ResponseWriter, r *http.Request) {
})
}

// Returns organizations the parameterized user has access to.
func (users *users) userOrganizations(rw http.ResponseWriter, r *http.Request) {
user := httpmw.UserParam(r)

organizations, err := users.Database.GetOrganizationsByUserID(r.Context(), user.ID)
if err != nil {
httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{
Message: fmt.Sprintf("get organizations: %s", err.Error()),
})
return
}

publicOrganizations := make([]Organization, 0, len(organizations))
for _, organization := range organizations {
publicOrganizations = append(publicOrganizations, convertOrganization(organization))
}

render.Status(r, http.StatusOK)
render.JSON(rw, r, publicOrganizations)
}

// Authenticates the user with an email and password.
func (users *users) loginWithPassword(rw http.ResponseWriter, r *http.Request) {
var loginWithPassword LoginWithPasswordRequest
Expand Down
38 changes: 29 additions & 9 deletions coderd/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,35 @@ func TestUsers(t *testing.T) {
t.Run("Authenticated", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
_, err := server.Client.User(context.Background(), "")
require.NoError(t, err)
})

t.Run("CreateMultipleInitial", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_, err := server.Client.CreateInitialUser(context.Background(), coderd.CreateUserRequest{
Email: "dummy@coder.com",
Username: "fake",
Password: "password",
_ = server.RandomInitialUser(t)
_, err := server.Client.CreateInitialUser(context.Background(), coderd.CreateInitialUserRequest{
Email: "dummy@coder.com",
Organization: "bananas",
Username: "fake",
Password: "password",
})
require.Error(t, err)
})

t.Run("LoginNoEmail", func(t *testing.T) {
t.Run("Login", func(t *testing.T) {
server := coderdtest.New(t)
user := server.RandomInitialUser(t)
_, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: user.Email,
Password: user.Password,
})
require.NoError(t, err)
})

t.Run("LoginInvalidUser", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Expand All @@ -44,13 +57,20 @@ func TestUsers(t *testing.T) {
t.Run("LoginBadPassword", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
user, err := server.Client.User(context.Background(), "")
require.NoError(t, err)

_, err = server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
user := server.RandomInitialUser(t)
_, err := server.Client.LoginWithPassword(context.Background(), coderd.LoginWithPasswordRequest{
Email: user.Email,
Password: "bananas",
})
require.Error(t, err)
})

t.Run("ListOrganizations", func(t *testing.T) {
t.Parallel()
server := coderdtest.New(t)
_ = server.RandomInitialUser(t)
orgs, err := server.Client.UserOrganizations(context.Background(), "")
require.NoError(t, err)
require.Len(t, orgs, 1)
})
}

0 comments on commit 8be2456

Please sign in to comment.