diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 90bb94e8d132a..76084b1ff54dd 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9116,7 +9116,8 @@ const docTemplate = `{ "stop", "login", "logout", - "register" + "register", + "request_password_reset" ], "x-enum-varnames": [ "AuditActionCreate", @@ -9126,7 +9127,8 @@ const docTemplate = `{ "AuditActionStop", "AuditActionLogin", "AuditActionLogout", - "AuditActionRegister" + "AuditActionRegister", + "AuditActionRequestPasswordReset" ] }, "codersdk.AuditDiff": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7429cef850c0a..beff69ca22373 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8090,7 +8090,8 @@ "stop", "login", "logout", - "register" + "register", + "request_password_reset" ], "x-enum-varnames": [ "AuditActionCreate", @@ -8100,7 +8101,8 @@ "AuditActionStop", "AuditActionLogin", "AuditActionLogout", - "AuditActionRegister" + "AuditActionRegister", + "AuditActionRequestPasswordReset" ] }, "codersdk.AuditDiff": { diff --git a/coderd/audit.go b/coderd/audit.go index 6d9a23ad217a5..f764094782a2f 100644 --- a/coderd/audit.go +++ b/coderd/audit.go @@ -274,8 +274,15 @@ func (api *API) convertAuditLog(ctx context.Context, dblog database.GetAuditLogs func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { b := strings.Builder{} + // NOTE: WriteString always returns a nil error, so we never check it - _, _ = b.WriteString("{user} ") + + // Requesting a password reset can be performed by anyone that knows the email + // of a user so saying the user performed this action might be slightly misleading. + if alog.AuditLog.Action != database.AuditActionRequestPasswordReset { + _, _ = b.WriteString("{user} ") + } + if alog.AuditLog.StatusCode >= 400 { _, _ = b.WriteString("unsuccessfully attempted to ") _, _ = b.WriteString(string(alog.AuditLog.Action)) @@ -298,8 +305,12 @@ func auditLogDescription(alog database.GetAuditLogsOffsetRow) string { return b.String() } - _, _ = b.WriteString(" ") - _, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString()) + if alog.AuditLog.Action == database.AuditActionRequestPasswordReset { + _, _ = b.WriteString(" for") + } else { + _, _ = b.WriteString(" ") + _, _ = b.WriteString(codersdk.ResourceType(alog.AuditLog.ResourceType).FriendlyString()) + } if alog.AuditLog.ResourceType == database.ResourceTypeConvertLogin { _, _ = b.WriteString(" to") diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 626d00cc81b41..382cab743fb39 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -19,7 +19,8 @@ CREATE TYPE audit_action AS ENUM ( 'stop', 'login', 'logout', - 'register' + 'register', + 'request_password_reset' ); CREATE TYPE automatic_updates AS ENUM ( diff --git a/coderd/database/migrations/000268_add_audit_action_request_password_reset.down.sql b/coderd/database/migrations/000268_add_audit_action_request_password_reset.down.sql new file mode 100644 index 0000000000000..d1d1637f4fa90 --- /dev/null +++ b/coderd/database/migrations/000268_add_audit_action_request_password_reset.down.sql @@ -0,0 +1,2 @@ +-- It's not possible to drop enum values from enum types, so the UP has "IF NOT +-- EXISTS". diff --git a/coderd/database/migrations/000268_add_audit_action_request_password_reset.up.sql b/coderd/database/migrations/000268_add_audit_action_request_password_reset.up.sql new file mode 100644 index 0000000000000..81371517202fc --- /dev/null +++ b/coderd/database/migrations/000268_add_audit_action_request_password_reset.up.sql @@ -0,0 +1,2 @@ +ALTER TYPE audit_action + ADD VALUE IF NOT EXISTS 'request_password_reset'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 05b4c404ea16f..c44aa6011bc22 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -138,14 +138,15 @@ func AllAppSharingLevelValues() []AppSharingLevel { type AuditAction string const ( - AuditActionCreate AuditAction = "create" - AuditActionWrite AuditAction = "write" - AuditActionDelete AuditAction = "delete" - AuditActionStart AuditAction = "start" - AuditActionStop AuditAction = "stop" - AuditActionLogin AuditAction = "login" - AuditActionLogout AuditAction = "logout" - AuditActionRegister AuditAction = "register" + AuditActionCreate AuditAction = "create" + AuditActionWrite AuditAction = "write" + AuditActionDelete AuditAction = "delete" + AuditActionStart AuditAction = "start" + AuditActionStop AuditAction = "stop" + AuditActionLogin AuditAction = "login" + AuditActionLogout AuditAction = "logout" + AuditActionRegister AuditAction = "register" + AuditActionRequestPasswordReset AuditAction = "request_password_reset" ) func (e *AuditAction) Scan(src interface{}) error { @@ -192,7 +193,8 @@ func (e AuditAction) Valid() bool { AuditActionStop, AuditActionLogin, AuditActionLogout, - AuditActionRegister: + AuditActionRegister, + AuditActionRequestPasswordReset: return true } return false @@ -208,6 +210,7 @@ func AllAuditActionValues() []AuditAction { AuditActionLogin, AuditActionLogout, AuditActionRegister, + AuditActionRequestPasswordReset, } } diff --git a/coderd/userauth.go b/coderd/userauth.go index 0ff3dfa8f97cc..85ab0d77e6cc1 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -220,7 +220,7 @@ func (api *API) postRequestOneTimePasscode(rw http.ResponseWriter, r *http.Reque Audit: *auditor, Log: api.Logger, Request: r, - Action: database.AuditActionWrite, + Action: database.AuditActionRequestPasswordReset, }) ) defer commitAudit() @@ -253,6 +253,7 @@ func (api *API) postRequestOneTimePasscode(rw http.ResponseWriter, r *http.Reque } // We continue if err == sql.ErrNoRows to help prevent a timing-based attack. aReq.Old = user + aReq.UserID = user.ID passcode := uuid.New() passcodeExpiresAt := dbtime.Now().Add(api.OneTimePasscodeValidityPeriod) @@ -365,6 +366,7 @@ func (api *API) postChangePasswordWithOneTimePasscode(rw http.ResponseWriter, r } // We continue if err == sql.ErrNoRows to help prevent a timing-based attack. aReq.Old = user + aReq.UserID = user.ID equal, err := userpassword.Compare(string(user.HashedOneTimePasscode), req.OneTimePasscode) if err != nil { diff --git a/codersdk/audit.go b/codersdk/audit.go index 7d83c8e238ce0..9fe51e5f24a5f 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -86,14 +86,15 @@ func (r ResourceType) FriendlyString() string { type AuditAction string const ( - AuditActionCreate AuditAction = "create" - AuditActionWrite AuditAction = "write" - AuditActionDelete AuditAction = "delete" - AuditActionStart AuditAction = "start" - AuditActionStop AuditAction = "stop" - AuditActionLogin AuditAction = "login" - AuditActionLogout AuditAction = "logout" - AuditActionRegister AuditAction = "register" + AuditActionCreate AuditAction = "create" + AuditActionWrite AuditAction = "write" + AuditActionDelete AuditAction = "delete" + AuditActionStart AuditAction = "start" + AuditActionStop AuditAction = "stop" + AuditActionLogin AuditAction = "login" + AuditActionLogout AuditAction = "logout" + AuditActionRegister AuditAction = "register" + AuditActionRequestPasswordReset AuditAction = "request_password_reset" ) func (a AuditAction) Friendly() string { @@ -114,6 +115,8 @@ func (a AuditAction) Friendly() string { return "logged out" case AuditActionRegister: return "registered" + case AuditActionRequestPasswordReset: + return "password reset requested" default: return "unknown" } diff --git a/docs/admin/security/audit-logs.md b/docs/admin/security/audit-logs.md index c4b9499f8b966..b22055ff18b5a 100644 --- a/docs/admin/security/audit-logs.md +++ b/docs/admin/security/audit-logs.md @@ -25,7 +25,7 @@ We track the following resources: | Organization
|
FieldTracked
created_atfalse
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| | Template
write, delete |
FieldTracked
active_version_idtrue
activity_bumptrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_port_sharing_leveltrue
nametrue
organization_display_namefalse
organization_iconfalse
organization_idfalse
organization_namefalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| | TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodetrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
must_reset_passwordtrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
theme_preferencefalse
updated_atfalse
usernametrue
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
github_com_user_idfalse
hashed_one_time_passcodefalse
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
must_reset_passwordtrue
nametrue
one_time_passcode_expires_attrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
theme_preferencefalse
updated_atfalse
usernametrue
| | Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
favoritetrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| | WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| | WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
versiontrue
wildcard_hostnametrue
| diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index ac8636f3e6f46..ed3800b3a27cd 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -513,16 +513,17 @@ #### Enumerated Values -| Value | -| ---------- | -| `create` | -| `write` | -| `delete` | -| `start` | -| `stop` | -| `login` | -| `logout` | -| `register` | +| Value | +| ------------------------ | +| `create` | +| `write` | +| `delete` | +| `start` | +| `stop` | +| `login` | +| `logout` | +| `register` | +| `request_password_reset` | ## codersdk.AuditDiff diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 15eaaeb11b4f5..baa9f33b18786 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -145,7 +145,7 @@ var auditableResourcesTypes = map[any]map[string]Action{ "theme_preference": ActionIgnore, "name": ActionTrack, "github_com_user_id": ActionIgnore, - "hashed_one_time_passcode": ActionSecret, // Do not expose a user's one time passcode. + "hashed_one_time_passcode": ActionIgnore, "one_time_passcode_expires_at": ActionTrack, "must_reset_password": ActionTrack, }, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 76be331a526cf..e55167ef03f88 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2098,8 +2098,8 @@ export type AgentSubsystem = "envbox" | "envbuilder" | "exectrace" export const AgentSubsystems: AgentSubsystem[] = ["envbox", "envbuilder", "exectrace"] // From codersdk/audit.go -export type AuditAction = "create" | "delete" | "login" | "logout" | "register" | "start" | "stop" | "write" -export const AuditActions: AuditAction[] = ["create", "delete", "login", "logout", "register", "start", "stop", "write"] +export type AuditAction = "create" | "delete" | "login" | "logout" | "register" | "request_password_reset" | "start" | "stop" | "write" +export const AuditActions: AuditAction[] = ["create", "delete", "login", "logout", "register", "request_password_reset", "start", "stop", "write"] // From codersdk/workspaces.go export type AutomaticUpdates = "always" | "never" diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx index a8c1e2435475e..dd2c88f5be50b 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDescription/AuditLogDescription.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react"; import { MockAuditLog, + MockAuditLogRequestPasswordReset, MockAuditLogSuccessfulLogin, MockAuditLogUnsuccessfulLoginKnownUser, MockAuditLogWithWorkspaceBuild, @@ -57,6 +58,12 @@ export const UnsuccessfulLoginForUnknownUser: Story = { }, }; +export const RequestPasswordReset: Story = { + args: { + auditLog: MockAuditLogRequestPasswordReset, + }, +}; + export const CreateUser: Story = { args: { auditLog: { diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx index 33a4f24b58385..584269c515190 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogDiff/AuditLogDiff.tsx @@ -9,6 +9,14 @@ const getDiffValue = (value: unknown): string => { return `"${value}"`; } + if (isTimeObject(value)) { + if (!value.Valid) { + return "null"; + } + + return new Date(value.Time).toLocaleString(); + } + if (Array.isArray(value)) { const values = value.map((v) => getDiffValue(v)); return `[${values.join(", ")}]`; @@ -21,6 +29,19 @@ const getDiffValue = (value: unknown): string => { return String(value); }; +const isTimeObject = ( + value: unknown, +): value is { Time: string; Valid: boolean } => { + return ( + value !== null && + typeof value === "object" && + "Time" in value && + typeof value.Time === "string" && + "Valid" in value && + typeof value.Valid === "boolean" + ); +}; + interface AuditLogDiffProps { diff: AuditDiff; } diff --git a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx index f6b601486a833..12d57b63047e8 100644 --- a/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx +++ b/site/src/pages/AuditPage/AuditLogRow/AuditLogRow.stories.tsx @@ -10,6 +10,7 @@ import { MockAuditLog, MockAuditLog2, MockAuditLogGitSSH, + MockAuditLogRequestPasswordReset, MockAuditLogWithDeletedResource, MockAuditLogWithWorkspaceBuild, MockUser, @@ -122,6 +123,12 @@ export const WithOrganization: Story = { }, }; +export const WithDateDiffValue: Story = { + args: { + auditLog: MockAuditLogRequestPasswordReset, + }, +}; + export const NoUserAgent: Story = { args: { auditLog: { diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7b654e54c48a2..0db6e80d435d6 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -2600,6 +2600,32 @@ export const MockAuditLogUnsuccessfulLoginKnownUser: TypesGen.AuditLog = { status_code: 401, }; +export const MockAuditLogRequestPasswordReset: TypesGen.AuditLog = { + ...MockAuditLog, + resource_type: "user", + resource_target: "member", + action: "request_password_reset", + description: "password reset requested for {target}", + diff: { + hashed_password: { + old: "", + new: "", + secret: true, + }, + one_time_passcode_expires_at: { + old: { + Time: "0001-01-01T00:00:00Z", + Valid: false, + }, + new: { + Time: "2024-10-22T09:03:23.961702Z", + Valid: true, + }, + secret: false, + }, + }, +}; + export const MockWorkspaceQuota: TypesGen.WorkspaceQuota = { credits_consumed: 0, budget: 100,