mirror of https://github.com/coder/coder.git
feat: add "dormant" user state (#8644)
This commit is contained in:
parent
d2c7c8e1d8
commit
d6e9870209
|
@ -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.
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
Usage: coder scaletest
|
||||
|
||||
Run a scale test against the Coder API
|
||||
|
||||
[1mSubcommands[0m
|
||||
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.
|
|
@ -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.
|
||||
|
||||
[1mOptions[0m
|
||||
--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.
|
|
@ -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.
|
||||
|
||||
[1mOptions[0m
|
||||
--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.
|
|
@ -1,62 +0,0 @@
|
|||
Usage: coder scaletest workspace-traffic [flags]
|
||||
|
||||
Generate traffic to scaletest workspaces through coderd
|
||||
|
||||
[1mOptions[0m
|
||||
--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.
|
|
@ -24,7 +24,7 @@
|
|||
"email": "testuser2@coder.com",
|
||||
"created_at": "[timestamp]",
|
||||
"last_seen_at": "[timestamp]",
|
||||
"status": "active",
|
||||
"status": "dormant",
|
||||
"organization_ids": [
|
||||
"[first org ID]"
|
||||
],
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -10227,10 +10227,12 @@ const docTemplate = `{
|
|||
"type": "string",
|
||||
"enum": [
|
||||
"active",
|
||||
"dormant",
|
||||
"suspended"
|
||||
],
|
||||
"x-enum-varnames": [
|
||||
"UserStatusActive",
|
||||
"UserStatusDormant",
|
||||
"UserStatusSuspended"
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -19,6 +19,7 @@ type UserStatus string
|
|||
|
||||
const (
|
||||
UserStatusActive UserStatus = "active"
|
||||
UserStatusDormant UserStatus = "dormant"
|
||||
UserStatusSuspended UserStatus = "suspended"
|
||||
)
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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}
|
||||
|
|
Loading…
Reference in New Issue