WIP
This commit is contained in:
13
src/api/MicCheck.Api/ApiKeys/ApiKey.cs
Executable file
13
src/api/MicCheck.Api/ApiKeys/ApiKey.cs
Executable file
@@ -0,0 +1,13 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public class ApiKey
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Prefix { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public int OrganizationId { get; init; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
46
src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs
Executable file
46
src/api/MicCheck.Api/ApiKeys/ApiKeyEndpoints.cs
Executable file
@@ -0,0 +1,46 @@
|
||||
using MicCheck.Api.Authorization;
|
||||
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public static class ApiKeyEndpoints
|
||||
{
|
||||
public static void MapApiKeyEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app
|
||||
.MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
|
||||
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
|
||||
.WithTags("ApiKeys");
|
||||
|
||||
group.MapPost("/", async (
|
||||
int organizationId,
|
||||
CreateApiKeyRequest request,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (key, rawKey) = await apiKeyService.CreateAsync(
|
||||
organizationId, request.Name, request.ExpiresAt, ct);
|
||||
|
||||
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
|
||||
}).WithName("CreateApiKey");
|
||||
|
||||
group.MapGet("/", async (
|
||||
int organizationId,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var keys = await apiKeyService.ListAsync(organizationId, ct);
|
||||
return Results.Ok(keys.Select(k =>
|
||||
new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
|
||||
}).WithName("ListApiKeys");
|
||||
|
||||
group.MapDelete("/{keyId:int}", async (
|
||||
int organizationId,
|
||||
int keyId,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
|
||||
return Results.NoContent();
|
||||
}).WithName("RevokeApiKey");
|
||||
}
|
||||
}
|
||||
22
src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs
Executable file
22
src/api/MicCheck.Api/ApiKeys/ApiKeyHasher.cs
Executable file
@@ -0,0 +1,22 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public static class ApiKeyHasher
|
||||
{
|
||||
public static string Hash(string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string GenerateKey()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
9
src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs
Executable file
9
src/api/MicCheck.Api/ApiKeys/ApiKeyResponse.cs
Executable file
@@ -0,0 +1,9 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
50
src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs
Executable file
50
src/api/MicCheck.Api/ApiKeys/ApiKeyService.cs
Executable file
@@ -0,0 +1,50 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public class ApiKeyService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
|
||||
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
|
||||
{
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
||||
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Key = hashedKey,
|
||||
Prefix = prefix,
|
||||
Name = name,
|
||||
OrganizationId = organizationId,
|
||||
IsActive = true,
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return (apiKey, rawKey);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.ApiKeys
|
||||
.Where(k => k.OrganizationId == organizationId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(int organizationId, int keyId, CancellationToken ct = default)
|
||||
{
|
||||
var key = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId && k.OrganizationId == organizationId, ct);
|
||||
|
||||
if (key is null)
|
||||
return;
|
||||
|
||||
key.IsActive = false;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
3
src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs
Executable file
3
src/api/MicCheck.Api/ApiKeys/CreateApiKeyRequest.cs
Executable file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);
|
||||
8
src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs
Executable file
8
src/api/MicCheck.Api/ApiKeys/CreateApiKeyResponse.cs
Executable file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.ApiKeys;
|
||||
|
||||
public record CreateApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Key,
|
||||
string Prefix,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
0
src/api/MicCheck.Api/Audit/AuditLog.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditLog.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditLogFilter.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditLogFilter.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditLogQueryService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditLogResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditLogResponse.cs
Normal file → Executable file
118
src/api/MicCheck.Api/Audit/AuditLogsController.cs
Normal file → Executable file
118
src/api/MicCheck.Api/Audit/AuditLogsController.cs
Normal file → Executable file
@@ -1,59 +1,59 @@
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using MicCheck.Api.Environments;
|
||||
using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Organizations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Audit;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class AuditLogsController(
|
||||
AuditLogQueryService auditLogQueryService,
|
||||
OrganizationService organizationService,
|
||||
ProjectService projectService,
|
||||
EnvironmentService environmentService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/v1/organisations/{id}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
|
||||
int id,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
|
||||
int projectId,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(projectId, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/environments/{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
|
||||
string apiKey,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using MicCheck.Api.Environments;
|
||||
using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Organizations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Audit;
|
||||
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class AuditLogsController(
|
||||
AuditLogQueryService auditLogQueryService,
|
||||
OrganizationService organizationService,
|
||||
ProjectService projectService,
|
||||
EnvironmentService environmentService) : ControllerBase
|
||||
{
|
||||
[HttpGet("api/v1/organisations/{id}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByOrganization(
|
||||
int id,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var org = await organizationService.FindByIdAsync(id, ct);
|
||||
if (org is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByOrganizationAsync(id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/projects/{projectId}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByProject(
|
||||
int projectId,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var project = await projectService.FindByIdAsync(projectId, ct);
|
||||
if (project is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByProjectAsync(projectId, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
|
||||
[HttpGet("api/v1/environments/{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> ListByEnvironment(
|
||||
string apiKey,
|
||||
[FromQuery] AuditLogFilter filter,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (total, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(total, null, null, logs.ToList()));
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Audit/AuditService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Audit/AuditService.cs
Normal file → Executable file
60
src/api/MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs
Executable file
60
src/api/MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs
Executable file
@@ -0,0 +1,60 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using MicCheck.Api.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MicCheck.Api.Authentication;
|
||||
|
||||
public class ApiKeyAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "ApiKey";
|
||||
private const string ApiKeyPrefix = "Api-Key ";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var headerValue = authHeader.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue) ||
|
||||
!headerValue.StartsWith(ApiKeyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var rawKey = headerValue[ApiKeyPrefix.Length..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(rawKey))
|
||||
return AuthenticateResult.Fail("API key value is empty.");
|
||||
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
|
||||
var apiKey = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Key == hashedKey);
|
||||
|
||||
if (apiKey is null)
|
||||
return AuthenticateResult.Fail("Invalid API key.");
|
||||
|
||||
if (!apiKey.IsActive)
|
||||
return AuthenticateResult.Fail("API key is inactive.");
|
||||
|
||||
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
return AuthenticateResult.Fail("API key has expired.");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("OrganizationId", apiKey.OrganizationId.ToString()),
|
||||
new Claim("OrganizationRole", "Admin")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
45
src/api/MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs
Executable file
45
src/api/MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs
Executable file
@@ -0,0 +1,45 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MicCheck.Api.Authentication;
|
||||
|
||||
public class EnvironmentKeyAuthenticationHandler(
|
||||
IOptionsMonitor<AuthenticationSchemeOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "EnvironmentKey";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-Environment-Key", out var keyValues))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var key = keyValues.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return AuthenticateResult.Fail("X-Environment-Key header is empty.");
|
||||
|
||||
var environment = await db.Environments
|
||||
.FirstOrDefaultAsync(e => e.ApiKey == key);
|
||||
|
||||
if (environment is null)
|
||||
return AuthenticateResult.Fail("Invalid environment key.");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("EnvironmentId", environment.Id.ToString()),
|
||||
new Claim("ProjectId", environment.ProjectId.ToString())
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
66
src/api/MicCheck.Api/Authorization/AuthEndpoints.cs
Executable file
66
src/api/MicCheck.Api/Authorization/AuthEndpoints.cs
Executable file
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/auth").AllowAnonymous().WithTags("Auth");
|
||||
|
||||
group.MapPost("/login", async (
|
||||
[FromBody] LoginRequest request,
|
||||
AuthService authService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Email and password are required.");
|
||||
|
||||
var response = await authService.LoginAsync(request.Email, request.Password, ct);
|
||||
return response is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(response);
|
||||
}).WithName("Login");
|
||||
|
||||
group.MapPost("/register", async (
|
||||
[FromBody] RegisterRequest request,
|
||||
AuthService authService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Email and password are required.");
|
||||
|
||||
var response = await authService.RegisterAsync(
|
||||
request.Email, request.Password,
|
||||
request.FirstName, request.LastName,
|
||||
request.OrganizationName, ct);
|
||||
|
||||
return response is null
|
||||
? Results.Conflict("An account with this email already exists.")
|
||||
: Results.Ok(response);
|
||||
}).WithName("Register");
|
||||
|
||||
group.MapPost("/refresh", async (
|
||||
[FromBody] RefreshRequest request,
|
||||
AuthService authService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
return Results.BadRequest("Refresh token is required.");
|
||||
|
||||
var response = await authService.RefreshAsync(request.Token, ct);
|
||||
return response is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(response);
|
||||
}).WithName("RefreshToken");
|
||||
|
||||
group.MapPost("/logout", async (
|
||||
[FromBody] LogoutRequest request,
|
||||
AuthService authService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await authService.LogoutAsync(request.Token, ct);
|
||||
return Results.NoContent();
|
||||
}).WithName("Logout");
|
||||
}
|
||||
}
|
||||
145
src/api/MicCheck.Api/Authorization/AuthService.cs
Executable file
145
src/api/MicCheck.Api/Authorization/AuthService.cs
Executable file
@@ -0,0 +1,145 @@
|
||||
using System.Security.Cryptography;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public class AuthService(
|
||||
MicCheckDbContext db,
|
||||
ITokenService tokenService,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
{
|
||||
public async Task<LoginResponse?> LoginAsync(string email, string password, CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.Users
|
||||
.Include(u => u.Organizations)
|
||||
.FirstOrDefaultAsync(u => u.Email == email && u.IsActive, ct);
|
||||
|
||||
if (user is null)
|
||||
return null;
|
||||
|
||||
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
return null;
|
||||
|
||||
var accessToken = tokenService.GenerateToken(user);
|
||||
var refreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = refreshTokenValue,
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse?> RegisterAsync(
|
||||
string email, string password,
|
||||
string firstName, string lastName,
|
||||
string organizationName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (await db.Users.AnyAsync(u => u.Email == email, ct))
|
||||
return null;
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = email,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PasswordHash = string.Empty
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Name = organizationName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Organizations.Add(organization);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
db.OrganizationUsers.Add(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Role = OrganizationRole.Admin
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
|
||||
|
||||
var accessToken = tokenService.GenerateToken(user);
|
||||
var refreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = refreshTokenValue,
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse?> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens
|
||||
.Include(rt => rt.User)
|
||||
.ThenInclude(u => u.Organizations)
|
||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
||||
|
||||
if (token is null || token.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return null;
|
||||
|
||||
token.IsRevoked = true;
|
||||
|
||||
var accessToken = tokenService.GenerateToken(token.User);
|
||||
var newRefreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = newRefreshTokenValue,
|
||||
UserId = token.UserId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, newRefreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
||||
|
||||
if (token is null)
|
||||
return;
|
||||
|
||||
token.IsRevoked = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
8
src/api/MicCheck.Api/Authorization/AuthorizationPolicies.cs
Executable file
8
src/api/MicCheck.Api/Authorization/AuthorizationPolicies.cs
Executable file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string FlagsApiAccess = "FlagsApiAccess";
|
||||
public const string AdminApiAccess = "AdminApiAccess";
|
||||
public const string OrganizationAdmin = "OrganizationAdmin";
|
||||
}
|
||||
8
src/api/MicCheck.Api/Authorization/ITokenService.cs
Executable file
8
src/api/MicCheck.Api/Authorization/ITokenService.cs
Executable file
@@ -0,0 +1,8 @@
|
||||
using MicCheck.Api.Users;
|
||||
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
TokenResponse GenerateToken(User user);
|
||||
}
|
||||
3
src/api/MicCheck.Api/Authorization/LoginRequest.cs
Executable file
3
src/api/MicCheck.Api/Authorization/LoginRequest.cs
Executable file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
3
src/api/MicCheck.Api/Authorization/LoginResponse.cs
Executable file
3
src/api/MicCheck.Api/Authorization/LoginResponse.cs
Executable file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);
|
||||
3
src/api/MicCheck.Api/Authorization/LogoutRequest.cs
Executable file
3
src/api/MicCheck.Api/Authorization/LogoutRequest.cs
Executable file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record LogoutRequest(string Token);
|
||||
17
src/api/MicCheck.Api/Authorization/ProjectPermission.cs
Executable file
17
src/api/MicCheck.Api/Authorization/ProjectPermission.cs
Executable file
@@ -0,0 +1,17 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public enum ProjectPermission
|
||||
{
|
||||
ViewProject,
|
||||
CreateFeature,
|
||||
EditFeature,
|
||||
DeleteFeature,
|
||||
CreateEnvironment,
|
||||
EditEnvironment,
|
||||
DeleteEnvironment,
|
||||
CreateSegment,
|
||||
EditSegment,
|
||||
DeleteSegment,
|
||||
ManageWebhooks,
|
||||
ViewAuditLog
|
||||
}
|
||||
10
src/api/MicCheck.Api/Authorization/ProjectPermissionRequirement.cs
Executable file
10
src/api/MicCheck.Api/Authorization/ProjectPermissionRequirement.cs
Executable file
@@ -0,0 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public class ProjectPermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public ProjectPermission Permission { get; }
|
||||
|
||||
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
|
||||
}
|
||||
54
src/api/MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs
Executable file
54
src/api/MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs
Executable file
@@ -0,0 +1,54 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Organizations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public class ProjectPermissionRequirementHandler(
|
||||
MicCheckDbContext db,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
: AuthorizationHandler<ProjectPermissionRequirement>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ProjectPermissionRequirement requirement)
|
||||
{
|
||||
var userIdClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub);
|
||||
if (userIdClaim is null || !int.TryParse(userIdClaim.Value, out var userId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.User.HasClaim("OrganizationRole", OrganizationRole.Admin.ToString()))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is null ||
|
||||
!httpContext.Request.RouteValues.TryGetValue("projectId", out var projectIdValue) ||
|
||||
!int.TryParse(projectIdValue?.ToString(), out var projectId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
var permission = await db.UserProjectPermissions
|
||||
.FirstOrDefaultAsync(p => p.UserId == userId && p.ProjectId == projectId);
|
||||
|
||||
if (permission is null)
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission.IsAdmin || permission.Permissions.Contains(requirement.Permission))
|
||||
context.Succeed(requirement);
|
||||
else
|
||||
context.Fail();
|
||||
}
|
||||
}
|
||||
3
src/api/MicCheck.Api/Authorization/RefreshRequest.cs
Executable file
3
src/api/MicCheck.Api/Authorization/RefreshRequest.cs
Executable file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record RefreshRequest(string Token);
|
||||
8
src/api/MicCheck.Api/Authorization/RegisterRequest.cs
Executable file
8
src/api/MicCheck.Api/Authorization/RegisterRequest.cs
Executable file
@@ -0,0 +1,8 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record RegisterRequest(
|
||||
string Email,
|
||||
string Password,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string OrganizationName);
|
||||
3
src/api/MicCheck.Api/Authorization/TokenResponse.cs
Executable file
3
src/api/MicCheck.Api/Authorization/TokenResponse.cs
Executable file
@@ -0,0 +1,3 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public record TokenResponse(string Token, DateTime ExpiresAt);
|
||||
52
src/api/MicCheck.Api/Authorization/TokenService.cs
Executable file
52
src/api/MicCheck.Api/Authorization/TokenService.cs
Executable file
@@ -0,0 +1,52 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public class TokenService(IConfiguration configuration) : ITokenService
|
||||
{
|
||||
public TokenResponse GenerateToken(User user)
|
||||
{
|
||||
var secretKey = configuration["Jwt:SecretKey"]
|
||||
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
||||
var issuer = configuration["Jwt:Issuer"]
|
||||
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
||||
var audience = configuration["Jwt:Audience"]
|
||||
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||
var expiryMinutes = int.Parse(configuration["Jwt:ExpiryMinutes"] ?? "60");
|
||||
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Email, user.Email),
|
||||
new(JwtRegisteredClaimNames.GivenName, user.FirstName),
|
||||
new(JwtRegisteredClaimNames.FamilyName, user.LastName),
|
||||
new(JwtRegisteredClaimNames.Iat,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
|
||||
ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
foreach (var org in user.Organizations)
|
||||
{
|
||||
claims.Add(new Claim("OrganizationId", org.OrganizationId.ToString()));
|
||||
claims.Add(new Claim("OrganizationRole", org.Role.ToString()));
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||
}
|
||||
}
|
||||
9
src/api/MicCheck.Api/Authorization/UserProjectPermission.cs
Executable file
9
src/api/MicCheck.Api/Authorization/UserProjectPermission.cs
Executable file
@@ -0,0 +1,9 @@
|
||||
namespace MicCheck.Api.Authorization;
|
||||
|
||||
public class UserProjectPermission
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
public int ProjectId { get; init; }
|
||||
public List<ProjectPermission> Permissions { get; set; } = [];
|
||||
public bool IsAdmin { get; set; }
|
||||
}
|
||||
0
src/api/MicCheck.Api/Common/DomainException.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Common/DomainException.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Common/PaginatedResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Common/PaginatedResponse.cs
Normal file → Executable file
26
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs
Normal file → Executable file
26
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKey.cs
Normal file → Executable file
@@ -1,13 +1,13 @@
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public class ApiKey
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Prefix { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public int OrganizationId { get; init; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public class ApiKey
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Prefix { get; set; }
|
||||
public required string Name { get; set; }
|
||||
public int OrganizationId { get; init; }
|
||||
public bool IsActive { get; set; }
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
83
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs
Normal file → Executable file
83
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyEndpoints.cs
Normal file → Executable file
@@ -1,46 +1,37 @@
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public static class ApiKeyEndpoints
|
||||
{
|
||||
public static void MapApiKeyEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app
|
||||
.MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
|
||||
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
|
||||
.WithTags("ApiKeys");
|
||||
|
||||
group.MapPost("/", async (
|
||||
int organizationId,
|
||||
CreateApiKeyRequest request,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var (key, rawKey) = await apiKeyService.CreateAsync(
|
||||
organizationId, request.Name, request.ExpiresAt, ct);
|
||||
|
||||
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
|
||||
}).WithName("CreateApiKey");
|
||||
|
||||
group.MapGet("/", async (
|
||||
int organizationId,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var keys = await apiKeyService.ListAsync(organizationId, ct);
|
||||
return Results.Ok(keys.Select(k =>
|
||||
new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
|
||||
}).WithName("ListApiKeys");
|
||||
|
||||
group.MapDelete("/{keyId:int}", async (
|
||||
int organizationId,
|
||||
int keyId,
|
||||
ApiKeyService apiKeyService,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
|
||||
return Results.NoContent();
|
||||
}).WithName("RevokeApiKey");
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public static class ApiKeyEndpoints
|
||||
{
|
||||
public static void MapApiKeyEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app
|
||||
.MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
|
||||
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
|
||||
.WithTags("ApiKeys");
|
||||
|
||||
group.MapPost("/", async (int organizationId, CreateApiKeyRequest request, ApiKeyService apiKeyService, CancellationToken ct) =>
|
||||
{
|
||||
var (key, rawKey) = await apiKeyService.CreateAsync(
|
||||
organizationId, request.Name, request.ExpiresAt, ct);
|
||||
|
||||
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
|
||||
|
||||
}).WithName("CreateApiKey");
|
||||
|
||||
group.MapGet("/", async (int organizationId, ApiKeyService apiKeyService, CancellationToken ct) =>
|
||||
{
|
||||
var keys = await apiKeyService.ListAsync(organizationId, ct);
|
||||
return Results.Ok(keys.Select(k => new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
|
||||
|
||||
}).WithName("ListApiKeys");
|
||||
|
||||
group.MapDelete("/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) =>
|
||||
{
|
||||
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
|
||||
return Results.NoContent();
|
||||
|
||||
}).WithName("RevokeApiKey");
|
||||
}
|
||||
}
|
||||
|
||||
44
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyHasher.cs
Normal file → Executable file
44
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyHasher.cs
Normal file → Executable file
@@ -1,22 +1,22 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public static class ApiKeyHasher
|
||||
{
|
||||
public static string Hash(string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string GenerateKey()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public static class ApiKeyHasher
|
||||
{
|
||||
public static string Hash(string key)
|
||||
{
|
||||
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static string GenerateKey()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(32);
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
|
||||
18
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyResponse.cs
Normal file → Executable file
18
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyResponse.cs
Normal file → Executable file
@@ -1,9 +1,9 @@
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record ApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Prefix,
|
||||
bool IsActive,
|
||||
DateTimeOffset? ExpiresAt,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
100
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs
Normal file → Executable file
100
src/api/MicCheck.Api/Common/Security/ApiKeys/ApiKeyService.cs
Normal file → Executable file
@@ -1,50 +1,50 @@
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public class ApiKeyService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
|
||||
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
|
||||
{
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
||||
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Key = hashedKey,
|
||||
Prefix = prefix,
|
||||
Name = name,
|
||||
OrganizationId = organizationId,
|
||||
IsActive = true,
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return (apiKey, rawKey);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.ApiKeys
|
||||
.Where(k => k.OrganizationId == organizationId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(int organizationId, int keyId, CancellationToken ct = default)
|
||||
{
|
||||
var key = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId && k.OrganizationId == organizationId, ct);
|
||||
|
||||
if (key is null)
|
||||
return;
|
||||
|
||||
key.IsActive = false;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public class ApiKeyService(MicCheckDbContext db)
|
||||
{
|
||||
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
|
||||
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
|
||||
{
|
||||
var rawKey = ApiKeyHasher.GenerateKey();
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
|
||||
|
||||
var apiKey = new ApiKey
|
||||
{
|
||||
Key = hashedKey,
|
||||
Prefix = prefix,
|
||||
Name = name,
|
||||
OrganizationId = organizationId,
|
||||
IsActive = true,
|
||||
ExpiresAt = expiresAt,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.ApiKeys.Add(apiKey);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return (apiKey, rawKey);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.ApiKeys
|
||||
.Where(k => k.OrganizationId == organizationId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task RevokeAsync(int organizationId, int keyId, CancellationToken ct = default)
|
||||
{
|
||||
var key = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Id == keyId && k.OrganizationId == organizationId, ct);
|
||||
|
||||
if (key is null)
|
||||
return;
|
||||
|
||||
key.IsActive = false;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
6
src/api/MicCheck.Api/Common/Security/ApiKeys/CreateApiKeyRequest.cs
Normal file → Executable file
6
src/api/MicCheck.Api/Common/Security/ApiKeys/CreateApiKeyRequest.cs
Normal file → Executable file
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);
|
||||
|
||||
16
src/api/MicCheck.Api/Common/Security/ApiKeys/CreateApiKeyResponse.cs
Normal file → Executable file
16
src/api/MicCheck.Api/Common/Security/ApiKeys/CreateApiKeyResponse.cs
Normal file → Executable file
@@ -1,8 +1,8 @@
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record CreateApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Key,
|
||||
string Prefix,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
namespace MicCheck.Api.Common.Security.ApiKeys;
|
||||
|
||||
public record CreateApiKeyResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Key,
|
||||
string Prefix,
|
||||
DateTimeOffset? ExpiresAt);
|
||||
|
||||
112
src/api/MicCheck.Api/Common/Security/Authentication/ApiKeyAuthenticationHandler.cs
Normal file → Executable file
112
src/api/MicCheck.Api/Common/Security/Authentication/ApiKeyAuthenticationHandler.cs
Normal file → Executable file
@@ -1,56 +1,56 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authentication;
|
||||
|
||||
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "ApiKey";
|
||||
private const string ApiKeyPrefix = "Api-Key ";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var headerValue = authHeader.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue) ||
|
||||
!headerValue.StartsWith(ApiKeyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var rawKey = headerValue[ApiKeyPrefix.Length..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(rawKey))
|
||||
return AuthenticateResult.Fail("API key value is empty.");
|
||||
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
|
||||
var apiKey = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Key == hashedKey);
|
||||
|
||||
if (apiKey is null)
|
||||
return AuthenticateResult.Fail("Invalid API key.");
|
||||
|
||||
if (!apiKey.IsActive)
|
||||
return AuthenticateResult.Fail("API key is inactive.");
|
||||
|
||||
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
return AuthenticateResult.Fail("API key has expired.");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("OrganizationId", apiKey.OrganizationId.ToString()),
|
||||
new Claim("OrganizationRole", "Admin")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authentication;
|
||||
|
||||
public class ApiKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "ApiKey";
|
||||
private const string ApiKeyPrefix = "Api-Key ";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var headerValue = authHeader.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(headerValue) ||
|
||||
!headerValue.StartsWith(ApiKeyPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var rawKey = headerValue[ApiKeyPrefix.Length..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(rawKey))
|
||||
return AuthenticateResult.Fail("API key value is empty.");
|
||||
|
||||
var hashedKey = ApiKeyHasher.Hash(rawKey);
|
||||
|
||||
var apiKey = await db.ApiKeys
|
||||
.FirstOrDefaultAsync(k => k.Key == hashedKey);
|
||||
|
||||
if (apiKey is null)
|
||||
return AuthenticateResult.Fail("Invalid API key.");
|
||||
|
||||
if (!apiKey.IsActive)
|
||||
return AuthenticateResult.Fail("API key is inactive.");
|
||||
|
||||
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTimeOffset.UtcNow)
|
||||
return AuthenticateResult.Fail("API key has expired.");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("OrganizationId", apiKey.OrganizationId.ToString()),
|
||||
new Claim("OrganizationRole", "Admin")
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
82
src/api/MicCheck.Api/Common/Security/Authentication/EnvironmentKeyAuthenticationHandler.cs
Normal file → Executable file
82
src/api/MicCheck.Api/Common/Security/Authentication/EnvironmentKeyAuthenticationHandler.cs
Normal file → Executable file
@@ -1,41 +1,41 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authentication;
|
||||
|
||||
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "EnvironmentKey";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-Environment-Key", out var keyValues))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var key = keyValues.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return AuthenticateResult.Fail("X-Environment-Key header is empty.");
|
||||
|
||||
var environment = await db.Environments
|
||||
.FirstOrDefaultAsync(e => e.ApiKey == key);
|
||||
|
||||
if (environment is null)
|
||||
return AuthenticateResult.Fail("Invalid environment key.");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("EnvironmentId", environment.Id.ToString()),
|
||||
new Claim("ProjectId", environment.ProjectId.ToString())
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using MicCheck.Api.Data;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authentication;
|
||||
|
||||
public class EnvironmentKeyAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder, MicCheckDbContext db)
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
|
||||
{
|
||||
public const string SchemeName = "EnvironmentKey";
|
||||
|
||||
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
if (!Request.Headers.TryGetValue("X-Environment-Key", out var keyValues))
|
||||
return AuthenticateResult.NoResult();
|
||||
|
||||
var key = keyValues.FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return AuthenticateResult.Fail("X-Environment-Key header is empty.");
|
||||
|
||||
var environment = await db.Environments
|
||||
.FirstOrDefaultAsync(e => e.ApiKey == key);
|
||||
|
||||
if (environment is null)
|
||||
return AuthenticateResult.Fail("Invalid environment key.");
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("EnvironmentId", environment.Id.ToString()),
|
||||
new Claim("ProjectId", environment.ProjectId.ToString())
|
||||
};
|
||||
|
||||
var identity = new ClaimsIdentity(claims, Scheme.Name);
|
||||
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
|
||||
|
||||
return AuthenticateResult.Success(ticket);
|
||||
}
|
||||
}
|
||||
|
||||
116
src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs
Normal file → Executable file
116
src/api/MicCheck.Api/Common/Security/Authorization/AuthEndpoints.cs
Normal file → Executable file
@@ -1,58 +1,58 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/auth").AllowAnonymous().WithTags("Auth");
|
||||
|
||||
group.MapPost("/login", async ([FromBody] LoginRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Email and password are required.");
|
||||
|
||||
var response = await authService.LoginAsync(request.Email, request.Password, ct);
|
||||
return response is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(response);
|
||||
|
||||
}).WithName("Login");
|
||||
|
||||
group.MapPost("/register", async ([FromBody] RegisterRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Email and password are required.");
|
||||
|
||||
var response = await authService.RegisterAsync(
|
||||
request.Email, request.Password,
|
||||
request.FirstName, request.LastName,
|
||||
request.OrganizationName, ct);
|
||||
|
||||
return response is null
|
||||
? Results.Conflict("An account with this email already exists.")
|
||||
: Results.Ok(response);
|
||||
|
||||
}).WithName("Register");
|
||||
|
||||
group.MapPost("/refresh", async ([FromBody] RefreshRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
return Results.BadRequest("Refresh token is required.");
|
||||
|
||||
var response = await authService.RefreshAsync(request.Token, ct);
|
||||
return response is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(response);
|
||||
|
||||
}).WithName("RefreshToken");
|
||||
|
||||
group.MapPost("/logout", async ([FromBody] LogoutRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
await authService.LogoutAsync(request.Token, ct);
|
||||
return Results.NoContent();
|
||||
|
||||
}).WithName("Logout");
|
||||
}
|
||||
}
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("/api/v1/auth").AllowAnonymous().WithTags("Auth");
|
||||
|
||||
group.MapPost("/login", async ([FromBody] LoginRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Email and password are required.");
|
||||
|
||||
var response = await authService.LoginAsync(request.Email, request.Password, ct);
|
||||
return response is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(response);
|
||||
|
||||
}).WithName("Login");
|
||||
|
||||
group.MapPost("/register", async ([FromBody] RegisterRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Email) || string.IsNullOrWhiteSpace(request.Password))
|
||||
return Results.BadRequest("Email and password are required.");
|
||||
|
||||
var response = await authService.RegisterAsync(
|
||||
request.Email, request.Password,
|
||||
request.FirstName, request.LastName,
|
||||
request.OrganizationName, ct);
|
||||
|
||||
return response is null
|
||||
? Results.Conflict("An account with this email already exists.")
|
||||
: Results.Ok(response);
|
||||
|
||||
}).WithName("Register");
|
||||
|
||||
group.MapPost("/refresh", async ([FromBody] RefreshRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Token))
|
||||
return Results.BadRequest("Refresh token is required.");
|
||||
|
||||
var response = await authService.RefreshAsync(request.Token, ct);
|
||||
return response is null
|
||||
? Results.Unauthorized()
|
||||
: Results.Ok(response);
|
||||
|
||||
}).WithName("RefreshToken");
|
||||
|
||||
group.MapPost("/logout", async ([FromBody] LogoutRequest request, AuthService authService, CancellationToken ct) =>
|
||||
{
|
||||
await authService.LogoutAsync(request.Token, ct);
|
||||
return Results.NoContent();
|
||||
|
||||
}).WithName("Logout");
|
||||
}
|
||||
}
|
||||
|
||||
290
src/api/MicCheck.Api/Common/Security/Authorization/AuthService.cs
Normal file → Executable file
290
src/api/MicCheck.Api/Common/Security/Authorization/AuthService.cs
Normal file → Executable file
@@ -1,145 +1,145 @@
|
||||
using System.Security.Cryptography;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class AuthService(
|
||||
MicCheckDbContext db,
|
||||
ITokenService tokenService,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
{
|
||||
public async Task<LoginResponse?> LoginAsync(string email, string password, CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.Users
|
||||
.Include(u => u.Organizations)
|
||||
.FirstOrDefaultAsync(u => u.Email == email && u.IsActive, ct);
|
||||
|
||||
if (user is null)
|
||||
return null;
|
||||
|
||||
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
return null;
|
||||
|
||||
var accessToken = tokenService.GenerateToken(user);
|
||||
var refreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = refreshTokenValue,
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse?> RegisterAsync(
|
||||
string email, string password,
|
||||
string firstName, string lastName,
|
||||
string organizationName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (await db.Users.AnyAsync(u => u.Email == email, ct))
|
||||
return null;
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = email,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PasswordHash = string.Empty
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Name = organizationName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Organizations.Add(organization);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
db.OrganizationUsers.Add(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Role = OrganizationRole.Admin
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
|
||||
|
||||
var accessToken = tokenService.GenerateToken(user);
|
||||
var refreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = refreshTokenValue,
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse?> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens
|
||||
.Include(rt => rt.User)
|
||||
.ThenInclude(u => u.Organizations)
|
||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
||||
|
||||
if (token is null || token.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return null;
|
||||
|
||||
token.IsRevoked = true;
|
||||
|
||||
var accessToken = tokenService.GenerateToken(token.User);
|
||||
var newRefreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = newRefreshTokenValue,
|
||||
UserId = token.UserId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, newRefreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
||||
|
||||
if (token is null)
|
||||
return;
|
||||
|
||||
token.IsRevoked = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
using System.Security.Cryptography;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class AuthService(
|
||||
MicCheckDbContext db,
|
||||
ITokenService tokenService,
|
||||
IPasswordHasher<User> passwordHasher)
|
||||
{
|
||||
public async Task<LoginResponse?> LoginAsync(string email, string password, CancellationToken ct = default)
|
||||
{
|
||||
var user = await db.Users
|
||||
.Include(u => u.Organizations)
|
||||
.FirstOrDefaultAsync(u => u.Email == email && u.IsActive, ct);
|
||||
|
||||
if (user is null)
|
||||
return null;
|
||||
|
||||
var result = passwordHasher.VerifyHashedPassword(user, user.PasswordHash, password);
|
||||
if (result == PasswordVerificationResult.Failed)
|
||||
return null;
|
||||
|
||||
var accessToken = tokenService.GenerateToken(user);
|
||||
var refreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = refreshTokenValue,
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
|
||||
user.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse?> RegisterAsync(
|
||||
string email, string password,
|
||||
string firstName, string lastName,
|
||||
string organizationName,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (await db.Users.AnyAsync(u => u.Email == email, ct))
|
||||
return null;
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Email = email,
|
||||
FirstName = firstName,
|
||||
LastName = lastName,
|
||||
IsActive = true,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
PasswordHash = string.Empty
|
||||
};
|
||||
user.PasswordHash = passwordHasher.HashPassword(user, password);
|
||||
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var organization = new Organization
|
||||
{
|
||||
Name = organizationName,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Organizations.Add(organization);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
db.OrganizationUsers.Add(new OrganizationUser
|
||||
{
|
||||
OrganizationId = organization.Id,
|
||||
UserId = user.Id,
|
||||
Role = OrganizationRole.Admin
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
await db.Entry(user).Collection(u => u.Organizations).LoadAsync(ct);
|
||||
|
||||
var accessToken = tokenService.GenerateToken(user);
|
||||
var refreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = refreshTokenValue,
|
||||
UserId = user.Id,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, refreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task<LoginResponse?> RefreshAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens
|
||||
.Include(rt => rt.User)
|
||||
.ThenInclude(u => u.Organizations)
|
||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
||||
|
||||
if (token is null || token.ExpiresAt < DateTimeOffset.UtcNow)
|
||||
return null;
|
||||
|
||||
token.IsRevoked = true;
|
||||
|
||||
var accessToken = tokenService.GenerateToken(token.User);
|
||||
var newRefreshTokenValue = GenerateSecureToken();
|
||||
|
||||
db.RefreshTokens.Add(new RefreshToken
|
||||
{
|
||||
Token = newRefreshTokenValue,
|
||||
UserId = token.UserId,
|
||||
ExpiresAt = DateTimeOffset.UtcNow.AddDays(30),
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return new LoginResponse(accessToken.Token, newRefreshTokenValue, accessToken.ExpiresAt);
|
||||
}
|
||||
|
||||
public async Task LogoutAsync(string refreshToken, CancellationToken ct = default)
|
||||
{
|
||||
var token = await db.RefreshTokens
|
||||
.FirstOrDefaultAsync(rt => rt.Token == refreshToken && !rt.IsRevoked, ct);
|
||||
|
||||
if (token is null)
|
||||
return;
|
||||
|
||||
token.IsRevoked = true;
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private static string GenerateSecureToken()
|
||||
{
|
||||
var bytes = RandomNumberGenerator.GetBytes(64);
|
||||
return Convert.ToBase64String(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
16
src/api/MicCheck.Api/Common/Security/Authorization/AuthorizationPolicies.cs
Normal file → Executable file
16
src/api/MicCheck.Api/Common/Security/Authorization/AuthorizationPolicies.cs
Normal file → Executable file
@@ -1,8 +1,8 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string FlagsApiAccess = "FlagsApiAccess";
|
||||
public const string AdminApiAccess = "AdminApiAccess";
|
||||
public const string OrganizationAdmin = "OrganizationAdmin";
|
||||
}
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string FlagsApiAccess = "FlagsApiAccess";
|
||||
public const string AdminApiAccess = "AdminApiAccess";
|
||||
public const string OrganizationAdmin = "OrganizationAdmin";
|
||||
}
|
||||
|
||||
6
src/api/MicCheck.Api/Common/Security/Authorization/LoginRequest.cs
Normal file → Executable file
6
src/api/MicCheck.Api/Common/Security/Authorization/LoginRequest.cs
Normal file → Executable file
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record LoginRequest(string Email, string Password);
|
||||
|
||||
6
src/api/MicCheck.Api/Common/Security/Authorization/LoginResponse.cs
Normal file → Executable file
6
src/api/MicCheck.Api/Common/Security/Authorization/LoginResponse.cs
Normal file → Executable file
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);
|
||||
|
||||
6
src/api/MicCheck.Api/Common/Security/Authorization/LogoutRequest.cs
Normal file → Executable file
6
src/api/MicCheck.Api/Common/Security/Authorization/LogoutRequest.cs
Normal file → Executable file
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record LogoutRequest(string Token);
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record LogoutRequest(string Token);
|
||||
|
||||
34
src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermission.cs
Normal file → Executable file
34
src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermission.cs
Normal file → Executable file
@@ -1,17 +1,17 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public enum ProjectPermission
|
||||
{
|
||||
ViewProject,
|
||||
CreateFeature,
|
||||
EditFeature,
|
||||
DeleteFeature,
|
||||
CreateEnvironment,
|
||||
EditEnvironment,
|
||||
DeleteEnvironment,
|
||||
CreateSegment,
|
||||
EditSegment,
|
||||
DeleteSegment,
|
||||
ManageWebhooks,
|
||||
ViewAuditLog
|
||||
}
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public enum ProjectPermission
|
||||
{
|
||||
ViewProject,
|
||||
CreateFeature,
|
||||
EditFeature,
|
||||
DeleteFeature,
|
||||
CreateEnvironment,
|
||||
EditEnvironment,
|
||||
DeleteEnvironment,
|
||||
CreateSegment,
|
||||
EditSegment,
|
||||
DeleteSegment,
|
||||
ManageWebhooks,
|
||||
ViewAuditLog
|
||||
}
|
||||
|
||||
20
src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs
Normal file → Executable file
20
src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirement.cs
Normal file → Executable file
@@ -1,10 +1,10 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class ProjectPermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public ProjectPermission Permission { get; }
|
||||
|
||||
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
|
||||
}
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class ProjectPermissionRequirement : IAuthorizationRequirement
|
||||
{
|
||||
public ProjectPermission Permission { get; }
|
||||
|
||||
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
|
||||
}
|
||||
|
||||
108
src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirementHandler.cs
Normal file → Executable file
108
src/api/MicCheck.Api/Common/Security/Authorization/ProjectPermissionRequirementHandler.cs
Normal file → Executable file
@@ -1,54 +1,54 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Organizations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class ProjectPermissionRequirementHandler(
|
||||
MicCheckDbContext db,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
: AuthorizationHandler<ProjectPermissionRequirement>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ProjectPermissionRequirement requirement)
|
||||
{
|
||||
var userIdClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub);
|
||||
if (userIdClaim is null || !int.TryParse(userIdClaim.Value, out var userId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.User.HasClaim("OrganizationRole", OrganizationRole.Admin.ToString()))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is null ||
|
||||
!httpContext.Request.RouteValues.TryGetValue("projectId", out var projectIdValue) ||
|
||||
!int.TryParse(projectIdValue?.ToString(), out var projectId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
var permission = await db.UserProjectPermissions
|
||||
.FirstOrDefaultAsync(p => p.UserId == userId && p.ProjectId == projectId);
|
||||
|
||||
if (permission is null)
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission.IsAdmin || permission.Permissions.Contains(requirement.Permission))
|
||||
context.Succeed(requirement);
|
||||
else
|
||||
context.Fail();
|
||||
}
|
||||
}
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Organizations;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class ProjectPermissionRequirementHandler(
|
||||
MicCheckDbContext db,
|
||||
IHttpContextAccessor httpContextAccessor)
|
||||
: AuthorizationHandler<ProjectPermissionRequirement>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
ProjectPermissionRequirement requirement)
|
||||
{
|
||||
var userIdClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub);
|
||||
if (userIdClaim is null || !int.TryParse(userIdClaim.Value, out var userId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.User.HasClaim("OrganizationRole", OrganizationRole.Admin.ToString()))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
var httpContext = httpContextAccessor.HttpContext;
|
||||
if (httpContext is null ||
|
||||
!httpContext.Request.RouteValues.TryGetValue("projectId", out var projectIdValue) ||
|
||||
!int.TryParse(projectIdValue?.ToString(), out var projectId))
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
var permission = await db.UserProjectPermissions
|
||||
.FirstOrDefaultAsync(p => p.UserId == userId && p.ProjectId == projectId);
|
||||
|
||||
if (permission is null)
|
||||
{
|
||||
context.Fail();
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission.IsAdmin || permission.Permissions.Contains(requirement.Permission))
|
||||
context.Succeed(requirement);
|
||||
else
|
||||
context.Fail();
|
||||
}
|
||||
}
|
||||
|
||||
6
src/api/MicCheck.Api/Common/Security/Authorization/RefreshRequest.cs
Normal file → Executable file
6
src/api/MicCheck.Api/Common/Security/Authorization/RefreshRequest.cs
Normal file → Executable file
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record RefreshRequest(string Token);
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record RefreshRequest(string Token);
|
||||
|
||||
16
src/api/MicCheck.Api/Common/Security/Authorization/RegisterRequest.cs
Normal file → Executable file
16
src/api/MicCheck.Api/Common/Security/Authorization/RegisterRequest.cs
Normal file → Executable file
@@ -1,8 +1,8 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record RegisterRequest(
|
||||
string Email,
|
||||
string Password,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string OrganizationName);
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record RegisterRequest(
|
||||
string Email,
|
||||
string Password,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
string OrganizationName);
|
||||
|
||||
6
src/api/MicCheck.Api/Common/Security/Authorization/TokenResponse.cs
Normal file → Executable file
6
src/api/MicCheck.Api/Common/Security/Authorization/TokenResponse.cs
Normal file → Executable file
@@ -1,3 +1,3 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record TokenResponse(string Token, DateTime ExpiresAt);
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public record TokenResponse(string Token, DateTime ExpiresAt);
|
||||
|
||||
110
src/api/MicCheck.Api/Common/Security/Authorization/TokenService.cs
Normal file → Executable file
110
src/api/MicCheck.Api/Common/Security/Authorization/TokenService.cs
Normal file → Executable file
@@ -1,55 +1,55 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class TokenService(IConfiguration configuration) : ITokenService
|
||||
{
|
||||
public TokenResponse GenerateToken(User user)
|
||||
{
|
||||
var secretKey = configuration["Jwt:SecretKey"]
|
||||
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
||||
var issuer = configuration["Jwt:Issuer"]
|
||||
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
||||
var audience = configuration["Jwt:Audience"]
|
||||
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||
var expiryMinutes = int.Parse(configuration["Jwt:ExpiryMinutes"] ?? "60");
|
||||
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Email, user.Email),
|
||||
new(JwtRegisteredClaimNames.GivenName, user.FirstName),
|
||||
new(JwtRegisteredClaimNames.FamilyName, user.LastName),
|
||||
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
foreach (var org in user.Organizations)
|
||||
{
|
||||
claims.Add(new Claim("OrganizationId", org.OrganizationId.ToString()));
|
||||
claims.Add(new Claim("OrganizationRole", org.Role.ToString()));
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
TokenResponse GenerateToken(User user);
|
||||
}
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using MicCheck.Api.Users;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class TokenService(IConfiguration configuration) : ITokenService
|
||||
{
|
||||
public TokenResponse GenerateToken(User user)
|
||||
{
|
||||
var secretKey = configuration["Jwt:SecretKey"]
|
||||
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
|
||||
var issuer = configuration["Jwt:Issuer"]
|
||||
?? throw new InvalidOperationException("Jwt:Issuer is not configured.");
|
||||
var audience = configuration["Jwt:Audience"]
|
||||
?? throw new InvalidOperationException("Jwt:Audience is not configured.");
|
||||
var expiryMinutes = int.Parse(configuration["Jwt:ExpiryMinutes"] ?? "60");
|
||||
|
||||
var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
|
||||
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
|
||||
var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
|
||||
new(JwtRegisteredClaimNames.Email, user.Email),
|
||||
new(JwtRegisteredClaimNames.GivenName, user.FirstName),
|
||||
new(JwtRegisteredClaimNames.FamilyName, user.LastName),
|
||||
new(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
};
|
||||
|
||||
foreach (var org in user.Organizations)
|
||||
{
|
||||
claims.Add(new Claim("OrganizationId", org.OrganizationId.ToString()));
|
||||
claims.Add(new Claim("OrganizationRole", org.Role.ToString()));
|
||||
}
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
claims: claims,
|
||||
expires: expiresAt,
|
||||
signingCredentials: credentials);
|
||||
|
||||
return new TokenResponse(new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
TokenResponse GenerateToken(User user);
|
||||
}
|
||||
|
||||
18
src/api/MicCheck.Api/Common/Security/Authorization/UserProjectPermission.cs
Normal file → Executable file
18
src/api/MicCheck.Api/Common/Security/Authorization/UserProjectPermission.cs
Normal file → Executable file
@@ -1,9 +1,9 @@
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class UserProjectPermission
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
public int ProjectId { get; init; }
|
||||
public List<ProjectPermission> Permissions { get; set; } = [];
|
||||
public bool IsAdmin { get; set; }
|
||||
}
|
||||
namespace MicCheck.Api.Common.Security.Authorization;
|
||||
|
||||
public class UserProjectPermission
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
public int ProjectId { get; init; }
|
||||
public List<ProjectPermission> Permissions { get; set; } = [];
|
||||
public bool IsAdmin { get; set; }
|
||||
}
|
||||
|
||||
38
src/api/MicCheck.Api/Data/Configurations/ApiKeyConfiguration.cs
Normal file → Executable file
38
src/api/MicCheck.Api/Data/Configurations/ApiKeyConfiguration.cs
Normal file → Executable file
@@ -1,19 +1,19 @@
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MicCheck.Api.Data.Configurations;
|
||||
|
||||
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ApiKey> builder)
|
||||
{
|
||||
builder.HasKey(k => k.Id);
|
||||
builder.Property(k => k.Key).HasMaxLength(512).IsRequired();
|
||||
builder.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
|
||||
builder.Property(k => k.Name).HasMaxLength(200).IsRequired();
|
||||
builder.Property(k => k.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(k => k.Key).IsUnique();
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MicCheck.Api.Data.Configurations;
|
||||
|
||||
public class ApiKeyConfiguration : IEntityTypeConfiguration<ApiKey>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ApiKey> builder)
|
||||
{
|
||||
builder.HasKey(k => k.Id);
|
||||
builder.Property(k => k.Key).HasMaxLength(512).IsRequired();
|
||||
builder.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
|
||||
builder.Property(k => k.Name).HasMaxLength(200).IsRequired();
|
||||
builder.Property(k => k.CreatedAt).IsRequired();
|
||||
|
||||
builder.HasIndex(k => k.Key).IsUnique();
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Data/Configurations/AuditLogConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/AuditLogConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/EnvironmentConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/EnvironmentConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/FeatureConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/FeatureSegmentConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/FeatureSegmentConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/FeatureStateConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/FeatureStateConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/IdentityConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/IdentityConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/IdentityTraitConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/IdentityTraitConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/OrganizationConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/OrganizationConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/OrganizationUserConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/OrganizationUserConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/ProjectConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/ProjectConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/RefreshTokenConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/RefreshTokenConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/SegmentConditionConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/SegmentConditionConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/SegmentConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/SegmentConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/SegmentRuleConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/SegmentRuleConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/TagConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/UserConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/UserConfiguration.cs
Normal file → Executable file
44
src/api/MicCheck.Api/Data/Configurations/UserProjectPermissionConfiguration.cs
Normal file → Executable file
44
src/api/MicCheck.Api/Data/Configurations/UserProjectPermissionConfiguration.cs
Normal file → Executable file
@@ -1,22 +1,22 @@
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MicCheck.Api.Data.Configurations;
|
||||
|
||||
public class UserProjectPermissionConfiguration : IEntityTypeConfiguration<UserProjectPermission>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserProjectPermission> builder)
|
||||
{
|
||||
builder.HasKey(p => new { p.UserId, p.ProjectId });
|
||||
|
||||
builder.Property(p => p.Permissions)
|
||||
.HasConversion(
|
||||
v => string.Join(',', v.Select(p => p.ToString())),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<ProjectPermission>()
|
||||
: v.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Enum.Parse<ProjectPermission>)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace MicCheck.Api.Data.Configurations;
|
||||
|
||||
public class UserProjectPermissionConfiguration : IEntityTypeConfiguration<UserProjectPermission>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<UserProjectPermission> builder)
|
||||
{
|
||||
builder.HasKey(p => new { p.UserId, p.ProjectId });
|
||||
|
||||
builder.Property(p => p.Permissions)
|
||||
.HasConversion(
|
||||
v => string.Join(',', v.Select(p => p.ToString())),
|
||||
v => string.IsNullOrEmpty(v)
|
||||
? new List<ProjectPermission>()
|
||||
: v.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(Enum.Parse<ProjectPermission>)
|
||||
.ToList());
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Data/Configurations/WebhookConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/WebhookConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/WebhookDeliveryLogConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/Configurations/WebhookDeliveryLogConfiguration.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/DatabaseSeeder.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/DatabaseSeeder.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/DatabaseStartupExtensions.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/DatabaseStartupExtensions.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/DatabaseUrlParser.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Data/DatabaseUrlParser.cs
Normal file → Executable file
86
src/api/MicCheck.Api/Data/MicCheckDbContext.cs
Normal file → Executable file
86
src/api/MicCheck.Api/Data/MicCheckDbContext.cs
Normal file → Executable file
@@ -1,43 +1,43 @@
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Segments;
|
||||
using MicCheck.Api.Users;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Data;
|
||||
|
||||
public class MicCheckDbContext : DbContext
|
||||
{
|
||||
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<OrganizationUser> OrganizationUsers => Set<OrganizationUser>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<AppEnvironment> Environments => Set<AppEnvironment>();
|
||||
public DbSet<Feature> Features => Set<Feature>();
|
||||
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
|
||||
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Segment> Segments => Set<Segment>();
|
||||
public DbSet<SegmentRule> SegmentRules => Set<SegmentRule>();
|
||||
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
|
||||
public DbSet<Identity> Identities => Set<Identity>();
|
||||
public DbSet<IdentityTrait> IdentityTraits => Set<IdentityTrait>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<Webhook> Webhooks => Set<Webhook>();
|
||||
public DbSet<WebhookDeliveryLog> WebhookDeliveryLogs => Set<WebhookDeliveryLog>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||
}
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Features;
|
||||
using MicCheck.Api.Identities;
|
||||
using MicCheck.Api.Organizations;
|
||||
using MicCheck.Api.Projects;
|
||||
using MicCheck.Api.Segments;
|
||||
using MicCheck.Api.Users;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Data;
|
||||
|
||||
public class MicCheckDbContext : DbContext
|
||||
{
|
||||
public MicCheckDbContext(DbContextOptions<MicCheckDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<OrganizationUser> OrganizationUsers => Set<OrganizationUser>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<AppEnvironment> Environments => Set<AppEnvironment>();
|
||||
public DbSet<Feature> Features => Set<Feature>();
|
||||
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
|
||||
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Segment> Segments => Set<Segment>();
|
||||
public DbSet<SegmentRule> SegmentRules => Set<SegmentRule>();
|
||||
public DbSet<SegmentCondition> SegmentConditions => Set<SegmentCondition>();
|
||||
public DbSet<Identity> Identities => Set<Identity>();
|
||||
public DbSet<IdentityTrait> IdentityTraits => Set<IdentityTrait>();
|
||||
public DbSet<AuditLog> AuditLogs => Set<AuditLog>();
|
||||
public DbSet<Webhook> Webhooks => Set<Webhook>();
|
||||
public DbSet<WebhookDeliveryLog> WebhookDeliveryLogs => Set<WebhookDeliveryLog>();
|
||||
public DbSet<ApiKey> ApiKeys => Set<ApiKey>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<UserProjectPermission> UserProjectPermissions => Set<UserProjectPermission>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Dockerfile
Normal file → Executable file
0
src/api/MicCheck.Api/Dockerfile
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/CloneEnvironmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/CreateEnvironmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/Environment.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/Environment.cs
Normal file → Executable file
40
src/api/MicCheck.Api/Environments/EnvironmentDocumentController.cs
Normal file → Executable file
40
src/api/MicCheck.Api/Environments/EnvironmentDocumentController.cs
Normal file → Executable file
@@ -1,20 +1,20 @@
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/environment-document")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class EnvironmentDocumentController(EnvironmentDocumentService environmentDocumentService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<EnvironmentDocumentResponse>> Get(CancellationToken ct)
|
||||
{
|
||||
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value);
|
||||
var document = await environmentDocumentService.GetAsync(environmentId, ct);
|
||||
|
||||
return document is null ? NotFound() : Ok(document);
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/environment-document")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class EnvironmentDocumentController(EnvironmentDocumentService environmentDocumentService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<EnvironmentDocumentResponse>> Get(CancellationToken ct)
|
||||
{
|
||||
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value);
|
||||
var document = await environmentDocumentService.GetAsync(environmentId, ct);
|
||||
|
||||
return document is null ? NotFound() : Ok(document);
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Environments/EnvironmentDocumentResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/EnvironmentDocumentResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/EnvironmentDocumentService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/EnvironmentResponse.cs
Normal file → Executable file
270
src/api/MicCheck.Api/Environments/EnvironmentService.cs
Normal file → Executable file
270
src/api/MicCheck.Api/Environments/EnvironmentService.cs
Normal file → Executable file
@@ -1,135 +1,135 @@
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public class EnvironmentService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Environments
|
||||
.Where(e => e.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment?> FindByApiKeyAsync(string apiKey, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> CreateAsync(int projectId, string name, CancellationToken ct = default)
|
||||
{
|
||||
var apiKey = ApiKeyHasher.GenerateKey();
|
||||
|
||||
var environment = new AppEnvironment
|
||||
{
|
||||
Name = name,
|
||||
ApiKey = apiKey,
|
||||
ProjectId = projectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Environments.Add(environment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var features = await db.Features
|
||||
.Where(f => f.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var feature in features)
|
||||
{
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = environment.Id,
|
||||
Enabled = feature.DefaultEnabled,
|
||||
Value = feature.InitialValue,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (features.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", environment.Id.ToString(), "created",
|
||||
project.OrganizationId, projectId, environment.Id, ct: ct);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> UpdateAsync(string apiKey, string name, CancellationToken ct = default)
|
||||
{
|
||||
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct)
|
||||
?? throw new KeyNotFoundException($"Environment with key '{apiKey}' not found.");
|
||||
|
||||
environment.Name = name;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", environment.Id.ToString(), "updated",
|
||||
project.OrganizationId, environment.ProjectId, environment.Id, ct: ct);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string apiKey, CancellationToken ct = default)
|
||||
{
|
||||
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
|
||||
if (environment is null) return;
|
||||
|
||||
db.Environments.Remove(environment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default)
|
||||
{
|
||||
var source = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == sourceApiKey, ct)
|
||||
?? throw new KeyNotFoundException($"Source environment with key '{sourceApiKey}' not found.");
|
||||
|
||||
var newApiKey = ApiKeyHasher.GenerateKey();
|
||||
|
||||
var cloned = new AppEnvironment
|
||||
{
|
||||
Name = newName,
|
||||
ApiKey = newApiKey,
|
||||
ProjectId = source.ProjectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Environments.Add(cloned);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var sourceStates = await db.FeatureStates
|
||||
.Where(fs => fs.EnvironmentId == source.Id && fs.IdentityId == null && fs.FeatureSegmentId == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var state in sourceStates)
|
||||
{
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = state.FeatureId,
|
||||
EnvironmentId = cloned.Id,
|
||||
Enabled = state.Enabled,
|
||||
Value = state.Value,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceStates.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == source.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", cloned.Id.ToString(), "cloned",
|
||||
project.OrganizationId, source.ProjectId, cloned.Id, ct: ct);
|
||||
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.ApiKeys;
|
||||
using MicCheck.Api.Audit;
|
||||
using MicCheck.Api.Data;
|
||||
using MicCheck.Api.Features;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using AppEnvironment = MicCheck.Api.Environments.Environment;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
public class EnvironmentService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
public async Task<IReadOnlyList<AppEnvironment>> ListByProjectAsync(int projectId, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Environments
|
||||
.Where(e => e.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment?> FindByApiKeyAsync(string apiKey, CancellationToken ct = default)
|
||||
{
|
||||
return await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> CreateAsync(int projectId, string name, CancellationToken ct = default)
|
||||
{
|
||||
var apiKey = ApiKeyHasher.GenerateKey();
|
||||
|
||||
var environment = new AppEnvironment
|
||||
{
|
||||
Name = name,
|
||||
ApiKey = apiKey,
|
||||
ProjectId = projectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Environments.Add(environment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var features = await db.Features
|
||||
.Where(f => f.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var feature in features)
|
||||
{
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = feature.Id,
|
||||
EnvironmentId = environment.Id,
|
||||
Enabled = feature.DefaultEnabled,
|
||||
Value = feature.InitialValue,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (features.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == projectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", environment.Id.ToString(), "created",
|
||||
project.OrganizationId, projectId, environment.Id, ct: ct);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> UpdateAsync(string apiKey, string name, CancellationToken ct = default)
|
||||
{
|
||||
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct)
|
||||
?? throw new KeyNotFoundException($"Environment with key '{apiKey}' not found.");
|
||||
|
||||
environment.Name = name;
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", environment.Id.ToString(), "updated",
|
||||
project.OrganizationId, environment.ProjectId, environment.Id, ct: ct);
|
||||
|
||||
return environment;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string apiKey, CancellationToken ct = default)
|
||||
{
|
||||
var environment = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == apiKey, ct);
|
||||
if (environment is null) return;
|
||||
|
||||
db.Environments.Remove(environment);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<AppEnvironment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default)
|
||||
{
|
||||
var source = await db.Environments.FirstOrDefaultAsync(e => e.ApiKey == sourceApiKey, ct)
|
||||
?? throw new KeyNotFoundException($"Source environment with key '{sourceApiKey}' not found.");
|
||||
|
||||
var newApiKey = ApiKeyHasher.GenerateKey();
|
||||
|
||||
var cloned = new AppEnvironment
|
||||
{
|
||||
Name = newName,
|
||||
ApiKey = newApiKey,
|
||||
ProjectId = source.ProjectId,
|
||||
CreatedAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
db.Environments.Add(cloned);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var sourceStates = await db.FeatureStates
|
||||
.Where(fs => fs.EnvironmentId == source.Id && fs.IdentityId == null && fs.FeatureSegmentId == null)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var state in sourceStates)
|
||||
{
|
||||
db.FeatureStates.Add(new FeatureState
|
||||
{
|
||||
FeatureId = state.FeatureId,
|
||||
EnvironmentId = cloned.Id,
|
||||
Enabled = state.Enabled,
|
||||
Value = state.Value,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
if (sourceStates.Count > 0)
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == source.ProjectId, ct);
|
||||
if (project is not null)
|
||||
await auditService.LogAsync("Environment", cloned.Id.ToString(), "cloned",
|
||||
project.OrganizationId, source.ProjectId, cloned.Id, ct: ct);
|
||||
|
||||
return cloned;
|
||||
}
|
||||
}
|
||||
|
||||
886
src/api/MicCheck.Api/Environments/EnvironmentsController.cs
Normal file → Executable file
886
src/api/MicCheck.Api/Environments/EnvironmentsController.cs
Normal file → Executable file
@@ -1,443 +1,443 @@
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/environments")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<EnvironmentResponse>>> List(
|
||||
[FromQuery] int projectId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var all = await environmentService.ListByProjectAsync(projectId, ct);
|
||||
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(EnvironmentResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<EnvironmentResponse>(all.Count, null, null, paged));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Create(CreateEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.CreateAsync(request.ProjectId, request.Name, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = environment.ApiKey }, EnvironmentResponse.From(environment));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> GetByApiKey(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
return Ok(EnvironmentResponse.From(environment));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Update(string apiKey, UpdateEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var updated = await environmentService.UpdateAsync(apiKey, request.Name, ct);
|
||||
return Ok(EnvironmentResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}")]
|
||||
public async Task<IActionResult> Delete(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
await environmentService.DeleteAsync(apiKey, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/clone")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Clone(string apiKey, CloneEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var source = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (source is null) return NotFound();
|
||||
|
||||
var cloned = await environmentService.CloneAsync(apiKey, request.Name, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = cloned.ApiKey }, EnvironmentResponse.From(cloned));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/webhooks")]
|
||||
public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhooks = await webhookService.ListByEnvironmentAsync(environment.Id, ct);
|
||||
return Ok(webhooks.Select(WebhookResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/webhooks")]
|
||||
public async Task<ActionResult<WebhookResponse>> CreateWebhook(
|
||||
string apiKey, CreateWebhookRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.CreateAsync(environment.Id, request.Url, request.Secret, request.Enabled, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey }, WebhookResponse.From(webhook));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/webhooks/{id}")]
|
||||
public async Task<ActionResult<WebhookResponse>> UpdateWebhook(
|
||||
string apiKey, int id, CreateWebhookRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
var updated = await webhookService.UpdateAsync(id, request.Url, request.Secret, request.Enabled, ct);
|
||||
return Ok(WebhookResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/webhooks/{id}/deliveries")]
|
||||
public async Task<ActionResult<IReadOnlyList<WebhookDeliveryLogResponse>>> ListWebhookDeliveries(
|
||||
string apiKey, int id, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
var logs = await webhookService.ListDeliveriesAsync(id, ct);
|
||||
return Ok(logs.Select(WebhookDeliveryLogResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/webhooks/{id}")]
|
||||
public async Task<IActionResult> DeleteWebhook(string apiKey, int id, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
await webhookService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<IReadOnlyList<Audit.AuditLogResponse>>> ListAuditLogs(
|
||||
string apiKey,
|
||||
[FromServices] Audit.AuditLogQueryService auditLogQueryService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
|
||||
return Ok(logs.ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities")]
|
||||
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities(
|
||||
string apiKey,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
|
||||
var results = items.Select(Identities.AdminIdentityResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/identities")]
|
||||
public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity(
|
||||
string apiKey,
|
||||
[FromBody] Identities.CreateIdentityRequest request,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.CreateAsync(environment.Id, request.Identifier, ct);
|
||||
if (identity is null)
|
||||
return Conflict(new { error = "An identity with this identifier already exists in the environment." });
|
||||
|
||||
return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id },
|
||||
Identities.AdminIdentityResponse.From(identity));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}")]
|
||||
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
return Ok(Identities.AdminIdentityResponse.From(identity));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}")]
|
||||
public async Task<IActionResult> DeleteIdentity(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
await adminIdentityService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/identities/{id}/traits/{key}")]
|
||||
public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait(
|
||||
string apiKey, int id, string key,
|
||||
[FromBody] Identities.UpsertTraitRequest request,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var result = await adminIdentityService.UpsertTraitAsync(id, environment.Id, key, request.Value, ct);
|
||||
if (result is null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}/traits/{key}")]
|
||||
public async Task<IActionResult> DeleteIdentityTrait(
|
||||
string apiKey, int id, string key,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var deleted = await adminIdentityService.DeleteTraitAsync(id, environment.Id, key, ct);
|
||||
if (!deleted) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}/featurestates")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
|
||||
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/identities/{id}/featurestates/{featureId}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState(
|
||||
string apiKey, int id, int featureId,
|
||||
Features.UpdateFeatureStateRequest request,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(state));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}/featurestates/{featureId}")]
|
||||
public async Task<IActionResult> DeleteIdentityFeatureState(
|
||||
string apiKey, int id, int featureId,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
await adminIdentityService.DeleteFeatureStateAsync(id, environment.Id, featureId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/featurestates")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates(
|
||||
string apiKey,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
|
||||
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState(
|
||||
string apiKey, int id,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
return Ok(Features.FeatureStateResponse.From(state));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState(
|
||||
string apiKey, int id,
|
||||
Features.UpdateFeatureStateRequest request,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
|
||||
var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpPatch("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState(
|
||||
string apiKey, int id,
|
||||
Features.PatchFeatureStateRequest request,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
|
||||
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(updated));
|
||||
}
|
||||
|
||||
// ─── Feature Segments ────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{apiKey}/features/{featureId}/segments")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments(
|
||||
string apiKey, int featureId,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var results = await featureSegmentService.ListByFeatureAsync(featureId, environment.Id, ct);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/features/{featureId}/segments")]
|
||||
public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment(
|
||||
string apiKey, int featureId,
|
||||
Features.CreateFeatureSegmentRequest request,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await featureSegmentService.CreateAsync(
|
||||
featureId, environment.Id, request.SegmentId, request.Priority,
|
||||
request.Enabled, request.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/features/{featureId}/segments/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment(
|
||||
string apiKey, int featureId, int id,
|
||||
Features.UpdateFeatureSegmentRequest request,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var result = await featureSegmentService.UpdateAsync(id, request.Priority, request.Enabled, request.Value, ct);
|
||||
if (result is null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/features/{featureId}/segments/{id}")]
|
||||
public async Task<IActionResult> DeleteFeatureSegment(
|
||||
string apiKey, int featureId, int id,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var deleted = await featureSegmentService.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
// ─── Identity Segments ───────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}/segments")]
|
||||
public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
[FromServices] Segments.SegmentService segmentService,
|
||||
[FromServices] Segments.SegmentEvaluator segmentEvaluator,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct);
|
||||
var matching = segments
|
||||
.Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier))
|
||||
.Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name))
|
||||
.ToList();
|
||||
|
||||
return Ok(matching);
|
||||
}
|
||||
}
|
||||
using MicCheck.Api.Common.Security.Authorization;
|
||||
using MicCheck.Api.Common;
|
||||
using MicCheck.Api.Webhooks;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
|
||||
namespace MicCheck.Api.Environments;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/environments")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<EnvironmentResponse>>> List(
|
||||
[FromQuery] int projectId,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var all = await environmentService.ListByProjectAsync(projectId, ct);
|
||||
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(EnvironmentResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<EnvironmentResponse>(all.Count, null, null, paged));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Create(CreateEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.CreateAsync(request.ProjectId, request.Name, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = environment.ApiKey }, EnvironmentResponse.From(environment));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> GetByApiKey(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
return Ok(EnvironmentResponse.From(environment));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Update(string apiKey, UpdateEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var updated = await environmentService.UpdateAsync(apiKey, request.Name, ct);
|
||||
return Ok(EnvironmentResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}")]
|
||||
public async Task<IActionResult> Delete(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
await environmentService.DeleteAsync(apiKey, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/clone")]
|
||||
public async Task<ActionResult<EnvironmentResponse>> Clone(string apiKey, CloneEnvironmentRequest request, CancellationToken ct)
|
||||
{
|
||||
var source = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (source is null) return NotFound();
|
||||
|
||||
var cloned = await environmentService.CloneAsync(apiKey, request.Name, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey = cloned.ApiKey }, EnvironmentResponse.From(cloned));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/webhooks")]
|
||||
public async Task<ActionResult<IReadOnlyList<WebhookResponse>>> ListWebhooks(string apiKey, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhooks = await webhookService.ListByEnvironmentAsync(environment.Id, ct);
|
||||
return Ok(webhooks.Select(WebhookResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/webhooks")]
|
||||
public async Task<ActionResult<WebhookResponse>> CreateWebhook(
|
||||
string apiKey, CreateWebhookRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.CreateAsync(environment.Id, request.Url, request.Secret, request.Enabled, ct);
|
||||
return CreatedAtAction(nameof(GetByApiKey), new { apiKey }, WebhookResponse.From(webhook));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/webhooks/{id}")]
|
||||
public async Task<ActionResult<WebhookResponse>> UpdateWebhook(
|
||||
string apiKey, int id, CreateWebhookRequest request, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
var updated = await webhookService.UpdateAsync(id, request.Url, request.Secret, request.Enabled, ct);
|
||||
return Ok(WebhookResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/webhooks/{id}/deliveries")]
|
||||
public async Task<ActionResult<IReadOnlyList<WebhookDeliveryLogResponse>>> ListWebhookDeliveries(
|
||||
string apiKey, int id, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
var logs = await webhookService.ListDeliveriesAsync(id, ct);
|
||||
return Ok(logs.Select(WebhookDeliveryLogResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/webhooks/{id}")]
|
||||
public async Task<IActionResult> DeleteWebhook(string apiKey, int id, CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var webhook = await webhookService.FindByIdAsync(id, ct);
|
||||
if (webhook is null || webhook.EnvironmentId != environment.Id) return NotFound();
|
||||
|
||||
await webhookService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/audit-logs")]
|
||||
public async Task<ActionResult<IReadOnlyList<Audit.AuditLogResponse>>> ListAuditLogs(
|
||||
string apiKey,
|
||||
[FromServices] Audit.AuditLogQueryService auditLogQueryService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (_, logs) = await auditLogQueryService.ListByEnvironmentAsync(environment.Id, new Audit.AuditLogFilter(), ct);
|
||||
return Ok(logs.ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities")]
|
||||
public async Task<ActionResult<PaginatedResponse<Identities.AdminIdentityResponse>>> ListIdentities(
|
||||
string apiKey,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
[FromQuery] int page = 1,
|
||||
[FromQuery] int pageSize = 20,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
pageSize = Math.Clamp(pageSize, 1, 100);
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var (total, items) = await adminIdentityService.ListAsync(environment.Id, page, pageSize, ct);
|
||||
var results = items.Select(Identities.AdminIdentityResponse.From).ToList();
|
||||
return Ok(new PaginatedResponse<Identities.AdminIdentityResponse>(total, null, null, results));
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/identities")]
|
||||
public async Task<ActionResult<Identities.AdminIdentityResponse>> CreateIdentity(
|
||||
string apiKey,
|
||||
[FromBody] Identities.CreateIdentityRequest request,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.CreateAsync(environment.Id, request.Identifier, ct);
|
||||
if (identity is null)
|
||||
return Conflict(new { error = "An identity with this identifier already exists in the environment." });
|
||||
|
||||
return CreatedAtAction(nameof(GetIdentity), new { apiKey, id = identity.Id },
|
||||
Identities.AdminIdentityResponse.From(identity));
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}")]
|
||||
public async Task<ActionResult<Identities.AdminIdentityResponse>> GetIdentity(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
return Ok(Identities.AdminIdentityResponse.From(identity));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}")]
|
||||
public async Task<IActionResult> DeleteIdentity(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
await adminIdentityService.DeleteAsync(id, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/identities/{id}/traits/{key}")]
|
||||
public async Task<ActionResult<Identities.TraitResponse>> UpsertIdentityTrait(
|
||||
string apiKey, int id, string key,
|
||||
[FromBody] Identities.UpsertTraitRequest request,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var result = await adminIdentityService.UpsertTraitAsync(id, environment.Id, key, request.Value, ct);
|
||||
if (result is null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}/traits/{key}")]
|
||||
public async Task<IActionResult> DeleteIdentityTrait(
|
||||
string apiKey, int id, string key,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var deleted = await adminIdentityService.DeleteTraitAsync(id, environment.Id, key, ct);
|
||||
if (!deleted) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}/featurestates")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> GetIdentityFeatureStates(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var states = await adminIdentityService.GetFeatureStatesAsync(id, environment.Id, ct);
|
||||
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/identities/{id}/featurestates/{featureId}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> SetIdentityFeatureState(
|
||||
string apiKey, int id, int featureId,
|
||||
Features.UpdateFeatureStateRequest request,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var state = await adminIdentityService.SetFeatureStateAsync(id, environment.Id, featureId, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(state));
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/identities/{id}/featurestates/{featureId}")]
|
||||
public async Task<IActionResult> DeleteIdentityFeatureState(
|
||||
string apiKey, int id, int featureId,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
await adminIdentityService.DeleteFeatureStateAsync(id, environment.Id, featureId, ct);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/featurestates")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureStateResponse>>> ListFeatureStates(
|
||||
string apiKey,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var states = await featureStateService.ListByEnvironmentAsync(environment.Id, ct);
|
||||
return Ok(states.Select(Features.FeatureStateResponse.From).ToList());
|
||||
}
|
||||
|
||||
[HttpGet("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> GetFeatureState(
|
||||
string apiKey, int id,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
return Ok(Features.FeatureStateResponse.From(state));
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> UpdateFeatureState(
|
||||
string apiKey, int id,
|
||||
Features.UpdateFeatureStateRequest request,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
|
||||
var updated = await featureStateService.UpdateAsync(id, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(updated));
|
||||
}
|
||||
|
||||
[HttpPatch("{apiKey}/featurestates/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureStateResponse>> PatchFeatureState(
|
||||
string apiKey, int id,
|
||||
Features.PatchFeatureStateRequest request,
|
||||
[FromServices] Features.FeatureStateService featureStateService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var state = await featureStateService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (state is null) return NotFound();
|
||||
|
||||
var updated = await featureStateService.PatchAsync(id, request.Enabled, request.Value, ct);
|
||||
return Ok(Features.FeatureStateResponse.From(updated));
|
||||
}
|
||||
|
||||
// ─── Feature Segments ────────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{apiKey}/features/{featureId}/segments")]
|
||||
public async Task<ActionResult<IReadOnlyList<Features.FeatureSegmentResponse>>> ListFeatureSegments(
|
||||
string apiKey, int featureId,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var results = await featureSegmentService.ListByFeatureAsync(featureId, environment.Id, ct);
|
||||
return Ok(results);
|
||||
}
|
||||
|
||||
[HttpPost("{apiKey}/features/{featureId}/segments")]
|
||||
public async Task<ActionResult<Features.FeatureSegmentResponse>> CreateFeatureSegment(
|
||||
string apiKey, int featureId,
|
||||
Features.CreateFeatureSegmentRequest request,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var result = await featureSegmentService.CreateAsync(
|
||||
featureId, environment.Id, request.SegmentId, request.Priority,
|
||||
request.Enabled, request.Value, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{apiKey}/features/{featureId}/segments/{id}")]
|
||||
public async Task<ActionResult<Features.FeatureSegmentResponse>> UpdateFeatureSegment(
|
||||
string apiKey, int featureId, int id,
|
||||
Features.UpdateFeatureSegmentRequest request,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var result = await featureSegmentService.UpdateAsync(id, request.Priority, request.Enabled, request.Value, ct);
|
||||
if (result is null) return NotFound();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpDelete("{apiKey}/features/{featureId}/segments/{id}")]
|
||||
public async Task<IActionResult> DeleteFeatureSegment(
|
||||
string apiKey, int featureId, int id,
|
||||
[FromServices] Features.FeatureSegmentService featureSegmentService,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var deleted = await featureSegmentService.DeleteAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
// ─── Identity Segments ───────────────────────────────────────────────────
|
||||
|
||||
[HttpGet("{apiKey}/identities/{id}/segments")]
|
||||
public async Task<ActionResult<IReadOnlyList<Segments.SegmentSummaryResponse>>> GetIdentitySegments(
|
||||
string apiKey, int id,
|
||||
[FromServices] Identities.AdminIdentityService adminIdentityService,
|
||||
[FromServices] Segments.SegmentService segmentService,
|
||||
[FromServices] Segments.SegmentEvaluator segmentEvaluator,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var environment = await environmentService.FindByApiKeyAsync(apiKey, ct);
|
||||
if (environment is null) return NotFound();
|
||||
|
||||
var identity = await adminIdentityService.FindByIdAsync(id, environment.Id, ct);
|
||||
if (identity is null) return NotFound();
|
||||
|
||||
var segments = await segmentService.ListByProjectAsync(environment.ProjectId, ct);
|
||||
var matching = segments
|
||||
.Where(s => segmentEvaluator.Evaluate(s, identity.Traits.ToList(), identity.Identifier))
|
||||
.Select(s => new Segments.SegmentSummaryResponse(s.Id, s.Name))
|
||||
.ToList();
|
||||
|
||||
return Ok(matching);
|
||||
}
|
||||
}
|
||||
|
||||
0
src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Environments/UpdateEnvironmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/CreateTagRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/Feature.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/Feature.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureEvaluationService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegment.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegment.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentRequest.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentResponse.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureSegmentService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file → Executable file
0
src/api/MicCheck.Api/Features/FeatureService.cs
Normal file → Executable file
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user