mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add "dormant" user state (#8644)
This commit is contained in:
Generated
+2
@@ -10227,10 +10227,12 @@ const docTemplate = `{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"dormant",
|
||||
"suspended"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"UserStatusActive",
|
||||
"UserStatusDormant",
|
||||
"UserStatusSuspended"
|
||||
]
|
||||
},
|
||||
|
||||
Generated
+6
-2
@@ -9256,8 +9256,12 @@
|
||||
},
|
||||
"codersdk.UserStatus": {
|
||||
"type": "string",
|
||||
"enum": ["active", "suspended"],
|
||||
"x-enum-varnames": ["UserStatusActive", "UserStatusSuspended"]
|
||||
"enum": ["active", "dormant", "suspended"],
|
||||
"x-enum-varnames": [
|
||||
"UserStatusActive",
|
||||
"UserStatusDormant",
|
||||
"UserStatusSuspended"
|
||||
]
|
||||
},
|
||||
"codersdk.ValidationError": {
|
||||
"type": "object",
|
||||
|
||||
@@ -587,6 +587,14 @@ func createAnotherUserRetry(t *testing.T, client *codersdk.Client, organizationI
|
||||
sessionToken = token.Key
|
||||
}
|
||||
|
||||
if user.Status == codersdk.UserStatusDormant {
|
||||
// Use admin client so that user's LastSeenAt is not updated.
|
||||
// In general we need to refresh the user status, which should
|
||||
// transition from "dormant" to "active".
|
||||
user, err = client.User(context.Background(), user.Username)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
other := codersdk.New(client.URL)
|
||||
other.SetSessionToken(sessionToken)
|
||||
t.Cleanup(func() {
|
||||
|
||||
@@ -2099,6 +2099,13 @@ func (q *querier) UpdateGroupByID(ctx context.Context, arg database.UpdateGroupB
|
||||
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateGroupByID)(ctx, arg)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) {
|
||||
if err := q.authorizeContext(ctx, rbac.ActionCreate, rbac.ResourceSystem); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return q.db.UpdateInactiveUsersToDormant(ctx, lastSeenAfter)
|
||||
}
|
||||
|
||||
func (q *querier) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
// Authorized fetch will check that the actor has read access to the org member since the org member is returned.
|
||||
member, err := q.GetOrganizationMemberByUserID(ctx, database.GetOrganizationMemberByUserIDParams{
|
||||
|
||||
@@ -3862,7 +3862,7 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam
|
||||
CreatedAt: arg.CreatedAt,
|
||||
UpdatedAt: arg.UpdatedAt,
|
||||
Username: arg.Username,
|
||||
Status: database.UserStatusActive,
|
||||
Status: database.UserStatusDormant,
|
||||
RBACRoles: arg.RBACRoles,
|
||||
LoginType: arg.LoginType,
|
||||
}
|
||||
@@ -4337,6 +4337,29 @@ func (q *FakeQuerier) UpdateGroupByID(_ context.Context, arg database.UpdateGrou
|
||||
return database.Group{}, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) {
|
||||
q.mutex.Lock()
|
||||
defer q.mutex.Unlock()
|
||||
|
||||
var updated []database.UpdateInactiveUsersToDormantRow
|
||||
for index, user := range q.users {
|
||||
if user.Status == database.UserStatusActive && user.LastSeenAt.Before(params.LastSeenAfter) {
|
||||
q.users[index].Status = database.UserStatusDormant
|
||||
q.users[index].UpdatedAt = params.UpdatedAt
|
||||
updated = append(updated, database.UpdateInactiveUsersToDormantRow{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
LastSeenAt: user.LastSeenAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(updated) == 0 {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (q *FakeQuerier) UpdateMemberRoles(_ context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
if err := validateDatabaseType(arg); err != nil {
|
||||
return database.OrganizationMember{}, err
|
||||
|
||||
@@ -224,6 +224,13 @@ func User(t testing.TB, db database.Store, orig database.User) database.User {
|
||||
})
|
||||
require.NoError(t, err, "insert user")
|
||||
|
||||
user, err = db.UpdateUserStatus(genCtx, database.UpdateUserStatusParams{
|
||||
ID: user.ID,
|
||||
Status: database.UserStatusActive,
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
require.NoError(t, err, "insert user")
|
||||
|
||||
if !orig.LastSeenAt.IsZero() {
|
||||
user, err = db.UpdateUserLastSeenAt(genCtx, database.UpdateUserLastSeenAtParams{
|
||||
ID: user.ID,
|
||||
|
||||
@@ -1313,6 +1313,13 @@ func (m metricsStore) UpdateGroupByID(ctx context.Context, arg database.UpdateGr
|
||||
return group, err
|
||||
}
|
||||
|
||||
func (m metricsStore) UpdateInactiveUsersToDormant(ctx context.Context, lastSeenAfter database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) {
|
||||
start := time.Now()
|
||||
r0, r1 := m.s.UpdateInactiveUsersToDormant(ctx, lastSeenAfter)
|
||||
m.queryLatencies.WithLabelValues("UpdateInactiveUsersToDormant").Observe(time.Since(start).Seconds())
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
func (m metricsStore) UpdateMemberRoles(ctx context.Context, arg database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
start := time.Now()
|
||||
member, err := m.s.UpdateMemberRoles(ctx, arg)
|
||||
|
||||
@@ -2775,6 +2775,21 @@ func (mr *MockStoreMockRecorder) UpdateGroupByID(arg0, arg1 interface{}) *gomock
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateGroupByID", reflect.TypeOf((*MockStore)(nil).UpdateGroupByID), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdateInactiveUsersToDormant mocks base method.
|
||||
func (m *MockStore) UpdateInactiveUsersToDormant(arg0 context.Context, arg1 database.UpdateInactiveUsersToDormantParams) ([]database.UpdateInactiveUsersToDormantRow, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "UpdateInactiveUsersToDormant", arg0, arg1)
|
||||
ret0, _ := ret[0].([]database.UpdateInactiveUsersToDormantRow)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// UpdateInactiveUsersToDormant indicates an expected call of UpdateInactiveUsersToDormant.
|
||||
func (mr *MockStoreMockRecorder) UpdateInactiveUsersToDormant(arg0, arg1 interface{}) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateInactiveUsersToDormant", reflect.TypeOf((*MockStore)(nil).UpdateInactiveUsersToDormant), arg0, arg1)
|
||||
}
|
||||
|
||||
// UpdateMemberRoles mocks base method.
|
||||
func (m *MockStore) UpdateMemberRoles(arg0 context.Context, arg1 database.UpdateMemberRolesParams) (database.OrganizationMember, error) {
|
||||
m.ctrl.T.Helper()
|
||||
|
||||
Generated
+5
-2
@@ -113,9 +113,12 @@ CREATE TYPE startup_script_behavior AS ENUM (
|
||||
|
||||
CREATE TYPE user_status AS ENUM (
|
||||
'active',
|
||||
'suspended'
|
||||
'suspended',
|
||||
'dormant'
|
||||
);
|
||||
|
||||
COMMENT ON TYPE user_status IS 'Defines the user status: active, dormant, or suspended.';
|
||||
|
||||
CREATE TYPE workspace_agent_lifecycle_state AS ENUM (
|
||||
'created',
|
||||
'starting',
|
||||
@@ -561,7 +564,7 @@ CREATE TABLE users (
|
||||
hashed_password bytea NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
status user_status DEFAULT 'active'::user_status NOT NULL,
|
||||
status user_status DEFAULT 'dormant'::user_status NOT NULL,
|
||||
rbac_roles text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
login_type login_type DEFAULT 'password'::login_type NOT NULL,
|
||||
avatar_url text,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- It's not possible to drop enum values from enum types, so the UP has "IF NOT EXISTS"
|
||||
|
||||
UPDATE users SET status = 'active'::user_status WHERE status = 'dormant'::user_status;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TYPE user_status ADD VALUE IF NOT EXISTS 'dormant';
|
||||
COMMENT ON TYPE user_status IS 'Defines the user status: active, dormant, or suspended.';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'::user_status;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'dormant'::user_status;
|
||||
@@ -1032,11 +1032,13 @@ func AllStartupScriptBehaviorValues() []StartupScriptBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines the user status: active, dormant, or suspended.
|
||||
type UserStatus string
|
||||
|
||||
const (
|
||||
UserStatusActive UserStatus = "active"
|
||||
UserStatusSuspended UserStatus = "suspended"
|
||||
UserStatusDormant UserStatus = "dormant"
|
||||
)
|
||||
|
||||
func (e *UserStatus) Scan(src interface{}) error {
|
||||
@@ -1077,7 +1079,8 @@ func (ns NullUserStatus) Value() (driver.Value, error) {
|
||||
func (e UserStatus) Valid() bool {
|
||||
switch e {
|
||||
case UserStatusActive,
|
||||
UserStatusSuspended:
|
||||
UserStatusSuspended,
|
||||
UserStatusDormant:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -1087,6 +1090,7 @@ func AllUserStatusValues() []UserStatus {
|
||||
return []UserStatus{
|
||||
UserStatusActive,
|
||||
UserStatusSuspended,
|
||||
UserStatusDormant,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -236,6 +236,7 @@ type sqlcQuerier interface {
|
||||
UpdateGitAuthLink(ctx context.Context, arg UpdateGitAuthLinkParams) (GitAuthLink, error)
|
||||
UpdateGitSSHKey(ctx context.Context, arg UpdateGitSSHKeyParams) (GitSSHKey, error)
|
||||
UpdateGroupByID(ctx context.Context, arg UpdateGroupByIDParams) (Group, error)
|
||||
UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error)
|
||||
UpdateMemberRoles(ctx context.Context, arg UpdateMemberRolesParams) (OrganizationMember, error)
|
||||
UpdateProvisionerJobByID(ctx context.Context, arg UpdateProvisionerJobByIDParams) error
|
||||
UpdateProvisionerJobWithCancelByID(ctx context.Context, arg UpdateProvisionerJobWithCancelByIDParams) error
|
||||
|
||||
@@ -5708,6 +5708,52 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
|
||||
return i, err
|
||||
}
|
||||
|
||||
const updateInactiveUsersToDormant = `-- name: UpdateInactiveUsersToDormant :many
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
status = 'dormant'::user_status,
|
||||
updated_at = $1
|
||||
WHERE
|
||||
last_seen_at < $2 :: timestamp
|
||||
AND status = 'active'::user_status
|
||||
RETURNING id, email, last_seen_at
|
||||
`
|
||||
|
||||
type UpdateInactiveUsersToDormantParams struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
LastSeenAfter time.Time `db:"last_seen_after" json:"last_seen_after"`
|
||||
}
|
||||
|
||||
type UpdateInactiveUsersToDormantRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, arg UpdateInactiveUsersToDormantParams) ([]UpdateInactiveUsersToDormantRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, updateInactiveUsersToDormant, arg.UpdatedAt, arg.LastSeenAfter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []UpdateInactiveUsersToDormantRow
|
||||
for rows.Next() {
|
||||
var i UpdateInactiveUsersToDormantRow
|
||||
if err := rows.Scan(&i.ID, &i.Email, &i.LastSeenAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateUserDeletedByID = `-- name: UpdateUserDeletedByID :exec
|
||||
UPDATE
|
||||
users
|
||||
|
||||
@@ -250,3 +250,15 @@ SET
|
||||
WHERE
|
||||
id = $1
|
||||
RETURNING *;
|
||||
|
||||
|
||||
-- name: UpdateInactiveUsersToDormant :many
|
||||
UPDATE
|
||||
users
|
||||
SET
|
||||
status = 'dormant'::user_status,
|
||||
updated_at = @updated_at
|
||||
WHERE
|
||||
last_seen_at < @last_seen_after :: timestamp
|
||||
AND status = 'active'::user_status
|
||||
RETURNING id, email, last_seen_at;
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package dormancy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
)
|
||||
|
||||
const (
|
||||
// Time interval between consecutive job runs
|
||||
jobInterval = 15 * time.Minute
|
||||
// User accounts inactive for `accountDormancyPeriod` will be marked as dormant
|
||||
accountDormancyPeriod = 90 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// CheckInactiveUsers function updates status of inactive users from active to dormant
|
||||
// using default parameters.
|
||||
func CheckInactiveUsers(ctx context.Context, logger slog.Logger, db database.Store) func() {
|
||||
return CheckInactiveUsersWithOptions(ctx, logger, db, jobInterval, accountDormancyPeriod)
|
||||
}
|
||||
|
||||
// CheckInactiveUsersWithOptions function updates status of inactive users from active to dormant
|
||||
// using provided parameters.
|
||||
func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, db database.Store, checkInterval, dormancyPeriod time.Duration) func() {
|
||||
logger = logger.Named("dormancy")
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
ticker := time.NewTicker(checkInterval)
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
lastSeenAfter := database.Now().Add(-dormancyPeriod)
|
||||
logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter))
|
||||
|
||||
updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, database.UpdateInactiveUsersToDormantParams{
|
||||
LastSeenAfter: lastSeenAfter,
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
for _, u := range updatedUsers {
|
||||
logger.Info(ctx, "account has been marked as dormant", slog.F("email", u.Email), slog.F("last_seen_at", u.LastSeenAt))
|
||||
}
|
||||
logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers)), slog.F("execution_time", time.Since(startTime)))
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
cancelFunc()
|
||||
<-done
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package dormancy_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/moby/moby/pkg/namesgenerator"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/coderd/database"
|
||||
"github.com/coder/coder/coderd/database/dbfake"
|
||||
"github.com/coder/coder/coderd/dormancy"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestCheckInactiveUsers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Predefine job settings
|
||||
interval := time.Millisecond
|
||||
dormancyPeriod := 90 * 24 * time.Hour
|
||||
|
||||
// Add some dormant accounts
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
db := dbfake.New()
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancelFunc)
|
||||
|
||||
inactiveUser1 := setupUser(ctx, t, db, "dormant-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Minute))
|
||||
inactiveUser2 := setupUser(ctx, t, db, "dormant-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-time.Hour))
|
||||
inactiveUser3 := setupUser(ctx, t, db, "dormant-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour))
|
||||
|
||||
activeUser1 := setupUser(ctx, t, db, "active-user-1@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Minute))
|
||||
activeUser2 := setupUser(ctx, t, db, "active-user-2@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(time.Hour))
|
||||
activeUser3 := setupUser(ctx, t, db, "active-user-3@coder.com", database.UserStatusActive, time.Now().Add(-dormancyPeriod).Add(6*time.Hour))
|
||||
|
||||
suspendedUser1 := setupUser(ctx, t, db, "suspended-user-1@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Minute))
|
||||
suspendedUser2 := setupUser(ctx, t, db, "suspended-user-2@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-time.Hour))
|
||||
suspendedUser3 := setupUser(ctx, t, db, "suspended-user-3@coder.com", database.UserStatusSuspended, time.Now().Add(-dormancyPeriod).Add(-6*time.Hour))
|
||||
|
||||
// Run the periodic job
|
||||
closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, db, interval, dormancyPeriod)
|
||||
t.Cleanup(closeFunc)
|
||||
|
||||
var rows []database.GetUsersRow
|
||||
var err error
|
||||
require.Eventually(t, func() bool {
|
||||
rows, err = db.GetUsers(ctx, database.GetUsersParams{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
var dormant, suspended int
|
||||
for _, row := range rows {
|
||||
if row.Status == database.UserStatusDormant {
|
||||
dormant++
|
||||
} else if row.Status == database.UserStatusSuspended {
|
||||
suspended++
|
||||
}
|
||||
}
|
||||
// 6 users in total, 3 dormant, 3 suspended
|
||||
return len(rows) == 9 && dormant == 3 && suspended == 3
|
||||
}, testutil.WaitShort, testutil.IntervalMedium)
|
||||
|
||||
allUsers := ignoreUpdatedAt(database.ConvertUserRows(rows))
|
||||
|
||||
// Verify user status
|
||||
expectedUsers := []database.User{
|
||||
asDormant(inactiveUser1),
|
||||
asDormant(inactiveUser2),
|
||||
asDormant(inactiveUser3),
|
||||
activeUser1,
|
||||
activeUser2,
|
||||
activeUser3,
|
||||
suspendedUser1,
|
||||
suspendedUser2,
|
||||
suspendedUser3,
|
||||
}
|
||||
require.ElementsMatch(t, allUsers, expectedUsers)
|
||||
}
|
||||
|
||||
func setupUser(ctx context.Context, t *testing.T, db database.Store, email string, status database.UserStatus, lastSeenAt time.Time) database.User {
|
||||
t.Helper()
|
||||
|
||||
user, err := db.InsertUser(ctx, database.InsertUserParams{ID: uuid.New(), LoginType: database.LoginTypePassword, Username: namesgenerator.GetRandomName(8), Email: email})
|
||||
require.NoError(t, err)
|
||||
// At the beginning of the test all users are marked as active
|
||||
user, err = db.UpdateUserStatus(ctx, database.UpdateUserStatusParams{ID: user.ID, Status: status})
|
||||
require.NoError(t, err)
|
||||
user, err = db.UpdateUserLastSeenAt(ctx, database.UpdateUserLastSeenAtParams{ID: user.ID, LastSeenAt: lastSeenAt})
|
||||
require.NoError(t, err)
|
||||
return user
|
||||
}
|
||||
|
||||
func asDormant(user database.User) database.User {
|
||||
user.Status = database.UserStatusDormant
|
||||
return user
|
||||
}
|
||||
|
||||
func ignoreUpdatedAt(rows []database.User) []database.User {
|
||||
for i := range rows {
|
||||
rows[i].UpdatedAt = time.Time{}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
@@ -393,6 +393,23 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
})
|
||||
}
|
||||
|
||||
if roles.Status == database.UserStatusDormant {
|
||||
// If coder confirms that the dormant user is valid, it can switch their account to active.
|
||||
// nolint:gocritic
|
||||
u, err := cfg.DB.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
|
||||
ID: key.UserID,
|
||||
Status: database.UserStatusActive,
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
return write(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: internalErrorMessage,
|
||||
Detail: fmt.Sprintf("can't activate a dormant user: %s", err.Error()),
|
||||
})
|
||||
}
|
||||
roles.Status = u.Status
|
||||
}
|
||||
|
||||
if roles.Status != database.UserStatusActive {
|
||||
return write(http.StatusUnauthorized, codersdk.Response{
|
||||
Message: fmt.Sprintf("User is not active (status = %q). Contact an admin to reactivate your account.", roles.Status),
|
||||
|
||||
@@ -156,6 +156,13 @@ func addUser(t *testing.T, db database.Store, roles ...string) (database.User, s
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{
|
||||
ID: user.ID,
|
||||
Status: database.UserStatusActive,
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.InsertAPIKey(context.Background(), database.InsertAPIKeyParams{
|
||||
ID: id,
|
||||
UserID: user.ID,
|
||||
|
||||
@@ -48,6 +48,13 @@ func TestWorkspaceParam(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
user, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{
|
||||
ID: user.ID,
|
||||
Status: database.UserStatusActive,
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
|
||||
ID: id,
|
||||
UserID: user.ID,
|
||||
|
||||
+31
-1
@@ -320,6 +320,22 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co
|
||||
return user, database.GetAuthorizationUserRolesRow{}, false
|
||||
}
|
||||
|
||||
if user.Status == database.UserStatusDormant {
|
||||
//nolint:gocritic // System needs to update status of the user account (dormant -> active).
|
||||
user, err = api.Database.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
|
||||
ID: user.ID,
|
||||
Status: database.UserStatusActive,
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "unable to update user status to active", slog.Error(err))
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error occurred. Try again later, or contact an admin for assistance.",
|
||||
})
|
||||
return user, database.GetAuthorizationUserRolesRow{}, false
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocritic // System needs to fetch user roles in order to login user.
|
||||
roles, err := api.Database.GetAuthorizationUserRoles(dbauthz.AsSystemRestricted(ctx), user.ID)
|
||||
if err != nil {
|
||||
@@ -333,7 +349,7 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co
|
||||
// If the user logged into a suspended account, reject the login request.
|
||||
if roles.Status != database.UserStatusActive {
|
||||
httpapi.Write(ctx, rw, http.StatusUnauthorized, codersdk.Response{
|
||||
Message: "Your account is suspended. Contact an admin to reactivate your account.",
|
||||
Message: fmt.Sprintf("Your account is %s. Contact an admin to reactivate your account.", roles.Status),
|
||||
})
|
||||
return user, database.GetAuthorizationUserRolesRow{}, false
|
||||
}
|
||||
@@ -1281,6 +1297,20 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
}
|
||||
}
|
||||
|
||||
// Activate dormant user on sigin
|
||||
if user.Status == database.UserStatusDormant {
|
||||
//nolint:gocritic // System needs to update status of the user account (dormant -> active).
|
||||
user, err = tx.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
|
||||
ID: user.ID,
|
||||
Status: database.UserStatusActive,
|
||||
UpdatedAt: database.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "unable to update user status to active", slog.Error(err))
|
||||
return xerrors.Errorf("update user status: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if link.UserID == uuid.Nil {
|
||||
//nolint:gocritic
|
||||
link, err = tx.InsertUserLink(dbauthz.AsSystemRestricted(ctx), database.InsertUserLinkParams{
|
||||
|
||||
+76
-5
@@ -1048,6 +1048,35 @@ func TestPutUserSuspend(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestActivateDormantUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
|
||||
// Create users
|
||||
me := coderdtest.CreateFirstUser(t, client)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
anotherUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "coder@coder.com",
|
||||
Username: "coder",
|
||||
Password: "SomeStrongPassword!",
|
||||
OrganizationID: me.OrganizationID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Ensure that new user has dormant account
|
||||
require.Equal(t, codersdk.UserStatusDormant, anotherUser.Status)
|
||||
|
||||
// Activate user account
|
||||
_, err = client.UpdateUserStatus(ctx, anotherUser.Username, codersdk.UserStatusActive)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify if the account is active now
|
||||
anotherUser, err = client.User(ctx, anotherUser.Username)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, codersdk.UserStatusActive, anotherUser.Status)
|
||||
}
|
||||
|
||||
func TestGetUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1368,17 +1397,21 @@ func TestGetUsers(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
bruno, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "bruno@email.com",
|
||||
Username: "bruno",
|
||||
_, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Tom will be active
|
||||
tom, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "tom@email.com",
|
||||
Username: "tom",
|
||||
Password: "MySecurePassword!",
|
||||
OrganizationID: first.OrganizationID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
active = append(active, bruno)
|
||||
|
||||
_, err = client.UpdateUserStatus(ctx, alice.Username, codersdk.UserStatusSuspended)
|
||||
tom, err = client.UpdateUserStatus(ctx, tom.Username, codersdk.UserStatusActive)
|
||||
require.NoError(t, err)
|
||||
active = append(active, tom)
|
||||
|
||||
res, err := client.Users(ctx, codersdk.UsersRequest{
|
||||
Status: codersdk.UserStatusActive,
|
||||
@@ -1510,6 +1543,44 @@ func TestWorkspacesByUser(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestDormantUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// Create a new user
|
||||
newUser, err := client.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "test@coder.com",
|
||||
Username: "someone",
|
||||
Password: "MySecurePassword!",
|
||||
OrganizationID: user.OrganizationID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// User should be dormant as they haven't logged in yet
|
||||
users, err := client.Users(ctx, codersdk.UsersRequest{Search: newUser.Username})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users.Users, 1)
|
||||
require.Equal(t, codersdk.UserStatusDormant, users.Users[0].Status)
|
||||
|
||||
// User logs in now
|
||||
_, err = client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: newUser.Email,
|
||||
Password: "MySecurePassword!",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// User status should be active now
|
||||
users, err = client.Users(ctx, codersdk.UsersRequest{Search: newUser.Username})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users.Users, 1)
|
||||
require.Equal(t, codersdk.UserStatusActive, users.Users[0].Status)
|
||||
}
|
||||
|
||||
// TestSuspendedPagination is when the after_id is a suspended record.
|
||||
// The database query should still return the correct page, as the after_id
|
||||
// is in a subquery that finds the record regardless of its status.
|
||||
|
||||
Reference in New Issue
Block a user