diff --git a/.gitignore b/.gitignore
index fbb5781..4d5d908 100644
--- a/.gitignore
+++ b/.gitignore
@@ -371,4 +371,10 @@ site/
 
 .aider.*
 PROMPT.md
-.aider*
\ No newline at end of file
+.aider*
+.windsurfrules
+
+## Python pip/env files
+Pipfile
+Pipfile.lock
+.venv
\ No newline at end of file
diff --git a/src/EllieBot/Migrations/PostgreSql/20250310101121_userroles.sql b/src/EllieBot/Migrations/PostgreSql/20250310101121_userroles.sql
new file mode 100644
index 0000000..e0d757e
--- /dev/null
+++ b/src/EllieBot/Migrations/PostgreSql/20250310101121_userroles.sql
@@ -0,0 +1,16 @@
+START TRANSACTION;
+CREATE TABLE userrole (
+    guildid numeric(20,0) NOT NULL,
+    userid numeric(20,0) NOT NULL,
+    roleid numeric(20,0) NOT NULL,
+    CONSTRAINT pk_userrole PRIMARY KEY (guildid, userid, roleid)
+);
+
+CREATE INDEX ix_userrole_guildid ON userrole (guildid);
+
+CREATE INDEX ix_userrole_guildid_userid ON userrole (guildid, userid);
+
+INSERT INTO "__EFMigrationsHistory" (migrationid, productversion)
+VALUES ('20250310101121_userroles', '9.0.1');
+
+COMMIT;
diff --git a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.Designer.cs b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs
similarity index 99%
rename from src/EllieBot/Migrations/PostgreSql/20250228044209_init.Designer.cs
rename to src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs
index 02ad1c2..8ecf1ff 100644
--- a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.Designer.cs
+++ b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.Designer.cs
@@ -12,7 +12,7 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
 namespace EllieBot.Migrations.PostgreSql
 {
     [DbContext(typeof(PostgreSqlContext))]
-    [Migration("20250228044209_init")]
+    [Migration("20250310101144_init")]
     partial class init
     {
         /// <inheritdoc />
@@ -3444,6 +3444,32 @@ namespace EllieBot.Migrations.PostgreSql
                 b.ToTable("userfishstats", (string)null);
             });
 
+            modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
+            {
+                b.Property<decimal>("GuildId")
+                    .HasColumnType("numeric(20,0)")
+                    .HasColumnName("guildid");
+
+                b.Property<decimal>("UserId")
+                    .HasColumnType("numeric(20,0)")
+                    .HasColumnName("userid");
+
+                b.Property<decimal>("RoleId")
+                    .HasColumnType("numeric(20,0)")
+                    .HasColumnName("roleid");
+
+                b.HasKey("GuildId", "UserId", "RoleId")
+                    .HasName("pk_userrole");
+
+                b.HasIndex("GuildId")
+                    .HasDatabaseName("ix_userrole_guildid");
+
+                b.HasIndex("GuildId", "UserId")
+                    .HasDatabaseName("ix_userrole_guildid_userid");
+
+                b.ToTable("userrole", (string)null);
+            });
+
             modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
             {
                 b.Property<int>("Id")
diff --git a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.cs b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs
similarity index 99%
rename from src/EllieBot/Migrations/PostgreSql/20250228044209_init.cs
rename to src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs
index a99bf07..22e4c85 100644
--- a/src/EllieBot/Migrations/PostgreSql/20250228044209_init.cs
+++ b/src/EllieBot/Migrations/PostgreSql/20250310101144_init.cs
@@ -1078,6 +1078,19 @@ namespace EllieBot.Migrations.PostgreSql
                     table.PrimaryKey("pk_userfishstats", x => x.id);
                 });
 
+            migrationBuilder.CreateTable(
+                name: "userrole",
+                columns: table => new
+                {
+                    guildid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
+                    userid = table.Column<decimal>(type: "numeric(20,0)", nullable: false),
+                    roleid = table.Column<decimal>(type: "numeric(20,0)", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("pk_userrole", x => new { x.guildid, x.userid, x.roleid });
+                });
+
             migrationBuilder.CreateTable(
                 name: "userxpstats",
                 columns: table => new
@@ -2131,6 +2144,16 @@ namespace EllieBot.Migrations.PostgreSql
                 column: "userid",
                 unique: true);
 
+            migrationBuilder.CreateIndex(
+                name: "ix_userrole_guildid",
+                table: "userrole",
+                column: "guildid");
+
+            migrationBuilder.CreateIndex(
+                name: "ix_userrole_guildid_userid",
+                table: "userrole",
+                columns: new[] { "guildid", "userid" });
+
             migrationBuilder.CreateIndex(
                 name: "ix_userxpstats_guildid",
                 table: "userxpstats",
@@ -2492,6 +2515,9 @@ namespace EllieBot.Migrations.PostgreSql
             migrationBuilder.DropTable(
                 name: "userfishstats");
 
+            migrationBuilder.DropTable(
+                name: "userrole");
+
             migrationBuilder.DropTable(
                 name: "userxpstats");
 
diff --git a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs
index e40eb15..cbc6220 100644
--- a/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs
+++ b/src/EllieBot/Migrations/PostgreSql/PostgreSqlContextModelSnapshot.cs
@@ -3441,6 +3441,32 @@ namespace EllieBot.Migrations.PostgreSql
                 b.ToTable("userfishstats", (string)null);
             });
 
