version-0.1 (#1)

Squash and merge of version-0.1

Co-authored-by: James Wampler <james@wamp.dev>
Co-committed-by: James Wampler <james@wamp.dev>
This commit was merged in pull request #1.
This commit is contained in:
2026-07-01 13:30:07 -07:00
committed by wamplerj
parent 8ff071c69b
commit 6887d09f9c
989 changed files with 76379 additions and 18042 deletions

0
src/api/MicCheck.Api/Audit/AuditLog.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Audit/AuditLogFilter.cs Normal file → Executable file
View File

19
src/api/MicCheck.Api/Audit/AuditLogQueryService.cs Normal file → Executable file
View File

@@ -5,28 +5,28 @@ namespace MicCheck.Api.Audit;
public class AuditLogQueryService(MicCheckDbContext db)
{
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByOrganizationAsync(
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByOrganizationAsync(
int organizationId, AuditLogFilter filter, CancellationToken ct = default)
{
var query = db.AuditLogs.Where(l => l.OrganizationId == organizationId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByProjectAsync(
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByProjectAsync(
int projectId, AuditLogFilter filter, CancellationToken ct = default)
{
var query = db.AuditLogs.Where(l => l.ProjectId == projectId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
public async Task<(int Total, IReadOnlyList<AuditLog> Items)> ListByEnvironmentAsync(
public async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ListByEnvironmentAsync(
int environmentId, AuditLogFilter filter, CancellationToken ct = default)
{
var query = db.AuditLogs.Where(l => l.EnvironmentId == environmentId);
return await ApplyFilterAndPageAsync(query, filter, ct);
}
private static async Task<(int Total, IReadOnlyList<AuditLog> Items)> ApplyFilterAndPageAsync(
private async Task<(int Total, IReadOnlyList<AuditLogResponse> Items)> ApplyFilterAndPageAsync(
IQueryable<AuditLog> query, AuditLogFilter filter, CancellationToken ct)
{
if (filter.From.HasValue)
@@ -46,10 +46,21 @@ public class AuditLogQueryService(MicCheckDbContext db)
var total = await query.CountAsync(ct);
var pageSize = Math.Clamp(filter.PageSize, 1, 100);
var items = await query
.OrderByDescending(l => l.CreatedAt)
.Skip((filter.Page - 1) * pageSize)
.Take(pageSize)
.GroupJoin(
db.Users,
log => log.ActorUserId,
user => (int?)user.Id,
(log, users) => new { log, users })
.SelectMany(
x => x.users.DefaultIfEmpty(),
(x, user) => AuditLogResponse.From(
x.log,
user != null ? user.FirstName + " " + user.LastName : null))
.ToListAsync(ct);
return (total, items);

4
src/api/MicCheck.Api/Audit/AuditLogResponse.cs Normal file → Executable file
View File

@@ -10,10 +10,11 @@ public record AuditLogResponse(
int? ProjectId,
int? EnvironmentId,
int? ActorUserId,
string? ActorUserName,
DateTimeOffset CreatedAt
)
{
public static AuditLogResponse From(AuditLog log) => new(
public static AuditLogResponse From(AuditLog log, string? actorUserName = null) => new(
log.Id,
log.ResourceType,
log.ResourceId,
@@ -23,5 +24,6 @@ public record AuditLogResponse(
log.ProjectId,
log.EnvironmentId,
log.ActorUserId,
actorUserName,
log.CreatedAt);
}

118
src/api/MicCheck.Api/Audit/AuditLogsController.cs Normal file → Executable file
View File

@@ -1,59 +1,59 @@
using MicCheck.Api.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.Select(AuditLogResponse.From).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.Select(AuditLogResponse.From).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.Select(AuditLogResponse.From).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/organisation/{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/project/{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/environment/{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
View File

View File

@@ -1,8 +0,0 @@
using MicCheck.Api.Users;
namespace MicCheck.Api.Authorization;
public interface ITokenService
{
TokenResponse GenerateToken(User user);
}

View File

@@ -1,3 +0,0 @@
namespace MicCheck.Api.Authorization;
public record LogoutRequest(string Token);

View File

@@ -1,3 +0,0 @@
namespace MicCheck.Api.Authorization;
public record RefreshRequest(string Token);

0
src/api/MicCheck.Api/Common/DomainException.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Common/PaginatedResponse.cs Normal file → Executable file
View File

View File

@@ -1,13 +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; }
}
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; }
}

View File

@@ -1,46 +1,37 @@
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");
}
}
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/organisation/{organizationId:int}")
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
.WithTags("ApiKeys");
group.MapPost("/api-keys", 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("/api-keys", 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("/api-key/{keyId:int}", async (int organizationId, int keyId, ApiKeyService apiKeyService, CancellationToken ct) =>
{
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
return Results.NoContent();
}).WithName("RevokeApiKey");
}
}

View File

@@ -1,22 +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('/', '_');
}
}
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('/', '_');
}
}

View File

@@ -1,9 +1,9 @@
namespace MicCheck.Api.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);

View File

@@ -1,50 +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);
}
}
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);
}
}

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.ApiKeys;
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);
namespace MicCheck.Api.Common.Security.ApiKeys;
public record CreateApiKeyRequest(string Name, DateTimeOffset? ExpiresAt);

