feat: add "dormant" user state (#8644)

This commit is contained in:
Marcin Tojek 2023-08-02 16:31:25 +02:00 committed by GitHub
parent d2c7c8e1d8
commit d6e9870209
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 587 additions and 239 deletions

View File

@ -70,6 +70,7 @@ import (
"github.com/coder/coder/coderd/database/migrations"
"github.com/coder/coder/coderd/database/pubsub"
"github.com/coder/coder/coderd/devtunnel"
"github.com/coder/coder/coderd/dormancy"
"github.com/coder/coder/coderd/gitauth"
"github.com/coder/coder/coderd/gitsshkey"
"github.com/coder/coder/coderd/httpapi"
@ -812,6 +813,9 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd.
options.SwaggerEndpoint = cfg.Swagger.Enable.Value()
}
closeCheckInactiveUsersFunc := dormancy.CheckInactiveUsers(ctx, logger, options.Database)
defer closeCheckInactiveUsersFunc()
// We use a separate coderAPICloser so the Enterprise API
// can have it's own close functions. This is cleaner
// than abstracting the Coder API itself.

View File

@ -1,16 +0,0 @@
Usage: coder scaletest
Run a scale test against the Coder API
Subcommands
cleanup Cleanup scaletest workspaces, then cleanup scaletest
users.
create-workspaces Creates many users, then creates a workspace for each
user and waits for them finish building and fully come
online. Optionally runs a command inside each
workspace, and connects to the workspace over
WireGuard.
workspace-traffic Generate traffic to scaletest workspaces through coderd
---
Run `coder --help` for a list of global options.

View File

@ -1,19 +0,0 @@
Usage: coder scaletest cleanup [flags]
Cleanup scaletest workspaces, then cleanup scaletest users.
The strategy flags will apply to each stage of the cleanup process.
Options
--cleanup-concurrency int, $CODER_SCALETEST_CLEANUP_CONCURRENCY (default: 1)
Number of concurrent cleanup jobs to run. 0 means unlimited.
--cleanup-job-timeout duration, $CODER_SCALETEST_CLEANUP_JOB_TIMEOUT (default: 5m)
Timeout per job. Jobs may take longer to complete under higher
concurrency limits.
--cleanup-timeout duration, $CODER_SCALETEST_CLEANUP_TIMEOUT (default: 30m)
Timeout for the entire cleanup run. 0 means unlimited.
---
Run `coder --help` for a list of global options.

View File

@ -1,114 +0,0 @@
Usage: coder scaletest create-workspaces [flags]
Creates many users, then creates a workspace for each user and waits for them
finish building and fully come online. Optionally runs a command inside each
workspace, and connects to the workspace over WireGuard.
It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP.
Options
--cleanup-concurrency int, $CODER_SCALETEST_CLEANUP_CONCURRENCY (default: 1)
Number of concurrent cleanup jobs to run. 0 means unlimited.
--cleanup-job-timeout duration, $CODER_SCALETEST_CLEANUP_JOB_TIMEOUT (default: 5m)
Timeout per job. Jobs may take longer to complete under higher
concurrency limits.
--cleanup-timeout duration, $CODER_SCALETEST_CLEANUP_TIMEOUT (default: 30m)
Timeout for the entire cleanup run. 0 means unlimited.
--concurrency int, $CODER_SCALETEST_CONCURRENCY (default: 1)
Number of concurrent jobs to run. 0 means unlimited.
--connect-hold duration, $CODER_SCALETEST_CONNECT_HOLD (default: 30s)
How long to hold the WireGuard connection open for.
--connect-interval duration, $CODER_SCALETEST_CONNECT_INTERVAL (default: 1s)
How long to wait between making requests to the --connect-url once the
connection is established.
--connect-mode derp|direct, $CODER_SCALETEST_CONNECT_MODE (default: derp)
Mode to use for connecting to the workspace.
--connect-timeout duration, $CODER_SCALETEST_CONNECT_TIMEOUT (default: 5s)
Timeout for each request to the --connect-url.
--connect-url string, $CODER_SCALETEST_CONNECT_URL
URL to connect to inside the the workspace over WireGuard. If not
specified, no connections will be made over WireGuard.
-c, --count int, $CODER_SCALETEST_COUNT (default: 1)
Required: Number of workspaces to create.
--job-timeout duration, $CODER_SCALETEST_JOB_TIMEOUT (default: 5m)
Timeout per job. Jobs may take longer to complete under higher
concurrency limits.
--no-cleanup bool, $CODER_SCALETEST_NO_CLEANUP
Do not clean up resources after the test completes. You can cleanup
manually using coder scaletest cleanup.
--no-plan bool, $CODER_SCALETEST_NO_PLAN
Skip the dry-run step to plan the workspace creation. This step
ensures that the given parameters are valid for the given template.
--no-wait-for-agents bool, $CODER_SCALETEST_NO_WAIT_FOR_AGENTS
Do not wait for agents to start before marking the test as succeeded.
This can be useful if you are running the test against a template that
does not start the agent quickly.
--output string-array, $CODER_SCALETEST_OUTPUTS (default: text)
Output format specs in the format "<format>[:<path>]". Not specifying
a path will default to stdout. Available formats: text, json.
--run-command string, $CODER_SCALETEST_RUN_COMMAND
Command to run inside each workspace using reconnecting-pty (i.e. web
terminal protocol). If not specified, no command will be run.
--run-expect-output string, $CODER_SCALETEST_RUN_EXPECT_OUTPUT
Expect the command to output the given string (on a single line). If
the command does not output the given string, it will be marked as
failed.
--run-expect-timeout bool, $CODER_SCALETEST_RUN_EXPECT_TIMEOUT
Expect the command to timeout. If the command does not finish within
the given --run-timeout, it will be marked as succeeded. If the
command finishes before the timeout, it will be marked as failed.
--run-log-output bool, $CODER_SCALETEST_RUN_LOG_OUTPUT
Log the output of the command to the test logs. This should be left
off unless you expect small amounts of output. Large amounts of output
will cause high memory usage.
--run-timeout duration, $CODER_SCALETEST_RUN_TIMEOUT (default: 5s)
Timeout for the command to complete.
-t, --template string, $CODER_SCALETEST_TEMPLATE
Required: Name or ID of the template to use for workspaces.
--timeout duration, $CODER_SCALETEST_TIMEOUT (default: 30m)
Timeout for the entire test run. 0 means unlimited.
--trace bool, $CODER_SCALETEST_TRACE
Whether application tracing data is collected. It exports to a backend
configured by environment variables. See:
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md.
--trace-coder bool, $CODER_SCALETEST_TRACE_CODER
Whether opentelemetry traces are sent to Coder. We recommend keeping
this disabled unless we advise you to enable it.
--trace-honeycomb-api-key string, $CODER_SCALETEST_TRACE_HONEYCOMB_API_KEY
Enables trace exporting to Honeycomb.io using the provided API key.
--trace-propagate bool, $CODER_SCALETEST_TRACE_PROPAGATE
Enables trace propagation to the Coder backend, which will be used to
correlate server-side spans with client-side spans. Only enable this
if the server is configured with the exact same tracing configuration
as the client.
--use-host-login bool, $CODER_SCALETEST_USE_HOST_LOGIN (default: false)
Use the use logged in on the host machine, instead of creating users.
---
Run `coder --help` for a list of global options.

View File

@ -1,62 +0,0 @@
Usage: coder scaletest workspace-traffic [flags]
Generate traffic to scaletest workspaces through coderd
Options
--bytes-per-tick int, $CODER_SCALETEST_WORKSPACE_TRAFFIC_BYTES_PER_TICK (default: 1024)
How much traffic to generate per tick.
--cleanup-concurrency int, $CODER_SCALETEST_CLEANUP_CONCURRENCY (default: 1)
Number of concurrent cleanup jobs to run. 0 means unlimited.
--cleanup-job-timeout duration, $CODER_SCALETEST_CLEANUP_JOB_TIMEOUT (default: 5m)
Timeout per job. Jobs may take longer to complete under higher
concurrency limits.
--cleanup-timeout duration, $CODER_SCALETEST_CLEANUP_TIMEOUT (default: 30m)
Timeout for the entire cleanup run. 0 means unlimited.
--concurrency int, $CODER_SCALETEST_CONCURRENCY (default: 1)
Number of concurrent jobs to run. 0 means unlimited.
--job-timeout duration, $CODER_SCALETEST_JOB_TIMEOUT (default: 5m)
Timeout per job. Jobs may take longer to complete under higher
concurrency limits.
--output string-array, $CODER_SCALETEST_OUTPUTS (default: text)
Output format specs in the format "<format>[:<path>]". Not specifying
a path will default to stdout. Available formats: text, json.
--scaletest-prometheus-address string, $CODER_SCALETEST_PROMETHEUS_ADDRESS (default: 0.0.0.0:21112)
Address on which to expose scaletest Prometheus metrics.
--scaletest-prometheus-wait duration, $CODER_SCALETEST_PROMETHEUS_WAIT (default: 5s)
How long to wait before exiting in order to allow Prometheus metrics
to be scraped.
--tick-interval duration, $CODER_SCALETEST_WORKSPACE_TRAFFIC_TICK_INTERVAL (default: 100ms)
How often to send traffic.
--timeout duration, $CODER_SCALETEST_TIMEOUT (default: 30m)
Timeout for the entire test run. 0 means unlimited.
--trace bool, $CODER_SCALETEST_TRACE
Whether application tracing data is collected. It exports to a backend
configured by environment variables. See:
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md.
--trace-coder bool, $CODER_SCALETEST_TRACE_CODER
Whether opentelemetry traces are sent to Coder. We recommend keeping
this disabled unless we advise you to enable it.
--trace-honeycomb-api-key string, $CODER_SCALETEST_TRACE_HONEYCOMB_API_KEY
Enables trace exporting to Honeycomb.io using the provided API key.
--trace-propagate bool, $CODER_SCALETEST_TRACE_PROPAGATE
Enables trace propagation to the Coder backend, which will be used to
correlate server-side spans with client-side spans. Only enable this
if the server is configured with the exact same tracing configuration
as the client.
---
Run `coder --help` for a list of global options.

View File

@ -24,7 +24,7 @@
"email": "testuser2@coder.com",
"created_at": "[timestamp]",
"last_seen_at": "[timestamp]",
"status": "active",
"status": "dormant",
"organization_ids": [
"[first org ID]"
],

View File

@ -14,14 +14,13 @@ import (
func TestUserStatus(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
admin := coderdtest.CreateFirstUser(t, client)
other, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
otherUser, err := other.User(context.Background(), codersdk.Me)
require.NoError(t, err, "fetch user")
t.Run("StatusSelf", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
coderdtest.CreateFirstUser(t, client)
inv, root := clitest.New(t, "users", "suspend", "me")
clitest.SetupConfig(t, client, root)
// Yes to the prompt
@ -34,13 +33,18 @@ func TestUserStatus(t *testing.T) {
t.Run("StatusOther", func(t *testing.T) {
t.Parallel()
require.Equal(t, codersdk.UserStatusActive, otherUser.Status, "start as active")
client := coderdtest.New(t, nil)
admin := coderdtest.CreateFirstUser(t, client)
other, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
otherUser, err := other.User(context.Background(), codersdk.Me)
require.NoError(t, err, "fetch user")
inv, root := clitest.New(t, "users", "suspend", otherUser.Username)
clitest.SetupConfig(t, client, root)
// Yes to the prompt
inv.Stdin = bytes.NewReader([]byte("yes\n"))
err := inv.Run()
err = inv.Run()
require.NoError(t, err, "suspend user")
// Check the user status

2
coderd/apidoc/docs.go generated
View File

@ -10227,10 +10227,12 @@ const docTemplate = `{
"type": "string",
"enum": [
"active",
"dormant",
"suspended"
],
"x-enum-varnames": [
"UserStatusActive",
"UserStatusDormant",
"UserStatusSuspended"
]
},

View File

@ -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",

View File

@ -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() {

View File

@ -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{

View File

@ -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

View File

@ -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,

View File

@ -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)

View File

@ -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()

View File

@ -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,

View File

@ -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;

View File

@ -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.';

View File

@ -0,0 +1 @@
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active'::user_status;

View File

@ -0,0 +1 @@
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'dormant'::user_status;

View File

@ -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,
}
}

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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),

View File

@ -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,

View File

@ -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,

View File

@ -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{

View File

@ -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.

View File

@ -19,6 +19,7 @@ type UserStatus string
const (
UserStatusActive UserStatus = "active"
UserStatusDormant UserStatus = "dormant"
UserStatusSuspended UserStatus = "suspended"
)

View File

@ -26,6 +26,28 @@ A malicious Template Admin could write a template that executes commands on the
In low-trust environments, we do not recommend giving users direct access to edit templates. Instead, use [CI/CD pipelines to update templates](../templates/change-management.md) with proper security scans and code reviews in place.
## User status
Coder user accounts can have different status types: active, dormant, and suspended.
### Active user
An _active_ user account in Coder is the default and desired state for all users. When a user's account is marked as _active_, they have complete access to the Coder platform
and can utilize all of its features and functionalities without any limitations. Active users can access workspaces, templates, and interact with Coder using CLI.
### Dormant user
A user account is set to _dormant_ status when they have not yet logged in, or have not logged into the Coder platform for the past 90 days. Once the user logs in to the platform, the account status will switch to _active_.
Dormant accounts do not count towards the total number of licensed seats in a Coder subscription, allowing organizations to optimize their license usage.
### Suspended user
When a user's account is marked as _suspended_ in Coder, it means that the account has been temporarily deactivated, and the user is unable to access the platform.
Only user administrators or owners have the necessary permissions to manage suspended accounts and decide whether to lift the suspension and allow the user back into the Coder environment.
This level of control ensures that administrators can enforce security measures and handle any compliance-related issues promptly.
## Create a user
To create a user with the web UI:
@ -139,5 +161,5 @@ In the Coder UI, you can filter your users using pre-defined filters or by utili
The following filters are supported:
- `status` - Indicates the status of the user. It can be either `active` or `suspended`.
- `status` - Indicates the status of the user. It can be either `active`, `dormant` or `suspended`.
- `role` - Represents the role of the user. You can refer to the [TemplateRole documentation](https://pkg.go.dev/github.com/coder/coder/codersdk#TemplateRole) for a list of supported user roles.

1
docs/api/schemas.md generated
View File

@ -5097,6 +5097,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| Value |
| ----------- |
| `active` |
| `dormant` |
| `suspended` |
## codersdk.ValidationError

View File

@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"cdr.dev/slog"
@ -259,14 +260,36 @@ func TestEntitlements(t *testing.T) {
t.Run("TooManyUsers", func(t *testing.T) {
t.Parallel()
db := dbfake.New()
db.InsertUser(context.Background(), database.InsertUserParams{
activeUser1, err := db.InsertUser(context.Background(), database.InsertUserParams{
ID: uuid.New(),
Username: "test1",
LoginType: database.LoginTypePassword,
})
db.InsertUser(context.Background(), database.InsertUserParams{
require.NoError(t, err)
_, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{
ID: activeUser1.ID,
Status: database.UserStatusActive,
UpdatedAt: database.Now(),
})
require.NoError(t, err)
activeUser2, err := db.InsertUser(context.Background(), database.InsertUserParams{
ID: uuid.New(),
Username: "test2",
LoginType: database.LoginTypePassword,
})
require.NoError(t, err)
_, err = db.UpdateUserStatus(context.Background(), database.UpdateUserStatusParams{
ID: activeUser2.ID,
Status: database.UserStatusActive,
UpdatedAt: database.Now(),
})
require.NoError(t, err)
_, err = db.InsertUser(context.Background(), database.InsertUserParams{
ID: uuid.New(),
Username: "dormant-user",
LoginType: database.LoginTypePassword,
})
require.NoError(t, err)
db.InsertLicense(context.Background(), database.InsertLicenseParams{
JWT: coderdenttest.GenerateLicense(t, coderdenttest.LicenseOptions{
Features: license.Features{

View File

@ -1784,8 +1784,8 @@ export const TemplateVersionWarnings: TemplateVersionWarning[] = [
]
// From codersdk/users.go
export type UserStatus = "active" | "suspended"
export const UserStatuses: UserStatus[] = ["active", "suspended"]
export type UserStatus = "active" | "dormant" | "suspended"
export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"]
// From codersdk/templateversions.go
export type ValidationMonotonicOrder = "decreasing" | "increasing"

View File

@ -137,12 +137,16 @@ export const Filter = ({
skeleton,
options,
learnMoreLink,
learnMoreLabel2,
learnMoreLink2,
presets,
}: {
filter: ReturnType<typeof useFilter>
skeleton: ReactNode
isLoading: boolean
learnMoreLink: string
learnMoreLabel2?: string
learnMoreLink2?: string
error?: unknown
options?: ReactNode
presets: PresetFilter[]
@ -178,6 +182,8 @@ export const Filter = ({
onSelect={(query) => filter.update(query)}
presets={presets}
learnMoreLink={learnMoreLink}
learnMoreLabel2={learnMoreLabel2}
learnMoreLink2={learnMoreLink2}
/>
<TextField
fullWidth
@ -253,10 +259,14 @@ export const Filter = ({
const PresetMenu = ({
presets,
learnMoreLink,
learnMoreLabel2,
learnMoreLink2,
onSelect,
}: {
presets: PresetFilter[]
learnMoreLink: string
learnMoreLabel2?: string
learnMoreLink2?: string
onSelect: (query: string) => void
}) => {
const [isOpen, setIsOpen] = useState(false)
@ -317,6 +327,22 @@ const PresetMenu = ({
<OpenInNewOutlined sx={{ fontSize: "14px !important" }} />
View advanced filtering
</MenuItem>
{learnMoreLink2 && learnMoreLabel2 && (
<>
<MenuItem
component="a"
href={learnMoreLink2}
target="_blank"
sx={{ fontSize: 13, fontWeight: 500 }}
onClick={() => {
setIsOpen(false)
}}
>
<OpenInNewOutlined sx={{ fontSize: "14px !important" }} />
{learnMoreLabel2}
</MenuItem>
</>
)}
</Menu>
</>
)

View File

@ -25,7 +25,24 @@ Example.args = {
export const Editable = Template.bind({})
Editable.args = {
users: [MockUser, MockUser2],
users: [
MockUser,
MockUser2,
{
...MockUser,
username: "John Doe",
email: "john.doe@coder.com",
roles: [],
status: "dormant",
},
{
...MockUser,
username: "Roger Moore",
email: "roger.moore@coder.com",
roles: [],
status: "suspended",
},
],
roles: MockAssignableSiteRoles,
canEditUsers: true,
canViewActivity: true,

View File

@ -174,7 +174,7 @@ export const UsersTableBody: FC<
data={user}
menuItems={
// Return either suspend or activate depending on status
(user.status === "active"
(user.status === "active" || user.status === "dormant"
? [
{
label: t(

View File

@ -24,7 +24,8 @@ export const useStatusFilterMenu = ({
}: Pick<UseFilterMenuOptions<StatusOption>, "value" | "onChange">) => {
const statusOptions: StatusOption[] = [
{ value: "active", label: "Active", color: "success" },
{ value: "suspended", label: "Suspended", color: "secondary" },
{ value: "dormant", label: "Dormant", color: "secondary" },
{ value: "suspended", label: "Suspended", color: "warning" },
]
return useFilterMenu({
onChange,
@ -58,6 +59,8 @@ export const UsersFilter = ({
<Filter
presets={PRESET_FILTERS}
learnMoreLink={docs("/admin/users#user-filtering")}
learnMoreLabel2="User status"
learnMoreLink2={docs("/admin/users#user-status")}
isLoading={menus.status.isInitializing}
filter={filter}
error={error}