From 953bf5d4dea2b07e3653090df1f7d832417ddf12 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sat, 23 Aug 2025 19:52:05 +0800 Subject: [PATCH] :sparkles: Bot controller has keys endpoints --- .../Identity/BotAccountController.cs | 176 +++++++++++++++++- .../Account/BotAccountReceiverGrpc.cs | 106 ++++++++++- DysonNetwork.Pass/Auth/ApiKey.cs | 32 +++- DysonNetwork.Shared/Data/Account.cs | 35 ++++ DysonNetwork.Shared/Proto/develop.proto | 37 +++- 5 files changed, 370 insertions(+), 16 deletions(-) diff --git a/DysonNetwork.Develop/Identity/BotAccountController.cs b/DysonNetwork.Develop/Identity/BotAccountController.cs index 499e94b..e7663d6 100644 --- a/DysonNetwork.Develop/Identity/BotAccountController.cs +++ b/DysonNetwork.Develop/Identity/BotAccountController.cs @@ -1,7 +1,9 @@ using System.ComponentModel.DataAnnotations; using DysonNetwork.Develop.Project; +using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using DysonNetwork.Shared.Registry; +using Grpc.Core; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using NodaTime; @@ -17,7 +19,8 @@ public class BotAccountController( DeveloperService developerService, DevProjectService projectService, ILogger logger, - AccountClientHelper accounts + AccountClientHelper accounts, + BotAccountReceiverService.BotAccountReceiverServiceClient accountsReceiver ) : ControllerBase { @@ -85,8 +88,8 @@ public class BotAccountController( return NotFound("Developer not found"); if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), - PublisherMemberRole.Editor)) - return StatusCode(403, "You must be an editor of the developer to list bots"); + PublisherMemberRole.Viewer)) + return StatusCode(403, "You must be an viewer of the developer to list bots"); var project = await projectService.GetProjectAsync(projectId, developer.Id); if (project is null) @@ -110,8 +113,8 @@ public class BotAccountController( return NotFound("Developer not found"); if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), - PublisherMemberRole.Editor)) - return StatusCode(403, "You must be an editor of the developer to view bot details"); + PublisherMemberRole.Viewer)) + return StatusCode(403, "You must be an viewer of the developer to view bot details"); var project = await projectService.GetProjectAsync(projectId, developer.Id); if (project is null) @@ -291,4 +294,167 @@ public class BotAccountController( return StatusCode(500, "An error occurred while deleting the bot account"); } } + + [HttpGet("{botId:guid}/keys")] + public async Task>> ListBotKeys( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid botId + ) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); + if (developer == null) return NotFound("Developer not found"); + if (project == null) return NotFound("Project not found or you don't have access"); + if (bot == null) return NotFound("Bot not found"); + + var keys = await accountsReceiver.ListApiKeyAsync(new ListApiKeyRequest + { + AutomatedId = bot.Id.ToString() + }); + var data = keys.Data.Select(ApiKeyReference.FromProtoValue).ToList(); + + return Ok(data); + } + + [HttpGet("{botId:guid}/keys/{keyId:guid}")] + public async Task> GetBotKey( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid botId, + [FromRoute] Guid keyId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Viewer); + if (developer == null) return NotFound("Developer not found"); + if (project == null) return NotFound("Project not found or you don't have access"); + if (bot == null) return NotFound("Bot not found"); + + try + { + var key = await accountsReceiver.GetApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); + if (key == null) return NotFound("API key not found"); + return Ok(ApiKeyReference.FromProtoValue(key)); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return NotFound("API key not found"); + } + } + + public class CreateApiKeyRequest + { + [Required, MaxLength(1024)] + public string Label { get; set; } = null!; + } + + [HttpPost("{botId:guid}/keys")] + public async Task> CreateBotKey( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid botId, + [FromBody] CreateApiKeyRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); + if (developer == null) return NotFound("Developer not found"); + if (project == null) return NotFound("Project not found or you don't have access"); + if (bot == null) return NotFound("Bot not found"); + + try + { + var newKey = new ApiKey + { + AccountId = bot.Id.ToString(), + Label = request.Label + }; + + var createdKey = await accountsReceiver.CreateApiKeyAsync(newKey); + return Ok(ApiKeyReference.FromProtoValue(createdKey)); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.InvalidArgument) + { + return BadRequest(ex.Status.Detail); + } + } + + [HttpPost("{botId:guid}/keys/{keyId:guid}/rotate")] + public async Task> RotateBotKey( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid botId, + [FromRoute] Guid keyId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); + if (developer == null) return NotFound("Developer not found"); + if (project == null) return NotFound("Project not found or you don't have access"); + if (bot == null) return NotFound("Bot not found"); + + try + { + var rotatedKey = await accountsReceiver.RotateApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); + return Ok(ApiKeyReference.FromProtoValue(rotatedKey)); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return NotFound("API key not found"); + } + } + + [HttpDelete("{botId:guid}/keys/{keyId:guid}")] + public async Task DeleteBotKey( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid botId, + [FromRoute] Guid keyId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var (developer, project, bot) = await ValidateBotAccess(pubName, projectId, botId, currentUser, PublisherMemberRole.Editor); + if (developer == null) return NotFound("Developer not found"); + if (project == null) return NotFound("Project not found or you don't have access"); + if (bot == null) return NotFound("Bot not found"); + + try + { + await accountsReceiver.DeleteApiKeyAsync(new GetApiKeyRequest { Id = keyId.ToString() }); + return NoContent(); + } + catch (RpcException ex) when (ex.StatusCode == Grpc.Core.StatusCode.NotFound) + { + return NotFound("API key not found"); + } + } + + private async Task<(Developer?, DevProject?, BotAccount?)> ValidateBotAccess( + string pubName, + Guid projectId, + Guid botId, + Account currentUser, + PublisherMemberRole requiredRole) + { + var developer = await developerService.GetDeveloperByName(pubName); + if (developer == null) return (null, null, null); + + if (!await developerService.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), requiredRole)) + return (null, null, null); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project == null) return (developer, null, null); + + var bot = await botService.GetBotByIdAsync(botId); + if (bot == null || bot.ProjectId != projectId) return (developer, project, null); + + return (developer, project, bot); + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs b/DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs index 243810a..215bec6 100644 --- a/DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs +++ b/DysonNetwork.Pass/Account/BotAccountReceiverGrpc.cs @@ -1,8 +1,10 @@ using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using Grpc.Core; -using NodaTime; +using Microsoft.EntityFrameworkCore; using NodaTime.Serialization.Protobuf; +using ApiKey = DysonNetwork.Shared.Proto.ApiKey; +using AuthService = DysonNetwork.Pass.Auth.AuthService; namespace DysonNetwork.Pass.Account; @@ -10,7 +12,8 @@ public class BotAccountReceiverGrpc( AppDatabase db, AccountService accounts, FileService.FileServiceClient files, - FileReferenceService.FileReferenceServiceClient fileRefs + FileReferenceService.FileReferenceServiceClient fileRefs, + AuthService authService ) : BotAccountReceiverService.BotAccountReceiverServiceBase { @@ -107,10 +110,107 @@ public class BotAccountReceiverGrpc( var automatedId = Guid.Parse(request.AutomatedId); var account = await accounts.GetBotAccount(automatedId); if (account is null) - throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found")); + throw new RpcException(new Grpc.Core.Status(Grpc.Core.StatusCode.NotFound, "Account not found")); await accounts.DeleteAccount(account); return new DeleteBotAccountResponse(); } + + public override async Task GetApiKey(GetApiKeyRequest request, ServerCallContext context) + { + var keyId = Guid.Parse(request.Id); + var key = await db.ApiKeys + .Include(k => k.Account) + .FirstOrDefaultAsync(k => k.Id == keyId); + + if (key == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); + + return key.ToProtoValue(); + } + + public override async Task ListApiKey(ListApiKeyRequest request, ServerCallContext context) + { + var automatedId = Guid.Parse(request.AutomatedId); + var account = await accounts.GetBotAccount(automatedId); + if (account == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found")); + + var keys = await db.ApiKeys + .Where(k => k.AccountId == account.Id) + .Select(k => k.ToProtoValue()) + .ToListAsync(); + + var response = new GetApiKeyBatchResponse(); + response.Data.AddRange(keys); + return response; + } + + public override async Task CreateApiKey(ApiKey request, ServerCallContext context) + { + var accountId = Guid.Parse(request.AccountId); + var account = await accounts.GetBotAccount(accountId); + if (account == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "Account not found")); + + if (string.IsNullOrWhiteSpace(request.Label)) + throw new RpcException(new Grpc.Core.Status(StatusCode.InvalidArgument, "Label is required")); + + var key = await authService.CreateApiKey(account.Id, request.Label, null); + key.Key = await authService.IssueApiKeyToken(key); + + return key.ToProtoValue(); + } + + public override async Task UpdateApiKey(ApiKey request, ServerCallContext context) + { + var keyId = Guid.Parse(request.Id); + var accountId = Guid.Parse(request.AccountId); + + var key = await db.ApiKeys + .FirstOrDefaultAsync(k => k.Id == keyId && k.AccountId == accountId); + + if (key == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); + + // Only update the label if provided + if (string.IsNullOrWhiteSpace(request.Label)) return key.ToProtoValue(); + key.Label = request.Label; + db.ApiKeys.Update(key); + await db.SaveChangesAsync(); + + return key.ToProtoValue(); + } + + public override async Task RotateApiKey(GetApiKeyRequest request, ServerCallContext context) + { + var keyId = Guid.Parse(request.Id); + var key = await db.ApiKeys + .Include(k => k.Account) + .FirstOrDefaultAsync(k => k.Id == keyId); + + if (key == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); + + key = await authService.RotateApiKeyToken(key); + key.Key = await authService.IssueApiKeyToken(key); + + return key.ToProtoValue(); + } + + public override async Task DeleteApiKey(GetApiKeyRequest request, ServerCallContext context) + { + var keyId = Guid.Parse(request.Id); + var key = await db.ApiKeys + .Include(k => k.Account) + .FirstOrDefaultAsync(k => k.Id == keyId); + + if (key == null) + throw new RpcException(new Grpc.Core.Status(StatusCode.NotFound, "API key not found")); + + await authService.RevokeApiKeyToken(key); + + return new DeleteApiKeyResponse { Success = true }; + } } \ No newline at end of file diff --git a/DysonNetwork.Pass/Auth/ApiKey.cs b/DysonNetwork.Pass/Auth/ApiKey.cs index c38ce4e..05c5519 100644 --- a/DysonNetwork.Pass/Auth/ApiKey.cs +++ b/DysonNetwork.Pass/Auth/ApiKey.cs @@ -9,11 +9,37 @@ public class ApiKey : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); [MaxLength(1024)] public string Label { get; set; } = null!; - + public Guid AccountId { get; set; } public Account.Account Account { get; set; } = null!; public Guid SessionId { get; set; } public AuthSession Session { get; set; } = null!; - - [NotMapped] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Key { get; set; } + + [NotMapped] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Key { get; set; } + + public DysonNetwork.Shared.Proto.ApiKey ToProtoValue() + { + return new DysonNetwork.Shared.Proto.ApiKey + { + Id = Id.ToString(), + Label = Label, + AccountId = AccountId.ToString(), + SessionId = SessionId.ToString(), + Key = Key + }; + } + + public static ApiKey FromProtoValue(DysonNetwork.Shared.Proto.ApiKey proto) + { + return new ApiKey + { + Id = Guid.Parse(proto.Id), + AccountId = Guid.Parse(proto.AccountId), + SessionId = Guid.Parse(proto.SessionId), + Label = proto.Label, + Key = proto.Key + }; + } } \ No newline at end of file diff --git a/DysonNetwork.Shared/Data/Account.cs b/DysonNetwork.Shared/Data/Account.cs index 789e5ae..7807555 100644 --- a/DysonNetwork.Shared/Data/Account.cs +++ b/DysonNetwork.Shared/Data/Account.cs @@ -308,4 +308,39 @@ public static class Leveling 512000, // Level 13 1024000 ]; +} + +public class ApiKeyReference : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public string Label { get; set; } = null!; + + public Guid AccountId { get; set; } + public Guid SessionId { get; set; } + + public string? Key { get; set; } + + public ApiKey ToProtoValue() + { + return new ApiKey + { + Id = Id.ToString(), + Label = Label, + AccountId = AccountId.ToString(), + SessionId = SessionId.ToString(), + Key = Key + }; + } + + public static ApiKeyReference FromProtoValue(ApiKey proto) + { + return new ApiKeyReference + { + Id = Guid.Parse(proto.Id), + AccountId = Guid.Parse(proto.AccountId), + SessionId = Guid.Parse(proto.SessionId), + Label = proto.Label, + Key = proto.Key + }; + } } \ No newline at end of file diff --git a/DysonNetwork.Shared/Proto/develop.proto b/DysonNetwork.Shared/Proto/develop.proto index ff3f0aa..180c251 100644 --- a/DysonNetwork.Shared/Proto/develop.proto +++ b/DysonNetwork.Shared/Proto/develop.proto @@ -5,6 +5,7 @@ package proto; option csharp_namespace = "DysonNetwork.Shared.Proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; import "account.proto"; import "file.proto"; @@ -136,15 +137,41 @@ message DeleteBotAccountResponse { bool success = 1; // Whether the deletion was successful } +message ApiKey { + string id = 1; + string label = 2; + string account_id = 3; + string session_id = 4; + google.protobuf.StringValue key = 5; +} + +message GetApiKeyRequest { + string id = 1; +} + +message ListApiKeyRequest { + string automated_id = 1; +} + +message GetApiKeyBatchResponse { + repeated ApiKey data = 1; +} + +message DeleteApiKeyResponse { + bool success = 1; +} + // This service should be implemented by the Pass service to handle the creation, update, and deletion of bot accounts service BotAccountReceiverService { - // Create a new bot account rpc CreateBotAccount(CreateBotAccountRequest) returns (CreateBotAccountResponse); - - // Update an existing bot account rpc UpdateBotAccount(UpdateBotAccountRequest) returns (UpdateBotAccountResponse); - - // Delete a bot account rpc DeleteBotAccount(DeleteBotAccountRequest) returns (DeleteBotAccountResponse); + + rpc GetApiKey(GetApiKeyRequest) returns (ApiKey); + rpc ListApiKey(ListApiKeyRequest) returns (GetApiKeyBatchResponse); + rpc CreateApiKey(ApiKey) returns (ApiKey); + rpc UpdateApiKey(ApiKey) returns (ApiKey); + rpc RotateApiKey(GetApiKeyRequest) returns (ApiKey); + rpc DeleteApiKey(GetApiKeyRequest) returns (DeleteApiKeyResponse); } \ No newline at end of file