View File

@@ -1,8 +1,8 @@
namespace MicCheck.Api.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);

View File

@@ -1,60 +1,56 @@
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);
}
}
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);
}
}

View File

@@ -1,45 +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.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);
}
}

View File

@@ -1,66 +1,58 @@
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");
}
}
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");
}
}

View 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.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);
}
}

View File

@@ -1,8 +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";
}
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";
}

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Authorization;
public record LoginRequest(string Email, string Password);
namespace MicCheck.Api.Common.Security.Authorization;
public record LoginRequest(string Email, string Password);

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.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);

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Common.Security.Authorization;
public record LogoutRequest(string Token);

View File

@@ -1,17 +1,17 @@
namespace MicCheck.Api.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
}

View File

@@ -1,10 +1,10 @@
using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.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;
}

View 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.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();
}
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Common.Security.Authorization;
public record RefreshRequest(string Token);

View File

@@ -1,8 +1,8 @@
namespace MicCheck.Api.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);

View File

@@ -1,3 +1,3 @@
namespace MicCheck.Api.Authorization;
public record TokenResponse(string Token, DateTime ExpiresAt);
namespace MicCheck.Api.Common.Security.Authorization;
public record TokenResponse(string Token, DateTime ExpiresAt);

View File

@@ -1,52 +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.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);
}
}
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);
}

View File

@@ -1,9 +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; }
}
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; }
}

View File

@@ -1,19 +1,19 @@
using MicCheck.Api.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();
}
}

View File

View File

View File

View File

View File

@@ -0,0 +1,19 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureUsageDailyConfiguration : IEntityTypeConfiguration<FeatureUsageDaily>
{
public void Configure(EntityTypeBuilder<FeatureUsageDaily> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.FeatureName).HasMaxLength(150).IsRequired();
builder.Property(u => u.UpdatedAt).IsRequired();
builder.Property(u => u.Count).IsRequired();
builder.HasIndex(u => new { u.EnvironmentId, u.FeatureId, u.UsageDate }).IsUnique();
builder.HasIndex(u => new { u.EnvironmentId, u.UsageDate });
}
}

View File

View File

View File

@@ -11,6 +11,8 @@ public class OrganizationConfiguration : IEntityTypeConfiguration<Organization>
builder.HasKey(o => o.Id);
builder.Property(o => o.Name).HasMaxLength(200).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired();
builder.Property(o => o.InviteToken).HasMaxLength(64);
builder.HasIndex(o => o.InviteToken).IsUnique().HasFilter("\"InviteToken\" IS NOT NULL");
builder.HasMany(o => o.Members)
.WithOne()

View File

