diff --git a/README.md b/README.md index 965dda7c..1ef7c8e7 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,13 @@ The dashboard tools now include several strategies to manage context window usag - **List teams:** View all configured teams in Grafana. - **List Users:** View all users in an organization in Grafana. +- **List all roles:** List all Grafana roles, with an optional filter for delegatable roles. +- **Get role details:** Get details for a specific Grafana role by UID. +- **List assignments for a role:** List all users, teams, and service accounts assigned to a role. +- **List roles for users:** List all roles assigned to one or more users. +- **List roles for teams:** List all roles assigned to one or more teams. +- **List permissions for a resource:** List all permissions defined for a specific resource (dashboard, datasource, folder, etc.). +- **Describe a Grafana resource:** List available permissions and assignment capabilities for a resource type. ### Navigation @@ -170,6 +177,13 @@ Scopes define the specific resources that permissions apply to. Each action requ | --------------------------------- | ----------- | ------------------------------------------------------------------- | --------------------------------------- | --------------------------------------------------- | | `list_teams` | Admin | List all teams | `teams:read` | `teams:*` or `teams:id:1` | | `list_users_by_org` | Admin | List all users in an organization | `users:read` | `global.users:*` or `global.users:id:123` | +| `list_all_roles` | Admin | List all Grafana roles | `roles:read` | `roles:*` | +| `get_role_details` | Admin | Get details for a Grafana role | `roles:read` | `roles:uid:editor` | +| `get_role_assignments` | Admin | List assignments for a role | `roles:read` | `roles:uid:editor` | +| `list_user_roles` | Admin | List roles for users | `roles:read` | `global.users:id:123` | +| `list_team_roles` | Admin | List roles for teams | `roles:read` | `teams:id:7` | +| `get_resource_permissions`| Admin | List permissions for a resource | `permissions:read` | `dashboards:uid:abcd1234` | +| `get_resource_description`| Admin | Describe a Grafana resource type | `permissions:read` | `dashboards:*` | | `search_dashboards` | Search | Search for dashboards | `dashboards:read` | `dashboards:*` or `dashboards:uid:abc123` | | `get_dashboard_by_uid` | Dashboard | Get a dashboard by uid | `dashboards:read` | `dashboards:uid:abc123` | | `update_dashboard` | Dashboard | Update or create a new dashboard | `dashboards:create`, `dashboards:write` | `dashboards:*`, `folders:*` or `folders:uid:xyz789` | diff --git a/tools/admin.go b/tools/admin.go index a80ac764..e8971259 100644 --- a/tools/admin.go +++ b/tools/admin.go @@ -7,6 +7,7 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" + "github.com/grafana/grafana-openapi-client-go/client/access_control" "github.com/grafana/grafana-openapi-client-go/client/org" "github.com/grafana/grafana-openapi-client-go/client/teams" "github.com/grafana/grafana-openapi-client-go/models" @@ -61,7 +62,193 @@ var ListUsersByOrg = mcpgrafana.MustTool( mcp.WithReadOnlyHintAnnotation(true), ) +type ListAllRolesParams struct { + DelegatableOnly bool `json:"delegatableOnly,omitempty" jsonschema:"description=Optional: If set true only return roles that can be delegated by current user"` +} + +func listAllRoles(ctx context.Context, args ListAllRolesParams) ([]*models.RoleDTO, error) { + c := mcpgrafana.GrafanaClientFromContext(ctx) + params := access_control.NewListRolesParamsWithContext(ctx) + + if args.DelegatableOnly { + delegatable := true + params.Delegatable = &delegatable + } + + resp, err := c.AccessControl.ListRoles(params) + if err != nil { + return nil, fmt.Errorf("list all roles: %w", err) + } + return resp.Payload, nil +} + +var ListAllRoles = mcpgrafana.MustTool( + "list_all_roles", + "List all roles in Grafana. Optionally filter to show only roles that can be delegated by the current user. Returns role details including UID, name, permissions, and metadata.", + listAllRoles, + mcp.WithTitleAnnotation("List all roles"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +type GetRoleDetailsParams struct { + RoleUID string `json:"roleUID" jsonschema:"required,description=Role UID to retrieve"` +} + +func getRoleDetails(ctx context.Context, args GetRoleDetailsParams) (*models.RoleDTO, error) { + c := mcpgrafana.GrafanaClientFromContext(ctx) + params := access_control.NewGetRoleParamsWithContext(ctx).WithRoleUID(args.RoleUID) + + resp, err := c.AccessControl.GetRoleWithParams(params) + if err != nil { + return nil, fmt.Errorf("get role details: %w", err) + } + return resp.Payload, nil +} + +var GetRoleDetails = mcpgrafana.MustTool( + "get_role_details", + "Get detailed information about a specific Grafana role by its UID, including permissions, metadata, and configuration.", + getRoleDetails, + mcp.WithTitleAnnotation("Get role details"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +type GetRoleAssignmentsParams struct { + RoleUID string `json:"roleUID" jsonschema:"required,description=Role UID to retrieve"` +} + +func getRoleAssignments(ctx context.Context, args GetRoleAssignmentsParams) (*models.RoleAssignmentsDTO, error) { + c := mcpgrafana.GrafanaClientFromContext(ctx) + params := access_control.NewGetRoleAssignmentsParamsWithContext(ctx).WithRoleUID(args.RoleUID) + + resp, err := c.AccessControl.GetRoleAssignmentsWithParams(params) + if err != nil { + return nil, fmt.Errorf("get role assignments: %w", err) + } + return resp.Payload, nil +} + +var GetRoleAssignments = mcpgrafana.MustTool( + "get_role_assignments", + "List all assignments for a specific role, showing which users, teams, and service accounts have been assigned this role.", + getRoleAssignments, + mcp.WithTitleAnnotation("Get role assignments"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +type ListUserRolesParams struct { + UserIDs []int64 `json:"userIds" jsonschema:"required,description=User ID(s) to get roles for. Can be a single user or multiple users."` +} + +func listUserRoles(ctx context.Context, args ListUserRolesParams) (map[string][]models.RoleDTO, error) { + c := mcpgrafana.GrafanaClientFromContext(ctx) + searchQuery := &models.RolesSearchQuery{UserIds: args.UserIDs} + params := access_control.NewListUsersRolesParamsWithContext(ctx).WithBody(searchQuery) + + resp, err := c.AccessControl.ListUsersRolesWithParams(params) + if err != nil { + return nil, fmt.Errorf("list user roles: %w", err) + } + return resp.Payload, nil +} + +var ListUserRoles = mcpgrafana.MustTool( + "list_user_roles", + "List all roles assigned to one or more users. Returns a map of user IDs to their assigned roles, excluding built-in roles and team-inherited roles.", + listUserRoles, + mcp.WithTitleAnnotation("List user roles"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +type ListTeamRolesParams struct { + TeamIDs []int64 `json:"teamIds" jsonschema:"required,description=Team ID(s) to get roles for. Can be a single team or multiple teams."` +} + +func listTeamRoles(ctx context.Context, args ListTeamRolesParams) (map[string][]models.RoleDTO, error) { + c := mcpgrafana.GrafanaClientFromContext(ctx) + searchQuery := &models.RolesSearchQuery{TeamIds: args.TeamIDs} + params := access_control.NewListTeamsRolesParamsWithContext(ctx).WithBody(searchQuery) + + resp, err := c.AccessControl.ListTeamsRolesWithParams(params) + if err != nil { + return nil, fmt.Errorf("list team roles: %w", err) + } + return resp.Payload, nil +} + +var ListTeamRoles = mcpgrafana.MustTool( + "list_team_roles", + "List all roles assigned to one or more teams. Returns a map of team IDs to their assigned roles.", + listTeamRoles, + mcp.WithTitleAnnotation("List team roles"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +type GetResourcePermissionsParams struct { + Resource string `json:"resource" jsonschema:"required,description=Resource type (e.g. 'dashboards' 'datasources' 'folders')"` + ResourceID string `json:"resourceId" jsonschema:"required,description=Unique identifier of the resource (UID for dashboards/datasources/folders)"` +} + +func getResourcePermissions(ctx context.Context, args GetResourcePermissionsParams) ([]*models.ResourcePermissionDTO, error) { + c := mcpgrafana.GrafanaClientFromContext(ctx) + params := access_control.NewGetResourcePermissionsParamsWithContext(ctx).WithResource(args.Resource).WithResourceID(args.ResourceID) + + resp, err := c.AccessControl.GetResourcePermissionsWithParams(params) + if err != nil { + return nil, fmt.Errorf("get resource permissions: %w", err) + } + return resp.Payload, nil +} + +var GetResourcePermissions = mcpgrafana.MustTool( + "get_resource_permissions", + "List all permissions set on a specific Grafana resource (e.g., dashboard, datasource, folder) by its type and ID.", + getResourcePermissions, + mcp.WithTitleAnnotation("Get resource permissions"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + +type GetResourceDescriptionParams struct { + ResourceType string `json:"resourceType" jsonschema:"required,enum=dashboards,enum=datasources,enum=folders,enum=teams,enum=users,enum=serviceaccounts,description=Type of Grafana resource to get description for"` +} + +func getResourceDescription(ctx context.Context, args GetResourceDescriptionParams) (*models.Description, error) { + c := mcpgrafana.GrafanaClientFromContext(ctx) + + params := access_control.NewGetResourceDescriptionParamsWithContext(ctx). + WithResource(args.ResourceType) + + resp, err := c.AccessControl.GetResourceDescriptionWithParams(params) + if err != nil { + return nil, fmt.Errorf("get resource description: %w", err) + } + + return resp.Payload, nil +} + +var GetResourceDescription = mcpgrafana.MustTool( + "get_resource_description", + "List available permissions and assignment capabilities for a Grafana resource type.", + getResourceDescription, + mcp.WithTitleAnnotation("Get resource description"), + mcp.WithIdempotentHintAnnotation(true), + mcp.WithReadOnlyHintAnnotation(true), +) + func AddAdminTools(mcp *server.MCPServer) { ListTeams.Register(mcp) ListUsersByOrg.Register(mcp) + ListAllRoles.Register(mcp) + GetRoleDetails.Register(mcp) + GetRoleAssignments.Register(mcp) + ListUserRoles.Register(mcp) + ListTeamRoles.Register(mcp) + GetResourcePermissions.Register(mcp) + GetResourceDescription.Register(mcp) } diff --git a/tools/admin_test.go b/tools/admin_test.go index ff774d1b..29db1658 100644 --- a/tools/admin_test.go +++ b/tools/admin_test.go @@ -17,24 +17,60 @@ func TestAdminToolsUnit(t *testing.T) { // Test that the tools are properly defined with correct metadata require.NotNil(t, ListUsersByOrg, "ListUsersByOrg tool should be defined") require.NotNil(t, ListTeams, "ListTeams tool should be defined") + require.NotNil(t, ListAllRoles, "ListAllRoles tool should be defined") + require.NotNil(t, GetRoleDetails, "GetRoleDetails tool should be defined") + require.NotNil(t, GetRoleAssignments, "GetRoleAssignments tool should be defined") + require.NotNil(t, ListUserRoles, "ListUserRoles tool should be defined") + require.NotNil(t, GetResourcePermissions, "GetResourcePermissions tool should be defined") + require.NotNil(t, GetResourceDescription, "GetResourceDescription tool should be defined") // Verify tool metadata assert.Equal(t, "list_users_by_org", ListUsersByOrg.Tool.Name) assert.Equal(t, "list_teams", ListTeams.Tool.Name) + assert.Equal(t, "list_all_roles", ListAllRoles.Tool.Name) + assert.Equal(t, "get_role_details", GetRoleDetails.Tool.Name) + assert.Equal(t, "get_role_assignments", GetRoleAssignments.Tool.Name) + assert.Equal(t, "list_user_roles", ListUserRoles.Tool.Name) + assert.Equal(t, "list_team_roles", ListTeamRoles.Tool.Name) + assert.Equal(t, "get_resource_permissions", GetResourcePermissions.Tool.Name) + assert.Equal(t, "get_resource_description", GetResourceDescription.Tool.Name) assert.Contains(t, ListUsersByOrg.Tool.Description, "List users by organization") assert.Contains(t, ListTeams.Tool.Description, "Search for Grafana teams") + assert.Contains(t, ListAllRoles.Tool.Description, "List all roles in Grafana") + assert.Contains(t, GetRoleDetails.Tool.Description, "Get detailed information about a specific Grafana") + assert.Contains(t, GetRoleAssignments.Tool.Description, "List all assignments for a specific role") + assert.Contains(t, ListUserRoles.Tool.Description, "List all roles assigned to one or more users") + assert.Contains(t, ListTeamRoles.Tool.Description, "List all roles assigned to one or more teams") + assert.Contains(t, GetResourcePermissions.Tool.Description, "List all permissions set on a specific Grafana resource") + assert.Contains(t, GetResourceDescription.Tool.Description, "List available permissions and assignment capabilities") }) t.Run("parameter structures", func(t *testing.T) { // Test parameter types are correctly defined userParams := ListUsersByOrgParams{} teamParams := ListTeamsParams{Query: "test-query"} + roleParams := ListAllRolesParams{} + roleDetailParams := GetRoleDetailsParams{RoleUID: "r1"} + assignParams := GetRoleAssignmentsParams{RoleUID: "r2"} + userRoleParams := ListUserRolesParams{UserIDs: []int64{1, 2}} + teamRoleParams := ListTeamRolesParams{TeamIDs: []int64{3}} + permParams := GetResourcePermissionsParams{Resource: "dashboards", ResourceID: "abc"} + descParams := GetResourceDescriptionParams{ResourceType: "folders"} // ListUsersByOrgParams should be an empty struct (no parameters required) assert.IsType(t, ListUsersByOrgParams{}, userParams) // ListTeamsParams should have a Query field assert.Equal(t, "test-query", teamParams.Query) + + assert.IsType(t, ListAllRolesParams{}, roleParams) + assert.Equal(t, "r1", roleDetailParams.RoleUID) + assert.Equal(t, "r2", assignParams.RoleUID) + assert.Equal(t, []int64{1, 2}, userRoleParams.UserIDs) + assert.Equal(t, []int64{3}, teamRoleParams.TeamIDs) + assert.Equal(t, "dashboards", permParams.Resource) + assert.Equal(t, "abc", permParams.ResourceID) + assert.Equal(t, "folders", descParams.ResourceType) }) t.Run("nil client handling", func(t *testing.T) { @@ -50,6 +86,39 @@ func TestAdminToolsUnit(t *testing.T) { assert.Panics(t, func() { listTeams(ctx, ListTeamsParams{}) }, "Should panic when no Grafana client in context") + + assert.Panics(t, func() { + listAllRoles(ctx, ListAllRolesParams{}) + }, "Should panic when no Grafana client in context") + + assert.Panics(t, func() { + getRoleDetails(ctx, GetRoleDetailsParams{RoleUID: "x"}) + }, "Should panic when no Grafana client in context") + + assert.Panics(t, func() { + getRoleAssignments(ctx, GetRoleAssignmentsParams{RoleUID: "x"}) + }, "Should panic when no Grafana client in context") + + assert.Panics(t, func() { + listUserRoles(ctx, ListUserRolesParams{UserIDs: []int64{1}}) + }, "Should panic when no Grafana client in context") + + assert.Panics(t, func() { + listTeamRoles(ctx, ListTeamRolesParams{TeamIDs: []int64{2}}) + }, "Should panic when no Grafana client in context") + + assert.Panics(t, func() { + getResourcePermissions(ctx, GetResourcePermissionsParams{ + Resource: "dashboards", + ResourceID: "x", + }) + }, "Should panic when no Grafana client in context") + + assert.Panics(t, func() { + getResourceDescription(ctx, GetResourceDescriptionParams{ + ResourceType: "folders", + }) + }, "Should panic when no Grafana client in context") }) t.Run("function signatures", func(t *testing.T) { @@ -71,5 +140,38 @@ func TestAdminToolsUnit(t *testing.T) { assert.Panics(t, func() { listTeams(ctx, ListTeamsParams{Query: "test"}) }) + + assert.Panics(t, func() { + listAllRoles(ctx, ListAllRolesParams{}) + }) + + assert.Panics(t, func() { + getRoleDetails(ctx, GetRoleDetailsParams{RoleUID: "r1"}) + }) + + assert.Panics(t, func() { + getRoleAssignments(ctx, GetRoleAssignmentsParams{RoleUID: "r2"}) + }) + + assert.Panics(t, func() { + listUserRoles(ctx, ListUserRolesParams{UserIDs: []int64{1}}) + }) + + assert.Panics(t, func() { + listTeamRoles(ctx, ListTeamRolesParams{TeamIDs: []int64{2}}) + }) + + assert.Panics(t, func() { + getResourcePermissions(ctx, GetResourcePermissionsParams{ + Resource: "dashboards", + ResourceID: "abc", + }) + }) + + assert.Panics(t, func() { + getResourceDescription(ctx, GetResourceDescriptionParams{ + ResourceType: "folders", + }) + }) }) }