+            modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
+            {
+                b.Property<decimal>("GuildId")
+                    .HasColumnType("numeric(20,0)")
+                    .HasColumnName("guildid");
+
+                b.Property<decimal>("UserId")
+                    .HasColumnType("numeric(20,0)")
+                    .HasColumnName("userid");
+
+                b.Property<decimal>("RoleId")
+                    .HasColumnType("numeric(20,0)")
+                    .HasColumnName("roleid");
+
+                b.HasKey("GuildId", "UserId", "RoleId")
+                    .HasName("pk_userrole");
+
+                b.HasIndex("GuildId")
+                    .HasDatabaseName("ix_userrole_guildid");
+
+                b.HasIndex("GuildId", "UserId")
+                    .HasDatabaseName("ix_userrole_guildid_userid");
+
+                b.ToTable("userrole", (string)null);
+            });
+
             modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
             {
                 b.Property<int>("Id")
diff --git a/src/EllieBot/Migrations/Sqlite/20250310101118_userroles.sql b/src/EllieBot/Migrations/Sqlite/20250310101118_userroles.sql
new file mode 100644
index 0000000..f5d2c57
--- /dev/null
+++ b/src/EllieBot/Migrations/Sqlite/20250310101118_userroles.sql
@@ -0,0 +1,16 @@
+BEGIN TRANSACTION;
+CREATE TABLE "UserRole" (
+    "GuildId" INTEGER NOT NULL,
+    "UserId" INTEGER NOT NULL,
+    "RoleId" INTEGER NOT NULL,
+    CONSTRAINT "PK_UserRole" PRIMARY KEY ("GuildId", "UserId", "RoleId")
+);
+
+CREATE INDEX "IX_UserRole_GuildId" ON "UserRole" ("GuildId");
+
+CREATE INDEX "IX_UserRole_GuildId_UserId" ON "UserRole" ("GuildId", "UserId");
+
+INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
+VALUES ('20250310101118_userroles', '9.0.1');
+
+COMMIT;
diff --git a/src/EllieBot/Migrations/Sqlite/20250228044206_init.Designer.cs b/src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs
similarity index 99%
rename from src/EllieBot/Migrations/Sqlite/20250228044206_init.Designer.cs
rename to src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs
index f1d82e7..962bbd2 100644
--- a/src/EllieBot/Migrations/Sqlite/20250228044206_init.Designer.cs
+++ b/src/EllieBot/Migrations/Sqlite/20250310101142_init.Designer.cs
@@ -11,7 +11,7 @@ using EllieBot.Db;
 namespace EllieBot.Migrations.Sqlite
 {
     [DbContext(typeof(SqliteContext))]
-    [Migration("20250228044206_init")]
+    [Migration("20250310101142_init")]
     partial class init
     {
         /// <inheritdoc />
@@ -2563,6 +2563,26 @@ namespace EllieBot.Migrations.Sqlite
                 b.ToTable("UserFishStats");
             });
 
+            modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
+            {
+                b.Property<ulong>("GuildId")
+                    .HasColumnType("INTEGER");
+
+                b.Property<ulong>("UserId")
+                    .HasColumnType("INTEGER");
+
+                b.Property<ulong>("RoleId")
+                    .HasColumnType("INTEGER");
+
+                b.HasKey("GuildId", "UserId", "RoleId");
+
+                b.HasIndex("GuildId");
+
+                b.HasIndex("GuildId", "UserId");
+
+                b.ToTable("UserRole");
+            });
+
             modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
             {
                 b.Property<int>("Id")
diff --git a/src/EllieBot/Migrations/Sqlite/20250228044206_init.cs b/src/EllieBot/Migrations/Sqlite/20250310101142_init.cs
similarity index 99%
rename from src/EllieBot/Migrations/Sqlite/20250228044206_init.cs
rename to src/EllieBot/Migrations/Sqlite/20250310101142_init.cs
index 7e5aeba..3ac77bd 100644
--- a/src/EllieBot/Migrations/Sqlite/20250228044206_init.cs
+++ b/src/EllieBot/Migrations/Sqlite/20250310101142_init.cs
@@ -1080,6 +1080,19 @@ namespace EllieBot.Migrations.Sqlite
                     table.PrimaryKey("PK_UserFishStats", x => x.Id);
                 });
 
