Adding Flags API

This commit is contained in:
2026-04-07 15:30:40 -07:00
parent 7b15086fe5
commit 0ba076b650
78 changed files with 3862 additions and 81 deletions

View File

@@ -0,0 +1,46 @@
using MicCheck.Api.Authorization;
namespace MicCheck.Api.ApiKeys;
public static class ApiKeyEndpoints
{
public static void MapApiKeyEndpoints(this WebApplication app)
{
var group = app
.MapGroup("/api/v1/organisations/{organizationId:int}/api-keys")
.RequireAuthorization(AuthorizationPolicies.OrganizationAdmin)
.WithTags("ApiKeys");
group.MapPost("/", async (
int organizationId,
CreateApiKeyRequest request,
ApiKeyService apiKeyService,
CancellationToken ct) =>
{
var (key, rawKey) = await apiKeyService.CreateAsync(
organizationId, request.Name, request.ExpiresAt, ct);
return Results.Ok(new CreateApiKeyResponse(key.Id, key.Name, rawKey, key.Prefix, key.ExpiresAt));
}).WithName("CreateApiKey");
group.MapGet("/", async (
int organizationId,
ApiKeyService apiKeyService,
CancellationToken ct) =>
{
var keys = await apiKeyService.ListAsync(organizationId, ct);
return Results.Ok(keys.Select(k =>
new ApiKeyResponse(k.Id, k.Name, k.Prefix, k.IsActive, k.ExpiresAt, k.CreatedAt)));
}).WithName("ListApiKeys");
group.MapDelete("/{keyId:int}", async (
int organizationId,
int keyId,
ApiKeyService apiKeyService,
CancellationToken ct) =>
{
await apiKeyService.RevokeAsync(organizationId, keyId, ct);
return Results.NoContent();
}).WithName("RevokeApiKey");
}
}

View File

@@ -0,0 +1,22 @@
using System.Security.Cryptography;
using System.Text;
namespace MicCheck.Api.ApiKeys;
public static class ApiKeyHasher
{
public static string Hash(string key)
{
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(key));
return Convert.ToHexString(bytes).ToLowerInvariant();
}
public static string GenerateKey()
{
var bytes = RandomNumberGenerator.GetBytes(32);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
}

View File

@@ -0,0 +1,9 @@
namespace MicCheck.Api.ApiKeys;
public record ApiKeyResponse(
int Id,
string Name,
string Prefix,
bool IsActive,
DateTimeOffset? ExpiresAt,
DateTimeOffset CreatedAt);

View File

@@ -0,0 +1,50 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.ApiKeys;
public class ApiKeyService(MicCheckDbContext db)
{
public async Task<(ApiKey Key, string RawKey)> CreateAsync(
int organizationId, string name, DateTimeOffset? expiresAt, CancellationToken ct = default)
{
var rawKey = ApiKeyHasher.GenerateKey();
var hashedKey = ApiKeyHasher.Hash(rawKey);
var prefix = rawKey.Length >= 8 ? rawKey[..8] : rawKey;
var apiKey = new ApiKey
{
Key = hashedKey,
Prefix = prefix,
Name = name,
OrganizationId = organizationId,
IsActive = true,
ExpiresAt = expiresAt,
CreatedAt = DateTimeOffset.UtcNow
};
db.ApiKeys.Add(apiKey);
await db.SaveChangesAsync(ct);
return (apiKey, rawKey);
}
public async Task<IReadOnlyList<ApiKey>> ListAsync(int organizationId, CancellationToken ct = default)
{
return await db.ApiKeys
.Where(k => k.OrganizationId == organizationId)
.ToListAsync(ct);
}
public async Task RevokeAsync(int organizationId, int keyId, CancellationToken ct = default)
{
var key = await db.ApiKeys
.FirstOrDefaultAsync(k => k.Id == keyId && k.OrganizationId == organizationId, ct);
if (key is null)
return;
key.IsActive = false;
await db.SaveChangesAsync(ct);
}
}

View File

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

View File

@@ -0,0 +1,8 @@
namespace MicCheck.Api.ApiKeys;
public record CreateApiKeyResponse(
int Id,
string Name,
string Key,
string Prefix,
DateTimeOffset? ExpiresAt);

View File

