From 05ce32c032eb9a8aee81ab4746b647e58e173d4d Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Thu, 6 Jun 2024 11:44:10 -0500
Subject: [PATCH 1/6] chore: implement audit log for custom role edits

---
 coderd/audit/diff.go                          |  3 +-
 coderd/audit/request.go                       |  8 ++++
 coderd/coderdtest/coderdtest.go               |  2 +
 coderd/database/dbauthz/customroles_test.go   |  1 -
 coderd/database/dbmem/dbmem.go                |  1 +
 coderd/database/dump.sql                      |  8 +++-
 .../000217_org_custom_role_audit.down.sql     |  2 +
 .../000217_org_custom_role_audit.up.sql       |  9 +++++
 coderd/database/models.go                     |  6 ++-
 coderd/database/queries.sql.go                |  6 ++-
 coderd/roles.go                               |  6 +--
 codersdk/audit.go                             |  3 ++
 enterprise/audit/diff.go                      | 13 +++++++
 enterprise/audit/table.go                     | 12 ++++++
 enterprise/coderd/coderd.go                   |  2 +-
 enterprise/coderd/roles.go                    | 38 ++++++++++++++++++-
 site/src/api/typesGenerated.ts                |  2 +
 17 files changed, 110 insertions(+), 12 deletions(-)
 create mode 100644 coderd/database/migrations/000217_org_custom_role_audit.down.sql
 create mode 100644 coderd/database/migrations/000217_org_custom_role_audit.up.sql

diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go
index a6835014d4fe2..dd5205c0afb42 100644
--- a/coderd/audit/diff.go
+++ b/coderd/audit/diff.go
@@ -21,7 +21,8 @@ type Auditable interface {
 		database.AuditOAuthConvertState |
 		database.HealthSettings |
 		database.OAuth2ProviderApp |
-		database.OAuth2ProviderAppSecret
+		database.OAuth2ProviderAppSecret |
+		database.CustomRole
 }
 
 // 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 e6d9d01fbfd27..20eb8185af53e 100644
--- a/coderd/audit/request.go
+++ b/coderd/audit/request.go
@@ -103,6 +103,8 @@ func ResourceTarget[T Auditable](tgt T) string {
 		return typed.Name
 	case database.OAuth2ProviderAppSecret:
 		return typed.DisplaySecret
+	case database.CustomRole:
+		return typed.Name
 	default:
 		panic(fmt.Sprintf("unknown resource %T for ResourceTarget", tgt))
 	}
@@ -140,6 +142,8 @@ func ResourceID[T Auditable](tgt T) uuid.UUID {
 		return typed.ID
 	case database.OAuth2ProviderAppSecret:
 		return typed.ID
+	case database.CustomRole:
+		return typed.ID
 	default:
 		panic(fmt.Sprintf("unknown resource %T for ResourceID", tgt))
 	}
@@ -175,6 +179,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType {
 		return database.ResourceTypeOauth2ProviderApp
 	case database.OAuth2ProviderAppSecret:
 		return database.ResourceTypeOauth2ProviderAppSecret
+	case database.CustomRole:
+		return database.ResourceTypeCustomRole
 	default:
 		panic(fmt.Sprintf("unknown resource %T for ResourceType", typed))
 	}
@@ -211,6 +217,8 @@ func ResourceRequiresOrgID[T Auditable]() bool {
 		return false
 	case database.OAuth2ProviderAppSecret:
 		return false
+	case database.CustomRole:
+		return true
 	default:
 		panic(fmt.Sprintf("unknown resource %T for ResourceRequiresOrgID", tgt))
 	}
diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go
index 316683a9f1e65..3ebca07686d0e 100644
--- a/coderd/coderdtest/coderdtest.go
+++ b/coderd/coderdtest/coderdtest.go
@@ -758,6 +758,8 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
 			roleName, _, err = rbac.RoleSplit(roleName)
 			require.NoError(t, err, "split org role name")
 			if ok {
+				roleName, _, err = rbac.RoleSplit(roleName)
+				require.NoError(t, err, "split rolename")
 				orgRoles[orgID] = append(orgRoles[orgID], roleName)
 			} else {
 				siteRoles = append(siteRoles, roleName)
diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go
index b98af8fd23889..006f35d2a2fe9 100644
--- a/coderd/database/dbauthz/customroles_test.go
+++ b/coderd/database/dbauthz/customroles_test.go
@@ -244,7 +244,6 @@ func TestUpsertCustomRoles(t *testing.T) {
 			} else {
 				require.NoError(t, err)
 
-				// Verify we can fetch the role
 				roles, err := az.CustomRoles(ctx, database.CustomRolesParams{
 					LookupRoles: []database.NameOrganizationPair{
 						{
diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go
index 3f9ef73048e6b..147eb8eca6a05 100644
--- a/coderd/database/dbmem/dbmem.go
+++ b/coderd/database/dbmem/dbmem.go
@@ -8415,6 +8415,7 @@ func (q *FakeQuerier) UpsertCustomRole(_ context.Context, arg database.UpsertCus
 	}
 
 	role := database.CustomRole{
+		ID:              uuid.New(),
 		Name:            arg.Name,
 		DisplayName:     arg.DisplayName,
 		OrganizationID:  arg.OrganizationID,
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index c6faa00c65fc5..03bb380706624 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -147,7 +147,8 @@ CREATE TYPE resource_type AS ENUM (
     'convert_login',
     'health_settings',
     'oauth2_provider_app',
-    'oauth2_provider_app_secret'
+    'oauth2_provider_app_secret',
+    'custom_role'
 );
 
 CREATE TYPE startup_script_behavior AS ENUM (
@@ -417,7 +418,8 @@ CREATE TABLE custom_roles (
     user_permissions jsonb DEFAULT '[]'::jsonb NOT NULL,
     created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
     updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
-    organization_id uuid
+    organization_id uuid,
+    id uuid DEFAULT gen_random_uuid() NOT NULL
 );
 
 COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at runtime';
@@ -1642,6 +1644,8 @@ CREATE INDEX idx_audit_log_user_id ON audit_logs USING btree (user_id);
 
 CREATE INDEX idx_audit_logs_time_desc ON audit_logs USING btree ("time" DESC);
 
+CREATE INDEX idx_custom_roles_id ON custom_roles USING btree (id);
+
 CREATE UNIQUE INDEX idx_custom_roles_name_lower ON custom_roles USING btree (lower(name));
 
 CREATE INDEX idx_organization_member_organization_id_uuid ON organization_members USING btree (organization_id);
diff --git a/coderd/database/migrations/000217_org_custom_role_audit.down.sql b/coderd/database/migrations/000217_org_custom_role_audit.down.sql
new file mode 100644
index 0000000000000..5ad6106f2fc26
--- /dev/null
+++ b/coderd/database/migrations/000217_org_custom_role_audit.down.sql
@@ -0,0 +1,2 @@
+DROP INDEX idx_custom_roles_id;
+ALTER TABLE custom_roles DROP COLUMN id;
diff --git a/coderd/database/migrations/000217_org_custom_role_audit.up.sql b/coderd/database/migrations/000217_org_custom_role_audit.up.sql
new file mode 100644
index 0000000000000..7a58bb3d7fa14
--- /dev/null
+++ b/coderd/database/migrations/000217_org_custom_role_audit.up.sql
@@ -0,0 +1,9 @@
+-- A role does not need to belong to an organization
+ALTER TABLE custom_roles ALTER COLUMN organization_id DROP NOT NULL;
+
+-- (name) is the primary key, this column is almost exclusively for auditing.
+ALTER TABLE custom_roles ADD COLUMN id uuid DEFAULT gen_random_uuid() NOT NULL;
+
+-- Ensure unique uuids.
+CREATE INDEX idx_custom_roles_id ON custom_roles (id);
+ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'custom_role';
diff --git a/coderd/database/models.go b/coderd/database/models.go
index aa69054abc2aa..affd3556f4140 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -1222,6 +1222,7 @@ const (
 	ResourceTypeHealthSettings          ResourceType = "health_settings"
 	ResourceTypeOauth2ProviderApp       ResourceType = "oauth2_provider_app"
 	ResourceTypeOauth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret"
+	ResourceTypeCustomRole              ResourceType = "custom_role"
 )
 
 func (e *ResourceType) Scan(src interface{}) error {
@@ -1275,7 +1276,8 @@ func (e ResourceType) Valid() bool {
 		ResourceTypeConvertLogin,
 		ResourceTypeHealthSettings,
 		ResourceTypeOauth2ProviderApp,
-		ResourceTypeOauth2ProviderAppSecret:
+		ResourceTypeOauth2ProviderAppSecret,
+		ResourceTypeCustomRole:
 		return true
 	}
 	return false
@@ -1298,6 +1300,7 @@ func AllResourceTypeValues() []ResourceType {
 		ResourceTypeHealthSettings,
 		ResourceTypeOauth2ProviderApp,
 		ResourceTypeOauth2ProviderAppSecret,
+		ResourceTypeCustomRole,
 	}
 }
 
@@ -1792,6 +1795,7 @@ type CustomRole struct {
 	UpdatedAt       time.Time             `db:"updated_at" json:"updated_at"`
 	// Roles can optionally be scoped to an organization
 	OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
+	ID             uuid.UUID     `db:"id" json:"id"`
 }
 
 // A table used to store the keys used to encrypt the database.
diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go
index 5dff8c05e05a7..823cf2cc45796 100644
--- a/coderd/database/queries.sql.go
+++ b/coderd/database/queries.sql.go
@@ -5618,7 +5618,7 @@ func (q *sqlQuerier) UpdateReplica(ctx context.Context, arg UpdateReplicaParams)
 
 const customRoles = `-- name: CustomRoles :many
 SELECT
-	name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id
+	name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id
 FROM
 	custom_roles
 WHERE
@@ -5667,6 +5667,7 @@ func (q *sqlQuerier) CustomRoles(ctx context.Context, arg CustomRolesParams) ([]
 			&i.CreatedAt,
 			&i.UpdatedAt,
 			&i.OrganizationID,
+			&i.ID,
 		); err != nil {
 			return nil, err
 		}
@@ -5711,7 +5712,7 @@ ON CONFLICT (name)
 	org_permissions = $5,
 	user_permissions = $6,
 	updated_at = now()
-RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id
+RETURNING name, display_name, site_permissions, org_permissions, user_permissions, created_at, updated_at, organization_id, id
 `
 
 type UpsertCustomRoleParams struct {
@@ -5742,6 +5743,7 @@ func (q *sqlQuerier) UpsertCustomRole(ctx context.Context, arg UpsertCustomRoleP
 		&i.CreatedAt,
 		&i.UpdatedAt,
 		&i.OrganizationID,
+		&i.ID,
 	)
 	return i, err
 }
diff --git a/coderd/roles.go b/coderd/roles.go
index 94b121940ed45..1e7f1b1473b9a 100644
--- a/coderd/roles.go
+++ b/coderd/roles.go
@@ -20,12 +20,12 @@ import (
 // roles. Ideally only included in the enterprise package, but the routes are
 // intermixed with AGPL endpoints.
 type CustomRoleHandler interface {
-	PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool)
+	PatchOrganizationRole(ctx context.Context, rw http.ResponseWriter, r *http.Request, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool)
 }
 
 type agplCustomRoleHandler struct{}
 
-func (agplCustomRoleHandler) PatchOrganizationRole(ctx context.Context, _ database.Store, rw http.ResponseWriter, _ uuid.UUID, _ codersdk.Role) (codersdk.Role, bool) {
+func (agplCustomRoleHandler) PatchOrganizationRole(ctx context.Context, rw http.ResponseWriter, _ *http.Request, _ uuid.UUID, _ codersdk.Role) (codersdk.Role, bool) {
 	httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
 		Message: "Creating and updating custom roles is an Enterprise feature. Contact sales!",
 	})
@@ -54,7 +54,7 @@ func (api *API) patchOrgRoles(rw http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	updated, ok := handler.PatchOrganizationRole(ctx, api.Database, rw, organization.ID, req)
+	updated, ok := handler.PatchOrganizationRole(ctx, rw, r, organization.ID, req)
 	if !ok {
 		return
 	}
diff --git a/codersdk/audit.go b/codersdk/audit.go
index 553bd9cc2dbea..837ef729e4a58 100644
--- a/codersdk/audit.go
+++ b/codersdk/audit.go
@@ -30,6 +30,7 @@ const (
 	ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app"
 	// nolint:gosec // This is not a secret.
 	ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret"
+	ResourceTypeCustomRole              ResourceType = "custom_role"
 )
 
 func (r ResourceType) FriendlyString() string {
@@ -66,6 +67,8 @@ func (r ResourceType) FriendlyString() string {
 		return "oauth2 app"
 	case ResourceTypeOAuth2ProviderAppSecret:
 		return "oauth2 app secret"
+	case ResourceTypeCustomRole:
+		return "custom role"
 	default:
 		return "unknown"
 	}
diff --git a/enterprise/audit/diff.go b/enterprise/audit/diff.go
index 59780d2918418..5a24c93c4991c 100644
--- a/enterprise/audit/diff.go
+++ b/enterprise/audit/diff.go
@@ -144,6 +144,19 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
 		return leftInt64Ptr, rightInt64Ptr, true
 	case database.TemplateACL:
 		return fmt.Sprintf("%+v", left), fmt.Sprintf("%+v", right), true
+	case database.CustomRolePermissions:
+		leftArr := make([]string, 0)
+		rightArr := make([]string, 0)
+		for _, p := range typedLeft {
+			leftArr = append(leftArr, p.String())
+		}
+		for _, p := range right.(database.CustomRolePermissions) {
+			rightArr = append(rightArr, p.String())
+		}
+
+		// String representation is much easier to visually inspect
+		//typedRight := right.(database.CustomRolePermission)
+		return leftArr, rightArr, true
 	default:
 		return left, right, false
 	}
diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go
index f26b4921aaace..e2788959e3275 100644
--- a/enterprise/audit/table.go
+++ b/enterprise/audit/table.go
@@ -50,6 +50,18 @@ type Table map[string]map[string]Action
 var AuditableResources = auditMap(auditableResourcesTypes)
 
 var auditableResourcesTypes = map[any]map[string]Action{
+	&database.CustomRole{}: {
+		"name":             ActionTrack,
+		"display_name":     ActionTrack,
+		"site_permissions": ActionTrack,
+		"org_permissions":  ActionTrack,
+		"user_permissions": ActionTrack,
+		"organization_id":  ActionTrack,
+
+		"id":         ActionIgnore,
+		"created_at": ActionIgnore,
+		"updated_at": ActionIgnore,
+	},
 	&database.GitSSHKey{}: {
 		"user_id":     ActionTrack,
 		"created_at":  ActionIgnore, // Never changes, but is implicit and not helpful in a diff.
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index 574d2c12dd2de..26fdab6ec1bfb 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -746,7 +746,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
 	}
 
 	if initial, changed, enabled := featureChanged(codersdk.FeatureCustomRoles); shouldUpdate(initial, changed, enabled) {
-		var handler coderd.CustomRoleHandler = &enterpriseCustomRoleHandler{Enabled: enabled}
+		var handler coderd.CustomRoleHandler = &enterpriseCustomRoleHandler{API: api, Enabled: enabled}
 		api.AGPL.CustomRoleHandler.Store(&handler)
 	}
 
diff --git a/enterprise/coderd/roles.go b/enterprise/coderd/roles.go
index b3a24a8a7779f..3a162a1b5ea80 100644
--- a/enterprise/coderd/roles.go
+++ b/enterprise/coderd/roles.go
@@ -7,6 +7,7 @@ import (
 
 	"github.com/google/uuid"
 
+	"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/httpapi"
@@ -15,10 +16,11 @@ import (
 )
 
 type enterpriseCustomRoleHandler struct {
+	API     *API
 	Enabled bool
 }
 
-func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, db database.Store, rw http.ResponseWriter, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) {
+func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context, rw http.ResponseWriter, r *http.Request, orgID uuid.UUID, role codersdk.Role) (codersdk.Role, bool) {
 	if !h.Enabled {
 		httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
 			Message: "Custom roles are not enabled",
@@ -26,6 +28,19 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context,
 		return codersdk.Role{}, false
 	}
 
+	var (
+		db                = h.API.Database
+		auditor           = h.API.AGPL.Auditor.Load()
+		aReq, commitAudit = audit.InitRequest[database.CustomRole](rw, &audit.RequestParams{
+			Audit:          *auditor,
+			Log:            h.API.Logger,
+			Request:        r,
+			Action:         database.AuditActionWrite,
+			OrganizationID: orgID,
+		})
+	)
+	defer commitAudit()
+
 	if err := httpapi.NameValid(role.Name); err != nil {
 		httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
 			Message: "Invalid role name",
@@ -59,6 +74,26 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context,
 		return codersdk.Role{}, false
 	}
 
+	originalRoles, err := db.CustomRoles(ctx, database.CustomRolesParams{
+		LookupRoles: []database.NameOrganizationPair{
+			{
+				Name:           role.Name,
+				OrganizationID: orgID,
+			},
+		},
+		ExcludeOrgRoles: false,
+		OrganizationID:  orgID,
+	})
+	// If it is a 404 (not found) error, ignore it.
+	if err != nil && !httpapi.Is404Error(err) {
+		httpapi.InternalServerError(rw, err)
+		return codersdk.Role{}, false
+	}
+	if len(originalRoles) == 1 {
+		// For auditing changes to a role.
+		aReq.Old = originalRoles[0]
+	}
+
 	inserted, err := db.UpsertCustomRole(ctx, database.UpsertCustomRoleParams{
 		Name:        role.Name,
 		DisplayName: role.DisplayName,
@@ -81,6 +116,7 @@ func (h enterpriseCustomRoleHandler) PatchOrganizationRole(ctx context.Context,
 		})
 		return codersdk.Role{}, false
 	}
+	aReq.New = inserted
 
 	return db2sdk.Role(inserted), true
 }
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index fae41504f1c34..a53717e3e0229 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -2181,6 +2181,7 @@ export const RBACResources: RBACResource[] = [
 export type ResourceType =
   | "api_key"
   | "convert_login"
+  | "custom_role"
   | "git_ssh_key"
   | "group"
   | "health_settings"
@@ -2197,6 +2198,7 @@ export type ResourceType =
 export const ResourceTypes: ResourceType[] = [
   "api_key",
   "convert_login",
+  "custom_role",
   "git_ssh_key",
   "group",
   "health_settings",

From 91cc834f9eb1d8122c4f1a2ea4a8b40cb99912d0 Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Thu, 6 Jun 2024 12:09:46 -0500
Subject: [PATCH 2/6] make fmt

---
 enterprise/audit/diff.go | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/enterprise/audit/diff.go b/enterprise/audit/diff.go
index 5a24c93c4991c..007f475f6f5eb 100644
--- a/enterprise/audit/diff.go
+++ b/enterprise/audit/diff.go
@@ -145,6 +145,7 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
 	case database.TemplateACL:
 		return fmt.Sprintf("%+v", left), fmt.Sprintf("%+v", right), true
 	case database.CustomRolePermissions:
+		// String representation is much easier to visually inspect
 		leftArr := make([]string, 0)
 		rightArr := make([]string, 0)
 		for _, p := range typedLeft {
@@ -154,8 +155,6 @@ func convertDiffType(left, right any) (newLeft, newRight any, changed bool) {
 			rightArr = append(rightArr, p.String())
 		}
 
-		// String representation is much easier to visually inspect
-		//typedRight := right.(database.CustomRolePermission)
 		return leftArr, rightArr, true
 	default:
 		return left, right, false

From be6deceb32ff354788b49c5a854d7e1d26252899 Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Thu, 6 Jun 2024 12:31:56 -0500
Subject: [PATCH 3/6] make gen

---
 coderd/apidoc/docs.go                                       | 6 ++++--
 coderd/apidoc/swagger.json                                  | 6 ++++--
 coderd/database/dump.sql                                    | 2 ++
 .../database/migrations/000217_org_custom_role_audit.up.sql | 1 +
 coderd/database/models.go                                   | 3 ++-
 docs/admin/audit-logs.md                                    | 1 +
 docs/api/schemas.md                                         | 1 +
 7 files changed, 15 insertions(+), 5 deletions(-)

diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 8905c8a09cba5..5fe1e929d83bd 100644
--- a/coderd/apidoc/docs.go
+++ b/coderd/apidoc/docs.go
@@ -11330,7 +11330,8 @@ const docTemplate = `{
                 "workspace_proxy",
                 "organization",
                 "oauth2_provider_app",
-                "oauth2_provider_app_secret"
+                "oauth2_provider_app_secret",
+                "custom_role"
             ],
             "x-enum-varnames": [
                 "ResourceTypeTemplate",
@@ -11347,7 +11348,8 @@ const docTemplate = `{
                 "ResourceTypeWorkspaceProxy",
                 "ResourceTypeOrganization",
                 "ResourceTypeOAuth2ProviderApp",
-                "ResourceTypeOAuth2ProviderAppSecret"
+                "ResourceTypeOAuth2ProviderAppSecret",
+                "ResourceTypeCustomRole"
             ]
         },
         "codersdk.Response": {
diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json
index 3ab826d3920da..18eb052c3fd64 100644
--- a/coderd/apidoc/swagger.json
+++ b/coderd/apidoc/swagger.json
@@ -10219,7 +10219,8 @@
         "workspace_proxy",
         "organization",
         "oauth2_provider_app",
-        "oauth2_provider_app_secret"
+        "oauth2_provider_app_secret",
+        "custom_role"
       ],
       "x-enum-varnames": [
         "ResourceTypeTemplate",
@@ -10236,7 +10237,8 @@
         "ResourceTypeWorkspaceProxy",
         "ResourceTypeOrganization",
         "ResourceTypeOAuth2ProviderApp",
-        "ResourceTypeOAuth2ProviderAppSecret"
+        "ResourceTypeOAuth2ProviderAppSecret",
+        "ResourceTypeCustomRole"
       ]
     },
     "codersdk.Response": {
diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql
index 03bb380706624..83eea6e3583a6 100644
--- a/coderd/database/dump.sql
+++ b/coderd/database/dump.sql
@@ -426,6 +426,8 @@ COMMENT ON TABLE custom_roles IS 'Custom roles allow dynamic roles expanded at r
 
 COMMENT ON COLUMN custom_roles.organization_id IS 'Roles can optionally be scoped to an organization';
 
+COMMENT ON COLUMN custom_roles.id IS 'Custom roles ID is used purely for auditing purposes. Name is a better unique identifier.';
+
 CREATE TABLE dbcrypt_keys (
     number integer NOT NULL,
     active_key_digest text,
diff --git a/coderd/database/migrations/000217_org_custom_role_audit.up.sql b/coderd/database/migrations/000217_org_custom_role_audit.up.sql
index 7a58bb3d7fa14..784aea22c20f7 100644
--- a/coderd/database/migrations/000217_org_custom_role_audit.up.sql
+++ b/coderd/database/migrations/000217_org_custom_role_audit.up.sql
@@ -3,6 +3,7 @@ ALTER TABLE custom_roles ALTER COLUMN organization_id DROP NOT NULL;
 
 -- (name) is the primary key, this column is almost exclusively for auditing.
 ALTER TABLE custom_roles ADD COLUMN id uuid DEFAULT gen_random_uuid() NOT NULL;
+COMMENT ON COLUMN custom_roles.id IS 'Custom roles ID is used purely for auditing purposes. Name is a better unique identifier.';
 
 -- Ensure unique uuids.
 CREATE INDEX idx_custom_roles_id ON custom_roles (id);
diff --git a/coderd/database/models.go b/coderd/database/models.go
index affd3556f4140..8a558f5beeb0b 100644
--- a/coderd/database/models.go
+++ b/coderd/database/models.go
@@ -1795,7 +1795,8 @@ type CustomRole struct {
 	UpdatedAt       time.Time             `db:"updated_at" json:"updated_at"`
 	// Roles can optionally be scoped to an organization
 	OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"`
-	ID             uuid.UUID     `db:"id" json:"id"`
+	// Custom roles ID is used purely for auditing purposes. Name is a better unique identifier.
+	ID uuid.UUID `db:"id" json:"id"`
 }
 
 // A table used to store the keys used to encrypt the database.
diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md
index fada57f32065f..34c4e8c9a8dc3 100644
--- a/docs/admin/audit-logs.md
+++ b/docs/admin/audit-logs.md
@@ -13,6 +13,7 @@ We track the following resources:
 | APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table>                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
 | AuditOAuthConvertState<br><i></i>                        | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table>                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   |
 | Group<br><i>create, write, delete</i>                    | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr><tr><td>source</td><td>false</td></tr></tbody></table>                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
+| CustomRole<br><i></i>                                    | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>org_permissions</td><td>true</td></tr><tr><td>organization_id</td><td>true</td></tr><tr><td>site_permissions</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_permissions</td><td>true</td></tr></tbody></table>                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
 | GitSSHKey<br><i>create</i>                               | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table>                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
 | HealthSettings<br><i></i>                                | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>dismissed_healthchecks</td><td>true</td></tr><tr><td>id</td><td>false</td></tr></tbody></table>                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |
 | License<br><i>create, delete</i>                         | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table>                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
diff --git a/docs/api/schemas.md b/docs/api/schemas.md
index 24af8aece05e6..55070fb629864 100644
--- a/docs/api/schemas.md
+++ b/docs/api/schemas.md
@@ -4323,6 +4323,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
 | `organization`               |
 | `oauth2_provider_app`        |
 | `oauth2_provider_app_secret` |
+| `custom_role`                |
 
 ## codersdk.Response
 

From 1c9fd5273348a8b7b8dfcbcbf2f9ee3fa401762c Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Fri, 7 Jun 2024 10:12:11 -0500
Subject: [PATCH 4/6] update migration file

---
 ..._role_audit.down.sql => 000218_org_custom_role_audit.down.sql} | 0
 ...stom_role_audit.up.sql => 000218_org_custom_role_audit.up.sql} | 0
 2 files changed, 0 insertions(+), 0 deletions(-)
 rename coderd/database/migrations/{000217_org_custom_role_audit.down.sql => 000218_org_custom_role_audit.down.sql} (100%)
 rename coderd/database/migrations/{000217_org_custom_role_audit.up.sql => 000218_org_custom_role_audit.up.sql} (100%)

diff --git a/coderd/database/migrations/000217_org_custom_role_audit.down.sql b/coderd/database/migrations/000218_org_custom_role_audit.down.sql
similarity index 100%
rename from coderd/database/migrations/000217_org_custom_role_audit.down.sql
rename to coderd/database/migrations/000218_org_custom_role_audit.down.sql
diff --git a/coderd/database/migrations/000217_org_custom_role_audit.up.sql b/coderd/database/migrations/000218_org_custom_role_audit.up.sql
similarity index 100%
rename from coderd/database/migrations/000217_org_custom_role_audit.up.sql
rename to coderd/database/migrations/000218_org_custom_role_audit.up.sql

From fa6c36ef7cdea20a02d906daecc012f5a3083f27 Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Fri, 7 Jun 2024 11:17:23 -0500
Subject: [PATCH 5/6] add comment

---
 coderd/database/dbauthz/customroles_test.go | 1 +
 1 file changed, 1 insertion(+)

diff --git a/coderd/database/dbauthz/customroles_test.go b/coderd/database/dbauthz/customroles_test.go
index 006f35d2a2fe9..814ba88a1b18c 100644
--- a/coderd/database/dbauthz/customroles_test.go
+++ b/coderd/database/dbauthz/customroles_test.go
@@ -244,6 +244,7 @@ func TestUpsertCustomRoles(t *testing.T) {
 			} else {
 				require.NoError(t, err)
 
+				// Verify the role is fetched with the lookup filter.
 				roles, err := az.CustomRoles(ctx, database.CustomRolesParams{
 					LookupRoles: []database.NameOrganizationPair{
 						{

From 4a8d5c44d82699d723598a4fe463653130ff7a76 Mon Sep 17 00:00:00 2001
From: Steven Masley <stevenmasley@gmail.com>
Date: Fri, 7 Jun 2024 11:19:06 -0500
Subject: [PATCH 6/6] remove excess sql

---
 .../database/migrations/000218_org_custom_role_audit.up.sql   | 4 +---
 1 file changed, 1 insertion(+), 3 deletions(-)

diff --git a/coderd/database/migrations/000218_org_custom_role_audit.up.sql b/coderd/database/migrations/000218_org_custom_role_audit.up.sql
index 784aea22c20f7..a780f34960907 100644
--- a/coderd/database/migrations/000218_org_custom_role_audit.up.sql
+++ b/coderd/database/migrations/000218_org_custom_role_audit.up.sql
@@ -1,7 +1,5 @@
--- A role does not need to belong to an organization
-ALTER TABLE custom_roles ALTER COLUMN organization_id DROP NOT NULL;
-
 -- (name) is the primary key, this column is almost exclusively for auditing.
+-- Audit logs require a uuid as the unique identifier for a resource.
 ALTER TABLE custom_roles ADD COLUMN id uuid DEFAULT gen_random_uuid() NOT NULL;
 COMMENT ON COLUMN custom_roles.id IS 'Custom roles ID is used purely for auditing purposes. Name is a better unique identifier.';