feat: add audit logs for dormancy events (#15298)

This commit is contained in:
Colin Adler
2024-10-31 17:55:42 -05:00
committed by GitHub
parent 14565615be
commit 088f21965b
26 changed files with 342 additions and 107 deletions
+8
View File
@@ -9896,6 +9896,14 @@ const docTemplate = `{
"password": {
"type": "string"
},
"user_status": {
"description": "UserStatus defaults to UserStatusDormant.",
"allOf": [
{
"$ref": "#/definitions/codersdk.UserStatus"
}
]
},
"username": {
"type": "string"
}
+8
View File
@@ -8809,6 +8809,14 @@
"password": {
"type": "string"
},
"user_status": {
"description": "UserStatus defaults to UserStatusDormant.",
"allOf": [
{
"$ref": "#/definitions/codersdk.UserStatus"
}
]
},
"username": {
"type": "string"
}
+33
View File
@@ -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
}
+7 -6
View File
@@ -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
+1
View File
@@ -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(),
+3
View File
@@ -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)
+1
View File
@@ -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")
+7 -1
View File
@@ -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,
})
}
+17 -4
View File
@@ -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)
+8 -3
View File
@@ -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
+9 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 != "" {
+36
View File
@@ -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)