+            migrationBuilder.CreateTable(
+                name: "UserRole",
+                columns: table => new
+                {
+                    GuildId = table.Column<ulong>(type: "INTEGER", nullable: false),
+                    UserId = table.Column<ulong>(type: "INTEGER", nullable: false),
+                    RoleId = table.Column<ulong>(type: "INTEGER", nullable: false)
+                },
+                constraints: table =>
+                {
+                    table.PrimaryKey("PK_UserRole", x => new { x.GuildId, x.UserId, x.RoleId });
+                });
+
             migrationBuilder.CreateTable(
                 name: "UserXpStats",
                 columns: table => new
@@ -2133,6 +2146,16 @@ namespace EllieBot.Migrations.Sqlite
                 column: "UserId",
                 unique: true);
 
+            migrationBuilder.CreateIndex(
+                name: "IX_UserRole_GuildId",
+                table: "UserRole",
+                column: "GuildId");
+
+            migrationBuilder.CreateIndex(
+                name: "IX_UserRole_GuildId_UserId",
+                table: "UserRole",
+                columns: new[] { "GuildId", "UserId" });
+
             migrationBuilder.CreateIndex(
                 name: "IX_UserXpStats_GuildId",
                 table: "UserXpStats",
@@ -2494,6 +2517,9 @@ namespace EllieBot.Migrations.Sqlite
             migrationBuilder.DropTable(
                 name: "UserFishStats");
 
+            migrationBuilder.DropTable(
+                name: "UserRole");
+
             migrationBuilder.DropTable(
                 name: "UserXpStats");
 
diff --git a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs
index 77fa0b9..fa6e48c 100644
--- a/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs
+++ b/src/EllieBot/Migrations/Sqlite/SqliteContextModelSnapshot.cs
@@ -2560,6 +2560,26 @@ namespace EllieBot.Migrations.Sqlite
                 b.ToTable("UserFishStats");
             });
 
