diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index dd5205c0afb42..8f6059b0dceff 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -22,7 +22,8 @@ type Auditable interface { database.HealthSettings | database.OAuth2ProviderApp | database.OAuth2ProviderAppSecret | - database.CustomRole + database.CustomRole | + database.AuditableOrganizationMember } // Map is a map of changed fields in an audited resource. It maps field names to diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 2171366f4c66f..d8d6ce094b4ea 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -105,6 +105,8 @@ func ResourceTarget[T Auditable](tgt T) string { return typed.DisplaySecret case database.CustomRole: return typed.Name + case database.AuditableOrganizationMember: + return typed.Username default: panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt)) } @@ -144,6 +146,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { return typed.ID case database.CustomRole: return typed.ID + case database.AuditableOrganizationMember: + return typed.UserID default: panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt)) } @@ -181,6 +185,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeOauth2ProviderAppSecret case database.CustomRole: return database.ResourceTypeCustomRole + case database.AuditableOrganizationMember: + return database.ResourceTypeOrganizationMember default: panic(fmt.Sprintf("unknown resource %T for ResourceType", typed)) } @@ -219,6 +225,8 @@ func ResourceRequiresOrgID[T Auditable]() bool { return false case database.CustomRole: return true + case database.AuditableOrganizationMember: + return true default: panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt)) } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index ca063f4b08eb1..0ca4c7ac18c99 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -148,7 +148,8 @@ CREATE TYPE resource_type AS ENUM ( 'health_settings', 'oauth2_provider_app', 'oauth2_provider_app_secret', - 'custom_role' + 'custom_role', + 'organization_member' ); CREATE TYPE startup_script_behavior AS ENUM ( diff --git a/coderd/database/migrations/000220_audit_org_member.down.sql b/coderd/database/migrations/000220_audit_org_member.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/coderd/database/migrations/000220_audit_org_member.up.sql b/coderd/database/migrations/000220_audit_org_member.up.sql new file mode 100644 index 0000000000000..c6f0f799a367d --- /dev/null +++ b/coderd/database/migrations/000220_audit_org_member.up.sql @@ -0,0 +1 @@ +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'organization_member'; diff --git a/coderd/database/modelmethods.go b/coderd/database/modelmethods.go index ee22ae1ad42ba..0ae838894aa8b 100644 --- a/coderd/database/modelmethods.go +++ b/coderd/database/modelmethods.go @@ -60,6 +60,18 @@ func (s WorkspaceAgentStatus) Valid() bool { } } +type AuditableOrganizationMember struct { + OrganizationMember + Username string `json:"username"` +} + +func (m OrganizationMember) Auditable(username string) AuditableOrganizationMember { + return AuditableOrganizationMember{ + OrganizationMember: m, + Username: username, + } +} + type AuditableGroup struct { Group Members []GroupMember `json:"members"` diff --git a/coderd/database/models.go b/coderd/database/models.go index 963587875b372..aea9837e92e89 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1223,6 +1223,7 @@ const ( ResourceTypeOauth2ProviderApp ResourceType = "oauth2_provider_app" ResourceTypeOauth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" + ResourceTypeOrganizationMember ResourceType = "organization_member" ) func (e *ResourceType) Scan(src interface{}) error { @@ -1277,7 +1278,8 @@ func (e ResourceType) Valid() bool { ResourceTypeHealthSettings, ResourceTypeOauth2ProviderApp, ResourceTypeOauth2ProviderAppSecret, - ResourceTypeCustomRole: + ResourceTypeCustomRole, + ResourceTypeOrganizationMember: return true } return false @@ -1301,6 +1303,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeOauth2ProviderApp, ResourceTypeOauth2ProviderAppSecret, ResourceTypeCustomRole, + ResourceTypeOrganizationMember, } } diff --git a/coderd/members.go b/coderd/members.go index 2528a17878f3b..e15aa9d4821f9 100644 --- a/coderd/members.go +++ b/coderd/members.go @@ -7,6 +7,7 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbtime" @@ -27,10 +28,19 @@ import ( // @Router /organizations/{organization}/members/{user} [post] func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) { var ( - ctx = r.Context() - organization = httpmw.OrganizationParam(r) - user = httpmw.UserParam(r) + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + user = httpmw.UserParam(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionCreate, + }) ) + aReq.Old = database.AuditableOrganizationMember{} + defer commitAudit() member, err := api.Database.InsertOrganizationMember(ctx, database.InsertOrganizationMemberParams{ OrganizationID: organization.ID, @@ -54,6 +64,7 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) return } + aReq.New = member.Auditable(user.Username) resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{member}) if err != nil { httpapi.InternalServerError(rw, err) @@ -79,10 +90,19 @@ func (api *API) postOrganizationMember(rw http.ResponseWriter, r *http.Request) // @Router /organizations/{organization}/members/{user} [delete] func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request) { var ( - ctx = r.Context() - organization = httpmw.OrganizationParam(r) - member = httpmw.OrganizationMemberParam(r) + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + member = httpmw.OrganizationMemberParam(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionDelete, + }) ) + aReq.Old = member.OrganizationMember.Auditable(member.Username) + defer commitAudit() err := api.Database.DeleteOrganizationMember(ctx, database.DeleteOrganizationMemberParams{ OrganizationID: organization.ID, @@ -97,6 +117,7 @@ func (api *API) deleteOrganizationMember(rw http.ResponseWriter, r *http.Request return } + aReq.New = database.AuditableOrganizationMember{} httpapi.Write(ctx, rw, http.StatusOK, "organization member removed") } @@ -149,13 +170,22 @@ func (api *API) listMembers(rw http.ResponseWriter, r *http.Request) { // @Router /organizations/{organization}/members/{user}/roles [put] func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) { var ( - ctx = r.Context() - organization = httpmw.OrganizationParam(r) - member = httpmw.OrganizationMemberParam(r) - apiKey = httpmw.APIKey(r) + ctx = r.Context() + organization = httpmw.OrganizationParam(r) + member = httpmw.OrganizationMemberParam(r) + apiKey = httpmw.APIKey(r) + auditor = api.Auditor.Load() + aReq, commitAudit = audit.InitRequest[database.AuditableOrganizationMember](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) ) + aReq.Old = member.OrganizationMember.Auditable(member.Username) + defer commitAudit() - if apiKey.UserID == member.UserID { + if apiKey.UserID == member.OrganizationMember.UserID { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "You cannot change your own organization roles.", }) @@ -182,6 +212,10 @@ func (api *API) putMemberRoles(rw http.ResponseWriter, r *http.Request) { }) return } + aReq.New = database.AuditableOrganizationMember{ + OrganizationMember: updatedUser, + Username: member.Username, + } resp, err := convertOrganizationMembers(ctx, api.Database, []database.OrganizationMember{updatedUser}) if err != nil { diff --git a/codersdk/audit.go b/codersdk/audit.go index 837ef729e4a58..b1d525d69179f 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -31,6 +31,7 @@ const ( // nolint:gosec // This is not a secret. ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" + ResourceTypeOrganizationMember = "organization_member" ) func (r ResourceType) FriendlyString() string { @@ -69,6 +70,8 @@ func (r ResourceType) FriendlyString() string { return "oauth2 app secret" case ResourceTypeCustomRole: return "custom role" + case ResourceTypeOrganizationMember: + return "organization member" default: return "unknown" } diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 34c4e8c9a8dc3..ff216b3da73d2 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -13,6 +13,7 @@ We track the following resources: | APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| | AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| | Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| AuditableOrganizationMember
|
FieldTracked
created_attrue
organization_idtrue
rolestrue
updated_attrue
user_idtrue
usernametrue
| | CustomRole
|
FieldTracked
created_atfalse
display_nametrue
idfalse
nametrue
org_permissionstrue
organization_idtrue
site_permissionstrue
updated_atfalse
user_permissionstrue
| | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index e2788959e3275..d5f7dfed70fb5 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -50,6 +50,14 @@ type Table map[string]map[string]Action var AuditableResources = auditMap(auditableResourcesTypes) var auditableResourcesTypes = map[any]map[string]Action{ + &database.AuditableOrganizationMember{}: { + "username": ActionTrack, + "user_id": ActionTrack, + "organization_id": ActionTrack, + "created_at": ActionTrack, + "updated_at": ActionTrack, + "roles": ActionTrack, + }, &database.CustomRole{}: { "name": ActionTrack, "display_name": ActionTrack,