mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
feat: add audit logs for dormancy events (#15298)
This commit is contained in:
@@ -197,6 +197,7 @@ func (r *RootCmd) newCreateAdminUserCommand() *serpent.Command {
|
||||
UpdatedAt: dbtime.Now(),
|
||||
RBACRoles: []string{rbac.RoleOwner().String()},
|
||||
LoginType: database.LoginTypePassword,
|
||||
Status: "",
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("insert user: %w", err)
|
||||
|
||||
Generated
+8
@@ -9896,6 +9896,14 @@ const docTemplate = `{
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_status": {
|
||||
"description": "UserStatus defaults to UserStatusDormant.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.UserStatus"
|
||||
}
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
Generated
+8
@@ -8809,6 +8809,14 @@
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_status": {
|
||||
"description": "UserStatus defaults to UserStatusDormant.",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/codersdk.UserStatus"
|
||||
}
|
||||
]
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
type BackgroundSubsystem string
|
||||
|
||||
const (
|
||||
BackgroundSubsystemDormancy BackgroundSubsystem = "dormancy"
|
||||
)
|
||||
|
||||
func BackgroundTaskFields(subsystem BackgroundSubsystem) map[string]string {
|
||||
return map[string]string{
|
||||
"automatic_actor": "coder",
|
||||
"automatic_subsystem": string(subsystem),
|
||||
}
|
||||
}
|
||||
|
||||
func BackgroundTaskFieldsBytes(ctx context.Context, logger slog.Logger, subsystem BackgroundSubsystem) []byte {
|
||||
af := BackgroundTaskFields(subsystem)
|
||||
|
||||
wriBytes, err := json.Marshal(af)
|
||||
if err != nil {
|
||||
logger.Error(ctx, "marshal additional fields for dormancy audit", slog.Error(err))
|
||||
return []byte("{}")
|
||||
}
|
||||
|
||||
return wriBytes
|
||||
}
|
||||
@@ -62,12 +62,13 @@ type BackgroundAuditParams[T Auditable] struct {
|
||||
Audit Auditor
|
||||
Log slog.Logger
|
||||
|
||||
UserID uuid.UUID
|
||||
RequestID uuid.UUID
|
||||
Status int
|
||||
Action database.AuditAction
|
||||
OrganizationID uuid.UUID
|
||||
IP string
|
||||
UserID uuid.UUID
|
||||
RequestID uuid.UUID
|
||||
Status int
|
||||
Action database.AuditAction
|
||||
OrganizationID uuid.UUID
|
||||
IP string
|
||||
// todo: this should automatically marshal an interface{} instead of accepting a raw message.
|
||||
AdditionalFields json.RawMessage
|
||||
|
||||
New T
|
||||
|
||||
@@ -702,6 +702,7 @@ func New(options *Options) *API {
|
||||
|
||||
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: options.Database,
|
||||
ActivateDormantUser: ActivateDormantUser(options.Logger, &api.Auditor, options.Database),
|
||||
OAuth2Configs: oauthConfigs,
|
||||
RedirectToLogin: false,
|
||||
DisableSessionExpiryRefresh: options.DeploymentValues.Sessions.DisableExpiryRefresh.Value(),
|
||||
|
||||
@@ -719,6 +719,9 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
|
||||
Name: RandomName(t),
|
||||
Password: "SomeSecurePassword!",
|
||||
OrganizationIDs: organizationIDs,
|
||||
// Always create users as active in tests to ignore an extra audit log
|
||||
// when logging in.
|
||||
UserStatus: ptr.Ref(codersdk.UserStatusActive),
|
||||
}
|
||||
for _, m := range mutators {
|
||||
m(&req)
|
||||
|
||||
@@ -342,6 +342,7 @@ func User(t testing.TB, db database.Store, orig database.User) database.User {
|
||||
UpdatedAt: takeFirst(orig.UpdatedAt, dbtime.Now()),
|
||||
RBACRoles: takeFirstSlice(orig.RBACRoles, []string{}),
|
||||
LoginType: takeFirst(orig.LoginType, database.LoginTypePassword),
|
||||
Status: string(takeFirst(orig.Status, database.UserStatusDormant)),
|
||||
})
|
||||
require.NoError(t, err, "insert user")
|
||||
|
||||
|
||||
@@ -7709,6 +7709,11 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam
|
||||
}
|
||||
}
|
||||
|
||||
status := database.UserStatusDormant
|
||||
if arg.Status != "" {
|
||||
status = database.UserStatus(arg.Status)
|
||||
}
|
||||
|
||||
user := database.User{
|
||||
ID: arg.ID,
|
||||
Email: arg.Email,
|
||||
@@ -7717,7 +7722,7 @@ func (q *FakeQuerier) InsertUser(_ context.Context, arg database.InsertUserParam
|
||||
UpdatedAt: arg.UpdatedAt,
|
||||
Username: arg.Username,
|
||||
Name: arg.Name,
|
||||
Status: database.UserStatusDormant,
|
||||
Status: status,
|
||||
RBACRoles: arg.RBACRoles,
|
||||
LoginType: arg.LoginType,
|
||||
}
|
||||
@@ -8640,6 +8645,7 @@ func (q *FakeQuerier) UpdateInactiveUsersToDormant(_ context.Context, params dat
|
||||
updated = append(updated, database.UpdateInactiveUsersToDormantRow{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
Username: user.Username,
|
||||
LastSeenAt: user.LastSeenAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -10345,10 +10345,15 @@ INSERT INTO
|
||||
created_at,
|
||||
updated_at,
|
||||
rbac_roles,
|
||||
login_type
|
||||
login_type,
|
||||
status
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
-- if the status passed in is empty, fallback to dormant, which is what
|
||||
-- we were doing before.
|
||||
COALESCE(NULLIF($10::text, '')::user_status, 'dormant'::user_status)
|
||||
) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, theme_preference, name, github_com_user_id, hashed_one_time_passcode, one_time_passcode_expires_at
|
||||
`
|
||||
|
||||
type InsertUserParams struct {
|
||||
@@ -10361,6 +10366,7 @@ type InsertUserParams struct {
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"`
|
||||
LoginType LoginType `db:"login_type" json:"login_type"`
|
||||
Status string `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User, error) {
|
||||
@@ -10374,6 +10380,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User
|
||||
arg.UpdatedAt,
|
||||
arg.RBACRoles,
|
||||
arg.LoginType,
|
||||
arg.Status,
|
||||
)
|
||||
var i User
|
||||
err := row.Scan(
|
||||
@@ -10408,7 +10415,7 @@ SET
|
||||
WHERE
|
||||
last_seen_at < $2 :: timestamp
|
||||
AND status = 'active'::user_status
|
||||
RETURNING id, email, last_seen_at
|
||||
RETURNING id, email, username, last_seen_at
|
||||
`
|
||||
|
||||
type UpdateInactiveUsersToDormantParams struct {
|
||||
@@ -10419,6 +10426,7 @@ type UpdateInactiveUsersToDormantParams struct {
|
||||
type UpdateInactiveUsersToDormantRow struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
Email string `db:"email" json:"email"`
|
||||
Username string `db:"username" json:"username"`
|
||||
LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"`
|
||||
}
|
||||
|
||||
@@ -10431,7 +10439,12 @@ func (q *sqlQuerier) UpdateInactiveUsersToDormant(ctx context.Context, arg Updat
|
||||
var items []UpdateInactiveUsersToDormantRow
|
||||
for rows.Next() {
|
||||
var i UpdateInactiveUsersToDormantRow
|
||||
if err := rows.Scan(&i.ID, &i.Email, &i.LastSeenAt); err != nil {
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Email,
|
||||
&i.Username,
|
||||
&i.LastSeenAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
|
||||
@@ -67,10 +67,15 @@ INSERT INTO
|
||||
created_at,
|
||||
updated_at,
|
||||
rbac_roles,
|
||||
login_type
|
||||
login_type,
|
||||
status
|
||||
)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *;
|
||||
($1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
-- if the status passed in is empty, fallback to dormant, which is what
|
||||
-- we were doing before.
|
||||
COALESCE(NULLIF(@status::text, '')::user_status, 'dormant'::user_status)
|
||||
) RETURNING *;
|
||||
|
||||
-- name: UpdateUserProfile :one
|
||||
UPDATE
|
||||
@@ -286,7 +291,7 @@ SET
|
||||
WHERE
|
||||
last_seen_at < @last_seen_after :: timestamp
|
||||
AND status = 'active'::user_status
|
||||
RETURNING id, email, last_seen_at;
|
||||
RETURNING id, email, username, last_seen_at;
|
||||
|
||||
-- AllUserIDs returns all UserIDs regardless of user status or deletion.
|
||||
-- name: AllUserIDs :many
|
||||
|
||||
@@ -82,6 +82,7 @@ const (
|
||||
|
||||
type ExtractAPIKeyConfig struct {
|
||||
DB database.Store
|
||||
ActivateDormantUser func(ctx context.Context, u database.User) (database.User, error)
|
||||
OAuth2Configs *OAuth2Configs
|
||||
RedirectToLogin bool
|
||||
DisableSessionExpiryRefresh bool
|
||||
@@ -414,21 +415,20 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon
|
||||
})
|
||||
}
|
||||
|
||||
if userStatus == 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: dbtime.Now(),
|
||||
if userStatus == database.UserStatusDormant && cfg.ActivateDormantUser != nil {
|
||||
id, _ := uuid.Parse(actor.ID)
|
||||
user, err := cfg.ActivateDormantUser(ctx, database.User{
|
||||
ID: id,
|
||||
Username: actor.FriendlyName,
|
||||
Status: userStatus,
|
||||
})
|
||||
if err != nil {
|
||||
return write(http.StatusInternalServerError, codersdk.Response{
|
||||
Message: internalErrorMessage,
|
||||
Detail: fmt.Sprintf("can't activate a dormant user: %s", err.Error()),
|
||||
Detail: fmt.Sprintf("update user status: %s", err.Error()),
|
||||
})
|
||||
}
|
||||
userStatus = u.Status
|
||||
userStatus = user.Status
|
||||
}
|
||||
|
||||
if userStatus != database.UserStatusActive {
|
||||
|
||||
@@ -1063,6 +1063,7 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto.
|
||||
wriBytes, err := json.Marshal(buildResourceInfo)
|
||||
if err != nil {
|
||||
s.Logger.Error(ctx, "marshal workspace resource info for failed job", slog.Error(err))
|
||||
wriBytes = []byte("{}")
|
||||
}
|
||||
|
||||
bag := audit.BaggageFromContext(ctx)
|
||||
|
||||
+67
-17
@@ -12,6 +12,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
@@ -27,6 +28,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/cryptokeys"
|
||||
"github.com/coder/coder/v2/coderd/idpsync"
|
||||
"github.com/coder/coder/v2/coderd/jwtutils"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/apikey"
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
@@ -565,20 +567,13 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co
|
||||
return user, rbac.Subject{}, 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: dbtime.Now(),
|
||||
user, err = ActivateDormantUser(api.Logger, &api.Auditor, api.Database)(ctx, user)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
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, rbac.Subject{}, false
|
||||
}
|
||||
return user, rbac.Subject{}, false
|
||||
}
|
||||
|
||||
subject, userStatus, err := httpmw.UserRBACSubject(ctx, api.Database, user.ID, rbac.ScopeAll)
|
||||
@@ -601,6 +596,42 @@ func (api *API) loginRequest(ctx context.Context, rw http.ResponseWriter, req co
|
||||
return user, subject, true
|
||||
}
|
||||
|
||||
func ActivateDormantUser(logger slog.Logger, auditor *atomic.Pointer[audit.Auditor], db database.Store) func(ctx context.Context, user database.User) (database.User, error) {
|
||||
return func(ctx context.Context, user database.User) (database.User, error) {
|
||||
if user.ID == uuid.Nil || user.Status != database.UserStatusDormant {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
//nolint:gocritic // System needs to update status of the user account (dormant -> active).
|
||||
newUser, err := db.UpdateUserStatus(dbauthz.AsSystemRestricted(ctx), database.UpdateUserStatusParams{
|
||||
ID: user.ID,
|
||||
Status: database.UserStatusActive,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "unable to update user status to active", slog.Error(err))
|
||||
return user, xerrors.Errorf("update user status: %w", err)
|
||||
}
|
||||
|
||||
oldAuditUser := user
|
||||
newAuditUser := user
|
||||
newAuditUser.Status = database.UserStatusActive
|
||||
|
||||
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.User]{
|
||||
Audit: *auditor.Load(),
|
||||
Log: logger,
|
||||
UserID: user.ID,
|
||||
Action: database.AuditActionWrite,
|
||||
Old: oldAuditUser,
|
||||
New: newAuditUser,
|
||||
Status: http.StatusOK,
|
||||
AdditionalFields: audit.BackgroundTaskFieldsBytes(ctx, logger, audit.BackgroundSubsystemDormancy),
|
||||
})
|
||||
|
||||
return newUser, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the user's session cookie.
|
||||
//
|
||||
// @Summary Log out user
|
||||
@@ -1385,10 +1416,22 @@ func (p *oauthLoginParams) CommitAuditLogs() {
|
||||
|
||||
func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.Cookie, database.User, database.APIKey, error) {
|
||||
var (
|
||||
ctx = r.Context()
|
||||
user database.User
|
||||
cookies []*http.Cookie
|
||||
logger = api.Logger.Named(userAuthLoggerName)
|
||||
ctx = r.Context()
|
||||
user database.User
|
||||
cookies []*http.Cookie
|
||||
logger = api.Logger.Named(userAuthLoggerName)
|
||||
auditor = *api.Auditor.Load()
|
||||
dormantConvertAudit *audit.Request[database.User]
|
||||
initDormantAuditOnce = sync.OnceFunc(func() {
|
||||
dormantConvertAudit = params.initAuditRequest(&audit.RequestParams{
|
||||
Audit: auditor,
|
||||
Log: api.Logger,
|
||||
Request: r,
|
||||
Action: database.AuditActionWrite,
|
||||
OrganizationID: uuid.Nil,
|
||||
AdditionalFields: audit.BackgroundTaskFields(audit.BackgroundSubsystemDormancy),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
var isConvertLoginType bool
|
||||
@@ -1490,6 +1533,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
Email: params.Email,
|
||||
Username: params.Username,
|
||||
OrganizationIDs: orgIDs,
|
||||
UserStatus: ptr.Ref(codersdk.UserStatusActive),
|
||||
},
|
||||
LoginType: params.LoginType,
|
||||
accountCreatorName: "oauth",
|
||||
@@ -1501,6 +1545,11 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
|
||||
// Activate dormant user on sign-in
|
||||
if user.Status == database.UserStatusDormant {
|
||||
// This is necessary because transactions can be retried, and we
|
||||
// only want to add the audit log a single time.
|
||||
initDormantAuditOnce()
|
||||
dormantConvertAudit.UserID = user.ID
|
||||
dormantConvertAudit.Old = user
|
||||
//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,
|
||||
@@ -1511,6 +1560,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C
|
||||
logger.Error(ctx, "unable to update user status to active", slog.Error(err))
|
||||
return xerrors.Errorf("update user status: %w", err)
|
||||
}
|
||||
dormantConvertAudit.New = user
|
||||
}
|
||||
|
||||
debugContext, err := json.Marshal(params.DebugContext)
|
||||
|
||||
+44
-1
@@ -1285,7 +1285,7 @@ func TestUserOIDC(t *testing.T) {
|
||||
tc.AssertResponse(t, resp)
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
if tc.AssertUser != nil {
|
||||
user, err := client.User(ctx, "me")
|
||||
@@ -1300,6 +1300,49 @@ func TestUserOIDC(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("OIDCDormancy", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
|
||||
auditor := audit.NewMock()
|
||||
fake := oidctest.NewFakeIDP(t,
|
||||
oidctest.WithRefresh(func(_ string) error {
|
||||
return xerrors.New("refreshing token should never occur")
|
||||
}),
|
||||
oidctest.WithServing(),
|
||||
)
|
||||
cfg := fake.OIDCConfig(t, nil, func(cfg *coderd.OIDCConfig) {
|
||||
cfg.AllowSignups = true
|
||||
})
|
||||
|
||||
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
|
||||
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
Auditor: auditor,
|
||||
OIDCConfig: cfg,
|
||||
Logger: &logger,
|
||||
})
|
||||
|
||||
user := dbgen.User(t, db, database.User{
|
||||
LoginType: database.LoginTypeOIDC,
|
||||
Status: database.UserStatusDormant,
|
||||
})
|
||||
auditor.ResetLogs()
|
||||
|
||||
client, resp := fake.AttemptLogin(t, owner, jwt.MapClaims{
|
||||
"email": user.Email,
|
||||
})
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
auditor.Contains(t, database.AuditLog{
|
||||
ResourceType: database.ResourceTypeUser,
|
||||
AdditionalFields: json.RawMessage(`{"automatic_actor":"coder","automatic_subsystem":"dormancy"}`),
|
||||
})
|
||||
me, err := client.User(ctx, "me")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, codersdk.UserStatusActive, me.Status)
|
||||
})
|
||||
|
||||
t.Run("OIDCConvert", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+13
-4
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/searchquery"
|
||||
"github.com/coder/coder/v2/coderd/telemetry"
|
||||
"github.com/coder/coder/v2/coderd/userpassword"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
@@ -188,10 +189,13 @@ func (api *API) postFirstUser(rw http.ResponseWriter, r *http.Request) {
|
||||
//nolint:gocritic // needed to create first user
|
||||
user, err := api.CreateUser(dbauthz.AsSystemRestricted(ctx), api.Database, CreateUserRequest{
|
||||
CreateUserRequestWithOrgs: codersdk.CreateUserRequestWithOrgs{
|
||||
Email: createUser.Email,
|
||||
Username: createUser.Username,
|
||||
Name: createUser.Name,
|
||||
Password: createUser.Password,
|
||||
Email: createUser.Email,
|
||||
Username: createUser.Username,
|
||||
Name: createUser.Name,
|
||||
Password: createUser.Password,
|
||||
// There's no reason to create the first user as dormant, since you have
|
||||
// to login immediately anyways.
|
||||
UserStatus: ptr.Ref(codersdk.UserStatusActive),
|
||||
OrganizationIDs: []uuid.UUID{defaultOrg.ID},
|
||||
},
|
||||
LoginType: database.LoginTypePassword,
|
||||
@@ -1343,6 +1347,10 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
|
||||
err := store.InTx(func(tx database.Store) error {
|
||||
orgRoles := make([]string, 0)
|
||||
|
||||
status := ""
|
||||
if req.UserStatus != nil {
|
||||
status = string(*req.UserStatus)
|
||||
}
|
||||
params := database.InsertUserParams{
|
||||
ID: uuid.New(),
|
||||
Email: req.Email,
|
||||
@@ -1354,6 +1362,7 @@ func (api *API) CreateUser(ctx context.Context, store database.Store, req Create
|
||||
// All new users are defaulted to members of the site.
|
||||
RBACRoles: []string{},
|
||||
LoginType: req.LoginType,
|
||||
Status: status,
|
||||
}
|
||||
// If a user signs up with OAuth, they can have no password!
|
||||
if req.Password != "" {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbgen"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -695,6 +696,41 @@ func TestPostUsers(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// User should default to dormant.
|
||||
require.Equal(t, codersdk.UserStatusDormant, user.Status)
|
||||
|
||||
require.Len(t, auditor.AuditLogs(), numLogs)
|
||||
require.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[numLogs-1].Action)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs()[numLogs-2].Action)
|
||||
|
||||
require.Len(t, user.OrganizationIDs, 1)
|
||||
assert.Equal(t, firstUser.OrganizationID, user.OrganizationIDs[0])
|
||||
})
|
||||
|
||||
t.Run("CreateWithStatus", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
auditor := audit.NewMock()
|
||||
client := coderdtest.New(t, &coderdtest.Options{Auditor: auditor})
|
||||
numLogs := len(auditor.AuditLogs())
|
||||
|
||||
firstUser := coderdtest.CreateFirstUser(t, client)
|
||||
numLogs++ // add an audit log for user create
|
||||
numLogs++ // add an audit log for login
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
user, err := client.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||
OrganizationIDs: []uuid.UUID{firstUser.OrganizationID},
|
||||
Email: "another@user.org",
|
||||
Username: "someone-else",
|
||||
Password: "SomeSecurePassword!",
|
||||
UserStatus: ptr.Ref(codersdk.UserStatusActive),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, codersdk.UserStatusActive, user.Status)
|
||||
|
||||
require.Len(t, auditor.AuditLogs(), numLogs)
|
||||
require.Equal(t, database.AuditActionCreate, auditor.AuditLogs()[numLogs-1].Action)
|
||||
require.Equal(t, database.AuditActionLogin, auditor.AuditLogs()[numLogs-2].Action)
|
||||
|
||||
@@ -139,6 +139,8 @@ type CreateUserRequestWithOrgs struct {
|
||||
Password string `json:"password"`
|
||||
// UserLoginType defaults to LoginTypePassword.
|
||||
UserLoginType LoginType `json:"login_type"`
|
||||
// UserStatus defaults to UserStatusDormant.
|
||||
UserStatus *UserStatus `json:"user_status"`
|
||||
// OrganizationIDs is a list of organization IDs that the user should be a member of.
|
||||
OrganizationIDs []uuid.UUID `json:"organization_ids" validate:"" format:"uuid"`
|
||||
}
|
||||
|
||||
Generated
+10
-8
@@ -1342,20 +1342,22 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
"name": "string",
|
||||
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"password": "string",
|
||||
"user_status": "active",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
|
||||
### Properties
|
||||
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------------ | ---------------------------------------- | -------- | ------------ | ----------------------------------------------------------------------------------- |
|
||||
| `email` | string | true | | |
|
||||
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. |
|
||||
| `name` | string | false | | |
|
||||
| `organization_ids` | array of string | false | | Organization ids is a list of organization IDs that the user should be a member of. |
|
||||
| `password` | string | false | | |
|
||||
| `username` | string | true | | |
|
||||
| Name | Type | Required | Restrictions | Description |
|
||||
| ------------------ | ------------------------------------------ | -------- | ------------ | ----------------------------------------------------------------------------------- |
|
||||
| `email` | string | true | | |
|
||||
| `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | Login type defaults to LoginTypePassword. |
|
||||
| `name` | string | false | | |
|
||||
| `organization_ids` | array of string | false | | Organization ids is a list of organization IDs that the user should be a member of. |
|
||||
| `password` | string | false | | |
|
||||
| `user_status` | [codersdk.UserStatus](#codersdkuserstatus) | false | | User status defaults to UserStatusDormant. |
|
||||
| `username` | string | true | | |
|
||||
|
||||
## codersdk.CreateWorkspaceBuildRequest
|
||||
|
||||
|
||||
Generated
+1
@@ -86,6 +86,7 @@ curl -X POST http://coder-server:8080/api/v2/users \
|
||||
"name": "string",
|
||||
"organization_ids": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"],
|
||||
"password": "string",
|
||||
"user_status": "active",
|
||||
"username": "string"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"github.com/coder/coder/v2/enterprise/dbcrypt"
|
||||
"github.com/coder/coder/v2/enterprise/trialer"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/quartz"
|
||||
"github.com/coder/serpent"
|
||||
|
||||
agplcoderd "github.com/coder/coder/v2/coderd"
|
||||
@@ -95,7 +96,7 @@ func (r *RootCmd) Server(_ func()) *serpent.Command {
|
||||
DefaultQuietHoursSchedule: options.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(),
|
||||
ProvisionerDaemonPSK: options.DeploymentValues.Provisioner.DaemonPSK.Value(),
|
||||
|
||||
CheckInactiveUsersCancelFunc: dormancy.CheckInactiveUsers(ctx, options.Logger, options.Database),
|
||||
CheckInactiveUsersCancelFunc: dormancy.CheckInactiveUsers(ctx, options.Logger, quartz.NewReal(), options.Database, options.Auditor),
|
||||
}
|
||||
|
||||
if encKeys := options.DeploymentValues.ExternalTokenEncryptionKeys.Value(); len(encKeys) != 0 {
|
||||
|
||||
@@ -172,6 +172,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) {
|
||||
}
|
||||
apiKeyMiddleware := httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{
|
||||
DB: options.Database,
|
||||
ActivateDormantUser: coderd.ActivateDormantUser(options.Logger, &api.AGPL.Auditor, options.Database),
|
||||
OAuth2Configs: oauthConfigs,
|
||||
RedirectToLogin: false,
|
||||
DisableSessionExpiryRefresh: options.DeploymentValues.Sessions.DisableExpiryRefresh.Value(),
|
||||
|
||||
@@ -3,14 +3,17 @@ package dormancy
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -22,50 +25,49 @@ const (
|
||||
|
||||
// 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)
|
||||
func CheckInactiveUsers(ctx context.Context, logger slog.Logger, clk quartz.Clock, db database.Store, auditor audit.Auditor) func() {
|
||||
return CheckInactiveUsersWithOptions(ctx, logger, clk, db, auditor, 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() {
|
||||
func CheckInactiveUsersWithOptions(ctx context.Context, logger slog.Logger, clk quartz.Clock, db database.Store, auditor audit.Auditor, 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:
|
||||
}
|
||||
tf := clk.TickerFunc(ctx, checkInterval, func() error {
|
||||
startTime := time.Now()
|
||||
lastSeenAfter := dbtime.Now().Add(-dormancyPeriod)
|
||||
logger.Debug(ctx, "check inactive user accounts", slog.F("dormancy_period", dormancyPeriod), slog.F("last_seen_after", lastSeenAfter))
|
||||
|
||||
startTime := time.Now()
|
||||
lastSeenAfter := dbtime.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: dbtime.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)))
|
||||
updatedUsers, err := db.UpdateInactiveUsersToDormant(ctx, database.UpdateInactiveUsersToDormantParams{
|
||||
LastSeenAfter: lastSeenAfter,
|
||||
UpdatedAt: dbtime.Now(),
|
||||
})
|
||||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||||
logger.Error(ctx, "can't mark inactive users as dormant", slog.Error(err))
|
||||
return nil
|
||||
}
|
||||
}()
|
||||
|
||||
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))
|
||||
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.User]{
|
||||
Audit: auditor,
|
||||
Log: logger,
|
||||
UserID: u.ID,
|
||||
Action: database.AuditActionWrite,
|
||||
Old: database.User{ID: u.ID, Username: u.Username, Status: database.UserStatusActive},
|
||||
New: database.User{ID: u.ID, Username: u.Username, Status: database.UserStatusDormant},
|
||||
Status: http.StatusOK,
|
||||
AdditionalFields: audit.BackgroundTaskFieldsBytes(ctx, logger, audit.BackgroundSubsystemDormancy),
|
||||
})
|
||||
}
|
||||
logger.Debug(ctx, "checking user accounts is done", slog.F("num_dormant_accounts", len(updatedUsers)), slog.F("execution_time", time.Since(startTime)))
|
||||
return nil
|
||||
})
|
||||
|
||||
return func() {
|
||||
cancelFunc()
|
||||
<-done
|
||||
_ = tf.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,11 @@ import (
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbmem"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/dormancy"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestCheckInactiveUsers(t *testing.T) {
|
||||
@@ -42,29 +43,34 @@ func TestCheckInactiveUsers(t *testing.T) {
|
||||
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))
|
||||
|
||||
mAudit := audit.NewMock()
|
||||
mClock := quartz.NewMock(t)
|
||||
// Run the periodic job
|
||||
closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, db, interval, dormancyPeriod)
|
||||
closeFunc := dormancy.CheckInactiveUsersWithOptions(ctx, logger, mClock, db, mAudit, 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
|
||||
}
|
||||
dur, w := mClock.AdvanceNext()
|
||||
require.Equal(t, interval, dur)
|
||||
w.MustWait(ctx)
|
||||
|
||||
var dormant, suspended int
|
||||
for _, row := range rows {
|
||||
if row.Status == database.UserStatusDormant {
|
||||
dormant++
|
||||
} else if row.Status == database.UserStatusSuspended {
|
||||
suspended++
|
||||
}
|
||||
rows, err := db.GetUsers(ctx, database.GetUsersParams{})
|
||||
require.NoError(t, err)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 9 users in total, 3 active, 3 dormant, 3 suspended
|
||||
require.Len(t, rows, 9)
|
||||
require.Equal(t, 3, dormant)
|
||||
require.Equal(t, 3, suspended)
|
||||
|
||||
require.Len(t, mAudit.AuditLogs(), 3)
|
||||
|
||||
allUsers := ignoreUpdatedAt(database.ConvertUserRows(rows))
|
||||
|
||||
|
||||
Generated
+1
@@ -328,6 +328,7 @@ export interface CreateUserRequestWithOrgs {
|
||||
readonly name: string;
|
||||
readonly password: string;
|
||||
readonly login_type: LoginType;
|
||||
readonly user_status?: UserStatus;
|
||||
readonly organization_ids: Readonly<Array<string>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ export const AuditLogDescription: FC<AuditLogDescriptionProps> = ({
|
||||
target = "";
|
||||
}
|
||||
|
||||
// This occurs when SCIM creates a user.
|
||||
// This occurs when SCIM creates a user, or dormancy changes a users status.
|
||||
if (
|
||||
auditLog.resource_type === "user" &&
|
||||
auditLog.additional_fields?.automatic_actor === "coder"
|
||||
|
||||
Reference in New Issue
Block a user