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
|
Field | Tracked |
---|
created_at | false |
description | true |
display_name | true |
icon | true |
id | false |
is_default | true |
name | true |
updated_at | true |
|
| Template
write, delete | Field | Tracked |
---|
active_version_id | true |
activity_bump | true |
allow_user_autostart | true |
allow_user_autostop | true |
allow_user_cancel_workspace_jobs | true |
autostart_block_days_of_week | true |
autostop_requirement_days_of_week | true |
autostop_requirement_weeks | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
default_ttl | true |
deleted | false |
deprecated | true |
description | true |
display_name | true |
failure_ttl | true |
group_acl | true |
icon | true |
id | true |
max_port_sharing_level | true |
name | true |
organization_display_name | false |
organization_icon | false |
organization_id | false |
organization_name | false |
provisioner | true |
require_active_version | true |
time_til_dormant | true |
time_til_dormant_autodelete | true |
updated_at | false |
user_acl | true |
|
| TemplateVersion
create, write | Field | Tracked |
---|
archived | true |
created_at | false |
created_by | true |
created_by_avatar_url | false |
created_by_username | false |
external_auth_providers | false |
id | true |
job_id | false |
message | false |
name | true |
organization_id | false |
readme | true |
template_id | true |
updated_at | false |
|
-| User
create, write, delete | Field | Tracked |
---|
avatar_url | false |
created_at | false |
deleted | true |
email | true |
github_com_user_id | false |
hashed_one_time_passcode | true |
hashed_password | true |
id | true |
last_seen_at | false |
login_type | true |
must_reset_password | true |
name | true |
one_time_passcode_expires_at | true |
quiet_hours_schedule | true |
rbac_roles | true |
status | true |
theme_preference | false |
updated_at | false |
username | true |
|
+| User
create, write, delete | Field | Tracked |
---|
avatar_url | false |
created_at | false |
deleted | true |
email | true |
github_com_user_id | false |
hashed_one_time_passcode | false |
hashed_password | true |
id | true |
last_seen_at | false |
login_type | true |
must_reset_password | true |
name | true |
one_time_passcode_expires_at | true |
quiet_hours_schedule | true |
rbac_roles | true |
status | true |
theme_preference | false |
updated_at | false |
username | true |
|
| Workspace
create, write, delete | Field | Tracked |
---|
automatic_updates | true |
autostart_schedule | true |
created_at | false |
deleted | false |
deleting_at | true |
dormant_at | true |
favorite | true |
id | true |
last_used_at | false |
name | true |
organization_id | false |
owner_id | true |
template_id | true |
ttl | true |
updated_at | false |
|
| WorkspaceBuild
start, stop | Field | Tracked |
---|
build_number | false |
created_at | false |
daily_cost | false |
deadline | false |
id | false |
initiator_by_avatar_url | false |
initiator_by_username | false |
initiator_id | false |
job_id | false |
max_deadline | false |
provisioner_state | false |
reason | false |
template_version_id | true |
transition | false |
updated_at | false |
workspace_id | false |
|
| WorkspaceProxy
| Field | Tracked |
---|
created_at | true |
deleted | false |
derp_enabled | true |
derp_only | true |
display_name | true |
icon | true |
id | true |
name | true |
region_id | true |
token_hashed_secret | true |
updated_at | false |
url | true |
version | true |
wildcard_hostname | true |
|
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,