Skip to content

feat: Add organizations endpoint for users #50

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 9 commits into from
Jan 23, 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 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)
})
}
Loading