@@ -6,18 +6,61 @@ public static class AuthEndpoints
{
public static void MapAuthEndpoints(this WebApplication app)
{
app.MapPost("/auth/token", (
[FromBody] TokenRequest request,
ITokenService tokenService) =>
{
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
return Results.BadRequest("Username and password are required.");
var group = app.MapGroup("/api/v1/auth").AllowAnonymous().WithTags("Auth");
var response = tokenService.GenerateToken(request.Username);
return Results.Ok(response);
})
.WithName("GetToken")
.WithTags("Auth")
.AllowAnonymous();
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

@@ -0,0 +1,145 @@
using System.Security.Cryptography;
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Auth;
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,6 +1,8 @@
using MicCheck.Api.Users;
namespace MicCheck.Api.Auth;
public interface ITokenService
{
TokenResponse GenerateToken(string username);
TokenResponse GenerateToken(User user);
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Auth;
public record LoginRequest(string Email, string Password);

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Auth;
public record LoginResponse(string AccessToken, string RefreshToken, DateTime ExpiresAt);

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
namespace MicCheck.Api.Auth;
public record RegisterRequest(
string Email,
string Password,
string FirstName,
string LastName,
string OrganizationName);

View File

@@ -1,13 +1,14 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using MicCheck.Api.Users;
using Microsoft.IdentityModel.Tokens;
namespace MicCheck.Api.Auth;
public class TokenService(IConfiguration configuration) : ITokenService
{
public TokenResponse GenerateToken(string username)
public TokenResponse GenerateToken(User user)
{
var secretKey = configuration["Jwt:SecretKey"]
?? throw new InvalidOperationException("Jwt:SecretKey is not configured.");
@@ -21,16 +22,24 @@ public class TokenService(IConfiguration configuration) : ITokenService
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
var expiresAt = DateTime.UtcNow.AddMinutes(expiryMinutes);
var claims = new[]
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, username),
new Claim(JwtRegisteredClaimNames.Name, username),
new Claim(JwtRegisteredClaimNames.Iat,
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 Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
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,

View File

@@ -0,0 +1,60 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Data;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MicCheck.Api.Authentication;
public class ApiKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
MicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "ApiKey";
private const string ApiKeyPrefix = "Api-Key ";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("Authorization", out var authHeader))
return AuthenticateResult.NoResult();
var headerValue = authHeader.FirstOrDefault();
if (string.IsNullOrWhiteSpace(headerValue) ||
!headerValue.StartsWith(ApiKeyPrefix, StringComparison.OrdinalIgnoreCase))
return AuthenticateResult.NoResult();
var rawKey = headerValue[ApiKeyPrefix.Length..].Trim();
if (string.IsNullOrWhiteSpace(rawKey))
return AuthenticateResult.Fail("API key value is empty.");
var hashedKey = ApiKeyHasher.Hash(rawKey);
var apiKey = await db.ApiKeys
.FirstOrDefaultAsync(k => k.Key == hashedKey);
if (apiKey is null)
return AuthenticateResult.Fail("Invalid API key.");
if (!apiKey.IsActive)
return AuthenticateResult.Fail("API key is inactive.");
if (apiKey.ExpiresAt.HasValue && apiKey.ExpiresAt.Value < DateTimeOffset.UtcNow)
return AuthenticateResult.Fail("API key has expired.");
var claims = new[]
{
new Claim("OrganizationId", apiKey.OrganizationId.ToString()),
new Claim("OrganizationRole", "Admin")
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}

View File

@@ -0,0 +1,45 @@
using System.Security.Claims;
using System.Text.Encodings.Web;
using MicCheck.Api.Data;
using Microsoft.AspNetCore.Authentication;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace MicCheck.Api.Authentication;
public class EnvironmentKeyAuthenticationHandler(
IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
MicCheckDbContext db)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
public const string SchemeName = "EnvironmentKey";
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Environment-Key", out var keyValues))
return AuthenticateResult.NoResult();
var key = keyValues.FirstOrDefault();
if (string.IsNullOrWhiteSpace(key))
return AuthenticateResult.Fail("X-Environment-Key header is empty.");
var environment = await db.Environments
.FirstOrDefaultAsync(e => e.ApiKey == key);
if (environment is null)
return AuthenticateResult.Fail("Invalid environment key.");
var claims = new[]
{
new Claim("EnvironmentId", environment.Id.ToString()),
new Claim("ProjectId", environment.ProjectId.ToString())
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var ticket = new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}

View File

@@ -0,0 +1,8 @@
namespace MicCheck.Api.Authorization;
public static class AuthorizationPolicies
{
public const string FlagsApiAccess = "FlagsApiAccess";
public const string AdminApiAccess = "AdminApiAccess";
public const string OrganizationAdmin = "OrganizationAdmin";
}

View File

@@ -0,0 +1,17 @@
namespace MicCheck.Api.Authorization;
public enum ProjectPermission
{
ViewProject,
CreateFeature,
EditFeature,
DeleteFeature,
CreateEnvironment,
EditEnvironment,
DeleteEnvironment,
CreateSegment,
EditSegment,
DeleteSegment,
ManageWebhooks,
ViewAuditLog
}

View File

@@ -0,0 +1,10 @@
using Microsoft.AspNetCore.Authorization;
namespace MicCheck.Api.Authorization;
public class ProjectPermissionRequirement : IAuthorizationRequirement
{
public ProjectPermission Permission { get; }
public ProjectPermissionRequirement(ProjectPermission permission) => Permission = permission;
}

View File

@@ -0,0 +1,54 @@
using System.IdentityModel.Tokens.Jwt;
using MicCheck.Api.Data;
using MicCheck.Api.Organizations;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Authorization;
public class ProjectPermissionRequirementHandler(
MicCheckDbContext db,
IHttpContextAccessor httpContextAccessor)
: AuthorizationHandler<ProjectPermissionRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ProjectPermissionRequirement requirement)
{
var userIdClaim = context.User.FindFirst(JwtRegisteredClaimNames.Sub);
if (userIdClaim is null || !int.TryParse(userIdClaim.Value, out var userId))
{
context.Fail();
return;
}
if (context.User.HasClaim("OrganizationRole", OrganizationRole.Admin.ToString()))
{
context.Succeed(requirement);
return;
}
var httpContext = httpContextAccessor.HttpContext;
if (httpContext is null ||
!httpContext.Request.RouteValues.TryGetValue("projectId", out var projectIdValue) ||
!int.TryParse(projectIdValue?.ToString(), out var projectId))
{
context.Fail();
return;
}
var permission = await db.UserProjectPermissions
.FirstOrDefaultAsync(p => p.UserId == userId && p.ProjectId == projectId);
if (permission is null)
{
context.Fail();
return;
}
if (permission.IsAdmin || permission.Permissions.Contains(requirement.Permission))
context.Succeed(requirement);
else
context.Fail();
}
}

View File

@@ -0,0 +1,9 @@
namespace MicCheck.Api.Authorization;
public class UserProjectPermission
{
public int UserId { get; init; }
public int ProjectId { get; init; }
public List<ProjectPermission> Permissions { get; set; } = [];
public bool IsAdmin { get; set; }
}

View File

@@ -0,0 +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();
}
}