@@ -10,5 +10,6 @@ public class OrganizationUserConfiguration : IEntityTypeConfiguration<Organizati
{
builder.HasKey(ou => new { ou.OrganizationId, ou.UserId });
builder.Property(ou => ou.Role).IsRequired();
builder.Property(ou => ou.IsPrimary).IsRequired().HasDefaultValue(false);
}
}

View File

View File

View File

View File

View File

View File

View File

@@ -1,22 +1,22 @@
using MicCheck.Api.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());
}
}

View File

0
src/api/MicCheck.Api/Data/DatabaseSeeder.cs Normal file → Executable file
View File

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Data;
public static class DatabaseStartupExtensions
{
public static async Task ApplyMigrationsAsync(this WebApplication app, CancellationToken ct = default)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
if (db.Database.ProviderName is null or "Microsoft.EntityFrameworkCore.InMemory")
return;
await db.Database.MigrateAsync(ct);
}
public static async Task SeedDevelopmentDataAsync(this WebApplication app, CancellationToken ct = default)
{
using var scope = app.Services.CreateScope();
var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
await seeder.SeedAsync(ct);
}
}

0
src/api/MicCheck.Api/Data/DatabaseUrlParser.cs Normal file → Executable file
View File

87
src/api/MicCheck.Api/Data/MicCheckDbContext.cs Normal file → Executable file
View File

@@ -1,43 +1,44 @@
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Audit;
using MicCheck.Api.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>();
public DbSet<FeatureUsageDaily> FeatureUsageDaily => Set<FeatureUsageDaily>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
}

0
src/api/MicCheck.Api/Dockerfile Normal file → Executable file
View File

View File

View File

0
src/api/MicCheck.Api/Environments/Environment.cs Normal file → Executable file
View File

View File

@@ -1,20 +1,20 @@
using MicCheck.Api.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);
}
}

View File

View File

View File

270
src/api/MicCheck.Api/Environments/EnvironmentService.cs Normal file → Executable file
View File

@@ -1,135 +1,135 @@
using MicCheck.Api.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;
}
}

View File

