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 |
Field | Tracked |
---|
created_at | true |
expires_at | true |
hashed_secret | false |
id | false |
ip_address | false |
last_used | true |
lifetime_seconds | false |
login_type | false |
scope | false |
token_name | false |
updated_at | false |
user_id | true |
|
| AuditOAuthConvertState
| Field | Tracked |
---|
created_at | true |
expires_at | true |
from_login_type | true |
to_login_type | true |
user_id | true |
|
| Group
create, write, delete | Field | Tracked |
---|
avatar_url | true |
display_name | true |
id | true |
members | true |
name | true |
organization_id | false |
quota_allowance | true |
source | false |
|
+| AuditableOrganizationMember
| Field | Tracked |
---|
created_at | true |
organization_id | true |
roles | true |
updated_at | true |
user_id | true |
username | true |
|
| CustomRole
| Field | Tracked |
---|
created_at | false |
display_name | true |
id | false |
name | true |
org_permissions | true |
organization_id | true |
site_permissions | true |
updated_at | false |
user_permissions | true |
|
| GitSSHKey
create | Field | Tracked |
---|
created_at | false |
private_key | true |
public_key | true |
updated_at | false |
user_id | true |
|
| HealthSettings
| Field | Tracked |
---|
dismissed_healthchecks | true |
id | false |
|
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,