+            modelBuilder.Entity("EllieBot.Modules.Utility.UserRole.UserRole", b =>
+            {
+                b.Property<ulong>("GuildId")
+                    .HasColumnType("INTEGER");
+
+                b.Property<ulong>("UserId")
+                    .HasColumnType("INTEGER");
+
+                b.Property<ulong>("RoleId")
+                    .HasColumnType("INTEGER");
+
+                b.HasKey("GuildId", "UserId", "RoleId");
+
+                b.HasIndex("GuildId");
+
+                b.HasIndex("GuildId", "UserId");
+
+                b.ToTable("UserRole");
+            });
+
             modelBuilder.Entity("EllieBot.Modules.Xp.ChannelXpConfig", b =>
             {
                 b.Property<int>("Id")
diff --git a/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs b/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs
index afc172d..38c392e 100644
--- a/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs
+++ b/src/EllieBot/Modules/Utility/Quote/QuoteCommands.cs
@@ -20,7 +20,6 @@ public partial class Utility
             #   txt: Quote text
 
             """;
-
         private static readonly ISerializer _exportSerializer = new SerializerBuilder()
                                                                 .WithEventEmitter(args
                                                                     => new MultilineScalarFlowStyleEmitter(args))
diff --git a/src/EllieBot/Modules/Utility/UserRole/IDiscordRoleManager.cs b/src/EllieBot/Modules/Utility/UserRole/IDiscordRoleManager.cs
new file mode 100644
index 0000000..41b98b4
--- /dev/null
+++ b/src/EllieBot/Modules/Utility/UserRole/IDiscordRoleManager.cs
@@ -0,0 +1,21 @@
+namespace EllieBot.Modules.Utility.UserRole;
+
+public interface IDiscordRoleManager
+{
+    /// <summary>
+    /// Modifies a role's properties in Discord
+    /// </summary>
+    /// <param name="guildId">The ID of the guild containing the role</param>
+    /// <param name="roleId">ID of the role to modify</param>
+    /// <param name="name">New name for the role (optional)</param>
+    /// <param name="color">New color for the role (optional)</param>
+    /// <param name="image">Image for the role (optional)</param>
+    /// <returns>True if successful, false otherwise</returns>
+    Task<bool> ModifyRoleAsync(
+        ulong guildId,
+        ulong roleId,
+        string? name = null,
+        Color? color = null,
+        Image? image = null
+    );
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Utility/UserRole/IUserRoleService.cs b/src/EllieBot/Modules/Utility/UserRole/IUserRoleService.cs
new file mode 100644
index 0000000..399c604
--- /dev/null
+++ b/src/EllieBot/Modules/Utility/UserRole/IUserRoleService.cs
@@ -0,0 +1,76 @@
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace EllieBot.Modules.Utility.UserRole;
+
+public interface IUserRoleService
+{
+    /// <summary>
+    /// Assigns a role to a user and updates both database and Discord
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="userId">ID of the user</param>
+    /// <param name="roleId">ID of the role</param>
+    /// <returns>True if successful, false otherwise</returns>
+    Task<bool> AddRoleAsync(ulong guildId, ulong userId, ulong roleId);
+
+    /// <summary>
+    /// Removes a role from a user and updates both database and Discord
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="userId">ID of the user</param>
+    /// <param name="roleId">ID of the role</param>
+    /// <returns>True if successful, false otherwise</returns>
+    Task<bool> RemoveRoleAsync(ulong guildId, ulong userId, ulong roleId);
+
+    /// <summary>
+    /// Gets all user roles for a guild
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    Task<IReadOnlyCollection<UserRole>> ListRolesAsync(ulong guildId);
+
+    /// <summary>
+    /// Gets all roles for a specific user in a guild
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="userId">ID of the user</param>
+    Task<IReadOnlyCollection<UserRole>> ListUserRolesAsync(ulong guildId, ulong userId);
+
+    /// <summary>
+    /// Sets the custom color for a user's role and updates both database and Discord
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="userId">ID of the user</param>
+    /// <param name="roleId">ID of the role</param>
+    /// <param name="color">Hex color code</param>
+    /// <returns>True if successful, false otherwise</returns>
+    Task<bool> SetRoleColorAsync(ulong guildId, ulong userId, ulong roleId, Rgba32 color);
+
+    /// <summary>
+    /// Sets the custom name for a user's role and updates both database and Discord
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="userId">ID of the user</param>
+    /// <param name="roleId">ID of the role</param>
+    /// <param name="name">New role name</param>
+    /// <returns>True if successful, false otherwise</returns>
+    Task<bool> SetRoleNameAsync(ulong guildId, ulong userId, ulong roleId, string name);
+
+    /// <summary>
+    /// Sets the custom icon for a user's role and updates both database and Discord
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="userId">ID of the user</param>
+    /// <param name="roleId">ID of the role</param>
+    /// <param name="icon">Icon URL or emoji</param>
+    /// <returns>True if successful, false otherwise</returns>
+    Task<bool> SetRoleIconAsync(ulong guildId, ulong userId, ulong roleId, string icon);
+
+    /// <summary>
+    /// Checks if a user has a specific role assigned
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="userId">ID of the user</param>
+    /// <param name="roleId">ID of the role</param>
+    /// <returns>True if the user has the role, false otherwise</returns>
+    Task<bool> UserOwnsRoleAsync(ulong guildId, ulong userId, ulong roleId);
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRole.cs b/src/EllieBot/Modules/Utility/UserRole/UserRole.cs
new file mode 100644
index 0000000..1c667b3
--- /dev/null
+++ b/src/EllieBot/Modules/Utility/UserRole/UserRole.cs
@@ -0,0 +1,38 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace EllieBot.Modules.Utility.UserRole;
+
+/// <summary>
+/// Represents a user's assigned role in a guild
+/// </summary>
+public class UserRole
+{
+    /// <summary>
+    /// ID of the guild
+    /// </summary>
+    public ulong GuildId { get; set; }
+
+    /// <summary>
+    /// ID of the user
+    /// </summary>
+    public ulong UserId { get; set; }
+
+    /// <summary>
+    /// ID of the Discord role
+    /// </summary>
+    public ulong RoleId { get; set; }
+}
+
+public class UserRoleConfiguration : IEntityTypeConfiguration<UserRole>
+{
+    public void Configure(EntityTypeBuilder<UserRole> builder)
+    {
+        // Set composite primary key
+        builder.HasKey(x => new { x.GuildId, x.UserId, x.RoleId });
+
+        // Create indexes for frequently queried columns
+        builder.HasIndex(x => x.GuildId);
+        builder.HasIndex(x => new { x.GuildId, x.UserId });
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs b/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs
new file mode 100644
index 0000000..1952001
--- /dev/null
+++ b/src/EllieBot/Modules/Utility/UserRole/UserRoleCommands.cs
@@ -0,0 +1,253 @@
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace EllieBot.Modules.Utility.UserRole;
+
+public partial class Utility
+{
+    [Group]
+    public class UserRoleCommands : EllieModule
+    {
+        private readonly IUserRoleService _urs;
+
+        public UserRoleCommands(IUserRoleService userRoleService)
+        {
+            _urs = userRoleService;
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageRoles)]
+        public async Task UserRoleAssign(IGuildUser user, IRole role)
+        {
+            var modUser = (IGuildUser)ctx.User;
+
+            if (modUser.GetRoles().Max(x => x.Position) <= role.Position)
+            {
+                await Response().Error(strs.userrole_hierarchy_error).SendAsync();
+                return;
+            }
+
+            var success = await _urs.AddRoleAsync(ctx.Guild.Id, user.Id, role.Id);
+
+            if (!success)
+                return;
+
+            await Response()
+                .Confirm(strs.userrole_assigned(Format.Bold(user.ToString()), Format.Bold(role.Name)))
+                .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageRoles)]
+        public async Task UserRoleRemove(IUser user, IRole role)
+        {
+            var modUser = (IGuildUser)ctx.User;
+
+            if (modUser.GetRoles().Max(x => x.Position) <= role.Position)
+            {
+                await Response().Error(strs.userrole_hierarchy_error).SendAsync();
+                return;
+            }
+
+            var success = await _urs.RemoveRoleAsync(ctx.Guild.Id, user.Id, role.Id);
+            if (!success)
+            {
+                await Response().Error(strs.userrole_not_found).SendAsync();
+                return;
+            }
+
+            await Response()
+                .Confirm(strs.userrole_removed(Format.Bold(user.ToString()), Format.Bold(role.Name)))
+                .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageRoles)]
+        public async Task UserRoleList()
+        {
+            var roles = await _urs.ListRolesAsync(ctx.Guild.Id);
+
+            if (roles.Count == 0)
+            {
+                await Response().Error(strs.userrole_none).SendAsync();
+                return;
+            }
+
+            var guild = ctx.Guild as SocketGuild;
+
+            // Group roles by user
+            var userGroups = roles.GroupBy(r => r.UserId)
+                .Select(g => (UserId: g.Key,
+                    UserName: guild?.GetUser(g.Key)?.ToString() ?? g.Key.ToString(),
+                    Roles: g.ToList()))
+                .ToList();
+
+            await Response()
+                .Paginated()
+                .Items(userGroups)
+                .PageSize(5)
+                .Page((pageUsers, _) =>
+                {
+                    var eb = CreateEmbed()
+                        .WithTitle(GetText(strs.userrole_list_title))
+                        .WithOkColor();
+
+                    foreach (var user in pageUsers)
+                    {
+                        var roleNames = user.Roles
+                            .Select(r => $"- {guild?.GetRole(r.RoleId)} `{r.RoleId}`")
+                            .Join("\n");
+                        eb.AddField(user.UserName, roleNames);
+                    }
+
+                    return eb;
+                })
+                .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [UserPerm(GuildPerm.ManageRoles)]
+        [BotPerm(GuildPerm.ManageRoles)]
+        public async Task UserRoleList(IUser user)
+        {
+            var roles = await _urs.ListUserRolesAsync(ctx.Guild.Id, user.Id);
+
+            if (roles.Count == 0)
+            {
+                await Response()
+                    .Error(strs.userrole_none_user(Format.Bold(user.ToString())))
+                    .SendAsync();
+                return;
+            }
+
+            var guild = ctx.Guild as SocketGuild;
+
+            await Response()
+                .Paginated()
+                .Items(roles)
+                .PageSize(10)
+                .Page((pageRoles, _) =>
+                {
+                    var roleList = pageRoles
+                        .Select(r => $"- {guild?.GetRole(r.RoleId)} `{r.RoleId}`")
+                        .Join("\n");
+
+                    return CreateEmbed()
+                        .WithTitle(GetText(strs.userrole_list_for_user(Format.Bold(user.ToString()))))
+                        .WithDescription(roleList)
+                        .WithOkColor();
+                })
+                .SendAsync();
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        public async Task UserRoleMy()
+            => await UserRoleList(ctx.User);
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [BotPerm(GuildPerm.ManageRoles)]
+        public async Task UserRoleColor(IRole role, Rgba32 color)
+        {
+            if (!await _urs.UserOwnsRoleAsync(ctx.Guild.Id, ctx.User.Id, role.Id))
+            {
+                await Response().Error(strs.userrole_no_permission).SendAsync();
+                return;
+            }
+
+            var success = await _urs.SetRoleColorAsync(
+                ctx.Guild.Id,
+                ctx.User.Id,
+                role.Id,
+                color
+            );
+
+            if (success)
+            {
+                await Response().Confirm(strs.userrole_color_success(Format.Bold(role.Name), color)).SendAsync();
+            }
+            else
+            {
+                await Response().Error(strs.userrole_color_fail).SendAsync();
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [BotPerm(GuildPerm.ManageRoles)]
+        public async Task UserRoleName(IRole role, [Leftover] string name)
+        {
+            if (string.IsNullOrWhiteSpace(name) || name.Length > 100)
+            {
+                await Response().Error(strs.userrole_name_invalid).SendAsync();
+                return;
+            }
+
+            if (!await _urs.UserOwnsRoleAsync(ctx.Guild.Id, ctx.User.Id, role.Id))
+            {
+                await Response().Error(strs.userrole_no_permission).SendAsync();
+                return;
+            }
+
+            var success = await _urs.SetRoleNameAsync(
+                ctx.Guild.Id,
+                ctx.User.Id,
+                role.Id,
+                name
+            );
+
+            if (success)
+            {
+                await Response().Confirm(strs.userrole_name_success(Format.Bold(name))).SendAsync();
+            }
+            else
+            {
+                await Response().Error(strs.userrole_name_fail).SendAsync();
+            }
+        }
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [BotPerm(GuildPerm.ManageRoles)]
+        public Task UserRoleIcon(IRole role, Emote emote)
+            => UserRoleIcon(role, emote.Url);
+
+        [Cmd]
+        [RequireContext(ContextType.Guild)]
+        [BotPerm(GuildPerm.ManageRoles)]
+        public async Task UserRoleIcon(IRole role, [Leftover] string icon)
+        {
+            if (string.IsNullOrWhiteSpace(icon))
+            {
+                await Response().Error(strs.userrole_icon_invalid).SendAsync();
+                return;
+            }
+
+            if (!await _urs.UserOwnsRoleAsync(ctx.Guild.Id, ctx.User.Id, role.Id))
+            {
+                await Response().Error(strs.userrole_no_permission).SendAsync();
+                return;
+            }
+
+            var success = await _urs.SetRoleIconAsync(
+                ctx.Guild.Id,
+                ctx.User.Id,
+                role.Id,
+                icon
+            );
+
+            if (success)
+            {
+                await Response().Confirm(strs.userrole_icon_success(Format.Bold(role.Name))).SendAsync();
+            }
+            else
+            {
+                await Response().Error(strs.userrole_icon_fail).SendAsync();
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRoleDiscordManager.cs b/src/EllieBot/Modules/Utility/UserRole/UserRoleDiscordManager.cs
new file mode 100644
index 0000000..9d60260
--- /dev/null
+++ b/src/EllieBot/Modules/Utility/UserRole/UserRoleDiscordManager.cs
@@ -0,0 +1,52 @@
+namespace EllieBot.Modules.Utility.UserRole;
+
+public class UserRoleDiscordManager(DiscordSocketClient client) : IDiscordRoleManager, IEService
+{
+    /// <summary>
+    /// Modifies a role's properties in Discord
+    /// </summary>
+    /// <param name="guildId">ID of the guild</param>
+    /// <param name="roleId">ID of the role to modify</param>
+    /// <param name="name">New name for the role (optional)</param>
+    /// <param name="color">New color for the role (optional)</param>
+    /// <param name="image">New emoji for the role (optional)</param>
+    /// <returns>True if successful, false otherwise</returns>
+    public async Task<bool> ModifyRoleAsync(
+        ulong guildId,
+        ulong roleId,
+        string? name = null,
+        Color? color = null,
+        Image? image = null
+    )
+    {
+        try
+        {
+            var guild = client.GetGuild(guildId);
+            if (guild is null)
+                return false;
+
+            var role = guild.GetRole(roleId);
+            if (role is null)
+                return false;
+
+            await role.ModifyAsync(properties =>
+            {
+                if (name is not null)
+                    properties.Name = name;
+
+                if (color is not null)
+                    properties.Color = color.Value;
+
+                if (image is not null)
+                    properties.Icon = image;
+            });
+
+            return true;
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "Unable to modify role {RoleId}", roleId);
+            return false;
+        }
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/Modules/Utility/UserRole/UserRoleService.cs b/src/EllieBot/Modules/Utility/UserRole/UserRoleService.cs
new file mode 100644
index 0000000..d500cb0
--- /dev/null
+++ b/src/EllieBot/Modules/Utility/UserRole/UserRoleService.cs
@@ -0,0 +1,184 @@
+#nullable disable warnings
+using LinqToDB;
+using LinqToDB.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace EllieBot.Modules.Utility.UserRole;
+
+public sealed class UserRoleService : IUserRoleService, IEService
+{
+    private readonly DbService _db;
+    private readonly IDiscordRoleManager _discordRoleManager;
+    private readonly IHttpClientFactory _httpClientFactory;
+
+    public UserRoleService(
+        DbService db,
+        IDiscordRoleManager discordRoleManager,
+        IHttpClientFactory httpClientFactory)
+    {
+        _db = db;
+        _discordRoleManager = discordRoleManager;
+        _httpClientFactory = httpClientFactory;
+    }
+
+    /// <summary>
+    /// Assigns a role to a user and updates both database and Discord
+    /// </summary>
+    public async Task<bool> AddRoleAsync(ulong guildId, ulong userId, ulong roleId)
+    {
+        await using var ctx = _db.GetDbContext();
+        await ctx.GetTable<UserRole>()
+            .InsertOrUpdateAsync(() => new UserRole
+            {
+                GuildId = guildId,
+                UserId = userId,
+                RoleId = roleId,
+            },
+                _ => new() { },
+                () => new()
+                {
+                    GuildId = guildId,
+                    UserId = userId,
+                    RoleId = roleId
+                });
+
+        return true;
+    }
+
+    /// <summary>
+    /// Removes a role from a user and updates both database and Discord
+    /// </summary>
+    public async Task<bool> RemoveRoleAsync(ulong guildId, ulong userId, ulong roleId)
+    {
+        await using var ctx = _db.GetDbContext();
+
+        var deleted = await ctx.GetTable<UserRole>()
+            .Where(r => r.GuildId == guildId && r.UserId == userId && r.RoleId == roleId)
+            .DeleteAsync();
+
+        return deleted > 0;
+    }
+
+    /// <summary>
+    /// Gets all user roles for a guild
+    /// </summary>
+    public async Task<IReadOnlyCollection<UserRole>> ListRolesAsync(ulong guildId)
+    {
+        await using var ctx = _db.GetDbContext();
+
+        var roles = await ctx.GetTable<UserRole>()
+            .AsNoTracking()
+            .Where(r => r.GuildId == guildId)
+            .ToListAsyncLinqToDB();
+
+        return roles;
+    }
+
+    /// <summary>
+    /// Gets all roles for a specific user in a guild
+    /// </summary>
+    public async Task<IReadOnlyCollection<UserRole>> ListUserRolesAsync(ulong guildId, ulong userId)
+    {
+        await using var ctx = _db.GetDbContext();
+
+        var roles = await ctx.GetTable<UserRole>()
+            .AsNoTracking()
+            .Where(r => r.GuildId == guildId && r.UserId == userId)
+            .ToListAsyncLinqToDB();
+
+        return roles;
+    }
+
+    /// <summary>
+    /// Sets the custom color for a user's role and updates both database and Discord
+    /// </summary>
+    public async Task<bool> SetRoleColorAsync(ulong guildId, ulong userId, ulong roleId, Rgba32 color)
+    {
+        var discordSuccess = await _discordRoleManager.ModifyRoleAsync(
+            guildId,
+            roleId,
+            color: color.ToDiscordColor());
+
+        return discordSuccess;
+    }
+
+    /// <summary>
+    /// Sets the custom name for a user's role and updates both database and Discord
+    /// </summary>
+    public async Task<bool> SetRoleNameAsync(ulong guildId, ulong userId, ulong roleId, string name)
+    {
+        var discordSuccess = await _discordRoleManager.ModifyRoleAsync(
+            guildId,
+            roleId,
+            name: name);
+
+        return discordSuccess;
+    }
+
+    /// <summary>
+    /// Sets the custom icon for a user's role and updates both database and Discord
+    /// </summary>
+    public async Task<bool> SetRoleIconAsync(ulong guildId, ulong userId, ulong roleId, string iconUrl)
+    {
+        // Validate the URL format
+        if (!Uri.TryCreate(iconUrl, UriKind.Absolute, out var uri))
+            return false;
+
+        try
+        {
+            // Download the image
+            using var httpClient = _httpClientFactory.CreateClient();
+            using var response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead);
+
+            // Check if the response is successful
+            if (!response.IsSuccessStatusCode)
+                return false;
+
+            // Check content type - must be image/png or image/jpeg
+            var contentType = response.Content.Headers.ContentType?.MediaType?.ToLower();
+            if (contentType != "image/png"
+                && contentType != "image/jpeg"
+                && contentType != "image/webp")
+                return false;
+
+            // Check file size - Discord limit is 256KB
+            var contentLength = response.Content.Headers.ContentLength;
+            if (contentLength is > 256 * 1024)
+                return false;
+
+            // Save the image to a memory stream
+            await using var stream = await response.Content.ReadAsStreamAsync();
+            await using var memoryStream = new MemoryStream();
+            await stream.CopyToAsync(memoryStream);
+            memoryStream.Position = 0;
+
+            // Create Discord image from stream
+            var discordImage = new Image(memoryStream);
+
+            // Upload the image to Discord
+            var discordSuccess = await _discordRoleManager.ModifyRoleAsync(
+                guildId,
+                roleId,
+                image: discordImage);
+
+            return discordSuccess;
+        }
+        catch (Exception ex)
+        {
+            Log.Warning(ex, "Failed to process role icon from URL {IconUrl}", iconUrl);
+            return false;
+        }
+    }
+
+    /// <summary>
+    /// Checks if a user has a specific role assigned
+    /// </summary>
+    public async Task<bool> UserOwnsRoleAsync(ulong guildId, ulong userId, ulong roleId)
+    {
+        await using var ctx = _db.GetDbContext();
+
+        return await ctx.GetTable<UserRole>()
+            .AnyAsyncLinqToDB(r => r.GuildId == guildId && r.UserId == userId && r.RoleId == roleId);
+    }
+}
\ No newline at end of file
diff --git a/src/EllieBot/strings/aliases.yml b/src/EllieBot/strings/aliases.yml
index 8f2929a..554ad0f 100644
--- a/src/EllieBot/strings/aliases.yml
+++ b/src/EllieBot/strings/aliases.yml
@@ -1588,4 +1588,28 @@ xprate:
 xpratereset:
   - xpratereset
 lyrics:
-   - lyrics
\ No newline at end of file
+   - lyrics
+userroleassign:
+   - userroleassign
+   - ura
+   - uradd
+userroleremove:
+   - userroleremove
+   - urr
+   - urdel
+   - urrm
+userrolelist:
+   - userrolelist
+   - url
+userrolemy:
+   - userrolemy
+   - urm
+userrolecolor:
+   - userrolecolor
+   - urc
+userrolename:
+   - userrolename
+   - urn
+userroleicon:
+   - userroleicon
+   - uri
\ No newline at end of file
diff --git a/src/EllieBot/strings/commands/commands.en-US.yml b/src/EllieBot/strings/commands/commands.en-US.yml
index e71adcf..d8ac450 100644
--- a/src/EllieBot/strings/commands/commands.en-US.yml
+++ b/src/EllieBot/strings/commands/commands.en-US.yml
@@ -4985,4 +4985,75 @@ lyrics:
      - 'biri biri'
    params:
      - song:
-         desc: "The song to look up lyrics for."
\ No newline at end of file
+         desc: "The song to look up lyrics for."
+userroleassign:
+   desc: |-
+     Assigns a role to a user that can later be modified by that user.
+   ex:
+     - '@User @Role'
+   params:
+     - user:
+         desc: 'The user to assign the role to.'
+       role:
+         desc: 'The role to assign.'
+userroleremove:
+   desc: |-
+     Removes a previously assigned role from a user.
+   ex:
+     - '@User @Role'
+   params:
+     - user:
+         desc: 'The user to remove the role from.'
+       role:
+         desc: 'The role to remove.' 
+userrolelist:
+   desc: |-
+     Lists all user roles in the server, or for a specific user.
+   ex:
+     - ''
+     - '@User'
+   params:
+     - { }
+     - user:
+         desc: 'The user whose roles to list.'
+userrolemy:
+   desc: |-
+     Lists all of the user roles assigned to you.
+   ex:
+     - ''
+   params:
+     - { }
+userrolecolor:
+   desc: |-
+     Changes the color of your assigned role.
+   ex:
+     - '@Role #ff0000'
+   params:
+     - role:
+         desc: 'The assigned role to change the color of.'
+       color:
+         desc: 'The new color for the role in hex format.' 
+userroleicon:
+   desc: |-
+     Changes the icon of your assigned role.
+   ex:
+     - '@Role :thumbsup:'
+   params:
+     - role:
+         desc: 'The assigned role to change the icon of.'
+       icon:
+         desc: 'The new icon for the role.'
+     - role:
+         desc: 'The assigned role to change the icon of.'
+       emote:
+         desc: 'The server emoji for the role.'
+userrolename:
+   desc: |-
+     Changes the name of your assigned role.
+   ex:
+     - '@Role New Role Name'
+   params:
+     - role:
+         desc: 'The assigned role to rename.'
+       name:
+         desc: 'The new name for the role.'
\ No newline at end of file
diff --git a/src/EllieBot/strings/responses/responses.en-US.json b/src/EllieBot/strings/responses/responses.en-US.json
index cd15917..b54e7ec 100644
--- a/src/EllieBot/strings/responses/responses.en-US.json
+++ b/src/EllieBot/strings/responses/responses.en-US.json
@@ -1179,5 +1179,28 @@
   "xp_rate_server_reset": "Server xp rate has been reset to global defaults.",
   "xp_rate_channel_reset": "Channel {0} xp rate has been reset.",
   "xp_rate_no_gain": "No xp gain",
-  "no_lyrics_found": "No lyrics found."
+  "no_lyrics_found": "No lyrics found.",
+  "userrole_not_found": "Role not found or not assigned to you.",
+  "userrole_assigned": "{0} has been assigned the role {1}.",
+  "userrole_removed": "{0} has been removed from the role {1}.",
+  "userrole_none": "No user roles have been assigned in this server.",
+  "userrole_none_user": "{0} has no assigned user roles.",
+  "userrole_list_title": "User Roles in this server",
+  "userrole_list_for_user": "{0}'s User Roles",
+  "userrole_no_permission": "You don't have this role assigned as a user role.",
+  "userrole_no_user_roles": "You have no assigned user roles.",
+  "userrole_your_roles_title": "Your User Roles",
+  "userrole_your_roles_footer": "You may customize these roles",
+  "userrole_color_success": "The color of {0} has been changed to {1}.",
+  "userrole_color_fail": "Failed to set the role color. Make sure you're using a valid hex color code (e.g., #FF0000).",
+  "userrole_color_discord_fail": "Failed to update the role color in Discord. The change was saved in the database.",
+  "userrole_name_success": "The name of your role has been changed to {0}.",
+  "userrole_name_fail": "Failed to set the role name.",
+  "userrole_name_invalid": "The role name must not be empty and must be less than 100 characters.",
+  "userrole_name_discord_fail": "Failed to update the role name in Discord. The change was saved in the database.",
+  "userrole_icon_success": "The icon for {0} has been saved.",
+  "userrole_icon_fail": "Failed to set the role icon.",
+  "userrole_icon_invalid": "The role icon cannot be empty.",
+  "userrole_hierarchy_error": "You can't assign or modify roles that are higher than or equal to your highest role.",
+  "userrole_role_not_exists": "That role doesn't exist."
 }