diff --git a/pkg/quarterdeck/api/v1/errors.go b/pkg/quarterdeck/api/v1/errors.go index 92170b85f..975debf0f 100644 --- a/pkg/quarterdeck/api/v1/errors.go +++ b/pkg/quarterdeck/api/v1/errors.go @@ -24,6 +24,7 @@ var ( ErrInvalidField = errors.New("invalid or unparsable field") ErrRestrictedField = errors.New("field restricted for request") ErrModelIDMismatch = errors.New("resource id does not match id of endpoint") + ErrUserExists = errors.New("user or organization already exists") ) // Construct a new response for an error or simply return unsuccessful. diff --git a/pkg/quarterdeck/auth.go b/pkg/quarterdeck/auth.go index daf2887b6..b2f1b206d 100644 --- a/pkg/quarterdeck/auth.go +++ b/pkg/quarterdeck/auth.go @@ -2,32 +2,32 @@ package quarterdeck import ( "context" + "errors" "net/http" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v4" + "github.com/oklog/ulid/v2" "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" "github.com/rotationalio/ensign/pkg/quarterdeck/db/models" "github.com/rotationalio/ensign/pkg/quarterdeck/passwd" + "github.com/rotationalio/ensign/pkg/quarterdeck/permissions" "github.com/rotationalio/ensign/pkg/quarterdeck/tokens" "github.com/rotationalio/ensign/pkg/utils/gravatar" "github.com/rs/zerolog/log" ) -const ( - DefaultRole = "Member" -) - // Register creates a new user in the database with the specified password, allowing the // user to login to Quarterdeck. This endpoint requires a "strong" password and a valid // register request, otherwise a 400 reply is returned. The password is stored in the // database as an argon2 derived key so it is impossible for a hacker to get access to -// raw passwords. By default the user is given the Member role, unless an organization -// is being created for the user, in which case the user is assigned the Owner role. +// raw passwords. +// +// An organization is created for the user registering based on the organization data +// in the register request and the user is assigned the Owner role. This endpoint does +// not handle adding users to existing organizations through collaborator invites. // TODO: add rate limiting to ensure that we don't get spammed with registrations -// TODO: review and ensure the register methodology is what we want -// TODO: handle organizations and invites (e.g. with role association). func (s *Server) Register(c *gin.Context) { var ( err error @@ -46,8 +46,7 @@ func (s *Server) Register(c *gin.Context) { return } - // Create a user model to insert into the database with the default role. - // TODO: ensure role can be associated with the model directly. + // Create a user model to insert into the database. user := &models.User{ Name: in.Name, Email: in.Email, @@ -63,14 +62,20 @@ func (s *Server) Register(c *gin.Context) { return } - // Create an org to associate with the user since this is not an invite + // Create a new organization to associate with the user since this is not an invite. org := &models.Organization{ Name: in.Organization, Domain: in.Domain, } - if err = user.Create(c.Request.Context(), org, DefaultRole); err != nil { - // TODO: handle database constraint errors (e.g. unique email address) + if err = user.Create(c.Request.Context(), org, permissions.RoleOwner); err != nil { + // Handle constraint errors + var dberr *models.ConstraintError + if errors.As(err, &dberr) { + c.JSON(http.StatusConflict, api.ErrorResponse(api.ErrUserExists)) + return + } + log.Error().Err(err).Msg("could not insert user into database during registration") c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not process registration")) return @@ -79,9 +84,10 @@ func (s *Server) Register(c *gin.Context) { // Prepare response to return to the registering user. out = &api.RegisterReply{ ID: user.ID, + OrgID: org.ID, Email: user.Email, Message: "Welcome to Ensign!", - Role: "Member", + Role: permissions.RoleOwner, Created: user.Created, } c.JSON(http.StatusCreated, out) @@ -126,8 +132,13 @@ func (s *Server) Login(c *gin.Context) { } // Retrieve the user by email (read-only transaction) - if user, err = models.GetUserEmail(c.Request.Context(), in.Email); err != nil { - // TODO: handle user not found error with a 403. + if user, err = models.GetUserEmail(c.Request.Context(), in.Email, in.OrgID); err != nil { + // handle user not found error with a 403. + if errors.Is(err, models.ErrNotFound) { + c.JSON(http.StatusForbidden, api.ErrorResponse("invalid login credentials")) + return + } + log.Error().Err(err).Msg("could not find user by email") c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not complete request")) return @@ -135,14 +146,12 @@ func (s *Server) Login(c *gin.Context) { // Check that the password supplied by the user is correct. if verified, err := passwd.VerifyDerivedKey(user.Password, in.Password); err != nil || !verified { - // TODO: more graceful handling of error and failures log.Debug().Err(err).Msg("invalid login credentials") c.JSON(http.StatusForbidden, api.ErrorResponse("invalid login credentials")) return } // Create the access and refresh tokens and return them to the user. - // TODO: add organization ID and project ID to the claims claims := &tokens.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID.String(), @@ -152,6 +161,15 @@ func (s *Server) Login(c *gin.Context) { Picture: gravatar.New(user.Email, nil), } + // Add the orgID to the claims + var orgID ulid.ULID + if orgID, err = user.OrgID(); err != nil { + log.Error().Err(err).Msg("could not get orgID from user") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not create credentials")) + return + } + claims.OrgID = orgID.String() + // Add the user permissions to the claims. // NOTE: these should have been fetched on the first query. if claims.Permissions, err = user.Permissions(c.Request.Context(), false); err != nil { @@ -215,31 +233,35 @@ func (s *Server) Authenticate(c *gin.Context) { // Retrieve the API key by the client ID (read-only transaction) if apikey, err = models.GetAPIKey(c.Request.Context(), in.ClientID); err != nil { - // TODO: handle apikey not found with a 404. - log.Error().Err(err).Msg("could not find api key by client id") + // handle apikey not found with a 403. + if errors.Is(err, models.ErrNotFound) { + c.JSON(http.StatusForbidden, api.ErrorResponse("invalid credentials")) + return + } + + log.Error().Err(err).Msg("could not retrieve api key by client id") c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not complete request")) return } // Check that the client secret supplied by the user is correct. if verified, err := passwd.VerifyDerivedKey(apikey.Secret, in.ClientSecret); err != nil || !verified { - // TODO: more graceful handling of error and failures log.Debug().Err(err).Msg("invalid api key credentials") c.JSON(http.StatusForbidden, api.ErrorResponse("invalid credentials")) return } // Create the access and refresh tokens and return them. - // TODO: add the organization ID to the claims claims := &tokens.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Subject: apikey.ID.String(), }, + OrgID: apikey.OrgID.String(), ProjectID: apikey.ProjectID.String(), } // Add the key permissions to the claims. - // NOTE: these should have been fetched on the first query. + // NOTE: these should have been fetched on the first query and cached. if claims.Permissions, err = apikey.Permissions(c.Request.Context(), false); err != nil { log.Error().Err(err).Msg("could not fetch api key permissions") c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not create credentials")) @@ -300,8 +322,13 @@ func (s *Server) Refresh(c *gin.Context) { } // get the user from the database using the ID - user, err := models.GetUser(c, claims.Subject) + user, err := models.GetUser(c, claims.Subject, claims.OrgID) if err != nil { + if errors.Is(err, models.ErrNotFound) { + c.JSON(http.StatusForbidden, api.ErrorResponse("invalid credentials")) + return + } + log.Error().Err(err).Msg("could not retrieve user from claims") c.JSON(http.StatusUnauthorized, api.ErrorResponse("could not retrieve user from claims")) return @@ -309,7 +336,6 @@ func (s *Server) Refresh(c *gin.Context) { // Create a new claims object using the user retrieved from the database // Create the access and refresh tokens and return them to the user. - // TODO: add organization ID and project ID to the claims refreshClaims := &tokens.Claims{ RegisteredClaims: jwt.RegisteredClaims{ Subject: user.ID.String(), @@ -319,6 +345,15 @@ func (s *Server) Refresh(c *gin.Context) { Picture: gravatar.New(user.Email, nil), } + // Add the orgID to the claims + var orgID ulid.ULID + if orgID, err = user.OrgID(); err != nil { + log.Error().Err(err).Msg("could not get orgID from user") + c.JSON(http.StatusInternalServerError, api.ErrorResponse("could not create credentials")) + return + } + refreshClaims.OrgID = orgID.String() + // Add the user permissions to the claims. // NOTE: these should have been fetched on the first query. if refreshClaims.Permissions, err = user.Permissions(c.Request.Context(), false); err != nil { diff --git a/pkg/quarterdeck/auth_test.go b/pkg/quarterdeck/auth_test.go index 012be18ab..474a736e0 100644 --- a/pkg/quarterdeck/auth_test.go +++ b/pkg/quarterdeck/auth_test.go @@ -5,7 +5,11 @@ import ( "net/http" "time" + "github.com/oklog/ulid/v2" "github.com/rotationalio/ensign/pkg/quarterdeck/api/v1" + "github.com/rotationalio/ensign/pkg/quarterdeck/db/models" + "github.com/rotationalio/ensign/pkg/quarterdeck/permissions" + ulids "github.com/rotationalio/ensign/pkg/utils/ulid" ) func (s *quarterdeckTestSuite) TestRegister() { @@ -14,7 +18,6 @@ func (s *quarterdeckTestSuite) TestRegister() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // TODO: only happy path test is implemented; implement error paths as well. req := &api.RegisterRequest{ Name: "Rachel Johnson", Email: "rachel@example.com", @@ -29,12 +32,46 @@ func (s *quarterdeckTestSuite) TestRegister() { rep, err := s.client.Register(ctx, req) require.NoError(err, "unable to create user from valid request") - require.NotEmpty(rep.ID, "did not get a user ID back from the database") + require.False(ulids.IsZero(rep.ID), "did not get a user ID back from the database") + require.False(ulids.IsZero(rep.OrgID), "did not get back an orgID from the database") require.Equal(req.Email, rep.Email) require.Equal("Welcome to Ensign!", rep.Message) + require.Equal(rep.Role, permissions.RoleOwner) require.NotEmpty(rep.Created, "did not get a created timestamp back") - // TODO: test that the user actually made it into the database + // Test that the user actually made it into the database + user, err := models.GetUser(context.Background(), rep.ID, rep.OrgID) + require.NoError(err, "could not get user from database") + require.Equal(rep.Email, user.Email, "user creation check failed") + + // Test error paths + // Test password mismatch + req.PwCheck = "notthe same" + _, err = s.client.Register(ctx, req) + s.CheckError(err, http.StatusBadRequest, "passwords do not match") + + // Test no agreement + req.PwCheck = req.Password + req.AgreeToS = false + _, err = s.client.Register(ctx, req) + s.CheckError(err, http.StatusBadRequest, "missing required field: terms_agreement") + + // Test no email address + req.AgreeToS = true + req.Email = "" + _, err = s.client.Register(ctx, req) + s.CheckError(err, http.StatusBadRequest, "missing required field: email") + + // Test cannot create existing user + req.Email = "jannel@example.com" + _, err = s.client.Register(ctx, req) + s.CheckError(err, http.StatusConflict, "user or organization already exists") + + // Test cannot create existing organization + req.Email = "freddy@example.com" + req.Domain = "example.com" + _, err = s.client.Register(ctx, req) + s.CheckError(err, http.StatusConflict, "user or organization already exists") } func (s *quarterdeckTestSuite) TestLogin() { @@ -42,10 +79,75 @@ func (s *quarterdeckTestSuite) TestLogin() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // TODO: actually implement the login test! - req := &api.LoginRequest{} - _, err := s.client.Login(ctx, req) - require.Error(err, "expected bad request") + // Test Happy Path: user and password expected to be in database fixtures. + req := &api.LoginRequest{ + Email: "jannel@example.com", + Password: "theeaglefliesatmidnight", + } + tokens, err := s.client.Login(ctx, req) + require.NoError(err, "was unable to login with valid credentials, have fixtures changed?") + require.NotEmpty(tokens.AccessToken, "missing access token in response") + require.NotEmpty(tokens.RefreshToken, "missing refresh token in response") + + // Validate claims are as expected + claims, err := s.srv.VerifyToken(tokens.AccessToken) + require.NoError(err, "could not verify token") + require.Equal("01GKHJSK7CZW0W282ZN3E9W86Z", claims.Subject) + require.Equal("Jannel P. Hudson", claims.Name) + require.Equal("jannel@example.com", claims.Email) + require.NotEmpty(claims.Picture) + require.Equal("01GKHJRF01YXHZ51YMMKV3RCMK", claims.OrgID) + require.Len(claims.Permissions, 18) + + // Test password incorrect + req.Password = "this is not the right password" + _, err = s.client.Login(ctx, req) + s.CheckError(err, http.StatusForbidden, "invalid login credentials") + + // Test email and password are required + _, err = s.client.Login(ctx, &api.LoginRequest{Email: "jannel@example.com"}) + s.CheckError(err, http.StatusBadRequest, "missing credentials") + + _, err = s.client.Login(ctx, &api.LoginRequest{Password: "theeaglefliesatmidnight"}) + s.CheckError(err, http.StatusBadRequest, "missing credentials") + + // Test user not found + _, err = s.client.Login(ctx, &api.LoginRequest{Email: "jonsey@example.com", Password: "logmeinplease"}) + s.CheckError(err, http.StatusForbidden, "invalid login credentials") +} + +func (s *quarterdeckTestSuite) TestLoginMultiOrg() { + require := s.Require() + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Test Happy Path: user and password expected to be in database fixtures. + req := &api.LoginRequest{ + Email: "zendaya@testing.io", + Password: "iseeallthings", + OrgID: ulid.MustParse("01GKHJRF01YXHZ51YMMKV3RCMK"), + } + + tokens, err := s.client.Login(ctx, req) + require.NoError(err, "was unable to login with valid credentials, have fixtures changed?") + + claims, err := s.srv.VerifyToken(tokens.AccessToken) + require.NoError(err, "could not verify token") + + require.Equal("01GKHJRF01YXHZ51YMMKV3RCMK", claims.OrgID) + require.Len(claims.Permissions, 6) + + // Should be able to log into a different organization now + req.OrgID = ulid.MustParse("01GQFQ14HXF2VC7C1HJECS60XX") + + tokens, err = s.client.Login(ctx, req) + require.NoError(err, "was unable to login with valid credentials, have fixtures changed?") + + claims, err = s.srv.VerifyToken(tokens.AccessToken) + require.NoError(err, "could not verify token") + + require.Equal("01GQFQ14HXF2VC7C1HJECS60XX", claims.OrgID) + require.Len(claims.Permissions, 13) } func (s *quarterdeckTestSuite) TestAuthenticate() { @@ -53,53 +155,85 @@ func (s *quarterdeckTestSuite) TestAuthenticate() { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - // TODO: actually implement the authenticate test! - req := &api.APIAuthentication{} - _, err := s.client.Authenticate(ctx, req) - require.Error(err, "expected bad request") + // Test Happy Path: user and password expected to be in database fixtures. + req := &api.APIAuthentication{ + ClientID: "DbIxBEtIUgNIClnFMDmvoZeMrLxUTJVa", + ClientSecret: "wAfRpXLTiWn7yo7HQzOCwxMvveqiHXoeVJghlSIK2YbMqOMCUiSVRVQOLT0ORrVS", + } + tokens, err := s.client.Authenticate(ctx, req) + require.NoError(err, "was unable to authenticate with valid api credentials, have fixtures changed?") + require.NotEmpty(tokens.AccessToken, "missing access token in response") + require.NotEmpty(tokens.RefreshToken, "missing refresh token in response") + + // Validate claims are as expected + claims, err := s.srv.VerifyToken(tokens.AccessToken) + require.NoError(err, "could not verify token") + require.Equal("01GME02TJP2RRP39MKR525YDQ6", claims.Subject) + require.Empty(claims.Name) + require.Empty(claims.Email) + require.Empty(claims.Picture) + require.Equal("01GKHJRF01YXHZ51YMMKV3RCMK", claims.OrgID) + require.Equal("01GQ7P8DNR9MR64RJR9D64FFNT", claims.ProjectID) + require.Len(claims.Permissions, 5) + + // Test client secret incorrect + req.ClientSecret = "this is not the right secret" + _, err = s.client.Authenticate(ctx, req) + s.CheckError(err, http.StatusForbidden, "invalid credentials") + + // Test email and password are required + _, err = s.client.Authenticate(ctx, &api.APIAuthentication{ClientID: "DbIxBEtIUgNIClnFMDmvoZeMrLxUTJVa"}) + s.CheckError(err, http.StatusBadRequest, "missing credentials") + + _, err = s.client.Authenticate(ctx, &api.APIAuthentication{ClientSecret: "wAfRpXLTiWn7yo7HQzOCwxMvveqiHXoeVJghlSIK2YbMqOMCUiSVRVQOLT0ORrVS"}) + s.CheckError(err, http.StatusBadRequest, "missing credentials") + + // Test user not found + _, err = s.client.Authenticate(ctx, &api.APIAuthentication{ClientID: "PBWNdzLwHpcgVBEhocVtRcCWShAYVefe", ClientSecret: "hvXZZcouqH9SKnT6meloCYn2IvkOhYfXuxJzb8Wy9w690BGOKBP0VjQ9vrdv0spI"}) + s.CheckError(err, http.StatusForbidden, "invalid credentials") } func (s *quarterdeckTestSuite) TestRefresh() { - defer s.ResetDatabase() require := s.Require() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() + // Test Happy Path: user and password expected to be in database fixtures. + req := &api.LoginRequest{ + Email: "jannel@example.com", + Password: "theeaglefliesatmidnight", + } + tokens, err := s.client.Login(ctx, req) + require.NoError(err, "could not login user to begin authenticate tests, have fixtures changed?") + require.NotEmpty(tokens.RefreshToken, "no refresh token returned") + + // Get the claims from the refresh token + origClaims, err := s.srv.VerifyToken(tokens.AccessToken) + require.NoError(err, "could not verify refresh token") + + // Refresh the access and refresh tokens + newTokens, err := s.client.Refresh(ctx, &api.RefreshRequest{RefreshToken: tokens.RefreshToken}) + require.NoError(err, "could not refresh credentials with refresh token") + require.NotEmpty(newTokens.AccessToken) + + require.NotEqual(tokens.AccessToken, newTokens.AccessToken) + require.NotEqual(tokens.RefreshToken, newTokens.RefreshToken) + + claims, err := s.srv.VerifyToken(newTokens.AccessToken) + require.NoError(err, "could not verify new access token") + + require.Equal(origClaims.Subject, claims.Subject) + require.Equal(origClaims.Name, claims.Name) + require.Equal(origClaims.Email, claims.Email) + require.Equal(origClaims.Picture, claims.Picture) + require.Equal(origClaims.OrgID, claims.OrgID) + require.Equal(origClaims.ProjectID, claims.ProjectID) + // Test empty RefreshRequest returns error - req := &api.RefreshRequest{} - _, err := s.client.Refresh(ctx, req) + _, err = s.client.Refresh(ctx, &api.RefreshRequest{}) s.CheckError(err, http.StatusBadRequest, "missing credentials") // Test invalid refresh token returns error - req = &api.RefreshRequest{RefreshToken: "refresh"} - _, err = s.client.Refresh(ctx, req) + _, err = s.client.Refresh(ctx, &api.RefreshRequest{RefreshToken: "refresh"}) s.CheckError(err, http.StatusUnauthorized, "could not verify refresh token") - - // Happy path test - registerReq := &api.RegisterRequest{ - Name: "Raquel Johnson", - Email: "raquelel@example.com", - Password: "supers4cretSquirrel?", - PwCheck: "supers4cretSquirrel?", - Organization: "Financial Services Ltd", - Domain: "financial-services", - AgreeToS: true, - AgreePrivacy: true, - } - registerRep, err := s.client.Register(ctx, registerReq) - require.NoError(err) - loginReq := &api.LoginRequest{ - Email: registerRep.Email, - Password: "supers4cretSquirrel?", - } - loginRep, err := s.client.Login(ctx, loginReq) - require.NoError(err) - refreshReq := &api.RefreshRequest{ - RefreshToken: loginRep.RefreshToken, - } - refreshRep, err := s.client.Refresh(ctx, refreshReq) - require.NoError(err, "could not create credentials") - require.NotNil(refreshRep) - require.NotEqual(loginRep.AccessToken, refreshRep.AccessToken) - require.NotEqual(loginRep.RefreshToken, refreshRep.RefreshToken) } diff --git a/pkg/quarterdeck/db/models/apikeys.go b/pkg/quarterdeck/db/models/apikeys.go index ed59f482d..b0eeb828c 100644 --- a/pkg/quarterdeck/db/models/apikeys.go +++ b/pkg/quarterdeck/db/models/apikeys.go @@ -441,7 +441,6 @@ func (k *APIKey) Update(ctx context.Context) (err error) { // Validate an API key is ready to be inserted into the database. Note that this // validation does not perform database constraint validation such as if the permission // foreign keys exist in the database, uniqueness, or not null checks. -// TODO: should we validate timestamps? func (k *APIKey) Validate() error { if ulids.IsZero(k.ID) { return invalid(ErrMissingModelID) diff --git a/pkg/quarterdeck/db/models/count.go b/pkg/quarterdeck/db/models/count.go new file mode 100644 index 000000000..f1bb05288 --- /dev/null +++ b/pkg/quarterdeck/db/models/count.go @@ -0,0 +1,49 @@ +package models + +import ( + "context" + "database/sql" + + "github.com/rotationalio/ensign/pkg/quarterdeck/db" +) + +const ( + countUsersSQL = "SELECT count(id) FROM users" + countOrgsSQL = "SELECT count(id) FROM organizations" +) + +// CountUsers returns the number of users currently in the database. +func CountUsers(ctx context.Context) (count int64, err error) { + var tx *sql.Tx + if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { + return 0, err + } + defer tx.Rollback() + + if err = tx.QueryRow(countUsersSQL).Scan(&count); err != nil { + return 0, err + } + + if err = tx.Commit(); err != nil { + return 0, err + } + return count, nil +} + +// CountOrganizations returns the number of organizations currently in the database. +func CountOrganizations(ctx context.Context) (count int64, err error) { + var tx *sql.Tx + if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { + return 0, err + } + defer tx.Rollback() + + if err = tx.QueryRow(countOrgsSQL).Scan(&count); err != nil { + return 0, err + } + + if err = tx.Commit(); err != nil { + return 0, err + } + return count, nil +} diff --git a/pkg/quarterdeck/db/models/count_test.go b/pkg/quarterdeck/db/models/count_test.go new file mode 100644 index 000000000..c53beb98b --- /dev/null +++ b/pkg/quarterdeck/db/models/count_test.go @@ -0,0 +1,29 @@ +package models_test + +import ( + "context" + + "github.com/rotationalio/ensign/pkg/quarterdeck/db/models" +) + +// If the fixtures change, these will need to be updated. +var ( + nUserFixtures = int64(3) + nOrganizationFixtures = int64(3) +) + +func (m *modelTestSuite) TestCountUsers() { + require := m.Require() + + nUsers, err := models.CountUsers(context.Background()) + require.NoError(err, "could not count the number of users") + require.Equal(nUserFixtures, nUsers, "unexpected number of users returned, have the fixtures changed?") +} + +func (m *modelTestSuite) TestCountOrganizations() { + require := m.Require() + + nOrgs, err := models.CountOrganizations(context.Background()) + require.NoError(err, "could not count the number of organizations") + require.Equal(nOrganizationFixtures, nOrgs, "unexpected number of organizations returned, have the fixtures changed?") +} diff --git a/pkg/quarterdeck/db/models/orgs.go b/pkg/quarterdeck/db/models/orgs.go index 97278e794..59376741c 100644 --- a/pkg/quarterdeck/db/models/orgs.go +++ b/pkg/quarterdeck/db/models/orgs.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "errors" - "fmt" "time" "github.com/mattn/go-sqlite3" @@ -35,6 +34,9 @@ type OrganizationUser struct { OrgID ulid.ULID UserID ulid.ULID RoleID int64 + user *User + org *Organization + role *Role } // OrganizationProject is a model representing the many-to-one mapping between projects @@ -56,15 +58,8 @@ const ( func GetOrg(ctx context.Context, id any) (org *Organization, err error) { org = &Organization{} - switch t := id.(type) { - case string: - if org.ID, err = ulid.Parse(t); err != nil { - return nil, err - } - case ulid.ULID: - org.ID = t - default: - return nil, fmt.Errorf("unknown type %T for org id", t) + if org.ID, err = ulids.Parse(id); err != nil { + return nil, err } var tx *sql.Tx @@ -73,10 +68,7 @@ func GetOrg(ctx context.Context, id any) (org *Organization, err error) { } defer tx.Rollback() - if err = tx.QueryRow(getOrgSQL, sql.Named("id", org.ID)).Scan(&org.Name, &org.Domain, &org.Created, &org.Modified); err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } + if err = org.populate(tx); err != nil { return nil, err } @@ -138,6 +130,21 @@ func (o *Organization) create(tx *sql.Tx) (err error) { return nil } +func (o *Organization) populate(tx *sql.Tx) (err error) { + if ulids.IsZero(o.ID) { + return ErrMissingModelID + } + + if err = tx.QueryRow(getOrgSQL, sql.Named("id", o.ID)).Scan(&o.Name, &o.Domain, &o.Created, &o.Modified); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + + return nil +} + const ( orgExistsSQL = "SELECT EXISTS(SELECT 1 FROM organizations WHERE id=:orgID)" ) @@ -229,3 +236,71 @@ func (op *OrganizationProject) exists(tx *sql.Tx) (ok bool, err error) { } return ok, nil } + +const ( + getOrgUserSQL = "SELECT role_id, created, modified FROM organization_users WHERE user_id=:userID AND organization_id=:orgID" +) + +func GetOrgUser(ctx context.Context, userID, orgID any) (ou *OrganizationUser, err error) { + ou = &OrganizationUser{} + if ou.UserID, err = ulids.Parse(userID); err != nil { + return nil, err + } + if ou.OrgID, err = ulids.Parse(orgID); err != nil { + return nil, err + } + + var tx *sql.Tx + if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { + return nil, err + } + defer tx.Rollback() + + if err = tx.QueryRow(getOrgUserSQL, sql.Named("userID", ou.UserID), sql.Named("orgID", ou.OrgID)).Scan(&ou.RoleID, &ou.Created, &ou.Modified); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, err + } + return ou, nil +} + +// Returns the user associated with the OrganizationUser struct, ready to query with +// the given organization. This object is cached on the struct and can be refreshed. +// TODO: fetch on GetOrgUser to reduce number of raft queries. +func (o *OrganizationUser) User(ctx context.Context, refresh bool) (_ *User, err error) { + if refresh || o.user == nil { + if o.user, err = GetUser(ctx, o.UserID, o.OrgID); err != nil { + return nil, err + } + } + return o.user, nil +} + +// Returns the organization associated with the OrganizationUser struct. The object is +// cached on the struct and can be refreshed on demand. +// TODO: fetch on GetOrgUser to reduce number of raft queries. +func (o *OrganizationUser) Organization(ctx context.Context, refresh bool) (_ *Organization, err error) { + if refresh || o.org == nil { + if o.org, err = GetOrg(ctx, o.OrgID); err != nil { + return nil, err + } + } + return o.org, nil +} + +// Returns the role associated with the organization and user. The object is cached on +// the struct and can be refreshed on demand. +// TODO: fetch on GetOrgUser to reduce number of raft queries. +func (o *OrganizationUser) Role(ctx context.Context, refresh bool) (_ *Role, err error) { + if refresh || o.role == nil { + if o.role, err = GetRole(ctx, o.RoleID); err != nil { + return nil, err + } + } + return o.role, nil +} diff --git a/pkg/quarterdeck/db/models/roles.go b/pkg/quarterdeck/db/models/roles.go index 8fbaa29af..e5a6439d2 100644 --- a/pkg/quarterdeck/db/models/roles.go +++ b/pkg/quarterdeck/db/models/roles.go @@ -1,7 +1,11 @@ package models import ( + "context" "database/sql" + "errors" + + "github.com/rotationalio/ensign/pkg/quarterdeck/db" ) // Role is a model that represents a row in the roles table and provides database @@ -12,6 +16,7 @@ type Role struct { ID int64 Name string Description sql.NullString + permissions []*Permission } // Permission is a model that represents a row in the permissions table and provides @@ -34,3 +39,80 @@ type RolePermission struct { RoleID int64 PermissionID int64 } + +const ( + getRoleSQL = "SELECT name, description, created, modified FROM roles WHERE id=:roleID" + getRolePermsSQL = "SELECT p.id, p.name, p.description, p.allow_api_keys, p.allow_roles, p.created, p.modified FROM role_permissions rp JOIN permissions p ON rp.permission_id=p.id WHERE rp.role_id=:roleID" +) + +func GetRole(ctx context.Context, roleID int64) (role *Role, err error) { + role = &Role{ + ID: roleID, + } + + var tx *sql.Tx + if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { + return nil, err + } + defer tx.Rollback() + + if err = tx.QueryRow(getRoleSQL, sql.Named("roleID", role.ID)).Scan(&role.Name, &role.Description, &role.Created, &role.Modified); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + + if err = role.fetchPermissions(tx); err != nil { + return nil, err + } + + if err = tx.Commit(); err != nil { + return nil, err + } + return role, nil +} + +func (r *Role) Permissions(ctx context.Context, refresh bool) (_ []*Permission, err error) { + if refresh || len(r.permissions) == 0 { + var tx *sql.Tx + if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { + return nil, err + } + defer tx.Rollback() + + if err = r.fetchPermissions(tx); err != nil { + return nil, err + } + + tx.Commit() + } + return r.permissions, nil +} + +func (r *Role) fetchPermissions(tx *sql.Tx) (err error) { + if r.ID == 0 { + return ErrMissingModelID + } + + r.permissions = make([]*Permission, 0) + + var rows *sql.Rows + if rows, err = tx.Query(getRolePermsSQL, sql.Named("roleID", r.ID)); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + defer rows.Close() + + for rows.Next() { + p := &Permission{} + if err = rows.Scan(&p.ID, &p.Name, &p.Description, &p.AllowAPIKeys, &p.AllowRoles, &p.Created, &p.Modified); err != nil { + return err + } + r.permissions = append(r.permissions, p) + } + + return nil +} diff --git a/pkg/quarterdeck/db/models/testdata/fixtures.sql b/pkg/quarterdeck/db/models/testdata/fixtures.sql index f276684b6..4aec2cb7b 100644 --- a/pkg/quarterdeck/db/models/testdata/fixtures.sql +++ b/pkg/quarterdeck/db/models/testdata/fixtures.sql @@ -1,22 +1,29 @@ -- ULID example.com: 01GKHJRF01YXHZ51YMMKV3RCMK -- ULID checkers.io: 01GQFQ14HXF2VC7C1HJECS60XX +-- ULID empty.fr: 01GQZAC80RAZ1XQJKRZJ2R4KNJ INSERT INTO organizations (id, name, domain, created, modified) VALUES (x'0184e32c3c01f763f287d4a4f63c3293', 'Testing', 'example.com', '2022-12-05T16:43:57.825256Z', '2022-12-05T16:43:57.825256Z'), - (x'0185df70923d78b6c3b03193999303bd', 'Checkers', 'checkers.io', '2023-01-23T16:22:54.781762Z', '2023-01-23T16:22:54.781762Z') + (x'0185df70923d78b6c3b03193999303bd', 'Checkers', 'checkers.io', '2023-01-23T16:22:54.781762Z', '2023-01-23T16:22:54.781762Z'), + (x'0185fea6201857c3dbca78fc85824eb2', 'Empty', 'empty.fr', '2023-01-29T17:49:38.200949Z', '2023-01-29T17:49:38.200949Z') ; -- Jannel ULID: 01GKHJSK7CZW0W282ZN3E9W86Z -- Jannel Password: theeaglefliesatmidnight -- Edison ULID: 01GQFQ4475V3BZDMSXFV5DK6XX -- Edison Password: supersecretssquirrel +-- Zendaya ULID: 01GQYYKY0ECGWT5VJRVR32MFHM +-- Zendaya Password: iseeallthings INSERT INTO users (id, name, email, password, terms_agreement, privacy_agreement, last_login, created, modified) VALUES (x'0184e32cccecff01c1205fa8dc9e20df', 'Jannel P. Hudson', 'jannel@example.com', '$argon2id$v=19$m=65536,t=1,p=2$Ujy6FI2NBqRIUHmqH0YcQA==$f1lwLv4DpE4OTkMq3sTShZS3NHADg9UvnZNHtuUOmZ8=', 't', 't', '2022-12-13T01:22:39Z', '2022-12-05T16:44:34.924036Z', '2022-12-05T16:44:34.924036Z'), - (x'0185df7210e5d8d7f6d33d7ecad99bbd', 'Edison Edgar Franklin', 'eefrank@checkers.io', '$argon2id$v=19$m=65536,t=1,p=2$x4Zh4ARSD4wK7uZFaauyjg==$eCkUszypW+rLvQ+D9lpfTgVwqPSKH13rCdmzV9vZ8cQ=', 't', 't', '2023-02-14T14:48:08Z', '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z') + (x'0185df7210e5d8d7f6d33d7ecad99bbd', 'Edison Edgar Franklin', 'eefrank@checkers.io', '$argon2id$v=19$m=65536,t=1,p=2$x4Zh4ARSD4wK7uZFaauyjg==$eCkUszypW+rLvQ+D9lpfTgVwqPSKH13rCdmzV9vZ8cQ=', 't', 't', '2023-02-14T14:48:08Z', '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z'), + (x'0185fde9f80e6439a2ee58de062a3e34', 'Zendaya Longeye', 'zendaya@testing.io', '$argon2id$v=19$m=65536,t=1,p=2$rQMSo/Lksd+/DazFmcuu4Q==$GtZGSh9SajnzXp/Cd8h/zpzgXrw4coXhRz/DhnG7GEU=', 't', 't', '2023-02-14T08:09:48.739212Z', '2023-01-29T14:24:07.182624Z', '2023-01-29T14:24:07.182624Z') ; INSERT INTO organization_users (organization_id, user_id, role_id, created, modified) VALUES (x'0184e32c3c01f763f287d4a4f63c3293', x'0184e32cccecff01c1205fa8dc9e20df', 1, '2022-12-05T16:44:35.00123Z', '2022-12-05T16:44:35.00123Z'), - (x'0185df70923d78b6c3b03193999303bd', x'0185df7210e5d8d7f6d33d7ecad99bbd', 1, '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z') + (x'0185df70923d78b6c3b03193999303bd', x'0185df7210e5d8d7f6d33d7ecad99bbd', 1, '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z'), + (x'0184e32c3c01f763f287d4a4f63c3293', x'0185fde9f80e6439a2ee58de062a3e34', 4, '2023-01-29T14:24:07.182624Z', '2023-01-29T14:24:07.182624Z'), + (x'0185df70923d78b6c3b03193999303bd', x'0185fde9f80e6439a2ee58de062a3e34', 3, '2023-01-29T14:24:07.182624Z', '2023-01-29T14:24:07.182624Z') ; INSERT INTO organization_projects (organization_id, project_id, created, modified) VALUES diff --git a/pkg/quarterdeck/db/models/users.go b/pkg/quarterdeck/db/models/users.go index a817e1111..0a0289668 100644 --- a/pkg/quarterdeck/db/models/users.go +++ b/pkg/quarterdeck/db/models/users.go @@ -4,9 +4,9 @@ import ( "context" "database/sql" "errors" - "fmt" "time" + "github.com/mattn/go-sqlite3" "github.com/oklog/ulid/v2" "github.com/rotationalio/ensign/pkg/quarterdeck/db" "github.com/rotationalio/ensign/pkg/quarterdeck/passwd" @@ -18,7 +18,15 @@ import ( // serialization. Users may be retrieved from the database either via their ID (e.g. // from the sub claim in a JWT token) or via their email address (e.g. on login). The // user password should be stored as an argon2 hash and should be verified using the -// argon2 hashing algorithm. +// argon2 hashing algorithm. Care should be taken to ensure this model stays secure. +// +// Users are associated with one or more organizations. When the user model is loaded +// from the database one organization must be supplied so that permissions and role +// can be retrieved correctly. If no orgID is supplied then one of the user's +// organizations is selected from the database as the default organiztion. Use the +// SwitchOrganization method to switch the user model to a different organization to +// retrieve a different and permissions or use the UserRoles method to determine which +// organizations the user belongs to. type User struct { Base ID ulid.ULID @@ -28,6 +36,7 @@ type User struct { AgreeToS sql.NullBool AgreePrivacy sql.NullBool LastLogin sql.NullString + orgID ulid.ULID orgRoles map[ulid.ULID]string permissions []string } @@ -35,42 +44,27 @@ type User struct { const ( getUserIDSQL = "SELECT name, email, password, terms_agreement, privacy_agreement, last_login, created, modified FROM users WHERE id=:id" getUserEmailSQL = "SELECT id, name, password, terms_agreement, privacy_agreement, last_login, created, modified FROM users WHERE email=:email" - countUsersSQL = "SELECT count(id) FROM users" ) -// CountUsers returns the number of users currently in the database. -func CountUsers(ctx context.Context) (count int64, err error) { - var tx *sql.Tx - if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { - return 0, err - } - defer tx.Rollback() - - if err = tx.QueryRow(countUsersSQL).Scan(&count); err != nil { - return 0, err - } - - if err = tx.Commit(); err != nil { - return 0, err - } - return count, nil -} +//=========================================================================== +// Retrieve Users from Database +//=========================================================================== // GetUser by ID. The ID can be either a string, which is parsed into a ULID or it can // be a valid ULID. The query is then executed as a read-only transaction against the -// database and the user is returned. -func GetUser(ctx context.Context, id any) (u *User, err error) { +// database and the user is returned. An orgID can be specified to load the user in that +// organization. If the orgID is Null then one of the organizations the user belongs to +// is loaded (the default user organization). +func GetUser(ctx context.Context, userID, orgID any) (u *User, err error) { // Create the user struct and parse the ID input. u = &User{} - switch t := id.(type) { - case string: - if u.ID, err = ulid.Parse(t); err != nil { - return nil, err - } - case ulid.ULID: - u.ID = t - default: - return nil, fmt.Errorf("unknown type %T for user id", t) + if u.ID, err = ulids.Parse(userID); err != nil { + return nil, err + } + + var userOrg ulid.ULID + if userOrg, err = ulids.Parse(orgID); err != nil { + return nil, err } var tx *sql.Tx @@ -86,8 +80,11 @@ func GetUser(ctx context.Context, id any) (u *User, err error) { return nil, err } - // Cache permissions on the user - if err = u.fetchPermissions(tx); err != nil { + // Load user in the specified organization or default organization if null is + // specified; this also verifies the user is part of the organization and caches + // the organizations and roles the user belongs to as well as the permissions of + // the current organization. + if err = u.loadOrganization(tx, userOrg); err != nil { return nil, err } @@ -97,9 +94,17 @@ func GetUser(ctx context.Context, id any) (u *User, err error) { return u, nil } -// GetUser by Email. This query is executed as a read-only transaction. -func GetUserEmail(ctx context.Context, email string) (u *User, err error) { +// GetUser by Email. This query is executed as a read-only transaction. An orgID can be +// specified to load the user in that organization. If the orgID is Null then one of the +// organizations the user belongs to is loaded (the default user organization). +func GetUserEmail(ctx context.Context, email string, orgID any) (u *User, err error) { u = &User{Email: email} + + var userOrg ulid.ULID + if userOrg, err = ulids.Parse(orgID); err != nil { + return nil, err + } + var tx *sql.Tx if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { return nil, err @@ -113,8 +118,11 @@ func GetUserEmail(ctx context.Context, email string) (u *User, err error) { return nil, err } - // Cache permissions on the user - if err = u.fetchPermissions(tx); err != nil { + // Load user in the specified organization or default organization if null is + // specified; this also verifies the user is part of the organization and caches + // the organizations and roles the user belongs to as well as the permissions of + // the current organization. + if err = u.loadOrganization(tx, userOrg); err != nil { return nil, err } @@ -124,6 +132,10 @@ func GetUserEmail(ctx context.Context, email string) (u *User, err error) { return u, nil } +//=========================================================================== +// Create, Update, and Validate the User Model +//=========================================================================== + const ( insertUserSQL = "INSERT INTO users (id, name, email, password, terms_agreement, privacy_agreement, last_login, created, modified) VALUES (:id, :name, :email, :password, :agreeTerms, :agreePrivacy, :lastLogin, :created, :modified)" insertUserOrgSQL = "INSERT INTO organization_users (user_id, organization_id, role_id, created, modified) VALUES (:userID, :orgID, (SELECT id FROM roles WHERE name=:role), :created, :modified)" @@ -164,6 +176,12 @@ func (u *User) Create(ctx context.Context, org *Organization, role string) (err params[8] = sql.Named("modified", u.Modified) if _, err = tx.Exec(insertUserSQL, params...); err != nil { + var dberr sqlite3.Error + if errors.As(err, &dberr) { + if dberr.Code == sqlite3.ErrConstraint { + return constraint(dberr) + } + } return err } @@ -173,6 +191,10 @@ func (u *User) Create(ctx context.Context, org *Organization, role string) (err if err = org.create(tx); err != nil { return err } + } else { + if err = org.populate(tx); err != nil { + return err + } } // Associate the user and the organization with the specified role @@ -185,8 +207,23 @@ func (u *User) Create(ctx context.Context, org *Organization, role string) (err // Associate the user and the role if _, err = tx.Exec(insertUserOrgSQL, orguser...); err != nil { + var dberr sqlite3.Error + if errors.As(err, &dberr) { + if dberr.Code == sqlite3.ErrConstraint { + return constraint(dberr) + } + } + return err + } + + // Load user in the specified organization or default organization if null is + // specified; this also verifies the user is part of the organization and caches + // the organizations and roles the user belongs to as well as the permissions of + // the current organization. + if err = u.loadOrganization(tx, org.ID); err != nil { return err } + return tx.Commit() } @@ -194,10 +231,10 @@ const ( updateUserSQL = "UPDATE users SET name=:name, email=:email, password=:password, terms_agreement=:agreeToS, privacy_agreement=:agreePrivacy, last_login=:lastLogin, modified=:modified WHERE id=:id" ) -// Save a user's name, email, password, and last login. The modified timestamp is set to -// the current time and neither the ID nor the created timestamp is modified. This query -// is executed as a write-transaction. The user must be fully populated and exist in -// the database for this method to execute successfully. +// Save a user's name, email, password, agreements, and last login. The modified +// timestamp is set to the current time and neither the ID nor the created timestamp are +// modified. This query is executed as a write-transaction. The user must be fully +// populated and exist in the database for this method to execute successfully. func (u *User) Save(ctx context.Context) (err error) { if err = u.Validate(); err != nil { return err @@ -226,27 +263,20 @@ func (u *User) Save(ctx context.Context) (err error) { return tx.Commit() } -// GetLastLogin returns the parsed LastLogin timestamp if it is not null. If it is null -// then a zero-valued timestamp is returned without an error. -func (u *User) GetLastLogin() (time.Time, error) { - if u.LastLogin.Valid { - return time.Parse(time.RFC3339Nano, u.LastLogin.String) +// Validate that the user should be inserted or updated into the database. +func (u *User) Validate() error { + if ulids.IsZero(u.ID) { + return invalid(ErrMissingModelID) } - return time.Time{}, nil -} -// SetLastLogin ensures the LastLogin timestamp is serialized to a string correctly. -func (u *User) SetLastLogin(ts time.Time) { - u.LastLogin = sql.NullString{ - Valid: true, - String: ts.Format(time.RFC3339Nano), + if u.Email == "" || u.Password == "" { + return invalid(ErrInvalidUser) } -} -// SetAgreement marks if the user has accepted the terms of service and privacy policy. -func (u *User) SetAgreement(agreeToS, agreePrivacy bool) { - u.AgreeToS = sql.NullBool{Valid: true, Bool: agreeToS} - u.AgreePrivacy = sql.NullBool{Valid: true, Bool: agreePrivacy} + if !passwd.IsDerivedKey(u.Password) { + return invalid(ErrInvalidPassword) + } + return nil } const ( @@ -272,24 +302,118 @@ func (u *User) UpdateLastLogin(ctx context.Context) (err error) { return tx.Commit() } -// Validate that the user should be inserted or updated into the database. -func (u *User) Validate() error { - if ulids.IsZero(u.ID) { - return invalid(ErrMissingModelID) +//=========================================================================== +// User Organization Management +//=========================================================================== + +// OrgID returns the organization id that the user was loaded for. If the model doesn't +// have an orgID then an error is returned. This method requires that the user was +// loaded using one of the fetch and catch methods such as GetUserID or that the +// SwitchOrganization method was used to load the user. +func (u *User) OrgID() (ulid.ULID, error) { + if ulids.IsZero(u.orgID) { + return ulids.Null, ErrMissingOrgID } + return u.orgID, nil +} - if u.Email == "" || u.Password == "" { - return invalid(ErrInvalidUser) +// Role returns the current role for the user in the organization the user was loaded +// for. If the model does not have an orgID or the user doesn't belong to the +// organization then an error is returned. This method requires that the UserRoles have +// been fetched and cached (e.g. that the user was retrieved from the database with an +// organization or that SwitchOrganization) was used. +func (u *User) Role() (role string, _ error) { + if ulids.IsZero(u.orgID) { + return "", ErrMissingOrgID } - if !passwd.IsDerivedKey(u.Password) { - return invalid(ErrInvalidPassword) + var ok bool + if role, ok = u.orgRoles[u.orgID]; !ok { + return "", ErrUserOrganization + } + return role, nil +} + +// SwitchOrganization loads the user role and permissions for the specified organization +// returning an error if the user is not in the specified organization. +func (u *User) SwitchOrganization(ctx context.Context, orgID any) (err error) { + var userOrg ulid.ULID + if userOrg, err = ulids.Parse(orgID); err != nil { + return err + } + + if ulids.IsZero(userOrg) { + return ErrMissingOrgID + } + + var tx *sql.Tx + if tx, err = db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}); err != nil { + return err + } + defer tx.Rollback() + + if err = u.loadOrganization(tx, userOrg); err != nil { + return err + } + + return tx.Commit() +} + +// Fetches the organization roles and permissions for the specified orgID. If the orgID +// is Null then one of the user's organizations is used. If the user is not part of the +// organization with that orgID then an error is returned and the orgID on the user is +// not set or changed. This method is used by the GetUser methods as well as the +// SwitchOrganization method but with two different external contexts and validations. +func (u *User) loadOrganization(tx *sql.Tx, orgID ulid.ULID) (err error) { + // If no orgID is specified, fetch the "default orgID" + if ulids.IsZero(orgID) { + if orgID, err = u.defaultOrganization(tx); err != nil { + return err + } + } + + // Decache the current roles and load them again + u.orgRoles = nil + if err = u.fetchRoles(tx); err != nil { + return err + } + + // If the user is in the specified organization set the orgID, otherwise error. + if _, ok := u.orgRoles[orgID]; !ok { + return ErrUserOrganization + } + u.orgID = orgID + + // Decache the current permissions and load them again + u.permissions = nil + if err = u.fetchPermissions(tx); err != nil { + return err } return nil } const ( - getUserRolesSQL = "SELECT ur.organization_id, r.name FROM organization_users WHERE user_id=:userID" + getDefaultOrgSQL = "SELECT organization_id FROM organization_users WHERE user_id=:userID LIMIT 1" +) + +// Fetch the default organization for the user. This method returns at most one orgID, +// even if the user belongs to multiple organizations. It is not guaranteed that +// multiple calls to this method will return the same orgID. If the user doesn't exist +// or is not assigned to an organization an error is returned. +// TODO: right now the first organization is returned, use last logged in organization. +func (u *User) defaultOrganization(tx *sql.Tx) (orgID ulid.ULID, err error) { + if err = tx.QueryRow(getDefaultOrgSQL, sql.Named("userID", u.ID)).Scan(&orgID); err != nil { + return orgID, err + } + return orgID, nil +} + +//=========================================================================== +// Cacheing Database Queries +//=========================================================================== + +const ( + getUserRolesSQL = "SELECT ur.organization_id, r.name FROM organization_users ur JOIN roles r ON ur.role_id=r.id WHERE user_id=:userID" ) // Returns the name of the user role associated with the user for the specified @@ -341,7 +465,7 @@ func (u *User) fetchRoles(tx *sql.Tx) (err error) { } const ( - getUserPermsSQL = "SELECT permission FROM user_permissions WHERE user_id=:userID" + getUserPermsSQL = "SELECT permission FROM user_permissions WHERE user_id=:userID AND organization_id=:orgID" ) // Returns the Permissions associated with the user as a list of strings. @@ -364,10 +488,14 @@ func (u *User) Permissions(ctx context.Context, refresh bool) (_ []string, err e } func (u *User) fetchPermissions(tx *sql.Tx) (err error) { + if ulids.IsZero(u.orgID) { + return ErrMissingOrgID + } + u.permissions = make([]string, 0) var rows *sql.Rows - if rows, err = tx.Query(getUserPermsSQL, sql.Named("userID", u.ID)); err != nil { + if rows, err = tx.Query(getUserPermsSQL, sql.Named("userID", u.ID), sql.Named("orgID", u.orgID)); err != nil { return err } @@ -382,3 +510,30 @@ func (u *User) fetchPermissions(tx *sql.Tx) (err error) { return rows.Err() } + +//=========================================================================== +// Field Helper Methods +//=========================================================================== + +// GetLastLogin returns the parsed LastLogin timestamp if it is not null. If it is null +// then a zero-valued timestamp is returned without an error. +func (u *User) GetLastLogin() (time.Time, error) { + if u.LastLogin.Valid { + return time.Parse(time.RFC3339Nano, u.LastLogin.String) + } + return time.Time{}, nil +} + +// SetLastLogin ensures the LastLogin timestamp is serialized to a string correctly. +func (u *User) SetLastLogin(ts time.Time) { + u.LastLogin = sql.NullString{ + Valid: true, + String: ts.Format(time.RFC3339Nano), + } +} + +// SetAgreement marks if the user has accepted the terms of service and privacy policy. +func (u *User) SetAgreement(agreeToS, agreePrivacy bool) { + u.AgreeToS = sql.NullBool{Valid: true, Bool: agreeToS} + u.AgreePrivacy = sql.NullBool{Valid: true, Bool: agreePrivacy} +} diff --git a/pkg/quarterdeck/db/models/users_test.go b/pkg/quarterdeck/db/models/users_test.go index 65f81cb57..b712e28e0 100644 --- a/pkg/quarterdeck/db/models/users_test.go +++ b/pkg/quarterdeck/db/models/users_test.go @@ -2,13 +2,13 @@ package models_test import ( "context" - "database/sql" "testing" "time" "github.com/oklog/ulid/v2" "github.com/rotationalio/ensign/pkg/quarterdeck/db/models" "github.com/rotationalio/ensign/pkg/quarterdeck/passwd" + ulids "github.com/rotationalio/ensign/pkg/utils/ulid" "github.com/stretchr/testify/require" ) @@ -16,89 +16,337 @@ import ( func (m *modelTestSuite) TestGetUser() { require := m.Require() - // Test get by ID string - user, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z") - require.NoError(err, "could not fetch user by string ID") - require.NotNil(user) - require.Equal("01GKHJSK7CZW0W282ZN3E9W86Z", user.ID.String()) - require.True(user.AgreeToS.Valid && user.AgreeToS.Bool) - require.True(user.AgreePrivacy.Valid && user.AgreePrivacy.Bool) - require.Equal("Jannel P. Hudson", user.Name) + testCases := []struct { + userID any + orgID any + err error + validateFields bool + }{ + // Test GetUser by userID string and default org + {"01GKHJSK7CZW0W282ZN3E9W86Z", ulids.Null, nil, true}, + + // Test GetUser by userID ULID and default org + {ulid.MustParse("01GKHJSK7CZW0W282ZN3E9W86Z"), ulids.Null, nil, true}, + + // Test GetUser by string with specified OrgID + {"01GQYYKY0ECGWT5VJRVR32MFHM", "01GQFQ14HXF2VC7C1HJECS60XX", nil, true}, + + // Test GetUser by ULIDs with specified OrgID + {ulid.MustParse("01GQYYKY0ECGWT5VJRVR32MFHM"), ulid.MustParse("01GQFQ14HXF2VC7C1HJECS60XX"), nil, true}, + + // Should not be able to pass an integer in as the userID + {42, ulids.Null, ulids.ErrUnknownType, false}, + + // Test cannot parse ID + {"zedy", ulids.Null, ulid.ErrDataSize, false}, + {"01GQYYKY0ECGWT5VJRVR32MFHM", "zedy", ulid.ErrDataSize, false}, - // Test get by ULID - user2, err := models.GetUser(context.Background(), ulid.MustParse("01GKHJSK7CZW0W282ZN3E9W86Z")) - require.NoError(err, "could not fetch user by ulid") - require.Equal("01GKHJSK7CZW0W282ZN3E9W86Z", user2.ID.String()) - require.True(user2.AgreeToS.Valid && user2.AgreeToS.Bool) - require.True(user2.AgreePrivacy.Valid && user2.AgreePrivacy.Bool) - require.Equal(user, user2) - - // Ensure we cannot fetch a user by integer - _, err = models.GetUser(context.Background(), 1) - require.Error(err, "should not be able to pass a number in as an ID") - - // Test get by email - user3, err := models.GetUserEmail(context.Background(), "jannel@example.com") - require.NoError(err, "could not fetch user by email") - require.Equal("01GKHJSK7CZW0W282ZN3E9W86Z", user3.ID.String()) - require.True(user3.AgreeToS.Valid && user3.AgreeToS.Bool) - require.True(user3.AgreePrivacy.Valid && user3.AgreePrivacy.Bool) - require.Equal(user, user3) - - // Test Not Found by ID - _, err = models.GetUser(context.Background(), "01GKHKS95XD0J25GHR14KT3WX1") - require.ErrorIs(err, models.ErrNotFound, "should return not found error") - - _, err = models.GetUserEmail(context.Background(), "notvalid@testing.io") - require.ErrorIs(err, models.ErrNotFound, "should return not found error") - - // Test cannot parse ULID - _, err = models.GetUser(context.Background(), "zedy") - require.EqualError(err, "ulid: bad data size when unmarshaling") + // Test Not Found by userID + {"01GKHKS95XD0J25GHR14KT3WX1", ulids.Null, models.ErrNotFound, false}, + + // Test Not Found by null ID + {ulids.Null, ulids.Null, models.ErrNotFound, false}, + + // Test User not in organization + {"01GQYYKY0ECGWT5VJRVR32MFHM", "01GKHKS95XD0J25GHR14KT3WX1", models.ErrUserOrganization, false}, + } + + for _, tc := range testCases { + user, err := models.GetUser(context.Background(), tc.userID, tc.orgID) + require.ErrorIs(err, tc.err) + + if tc.validateFields { + // Ensure all fields are returned and not zero valued + require.False(ulids.IsZero(user.ID)) + require.NotEmpty(user.Name) + require.NotEmpty(user.Email) + require.NotEmpty(user.Password) + require.True(user.AgreeToS.Valid && user.AgreeToS.Bool) + require.True(user.AgreePrivacy.Valid && user.AgreePrivacy.Bool) + require.True(user.LastLogin.Valid && user.LastLogin.String != "") + require.NotEmpty(user.Created) + require.NotEmpty(user.Modified) + + orgID, err := user.OrgID() + require.NoError(err, "could not fetch orgID from user") + require.False(ulids.IsZero(orgID)) + + role, err := user.Role() + require.NoError(err, "could not fetch role from user") + require.NotEmpty(role) + + perms, err := user.Permissions(context.Background(), false) + require.NoError(err, "could not fetch permissions for user") + require.NotEmpty(perms) + } + } } -func (m *modelTestSuite) TestUserCreate() { +func (m *modelTestSuite) TestGetUserEmail() { + require := m.Require() + + testCases := []struct { + email string + orgID any + err error + validateFields bool + }{ + // Test GetUser by email and default org + {"jannel@example.com", ulids.Null, nil, true}, + + // Test GetUser by string with specified OrgID + {"jannel@example.com", "01GKHJRF01YXHZ51YMMKV3RCMK", nil, true}, + + // Test GetUser by ULIDs with specified OrgID + {"jannel@example.com", ulid.MustParse("01GKHJRF01YXHZ51YMMKV3RCMK"), nil, true}, + + // Test cannot parse org ID + {"jannel@example.com", "zedy", ulid.ErrDataSize, false}, + + // Test Not Found by email address + {"notvalid@esting.io", ulids.Null, models.ErrNotFound, false}, + + // Test Not Found by empty email address + {"", ulids.Null, models.ErrNotFound, false}, + + // Test User not in organization + {"jannel@example.com", "01GKHKS95XD0J25GHR14KT3WX1", models.ErrUserOrganization, false}, + } + + for i, tc := range testCases { + user, err := models.GetUserEmail(context.Background(), tc.email, tc.orgID) + require.ErrorIs(err, tc.err, "could not get user for test %d", i) + + if tc.validateFields { + // Ensure all fields are returned and not zero valued + require.False(ulids.IsZero(user.ID)) + require.NotEmpty(user.Name) + require.NotEmpty(user.Email) + require.NotEmpty(user.Password) + require.True(user.AgreeToS.Valid && user.AgreeToS.Bool) + require.True(user.AgreePrivacy.Valid && user.AgreePrivacy.Bool) + require.True(user.LastLogin.Valid && user.LastLogin.String != "") + require.NotEmpty(user.Created) + require.NotEmpty(user.Modified) + + orgID, err := user.OrgID() + require.NoError(err, "could not fetch orgID from user") + require.False(ulids.IsZero(orgID)) + + role, err := user.Role() + require.NoError(err, "could not fetch role from user") + require.NotEmpty(role) + + perms, err := user.Permissions(context.Background(), false) + require.NoError(err, "could not fetch permissions for user") + require.NotEmpty(perms) + } + } +} + +func (m *modelTestSuite) TestGetUserMultiOrg() { + require := m.Require() + testCases := []struct { + userID any + orgID string + email string + role string + }{ + {"01GQYYKY0ECGWT5VJRVR32MFHM", "01GKHJRF01YXHZ51YMMKV3RCMK", "zendaya@testing.io", "Observer"}, + {"01GQYYKY0ECGWT5VJRVR32MFHM", "01GQFQ14HXF2VC7C1HJECS60XX", "zendaya@testing.io", "Member"}, + } + + for _, tc := range testCases { + // Test GetUser by ID + user, err := models.GetUser(context.Background(), tc.userID, tc.orgID) + require.NoError(err) + + orgID, _ := user.OrgID() + require.Equal(tc.orgID, orgID.String()) + + role, _ := user.Role() + require.Equal(tc.role, role) + + // Test GetUser by email + user, err = models.GetUserEmail(context.Background(), tc.email, tc.orgID) + require.NoError(err) + + orgID, _ = user.OrgID() + require.Equal(tc.orgID, orgID.String()) + + role, _ = user.Role() + require.Equal(tc.role, role) + } +} + +func (m *modelTestSuite) TestUserCreateNewOrg() { defer m.ResetDB() require := m.Require() - // Ensure the original user count is as expected - count, err := models.CountUsers(context.Background()) + // Ensure the original user and organization count is as expected + nUsers, err := models.CountUsers(context.Background()) require.NoError(err, "could not count users") - require.Equal(int64(2), count, "unexpected user fixtures count") + require.Equal(nUserFixtures, nUsers, "unexpected user fixtures count") + + nOrgs, err := models.CountOrganizations(context.Background()) + require.NoError(err, "could not count orgs") + require.Equal(nOrganizationFixtures, nOrgs, "unexpected organization fixtures count") - // Create a user + // Create a user with as minimal information as possible. user := &models.User{ - Name: "Angelica Hudson", - Email: "hudson@example.com", - Password: "$argon2id$v=19$m=65536,t=1,p=2$xto5+nlVR9oyc6CpJR1MtQ==$KToxSO2i3H6KmD8th1FiP1jh/JvDUOfdtMtj5g1Ilnk=", - AgreeToS: sql.NullBool{Valid: true, Bool: true}, - AgreePrivacy: sql.NullBool{Valid: true, Bool: true}, + Name: "Angelica Hudson", + Email: "hudson@example.com", + Password: "$argon2id$v=19$m=65536,t=1,p=2$xto5+nlVR9oyc6CpJR1MtQ==$KToxSO2i3H6KmD8th1FiP1jh/JvDUOfdtMtj5g1Ilnk=", } + + user.SetAgreement(true, true) + + // This organization should not exist in the database org := &models.Organization{ Name: "Testing Organization", Domain: "testing", } + // Create the user, the organization, and associate them with the role "Admin" require.NoError(user.Create(context.Background(), org, "Admin"), "could not create user") - // Ensure that an ID, created, and modified timestamps were created - require.NotEqual(0, user.ID.Compare(ulid.ULID{})) + // Ensure that an ID, created, and modified timestamps on the user were created + require.False(ulids.IsZero(user.ID)) require.NotZero(user.Created) require.NotZero(user.Modified) + // Ensure that an ID, created, and modified timestamps on the org were created + require.False(ulids.IsZero(org.ID)) + require.NotZero(org.Created) + require.NotZero(org.Modified) + // Ensure that the number of users in the database has increased - count, err = models.CountUsers(context.Background()) + nUsers, err = models.CountUsers(context.Background()) require.NoError(err, "could not count users") - require.Equal(int64(3), count, "user count not increased after create") + require.Equal(nUserFixtures+1, nUsers, "user count not increased after create") - // TODO: Ensure that the user's role has been created + // Ensure the number of organizations in the database have been increased + nOrgs, err = models.CountOrganizations(context.Background()) + require.NoError(err, "could not count organizations") + require.Equal(nOrganizationFixtures+1, nOrgs, "organization count not increased after create") + + // Check that the user has been assigned the organization that was created + userOrg, _ := user.OrgID() + require.Equal(org.ID, userOrg) + + // Check that the organization and user are linked with a role + our, err := models.GetOrgUser(context.Background(), user.ID, org.ID) + require.NoError(err, "could not fetch organization user mapping with role") + + cmpuser, err := our.User(context.Background(), false) + require.NoError(err, "could not get user to compare") + require.Equal(user, cmpuser) + + cmporg, err := our.Organization(context.Background(), false) + require.NoError(err, "could not get organization to compare") + require.Equal(org, cmporg) + + role, err := our.Role(context.Background(), false) + require.NoError(err, "could not get user role fom database") + require.Equal("Admin", role.Name) + + userPerms, err := user.Permissions(context.Background(), false) + require.NoError(err, "could not get user permissions") + rolePerms, err := role.Permissions(context.Background(), false) + require.NoError(err, "could not get role permissions") + + require.Equal(len(userPerms), len(rolePerms), "user and role permissions do not match") + for _, perm := range rolePerms { + require.Contains(userPerms, perm.Name) + } +} + +func (m *modelTestSuite) TestUserCreateExistingOrg() { + defer m.ResetDB() + require := m.Require() + + // Ensure the original user and organization count is as expected + nUsers, err := models.CountUsers(context.Background()) + require.NoError(err, "could not count users") + require.Equal(nUserFixtures, nUsers, "unexpected user fixtures count") + + nOrgs, err := models.CountOrganizations(context.Background()) + require.NoError(err, "could not count orgs") + require.Equal(nOrganizationFixtures, nOrgs, "unexpected organization fixtures count") + + // Create a user with as minimal information as possible. + user := &models.User{ + Name: "Angelica Hudson", + Email: "hudson@example.com", + Password: "$argon2id$v=19$m=65536,t=1,p=2$xto5+nlVR9oyc6CpJR1MtQ==$KToxSO2i3H6KmD8th1FiP1jh/JvDUOfdtMtj5g1Ilnk=", + } + + user.SetAgreement(true, true) + + // This organization should not exist in the database + org := &models.Organization{ + ID: ulid.MustParse("01GQFQ14HXF2VC7C1HJECS60XX"), + } + + // Create the user, the organization, and associate them with the role "Member" + require.NoError(user.Create(context.Background(), org, "Member"), "could not create user") + + // Ensure that an ID, created, and modified timestamps on the user were created + require.False(ulids.IsZero(user.ID)) + require.NotZero(user.Created) + require.NotZero(user.Modified) + + // Ensure that an ID, created, and modified timestamps on the org were created + require.False(ulids.IsZero(org.ID)) + require.NotZero(org.Created) + require.NotZero(org.Modified) + + // Ensure that the number of users in the database has increased + nUsers, err = models.CountUsers(context.Background()) + require.NoError(err, "could not count users") + require.Equal(nUserFixtures+1, nUsers, "user count not increased after create") + + // Ensure the number of organizations in the database have been increased + nOrgs, err = models.CountOrganizations(context.Background()) + require.NoError(err, "could not count organizations") + require.Equal(nOrganizationFixtures, nOrgs, "organization count not increased after create") + + // Check that the user has been assigned the organization that was created + userOrg, _ := user.OrgID() + require.Equal(org.ID, userOrg) + + // Check that the organization and user are linked with a role + our, err := models.GetOrgUser(context.Background(), user.ID, org.ID) + require.NoError(err, "could not fetch organization user mapping with role") + + cmpuser, err := our.User(context.Background(), false) + require.NoError(err, "could not get user to compare") + require.Equal(user, cmpuser) + + cmporg, err := our.Organization(context.Background(), false) + require.NoError(err, "could not get organization to compare") + require.Equal(org, cmporg) + + role, err := our.Role(context.Background(), false) + require.NoError(err, "could not get user role fom database") + require.Equal("Member", role.Name) + + userPerms, err := user.Permissions(context.Background(), false) + require.NoError(err, "could not get user permissions") + rolePerms, err := role.Permissions(context.Background(), false) + require.NoError(err, "could not get role permissions") + + require.Equal(len(userPerms), len(rolePerms), "user and role permissions do not match") + for _, perm := range rolePerms { + require.Contains(userPerms, perm.Name) + } } func (m *modelTestSuite) TestUserSave() { defer m.ResetDB() require := m.Require() - user, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z") + user, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z", ulid.ULID{}) require.NoError(err, "could not fetch user by string ID") require.Equal("Jannel P. Hudson", user.Name) @@ -116,7 +364,7 @@ func (m *modelTestSuite) TestUserSave() { err = user.Save(context.Background()) require.NoError(err, "could not update user") - cmpr, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z") + cmpr, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z", ulid.ULID{}) require.NoError(err, "could not fetch user by string ID") // Everything but modified should be the same on compare @@ -133,18 +381,18 @@ func (m *modelTestSuite) TestUserUpdateLastLogin() { defer m.ResetDB() require := m.Require() - user, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z") + user, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z", ulid.ULID{}) require.NoError(err, "could not fetch user by string ID") // The user pointer will be modified so get a second copy for comparison - prev, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z") + prev, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z", ulid.ULID{}) require.NoError(err, "could not fetch user by string ID") err = user.UpdateLastLogin(context.Background()) require.NoError(err, "could not update last login: %+v", err) // Fetch the record from the database for comparison purposes. - cmpr, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z") + cmpr, err := models.GetUser(context.Background(), "01GKHJSK7CZW0W282ZN3E9W86Z", ulid.ULID{}) require.NoError(err, "could not fetch user by string ID") // Nothing but last login and modified should have changed. @@ -195,6 +443,68 @@ func TestUserLastLogin(t *testing.T) { require.True(t, now.Equal(ts)) } +func (m *modelTestSuite) TestUserSwitchOrganization() { + require := m.Require() + + // A zero-valued user cannot switch organizations + user := &models.User{} + require.ErrorIs(user.SwitchOrganization(context.Background(), "01GKHJRF01YXHZ51YMMKV3RCMK"), models.ErrUserOrganization) + + // Get the user in their first organization + user, err := models.GetUser(context.Background(), "01GQYYKY0ECGWT5VJRVR32MFHM", "01GKHJRF01YXHZ51YMMKV3RCMK") + require.NoError(err, "could not fetch multi-org user from database") + + orgID, err := user.OrgID() + require.NoError(err, "could not fetch orgID from user") + require.Equal(ulid.MustParse("01GKHJRF01YXHZ51YMMKV3RCMK"), orgID) + + role, err := user.Role() + require.NoError(err, "Could not fetch role from user") + require.Equal("Observer", role) + + // Should not be able to switch into an organization that does not exist + err = user.SwitchOrganization(context.Background(), "01GQZAE0GQRGB37RA1R3SR5XVH") + require.ErrorIs(err, models.ErrUserOrganization) + + // Should not be able to switch into an organization the user doesn't belong to + err = user.SwitchOrganization(context.Background(), "01GQZAC80RAZ1XQJKRZJ2R4KNJ") + require.ErrorIs(err, models.ErrUserOrganization) + + // Should not be able to switch organizations if the orgId doesn't parse + err = user.SwitchOrganization(context.Background(), "zeddy") + require.ErrorIs(err, ulid.ErrDataSize) + + // Switch user to a valid other organization + err = user.SwitchOrganization(context.Background(), "01GQFQ14HXF2VC7C1HJECS60XX") + require.NoError(err, "could not switch the user's organization") + + orgID, err = user.OrgID() + require.NoError(err, "could not fetch orgID from user") + require.Equal(ulid.MustParse("01GQFQ14HXF2VC7C1HJECS60XX"), orgID) + + role, err = user.Role() + require.NoError(err, "Could not fetch role from user") + require.Equal("Member", role) + +} + +func (m *modelTestSuite) TestUserRole() { + require := m.Require() + + // Create a user with only a user ID + userID := ulid.MustParse("01GKHJSK7CZW0W282ZN3E9W86Z") + user := &models.User{ID: userID} + + // Fetch the organization roles for the user + role, err := user.UserRole(context.Background(), ulid.MustParse("01GKHJRF01YXHZ51YMMKV3RCMK"), false) + require.NoError(err, "could not fetch user role for organization") + require.Equal(role, "Owner") + + // Should not be able to fetch role that doesn't exist + _, err = user.UserRole(context.Background(), ulid.MustParse("01GQZ77GJ4700TP8N6QXHQEBVF"), false) + require.ErrorIs(err, models.ErrUserOrganization) +} + func (m *modelTestSuite) TestUserPermissions() { require := m.Require() @@ -202,6 +512,13 @@ func (m *modelTestSuite) TestUserPermissions() { userID := ulid.MustParse("01GKHJSK7CZW0W282ZN3E9W86Z") user := &models.User{ID: userID} + // An organization ID is required to fetch permission + _, err := user.Permissions(context.Background(), false) + require.ErrorIs(err, models.ErrMissingOrgID) + + // Add the organization to the user + user.SwitchOrganization(context.Background(), "01GKHJRF01YXHZ51YMMKV3RCMK") + // Fetch the permissions for the user permissions, err := user.Permissions(context.Background(), false) require.NoError(err, "could not fetch permissions for user") diff --git a/pkg/quarterdeck/quarterdeck.go b/pkg/quarterdeck/quarterdeck.go index 4d4e7a996..2527ee91e 100644 --- a/pkg/quarterdeck/quarterdeck.go +++ b/pkg/quarterdeck/quarterdeck.go @@ -348,3 +348,12 @@ func (s *Server) CreateTokenPair(claims *tokens.Claims) (string, string) { } return "", "" } + +// VerifyToken extracts the claims from an access or refresh token returned by the +// server. This is only available if the server is in testing mode. +func (s *Server) VerifyToken(tks string) (*tokens.Claims, error) { + if s.conf.Mode == gin.TestMode { + return s.tokens.Verify(tks) + } + return nil, errors.New("can only use this method in test mode") +} diff --git a/pkg/quarterdeck/testdata/fixtures.sql b/pkg/quarterdeck/testdata/fixtures.sql index f276684b6..06ecc38cf 100644 --- a/pkg/quarterdeck/testdata/fixtures.sql +++ b/pkg/quarterdeck/testdata/fixtures.sql @@ -9,14 +9,19 @@ INSERT INTO organizations (id, name, domain, created, modified) VALUES -- Jannel Password: theeaglefliesatmidnight -- Edison ULID: 01GQFQ4475V3BZDMSXFV5DK6XX -- Edison Password: supersecretssquirrel +-- Zendaya ULID: 01GQYYKY0ECGWT5VJRVR32MFHM +-- Zendaya Password: iseeallthings INSERT INTO users (id, name, email, password, terms_agreement, privacy_agreement, last_login, created, modified) VALUES (x'0184e32cccecff01c1205fa8dc9e20df', 'Jannel P. Hudson', 'jannel@example.com', '$argon2id$v=19$m=65536,t=1,p=2$Ujy6FI2NBqRIUHmqH0YcQA==$f1lwLv4DpE4OTkMq3sTShZS3NHADg9UvnZNHtuUOmZ8=', 't', 't', '2022-12-13T01:22:39Z', '2022-12-05T16:44:34.924036Z', '2022-12-05T16:44:34.924036Z'), - (x'0185df7210e5d8d7f6d33d7ecad99bbd', 'Edison Edgar Franklin', 'eefrank@checkers.io', '$argon2id$v=19$m=65536,t=1,p=2$x4Zh4ARSD4wK7uZFaauyjg==$eCkUszypW+rLvQ+D9lpfTgVwqPSKH13rCdmzV9vZ8cQ=', 't', 't', '2023-02-14T14:48:08Z', '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z') + (x'0185df7210e5d8d7f6d33d7ecad99bbd', 'Edison Edgar Franklin', 'eefrank@checkers.io', '$argon2id$v=19$m=65536,t=1,p=2$x4Zh4ARSD4wK7uZFaauyjg==$eCkUszypW+rLvQ+D9lpfTgVwqPSKH13rCdmzV9vZ8cQ=', 't', 't', '2023-02-14T14:48:08Z', '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z'), + (x'0185fde9f80e6439a2ee58de062a3e34', 'Zendaya Longeye', 'zendaya@testing.io', '$argon2id$v=19$m=65536,t=1,p=2$rQMSo/Lksd+/DazFmcuu4Q==$GtZGSh9SajnzXp/Cd8h/zpzgXrw4coXhRz/DhnG7GEU=', 't', 't', '2023-02-14T08:09:48.739212Z', '2023-01-29T14:24:07.182624Z', '2023-01-29T14:24:07.182624Z') ; INSERT INTO organization_users (organization_id, user_id, role_id, created, modified) VALUES (x'0184e32c3c01f763f287d4a4f63c3293', x'0184e32cccecff01c1205fa8dc9e20df', 1, '2022-12-05T16:44:35.00123Z', '2022-12-05T16:44:35.00123Z'), - (x'0185df70923d78b6c3b03193999303bd', x'0185df7210e5d8d7f6d33d7ecad99bbd', 1, '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z') + (x'0185df70923d78b6c3b03193999303bd', x'0185df7210e5d8d7f6d33d7ecad99bbd', 1, '2023-01-23T16:24:32.741955Z', '2023-01-23T16:24:32.741955Z'), + (x'0184e32c3c01f763f287d4a4f63c3293', x'0185fde9f80e6439a2ee58de062a3e34', 4, '2023-01-29T14:24:07.182624Z', '2023-01-29T14:24:07.182624Z'), + (x'0185df70923d78b6c3b03193999303bd', x'0185fde9f80e6439a2ee58de062a3e34', 3, '2023-01-29T14:24:07.182624Z', '2023-01-29T14:24:07.182624Z') ; INSERT INTO organization_projects (organization_id, project_id, created, modified) VALUES diff --git a/pkg/utils/ulid/ulid.go b/pkg/utils/ulid/ulid.go index eaf06bcf7..7d2fdf810 100644 --- a/pkg/utils/ulid/ulid.go +++ b/pkg/utils/ulid/ulid.go @@ -8,6 +8,7 @@ package ulid import ( "crypto/rand" + "errors" "io" "time" @@ -15,7 +16,9 @@ import ( ) // The source of entropy used for all ULIDs generated by this package. This variable is -// initialized when the package is first imported. +// initialized when the package is first imported. This package ensures that a +// cryptographic random number generator is used with a locked montonic source so that +// ulids are always increasing in a thread-safe manner. var entropy io.Reader func init() { @@ -27,10 +30,20 @@ func init() { // Null ULID pre-allocated for easier checking. var Null = ulid.ULID{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00} +var ( + ErrUnknownType = errors.New("cannot parse input: unknown type") +) + +// Determines if the specified uid is the Null or zero-valued ULID. Useful for +// determining if a ULID has been passed into a method or is valid. func IsZero(uid ulid.ULID) bool { return uid.Compare(Null) == 0 } +// New creates a new montonically increasing ULID in a threadsafe manner using a +// cryptographically random source of entropy for security (e.g. to ensure that an +// attacker cannot guess the next ULID to be generated). If the ULID cannot be generated +// this method panics rather than returning an error. func New() ulid.ULID { ms := ulid.Timestamp(time.Now()) uid, err := ulid.New(ms, entropy) @@ -39,3 +52,27 @@ func New() ulid.ULID { } return uid } + +// Parse a ULID from a string or a []byte (or return a ulid.ULID). This method makes it +// easier to convert any user-specified type into a ULID. +func Parse(uid any) (ulid.ULID, error) { + switch t := uid.(type) { + case ulid.ULID: + return t, nil + case string: + if t == "" { + return Null, nil + } + return ulid.Parse(t) + case []byte: + var id ulid.ULID + if err := id.UnmarshalBinary(t); err != nil { + return Null, err + } + return id, nil + case [16]byte: + return ulid.ULID(t), nil + default: + return Null, ErrUnknownType + } +} diff --git a/pkg/utils/ulid/ulid_test.go b/pkg/utils/ulid/ulid_test.go index bc933b3bf..10d7dadff 100644 --- a/pkg/utils/ulid/ulid_test.go +++ b/pkg/utils/ulid/ulid_test.go @@ -38,3 +38,29 @@ func TestNew(t *testing.T) { } wg.Wait() } + +func TestParse(t *testing.T) { + example := ulidlib.New() + + testCases := []struct { + input any + expected ulid.ULID + err error + }{ + {example.String(), example, nil}, + {example.Bytes(), example, nil}, + {example, example, nil}, + {[16]byte(example), example, nil}, + {"", ulidlib.Null, nil}, + {uint64(14), ulidlib.Null, ulidlib.ErrUnknownType}, + {"foo", ulidlib.Null, ulid.ErrDataSize}, + {[]byte{0x14, 0x21}, ulidlib.Null, ulid.ErrDataSize}, + {ulidlib.Null.String(), ulidlib.Null, nil}, + } + + for i, tc := range testCases { + actual, err := ulidlib.Parse(tc.input) + require.ErrorIs(t, err, tc.err, "could not compare error on test case %d", i) + require.Equal(t, tc.expected, actual, "expected result not returned") + } +}