diff --git a/DysonNetwork.Develop/Identity/CustomAppController.cs b/DysonNetwork.Develop/Identity/CustomAppController.cs index 137419b..49c07bb 100644 --- a/DysonNetwork.Develop/Identity/CustomAppController.cs +++ b/DysonNetwork.Develop/Identity/CustomAppController.cs @@ -3,6 +3,7 @@ using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Proto; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using NodaTime; namespace DysonNetwork.Develop.Identity; @@ -22,12 +23,36 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d CustomAppOauthConfig? OauthConfig ); + public record CreateSecretRequest( + [MaxLength(4096)] string? Description, + TimeSpan? ExpiresIn = null, + bool IsOidc = false + ); + + public record SecretResponse( + string Id, + string? Secret, + string? Description, + Instant? ExpiresAt, + bool IsOidc, + Instant CreatedAt, + Instant UpdatedAt + ); + [HttpGet] + [Authorize] public async Task ListApps([FromRoute] string pubName, [FromRoute] Guid projectId) { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + var developer = await ds.GetDeveloperByName(pubName); if (developer is null) return NotFound(); + var accountId = Guid.Parse(currentUser.Id); + if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer)) + return StatusCode(403, "You must be a viewer of the developer to list custom apps"); + var project = await projectService.GetProjectAsync(projectId, developer.Id); if (project is null) return NotFound(); @@ -36,11 +61,19 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d } [HttpGet("{appId:guid}")] + [Authorize] public async Task GetApp([FromRoute] string pubName, [FromRoute] Guid projectId, [FromRoute] Guid appId) { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + var developer = await ds.GetDeveloperByName(pubName); if (developer is null) return NotFound(); + + var accountId = Guid.Parse(currentUser.Id); + if (!await ds.IsMemberWithRole(developer.PublisherId, accountId, PublisherMemberRole.Viewer)) + return StatusCode(403, "You must be a viewer of the developer to list custom apps"); var project = await projectService.GetProjectAsync(projectId, developer.Id); if (project is null) return NotFound(); @@ -65,7 +98,7 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d var developer = await ds.GetDeveloperByName(pubName); if (developer is null) return NotFound("Developer not found"); - + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) return StatusCode(403, "You must be an editor of the developer to create a custom app"); @@ -164,4 +197,235 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d return NoContent(); } + + [HttpGet("{appId:guid}/secrets")] + [Authorize] + public async Task ListSecrets( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid appId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to view app secrets"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + var app = await customApps.GetAppAsync(appId, projectId); + if (app == null) + return NotFound("App not found"); + + var secrets = await customApps.GetAppSecretsAsync(appId); + return Ok(secrets.Select(s => new SecretResponse( + s.Id.ToString(), + null, + s.Description, + s.ExpiredAt, + s.IsOidc, + s.CreatedAt, + s.UpdatedAt + ))); + } + + [HttpPost("{appId:guid}/secrets")] + [Authorize] + public async Task CreateSecret( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid appId, + [FromBody] CreateSecretRequest request) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to create app secrets"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + var app = await customApps.GetAppAsync(appId, projectId); + if (app == null) + return NotFound("App not found"); + + try + { + var secret = await customApps.CreateAppSecretAsync(new CustomAppSecret + { + AppId = appId, + Description = request.Description, + ExpiredAt = request.ExpiresIn.HasValue + ? NodaTime.SystemClock.Instance.GetCurrentInstant() + .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value)) + : (NodaTime.Instant?)null, + IsOidc = request.IsOidc + }); + + return CreatedAtAction( + nameof(GetSecret), + new { pubName, projectId, appId, secretId = secret.Id }, + new SecretResponse( + secret.Id.ToString(), + secret.Secret, + secret.Description, + secret.ExpiredAt, + secret.IsOidc, + secret.CreatedAt, + secret.UpdatedAt + ) + ); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("{appId:guid}/secrets/{secretId:guid}")] + [Authorize] + public async Task GetSecret( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid appId, + [FromRoute] Guid secretId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to view app secrets"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + var app = await customApps.GetAppAsync(appId, projectId); + if (app == null) + return NotFound("App not found"); + + var secret = await customApps.GetAppSecretAsync(secretId, appId); + if (secret == null) + return NotFound("Secret not found"); + + return Ok(new SecretResponse( + secret.Id.ToString(), + null, + secret.Description, + secret.ExpiredAt, + secret.IsOidc, + secret.CreatedAt, + secret.UpdatedAt + )); + } + + [HttpDelete("{appId:guid}/secrets/{secretId:guid}")] + [Authorize] + public async Task DeleteSecret( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid appId, + [FromRoute] Guid secretId) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to delete app secrets"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + var app = await customApps.GetAppAsync(appId, projectId); + if (app == null) + return NotFound("App not found"); + + var secret = await customApps.GetAppSecretAsync(secretId, appId); + if (secret == null) + return NotFound("Secret not found"); + + var result = await customApps.DeleteAppSecretAsync(secretId, appId); + if (!result) + return NotFound("Failed to delete secret"); + + return NoContent(); + } + + [HttpPost("{appId:guid}/secrets/{secretId:guid}/rotate")] + [Authorize] + public async Task RotateSecret( + [FromRoute] string pubName, + [FromRoute] Guid projectId, + [FromRoute] Guid appId, + [FromRoute] Guid secretId, + [FromBody] CreateSecretRequest? request = null) + { + if (HttpContext.Items["CurrentUser"] is not Account currentUser) + return Unauthorized(); + + var developer = await ds.GetDeveloperByName(pubName); + if (developer is null) + return NotFound("Developer not found"); + + if (!await ds.IsMemberWithRole(developer.PublisherId, Guid.Parse(currentUser.Id), PublisherMemberRole.Editor)) + return StatusCode(403, "You must be an editor of the developer to rotate app secrets"); + + var project = await projectService.GetProjectAsync(projectId, developer.Id); + if (project is null) + return NotFound("Project not found or you don't have access"); + + var app = await customApps.GetAppAsync(appId, projectId); + if (app == null) + return NotFound("App not found"); + + try + { + var secret = await customApps.RotateAppSecretAsync(new CustomAppSecret + { + Id = secretId, + AppId = appId, + Description = request?.Description, + ExpiredAt = request?.ExpiresIn.HasValue == true + ? NodaTime.SystemClock.Instance.GetCurrentInstant() + .Plus(Duration.FromTimeSpan(request.ExpiresIn.Value)) + : (NodaTime.Instant?)null, + IsOidc = request?.IsOidc ?? false + }); + + return Ok(new SecretResponse( + secret.Id.ToString(), + secret.Secret, + secret.Description, + secret.ExpiredAt, + secret.IsOidc, + secret.CreatedAt, + secret.UpdatedAt + )); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } } \ No newline at end of file diff --git a/DysonNetwork.Develop/Identity/CustomAppService.cs b/DysonNetwork.Develop/Identity/CustomAppService.cs index c763402..313d0df 100644 --- a/DysonNetwork.Develop/Identity/CustomAppService.cs +++ b/DysonNetwork.Develop/Identity/CustomAppService.cs @@ -2,6 +2,8 @@ using DysonNetwork.Develop.Project; using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using Microsoft.EntityFrameworkCore; +using System.Security.Cryptography; +using System.Text; namespace DysonNetwork.Develop.Identity; @@ -94,6 +96,87 @@ public class CustomAppService( return await query.FirstOrDefaultAsync(a => a.Id == id); } + public async Task> GetAppSecretsAsync(Guid appId) + { + return await db.CustomAppSecrets + .Where(s => s.AppId == appId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(); + } + + public async Task GetAppSecretAsync(Guid secretId, Guid appId) + { + return await db.CustomAppSecrets + .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); + } + + public async Task CreateAppSecretAsync(CustomAppSecret secret) + { + if (string.IsNullOrWhiteSpace(secret.Secret)) + { + // Generate a new random secret if not provided + secret.Secret = GenerateRandomSecret(); + } + + secret.Id = Guid.NewGuid(); + secret.CreatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(); + secret.UpdatedAt = secret.CreatedAt; + + db.CustomAppSecrets.Add(secret); + await db.SaveChangesAsync(); + + return secret; + } + + public async Task DeleteAppSecretAsync(Guid secretId, Guid appId) + { + var secret = await db.CustomAppSecrets + .FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId); + + if (secret == null) + return false; + + db.CustomAppSecrets.Remove(secret); + await db.SaveChangesAsync(); + return true; + } + + public async Task RotateAppSecretAsync(CustomAppSecret secretUpdate) + { + var existingSecret = await db.CustomAppSecrets + .FirstOrDefaultAsync(s => s.Id == secretUpdate.Id && s.AppId == secretUpdate.AppId); + + if (existingSecret == null) + throw new InvalidOperationException("Secret not found"); + + // Update the existing secret with new values + existingSecret.Secret = GenerateRandomSecret(); + existingSecret.Description = secretUpdate.Description ?? existingSecret.Description; + existingSecret.ExpiredAt = secretUpdate.ExpiredAt ?? existingSecret.ExpiredAt; + existingSecret.IsOidc = secretUpdate.IsOidc; + existingSecret.UpdatedAt = NodaTime.SystemClock.Instance.GetCurrentInstant(); + + await db.SaveChangesAsync(); + return existingSecret; + } + + private static string GenerateRandomSecret(int length = 64) + { + const string valid = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-._~+"; + var res = new StringBuilder(); + using (var rng = RandomNumberGenerator.Create()) + { + var uintBuffer = new byte[sizeof(uint)]; + while (length-- > 0) + { + rng.GetBytes(uintBuffer); + var num = BitConverter.ToUInt32(uintBuffer, 0); + res.Append(valid[(int)(num % (uint)valid.Length)]); + } + } + return res.ToString(); + } + public async Task> GetAppsByProjectAsync(Guid projectId) { return await db.CustomApps