✨ App custom secret management
This commit is contained in:
@@ -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<IActionResult> 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,12 +61,20 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
}
|
||||
|
||||
[HttpGet("{appId:guid}")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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();
|
||||
|
||||
@@ -164,4 +197,235 @@ public class CustomAppController(CustomAppService customApps, DeveloperService d
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{appId:guid}/secrets")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
@@ -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<List<CustomAppSecret>> GetAppSecretsAsync(Guid appId)
|
||||
{
|
||||
return await db.CustomAppSecrets
|
||||
.Where(s => s.AppId == appId)
|
||||
.OrderByDescending(s => s.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret?> GetAppSecretAsync(Guid secretId, Guid appId)
|
||||
{
|
||||
return await db.CustomAppSecrets
|
||||
.FirstOrDefaultAsync(s => s.Id == secretId && s.AppId == appId);
|
||||
}
|
||||
|
||||
public async Task<CustomAppSecret> 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<bool> 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<CustomAppSecret> 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<List<CustomApp>> GetAppsByProjectAsync(Guid projectId)
|
||||
{
|
||||
return await db.CustomApps
|
||||
|
Reference in New Issue
Block a user