Files
coder/enterprise/coderd/audit_test.go
T
Michael Suchacz 24b83dc727 feat: add organization picker to MCP server settings pages (#28150)
Adds an organization picker to the MCP server settings pages so
multi-org deployments can manage every organization's MCP servers
through the UI, not just the default organization's. Closes CODAGT-714
(scoped to MCP servers only; model configs stay deployment-wide until
CODAGT-709 lands).

## Stack Context

Top of the CODAGT-711/717/712/806 stack: MCP server configs are
org-scoped with RBAC, audit logging, per-config ACLs, and owner-scoped
user tokens. This layer makes the admin UI org-aware, resolving the
"default org only" limitation documented in the bottom PR.

## Summary

- The MCP servers list and add pages resolve the working organization
from an `org` search param (falling back to the default org), and render
an organization picker built on the existing `OrganizationAutocomplete`.
The picker only appears when more than one organization is visible, so
single-org deployments see no change.
- Selecting an organization rewrites the search param, refetches the
list for that org, and threads through to the add page so creation posts
to the selected organization.
- The update page now loads the server by ID (new organization-scoped
`GET /api/experimental/organizations/{organization}/mcp-servers/{id}`
frontend binding) instead of listing the default org and searching
client-side, so servers from any organization can be edited. Post-save
navigation returns to the owning organization's list via the server's
`organization_id`.
- Removes the now-unused `getDefaultOrganizationId` helper.
- Review hardening: organization display labels are disambiguated with
the unique org name when effective display names collide (picker
options, trigger text, and accessible name). Access is no longer
restricted to deployment admins: the pages, sidebar item, and nav entry
accept org-scoped `mcp_server_config` permissions, the picker lists only
organizations the user can view, content and affordances (add, edit,
delete) are gated on the selected organization's permissions, and
deployment admins keep full access everywhere.

## Testing

Storybook interaction stories cover: picker hidden with one org,
switching orgs refetches and swaps list contents, add posting to the
URL-selected org, add posting after an in-form org switch (asserting
typed form state survives the switch), and update loading by ID.
Red-green verified by breaking the search-param resolution and observing
the new stories fail.

> Mux acted on Mike's behalf for this PR.
2026-08-19 19:46:23 +00:00

166 lines
5.4 KiB
Go

package coderd_test
import (
"context"
"fmt"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/testutil"
)
func TestEnterpriseAuditLogs(t *testing.T) {
t.Parallel()
t.Run("IncludeOrganization", func(t *testing.T) {
t.Parallel()
ctx := context.Background()
client, user := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureMultipleOrganizations: 1,
},
},
})
//nolint:gocritic // only owners can create organizations
o, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
Name: "new-org",
DisplayName: "New organization",
Description: "A new organization to love and cherish until the test is over.",
Icon: "/emojis/1f48f-1f3ff.png",
})
require.NoError(t, err)
err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
OrganizationID: o.ID,
ResourceID: user.UserID,
})
require.NoError(t, err)
alogs, err := client.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Equal(t, int64(1), alogs.Count)
require.Len(t, alogs.AuditLogs, 1)
// Make sure the organization is fully populated.
require.Equal(t, &codersdk.MinimalOrganization{
ID: o.ID,
Name: o.Name,
DisplayName: o.DisplayName,
Icon: o.Icon,
}, alogs.AuditLogs[0].Organization)
// OrganizationID is deprecated, but make sure it is set.
require.Equal(t, o.ID, alogs.AuditLogs[0].OrganizationID)
// Delete the org and try again, should be mostly empty.
err = client.DeleteOrganization(ctx, o.ID.String())
require.NoError(t, err)
alogs, err = client.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Equal(t, int64(1), alogs.Count)
require.Len(t, alogs.AuditLogs, 1)
// OrganizationID is deprecated, but make sure it is set.
require.Equal(t, o.ID, alogs.AuditLogs[0].OrganizationID)
// Some audit entries do not have an organization at all, in which case the
// response omits the organization.
err = client.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
ResourceType: codersdk.ResourceTypeAPIKey,
ResourceID: user.UserID,
})
require.NoError(t, err)
alogs, err = client.AuditLogs(ctx, codersdk.AuditLogsRequest{
SearchQuery: "resource_type:api_key",
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Equal(t, int64(1), alogs.Count)
require.Len(t, alogs.AuditLogs, 1)
// The other will have no organization.
require.Equal(t, (*codersdk.MinimalOrganization)(nil), alogs.AuditLogs[0].Organization)
// OrganizationID is deprecated, but make sure it is empty.
require.Equal(t, uuid.Nil, alogs.AuditLogs[0].OrganizationID)
})
t.Run("MCPServerConfigLinkForMutationOnlyRole", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitLong)
owner, firstUser := coderdenttest.New(t, &coderdenttest.Options{
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureCustomRoles: 1,
},
},
})
config := createMCPServerConfigForOrganization(t, owner, firstUser.OrganizationID, "audit-link-mcp")
// Remove the default everyone-in-org ACL grant so the custom
// role's update permission is the requester's only access.
//nolint:gocritic // Owner access removes the default ACL grant.
err := owner.UpdateMCPServerConfigACL(ctx, firstUser.OrganizationID, config.ID, codersdk.UpdateMCPServerConfigACLRequest{
GroupRoles: map[string]codersdk.MCPServerConfigRole{
firstUser.OrganizationID.String(): codersdk.MCPServerConfigRoleDeleted,
},
})
require.NoError(t, err)
//nolint:gocritic // Owner access isolates custom-role setup from the behavior under test.
role, err := owner.CreateOrganizationRole(ctx, codersdk.Role{
Name: "mcp-update-only-audit",
OrganizationID: firstUser.OrganizationID.String(),
OrganizationPermissions: codersdk.CreatePermissions(map[codersdk.RBACResource][]codersdk.RBACAction{
codersdk.ResourceMCPServerConfig: {codersdk.ActionUpdate},
codersdk.ResourceAuditLog: {codersdk.ActionRead},
}),
})
require.NoError(t, err)
updateOnly, _ := coderdtest.CreateAnotherUser(t, owner, firstUser.OrganizationID,
rbac.RoleIdentifier{Name: role.Name, OrganizationID: firstUser.OrganizationID})
err = owner.CreateTestAuditLog(ctx, codersdk.CreateTestAuditLogRequest{
Action: codersdk.AuditActionCreate,
ResourceType: codersdk.ResourceTypeMCPServerConfig,
ResourceID: config.ID,
OrganizationID: firstUser.OrganizationID,
})
require.NoError(t, err)
// Update-only administrators lack config read access, yet the
// settings page admits them, so the link must still appear.
logs, err := updateOnly.AuditLogs(ctx, codersdk.AuditLogsRequest{
Pagination: codersdk.Pagination{
Limit: 1,
},
})
require.NoError(t, err)
require.Len(t, logs.AuditLogs, 1)
require.Contains(t, logs.AuditLogs[0].ResourceLink, fmt.Sprintf("/ai/settings/mcp-servers/%s", config.ID))
})
}