@@ -1,443 +1,442 @@
using MicCheck.Api.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.Select(Audit.AuditLogResponse.From).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]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class EnvironmentsController(EnvironmentService environmentService, WebhookService webhookService) : ControllerBase
{
[HttpGet("api/v1/environments")]
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("api/v1/environments")]
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("api/v1/environment/{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("api/v1/environment/{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("api/v1/environment/{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("api/v1/environment/{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("api/v1/environment/{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("api/v1/environment/{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("api/v1/environment/{apiKey}/webhook/{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("api/v1/environment/{apiKey}/webhook/{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("api/v1/environment/{apiKey}/webhook/{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("api/v1/environment/{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("api/v1/environment/{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("api/v1/environment/{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("api/v1/environment/{apiKey}/identity/{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("api/v1/environment/{apiKey}/identity/{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("api/v1/environment/{apiKey}/identity/{id}/trait/{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("api/v1/environment/{apiKey}/identity/{id}/trait/{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("api/v1/environment/{apiKey}/identity/{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("api/v1/environment/{apiKey}/identity/{id}/featurestate/{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("api/v1/environment/{apiKey}/identity/{id}/featurestate/{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("api/v1/environment/{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("api/v1/environment/{apiKey}/featurestate/{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("api/v1/environment/{apiKey}/featurestate/{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("api/v1/environment/{apiKey}/featurestate/{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("api/v1/environment/{apiKey}/feature/{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("api/v1/environment/{apiKey}/feature/{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("api/v1/environment/{apiKey}/feature/{featureId}/segment/{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("api/v1/environment/{apiKey}/feature/{featureId}/segment/{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("api/v1/environment/{apiKey}/identity/{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);
}
}

View File

0
src/api/MicCheck.Api/Features/CreateFeatureRequest.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/CreateTagRequest.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/Feature.cs Normal file → Executable file
View File

View File

@@ -8,7 +8,8 @@ namespace MicCheck.Api.Features;
public class FeatureEvaluationService(
MicCheckDbContext db,
SegmentEvaluator segmentEvaluator,
FlagCache flagCache)
FlagCache flagCache,
FeatureUsageMetrics usageMetrics)
{
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
int environmentId, CancellationToken ct = default)
@@ -32,6 +33,10 @@ public class FeatureEvaluationService(
).ToListAsync(ct);
flagCache.Set(environmentId, results);
foreach (var r in results)
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
return results;
}
@@ -134,6 +139,9 @@ public class FeatureEvaluationService(
}
}
foreach (var r in results)
usageMetrics.RecordEvaluation(environmentId, r.Feature.Id, r.Feature.Name);
return results;
}
}

6
src/api/MicCheck.Api/Features/FeatureResponse.cs Normal file → Executable file
View File

@@ -8,7 +8,8 @@ public record FeatureResponse(
string? Description,
bool DefaultEnabled,
int ProjectId,
DateTimeOffset CreatedAt
DateTimeOffset CreatedAt,
IReadOnlyList<TagResponse> Tags
)
{
public static FeatureResponse From(Feature feature) => new(
@@ -19,5 +20,6 @@ public record FeatureResponse(
feature.Description,
feature.DefaultEnabled,
feature.ProjectId,
feature.CreatedAt);
feature.CreatedAt,
feature.Tags.Select(TagResponse.From).ToList());
}

0
src/api/MicCheck.Api/Features/FeatureSegment.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/FeatureSegmentRequest.cs Normal file → Executable file
View File

View File

0
src/api/MicCheck.Api/Features/FeatureSegmentService.cs Normal file → Executable file
View File

39
src/api/MicCheck.Api/Features/FeatureService.cs Normal file → Executable file
View File

@@ -13,13 +13,14 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
public async Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId, CancellationToken ct = default)
{
return await db.Features
.Include(f => f.Tags)
.Where(f => f.ProjectId == projectId)
.ToListAsync(ct);
}
public async Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default)
{
return await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);
return await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct);
}
public async Task<Feature> CreateAsync(
@@ -80,7 +81,7 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
public async Task<Feature> UpdateAsync(
int id, string name, string? description, CancellationToken ct = default)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct)
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == id, ct)
?? throw new KeyNotFoundException($"Feature {id} not found.");
var before = new { feature.Name, feature.Description };
@@ -101,6 +102,40 @@ public class FeatureService(MicCheckDbContext db, AuditService auditService, Web
return feature;
}
public async Task<Feature> AssignTagAsync(int featureId, int tagId, CancellationToken ct = default)
{
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == featureId, ct)
?? throw new KeyNotFoundException($"Feature {featureId} not found.");
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new KeyNotFoundException($"Tag {tagId} not found.");
if (tag.ProjectId != feature.ProjectId)
throw new DomainException("Tag does not belong to the feature's project.");
if (feature.Tags.All(t => t.Id != tagId))
{
feature.Tags.Add(tag);
await db.SaveChangesAsync(ct);
}
return feature;
}
public async Task<Feature> RemoveTagAsync(int featureId, int tagId, CancellationToken ct = default)
{
var feature = await db.Features.Include(f => f.Tags).FirstOrDefaultAsync(f => f.Id == featureId, ct)
?? throw new KeyNotFoundException($"Feature {featureId} not found.");
var tag = feature.Tags.FirstOrDefault(t => t.Id == tagId);
if (tag is not null)
{
feature.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
}
return feature;
}
public async Task DeleteAsync(int id, CancellationToken ct = default)
{
var feature = await db.Features.FirstOrDefaultAsync(f => f.Id == id, ct);

0
src/api/MicCheck.Api/Features/FeatureState.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/FeatureStateResponse.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/FeatureStateResult.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/FeatureStateService.cs Normal file → Executable file
View File

View File

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Features;
public record struct FeatureUsageBucketKey(int EnvironmentId, int FeatureId, string FeatureName, DateOnly UsageDate);

View File

@@ -0,0 +1,32 @@
using MicCheck.Api.Common.Security.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/environment/{environmentId}/usage")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeatureUsageController(FeatureUsageQueryService queryService, IMemoryCache cache) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<DashboardUsageResponse>> GetDashboardUsage(
int environmentId,
[FromQuery] int days = 14,
CancellationToken ct = default)
{
days = Math.Clamp(days, 1, 90);
var cacheKey = $"usage-dashboard:{environmentId}:{days}";
if (cache.TryGetValue(cacheKey, out DashboardUsageResponse? cached))
return Ok(cached);
var result = await queryService.GetDashboardUsageAsync(environmentId, days, ct);
cache.Set(cacheKey, result, TimeSpan.FromSeconds(180));
return Ok(result);
}
}

View File

@@ -0,0 +1,12 @@
namespace MicCheck.Api.Features;
public class FeatureUsageDaily
{
public int Id { get; init; }
public int EnvironmentId { get; init; }
public int FeatureId { get; init; }
public required string FeatureName { get; set; }
public DateOnly UsageDate { get; init; }
public long Count { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
}

View File

@@ -0,0 +1,60 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureUsageFlushBackgroundService(
FeatureUsageMetrics metrics,
IServiceScopeFactory scopeFactory,
ILogger<FeatureUsageFlushBackgroundService> logger) : BackgroundService
{
private static readonly TimeSpan FlushInterval = TimeSpan.FromSeconds(30);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(FlushInterval);
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await FlushAsync(stoppingToken);
}
}
public override async Task StopAsync(CancellationToken cancellationToken)
{
await base.StopAsync(cancellationToken);
await FlushAsync(CancellationToken.None);
}
private async Task FlushAsync(CancellationToken ct)
{
var deltas = metrics.DrainAccumulated();
if (deltas.Count == 0)
return;
var updatedAt = DateTimeOffset.UtcNow;
try
{
await using var scope = scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<MicCheckDbContext>();
foreach (var (key, count) in deltas)
{
await db.Database.ExecuteSqlAsync(
$"""
INSERT INTO "FeatureUsageDaily" ("EnvironmentId", "FeatureId", "FeatureName", "UsageDate", "Count", "UpdatedAt")
VALUES ({key.EnvironmentId}, {key.FeatureId}, {key.FeatureName}, {key.UsageDate}, {count}, {updatedAt})
ON CONFLICT ("EnvironmentId", "FeatureId", "UsageDate")
DO UPDATE SET "Count" = "FeatureUsageDaily"."Count" + EXCLUDED."Count",
"FeatureName" = EXCLUDED."FeatureName",
"UpdatedAt" = EXCLUDED."UpdatedAt"
""",
ct);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to flush feature usage data ({BucketCount} buckets)", deltas.Count);
}
}
}

View File

@@ -0,0 +1,35 @@
using System.Collections.Concurrent;
using System.Diagnostics.Metrics;
namespace MicCheck.Api.Features;
public class FeatureUsageMetrics : IDisposable
{
private readonly Meter _meter;
private readonly Counter<long> _evaluationCounter;
private ConcurrentDictionary<FeatureUsageBucketKey, long> _accumulator = new();
public FeatureUsageMetrics(IMeterFactory meterFactory)
{
_meter = meterFactory.Create("MicCheck.FeatureUsage");
_evaluationCounter = _meter.CreateCounter<long>("miccheck.feature.evaluations", description: "Number of feature flag evaluations");
}
public void RecordEvaluation(int environmentId, int featureId, string featureName)
{
_evaluationCounter.Add(1,
new KeyValuePair<string, object?>("environment.id", environmentId),
new KeyValuePair<string, object?>("feature.id", featureId));
var key = new FeatureUsageBucketKey(environmentId, featureId, featureName, DateOnly.FromDateTime(DateTime.UtcNow));
_accumulator.AddOrUpdate(key, 1L, (_, existing) => existing + 1);
}
public IReadOnlyDictionary<FeatureUsageBucketKey, long> DrainAccumulated()
{
var drained = Interlocked.Exchange(ref _accumulator, new ConcurrentDictionary<FeatureUsageBucketKey, long>());
return drained;
}
public void Dispose() => _meter.Dispose();
}

View File

@@ -0,0 +1,42 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureUsageQueryService(MicCheckDbContext db)
{
public async Task<DashboardUsageResponse> GetDashboardUsageAsync(int environmentId, int days, CancellationToken ct = default)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var windowStart = today.AddDays(-days);
var yesterday = today.AddDays(-1);
var rows = await db.FeatureUsageDaily
.Where(u => u.EnvironmentId == environmentId && u.UsageDate >= windowStart)
.ToListAsync(ct);
var topFeaturesLastDay = rows
.Where(u => u.UsageDate >= yesterday)
.GroupBy(u => new { u.FeatureId, u.FeatureName })
.Select(g => new TopFeatureUsage(g.Key.FeatureId, g.Key.FeatureName, g.Sum(u => u.Count)))
.OrderByDescending(t => t.Count)
.Take(10)
.ToList();
var dailyUsage = rows
.GroupBy(u => u.UsageDate)
.OrderBy(g => g.Key)
.Select(g =>
{
var features = g
.GroupBy(u => new { u.FeatureId, u.FeatureName })
.Select(fg => new TopFeatureUsage(fg.Key.FeatureId, fg.Key.FeatureName, fg.Sum(u => u.Count)))
.OrderByDescending(f => f.Count)
.ToList();
return new DailyUsage(g.Key, features.Sum(f => f.Count), features);
})
.ToList();
return new DashboardUsageResponse(topFeaturesLastDay, dailyUsage);
}
}

View File

@@ -0,0 +1,9 @@
namespace MicCheck.Api.Features;
public record TopFeatureUsage(int FeatureId, string FeatureName, long Count);
public record DailyUsage(DateOnly Date, long TotalCount, IReadOnlyList<TopFeatureUsage> Features);
public record DashboardUsageResponse(
IReadOnlyList<TopFeatureUsage> TopFeaturesLastDay,
IReadOnlyList<DailyUsage> DailyUsage);

205
src/api/MicCheck.Api/Features/FeaturesController.cs Normal file → Executable file
View File

@@ -1,88 +1,117 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/projects/{projectId}/features")]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeaturesController(FeatureService featureService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List(
int projectId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var all = await featureService.ListByProjectAsync(projectId, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(FeatureResponse.From).ToList();
return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged));
}
[HttpPost]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
try
{
var feature = await featureService.CreateAsync(
projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id }, FeatureResponse.From(feature));
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("{id}")]
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
return Ok(FeatureResponse.From(feature));
}
[HttpPut("{id}")]
public async Task<ActionResult<FeatureResponse>> Update(
int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
var updated = await featureService.UpdateAsync(id, request.Name, request.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpPatch("{id}")]
public async Task<ActionResult<FeatureResponse>> Patch(
int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
if (request.Name is not null) feature.Name = request.Name;
if (request.Description is not null) feature.Description = request.Description;
if (request.DefaultEnabled.HasValue) feature.DefaultEnabled = request.DefaultEnabled.Value;
var updated = await featureService.UpdateAsync(id, feature.Name, feature.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
await featureService.DeleteAsync(id, ct);
return NoContent();
}
}
using MicCheck.Api.Common.Security.Authorization;
using MicCheck.Api.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace MicCheck.Api.Features;
[ApiController]
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
[EnableRateLimiting("AdminApi")]
public class FeaturesController(FeatureService featureService) : ControllerBase
{
[HttpGet("api/v1/project/{projectId}/features")]
public async Task<ActionResult<PaginatedResponse<FeatureResponse>>> List(
int projectId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
pageSize = Math.Clamp(pageSize, 1, 100);
var all = await featureService.ListByProjectAsync(projectId, ct);
var paged = all.Skip((page - 1) * pageSize).Take(pageSize).Select(FeatureResponse.From).ToList();
return Ok(new PaginatedResponse<FeatureResponse>(all.Count, null, null, paged));
}
[HttpPost("api/v1/project/{projectId}/features")]
public async Task<ActionResult<FeatureResponse>> Create(
int projectId, CreateFeatureRequest request, CancellationToken ct)
{
try
{
var feature = await featureService.CreateAsync(projectId, request.Name, request.Type, request.InitialValue, request.Description, ct);
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id }, FeatureResponse.From(feature));
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("api/v1/project/{projectId}/feature/{id}")]
public async Task<ActionResult<FeatureResponse>> GetById(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
return Ok(FeatureResponse.From(feature));
}
[HttpPut("api/v1/project/{projectId}/feature/{id}")]
public async Task<ActionResult<FeatureResponse>> Update(
int projectId, int id, UpdateFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
var updated = await featureService.UpdateAsync(id, request.Name, request.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpPatch("api/v1/project/{projectId}/feature/{id}")]
public async Task<ActionResult<FeatureResponse>> Patch(
int projectId, int id, PatchFeatureRequest request, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
if (request.Name is not null) feature.Name = request.Name;
if (request.Description is not null) feature.Description = request.Description;
if (request.DefaultEnabled.HasValue) feature.DefaultEnabled = request.DefaultEnabled.Value;
var updated = await featureService.UpdateAsync(id, feature.Name, feature.Description, ct);
return Ok(FeatureResponse.From(updated));
}
[HttpDelete("api/v1/project/{projectId}/feature/{id}")]
public async Task<IActionResult> Delete(int projectId, int id, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
await featureService.DeleteAsync(id, ct);
return NoContent();
}
[HttpPut("api/v1/project/{projectId}/feature/{id}/tag/{tagId}")]
public async Task<ActionResult<FeatureResponse>> AssignTag(int projectId, int id, int tagId, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
try
{
var updated = await featureService.AssignTagAsync(id, tagId, ct);
return Ok(FeatureResponse.From(updated));
}
catch (KeyNotFoundException)
{
return NotFound();
}
catch (DomainException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpDelete("api/v1/project/{projectId}/feature/{id}/tag/{tagId}")]
public async Task<ActionResult<FeatureResponse>> RemoveTag(int projectId, int id, int tagId, CancellationToken ct)
{
var feature = await featureService.FindByIdAsync(id, ct);
if (feature is null || feature.ProjectId != projectId) return NotFound();
var updated = await featureService.RemoveTagAsync(id, tagId, ct);
return Ok(FeatureResponse.From(updated));
}
}

0
src/api/MicCheck.Api/Features/FlagCache.cs Normal file → Executable file
View File

12
src/api/MicCheck.Api/Features/FlagResponse.cs Normal file → Executable file
View File

@@ -1,14 +1,6 @@
namespace MicCheck.Api.Features;
public record FlagResponse(
int Id,
FeatureSummaryResponse Feature,
bool Enabled,
string? FeatureStateValue)
public record FlagResponse(int Id, FeatureSummaryResponse Feature, bool Enabled, string? FeatureStateValue)
{
public static FlagResponse From(FeatureStateResult result) => new(
result.Feature.Id,
FeatureSummaryResponse.From(result.Feature),
result.Enabled,
result.Value);
public static FlagResponse From(FeatureStateResult result) => new(result.Feature.Id, FeatureSummaryResponse.From(result.Feature), result.Enabled, result.Value);
}

38
src/api/MicCheck.Api/Features/FlagsController.cs Normal file → Executable file
View File

@@ -1,19 +1,19 @@
using MicCheck.Api.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/flags")]
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
public class FlagsController(FeatureEvaluationService featureEvaluationService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
{
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value);
var results = await featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
return Ok(results.Select(FlagResponse.From).ToList());
}
}
using MicCheck.Api.Common.Security.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Features;
[ApiController]
[Route("api/v1/flags")]
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
public class FlagsController(FeatureEvaluationService featureEvaluationService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
{
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value);
var results = await featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
return Ok(results.Select(FlagResponse.From).ToList());
}
}

0
src/api/MicCheck.Api/Features/PatchFeatureRequest.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/Tag.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/TagResponse.cs Normal file → Executable file
View File

0
src/api/MicCheck.Api/Features/TagService.cs Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More