View File

@@ -0,0 +1,19 @@
using MicCheck.Api.Audit;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class AuditLogConfiguration : IEntityTypeConfiguration<AuditLog>
{
public void Configure(EntityTypeBuilder<AuditLog> builder)
{
builder.HasKey(a => a.Id);
builder.Property(a => a.ResourceType).HasMaxLength(100).IsRequired();
builder.Property(a => a.ResourceId).HasMaxLength(100).IsRequired();
builder.Property(a => a.Action).HasMaxLength(50).IsRequired();
builder.Property(a => a.CreatedAt).IsRequired();
builder.HasIndex(a => new { a.OrganizationId, a.CreatedAt });
}
}

View File

@@ -0,0 +1,18 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data.Configurations;
public class EnvironmentConfiguration : IEntityTypeConfiguration<AppEnvironment>
{
public void Configure(EntityTypeBuilder<AppEnvironment> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Name).HasMaxLength(200).IsRequired();
builder.Property(e => e.ApiKey).HasMaxLength(100).IsRequired();
builder.Property(e => e.CreatedAt).IsRequired();
builder.HasIndex(e => e.ApiKey).IsUnique();
}
}

View File

@@ -0,0 +1,29 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureConfiguration : IEntityTypeConfiguration<Feature>
{
public void Configure(EntityTypeBuilder<Feature> builder)
{
builder.HasKey(f => f.Id);
builder.Property(f => f.Name).HasMaxLength(150).IsRequired();
builder.Property(f => f.InitialValue).HasMaxLength(20_000);
builder.Property(f => f.Description).HasMaxLength(2_000);
builder.Property(f => f.CreatedAt).IsRequired();
builder.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique();
builder.HasMany(f => f.FeatureStates)
.WithOne()
.HasForeignKey(fs => fs.FeatureId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(f => f.Tags)
.WithOne()
.HasForeignKey(t => t.ProjectId)
.OnDelete(DeleteBehavior.NoAction);
}
}

View File

@@ -0,0 +1,18 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureSegmentConfiguration : IEntityTypeConfiguration<FeatureSegment>
{
public void Configure(EntityTypeBuilder<FeatureSegment> builder)
{
builder.HasKey(fs => fs.Id);
builder.HasOne(fsg => fsg.FeatureState)
.WithOne()
.HasForeignKey<MicCheck.Api.Features.FeatureState>(fs => fs.FeatureSegmentId)
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -0,0 +1,21 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class FeatureStateConfiguration : IEntityTypeConfiguration<FeatureState>
{
public void Configure(EntityTypeBuilder<FeatureState> builder)
{
builder.HasKey(fs => fs.Id);
builder.Property(fs => fs.Value).HasMaxLength(20_000);
builder.Property(fs => fs.CreatedAt).IsRequired();
builder.Property(fs => fs.UpdatedAt).IsRequired();
builder.Property(fs => fs.Version).IsConcurrencyToken();
builder.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique();
builder.HasIndex(fs => fs.EnvironmentId);
builder.HasIndex(fs => fs.IdentityId);
}
}

View File

@@ -0,0 +1,27 @@
using MicCheck.Api.Identities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class IdentityConfiguration : IEntityTypeConfiguration<Identity>
{
public void Configure(EntityTypeBuilder<Identity> builder)
{
builder.HasKey(i => i.Id);
builder.Property(i => i.Identifier).HasMaxLength(2_000).IsRequired();
builder.Property(i => i.CreatedAt).IsRequired();
builder.HasIndex(i => new { i.EnvironmentId, i.Identifier }).IsUnique();
builder.HasMany(i => i.Traits)
.WithOne()
.HasForeignKey(t => t.IdentityId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(i => i.FeatureStateOverrides)
.WithOne()
.HasForeignKey(fs => fs.IdentityId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,17 @@
using MicCheck.Api.Identities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class IdentityTraitConfiguration : IEntityTypeConfiguration<IdentityTrait>
{
public void Configure(EntityTypeBuilder<IdentityTrait> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Key).HasMaxLength(200).IsRequired();
builder.Property(t => t.Value).HasMaxLength(2_000).IsRequired();
builder.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique();
}
}

View File

@@ -0,0 +1,30 @@
using MicCheck.Api.Organizations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class OrganizationConfiguration : IEntityTypeConfiguration<Organization>
{
public void Configure(EntityTypeBuilder<Organization> builder)
{
builder.HasKey(o => o.Id);
builder.Property(o => o.Name).HasMaxLength(200).IsRequired();
builder.Property(o => o.CreatedAt).IsRequired();
builder.HasMany(o => o.Members)
.WithOne()
.HasForeignKey(ou => ou.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(o => o.ApiKeys)
.WithOne()
.HasForeignKey(k => k.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(o => o.Projects)
.WithOne()
.HasForeignKey(p => p.OrganizationId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,14 @@
using MicCheck.Api.Organizations;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class OrganizationUserConfiguration : IEntityTypeConfiguration<OrganizationUser>
{
public void Configure(EntityTypeBuilder<OrganizationUser> builder)
{
builder.HasKey(ou => new { ou.OrganizationId, ou.UserId });
builder.Property(ou => ou.Role).IsRequired();
}
}

View File

@@ -0,0 +1,15 @@
using MicCheck.Api.Projects;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class ProjectConfiguration : IEntityTypeConfiguration<Project>
{
public void Configure(EntityTypeBuilder<Project> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
builder.Property(p => p.CreatedAt).IsRequired();
}
}

View File

@@ -0,0 +1,24 @@
using MicCheck.Api.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
{
public void Configure(EntityTypeBuilder<RefreshToken> builder)
{
builder.HasKey(rt => rt.Id);
builder.Property(rt => rt.Token).HasMaxLength(512).IsRequired();
builder.Property(rt => rt.ExpiresAt).IsRequired();
builder.Property(rt => rt.CreatedAt).IsRequired();
builder.HasIndex(rt => rt.Token).IsUnique();
builder.HasIndex(rt => rt.UserId);
builder.HasOne(rt => rt.User)
.WithMany()
.HasForeignKey(rt => rt.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,15 @@
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class SegmentConditionConfiguration : IEntityTypeConfiguration<SegmentCondition>
{
public void Configure(EntityTypeBuilder<SegmentCondition> builder)
{
builder.HasKey(c => c.Id);
builder.Property(c => c.Property).HasMaxLength(200).IsRequired();
builder.Property(c => c.Value).HasMaxLength(1_000).IsRequired();
}
}

View File

@@ -0,0 +1,20 @@
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class SegmentConfiguration : IEntityTypeConfiguration<Segment>
{
public void Configure(EntityTypeBuilder<Segment> builder)
{
builder.HasKey(s => s.Id);
builder.Property(s => s.Name).HasMaxLength(200).IsRequired();
builder.Property(s => s.CreatedAt).IsRequired();
builder.HasMany(s => s.Rules)
.WithOne()
.HasForeignKey(r => r.SegmentId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,23 @@
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class SegmentRuleConfiguration : IEntityTypeConfiguration<SegmentRule>
{
public void Configure(EntityTypeBuilder<SegmentRule> builder)
{
builder.HasKey(r => r.Id);
builder.HasMany(r => r.Conditions)
.WithOne()
.HasForeignKey(c => c.RuleId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(r => r.ChildRules)
.WithOne()
.HasForeignKey(r => r.ParentRuleId)
.OnDelete(DeleteBehavior.NoAction);
}
}

View File

@@ -0,0 +1,15 @@
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class TagConfiguration : IEntityTypeConfiguration<Tag>
{
public void Configure(EntityTypeBuilder<Tag> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Label).HasMaxLength(200).IsRequired();
builder.Property(t => t.Color).HasMaxLength(20).IsRequired();
}
}

View File

@@ -0,0 +1,25 @@
using MicCheck.Api.Users;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class UserConfiguration : IEntityTypeConfiguration<User>
{
public void Configure(EntityTypeBuilder<User> builder)
{
builder.HasKey(u => u.Id);
builder.Property(u => u.Email).HasMaxLength(500).IsRequired();
builder.Property(u => u.PasswordHash).IsRequired();
builder.Property(u => u.FirstName).HasMaxLength(200).IsRequired();
builder.Property(u => u.LastName).HasMaxLength(200).IsRequired();
builder.Property(u => u.CreatedAt).IsRequired();
builder.HasIndex(u => u.Email).IsUnique();
builder.HasMany(u => u.Organizations)
.WithOne()
.HasForeignKey(ou => ou.UserId)
.OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +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());
}
}

View File

@@ -0,0 +1,18 @@
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class WebhookConfiguration : IEntityTypeConfiguration<Webhook>
{
public void Configure(EntityTypeBuilder<Webhook> builder)
{
builder.HasKey(w => w.Id);
builder.Property(w => w.Url).HasMaxLength(500).IsRequired();
builder.Property(w => w.Secret).HasMaxLength(200);
builder.Property(w => w.CreatedAt).IsRequired();
builder.HasIndex(w => w.EnvironmentId);
}
}

View File

@@ -0,0 +1,17 @@
using MicCheck.Api.Webhooks;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace MicCheck.Api.Data.Configurations;
public class WebhookDeliveryLogConfiguration : IEntityTypeConfiguration<WebhookDeliveryLog>
{
public void Configure(EntityTypeBuilder<WebhookDeliveryLog> builder)
{
builder.HasKey(d => d.Id);
builder.Property(d => d.EventType).HasMaxLength(100).IsRequired();
builder.Property(d => d.AttemptedAt).IsRequired();
builder.HasIndex(d => d.WebhookId);
}
}

View File

@@ -0,0 +1,53 @@
using MicCheck.Api.Organizations;
using MicCheck.Api.Projects;
using Microsoft.EntityFrameworkCore;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Data;
public class DatabaseSeeder
{
private readonly MicCheckDbContext _db;
public DatabaseSeeder(MicCheckDbContext db)
{
_db = db;
}
public async Task SeedAsync(CancellationToken ct = default)
{
if (await _db.Organizations.AnyAsync(ct))
return;
var organization = new Organization
{
Name = "Default",
CreatedAt = DateTimeOffset.UtcNow
};
_db.Organizations.Add(organization);
await _db.SaveChangesAsync(ct);
var project = new Project
{
Name = "My Project",
OrganizationId = organization.Id,
CreatedAt = DateTimeOffset.UtcNow
};
_db.Projects.Add(project);
await _db.SaveChangesAsync(ct);
var environmentNames = new[] { "Development", "Staging", "Production" };
foreach (var name in environmentNames)
{
_db.Environments.Add(new AppEnvironment
{
Name = name,
ApiKey = $"env-{Guid.NewGuid():N}",
ProjectId = project.Id,
CreatedAt = DateTimeOffset.UtcNow
});
}
await _db.SaveChangesAsync(ct);
}
}

View File

@@ -0,0 +1,21 @@
namespace MicCheck.Api.Data;
public static class DatabaseUrlParser
{
/// <summary>
/// Converts a DATABASE_URL in postgresql://user:password@host:port/dbname format
/// to an Npgsql connection string.
/// </summary>
public static string ToNpgsqlConnectionString(string databaseUrl)
{
var uri = new Uri(databaseUrl);
var userInfo = uri.UserInfo.Split(':');
var host = uri.Host;
var port = uri.Port > 0 ? uri.Port : 5432;
var database = uri.AbsolutePath.TrimStart('/');
var username = userInfo.Length > 0 ? userInfo[0] : string.Empty;
var password = userInfo.Length > 1 ? userInfo[1] : string.Empty;
return $"Host={host};Port={port};Database={database};Username={username};Password={password}";
}
}

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
using MicCheck.Api.Features;
using MicCheck.Api.Segments;
namespace MicCheck.Api.Environments;
public record EnvironmentDocumentResponse(
int Id,
string ApiKey,
IReadOnlyList<FlagResponse> FeatureStates,
EnvironmentProjectResponse Project);
public record EnvironmentProjectResponse(
int Id,
string Name,
IReadOnlyList<SegmentDocumentResponse> Segments);
public record SegmentDocumentResponse(
int Id,
string Name,
IReadOnlyList<SegmentRuleDocumentResponse> Rules)
{
public static SegmentDocumentResponse From(Segment segment) => new(
segment.Id,
segment.Name,
segment.Rules
.Where(r => r.ParentRuleId == null)
.Select(SegmentRuleDocumentResponse.From)
.ToList());
}
public record SegmentRuleDocumentResponse(
int Id,
string Type,
IReadOnlyList<SegmentConditionDocumentResponse> Conditions,
IReadOnlyList<SegmentRuleDocumentResponse> Rules)
{
public static SegmentRuleDocumentResponse From(SegmentRule rule) => new(
rule.Id,
rule.Type.ToString().ToUpperInvariant(),
rule.Conditions.Select(SegmentConditionDocumentResponse.From).ToList(),
rule.ChildRules.Select(From).ToList());
}
public record SegmentConditionDocumentResponse(string Property, string Operator, string Value)
{
public static SegmentConditionDocumentResponse From(SegmentCondition condition) => new(
condition.Property,
condition.Operator.ToString(),
condition.Value);
}

View File

@@ -0,0 +1,54 @@
using MicCheck.Api.Data;
using MicCheck.Api.Features;
using Microsoft.EntityFrameworkCore;
using AppEnvironment = MicCheck.Api.Environments.Environment;
namespace MicCheck.Api.Environments;
public class EnvironmentDocumentService(MicCheckDbContext db)
{
public async Task<EnvironmentDocumentResponse?> GetAsync(int environmentId, CancellationToken ct = default)
{
var environment = await db.Environments
.FindAsync([environmentId], ct);
if (environment is null)
return null;
var featureStateResults = await (
from fs in db.FeatureStates
join f in db.Features on fs.FeatureId equals f.Id
where fs.EnvironmentId == environmentId
&& fs.IdentityId == null
&& fs.FeatureSegmentId == null
select new FeatureStateResult
{
Feature = f,
Enabled = fs.Enabled,
Value = fs.Value
}
).ToListAsync(ct);
var project = await db.Projects
.Include(p => p.Segments)
.ThenInclude(s => s.Rules)
.ThenInclude(r => r.Conditions)
.Include(p => p.Segments)
.ThenInclude(s => s.Rules)
.ThenInclude(r => r.ChildRules)
.ThenInclude(cr => cr.Conditions)
.FirstOrDefaultAsync(p => p.Id == environment.ProjectId, ct);
if (project is null)
return null;
return new EnvironmentDocumentResponse(
environment.Id,
environment.ApiKey,
featureStateResults.Select(FlagResponse.From).ToList(),
new EnvironmentProjectResponse(
project.Id,
project.Name,
project.Segments.Select(SegmentDocumentResponse.From).ToList()));
}
}

View File

@@ -0,0 +1,139 @@
using MicCheck.Api.Data;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Features;
public class FeatureEvaluationService(
MicCheckDbContext db,
SegmentEvaluator segmentEvaluator,
FlagCache flagCache)
{
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
int environmentId, CancellationToken ct = default)
{
var cached = flagCache.Get(environmentId);
if (cached is not null)
return cached;
var results = await (
from fs in db.FeatureStates
join f in db.Features on fs.FeatureId equals f.Id
where fs.EnvironmentId == environmentId
&& fs.IdentityId == null
&& fs.FeatureSegmentId == null
select new FeatureStateResult
{
Feature = f,
Enabled = fs.Enabled,
Value = fs.Value
}
).ToListAsync(ct);
flagCache.Set(environmentId, results);
return results;
}
public async Task<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
int environmentId,
string identifier,
IReadOnlyList<TraitInput>? traits = null,
CancellationToken ct = default)
{
var environment = await db.Environments.FindAsync([environmentId], ct);
if (environment is null)
return [];
var identity = await new IdentityResolutionService(db)
.ResolveAsync(environmentId, identifier, traits, ct);
var features = await db.Features
.Where(f => f.ProjectId == environment.ProjectId)
.ToListAsync(ct);
var featureIds = features.Select(f => f.Id).ToList();
var allFeatureStates = await db.FeatureStates
.Where(fs => fs.EnvironmentId == environmentId && featureIds.Contains(fs.FeatureId))
.ToListAsync(ct);
var featureSegments = await db.FeatureSegments
.Where(fsg => fsg.EnvironmentId == environmentId && featureIds.Contains(fsg.FeatureId))
.OrderBy(fsg => fsg.Priority)
.ToListAsync(ct);
var segmentIds = featureSegments.Select(fsg => fsg.SegmentId).Distinct().ToList();
var segments = await db.Segments
.Include(s => s.Rules)
.ThenInclude(r => r.Conditions)
.Include(s => s.Rules)
.ThenInclude(r => r.ChildRules)
.ThenInclude(cr => cr.Conditions)
.Where(s => segmentIds.Contains(s.Id))
.ToDictionaryAsync(s => s.Id, ct);
var identityTraits = identity.Traits.ToList();
var results = new List<FeatureStateResult>();
foreach (var feature in features)
{
// 1. Identity-level override
var identityOverride = allFeatureStates.FirstOrDefault(
fs => fs.FeatureId == feature.Id && fs.IdentityId == identity.Id);
if (identityOverride is not null)
{
results.Add(new FeatureStateResult
{
Feature = feature,
Enabled = identityOverride.Enabled,
Value = identityOverride.Value
});
continue;
}
// 2. Segment overrides (highest priority segment that matches)
var segmentOverrideFound = false;
foreach (var fsg in featureSegments.Where(fsg => fsg.FeatureId == feature.Id))
{
if (!segments.TryGetValue(fsg.SegmentId, out var segment))
continue;
if (!segmentEvaluator.Evaluate(segment, identityTraits, identity.Identifier))
continue;
var segmentState = allFeatureStates.FirstOrDefault(
fs => fs.FeatureId == feature.Id && fs.FeatureSegmentId == fsg.Id);
if (segmentState is null)
continue;
results.Add(new FeatureStateResult
{
Feature = feature,
Enabled = segmentState.Enabled,
Value = segmentState.Value
});
segmentOverrideFound = true;
break;
}
if (segmentOverrideFound)
continue;
// 3. Environment default
var envDefault = allFeatureStates.FirstOrDefault(
fs => fs.FeatureId == feature.Id
&& fs.IdentityId == null
&& fs.FeatureSegmentId == null);
if (envDefault is not null)
{
results.Add(new FeatureStateResult
{
Feature = feature,
Enabled = envDefault.Enabled,
Value = envDefault.Value
});
}
}
return results;
}
}

View File

@@ -0,0 +1,7 @@
namespace MicCheck.Api.Features;
public record FeatureSummaryResponse(int Id, string Name, string Type)
{
public static FeatureSummaryResponse From(Feature feature) =>
new(feature.Id, feature.Name, feature.Type.ToString().ToUpperInvariant());
}

View File

@@ -0,0 +1,22 @@
using Microsoft.Extensions.Caching.Memory;
namespace MicCheck.Api.Features;
public class FlagCache(IMemoryCache cache)
{
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(60);
public IReadOnlyList<FeatureStateResult>? Get(int environmentId)
{
cache.TryGetValue(CacheKey(environmentId), out IReadOnlyList<FeatureStateResult>? flags);
return flags;
}
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags)
=> cache.Set(CacheKey(environmentId), flags, CacheDuration);
public void Invalidate(int environmentId)
=> cache.Remove(CacheKey(environmentId));
private static string CacheKey(int environmentId) => $"flags:{environmentId}";
}

View File

@@ -0,0 +1,14 @@
namespace MicCheck.Api.Features;
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);
}

View File

@@ -0,0 +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());
}
}

View File

@@ -0,0 +1,47 @@
using MicCheck.Api.Authorization;
using MicCheck.Api.Features;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MicCheck.Api.Identities;
[ApiController]
[Route("api/v1/identities")]
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
public class IdentitiesController(
FeatureEvaluationService featureEvaluationService,
IdentityResolutionService identityResolutionService) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IdentityResponse>> GetByIdentifier(
[FromQuery] string identifier, CancellationToken ct)
{
if (string.IsNullOrWhiteSpace(identifier))
return BadRequest("identifier query parameter is required.");
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value!);
var identity = await identityResolutionService.ResolveAsync(environmentId, identifier, null, ct);
var flags = await featureEvaluationService.EvaluateForIdentityAsync(environmentId, identifier, null, ct);
return Ok(new IdentityResponse(
identity.Traits.Select(TraitResponse.From).ToList(),
flags.Select(FlagResponse.From).ToList()));
}
[HttpPost]
public async Task<ActionResult<IdentityResponse>> Identify(
[FromBody] IdentityRequest request, CancellationToken ct)
{
var environmentId = int.Parse(User.FindFirst("EnvironmentId")!.Value!);
var identity = await identityResolutionService.ResolveAsync(
environmentId, request.Identifier, request.Traits, ct);
var flags = await featureEvaluationService.EvaluateForIdentityAsync(
environmentId, request.Identifier, request.Traits, ct);
return Ok(new IdentityResponse(
identity.Traits.Select(TraitResponse.From).ToList(),
flags.Select(FlagResponse.From).ToList()));
}
}

View File

@@ -0,0 +1,3 @@
namespace MicCheck.Api.Identities;
public record IdentityRequest(string Identifier, IReadOnlyList<TraitInput>? Traits);

View File

@@ -0,0 +1,58 @@
using MicCheck.Api.Data;
using Microsoft.EntityFrameworkCore;
namespace MicCheck.Api.Identities;
public class IdentityResolutionService(MicCheckDbContext db)
{
public async Task<Identity> ResolveAsync(
int environmentId,
string identifier,
IReadOnlyList<TraitInput>? traits,
CancellationToken ct = default)
{
var identity = await db.Identities
.Include(i => i.Traits)
.FirstOrDefaultAsync(i => i.EnvironmentId == environmentId && i.Identifier == identifier, ct);
if (identity is null)
{
identity = new Identity
{
Identifier = identifier,
EnvironmentId = environmentId,
CreatedAt = DateTimeOffset.UtcNow
};
db.Identities.Add(identity);
await db.SaveChangesAsync(ct);
}
if (traits is { Count: > 0 })
{
foreach (var traitInput in traits)
{
var existing = identity.Traits.FirstOrDefault(t => t.Key == traitInput.TraitKey);
if (existing is not null)
{
existing.Value = traitInput.GetStringValue();
existing.ValueType = traitInput.GetValueType();
}
else
{
var newTrait = new IdentityTrait
{
IdentityId = identity.Id,
Key = traitInput.TraitKey,
Value = traitInput.GetStringValue(),
ValueType = traitInput.GetValueType()
};
db.IdentityTraits.Add(newTrait);
identity.Traits.Add(newTrait);
}
}
await db.SaveChangesAsync(ct);
}
return identity;
}
}

View File

@@ -0,0 +1,7 @@
using MicCheck.Api.Features;
namespace MicCheck.Api.Identities;
public record IdentityResponse(
IReadOnlyList<TraitResponse> Traits,
IReadOnlyList<FlagResponse> Flags);

View File

@@ -0,0 +1,31 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace MicCheck.Api.Identities;
public record TraitInput(
[property: JsonPropertyName("trait_key")] string TraitKey,
[property: JsonPropertyName("trait_value")] object? TraitValue)
{
public string GetStringValue() => TraitValue switch
{
JsonElement el when el.ValueKind == JsonValueKind.String => el.GetString() ?? string.Empty,
JsonElement el when el.ValueKind == JsonValueKind.True => "true",
JsonElement el when el.ValueKind == JsonValueKind.False => "false",
JsonElement el when el.ValueKind == JsonValueKind.Null => string.Empty,
JsonElement el => el.ToString(),
null => string.Empty,
_ => TraitValue.ToString() ?? string.Empty
};
public TraitValueType GetValueType() => TraitValue switch
{
JsonElement el when el.ValueKind is JsonValueKind.True or JsonValueKind.False => TraitValueType.Boolean,
JsonElement el when el.ValueKind == JsonValueKind.Number && el.TryGetInt64(out _) => TraitValueType.Integer,
JsonElement el when el.ValueKind == JsonValueKind.Number => TraitValueType.Float,
bool => TraitValueType.Boolean,
int or long => TraitValueType.Integer,
float or double or decimal => TraitValueType.Float,
_ => TraitValueType.String
};
}

View File

@@ -0,0 +1,7 @@
namespace MicCheck.Api.Identities;
public record TraitResponse(string TraitKey, string TraitValue)
{
public static TraitResponse From(IdentityTrait trait) =>
new(trait.Key, trait.Value);
}

View File

@@ -12,6 +12,12 @@
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Scalar.AspNetCore" Version="2.6.0" />
<PackageReference Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.16.0" />

View File

@@ -2,10 +2,23 @@ using System.Text;
using System.Threading.RateLimiting;
using FluentValidation;
using FluentValidation.AspNetCore;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using MicCheck.Api.ApiKeys;
using MicCheck.Api.Auth;
using MicCheck.Api.Authentication;
using MicCheck.Api.Authorization;
using MicCheck.Api.Data;
using MicCheck.Api.Environments;
using MicCheck.Api.Features;
using MicCheck.Api.Identities;
using MicCheck.Api.Segments;
using MicCheck.Api.Users;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using Scalar.AspNetCore;
using Serilog;
@@ -25,8 +38,12 @@ try
builder.Services.AddOpenApi();
builder.Services.AddControllers();
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
builder.Services.AddAuthentication()
.AddScheme<AuthenticationSchemeOptions, EnvironmentKeyAuthenticationHandler>(
EnvironmentKeyAuthenticationHandler.SchemeName, _ => { })
.AddScheme<AuthenticationSchemeOptions, ApiKeyAuthenticationHandler>(
ApiKeyAuthenticationHandler.SchemeName, _ => { })
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
@@ -40,7 +57,22 @@ try
};
});
builder.Services.AddAuthorization();
builder.Services.AddAuthorization(options =>
{
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
policy.AddAuthenticationSchemes(EnvironmentKeyAuthenticationHandler.SchemeName)
.RequireClaim("EnvironmentId"));
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireAuthenticatedUser());
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
policy.AddAuthenticationSchemes(ApiKeyAuthenticationHandler.SchemeName, JwtBearerDefaults.AuthenticationScheme)
.RequireClaim("OrganizationRole", "Admin"));
});
builder.Services.AddHttpContextAccessor();
builder.Services.AddRateLimiter(options =>
options.AddFixedWindowLimiter("AdminApi", limiter =>
@@ -54,7 +86,26 @@ try
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddMemoryCache();
builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<ApiKeyService>();
builder.Services.AddScoped<DatabaseSeeder>();
builder.Services.AddScoped<IPasswordHasher<User>, PasswordHasher<User>>();
builder.Services.AddScoped<IAuthorizationHandler, ProjectPermissionRequirementHandler>();
builder.Services.AddScoped<FeatureEvaluationService>();
builder.Services.AddScoped<IdentityResolutionService>();
builder.Services.AddScoped<EnvironmentDocumentService>();
builder.Services.AddSingleton<SegmentEvaluator>();
builder.Services.AddSingleton<FlagCache>();
var connectionString = System.Environment.GetEnvironmentVariable("DATABASE_URL") is { } databaseUrl
? DatabaseUrlParser.ToNpgsqlConnectionString(databaseUrl)
: builder.Configuration.GetConnectionString("DefaultConnection")!;
builder.Services.AddDbContext<MicCheckDbContext>(options =>
options.UseNpgsql(connectionString));
var app = builder.Build();
@@ -62,6 +113,10 @@ try
{
app.MapOpenApi();
app.MapScalarApiReference();
using var scope = app.Services.CreateScope();
var seeder = scope.ServiceProvider.GetRequiredService<DatabaseSeeder>();
await seeder.SeedAsync();
}
app.UseSerilogRequestLogging();
@@ -71,6 +126,7 @@ try
app.MapControllers();
app.MapAuthEndpoints();
app.MapApiKeyEndpoints();
app.Run();
}

View File

@@ -0,0 +1,113 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using MicCheck.Api.Identities;
namespace MicCheck.Api.Segments;
public class SegmentEvaluator
{
public bool Evaluate(Segment segment, IReadOnlyList<IdentityTrait> traits, string identifier = "")
{
var topLevelRules = segment.Rules.Where(r => r.ParentRuleId == null).ToList();
return topLevelRules.All(rule => EvaluateRule(rule, traits, identifier, segment.Id));
}
private bool EvaluateRule(
SegmentRule rule,
IReadOnlyList<IdentityTrait> traits,
string identifier,
int segmentId)
{
var conditionResults = rule.Conditions
.Select(c => EvaluateCondition(c, traits, identifier, segmentId))
.ToList();
bool conditionsPass = rule.Type switch
{
SegmentRuleType.All => conditionResults.Count == 0 || conditionResults.All(r => r),
SegmentRuleType.Any => conditionResults.Count > 0 && conditionResults.Any(r => r),
SegmentRuleType.None => conditionResults.Count == 0 || conditionResults.All(r => !r),
_ => false
};
if (!conditionsPass)
return false;
return rule.ChildRules.All(child => EvaluateRule(child, traits, identifier, segmentId));
}
private bool EvaluateCondition(
SegmentCondition condition,
IReadOnlyList<IdentityTrait> traits,
string identifier,
int segmentId)
{
var trait = traits.FirstOrDefault(t => t.Key == condition.Property);
return condition.Operator switch
{
SegmentConditionOperator.IsSet => trait is not null,
SegmentConditionOperator.IsNotSet => trait is null,
SegmentConditionOperator.PercentageSplit => EvaluatePercentageSplit(condition.Value, identifier, segmentId),
_ => trait is not null && EvaluateTraitCondition(condition, trait.Value)
};
}
private static bool EvaluateTraitCondition(SegmentCondition condition, string traitValue)
{
return condition.Operator switch
{
SegmentConditionOperator.Equal => traitValue.Equals(condition.Value, StringComparison.OrdinalIgnoreCase),
SegmentConditionOperator.NotEqual => !traitValue.Equals(condition.Value, StringComparison.OrdinalIgnoreCase),
SegmentConditionOperator.Contains => traitValue.Contains(condition.Value, StringComparison.OrdinalIgnoreCase),
SegmentConditionOperator.NotContains => !traitValue.Contains(condition.Value, StringComparison.OrdinalIgnoreCase),
SegmentConditionOperator.Regex => Regex.IsMatch(traitValue, condition.Value),
SegmentConditionOperator.GreaterThan => CompareNumeric(traitValue, condition.Value) > 0,
SegmentConditionOperator.GreaterThanOrEqual => CompareNumeric(traitValue, condition.Value) >= 0,
SegmentConditionOperator.LessThan => CompareNumeric(traitValue, condition.Value) < 0,
SegmentConditionOperator.LessThanOrEqual => CompareNumeric(traitValue, condition.Value) <= 0,
SegmentConditionOperator.In => condition.Value.Split(',').Any(v => v.Trim().Equals(traitValue, StringComparison.OrdinalIgnoreCase)),
SegmentConditionOperator.NotIn => !condition.Value.Split(',').Any(v => v.Trim().Equals(traitValue, StringComparison.OrdinalIgnoreCase)),
SegmentConditionOperator.IsTrue => traitValue.Equals("true", StringComparison.OrdinalIgnoreCase),
SegmentConditionOperator.IsFalse => traitValue.Equals("false", StringComparison.OrdinalIgnoreCase),
SegmentConditionOperator.ModuloValueDivisorRemainder => EvaluateModulo(traitValue, condition.Value),
_ => false
};
}
private static int CompareNumeric(string traitValue, string conditionValue)
{
if (!decimal.TryParse(traitValue, out var trait) ||
!decimal.TryParse(conditionValue, out var condition))
return 0;
return trait.CompareTo(condition);
}
private static bool EvaluateModulo(string traitValue, string conditionValue)
{
var parts = conditionValue.Split('|');
if (parts.Length != 2)
return false;
if (!long.TryParse(traitValue, out var value) ||
!long.TryParse(parts[0], out var divisor) ||
!long.TryParse(parts[1], out var remainder))
return false;
if (divisor == 0)
return false;
return value % divisor == remainder;
}
private static bool EvaluatePercentageSplit(string conditionValue, string identifier, int segmentId)
{
if (!decimal.TryParse(conditionValue, out var percentage))
return false;
var input = $"{identifier}{segmentId}";
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(input));
var hashValue = BitConverter.ToUInt32(hashBytes, 0);
var bucket = (hashValue % 9999m) / 99.99m;
return bucket < percentage;
}
}

View File

@@ -0,0 +1,12 @@
namespace MicCheck.Api.Users;
public class RefreshToken
{
public int Id { get; init; }
public required string Token { get; init; }
public int UserId { get; init; }
public User User { get; init; } = null!;
public DateTimeOffset ExpiresAt { get; init; }
public bool IsRevoked { get; set; }
public DateTimeOffset CreatedAt { get; init; }
}