diff --git a/Moonlight.Api/Database/DataContext.cs b/Moonlight.Api/Database/DataContext.cs index 4c2a4852..ffbd2ad3 100644 --- a/Moonlight.Api/Database/DataContext.cs +++ b/Moonlight.Api/Database/DataContext.cs @@ -11,6 +11,7 @@ public class DataContext : DbContext public DbSet SettingsOptions { get; set; } public DbSet Roles { get; set; } public DbSet RoleMembers { get; set; } + public DbSet ApiKeys { get; set; } private readonly IOptions Options; diff --git a/Moonlight.Api/Database/Entities/ApiKey.cs b/Moonlight.Api/Database/Entities/ApiKey.cs new file mode 100644 index 00000000..5bbbd4f6 --- /dev/null +++ b/Moonlight.Api/Database/Entities/ApiKey.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; +using Moonlight.Api.Database.Interfaces; + +namespace Moonlight.Api.Database.Entities; + +public class ApiKey : IActionTimestamps +{ + public int Id { get; set; } + + [MaxLength(30)] + public required string Name { get; set; } + + [MaxLength(300)] + public required string Description { get; set; } + + public string[] Permissions { get; set; } = []; + + [MaxLength(32)] + public string Key { get; set; } + + // Action timestamps + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Api/Database/Entities/Role.cs b/Moonlight.Api/Database/Entities/Role.cs index 3ec1205a..a38ccd13 100644 --- a/Moonlight.Api/Database/Entities/Role.cs +++ b/Moonlight.Api/Database/Entities/Role.cs @@ -7,10 +7,10 @@ public class Role : IActionTimestamps { public int Id { get; set; } - [MaxLength(15)] + [MaxLength(30)] public required string Name { get; set; } - [MaxLength(100)] + [MaxLength(300)] public required string Description { get; set; } public string[] Permissions { get; set; } = []; diff --git a/Moonlight.Api/Database/Migrations/20260116133404_AddedApiKeys.Designer.cs b/Moonlight.Api/Database/Migrations/20260116133404_AddedApiKeys.Designer.cs new file mode 100644 index 00000000..5a99596d --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260116133404_AddedApiKeys.Designer.cs @@ -0,0 +1,215 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.Api.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260116133404_AddedApiKeys")] + partial class AddedApiKeys + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("core") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Roles", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("RoleMembers", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.ToTable("SettingsOptions", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("InvalidateTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Users", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.HasOne("Moonlight.Api.Database.Entities.Role", "Role") + .WithMany("Members") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.Api.Database.Entities.User", "User") + .WithMany("RoleMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Navigation("RoleMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.Api/Database/Migrations/20260116133404_AddedApiKeys.cs b/Moonlight.Api/Database/Migrations/20260116133404_AddedApiKeys.cs new file mode 100644 index 00000000..6c0ce8cb --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260116133404_AddedApiKeys.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + /// + public partial class AddedApiKeys : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ApiKeys", + schema: "core", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(15)", maxLength: 15, nullable: false), + Description = table.Column(type: "character varying(100)", maxLength: 100, nullable: false), + Permissions = table.Column(type: "text[]", nullable: false), + Key = table.Column(type: "character varying(32)", maxLength: 32, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ApiKeys", x => x.Id); + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ApiKeys", + schema: "core"); + } + } +} diff --git a/Moonlight.Api/Database/Migrations/20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings.Designer.cs b/Moonlight.Api/Database/Migrations/20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings.Designer.cs new file mode 100644 index 00000000..11979c0b --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings.Designer.cs @@ -0,0 +1,215 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Moonlight.Api.Database; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings")] + partial class AdjustedLenghtsOfRoleAndApiKeyStrings + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("core") + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Roles", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RoleId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.HasIndex("UserId"); + + b.ToTable("RoleMembers", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.SettingsOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Key") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("character varying(4096)"); + + b.HasKey("Id"); + + b.ToTable("SettingsOptions", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("InvalidateTimestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.ToTable("Users", "core"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.RoleMember", b => + { + b.HasOne("Moonlight.Api.Database.Entities.Role", "Role") + .WithMany("Members") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Moonlight.Api.Database.Entities.User", "User") + .WithMany("RoleMemberships") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("Moonlight.Api.Database.Entities.User", b => + { + b.Navigation("RoleMemberships"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Moonlight.Api/Database/Migrations/20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings.cs b/Moonlight.Api/Database/Migrations/20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings.cs new file mode 100644 index 00000000..eea3c716 --- /dev/null +++ b/Moonlight.Api/Database/Migrations/20260116134322_AdjustedLenghtsOfRoleAndApiKeyStrings.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Moonlight.Api.Database.Migrations +{ + /// + public partial class AdjustedLenghtsOfRoleAndApiKeyStrings : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Name", + schema: "core", + table: "Roles", + type: "character varying(30)", + maxLength: 30, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(15)", + oldMaxLength: 15); + + migrationBuilder.AlterColumn( + name: "Description", + schema: "core", + table: "Roles", + type: "character varying(300)", + maxLength: 300, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + + migrationBuilder.AlterColumn( + name: "Name", + schema: "core", + table: "ApiKeys", + type: "character varying(30)", + maxLength: 30, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(15)", + oldMaxLength: 15); + + migrationBuilder.AlterColumn( + name: "Description", + schema: "core", + table: "ApiKeys", + type: "character varying(300)", + maxLength: 300, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(100)", + oldMaxLength: 100); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Name", + schema: "core", + table: "Roles", + type: "character varying(15)", + maxLength: 15, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(30)", + oldMaxLength: 30); + + migrationBuilder.AlterColumn( + name: "Description", + schema: "core", + table: "Roles", + type: "character varying(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(300)", + oldMaxLength: 300); + + migrationBuilder.AlterColumn( + name: "Name", + schema: "core", + table: "ApiKeys", + type: "character varying(15)", + maxLength: 15, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(30)", + oldMaxLength: 30); + + migrationBuilder.AlterColumn( + name: "Description", + schema: "core", + table: "ApiKeys", + type: "character varying(100)", + maxLength: 100, + nullable: false, + oldClrType: typeof(string), + oldType: "character varying(300)", + oldMaxLength: 300); + } + } +} diff --git a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs index 88c7627a..ff2a5ef4 100644 --- a/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs +++ b/Moonlight.Api/Database/Migrations/DataContextModelSnapshot.cs @@ -23,6 +23,44 @@ namespace Moonlight.Api.Database.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("Moonlight.Api.Database.Entities.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(30) + .HasColumnType("character varying(30)"); + + b.PrimitiveCollection("Permissions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("ApiKeys", "core"); + }); + modelBuilder.Entity("Moonlight.Api.Database.Entities.Role", b => { b.Property("Id") @@ -36,13 +74,13 @@ namespace Moonlight.Api.Database.Migrations b.Property("Description") .IsRequired() - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + .HasMaxLength(300) + .HasColumnType("character varying(300)"); b.Property("Name") .IsRequired() - .HasMaxLength(15) - .HasColumnType("character varying(15)"); + .HasMaxLength(30) + .HasColumnType("character varying(30)"); b.PrimitiveCollection("Permissions") .IsRequired() diff --git a/Moonlight.Api/Http/Controllers/Admin/ApiKeyController.cs b/Moonlight.Api/Http/Controllers/Admin/ApiKeyController.cs new file mode 100644 index 00000000..e1c27afd --- /dev/null +++ b/Moonlight.Api/Http/Controllers/Admin/ApiKeyController.cs @@ -0,0 +1,134 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Moonlight.Api.Database; +using Moonlight.Api.Database.Entities; +using Moonlight.Api.Mappers; +using Moonlight.Shared; +using Moonlight.Shared.Http.Requests; +using Moonlight.Shared.Http.Requests.ApiKeys; +using Moonlight.Shared.Http.Responses; +using Moonlight.Shared.Http.Responses.ApiKeys; + +namespace Moonlight.Api.Http.Controllers.Admin; + +[Authorize] +[ApiController] +[Route("api/admin/apiKeys")] +public class ApiKeyController : Controller +{ + private readonly DatabaseRepository KeyRepository; + + public ApiKeyController(DatabaseRepository keyRepository) + { + KeyRepository = keyRepository; + } + + [HttpGet] + [Authorize(Policy = Permissions.ApiKeys.View)] + public async Task>> GetAsync( + [FromQuery] int startIndex, + [FromQuery] int length, + [FromQuery] FilterOptions? filterOptions + ) + { + // Validation + if (startIndex < 0) + return Problem("Invalid start index specified", statusCode: 400); + + if (length is < 1 or > 100) + return Problem("Invalid length specified"); + + // Query building + var query = KeyRepository.Query(); + + // Filters + if (filterOptions != null) + { + foreach (var filterOption in filterOptions.Filters) + { + query = filterOption.Key switch + { + nameof(ApiKey.Name) => + query.Where(k => EF.Functions.ILike(k.Name, $"%{filterOption.Value}%")), + + nameof(ApiKey.Description) => + query.Where(k => EF.Functions.ILike(k.Description, $"%{filterOption.Value}%")), + + _ => query + }; + } + } + + // Pagination + var data = await query + .OrderBy(k => k.Id) + .ProjectToDto() + .Skip(startIndex) + .Take(length) + .ToArrayAsync(); + + var total = await query.CountAsync(); + + return new PagedData(data, total); + } + + [HttpGet("{id:int}")] + [Authorize(Policy = Permissions.ApiKeys.View)] + public async Task> GetAsync([FromRoute] int id) + { + var key = await KeyRepository + .Query() + .FirstOrDefaultAsync(k => k.Id == id); + + if (key == null) + return Problem("No API key with this id found", statusCode: 404); + + return ApiKeyMapper.ToDto(key); + } + + [HttpPost] + [Authorize(Policy = Permissions.ApiKeys.Create)] + public async Task> CreateAsync([FromBody] CreateApiKeyDto request) + { + var apiKey = ApiKeyMapper.ToEntity(request); + + apiKey.Key = Guid.NewGuid().ToString("N").Substring(0, 32); + + var finalKey = await KeyRepository.AddAsync(apiKey); + + return ApiKeyMapper.ToDto(finalKey); + } + + [HttpPatch("{id:int}")] + [Authorize(Policy = Permissions.ApiKeys.Edit)] + public async Task> UpdateAsync([FromRoute] int id, [FromBody] UpdateApiKeyDto request) + { + var apiKey = await KeyRepository + .Query() + .FirstOrDefaultAsync(k => k.Id == id); + + if (apiKey == null) + return Problem("No API key with this id found", statusCode: 404); + + ApiKeyMapper.Merge(apiKey, request); + await KeyRepository.UpdateAsync(apiKey); + + return ApiKeyMapper.ToDto(apiKey); + } + + [HttpDelete("{id:int}")] + [Authorize(Policy = Permissions.ApiKeys.Delete)] + public async Task DeleteAsync([FromRoute] int id) + { + var apiKey = await KeyRepository + .Query() + .FirstOrDefaultAsync(k => k.Id == id); + + if (apiKey == null) + return Problem("No API key with this id found", statusCode: 404); + + await KeyRepository.RemoveAsync(apiKey); + return NoContent(); + } +} \ No newline at end of file diff --git a/Moonlight.Api/Mappers/ApiKeyMapper.cs b/Moonlight.Api/Mappers/ApiKeyMapper.cs new file mode 100644 index 00000000..f4f694ad --- /dev/null +++ b/Moonlight.Api/Mappers/ApiKeyMapper.cs @@ -0,0 +1,21 @@ +using System.Diagnostics.CodeAnalysis; +using Moonlight.Api.Database.Entities; +using Moonlight.Shared.Http.Requests.ApiKeys; +using Moonlight.Shared.Http.Responses.ApiKeys; +using Riok.Mapperly.Abstractions; + +namespace Moonlight.Api.Mappers; + +[Mapper] +[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] +[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")] +public static partial class ApiKeyMapper +{ + public static partial IQueryable ProjectToDto(this IQueryable apiKeys); + + public static partial ApiKeyDto ToDto(ApiKey apiKey); + + public static partial void Merge([MappingTarget] ApiKey apiKey, UpdateApiKeyDto request); + + public static partial ApiKey ToEntity(CreateApiKeyDto request); +} \ No newline at end of file diff --git a/Moonlight.Frontend/Implementations/PermissionProvider.cs b/Moonlight.Frontend/Implementations/PermissionProvider.cs index 22a09991..14735bee 100644 --- a/Moonlight.Frontend/Implementations/PermissionProvider.cs +++ b/Moonlight.Frontend/Implementations/PermissionProvider.cs @@ -28,6 +28,12 @@ public sealed class PermissionProvider : IPermissionProvider new Permission(Permissions.System.Info, "Info", "View system info"), new Permission(Permissions.System.Diagnose, "Diagnose", "Run diagnostics"), ]), + new PermissionCategory("API Keys", typeof(KeyIcon), [ + new Permission(Permissions.ApiKeys.Create, "Create", "Create new API keys"), + new Permission(Permissions.ApiKeys.View, "View", "View all API keys"), + new Permission(Permissions.ApiKeys.Edit, "Edit", "Edit API key details"), + new Permission(Permissions.ApiKeys.Delete, "Delete", "Delete API keys"), + ]), ]); } } \ No newline at end of file diff --git a/Moonlight.Frontend/Mappers/ApiKeyMapper.cs b/Moonlight.Frontend/Mappers/ApiKeyMapper.cs new file mode 100644 index 00000000..77dde21e --- /dev/null +++ b/Moonlight.Frontend/Mappers/ApiKeyMapper.cs @@ -0,0 +1,14 @@ +using System.Diagnostics.CodeAnalysis; +using Moonlight.Shared.Http.Requests.ApiKeys; +using Moonlight.Shared.Http.Responses.ApiKeys; +using Riok.Mapperly.Abstractions; + +namespace Moonlight.Frontend.Mappers; + +[Mapper] +[SuppressMessage("Mapper", "RMG020:No members are mapped in an object mapping")] +[SuppressMessage("Mapper", "RMG012:No members are mapped in an object mapping")] +public static partial class ApiKeyMapper +{ + public static partial UpdateApiKeyDto ToUpdate(ApiKeyDto apiKey); +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor new file mode 100644 index 00000000..24a3b735 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/CreateApiKeyDialog.razor @@ -0,0 +1,70 @@ +@using Moonlight.Frontend.UI.Admin.Components +@using Moonlight.Shared.Http.Requests.ApiKeys +@using ShadcnBlazor.Dialogs +@using ShadcnBlazor.Extras.Common +@using ShadcnBlazor.Extras.FormHandlers +@using ShadcnBlazor.Inputs +@using ShadcnBlazor.Labels + +@inherits ShadcnBlazor.Extras.Dialogs.DialogBase + + + Create new API key + + Define a name, description, and select the permissions that the key should have. + + + + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + Save changes + + +@code +{ + [Parameter] public Func OnSubmit { get; set; } + + private CreateApiKeyDto Request; + private FormHandler FormHandler; + + private List Permissions = new(); + + protected override void OnInitialized() + { + Request = new(); + } + + private async Task SubmitAsync() + { + Request.Permissions = Permissions.ToArray(); + await OnSubmit.Invoke(Request); + await CloseAsync(); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor b/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor new file mode 100644 index 00000000..10696f83 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Modals/UpdateApiKeyDialog.razor @@ -0,0 +1,73 @@ +@using Moonlight.Frontend.Mappers +@using Moonlight.Frontend.UI.Admin.Components +@using Moonlight.Shared.Http.Requests.ApiKeys +@using Moonlight.Shared.Http.Responses.ApiKeys +@using ShadcnBlazor.Dialogs +@using ShadcnBlazor.Extras.Common +@using ShadcnBlazor.Extras.FormHandlers +@using ShadcnBlazor.Inputs +@using ShadcnBlazor.Labels + +@inherits ShadcnBlazor.Extras.Dialogs.DialogBase + + + Update API key + + Edit the name, description, or the granted permissions for the key. + + + + +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + + Save changes + + +@code +{ + [Parameter] public Func OnSubmit { get; set; } + [Parameter] public ApiKeyDto Key { get; set; } + + private UpdateApiKeyDto Request; + private FormHandler FormHandler; + private List Permissions = new(); + + protected override void OnInitialized() + { + Request = ApiKeyMapper.ToUpdate(Key); + Permissions = Key.Permissions.ToList(); + } + + private async Task SubmitAsync() + { + Request.Permissions = Permissions.ToArray(); + await OnSubmit.Invoke(Request); + await CloseAsync(); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor new file mode 100644 index 00000000..db99b5b2 --- /dev/null +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/ApiKeys.razor @@ -0,0 +1,183 @@ +@using Moonlight.Shared.Http.Requests.ApiKeys +@using Moonlight.Shared.Http.Responses.ApiKeys +@using LucideBlazor +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Moonlight.Frontend.UI.Admin.Modals +@using Moonlight.Shared +@using Moonlight.Shared.Http.Requests +@using Moonlight.Shared.Http.Responses +@using ShadcnBlazor.DataGrids +@using ShadcnBlazor.Dropdowns +@using ShadcnBlazor.Extras.AlertDialogs +@using ShadcnBlazor.Extras.Dialogs +@using ShadcnBlazor.Tabels +@using ShadcnBlazor.Buttons +@using ShadcnBlazor.Extras.Toasts + +@inject ToastService ToastService +@inject DialogService DialogService +@inject AlertDialogService AlertDialogService +@inject IAuthorizationService AuthorizationService +@inject HttpClient HttpClient + +
+
+

API Keys

+
+ Manage API keys for your instance +
+
+
+ +
+
+ +
+ + + + + + + @context.Name + + + + + + + + +
+ + + + + + + + + Edit + + + + + + Delete + + + + + + +
+
+
+
+
+
+ +@code +{ + [CascadingParameter] public Task AuthState { get; set; } + + private DataGrid Grid; + + private AuthorizationResult EditAccess; + private AuthorizationResult DeleteAccess; + private AuthorizationResult CreateAccess; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthState; + + EditAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.Edit); + DeleteAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.Delete); + CreateAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.Create); + } + + private async Task> LoadAsync(DataGridRequest request) + { + var query = $"?startIndex={request.StartIndex}&length={request.Length}"; + var filterOptions = request.Filters.Count > 0 ? new FilterOptions(request.Filters) : null; + + var response = await HttpClient.GetFromJsonAsync>( + $"api/admin/apiKeys{query}&filterOptions={filterOptions}", + Constants.SerializerOptions + ); + + return new DataGridResponse(response!.Data, response.TotalLength); + } + + private async Task CreateAsync() + { + await DialogService.LaunchAsync(parameters => + { + parameters[nameof(CreateApiKeyDialog.OnSubmit)] = async (CreateApiKeyDto dto) => + { + await HttpClient.PostAsJsonAsync( + "/api/admin/apiKeys", + dto, + Constants.SerializerOptions + ); + + await ToastService.SuccessAsync( + "API Key creation", + $"Successfully created API key {dto.Name}" + ); + + await Grid.RefreshAsync(); + }; + }); + } + + private async Task EditAsync(ApiKeyDto key) + { + await DialogService.LaunchAsync(parameters => + { + parameters[nameof(UpdateApiKeyDialog.Key)] = key; + parameters[nameof(UpdateApiKeyDialog.OnSubmit)] = async (UpdateApiKeyDto dto) => + { + await HttpClient.PatchAsJsonAsync( + $"/api/admin/apiKeys/{key.Id}", + dto, + Constants.SerializerOptions + ); + + await ToastService.SuccessAsync( + "API Key update", + $"Successfully updated API key {dto.Name}" + ); + + await Grid.RefreshAsync(); + }; + }); + } + + private async Task DeleteAsync(ApiKeyDto key) + { + await AlertDialogService.ConfirmDangerAsync( + $"Deletion of API key {key.Name}", + "Do you really want to delete this API key? This action cannot be undone", + async () => + { + var response = await HttpClient.DeleteAsync($"api/admin/apiKeys/{key.Id}"); + response.EnsureSuccessStatusCode(); + + await ToastService.SuccessAsync("API Key deletion", $"Successfully deleted API key {key.Name}"); + + await Grid.RefreshAsync(); + } + ); + } +} \ No newline at end of file diff --git a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor index f1c42b9f..3c0b76de 100644 --- a/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor +++ b/Moonlight.Frontend/UI/Admin/Views/Sys/Index.razor @@ -1,6 +1,9 @@ @page "/admin/system" @using LucideBlazor +@using Microsoft.AspNetCore.Authorization +@using Microsoft.AspNetCore.Components.Authorization +@using Moonlight.Shared @using ShadcnBlazor.Buttons @using ShadcnBlazor.Cards @using ShadcnBlazor.Inputs @@ -8,6 +11,7 @@ @using ShadcnBlazor.Labels @inject NavigationManager Navigation +@inject IAuthorizationService AuthorizationService @@ -15,7 +19,7 @@ Customization - + API & API Keys @@ -45,6 +49,12 @@ + @if (ApiKeyAccess.Succeeded) + { + + + + } @code @@ -52,6 +62,17 @@ [SupplyParameterFromQuery(Name = "tab")] [Parameter] public string? Tab { get; set; } + + [CascadingParameter] public Task AuthState { get; set; } + + private AuthorizationResult ApiKeyAccess; + + protected override async Task OnInitializedAsync() + { + var authState = await AuthState; + + ApiKeyAccess = await AuthorizationService.AuthorizeAsync(authState.User, Permissions.ApiKeys.View); + } private void OnTabChanged(string name) { diff --git a/Moonlight.Shared/Http/Requests/ApiKeys/CreateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/ApiKeys/CreateApiKeyDto.cs new file mode 100644 index 00000000..c222b3dc --- /dev/null +++ b/Moonlight.Shared/Http/Requests/ApiKeys/CreateApiKeyDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.ApiKeys; + +public class CreateApiKeyDto +{ + [MaxLength(30)] + public string Name { get; set; } + + [MaxLength(300)] + public string Description { get; set; } + + public string[] Permissions { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/ApiKeys/UpdateApiKeyDto.cs b/Moonlight.Shared/Http/Requests/ApiKeys/UpdateApiKeyDto.cs new file mode 100644 index 00000000..633017ad --- /dev/null +++ b/Moonlight.Shared/Http/Requests/ApiKeys/UpdateApiKeyDto.cs @@ -0,0 +1,14 @@ +using System.ComponentModel.DataAnnotations; + +namespace Moonlight.Shared.Http.Requests.ApiKeys; + +public class UpdateApiKeyDto +{ + [MaxLength(30)] + public string Name { get; set; } + + [MaxLength(300)] + public string Description { get; set; } + + public string[] Permissions { get; set; } +} \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Roles/CreateRoleDto.cs b/Moonlight.Shared/Http/Requests/Roles/CreateRoleDto.cs index 5b4984d3..638673fd 100644 --- a/Moonlight.Shared/Http/Requests/Roles/CreateRoleDto.cs +++ b/Moonlight.Shared/Http/Requests/Roles/CreateRoleDto.cs @@ -4,9 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Roles; public class CreateRoleDto { - [Required] [MaxLength(15)] public string Name { get; set; } + [Required] [MaxLength(30)] public string Name { get; set; } - [MaxLength(100)] public string Description { get; set; } = ""; + [MaxLength(300)] public string Description { get; set; } = ""; [Required] public string[] Permissions { get; set; } } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Requests/Roles/UpdateRoleDto.cs b/Moonlight.Shared/Http/Requests/Roles/UpdateRoleDto.cs index 78fb81eb..d7a03ee6 100644 --- a/Moonlight.Shared/Http/Requests/Roles/UpdateRoleDto.cs +++ b/Moonlight.Shared/Http/Requests/Roles/UpdateRoleDto.cs @@ -4,9 +4,9 @@ namespace Moonlight.Shared.Http.Requests.Roles; public class UpdateRoleDto { - [Required] [MaxLength(15)] public string Name { get; set; } + [Required] [MaxLength(30)] public string Name { get; set; } - [MaxLength(100)] public string Description { get; set; } = ""; + [MaxLength(300)] public string Description { get; set; } = ""; [Required] public string[] Permissions { get; set; } } \ No newline at end of file diff --git a/Moonlight.Shared/Http/Responses/ApiKeys/ApiKeyDto.cs b/Moonlight.Shared/Http/Responses/ApiKeys/ApiKeyDto.cs new file mode 100644 index 00000000..8561d083 --- /dev/null +++ b/Moonlight.Shared/Http/Responses/ApiKeys/ApiKeyDto.cs @@ -0,0 +1,3 @@ +namespace Moonlight.Shared.Http.Responses.ApiKeys; + +public record ApiKeyDto(int Id, string Name, string Description, string[] Permissions, string Key, DateTimeOffset CreatedAt, DateTimeOffset UpdatedAt); \ No newline at end of file diff --git a/Moonlight.Shared/Http/SerializationContext.cs b/Moonlight.Shared/Http/SerializationContext.cs index f07e6d72..2de40ed1 100644 --- a/Moonlight.Shared/Http/SerializationContext.cs +++ b/Moonlight.Shared/Http/SerializationContext.cs @@ -1,8 +1,10 @@ using System.Text.Json.Serialization; +using Moonlight.Shared.Http.Requests.ApiKeys; using Moonlight.Shared.Http.Requests.Roles; using Moonlight.Shared.Http.Requests.Users; using Moonlight.Shared.Http.Responses; using Moonlight.Shared.Http.Responses.Admin; +using Moonlight.Shared.Http.Responses.ApiKeys; using Moonlight.Shared.Http.Responses.Auth; using Moonlight.Shared.Http.Responses.Users; @@ -20,6 +22,11 @@ namespace Moonlight.Shared.Http; [JsonSerializable(typeof(RoleDto))] [JsonSerializable(typeof(CreateRoleDto))] [JsonSerializable(typeof(UpdateRoleDto))] +[JsonSerializable(typeof(CreateApiKeyDto))] +[JsonSerializable(typeof(UpdateApiKeyDto))] +[JsonSerializable(typeof(UpdateApiKeyDto))] +[JsonSerializable(typeof(PagedData))] +[JsonSerializable(typeof(ApiKeyDto))] public partial class SerializationContext : JsonSerializerContext { } \ No newline at end of file diff --git a/Moonlight.Shared/Permissions.cs b/Moonlight.Shared/Permissions.cs index 4c773f9a..2d96d25d 100644 --- a/Moonlight.Shared/Permissions.cs +++ b/Moonlight.Shared/Permissions.cs @@ -16,6 +16,16 @@ public static class Permissions public const string Logout = $"{Prefix}{Section}.{nameof(Logout)}"; } + public static class ApiKeys + { + private const string Section = "ApiKeys"; + + public const string View = $"{Prefix}{Section}.{nameof(View)}"; + public const string Edit = $"{Prefix}{Section}.{nameof(Edit)}"; + public const string Create = $"{Prefix}{Section}.{nameof(Create)}"; + public const string Delete = $"{Prefix}{Section}.{nameof(Delete)}"; + } + public static class Roles { private const string Section = "Roles";