mirror of
https://github.com/coder/coder.git
synced 2026-10-31 11:02:50 +08:00
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.
166 lines
5.4 KiB
Go
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))
|
|
})
|
|
}
|