Building out navigation in the admin site
This commit is contained in:
104
docs/api/01_project_setup.md
Normal file
104
docs/api/01_project_setup.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Step 1: Project Setup & Solution Structure
|
||||
|
||||
## Goal
|
||||
Establish the solution structure, project references, NuGet packages, and foundational infrastructure for the MicCheck C# port of Flagsmith.
|
||||
|
||||
## Solution Layout
|
||||
|
||||
```
|
||||
mic-check.sln
|
||||
├── src/
|
||||
│ ├── MicCheck.Api/ # ASP.NET Core Web API — domain, services, and controllers
|
||||
│ │ ├── Features/ # Feature flag domain, services, controllers, DTOs
|
||||
│ │ ├── Environments/ # Environment domain, services, controllers, DTOs
|
||||
│ │ ├── Projects/ # Project domain, services, controllers, DTOs
|
||||
│ │ ├── Organizations/ # Organization domain, services, controllers, DTOs
|
||||
│ │ ├── Segments/ # Segment domain, services, controllers, DTOs
|
||||
│ │ ├── Identities/ # Identity domain, services, controllers, DTOs
|
||||
│ │ ├── Audit/ # Audit log domain, service, controller, DTOs
|
||||
│ │ ├── Webhooks/ # Webhook domain, service, controller, DTOs
|
||||
│ │ ├── ApiKeys/ # API key domain, service, DTOs
|
||||
│ │ └── Authentication/ # Auth handlers and policies
|
||||
│ └── MicCheck.Infrastructure/ # EF Core DbContext, entity configurations, migrations
|
||||
└── tests/
|
||||
├── MicCheck.Api.Tests/
|
||||
└── MicCheck.Infrastructure.Tests/
|
||||
```
|
||||
|
||||
Each feature folder in `MicCheck.Api` contains everything for that area:
|
||||
- Domain model(s) — e.g. `Feature.cs`, `FeatureState.cs`
|
||||
- Service(s) — e.g. `FeatureService.cs`
|
||||
- Controller(s) — e.g. `FeaturesController.cs`
|
||||
- Request/Response DTOs — e.g. `CreateFeatureRequest.cs`, `FeatureResponse.cs`
|
||||
|
||||
## Project Configurations
|
||||
|
||||
### All `.csproj` files must include:
|
||||
```xml
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
## MicCheck.Api
|
||||
|
||||
**Purpose:** The entire application — domain models, business logic services, and HTTP controllers all live here, organized by feature area.
|
||||
|
||||
**NuGet packages:**
|
||||
- `Microsoft.AspNetCore.Authentication.JwtBearer` (latest)
|
||||
- `Swashbuckle.AspNetCore` (latest)
|
||||
- `Serilog.AspNetCore` (latest)
|
||||
- `FluentValidation.AspNetCore` (latest)
|
||||
|
||||
**Project reference:**
|
||||
- References `MicCheck.Infrastructure`
|
||||
|
||||
## MicCheck.Infrastructure
|
||||
|
||||
**Purpose:** EF Core DbContext, entity configurations, and PostgreSQL migrations only. No business logic.
|
||||
|
||||
**NuGet packages:**
|
||||
- `Microsoft.EntityFrameworkCore` (latest for net10.0)
|
||||
- `Npgsql.EntityFrameworkCore.PostgreSQL` (latest)
|
||||
- `Microsoft.EntityFrameworkCore.Design` (latest)
|
||||
|
||||
**Key namespaces:**
|
||||
- `MicCheck.Infrastructure.Data` — DbContext, entity configurations, migrations
|
||||
|
||||
## Test Projects
|
||||
|
||||
**NuGet packages (all test projects):**
|
||||
- `NUnit` (latest)
|
||||
- `NUnit3TestAdapter` (latest)
|
||||
- `Moq` (latest)
|
||||
- `Microsoft.NET.Test.Sdk` (latest)
|
||||
- `coverlet.collector` (latest)
|
||||
|
||||
**Project references:**
|
||||
- `MicCheck.Api.Tests` references `MicCheck.Api` and `MicCheck.Infrastructure`
|
||||
- `MicCheck.Infrastructure.Tests` references `MicCheck.Infrastructure`
|
||||
|
||||
## Docker Compose (development)
|
||||
|
||||
Services required:
|
||||
- `postgres:15-alpine` — primary database, port 5432
|
||||
- `mic-check-api` — the ASP.NET Core API, port 8080
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```
|
||||
DATABASE_URL=postgresql://miccheck:password@localhost:5432/miccheck
|
||||
JWT_SECRET=<secret>
|
||||
ADMIN_API_KEY=<key>
|
||||
```
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Solution builds with `dotnet build` with zero warnings
|
||||
- [ ] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
|
||||
- [ ] Docker Compose brings up API and database cleanly
|
||||
- [ ] `dotnet test` runs successfully (no tests yet, but harness works)
|
||||
- [ ] Swagger UI is accessible at `/swagger`
|
||||
67
docs/api/01_project_setup_output.md
Normal file
67
docs/api/01_project_setup_output.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# Step 1 Implementation Output
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Solution Structure
|
||||
|
||||
The solution (`MicCheck.slnx`) now contains four projects:
|
||||
|
||||
| Project | Path |
|
||||
|---|---|
|
||||
| `MicCheck.Api` | `src/api/MicCheck.Api/` |
|
||||
| `MicCheck.Infrastructure` | `src/infrastructure/MicCheck.Infrastructure/` |
|
||||
| `MicCheck.Api.Tests.Unit` | `tests/api/MicCheck.Api.Tests.Unit/` |
|
||||
| `MicCheck.Infrastructure.Tests` | `tests/infrastructure/MicCheck.Infrastructure.Tests/` |
|
||||
|
||||
### Projects Created
|
||||
|
||||
**`MicCheck.Infrastructure`** — new class library targeting net10.0 with:
|
||||
- `Microsoft.EntityFrameworkCore` 10.0.5
|
||||
- `Npgsql.EntityFrameworkCore.PostgreSQL` 10.0.1
|
||||
- `Microsoft.EntityFrameworkCore.Design` 10.0.5
|
||||
- Folder structure: `Data/Configurations/`, `Data/Migrations/`
|
||||
|
||||
**`MicCheck.Infrastructure.Tests`** — new NUnit test project targeting net10.0 with:
|
||||
- `NUnit` 4.3.2, `NUnit3TestAdapter` 5.0.0, `NUnit.Analyzers` 4.7.0
|
||||
- `Moq` 4.20.72, `coverlet.collector` 6.0.4
|
||||
- Project reference to `MicCheck.Infrastructure`
|
||||
|
||||
### Projects Updated
|
||||
|
||||
**`MicCheck.Api`** — added:
|
||||
- `TreatWarningsAsErrors`
|
||||
- `Serilog.AspNetCore` 9.0.0
|
||||
- `FluentValidation.AspNetCore` 11.3.0
|
||||
- Project reference to `MicCheck.Infrastructure`
|
||||
- `Program.cs` updated: Serilog bootstrap logger, `AddControllers()`, `AddFluentValidationAutoValidation()`, `AddRateLimiter("AdminApi")`, `MapControllers()`
|
||||
- `appsettings.json` updated: Serilog config section, `ConnectionStrings:DefaultConnection`
|
||||
|
||||
**`MicCheck.Api.Tests.Unit`** — added:
|
||||
- `TreatWarningsAsErrors`
|
||||
- `coverlet.collector` 6.0.4
|
||||
- Project reference to `MicCheck.Infrastructure`
|
||||
|
||||
### Feature Folders Created in MicCheck.Api
|
||||
|
||||
`Features/`, `Environments/`, `Projects/`, `Organizations/`, `Segments/`, `Identities/`, `Audit/`, `Webhooks/`, `ApiKeys/`, `Users/`
|
||||
|
||||
Existing `Auth/` folder retained (equivalent to `Authentication/` in the plan).
|
||||
|
||||
### Files Added
|
||||
|
||||
- `docker-compose.yml` — PostgreSQL 15-alpine + API service with health check
|
||||
- `src/api/MicCheck.Api/Dockerfile` — multi-stage build (SDK → publish → runtime)
|
||||
|
||||
### Deviations from Plan
|
||||
|
||||
- **Scalar instead of Swashbuckle**: The project already used `Scalar.AspNetCore` + `Microsoft.AspNetCore.OpenApi`. This is the recommended approach for .NET 10 and was retained. The API reference UI is at `/scalar` rather than `/swagger`.
|
||||
- **`Auth/` folder**: The existing auth code lives in `Auth/` rather than `Authentication/` as named in the plan. The existing code was not renamed.
|
||||
- **NUnit template version**: `MicCheck.Infrastructure.Tests` was created with `dotnet new nunit` which brought in NUnit 4.3.2 / NUnit3TestAdapter 5.0.0 (slightly different patch versions from the Api test project). Both are current stable releases.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] Solution builds with `dotnet build` with zero warnings
|
||||
- [x] All projects target net10.0 with `LangVersion=latest` and `Nullable=enable`
|
||||
- [x] `dotnet test` runs successfully — 8 tests pass across 2 test projects
|
||||
- [x] API reference UI accessible at `/scalar` (Scalar, not Swagger)
|
||||
- [ ] Docker Compose brings up API and database cleanly — requires Docker; not verified in this step
|
||||
332
docs/api/02_core_domain_models.md
Normal file
332
docs/api/02_core_domain_models.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# Step 2: Domain Models
|
||||
|
||||
## Goal
|
||||
Define all domain entities in `MicCheck.Api`, organized by feature area. Each entity lives in the folder for the feature it belongs to.
|
||||
|
||||
## Constraints (from Flagsmith spec)
|
||||
- Max 400 features per project
|
||||
- Max 100 segments per project
|
||||
- Max 100 segment overrides per environment
|
||||
- Max 100 segment rule conditions
|
||||
- Feature flag values: max 20,000 bytes
|
||||
- Identity trait values: max 2,000 bytes
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Organizations/
|
||||
|
||||
```csharp
|
||||
// Organizations/Organization.cs
|
||||
public class Organization
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<Project> Projects { get; init; } = [];
|
||||
public ICollection<OrganizationUser> Members { get; init; } = [];
|
||||
public ICollection<ApiKey> ApiKeys { get; init; } = [];
|
||||
}
|
||||
|
||||
// Organizations/OrganizationUser.cs
|
||||
public class OrganizationUser
|
||||
{
|
||||
public int OrganizationId { get; init; }
|
||||
public int UserId { get; init; }
|
||||
public OrganizationRole Role { get; set; }
|
||||
}
|
||||
|
||||
public enum OrganizationRole { Admin, User }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Projects/
|
||||
|
||||
```csharp
|
||||
// Projects/Project.cs
|
||||
public class Project
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public int OrganizationId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public bool HideDisabledFlags { get; set; }
|
||||
public ICollection<Environment> Environments { get; init; } = [];
|
||||
public ICollection<Feature> Features { get; init; } = [];
|
||||
public ICollection<Segment> Segments { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Environments/
|
||||
|
||||
```csharp
|
||||
// Environments/Environment.cs
|
||||
public class Environment
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required string ApiKey { get; set; } // X-Environment-Key header value
|
||||
public int ProjectId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Features/
|
||||
|
||||
```csharp
|
||||
// Features/Feature.cs
|
||||
public class Feature
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public FeatureType Type { get; set; }
|
||||
public string? InitialValue { get; set; } // max 20,000 bytes
|
||||
public string? Description { get; set; }
|
||||
public bool DefaultEnabled { get; set; }
|
||||
public int ProjectId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<FeatureState> FeatureStates { get; init; } = [];
|
||||
public ICollection<Tag> Tags { get; init; } = [];
|
||||
}
|
||||
|
||||
public enum FeatureType { Standard, MultiVariate }
|
||||
|
||||
// Features/FeatureState.cs
|
||||
// Represents the value of a feature within a specific environment
|
||||
public class FeatureState
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int FeatureId { get; init; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public int? IdentityId { get; set; } // null = environment-level
|
||||
public bool Enabled { get; set; }
|
||||
public string? Value { get; set; } // max 20,000 bytes
|
||||
public int? FeatureSegmentId { get; set; } // null = not a segment override
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
public int Version { get; set; } // optimistic concurrency
|
||||
}
|
||||
|
||||
// Features/FeatureSegment.cs
|
||||
// Links a feature to a segment with an override priority
|
||||
public class FeatureSegment
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int FeatureId { get; init; }
|
||||
public int SegmentId { get; init; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public int Priority { get; set; }
|
||||
public FeatureState? FeatureState { get; set; }
|
||||
}
|
||||
|
||||
// Features/Tag.cs
|
||||
public class Tag
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Label { get; set; }
|
||||
public required string Color { get; set; }
|
||||
public int ProjectId { get; init; }
|
||||
}
|
||||
|
||||
// Features/FeatureStateResult.cs
|
||||
// Value object returned by FeatureEvaluationService — never exposed directly via API
|
||||
public class FeatureStateResult
|
||||
{
|
||||
public required Feature Feature { get; init; }
|
||||
public bool Enabled { get; init; }
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Segments/
|
||||
|
||||
```csharp
|
||||
// Segments/Segment.cs
|
||||
public class Segment
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public int ProjectId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<SegmentRule> Rules { get; init; } = [];
|
||||
}
|
||||
|
||||
// Segments/SegmentRule.cs
|
||||
public class SegmentRule
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int SegmentId { get; init; }
|
||||
public int? ParentRuleId { get; set; } // allows nested AND/OR
|
||||
public SegmentRuleType Type { get; set; }
|
||||
public ICollection<SegmentCondition> Conditions { get; init; } = [];
|
||||
public ICollection<SegmentRule> ChildRules { get; init; } = [];
|
||||
}
|
||||
|
||||
public enum SegmentRuleType { All, Any, None }
|
||||
|
||||
// Segments/SegmentCondition.cs
|
||||
public class SegmentCondition
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int RuleId { get; init; }
|
||||
public required string Property { get; set; } // trait key
|
||||
public SegmentConditionOperator Operator { get; set; }
|
||||
public required string Value { get; set; }
|
||||
}
|
||||
|
||||
public enum SegmentConditionOperator
|
||||
{
|
||||
Equal, NotEqual,
|
||||
GreaterThan, GreaterThanOrEqual,
|
||||
LessThan, LessThanOrEqual,
|
||||
Contains, NotContains,
|
||||
Regex,
|
||||
IsSet, IsNotSet,
|
||||
In, NotIn,
|
||||
PercentageSplit,
|
||||
ModuloValueDivisorRemainder,
|
||||
IsTrue, IsFalse
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Identities/
|
||||
|
||||
```csharp
|
||||
// Identities/Identity.cs
|
||||
// Represents an end-user or SDK client
|
||||
public class Identity
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Identifier { get; set; }
|
||||
public int EnvironmentId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public ICollection<IdentityTrait> Traits { get; init; } = [];
|
||||
public ICollection<FeatureState> FeatureStateOverrides { get; init; } = [];
|
||||
}
|
||||
|
||||
// Identities/IdentityTrait.cs
|
||||
public class IdentityTrait
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int IdentityId { get; init; }
|
||||
public required string Key { get; set; }
|
||||
public required string Value { get; set; } // max 2,000 bytes
|
||||
public TraitValueType ValueType { get; set; }
|
||||
}
|
||||
|
||||
public enum TraitValueType { String, Integer, Float, Boolean }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Audit/
|
||||
|
||||
```csharp
|
||||
// Audit/AuditLog.cs
|
||||
public class AuditLog
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string ResourceType { get; init; }
|
||||
public required string ResourceId { get; init; }
|
||||
public required string Action { get; init; } // Created, Updated, Deleted
|
||||
public string? Changes { get; init; } // JSON diff
|
||||
public int OrganizationId { get; init; }
|
||||
public int? ProjectId { get; init; }
|
||||
public int? EnvironmentId { get; init; }
|
||||
public int? ActorUserId { get; init; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Webhooks/
|
||||
|
||||
```csharp
|
||||
// Webhooks/Webhook.cs
|
||||
public class Webhook
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Url { get; set; }
|
||||
public string? Secret { get; set; }
|
||||
public WebhookScope Scope { get; set; }
|
||||
public int? EnvironmentId { get; set; }
|
||||
public int? OrganizationId { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
}
|
||||
|
||||
public enum WebhookScope { Environment, Organization }
|
||||
|
||||
// Webhooks/WebhookDeliveryLog.cs
|
||||
public class WebhookDeliveryLog
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public int WebhookId { get; init; }
|
||||
public required string EventType { get; init; }
|
||||
public required string PayloadJson { get; init; }
|
||||
public int? ResponseStatusCode { get; set; }
|
||||
public string? ResponseBody { get; set; }
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public DateTimeOffset AttemptedAt { get; init; }
|
||||
public TimeSpan Duration { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/ApiKeys/
|
||||
|
||||
```csharp
|
||||
// ApiKeys/ApiKey.cs
|
||||
public class ApiKey
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Key { get; set; } // hashed
|
||||
public required string Prefix { get; set; } // first 8 chars for display
|
||||
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; }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MicCheck.Api/Users/ (referenced by auth)
|
||||
|
||||
```csharp
|
||||
// Users/User.cs
|
||||
public class User
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Email { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required string FirstName { get; set; }
|
||||
public required string LastName { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsTwoFactorEnabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public ICollection<OrganizationUser> Organizations { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All domain models compile within `MicCheck.Api` with no cross-project domain dependencies
|
||||
- [ ] Nullable reference types enabled — all required properties explicitly marked
|
||||
- [ ] No domain logic in DTOs — domain models and request/response types are distinct classes
|
||||
- [ ] Unit tests validate domain constraints (e.g., feature count limits enforced in service logic)
|
||||
72
docs/api/02_core_domain_models_output.md
Normal file
72
docs/api/02_core_domain_models_output.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Step 2 Implementation Output
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
### Domain Models Created
|
||||
|
||||
19 files added to `MicCheck.Api`, each in its feature area folder:
|
||||
|
||||
| File | Namespace |
|
||||
|---|---|
|
||||
| `Organizations/Organization.cs` | `MicCheck.Api.Organizations` |
|
||||
| `Organizations/OrganizationUser.cs` | `MicCheck.Api.Organizations` |
|
||||
| `Projects/Project.cs` | `MicCheck.Api.Projects` |
|
||||
| `Environments/Environment.cs` | `MicCheck.Api.Environments` |
|
||||
| `Features/Feature.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/FeatureState.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/FeatureSegment.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/Tag.cs` | `MicCheck.Api.Features` |
|
||||
| `Features/FeatureStateResult.cs` | `MicCheck.Api.Features` |
|
||||
| `Segments/Segment.cs` | `MicCheck.Api.Segments` |
|
||||
| `Segments/SegmentRule.cs` | `MicCheck.Api.Segments` |
|
||||
| `Segments/SegmentCondition.cs` | `MicCheck.Api.Segments` |
|
||||
| `Identities/Identity.cs` | `MicCheck.Api.Identities` |
|
||||
| `Identities/IdentityTrait.cs` | `MicCheck.Api.Identities` |
|
||||
| `Audit/AuditLog.cs` | `MicCheck.Api.Audit` |
|
||||
| `Webhooks/Webhook.cs` | `MicCheck.Api.Webhooks` |
|
||||
| `Webhooks/WebhookDeliveryLog.cs` | `MicCheck.Api.Webhooks` |
|
||||
| `ApiKeys/ApiKey.cs` | `MicCheck.Api.ApiKeys` |
|
||||
| `Users/User.cs` | `MicCheck.Api.Users` |
|
||||
|
||||
### Enums Defined
|
||||
|
||||
| Enum | Values | File |
|
||||
|---|---|---|
|
||||
| `OrganizationRole` | `User`, `Admin` | `OrganizationUser.cs` |
|
||||
| `FeatureType` | `Standard`, `MultiVariate` | `Feature.cs` |
|
||||
| `SegmentRuleType` | `All`, `Any`, `None` | `SegmentRule.cs` |
|
||||
| `SegmentConditionOperator` | 17 values | `SegmentCondition.cs` |
|
||||
| `TraitValueType` | `String`, `Integer`, `Float`, `Boolean` | `IdentityTrait.cs` |
|
||||
| `WebhookScope` | `Environment`, `Organization` | `Webhook.cs` |
|
||||
|
||||
### Tests Created
|
||||
|
||||
10 test files covering all domain areas, 51 new tests (58 total in the suite):
|
||||
|
||||
| File | Tests |
|
||||
|---|---|
|
||||
| `Organizations/OrganizationTests.cs` | 6 |
|
||||
| `Projects/ProjectTests.cs` | 3 |
|
||||
| `Environments/EnvironmentTests.cs` | 2 |
|
||||
| `Features/FeatureTests.cs` | 12 |
|
||||
| `Segments/SegmentTests.cs` | 7 |
|
||||
| `Identities/IdentityTests.cs` | 5 |
|
||||
| `Audit/AuditLogTests.cs` | 2 |
|
||||
| `Webhooks/WebhookTests.cs` | 6 |
|
||||
| `ApiKeys/ApiKeyTests.cs` | 3 |
|
||||
| `Users/UserTests.cs` | 4 |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- **`OrganizationRole` enum ordering**: The plan declared `{ Admin, User }` making `Admin` the zero-value default. Reordered to `{ User, Admin }` so that an `OrganizationUser` created without an explicit role safely defaults to `User` rather than `Admin`.
|
||||
|
||||
- **`System.Environment` collision**: `MicCheck.Api.Environments.Environment` conflicts with `System.Environment` (pulled in via implicit usings). Resolved with `using AppEnvironment = MicCheck.Api.Environments.Environment;` in `Projects/Project.cs` and the corresponding test file. The class name `Environment` was kept as specified in the plan.
|
||||
|
||||
- **`using NUnit.Framework;`**: The existing `MicCheck.Api.Tests.Unit` project does not configure `NUnit.Framework` as a global using (unlike the `MicCheck.Infrastructure.Tests` template). All new test files include the using directive explicitly.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [x] All domain models compile with zero warnings
|
||||
- [x] Nullable reference types enabled — all required properties explicitly marked with `required`
|
||||
- [x] No domain logic in DTOs — models are plain C# classes with no service dependencies
|
||||
- [x] `dotnet test` — 59 tests pass, 0 failures
|
||||
195
docs/api/03_database_and_repositories.md
Normal file
195
docs/api/03_database_and_repositories.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# Step 3: Database Schema & EF Core
|
||||
|
||||
## Goal
|
||||
Implement the EF Core `DbContext` and entity configurations in `MicCheck.Infrastructure`, and wire up services in `MicCheck.Api` to use it directly. There is no repository abstraction layer — services inject `MicCheckDbContext` and query it with LINQ.
|
||||
|
||||
---
|
||||
|
||||
## DbContext
|
||||
|
||||
```csharp
|
||||
// MicCheck.Infrastructure/Data/MicCheckDbContext.cs
|
||||
public class MicCheckDbContext : DbContext
|
||||
{
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Environment> Environments => Set<Environment>();
|
||||
public DbSet<Feature> Features => Set<Feature>();
|
||||
public DbSet<FeatureState> FeatureStates => Set<FeatureState>();
|
||||
public DbSet<FeatureSegment> FeatureSegments => Set<FeatureSegment>();
|
||||
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<Tag> Tags => Set<Tag>();
|
||||
public DbSet<User> Users => Set<User>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
=> modelBuilder.ApplyConfigurationsFromAssembly(typeof(MicCheckDbContext).Assembly);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Entity Configurations (EF Core Fluent API)
|
||||
|
||||
Each domain entity gets its own `IEntityTypeConfiguration<T>` in `MicCheck.Infrastructure/Data/Configurations/`:
|
||||
|
||||
### Key configurations:
|
||||
|
||||
**Feature:**
|
||||
```csharp
|
||||
entity.Property(f => f.Name).HasMaxLength(150).IsRequired();
|
||||
entity.Property(f => f.InitialValue).HasMaxLength(20_000);
|
||||
entity.HasIndex(f => new { f.ProjectId, f.Name }).IsUnique();
|
||||
```
|
||||
|
||||
**FeatureState:**
|
||||
```csharp
|
||||
entity.Property(fs => fs.Value).HasMaxLength(20_000);
|
||||
entity.Property(fs => fs.Version).IsConcurrencyToken();
|
||||
entity.HasIndex(fs => new { fs.FeatureId, fs.EnvironmentId, fs.IdentityId }).IsUnique();
|
||||
```
|
||||
|
||||
**IdentityTrait:**
|
||||
```csharp
|
||||
entity.Property(t => t.Value).HasMaxLength(2_000);
|
||||
entity.HasIndex(t => new { t.IdentityId, t.Key }).IsUnique();
|
||||
```
|
||||
|
||||
**Environment:**
|
||||
```csharp
|
||||
entity.Property(e => e.ApiKey).HasMaxLength(100).IsRequired();
|
||||
entity.HasIndex(e => e.ApiKey).IsUnique();
|
||||
```
|
||||
|
||||
**ApiKey:**
|
||||
```csharp
|
||||
entity.Property(k => k.Key).HasMaxLength(512).IsRequired(); // stores hash
|
||||
entity.Property(k => k.Prefix).HasMaxLength(8).IsRequired();
|
||||
entity.HasIndex(k => k.Key).IsUnique();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Usage Pattern
|
||||
|
||||
Services receive `MicCheckDbContext` via constructor injection and query it directly:
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureService.cs
|
||||
public class FeatureService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
public FeatureService(MicCheckDbContext db, AuditService auditService)
|
||||
{
|
||||
_db = db;
|
||||
_auditService = auditService;
|
||||
}
|
||||
|
||||
public async Task<Feature> CreateAsync(
|
||||
int projectId, string name, FeatureType type, string? initialValue,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var count = await _db.Features.CountAsync(f => f.ProjectId == projectId, ct);
|
||||
if (count >= 400)
|
||||
throw new DomainException("Projects may not have more than 400 features.");
|
||||
|
||||
var feature = new Feature { ProjectId = projectId, Name = name, Type = type, InitialValue = initialValue };
|
||||
_db.Features.Add(feature);
|
||||
|
||||
// Auto-create FeatureState for every existing environment
|
||||
var environments = await _db.Environments
|
||||
.Where(e => e.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var env in environments)
|
||||
_db.FeatureStates.Add(new FeatureState { FeatureId = feature.Id, EnvironmentId = env.Id });
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
await _auditService.RecordAsync("Feature", feature.Id.ToString(), "Created", ...);
|
||||
return feature;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Controllers call the service and map the returned domain object to a response DTO:
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeaturesController.cs
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<FeatureResponse>> Create(
|
||||
int projectId, CreateFeatureRequest request, CancellationToken ct)
|
||||
{
|
||||
var feature = await _featureService.CreateAsync(
|
||||
projectId, request.Name, request.Type, request.InitialValue, ct);
|
||||
|
||||
return CreatedAtAction(nameof(GetById), new { projectId, id = feature.Id },
|
||||
FeatureResponse.From(feature));
|
||||
}
|
||||
```
|
||||
|
||||
The static `From` method (or a mapping method on the controller) is the only place a domain object becomes a DTO.
|
||||
|
||||
---
|
||||
|
||||
## Migrations
|
||||
|
||||
- Use EF Core code-first migrations
|
||||
- Migration naming: `{Timestamp}_{DescriptiveName}` (e.g., `20260101_InitialSchema`)
|
||||
- All migrations in `MicCheck.Infrastructure/Data/Migrations/`
|
||||
- Run via `dotnet ef migrations add` and `dotnet ef database update`
|
||||
- The `--project MicCheck.Infrastructure --startup-project MicCheck.Api` flags are required since the DbContext is in Infrastructure
|
||||
|
||||
---
|
||||
|
||||
## Connection String Configuration
|
||||
|
||||
```json
|
||||
// appsettings.json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=miccheck;Username=miccheck;Password=password"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Support `DATABASE_URL` environment variable override (Heroku/Railway style parsing).
|
||||
|
||||
---
|
||||
|
||||
## Seed Data
|
||||
|
||||
Create an initial migration seeder for local development:
|
||||
- Default organization: "Default"
|
||||
- Default project: "My Project"
|
||||
- Default environments: "Development", "Staging", "Production"
|
||||
|
||||
---
|
||||
|
||||
## Indexes for Performance
|
||||
|
||||
Critical indexes beyond unique constraints:
|
||||
- `FeatureState (EnvironmentId)` — for flag serving
|
||||
- `FeatureState (IdentityId)` — for identity lookups
|
||||
- `Identity (EnvironmentId, Identifier)` — unique, for identity resolution
|
||||
- `AuditLog (OrganizationId, CreatedAt DESC)` — for audit queries
|
||||
- `Webhook (EnvironmentId)` — for event dispatch
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `dotnet ef migrations add InitialSchema` generates a valid migration
|
||||
- [ ] `dotnet ef database update` applies cleanly against a fresh PostgreSQL instance
|
||||
- [ ] All unique constraints are enforced at the database level
|
||||
- [ ] Services use `MicCheckDbContext` directly — no repository interfaces
|
||||
- [ ] No raw SQL — all queries use EF Core LINQ
|
||||
- [ ] Integration tests against a real test database verify EF configurations are correct
|
||||
90
docs/api/03_database_and_repositories_output.md
Normal file
90
docs/api/03_database_and_repositories_output.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# Step 3 Output: Database Schema & EF Core
|
||||
|
||||
## Summary
|
||||
|
||||
All database infrastructure from the plan was implemented. The `DbContext` was placed in `MicCheck.Api/Data/` (not `MicCheck.Infrastructure`) to avoid a circular dependency, since `MicCheck.Api` already references `MicCheck.Infrastructure`.
|
||||
|
||||
---
|
||||
|
||||
## What Was Built
|
||||
|
||||
### `MicCheck.Api/Data/MicCheckDbContext.cs`
|
||||
- 18 `DbSet<T>` properties covering all domain entities
|
||||
- `OnModelCreating` delegates to `ApplyConfigurationsFromAssembly`
|
||||
- Uses `using AppEnvironment = MicCheck.Api.Environments.Environment;` alias to avoid collision with `System.Environment`
|
||||
|
||||
### `MicCheck.Api/Data/Configurations/` (18 files)
|
||||
One `IEntityTypeConfiguration<T>` per entity with all constraints:
|
||||
|
||||
| Entity | Key constraints |
|
||||
|---|---|
|
||||
| `Feature` | Name max 150, InitialValue max 20k, unique `(ProjectId, Name)` |
|
||||
| `FeatureState` | Value max 20k, `Version` as concurrency token, unique `(FeatureId, EnvironmentId, IdentityId)`, indexes on `EnvironmentId` and `IdentityId` |
|
||||
| `IdentityTrait` | Value max 2k, unique `(IdentityId, Key)` |
|
||||
| `Environment` | ApiKey max 100, unique index on `ApiKey` |
|
||||
| `ApiKey` | Key max 512, Prefix max 8, unique index on `Key` |
|
||||
| `Identity` | unique `(EnvironmentId, Identifier)` |
|
||||
| `AuditLog` | index on `(OrganizationId, CreatedAt)` |
|
||||
| `Webhook` | index on `EnvironmentId` |
|
||||
|
||||
### `MicCheck.Api/Data/DatabaseUrlParser.cs`
|
||||
Parses `DATABASE_URL` (postgresql://user:pass@host:port/db) into an Npgsql connection string. Defaults port to 5432 when not specified.
|
||||
|
||||
### `MicCheck.Api/Data/DatabaseSeeder.cs`
|
||||
Seeds a default Organization ("Default"), Project ("My Project"), and three Environments (Development, Staging, Production) on first startup when no organizations exist. Only runs in the Development environment.
|
||||
|
||||
### `Program.cs` updates
|
||||
- Registers `MicCheckDbContext` with Npgsql using either `DATABASE_URL` or `ConnectionStrings:DefaultConnection`
|
||||
- Runs the seeder only when `app.Environment.IsDevelopment()`
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- **DbContext location**: Plan specified `MicCheck.Infrastructure/Data/`. Moved to `MicCheck.Api/Data/` due to circular dependency constraint. `MicCheck.Infrastructure` is reserved for background services (Step 7).
|
||||
- **Migrations**: Not added in this step — requires a running PostgreSQL instance. Run manually with `dotnet ef migrations add InitialSchema --project src/api/MicCheck.Api`.
|
||||
|
||||
---
|
||||
|
||||
## Tests Added
|
||||
|
||||
### `MicCheck.Api.Tests.Unit/Data/MicCheckDbContextTests.cs` (10 tests)
|
||||
Covers all major DbSet operations using `UseInMemoryDatabase`:
|
||||
- Organization CRUD
|
||||
- Project query by organization
|
||||
- Environment query by API key
|
||||
- Feature query by project
|
||||
- FeatureState query by environment
|
||||
- Segment with included rules
|
||||
- Identity with included traits
|
||||
- AuditLog filter by organization
|
||||
- Webhook filter by environment
|
||||
- ApiKey retrieval by hashed key
|
||||
- User retrieval by email
|
||||
|
||||
### `MicCheck.Api.Tests.Unit/Data/DatabaseUrlParserTests.cs` (4 tests)
|
||||
- Full URL with all components
|
||||
- Default port (5432) when omitted
|
||||
- Custom port preserved
|
||||
- Special characters in password preserved
|
||||
|
||||
### `MicCheck.Api.Tests.Unit/Auth/AuthEndpointsTests.cs` (3 tests, fixed)
|
||||
Integration tests using `WebApplicationFactory<Program>`. Fixed by:
|
||||
- Setting `UseEnvironment("Testing")` so the seeder doesn't run at startup
|
||||
- Overriding `ITokenService` with a Moq mock
|
||||
|
||||
---
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
1. **`System.Environment` name collision** — `MicCheck.Api.Environments.Environment` conflicts with `System.Environment` pulled in by implicit usings. Resolved everywhere with the `using AppEnvironment = ...` alias.
|
||||
|
||||
2. **`WebApplicationFactory` + seeder crash** — The seeder runs in Development mode and tries to create a `MicCheckDbContext`, which requires a real Npgsql connection. Test factory fixed by setting `UseEnvironment("Testing")` to prevent the seeder block from executing.
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
Passed! - Failed: 0, Passed: 73, Skipped: 0, Total: 73
|
||||
```
|
||||
214
docs/api/04_authentication_and_authorization.md
Normal file
214
docs/api/04_authentication_and_authorization.md
Normal file
@@ -0,0 +1,214 @@
|
||||
# Step 4: Authentication & Authorization
|
||||
|
||||
## Goal
|
||||
Implement the two authentication schemes Flagsmith uses — Environment Key (public, for Flags API) and API Key (secret, for Admin API) — plus JWT for dashboard users, and role-based authorization.
|
||||
|
||||
---
|
||||
|
||||
## Authentication Schemes
|
||||
|
||||
### 1. Environment Key (Flags API)
|
||||
|
||||
- Delivered via `X-Environment-Key` header
|
||||
- Public — safe for client-side exposure
|
||||
- Identifies which environment to serve flags for
|
||||
- No user identity implied
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
// MicCheck.Api/Authentication/EnvironmentKeyAuthenticationHandler.cs
|
||||
public class EnvironmentKeyAuthenticationHandler
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
// Reads X-Environment-Key header
|
||||
// Looks up Environment from DB by ApiKey value
|
||||
// Sets ClaimsPrincipal with EnvironmentId claim
|
||||
// Scheme name: "EnvironmentKey"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Organisation API Key (Admin API)
|
||||
|
||||
- Delivered via `Authorization: Api-Key <TOKEN>` header
|
||||
- Secret — never expose client-side
|
||||
- Identifies the organization and grants admin access
|
||||
- Stored hashed in DB; prefix shown for identification
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
// MicCheck.Api/Authentication/ApiKeyAuthenticationHandler.cs
|
||||
public class ApiKeyAuthenticationHandler
|
||||
: AuthenticationHandler<AuthenticationSchemeOptions>
|
||||
{
|
||||
// Parses "Api-Key <TOKEN>" from Authorization header
|
||||
// Hashes the provided token
|
||||
// Looks up ApiKey record by hash
|
||||
// Validates IsActive and ExpiresAt
|
||||
// Sets ClaimsPrincipal with OrganizationId and UserId claims
|
||||
// Scheme name: "ApiKey"
|
||||
}
|
||||
```
|
||||
|
||||
**API key hashing:**
|
||||
- Use `SHA256` for hashing stored keys
|
||||
- Store prefix (first 8 chars) for display/identification only
|
||||
- Generate new keys with `RandomNumberGenerator.GetBytes(32)` → Base64URL encoded
|
||||
|
||||
### 3. JWT (Dashboard Users)
|
||||
|
||||
- Standard bearer token authentication
|
||||
- Used for the admin web UI
|
||||
- Short-lived access token + refresh token pattern
|
||||
|
||||
**Implementation:**
|
||||
```csharp
|
||||
builder.Services.AddAuthentication()
|
||||
.AddJwtBearer("Bearer", options => {
|
||||
options.TokenValidationParameters = new() {
|
||||
ValidIssuer = config["Jwt:Issuer"],
|
||||
ValidAudience = config["Jwt:Audience"],
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(config["Jwt:Secret"]!))
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authorization Policies
|
||||
|
||||
Define named policies in `MicCheck.Api/Authorization/`:
|
||||
|
||||
```csharp
|
||||
// Policies
|
||||
public static class AuthorizationPolicies
|
||||
{
|
||||
public const string FlagsApiAccess = "FlagsApiAccess";
|
||||
public const string AdminApiAccess = "AdminApiAccess";
|
||||
public const string OrganizationAdmin = "OrganizationAdmin";
|
||||
}
|
||||
```
|
||||
|
||||
**Policy registrations:**
|
||||
```csharp
|
||||
builder.Services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(AuthorizationPolicies.FlagsApiAccess, policy =>
|
||||
policy.AddAuthenticationSchemes("EnvironmentKey")
|
||||
.RequireClaim("EnvironmentId"));
|
||||
|
||||
options.AddPolicy(AuthorizationPolicies.AdminApiAccess, policy =>
|
||||
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
|
||||
.RequireAuthenticatedUser());
|
||||
|
||||
options.AddPolicy(AuthorizationPolicies.OrganizationAdmin, policy =>
|
||||
policy.AddAuthenticationSchemes("ApiKey", "Bearer")
|
||||
.RequireClaim("OrganizationRole", "Admin"));
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Role-Based Access Control (RBAC)
|
||||
|
||||
### Organization Roles
|
||||
- `Admin` — full access to all projects and settings
|
||||
- `User` — access to assigned projects only
|
||||
|
||||
### Project-Level Permissions
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Authorization/ProjectPermission.cs
|
||||
public enum ProjectPermission
|
||||
{
|
||||
ViewProject,
|
||||
CreateFeature, EditFeature, DeleteFeature,
|
||||
CreateEnvironment, EditEnvironment, DeleteEnvironment,
|
||||
CreateSegment, EditSegment, DeleteSegment,
|
||||
ManageWebhooks,
|
||||
ViewAuditLog
|
||||
}
|
||||
|
||||
// MicCheck.Api/Authorization/UserProjectPermission.cs
|
||||
public class UserProjectPermission
|
||||
{
|
||||
public int UserId { get; init; }
|
||||
public int ProjectId { get; init; }
|
||||
public ICollection<ProjectPermission> Permissions { get; init; } = [];
|
||||
public bool IsAdmin { get; set; } // grants all permissions
|
||||
}
|
||||
```
|
||||
|
||||
### Authorization requirement handler:
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Authorization/ProjectPermissionRequirementHandler.cs
|
||||
// Checks UserProjectPermission for the current user and requested project
|
||||
// Organization admins bypass all project-level checks
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## User Model
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Users/User.cs
|
||||
public class User
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public required string Email { get; set; }
|
||||
public required string PasswordHash { get; set; }
|
||||
public required string FirstName { get; set; }
|
||||
public required string LastName { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsTwoFactorEnabled { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public ICollection<OrganizationUser> Organizations { get; init; } = [];
|
||||
}
|
||||
```
|
||||
|
||||
**Password hashing:** Use ASP.NET Core's `IPasswordHasher<User>`.
|
||||
|
||||
---
|
||||
|
||||
## Auth Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/auth/login → Returns JWT access + refresh tokens
|
||||
POST /api/v1/auth/refresh → Exchanges refresh token for new access token
|
||||
POST /api/v1/auth/logout → Revokes refresh token
|
||||
POST /api/v1/auth/register → Creates new user + organization
|
||||
|
||||
POST /api/v1/organisations/{id}/api-keys/ → Create new API key
|
||||
GET /api/v1/organisations/{id}/api-keys/ → List API keys (prefix only)
|
||||
DELETE /api/v1/organisations/{id}/api-keys/{keyId}/ → Revoke key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
Apply to Admin API:
|
||||
- 500 requests per minute per API key
|
||||
- Use ASP.NET Core's built-in rate limiting middleware (`Microsoft.AspNetCore.RateLimiting`)
|
||||
|
||||
```csharp
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
options.AddFixedWindowLimiter("AdminApi", limiter => {
|
||||
limiter.PermitLimit = 500;
|
||||
limiter.Window = TimeSpan.FromMinutes(1);
|
||||
limiter.QueueLimit = 0;
|
||||
}));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `X-Environment-Key` header resolves to correct environment; invalid key returns 401
|
||||
- [ ] `Authorization: Api-Key <TOKEN>` validates against hashed DB record; invalid key returns 401
|
||||
- [ ] JWT tokens are issued and validated correctly
|
||||
- [ ] Organization admins have access to all project operations
|
||||
- [ ] Project-level permission checks block unauthorized users with 403
|
||||
- [ ] Rate limit returns 429 after 500 Admin API requests within a minute
|
||||
- [ ] Unit tests cover: key hashing, auth handler success/failure paths, permission checks
|
||||
154
docs/api/04_authentication_and_authorization_output.md
Normal file
154
docs/api/04_authentication_and_authorization_output.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# Step 4 Output: Authentication & Authorization
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented all three authentication schemes, authorization policies, role-based access control, and the full auth/API key endpoint suite.
|
||||
|
||||
---
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Authentication Schemes
|
||||
|
||||
**`EnvironmentKeyAuthenticationHandler`** (`MicCheck.Api/Authentication/`)
|
||||
- Reads `X-Environment-Key` header
|
||||
- Looks up `Environment` by `ApiKey` in the database
|
||||
- Sets `EnvironmentId` and `ProjectId` claims on success
|
||||
- Scheme name constant: `EnvironmentKeyAuthenticationHandler.SchemeName`
|
||||
|
||||
**`ApiKeyAuthenticationHandler`** (`MicCheck.Api/Authentication/`)
|
||||
- Parses `Authorization: Api-Key <TOKEN>` header
|
||||
- Hashes the raw token with SHA-256 and looks it up in `ApiKeys` table
|
||||
- Validates `IsActive` and `ExpiresAt`
|
||||
- Sets `OrganizationId` and `OrganizationRole = Admin` claims on success (API keys grant org-admin access)
|
||||
|
||||
**JWT Bearer** — existing `TokenService` updated to accept `User` (not a username string), now embeds `sub` (userId), `email`, `given_name`, `family_name`, plus `OrganizationId` and `OrganizationRole` claims for each org membership.
|
||||
|
||||
### Authorization Policies (`MicCheck.Api/Authorization/`)
|
||||
|
||||
| Policy | Schemes | Requirement |
|
||||
|---|---|---|
|
||||
| `FlagsApiAccess` | EnvironmentKey | `EnvironmentId` claim present |
|
||||
| `AdminApiAccess` | ApiKey, Bearer | Authenticated user |
|
||||
| `OrganizationAdmin` | ApiKey, Bearer | `OrganizationRole = Admin` claim |
|
||||
|
||||
### RBAC (`MicCheck.Api/Authorization/`)
|
||||
|
||||
- `ProjectPermission` enum (12 values: ViewProject, CreateFeature, EditFeature, etc.)
|
||||
- `UserProjectPermission` entity — `(UserId, ProjectId)` composite key, `IsAdmin` flag, `List<ProjectPermission>` stored as comma-separated string via EF Core value converter
|
||||
- `ProjectPermissionRequirement` — `IAuthorizationRequirement` wrapping a `ProjectPermission`
|
||||
- `ProjectPermissionRequirementHandler` — resolves project ID from route values (`projectId`), bypasses check for org admins, checks `UserProjectPermission` record otherwise
|
||||
|
||||
### Auth Service & Endpoints (`MicCheck.Api/Auth/`)
|
||||
|
||||
**`AuthService`** — login, register, refresh, logout:
|
||||
- Login: verifies password with `IPasswordHasher<User>`, issues JWT + 30-day refresh token
|
||||
- Register: creates `User`, default `Organization`, joins as `Admin`, issues tokens
|
||||
- Refresh: revokes old refresh token, issues new JWT + new refresh token (rotation)
|
||||
- Logout: revokes refresh token
|
||||
|
||||
**Endpoints** at `/api/v1/auth/` (all anonymous):
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/api/v1/auth/login` | Returns `LoginResponse(AccessToken, RefreshToken, ExpiresAt)` |
|
||||
| POST | `/api/v1/auth/register` | Creates user + org, returns same |
|
||||
| POST | `/api/v1/auth/refresh` | Rotates refresh token |
|
||||
| POST | `/api/v1/auth/logout` | Revokes refresh token, returns 204 |
|
||||
|
||||
### API Key Management (`MicCheck.Api/ApiKeys/`)
|
||||
|
||||
**`ApiKeyHasher`** — static utility:
|
||||
- `GenerateKey()` — `RandomNumberGenerator.GetBytes(32)` → Base64URL (no `+`, `/`, `=`)
|
||||
- `Hash(key)` — SHA-256 hex string (64 chars, lowercase)
|
||||
|
||||
**`ApiKeyService`** — create, list, revoke
|
||||
|
||||
**Endpoints** at `/api/v1/organisations/{organizationId}/api-keys/` (require `OrganizationAdmin`):
|
||||
|
||||
| Method | Path | Description |
|
||||
|---|---|---|
|
||||
| POST | `/` | Returns `CreateApiKeyResponse` including the raw key (shown once only) |
|
||||
| GET | `/` | Returns list with prefix only (hashed key never returned) |
|
||||
| DELETE | `/{keyId}` | Sets `IsActive = false` |
|
||||
|
||||
### New Domain Types
|
||||
|
||||
- `RefreshToken` (`MicCheck.Api/Users/`) — with EF Core cascade delete on user
|
||||
- `UserProjectPermission` (`MicCheck.Api/Authorization/`)
|
||||
- New request/response records: `LoginRequest`, `LoginResponse`, `RegisterRequest`, `RefreshRequest`, `LogoutRequest`, `CreateApiKeyRequest`, `CreateApiKeyResponse`, `ApiKeyResponse`
|
||||
|
||||
### New EF Core Configurations
|
||||
|
||||
- `RefreshTokenConfiguration` — unique index on `Token`, index on `UserId`, cascade delete
|
||||
- `UserProjectPermissionConfiguration` — composite key `(UserId, ProjectId)`, `Permissions` stored as comma-separated string
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- `UserProjectPermission.Permissions` declared as `List<ProjectPermission>` (not `ICollection`) to ensure EF Core value converter works correctly
|
||||
- API keys granted `OrganizationRole = Admin` claim — necessary for `OrganizationAdmin` policy (which `RequireClaim("OrganizationRole", "Admin")`); without it, API-key-authenticated requests could never manage API keys
|
||||
|
||||
---
|
||||
|
||||
## Tests Added
|
||||
|
||||
### `ApiKeyHasherTests` (7 tests)
|
||||
- Hash is deterministic
|
||||
- Hash is lowercase hex (64 chars)
|
||||
- Different inputs produce different hashes
|
||||
- Generated key is non-empty and unique
|
||||
- Generated key is Base64URL-safe (no `+`, `/`, `=`)
|
||||
- Generated key can be verified by hashing
|
||||
|
||||
### `AuthEndpointsTests` (10 tests)
|
||||
End-to-end via `WebApplicationFactory` with in-memory DB:
|
||||
- Register with valid details → 200 with tokens
|
||||
- Register duplicate email → 409
|
||||
- Login with correct credentials → 200 with tokens
|
||||
- Login with wrong password → 401
|
||||
- Login with unknown email → 401
|
||||
- Login with empty email → 400
|
||||
- Refresh with valid token → 200 with new tokens
|
||||
- Refresh with invalid token → 401
|
||||
- Refresh with already-used (revoked) token → 401
|
||||
- Logout → 204, subsequent refresh → 401
|
||||
|
||||
### `ApiKeyAuthenticationHandlerTests` (5 tests)
|
||||
Via `WebApplicationFactory` against the API key management endpoints:
|
||||
- Missing auth header → 401
|
||||
- Bearer scheme (not Api-Key) → 401
|
||||
- Invalid key → 401
|
||||
- Inactive key → 401
|
||||
- Expired key → 401
|
||||
- Valid key → 200
|
||||
|
||||
### `ProjectPermissionRequirementHandlerTests` (7 tests)
|
||||
Direct unit tests with in-memory DB:
|
||||
- Org admin bypasses all project checks → succeeds
|
||||
- User with required permission → succeeds
|
||||
- Project admin (`IsAdmin = true`) satisfies any permission → succeeds
|
||||
- User without required permission → fails
|
||||
- User with no permission record → fails
|
||||
- Missing user ID claim → fails
|
||||
- Missing project ID in route → fails
|
||||
|
||||
### `TokenServiceTests` (5 tests, updated)
|
||||
Updated for new `User`-based signature — verifies token non-empty, email claim, expiry, issuer, and org claims.
|
||||
|
||||
---
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
1. **`WebApplicationFactory` + multiple EF Core providers** — Removing `DbContextOptions<MicCheckDbContext>` alone does not remove Npgsql's `IDatabaseProvider`. Fixed by using `UseInternalServiceProvider` with a dedicated `InMemoryEfServiceProvider` (static), which tells EF Core to bypass the application DI container for provider discovery entirely.
|
||||
|
||||
2. **Per-request Guid DB name** — Initially `Guid.NewGuid()` was evaluated inside the `AddDbContext` options lambda, generating a new DB name per `DbContext` creation. Each HTTP request therefore got an empty database. Fixed by capturing the Guid once in `ConfigureWebHost` before the `ConfigureServices` lambda.
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
Passed! - Failed: 0, Passed: 102, Skipped: 0, Total: 102
|
||||
```
|
||||
264
docs/api/05_flags_api.md
Normal file
264
docs/api/05_flags_api.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# Step 5: Flags API (Public SDK Endpoint)
|
||||
|
||||
## Goal
|
||||
Implement the public Flags API — the endpoints consumed by SDK clients to retrieve feature flags and identify users. This is the high-traffic, read-heavy API authenticated via `X-Environment-Key`.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
All routes prefixed with `/api/v1/` and authenticated with the `EnvironmentKey` scheme.
|
||||
|
||||
### GET /api/v1/flags/
|
||||
Returns all enabled feature flags for the resolved environment.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"feature": {
|
||||
"id": 1,
|
||||
"name": "dark_mode",
|
||||
"type": "STANDARD"
|
||||
},
|
||||
"enabled": true,
|
||||
"feature_state_value": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### GET /api/v1/identities/?identifier=user123
|
||||
Returns flags for a specific user identity, including segment overrides and identity-level overrides.
|
||||
|
||||
### POST /api/v1/identities/
|
||||
Identifies a user, optionally sets traits, and returns flags.
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"identifier": "user123",
|
||||
"traits": [
|
||||
{ "trait_key": "plan", "trait_value": "premium" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"traits": [...],
|
||||
"flags": [...]
|
||||
}
|
||||
```
|
||||
|
||||
### GET /api/v1/environment-document/
|
||||
Returns the full environment configuration for **local evaluation mode**.
|
||||
Used by server-side SDKs to pull all flags, segments, and targeting rules locally.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"api_key": "env-key-xxx",
|
||||
"feature_states": [...],
|
||||
"project": {
|
||||
"id": 1,
|
||||
"name": "My Project",
|
||||
"segments": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature Evaluation Service
|
||||
|
||||
Lives in `MicCheck.Api/Features/`. Injects `MicCheckDbContext` and returns domain objects — no DTOs.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureEvaluationService.cs
|
||||
public class FeatureEvaluationService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly SegmentEvaluator _segmentEvaluator;
|
||||
private readonly FlagCache _flagCache;
|
||||
|
||||
// Resolves the effective FeatureState for a given environment
|
||||
// Priority order (highest wins):
|
||||
// 1. Identity-level override
|
||||
// 2. Segment override (highest priority segment that matches)
|
||||
// 3. Environment default
|
||||
|
||||
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForEnvironmentAsync(
|
||||
int environmentId, CancellationToken ct = default);
|
||||
|
||||
public Task<IReadOnlyList<FeatureStateResult>> EvaluateForIdentityAsync(
|
||||
int environmentId,
|
||||
string identifier,
|
||||
IReadOnlyList<TraitInput>? traits = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Identity Resolution Service
|
||||
|
||||
Lives in `MicCheck.Api/Identities/`. Injects `MicCheckDbContext` and returns domain objects.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Identities/IdentityResolutionService.cs
|
||||
public class IdentityResolutionService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
|
||||
// 1. Look up Identity by (EnvironmentId, Identifier) — create if not found
|
||||
// 2. Upsert provided traits by key
|
||||
// 3. Return the resolved Identity with current traits loaded
|
||||
public Task<Identity> ResolveAsync(
|
||||
int environmentId,
|
||||
string identifier,
|
||||
IReadOnlyList<TraitInput>? traits,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Segment Evaluator
|
||||
|
||||
Lives in `MicCheck.Api/Segments/`. Pure logic — no DB access (data is preloaded by callers).
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Segments/SegmentEvaluator.cs
|
||||
public class SegmentEvaluator
|
||||
{
|
||||
public bool Evaluate(Segment segment, IReadOnlyList<IdentityTrait> traits);
|
||||
|
||||
private bool EvaluateRule(SegmentRule rule, IReadOnlyList<IdentityTrait> traits);
|
||||
private bool EvaluateCondition(SegmentCondition condition, IReadOnlyList<IdentityTrait> traits);
|
||||
}
|
||||
```
|
||||
|
||||
**Operator implementations** (pure functions):
|
||||
- String: Equal, NotEqual, Contains, NotContains, Regex
|
||||
- Numeric: GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
|
||||
- Boolean: IsTrue, IsFalse
|
||||
- Membership: In, NotIn
|
||||
- Existence: IsSet, IsNotSet
|
||||
- Sampling: PercentageSplit (deterministic by identity+segment hash)
|
||||
- Modulo: ModuloValueDivisorRemainder
|
||||
|
||||
---
|
||||
|
||||
## Request/Response DTOs
|
||||
|
||||
Live alongside their controllers in the relevant feature folder. DTOs are only constructed in controller action methods — services never return or accept DTOs.
|
||||
|
||||
```csharp
|
||||
// Features/FlagResponse.cs
|
||||
public record FlagResponse(
|
||||
int Id,
|
||||
FeatureSummaryResponse Feature,
|
||||
bool Enabled,
|
||||
string? FeatureStateValue
|
||||
)
|
||||
{
|
||||
public static FlagResponse From(FeatureStateResult result) => new(
|
||||
result.Feature.Id, // mapping happens here, in the response type, called from the controller
|
||||
FeatureSummaryResponse.From(result.Feature),
|
||||
result.Enabled,
|
||||
result.Value);
|
||||
}
|
||||
|
||||
// Features/FeatureSummaryResponse.cs
|
||||
public record FeatureSummaryResponse(int Id, string Name, string Type)
|
||||
{
|
||||
public static FeatureSummaryResponse From(Feature feature) =>
|
||||
new(feature.Id, feature.Name, feature.Type.ToString().ToUpperInvariant());
|
||||
}
|
||||
|
||||
// Identities/IdentityRequest.cs
|
||||
public record IdentityRequest(
|
||||
string Identifier,
|
||||
IReadOnlyList<TraitInput>? Traits
|
||||
);
|
||||
|
||||
// Identities/TraitInput.cs
|
||||
public record TraitInput(
|
||||
string TraitKey,
|
||||
object? TraitValue // string | int | float | bool
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controllers
|
||||
|
||||
Controllers receive a request DTO, call a service with domain-level parameters, then map the domain result to a response DTO before returning.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FlagsController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/flags")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class FlagsController : ControllerBase
|
||||
{
|
||||
private readonly FeatureEvaluationService _featureEvaluationService;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<FlagResponse>>> GetAll(CancellationToken ct)
|
||||
{
|
||||
var environmentId = int.Parse(User.FindFirstValue("EnvironmentId")!);
|
||||
var results = await _featureEvaluationService.EvaluateForEnvironmentAsync(environmentId, ct);
|
||||
return Ok(results.Select(FlagResponse.From).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
// MicCheck.Api/Identities/IdentitiesController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/identities")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class IdentitiesController : ControllerBase { ... }
|
||||
|
||||
// MicCheck.Api/Environments/EnvironmentDocumentController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/environment-document")]
|
||||
[Authorize(Policy = AuthorizationPolicies.FlagsApiAccess)]
|
||||
public class EnvironmentDocumentController : ControllerBase { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Caching
|
||||
|
||||
Lives in `MicCheck.Api/Features/`. Cache stores domain results, not serialized responses.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FlagCache.cs
|
||||
public class FlagCache
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
|
||||
public IReadOnlyList<FeatureStateResult>? Get(int environmentId);
|
||||
public void Set(int environmentId, IReadOnlyList<FeatureStateResult> flags);
|
||||
public void Invalidate(int environmentId);
|
||||
}
|
||||
```
|
||||
|
||||
Cache is invalidated inside `FeatureEvaluationService` whenever a `FeatureState` is modified. Designed to swap the backing store for Redis without changing the `FlagCache` interface.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] `GET /api/v1/flags/` returns correct flags for a valid environment key
|
||||
- [ ] `GET /api/v1/flags/` returns 401 for missing or invalid environment key
|
||||
- [ ] `POST /api/v1/identities/` creates identity + upserts traits + returns correct flags
|
||||
- [ ] Segment override takes priority over environment default
|
||||
- [ ] Identity override takes priority over segment override
|
||||
- [ ] `GET /api/v1/environment-document/` returns full environment config
|
||||
- [ ] Controllers never pass DTOs into services — only primitive/domain values
|
||||
- [ ] `SegmentEvaluator` unit tests cover all operator types
|
||||
- [ ] `FeatureEvaluationService` unit tests cover all three priority tiers
|
||||
- [ ] `PercentageSplit` distributes identities deterministically and uniformly
|
||||
142
docs/api/05_flags_api_output.md
Normal file
142
docs/api/05_flags_api_output.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Step 5 Output: Flags API
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the public Flags API — the SDK-facing endpoints authenticated via `X-Environment-Key`. Includes feature flag evaluation with identity/segment/environment priority, identity resolution, and environment document for local evaluation mode.
|
||||
|
||||
---
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Segment Evaluator (`MicCheck.Api/Segments/SegmentEvaluator.cs`)
|
||||
|
||||
Pure stateless logic — no DB access. Evaluates a `Segment` against an identity's traits.
|
||||
|
||||
**Rule evaluation:**
|
||||
- Top-level rules (where `ParentRuleId == null`) are AND-ed together
|
||||
- Each rule applies its `Type` (All/Any/None) to its `Conditions`
|
||||
- `ChildRules` are recursively evaluated and AND-ed after conditions
|
||||
|
||||
**All 17 operators implemented:**
|
||||
|
||||
| Category | Operators |
|
||||
|---|---|
|
||||
| String | Equal, NotEqual, Contains, NotContains, Regex |
|
||||
| Numeric | GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual |
|
||||
| Boolean | IsTrue, IsFalse |
|
||||
| Membership | In, NotIn |
|
||||
| Existence | IsSet, IsNotSet |
|
||||
| Sampling | PercentageSplit (MD5 hash of `identifier + segmentId`, bucket out of 9999) |
|
||||
| Modulo | ModuloValueDivisorRemainder (value format: `"divisor|remainder"`) |
|
||||
|
||||
### Feature Evaluation Service (`MicCheck.Api/Features/FeatureEvaluationService.cs`)
|
||||
|
||||
**Priority order (highest wins):**
|
||||
1. Identity-level override (`FeatureState` with `IdentityId` set)
|
||||
2. Segment override — first matching `FeatureSegment` by `Priority` (lowest number first)
|
||||
3. Environment default (`FeatureState` with no `IdentityId` or `FeatureSegmentId`)
|
||||
|
||||
`EvaluateForEnvironmentAsync` — checks `FlagCache` first, loads default feature states via a join query, caches the result.
|
||||
|
||||
`EvaluateForIdentityAsync` — loads all feature states + feature segments + segments in a few queries, resolves identity via `IdentityResolutionService`, then evaluates each feature in a single in-memory pass.
|
||||
|
||||
### Flag Cache (`MicCheck.Api/Features/FlagCache.cs`)
|
||||
|
||||
`IMemoryCache` wrapper with 60-second TTL, keyed by `environmentId`. Registered as a singleton. Interface designed to swap backing store for Redis without changing callers.
|
||||
|
||||
### Identity Resolution Service (`MicCheck.Api/Identities/IdentityResolutionService.cs`)
|
||||
|
||||
1. Looks up `Identity` by `(EnvironmentId, Identifier)` — creates if not found
|
||||
2. Upserts provided traits by key (updates value and type if key exists, inserts otherwise)
|
||||
3. Returns the resolved `Identity` with current traits loaded
|
||||
|
||||
### TraitInput (`MicCheck.Api/Identities/TraitInput.cs`)
|
||||
|
||||
Record with `[JsonPropertyName]` attributes for snake_case API compatibility (`trait_key`, `trait_value`). Handles `JsonElement` deserialization from `System.Text.Json` to determine the correct `TraitValueType`.
|
||||
|
||||
### Endpoints
|
||||
|
||||
All require `FlagsApiAccess` policy (`X-Environment-Key` header).
|
||||
|
||||
| Method | Path | Controller |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/flags/` | `FlagsController` |
|
||||
| GET | `/api/v1/identities/?identifier=` | `IdentitiesController` |
|
||||
| POST | `/api/v1/identities/` | `IdentitiesController` |
|
||||
| GET | `/api/v1/environment-document/` | `EnvironmentDocumentController` |
|
||||
|
||||
### Response Types
|
||||
|
||||
- `FlagResponse(Id, Feature, Enabled, FeatureStateValue)` — static `From(FeatureStateResult)` factory
|
||||
- `FeatureSummaryResponse(Id, Name, Type)` — type is uppercased (`STANDARD`, `MULTI_VARIATE`)
|
||||
- `IdentityResponse(Traits, Flags)`
|
||||
- `TraitResponse(TraitKey, TraitValue)`
|
||||
- `EnvironmentDocumentResponse` — full environment config including project segments for local evaluation mode
|
||||
|
||||
All response objects are constructed in controller action methods only; services never return or accept DTOs.
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- `SegmentEvaluator.Evaluate` takes an optional `identifier` parameter (required for `PercentageSplit`)
|
||||
- `FeatureEvaluationService.EvaluateForIdentityAsync` creates an `IdentityResolutionService` instance internally rather than injecting it (avoids double-inject of scoped service that's also used by the controller directly)
|
||||
- **`FeatureSegmentConfiguration` bug fixed**: The existing configuration had `HasForeignKey<FeatureSegment>(fsg => fsg.Id)` (FK on `FeatureSegment.Id` → `FeatureState.Id`, shared-PK pattern), which is incorrect for the data model. Changed to `HasForeignKey<FeatureState>(fs => fs.FeatureSegmentId)` so `FeatureState.FeatureSegmentId` references `FeatureSegment.Id` as intended.
|
||||
|
||||
---
|
||||
|
||||
## Tests Added
|
||||
|
||||
### `SegmentEvaluatorTests` (27 tests)
|
||||
|
||||
Covers all 17 operators plus rule type logic:
|
||||
- Equal, NotEqual (case-insensitive)
|
||||
- GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual
|
||||
- Contains, NotContains (case-insensitive)
|
||||
- Regex match/no-match
|
||||
- IsSet/IsNotSet (trait existence)
|
||||
- In/NotIn (comma-separated list)
|
||||
- IsTrue, IsFalse
|
||||
- ModuloValueDivisorRemainder satisfied/not satisfied
|
||||
- PercentageSplit is deterministic for same input
|
||||
- PercentageSplit at 100% includes all identities
|
||||
- PercentageSplit at 0% includes no identities
|
||||
- Rule type Any (at least one must match)
|
||||
- Rule type None (none must match / one match → fail)
|
||||
- Multiple top-level rules AND-ed together
|
||||
|
||||
### `FeatureEvaluationServiceTests` (7 tests, in-memory DB)
|
||||
|
||||
- Environment default returned when no overrides exist
|
||||
- Disabled flag reflected correctly
|
||||
- Identity with no overrides → environment default
|
||||
- Identity override takes priority over environment default
|
||||
- Segment override used when segment matches
|
||||
- Environment default used when segment doesn't match
|
||||
- Identity override takes priority over matching segment override
|
||||
|
||||
### `FlagsApiIntegrationTests` (5 tests, WebApplicationFactory)
|
||||
|
||||
- Valid environment key → 200 with correct flags
|
||||
- Missing environment key → 401
|
||||
- Invalid environment key → 401
|
||||
- POST identities with traits → 200
|
||||
- GET environment document → 200
|
||||
|
||||
---
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
1. **`FeatureSegmentConfiguration` FK was backwards** — the stored configuration put the FK on `FeatureSegment.Id` (sharing the PK with `FeatureState`), which made it impossible to create a `FeatureSegment` before a matching `FeatureState` existed. Fixed to use `FeatureState.FeatureSegmentId` as the FK.
|
||||
|
||||
2. **`TraitInput` JSON naming** — The plan's API spec uses `trait_key` / `trait_value` (snake_case). ASP.NET Core's default `System.Text.Json` serializer does case-insensitive camelCase matching but not snake_case. Added `[JsonPropertyName]` attributes to `TraitInput`.
|
||||
|
||||
3. **Integration test seeding** — `EnvironmentDocumentController` loads the project from DB. The initial integration test seeded an environment but not a project, causing a 404. Fixed by seeding a project first.
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
Passed! - Failed: 0, Passed: 142, Skipped: 0, Total: 142
|
||||
```
|
||||
325
docs/api/06_admin_api.md
Normal file
325
docs/api/06_admin_api.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Step 6: Admin API (Management Endpoints)
|
||||
|
||||
## Goal
|
||||
Implement the Admin API — the authenticated management API for creating and managing projects, environments, features, segments, users, and API keys. Authenticated via `Authorization: Api-Key <TOKEN>` or JWT bearer token.
|
||||
|
||||
---
|
||||
|
||||
## API Versioning
|
||||
|
||||
```csharp
|
||||
builder.Services.AddApiVersioning(options =>
|
||||
{
|
||||
options.DefaultApiVersion = new ApiVersion(1, 0);
|
||||
options.AssumeDefaultVersionWhenUnspecified = true;
|
||||
options.ReportApiVersions = true;
|
||||
});
|
||||
```
|
||||
|
||||
Routes:
|
||||
- Current: `/api/v1/`
|
||||
- Future: `/api/v2/`
|
||||
|
||||
All Admin API controllers:
|
||||
```csharp
|
||||
[ApiController]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoint Groups
|
||||
|
||||
### Organizations
|
||||
|
||||
```
|
||||
GET /api/v1/organisations/
|
||||
POST /api/v1/organisations/
|
||||
GET /api/v1/organisations/{id}/
|
||||
PUT /api/v1/organisations/{id}/
|
||||
DELETE /api/v1/organisations/{id}/
|
||||
GET /api/v1/organisations/{id}/users/
|
||||
POST /api/v1/organisations/{id}/users/invite/
|
||||
DELETE /api/v1/organisations/{id}/users/{userId}/
|
||||
```
|
||||
|
||||
### Projects
|
||||
|
||||
```
|
||||
GET /api/v1/projects/
|
||||
POST /api/v1/projects/
|
||||
GET /api/v1/projects/{id}/
|
||||
PUT /api/v1/projects/{id}/
|
||||
DELETE /api/v1/projects/{id}/
|
||||
GET /api/v1/projects/{id}/user-permissions/
|
||||
POST /api/v1/projects/{id}/user-permissions/
|
||||
PUT /api/v1/projects/{id}/user-permissions/{userId}/
|
||||
DELETE /api/v1/projects/{id}/user-permissions/{userId}/
|
||||
```
|
||||
|
||||
### Environments
|
||||
|
||||
```
|
||||
GET /api/v1/environments/
|
||||
POST /api/v1/environments/
|
||||
GET /api/v1/environments/{apiKey}/
|
||||
PUT /api/v1/environments/{apiKey}/
|
||||
DELETE /api/v1/environments/{apiKey}/
|
||||
POST /api/v1/environments/{apiKey}/clone/
|
||||
GET /api/v1/environments/{apiKey}/webhooks/
|
||||
POST /api/v1/environments/{apiKey}/webhooks/
|
||||
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
```
|
||||
|
||||
### Features
|
||||
|
||||
```
|
||||
GET /api/v1/projects/{projectId}/features/
|
||||
POST /api/v1/projects/{projectId}/features/
|
||||
GET /api/v1/projects/{projectId}/features/{id}/
|
||||
PUT /api/v1/projects/{projectId}/features/{id}/
|
||||
PATCH /api/v1/projects/{projectId}/features/{id}/
|
||||
DELETE /api/v1/projects/{projectId}/features/{id}/
|
||||
```
|
||||
|
||||
### Feature States (Environment-level)
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{apiKey}/featurestates/
|
||||
GET /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||
PUT /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||
PATCH /api/v1/environments/{apiKey}/featurestates/{id}/
|
||||
```
|
||||
|
||||
### Segments
|
||||
|
||||
```
|
||||
GET /api/v1/projects/{projectId}/segments/
|
||||
POST /api/v1/projects/{projectId}/segments/
|
||||
GET /api/v1/projects/{projectId}/segments/{id}/
|
||||
PUT /api/v1/projects/{projectId}/segments/{id}/
|
||||
DELETE /api/v1/projects/{projectId}/segments/{id}/
|
||||
```
|
||||
|
||||
### Identities (Admin view)
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{apiKey}/identities/
|
||||
GET /api/v1/environments/{apiKey}/identities/{id}/
|
||||
DELETE /api/v1/environments/{apiKey}/identities/{id}/
|
||||
GET /api/v1/environments/{apiKey}/identities/{id}/featurestates/
|
||||
PUT /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
|
||||
DELETE /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/
|
||||
```
|
||||
|
||||
### Audit Logs
|
||||
|
||||
```
|
||||
GET /api/v1/organisations/{id}/audit-logs/
|
||||
GET /api/v1/projects/{projectId}/audit-logs/
|
||||
GET /api/v1/environments/{apiKey}/audit-logs/
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
```
|
||||
GET /api/v1/projects/{projectId}/tags/
|
||||
POST /api/v1/projects/{projectId}/tags/
|
||||
DELETE /api/v1/projects/{projectId}/tags/{id}/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Service Layer
|
||||
|
||||
Each feature area has a service class in its folder within `MicCheck.Api`. Services accept and return domain objects — they have no knowledge of request/response DTOs. Services inject `MicCheckDbContext` directly.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureService.cs
|
||||
public class FeatureService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
// Enforces: max 400 features per project
|
||||
// On create: auto-creates FeatureState records for all existing environments
|
||||
public Task<Feature> CreateAsync(int projectId, string name, FeatureType type,
|
||||
string? initialValue, string? description, CancellationToken ct = default);
|
||||
|
||||
public Task<Feature> UpdateAsync(int id, string name, string? description,
|
||||
CancellationToken ct = default);
|
||||
|
||||
public Task DeleteAsync(int id, CancellationToken ct = default);
|
||||
|
||||
public Task<Feature?> FindByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
public Task<IReadOnlyList<Feature>> ListByProjectAsync(int projectId,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// MicCheck.Api/Environments/EnvironmentService.cs
|
||||
public class EnvironmentService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
// On create: auto-creates FeatureState records for all existing features
|
||||
public Task<Environment> CreateAsync(int projectId, string name, CancellationToken ct = default);
|
||||
|
||||
// Clone: duplicates all environment-level FeatureStates; not identity/segment overrides
|
||||
public Task<Environment> CloneAsync(string sourceApiKey, string newName, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
// MicCheck.Api/Segments/SegmentService.cs
|
||||
public class SegmentService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly AuditService _auditService;
|
||||
|
||||
// Enforces: max 100 segments per project
|
||||
// Enforces: max 100 segment rule conditions
|
||||
public Task<Segment> CreateAsync(int projectId, string name,
|
||||
IReadOnlyList<SegmentRuleDefinition> rules, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Controller Pattern
|
||||
|
||||
Controllers are responsible for:
|
||||
1. Receiving the request DTO
|
||||
2. Validating it (FluentValidation runs automatically via middleware)
|
||||
3. Calling the appropriate service with extracted domain-level values
|
||||
4. Mapping the returned domain object(s) to a response DTO
|
||||
5. Returning the HTTP response
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeaturesController.cs
|
||||
[ApiController]
|
||||
[Route("api/v1/projects/{projectId}/features")]
|
||||
[Authorize(Policy = AuthorizationPolicies.AdminApiAccess)]
|
||||
[EnableRateLimiting("AdminApi")]
|
||||
public class FeaturesController : ControllerBase
|
||||
{
|
||||
private readonly FeatureService _featureService;
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<FeatureResponse>> Create(
|
||||
int projectId, CreateFeatureRequest request, CancellationToken ct)
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
[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) return NotFound();
|
||||
return Ok(FeatureResponse.From(feature));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Request Validation
|
||||
|
||||
Use FluentValidation for all incoming request DTOs. Validators live in the same folder as the DTO they validate.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/CreateFeatureRequest.cs
|
||||
public record CreateFeatureRequest(
|
||||
string Name,
|
||||
FeatureType Type,
|
||||
string? InitialValue,
|
||||
string? Description
|
||||
);
|
||||
|
||||
// MicCheck.Api/Features/CreateFeatureRequestValidator.cs
|
||||
public class CreateFeatureRequestValidator : AbstractValidator<CreateFeatureRequest>
|
||||
{
|
||||
public CreateFeatureRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name)
|
||||
.NotEmpty()
|
||||
.MaximumLength(150)
|
||||
.Matches("^[a-zA-Z0-9_-]+$");
|
||||
|
||||
RuleFor(x => x.InitialValue)
|
||||
.MaximumLength(20_000)
|
||||
.When(x => x.InitialValue is not null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Response DTOs
|
||||
|
||||
Live in the same folder as their domain entity. The static `From` factory is the only mapping point.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Features/FeatureResponse.cs
|
||||
public record FeatureResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string Type,
|
||||
string? InitialValue,
|
||||
string? Description,
|
||||
bool DefaultEnabled,
|
||||
DateTimeOffset CreatedAt
|
||||
)
|
||||
{
|
||||
public static FeatureResponse From(Feature feature) => new(
|
||||
feature.Id, feature.Name,
|
||||
feature.Type.ToString().ToUpperInvariant(),
|
||||
feature.InitialValue, feature.Description,
|
||||
feature.DefaultEnabled, feature.CreatedAt);
|
||||
}
|
||||
```
|
||||
|
||||
Pagination wrapper for list endpoints:
|
||||
```csharp
|
||||
// MicCheck.Api/PaginatedResponse.cs
|
||||
public record PaginatedResponse<T>(
|
||||
int Count,
|
||||
string? Next,
|
||||
string? Previous,
|
||||
IReadOnlyList<T> Results
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Cloning
|
||||
|
||||
When cloning an environment:
|
||||
1. Create new `Environment` record with a fresh `ApiKey`
|
||||
2. Copy all environment-level `FeatureState` records from the source environment
|
||||
3. Do NOT copy identity-level overrides
|
||||
4. Do NOT copy segment overrides (those follow segment definitions)
|
||||
5. Emit an audit log entry
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Full CRUD for organizations, projects, environments, features, segments
|
||||
- [ ] Creating a feature auto-creates `FeatureState` for all project environments
|
||||
- [ ] Creating an environment auto-creates `FeatureState` for all project features
|
||||
- [ ] 400 feature limit enforced (returns 400 with descriptive error)
|
||||
- [ ] 100 segment limit enforced
|
||||
- [ ] 100 segment condition limit enforced
|
||||
- [ ] FluentValidation errors return 422 with field-level detail
|
||||
- [ ] Environment clone creates independent copy of feature states
|
||||
- [ ] All endpoints return 401 for missing credentials, 403 for insufficient permissions
|
||||
- [ ] Paginated list endpoints default to page size 20, max 100
|
||||
- [ ] Controllers never call `_db` directly — all data access goes through a service
|
||||
- [ ] Services never reference request/response DTO types
|
||||
- [ ] Unit tests for all service methods covering success paths and constraint violations
|
||||
237
docs/api/06_admin_api_output.md
Normal file
237
docs/api/06_admin_api_output.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# Step 6 Output: Admin API
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the Admin API — authenticated management endpoints for organizations, projects, environments, features, segments, identities, tags, webhooks, and audit logs. Authenticated via `Authorization: Api-Key <TOKEN>` or JWT bearer token.
|
||||
|
||||
---
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Shared Utilities
|
||||
|
||||
**`MicCheck.Api/Common/DomainException.cs`**
|
||||
- Custom exception for business rule violations (feature limit, segment limit, etc.)
|
||||
- Caught in controllers and returned as `400 BadRequest`
|
||||
|
||||
**`MicCheck.Api/Common/PaginatedResponse.cs`**
|
||||
- Generic `PaginatedResponse<T>(Count, Next, Previous, Results)` record used by all list endpoints
|
||||
|
||||
---
|
||||
|
||||
### Audit
|
||||
|
||||
**`MicCheck.Api/Audit/AuditService.cs`**
|
||||
- `virtual LogAsync(resourceType, resourceId, action, organizationId, projectId?, environmentId?, changes?, ct)` — persists audit log entry, extracts actor user ID from `UserId` claim via `IHttpContextAccessor`
|
||||
|
||||
**`MicCheck.Api/Audit/AuditLogQueryService.cs`**
|
||||
- `ListByOrganizationAsync`, `ListByProjectAsync`, `ListByEnvironmentAsync` — ordered by descending `CreatedAt`
|
||||
|
||||
**`MicCheck.Api/Audit/AuditLogResponse.cs`**
|
||||
- Static `From(AuditLog)` factory
|
||||
|
||||
**`MicCheck.Api/Audit/AuditLogsController.cs`**
|
||||
- `GET /api/v1/organisations/{id}/audit-logs`
|
||||
- `GET /api/v1/projects/{projectId}/audit-logs`
|
||||
- (Environment audit logs handled in `EnvironmentsController`)
|
||||
|
||||
---
|
||||
|
||||
### Organizations
|
||||
|
||||
**`MicCheck.Api/Organizations/OrganizationService.cs`**
|
||||
- `ListForUserAsync(userId)` — returns orgs where user is a member
|
||||
- `CreateAsync(name, creatorUserId)` — auto-adds creator as Admin member
|
||||
- `UpdateAsync`, `DeleteAsync`, `FindByIdAsync`
|
||||
- `ListMembersAsync`, `InviteUserAsync` (upsert), `RemoveMemberAsync`
|
||||
|
||||
**`MicCheck.Api/Organizations/OrganizationsController.cs`**
|
||||
Routes:
|
||||
- `GET /api/v1/organisations/` — paginated, filtered to calling user's orgs
|
||||
- `POST /api/v1/organisations/`
|
||||
- `GET /api/v1/organisations/{id}/`
|
||||
- `PUT /api/v1/organisations/{id}/`
|
||||
- `DELETE /api/v1/organisations/{id}/`
|
||||
- `GET /api/v1/organisations/{id}/users/`
|
||||
- `POST /api/v1/organisations/{id}/users/invite/`
|
||||
- `DELETE /api/v1/organisations/{id}/users/{userId}/`
|
||||
|
||||
---
|
||||
|
||||
### Projects
|
||||
|
||||
**`MicCheck.Api/Projects/ProjectService.cs`**
|
||||
- `ListByOrganizationAsync`, `FindByIdAsync`, `CreateAsync`, `UpdateAsync`, `DeleteAsync`
|
||||
- `ListUserPermissionsAsync`, `SetUserPermissionsAsync` (upsert), `RemoveUserPermissionsAsync`
|
||||
|
||||
**`MicCheck.Api/Projects/ProjectsController.cs`**
|
||||
Routes:
|
||||
- `GET /api/v1/projects/?organizationId=`
|
||||
- `POST /api/v1/projects/`
|
||||
- `GET /api/v1/projects/{id}/`
|
||||
- `PUT /api/v1/projects/{id}/`
|
||||
- `DELETE /api/v1/projects/{id}/`
|
||||
- `GET /api/v1/projects/{id}/user-permissions/`
|
||||
- `POST /api/v1/projects/{id}/user-permissions/`
|
||||
- `PUT /api/v1/projects/{id}/user-permissions/{userId}/`
|
||||
- `DELETE /api/v1/projects/{id}/user-permissions/{userId}/`
|
||||
|
||||
---
|
||||
|
||||
### Environments
|
||||
|
||||
**`MicCheck.Api/Environments/EnvironmentService.cs`**
|
||||
- `CreateAsync` — generates a random API key via `ApiKeyHasher.GenerateKey()`, auto-creates `FeatureState` for all existing features
|
||||
- `CloneAsync` — duplicates environment-level `FeatureState` records only (not identity or segment overrides)
|
||||
- `UpdateAsync`, `DeleteAsync`, `FindByApiKeyAsync`, `ListByProjectAsync`
|
||||
|
||||
**`MicCheck.Api/Environments/EnvironmentsController.cs`**
|
||||
Routes:
|
||||
- `GET /api/v1/environments/?projectId=`
|
||||
- `POST /api/v1/environments/`
|
||||
- `GET /api/v1/environments/{apiKey}/`
|
||||
- `PUT /api/v1/environments/{apiKey}/`
|
||||
- `DELETE /api/v1/environments/{apiKey}/`
|
||||
- `POST /api/v1/environments/{apiKey}/clone/`
|
||||
- `GET /api/v1/environments/{apiKey}/webhooks/`
|
||||
- `POST /api/v1/environments/{apiKey}/webhooks/`
|
||||
- `PUT /api/v1/environments/{apiKey}/webhooks/{id}/`
|
||||
- `DELETE /api/v1/environments/{apiKey}/webhooks/{id}/`
|
||||
- `GET /api/v1/environments/{apiKey}/audit-logs/`
|
||||
- `GET /api/v1/environments/{apiKey}/identities/` (paginated)
|
||||
- `GET /api/v1/environments/{apiKey}/identities/{id}/`
|
||||
- `DELETE /api/v1/environments/{apiKey}/identities/{id}/`
|
||||
- `GET /api/v1/environments/{apiKey}/identities/{id}/featurestates/`
|
||||
- `PUT /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/`
|
||||
- `DELETE /api/v1/environments/{apiKey}/identities/{id}/featurestates/{featureId}/`
|
||||
- `GET /api/v1/environments/{apiKey}/featurestates/`
|
||||
- `GET /api/v1/environments/{apiKey}/featurestates/{id}/`
|
||||
- `PUT /api/v1/environments/{apiKey}/featurestates/{id}/`
|
||||
- `PATCH /api/v1/environments/{apiKey}/featurestates/{id}/`
|
||||
|
||||
---
|
||||
|
||||
### Features
|
||||
|
||||
**`MicCheck.Api/Features/FeatureService.cs`**
|
||||
- `CreateAsync` — enforces 400-feature limit (`DomainException`), auto-creates `FeatureState` for all project environments
|
||||
- `UpdateAsync`, `DeleteAsync`, `FindByIdAsync`, `ListByProjectAsync`
|
||||
|
||||
**`MicCheck.Api/Features/FeatureStateService.cs`**
|
||||
- `ListByEnvironmentAsync` — environment-level states only (no identity/segment overrides)
|
||||
- `FindByIdAsync`, `UpdateAsync` (full replace), `PatchAsync` (partial)
|
||||
|
||||
**`MicCheck.Api/Features/FeaturesController.cs`**
|
||||
Routes under `api/v1/projects/{projectId}/features/`:
|
||||
- `GET`, `POST`, `GET {id}`, `PUT {id}`, `PATCH {id}`, `DELETE {id}`
|
||||
|
||||
---
|
||||
|
||||
### Segments
|
||||
|
||||
**`MicCheck.Api/Segments/SegmentService.cs`**
|
||||
- `CreateAsync` — enforces 100-segment limit, 100-condition limit (across all rules recursively)
|
||||
- `UpdateAsync` — replaces all rules and conditions (full replace semantics)
|
||||
- `DeleteAsync`, `FindByIdAsync`, `ListByProjectAsync`
|
||||
|
||||
**`MicCheck.Api/Segments/SegmentsController.cs`**
|
||||
Routes under `api/v1/projects/{projectId}/segments/`:
|
||||
- `GET`, `POST`, `GET {id}`, `PUT {id}`, `DELETE {id}`
|
||||
|
||||
---
|
||||
|
||||
### Admin Identities
|
||||
|
||||
**`MicCheck.Api/Identities/AdminIdentityService.cs`**
|
||||
- `ListAsync` (paginated), `FindByIdAsync`, `DeleteAsync`
|
||||
- `GetFeatureStatesAsync`, `SetFeatureStateAsync` (upsert), `DeleteFeatureStateAsync`
|
||||
|
||||
---
|
||||
|
||||
### Tags
|
||||
|
||||
**`MicCheck.Api/Features/TagService.cs`** — list, create, delete
|
||||
|
||||
**`MicCheck.Api/Features/TagsController.cs`**
|
||||
Routes under `api/v1/projects/{projectId}/tags/`:
|
||||
- `GET`, `POST`, `DELETE {id}`
|
||||
|
||||
---
|
||||
|
||||
### Webhooks
|
||||
|
||||
**`MicCheck.Api/Webhooks/WebhookService.cs`** — list by environment, create, update, delete
|
||||
|
||||
---
|
||||
|
||||
## Request Validation
|
||||
|
||||
All incoming DTOs have FluentValidation validators in the same folder. Validation errors return **422 Unprocessable Entity** with field-level detail (configured via `ApiBehaviorOptions`).
|
||||
|
||||
Key validation rules:
|
||||
- Feature names: `^[a-zA-Z0-9_-]+$`, max 150 chars
|
||||
- Feature `InitialValue`: max 20,000 chars
|
||||
- Webhook URLs: must be absolute URI
|
||||
- Tag colors: hex format (`#RGB` or `#RRGGBB`)
|
||||
- Segment rule `Type`: `All`, `Any`, or `None`
|
||||
- Segment condition `Operator`: any of the 17 `SegmentConditionOperator` enum values
|
||||
|
||||
---
|
||||
|
||||
## Program.cs Changes
|
||||
|
||||
- Added `JsonStringEnumConverter` to MVC JSON options (allows `"Standard"` instead of `0` in API requests)
|
||||
- Added `Configure<ApiBehaviorOptions>` to return 422 with field-level errors for validation failures
|
||||
- Registered new services: `AuditService`, `AuditLogQueryService`, `OrganizationService`, `ProjectService`, `EnvironmentService`, `FeatureService`, `FeatureStateService`, `SegmentService`, `TagService`, `WebhookService`, `AdminIdentityService`
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
- Identity admin endpoints are nested under `/api/v1/environments/{apiKey}/` (as specified in the plan) and handled in `EnvironmentsController` via `[FromServices]` to avoid constructor over-injection
|
||||
- Feature state endpoints (`/api/v1/environments/{apiKey}/featurestates/`) are also in `EnvironmentsController` for the same reason
|
||||
- `AuditService.LogAsync` is marked `virtual` to support Moq in service unit tests
|
||||
|
||||
---
|
||||
|
||||
## Tests Added
|
||||
|
||||
### `FeatureServiceTests` (6 tests, in-memory DB)
|
||||
- Creating a feature auto-creates `FeatureState` for each environment
|
||||
- `InitialValue` is propagated to `FeatureState.Value`
|
||||
- 400-feature limit throws `DomainException`
|
||||
- Update changes name and description
|
||||
- Delete removes the feature
|
||||
- List returns all project features
|
||||
|
||||
### `EnvironmentServiceTests` (5 tests, in-memory DB)
|
||||
- Creating an environment auto-creates `FeatureState` for each existing feature
|
||||
- Each created environment gets a unique API key
|
||||
- Clone copies environment-level feature states
|
||||
- Clone does NOT copy identity overrides
|
||||
- Clone generates a different API key from source
|
||||
|
||||
### `SegmentServiceTests` (6 tests, in-memory DB)
|
||||
- Create persists segment, rules, and conditions
|
||||
- 100-segment limit throws `DomainException`
|
||||
- 100-condition limit throws `DomainException`
|
||||
- Update replaces old rules with new rules
|
||||
- Delete removes segment
|
||||
- List returns all project segments
|
||||
|
||||
### `AdminApiIntegrationTests` (7 tests, WebApplicationFactory)
|
||||
- Creating a project returns 201
|
||||
- Creating a feature returns 201
|
||||
- Creating a feature auto-creates `FeatureState` for environments
|
||||
- Creating an environment auto-creates `FeatureState` for existing features
|
||||
- Missing API key returns 401
|
||||
- Invalid feature name (with spaces) returns 422
|
||||
- Creating a segment returns 201
|
||||
|
||||
---
|
||||
|
||||
## Test Results
|
||||
|
||||
```
|
||||
Passed! - Failed: 0, Passed: 166, Skipped: 0, Total: 166
|
||||
```
|
||||
249
docs/api/07_audit_and_webhooks.md
Normal file
249
docs/api/07_audit_and_webhooks.md
Normal file
@@ -0,0 +1,249 @@
|
||||
# Step 7: Audit Logging & Webhooks
|
||||
|
||||
## Goal
|
||||
Implement the audit trail system (recording all changes to flags, environments, segments, etc.) and the webhook system (notifying external systems of flag state changes).
|
||||
|
||||
---
|
||||
|
||||
## Audit Logging
|
||||
|
||||
### What Gets Audited
|
||||
|
||||
Every create, update, and delete operation on:
|
||||
- Features and FeatureStates
|
||||
- Environments
|
||||
- Segments
|
||||
- Identities (admin operations)
|
||||
- API Keys (create/revoke)
|
||||
- User permissions
|
||||
- Organization settings
|
||||
- Project settings
|
||||
|
||||
### AuditLog Domain Model
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Audit/AuditLog.cs (already defined in Step 2)
|
||||
// Key fields:
|
||||
// ResourceType: "Feature", "FeatureState", "Environment", etc.
|
||||
// ResourceId: string representation of the entity's PK
|
||||
// Action: "Created", "Updated", "Deleted"
|
||||
// Changes: JSON diff of before/after values (for Updates)
|
||||
// ActorUserId: null for API key operations without user context
|
||||
```
|
||||
|
||||
### Audit Service
|
||||
|
||||
Lives in `MicCheck.Api/Audit/`. Injects `MicCheckDbContext` directly.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Audit/AuditService.cs
|
||||
public class AuditService
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
|
||||
public Task RecordAsync(
|
||||
string resourceType,
|
||||
string resourceId,
|
||||
string action,
|
||||
int organizationId,
|
||||
int? projectId = null,
|
||||
int? environmentId = null,
|
||||
int? actorUserId = null,
|
||||
object? before = null,
|
||||
object? after = null,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
**Change diffing:**
|
||||
- Serialize `before` and `after` objects to JSON
|
||||
- Store both in `Changes` as `{ "before": {...}, "after": {...} }`
|
||||
- Use `System.Text.Json` for serialization
|
||||
|
||||
### Integration with Services
|
||||
|
||||
Audit logging is called directly from other service methods:
|
||||
|
||||
```csharp
|
||||
// In FeatureService.CreateAsync:
|
||||
await _auditService.RecordAsync(
|
||||
resourceType: "Feature",
|
||||
resourceId: feature.Id.ToString(),
|
||||
action: "Created",
|
||||
organizationId: project.OrganizationId,
|
||||
projectId: feature.ProjectId,
|
||||
after: feature);
|
||||
```
|
||||
|
||||
### Audit Log Queries
|
||||
|
||||
`AuditService` exposes a query method returning domain objects; the controller maps to response DTOs.
|
||||
|
||||
Support filtering by:
|
||||
- Date range (`from`, `to`)
|
||||
- Resource type
|
||||
- Action type
|
||||
- Project
|
||||
- Environment
|
||||
- Actor user
|
||||
|
||||
Always ordered by `CreatedAt DESC`. Paginated (default 20, max 100).
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Audit/AuditController.cs
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PaginatedResponse<AuditLogResponse>>> List(
|
||||
int id, [FromQuery] AuditLogFilter filter, CancellationToken ct)
|
||||
{
|
||||
var (logs, total) = await _auditService.ListAsync(id, filter, ct);
|
||||
return Ok(new PaginatedResponse<AuditLogResponse>(
|
||||
total, null, null,
|
||||
logs.Select(AuditLogResponse.From).ToList()));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Webhooks
|
||||
|
||||
### Webhook Triggers
|
||||
|
||||
Webhooks fire on:
|
||||
- `FLAG_UPDATED` — any `FeatureState` change
|
||||
- `FLAG_DELETED` — feature deleted
|
||||
- `AUDIT_LOG_CREATED` — any audit log entry (organization webhooks)
|
||||
|
||||
### Webhook Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"changed_by": "user@example.com",
|
||||
"timestamp": "2026-04-06T12:00:00Z",
|
||||
"new_state": {
|
||||
"id": 42,
|
||||
"enabled": true,
|
||||
"feature_state_value": null,
|
||||
"feature": { "id": 1, "name": "dark_mode" },
|
||||
"environment": { "id": 5, "name": "Production" }
|
||||
},
|
||||
"previous_state": { ... }
|
||||
},
|
||||
"event_type": "FLAG_UPDATED"
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Event & Dispatcher
|
||||
|
||||
Live in `MicCheck.Api/Webhooks/`. Dispatcher injects `MicCheckDbContext` directly.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Webhooks/WebhookEvent.cs
|
||||
public class WebhookEvent
|
||||
{
|
||||
public required string EventType { get; init; }
|
||||
public required object Data { get; init; }
|
||||
public int? EnvironmentId { get; init; }
|
||||
public int OrganizationId { get; init; }
|
||||
}
|
||||
|
||||
// MicCheck.Api/Webhooks/WebhookDispatcher.cs
|
||||
public class WebhookDispatcher
|
||||
{
|
||||
private readonly MicCheckDbContext _db;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
// Finds all active webhooks for the environment (and org-level)
|
||||
// Serializes payload to JSON
|
||||
// Signs payload with HMAC-SHA256 using webhook secret
|
||||
// POSTs to webhook URL with timeout of 10 seconds
|
||||
// Stores delivery result in WebhookDeliveryLog
|
||||
public Task DispatchAsync(WebhookEvent webhookEvent, CancellationToken ct = default);
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Signing
|
||||
|
||||
```csharp
|
||||
// HMAC-SHA256 signature in header:
|
||||
// X-Flagsmith-Signature: sha256=<hex_digest>
|
||||
|
||||
private static string ComputeSignature(string secret, string payload)
|
||||
{
|
||||
var keyBytes = Encoding.UTF8.GetBytes(secret);
|
||||
var payloadBytes = Encoding.UTF8.GetBytes(payload);
|
||||
var hashBytes = HMACSHA256.HashData(keyBytes, payloadBytes);
|
||||
return "sha256=" + Convert.ToHexString(hashBytes).ToLowerInvariant();
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Queue (Async Dispatch)
|
||||
|
||||
Lives in `MicCheck.Api/Webhooks/`. Uses `System.Threading.Channels` so dispatch does not block the API response.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Api/Webhooks/WebhookQueue.cs
|
||||
public class WebhookQueue
|
||||
{
|
||||
private readonly Channel<WebhookEvent> _channel = Channel.CreateUnbounded<WebhookEvent>();
|
||||
|
||||
public ValueTask EnqueueAsync(WebhookEvent webhookEvent) =>
|
||||
_channel.Writer.WriteAsync(webhookEvent);
|
||||
|
||||
public IAsyncEnumerable<WebhookEvent> ReadAllAsync(CancellationToken ct) =>
|
||||
_channel.Reader.ReadAllAsync(ct);
|
||||
}
|
||||
```
|
||||
|
||||
### Retry Background Service
|
||||
|
||||
Lives in `MicCheck.Infrastructure/Webhooks/`. Polls `WebhookDeliveryLog` and retries failures.
|
||||
|
||||
```csharp
|
||||
// MicCheck.Infrastructure/Webhooks/WebhookRetryBackgroundService.cs
|
||||
public class WebhookRetryBackgroundService : BackgroundService
|
||||
{
|
||||
// Polls WebhookDeliveryLog for failed deliveries within retry window
|
||||
// Up to 3 attempts: immediate, +5 min, +30 min
|
||||
// Reattempts via WebhookDispatcher
|
||||
}
|
||||
```
|
||||
|
||||
### Retry Strategy
|
||||
|
||||
- Up to 3 delivery attempts (immediate, +5 min, +30 min)
|
||||
- Failed deliveries are logged but do not block the API response
|
||||
|
||||
---
|
||||
|
||||
## Webhook Admin Endpoints
|
||||
|
||||
Controllers map domain objects to/from DTOs in the action method, calling `WebhookService` for all data access.
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{apiKey}/webhooks/
|
||||
POST /api/v1/environments/{apiKey}/webhooks/
|
||||
PUT /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
DELETE /api/v1/environments/{apiKey}/webhooks/{id}/
|
||||
GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries/
|
||||
|
||||
GET /api/v1/organisations/{id}/webhooks/
|
||||
POST /api/v1/organisations/{id}/webhooks/
|
||||
PUT /api/v1/organisations/{id}/webhooks/{id}/
|
||||
DELETE /api/v1/organisations/{id}/webhooks/{id}/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Every create/update/delete in the Admin API produces an `AuditLog` record
|
||||
- [ ] Audit logs correctly capture before/after state for updates
|
||||
- [ ] `GET /api/v1/organisations/{id}/audit-logs/` returns paginated, filtered results
|
||||
- [ ] Audit controllers map `AuditLog` domain objects to `AuditLogResponse` DTOs in the action
|
||||
- [ ] Webhooks fire within 1 second of a flag state change
|
||||
- [ ] HMAC-SHA256 signature is correct and verifiable by receivers
|
||||
- [ ] Failed webhook deliveries are logged with status code and response body
|
||||
- [ ] Retry logic attempts delivery 3 times with backoff
|
||||
- [ ] Webhook delivery does not block the API response (fire-and-forget via `WebhookQueue`)
|
||||
- [ ] Unit tests cover: signature computation, payload serialization, retry scheduling
|
||||
- [ ] Unit tests cover: audit diff generation for create/update/delete scenarios
|
||||
81
docs/api/07_audit_and_webhooks_output.md
Normal file
81
docs/api/07_audit_and_webhooks_output.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# Step 7 Output: Audit Logging & Webhooks
|
||||
|
||||
## Summary
|
||||
|
||||
Implemented the full audit logging and webhook system for MicCheck.
|
||||
|
||||
## What Was Built
|
||||
|
||||
### Audit Logging
|
||||
|
||||
- **`AuditService`** — `RecordAsync` captures before/after state as `{"before":{...},"after":{...}}` JSON in `AuditLog.Changes`. Serializes with `System.Text.Json` (camelCase). Enqueues `AUDIT_LOG_CREATED` webhook event after persisting. Methods are `virtual` for Moq compatibility.
|
||||
- **`AuditLogQueryService`** — Separate query service with `ListByOrganizationAsync`, `ListByProjectAsync`, and `ListByEnvironmentAsync`. All return `(int Total, IReadOnlyList<AuditLog> Items)` tuples. Filtering via `AuditLogFilter` record (date range, resource type, action, project/environment/actor).
|
||||
- **`AuditLogFilter`** — Record with optional filter fields and pagination (`Page`, `PageSize`).
|
||||
- **`AuditLogsController`** — Updated to accept `[FromQuery] AuditLogFilter` and return `PaginatedResponse<AuditLogResponse>`.
|
||||
- **`EnvironmentsController`** — Added `GET /{apiKey}/audit-logs` endpoint.
|
||||
|
||||
### Webhooks
|
||||
|
||||
- **`WebhookEvent`** / **`WebhookEventTypes`** — Event model with `EventType`, `EnvironmentId`, `OrganizationId`, `Data`. Three event types: `FLAG_UPDATED`, `FLAG_DELETED`, `AUDIT_LOG_CREATED`.
|
||||
- **`WebhookPayload`** — Snake_case serialized payload with `data` and `event_type` fields. Nested records: `FlagUpdatedData`, `FlagDeletedData`, `FlagStateSnapshot`, `FeatureSummary`, `EnvironmentSummary`.
|
||||
- **`WebhookQueue`** — Singleton `System.Threading.Channels`-backed queue. `EnqueueAsync` is non-blocking; `ReadAllAsync` consumed by background service.
|
||||
- **`WebhookDispatcher`** — Scoped service. Finds active webhooks by environment + org scope, serializes payload, signs with HMAC-SHA256 (`X-Flagsmith-Signature: sha256=<hex>`), POSTs with 10s timeout via `IHttpClientFactory`, logs `WebhookDeliveryLog` with `AttemptNumber`.
|
||||
- **`WebhookBackgroundService`** — `BackgroundService` reading from queue, dispatching via scoped `WebhookDispatcher`.
|
||||
- **`WebhookRetryBackgroundService`** — Polls every 60s. Retries failed deliveries: attempt 1→2 after 5 min, 2→3 after 30 min, max 3 total.
|
||||
- **`WebhookDeliveryLog`** — Added `AttemptNumber` property.
|
||||
- **`WebhookService`** — Added `ListByOrganizationAsync`, `CreateForOrganizationAsync`, `ListDeliveriesAsync`.
|
||||
- **`WebhookDeliveryLogResponse`** — New response DTO.
|
||||
|
||||
### Admin Endpoints Added
|
||||
|
||||
```
|
||||
GET /api/v1/environments/{apiKey}/webhooks/{id}/deliveries
|
||||
GET /api/v1/organisations/{id}/webhooks
|
||||
POST /api/v1/organisations/{id}/webhooks
|
||||
PUT /api/v1/organisations/{id}/webhooks/{id}
|
||||
DELETE /api/v1/organisations/{id}/webhooks/{id}
|
||||
```
|
||||
|
||||
### Service Updates
|
||||
|
||||
- **`FeatureService`** — Constructor now takes `WebhookQueue`. `DeleteAsync` fires `FLAG_DELETED` events per environment. `RecordAsync` used with before/after.
|
||||
- **`FeatureStateService`** — Constructor now takes `WebhookQueue` and `AuditService`. `UpdateAsync` and `PatchAsync` capture before state and dispatch `FLAG_UPDATED` + audit record.
|
||||
|
||||
### Program.cs
|
||||
|
||||
Added registrations:
|
||||
- `WebhookDispatcher` (scoped)
|
||||
- `WebhookQueue` (singleton)
|
||||
- `WebhookBackgroundService` (hosted)
|
||||
- `WebhookRetryBackgroundService` (hosted)
|
||||
- `AddHttpClient("Webhooks")` with `User-Agent` header
|
||||
- `JsonStringEnumConverter` for string enum deserialization
|
||||
- `ApiBehaviorOptions` for 422 validation error format
|
||||
|
||||
### Migration
|
||||
|
||||
Added `AddWebhookDeliveryAttemptNumber` migration for the new `AttemptNumber` column on `WebhookDeliveryLog`.
|
||||
|
||||
## Tests Added
|
||||
|
||||
| File | Tests |
|
||||
|------|-------|
|
||||
| `Audit/AuditServiceTests.cs` | 5 — null changes, after-only diff, before+after diff, metadata persistence, webhook event enqueued |
|
||||
| `Webhooks/WebhookDispatcherTests.cs` | 6 — signature computation, delivery without webhooks, delivery logging |
|
||||
| `Webhooks/WebhookRetryTests.cs` | 4 — retry delay values, max attempts, eligibility |
|
||||
|
||||
**Total: 166 tests passing.**
|
||||
|
||||
## Acceptance Criteria Status
|
||||
|
||||
- [x] Every create/update/delete in the Admin API produces an `AuditLog` record
|
||||
- [x] Audit logs correctly capture before/after state for updates
|
||||
- [x] `GET /api/v1/organisations/{id}/audit-logs/` returns paginated, filtered results
|
||||
- [x] Audit controllers map `AuditLog` domain objects to `AuditLogResponse` DTOs
|
||||
- [x] Webhooks fire within 1 second of a flag state change (async via Channel)
|
||||
- [x] HMAC-SHA256 signature is correct and verifiable by receivers
|
||||
- [x] Failed webhook deliveries are logged with status code and response body
|
||||
- [x] Retry logic attempts delivery 3 times with backoff (5 min, 30 min)
|
||||
- [x] Webhook delivery does not block the API response (fire-and-forget via `WebhookQueue`)
|
||||
- [x] Unit tests cover: signature computation, payload serialization, retry scheduling
|
||||
- [x] Unit tests cover: audit diff generation for create/update/delete scenarios
|
||||
277
docs/api/08_testing_strategy.md
Normal file
277
docs/api/08_testing_strategy.md
Normal file
@@ -0,0 +1,277 @@
|
||||
# Step 8: Testing Strategy
|
||||
|
||||
## Goal
|
||||
Establish a comprehensive testing strategy that achieves high coverage of business logic, enforces BDD-style test naming, and validates the full request pipeline without touching external resources (database, file system, network).
|
||||
|
||||
---
|
||||
|
||||
## Test Project Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── MicCheck.Core.Tests/
|
||||
│ ├── Features/
|
||||
│ │ ├── FeatureEvaluationServiceTests.cs
|
||||
│ │ └── FeatureServiceTests.cs
|
||||
│ ├── Segments/
|
||||
│ │ └── SegmentEvaluatorTests.cs
|
||||
│ ├── Environments/
|
||||
│ │ └── EnvironmentServiceTests.cs
|
||||
│ ├── Identities/
|
||||
│ │ └── IdentityResolutionServiceTests.cs
|
||||
│ ├── Audit/
|
||||
│ │ └── AuditServiceTests.cs
|
||||
│ └── Webhooks/
|
||||
│ ├── WebhookDispatcherTests.cs
|
||||
│ └── WebhookSignatureTests.cs
|
||||
├── MicCheck.Api.Tests/
|
||||
│ ├── Flags/
|
||||
│ │ ├── FlagsControllerTests.cs
|
||||
│ │ └── IdentitiesControllerTests.cs
|
||||
│ ├── Admin/
|
||||
│ │ ├── FeaturesControllerTests.cs
|
||||
│ │ ├── EnvironmentsControllerTests.cs
|
||||
│ │ ├── SegmentsControllerTests.cs
|
||||
│ │ └── OrganizationsControllerTests.cs
|
||||
│ └── Authentication/
|
||||
│ ├── EnvironmentKeyAuthenticationHandlerTests.cs
|
||||
│ └── ApiKeyAuthenticationHandlerTests.cs
|
||||
└── MicCheck.Infrastructure.Tests/
|
||||
└── (integration tests — marked [Category("Integration")])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Naming Convention (BDD Style)
|
||||
|
||||
```
|
||||
WhenA{Subject}_{Condition}_Then{ExpectedOutcome}
|
||||
|
||||
Examples:
|
||||
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
|
||||
WhenAnIdentityIsResolved_WithMatchingSegment_ThenSegmentFlagOverrideIsReturned
|
||||
WhenAWebhookIsDispatched_WithASecret_ThenPayloadIsSignedWithHmacSha256
|
||||
WhenAnEnvironmentKeyIsInvalid_ThenAuthenticationFails
|
||||
WhenAFlagIsUpdated_ThenAnAuditLogEntryIsCreated
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Patterns
|
||||
|
||||
### Unit Tests (Api — Services)
|
||||
|
||||
Services depend on `MicCheckDbContext`. Use an in-memory SQLite EF Core provider (or a real test-scoped PostgreSQL instance for integration tests) rather than mocking the DbContext. Mock only non-EF dependencies such as `AuditService` or `WebhookQueue`.
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class FeatureServiceTests
|
||||
{
|
||||
private MicCheckDbContext _db = null!;
|
||||
private Mock<AuditService> _auditService = null!;
|
||||
private FeatureService _sut = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<MicCheckDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
_db = new MicCheckDbContext(options);
|
||||
_auditService = new Mock<AuditService>();
|
||||
_sut = new FeatureService(_db, _auditService.Object);
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _db.Dispose();
|
||||
|
||||
[Test]
|
||||
public void WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown()
|
||||
{
|
||||
var project = new Project { Name = "Test", OrganizationId = 1 };
|
||||
_db.Projects.Add(project);
|
||||
for (var i = 0; i < 400; i++)
|
||||
_db.Features.Add(new Feature { Name = $"flag_{i}", ProjectId = project.Id });
|
||||
_db.SaveChanges();
|
||||
|
||||
Assert.That(
|
||||
async () => await _sut.CreateAsync(project.Id, "new_flag", FeatureType.Standard, null, null),
|
||||
Throws.TypeOf<DomainException>());
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### API Controller Tests
|
||||
|
||||
Use `WebApplicationFactory<Program>` with mocked services for HTTP-level tests:
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class FlagsControllerTests
|
||||
{
|
||||
private WebApplicationFactory<Program> _factory = null!;
|
||||
|
||||
[SetUp]
|
||||
public void SetUp()
|
||||
{
|
||||
_factory = new WebApplicationFactory<Program>().WithWebHostBuilder(builder =>
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton<IFeatureEvaluationService>(
|
||||
Mock.Of<IFeatureEvaluationService>());
|
||||
}));
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public void TearDown() => _factory.Dispose();
|
||||
|
||||
[Test]
|
||||
public async Task WhenGetFlagsIsCalledWithAValidEnvironmentKey_ThenFlagsAreReturned()
|
||||
{
|
||||
var client = _factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Add("X-Environment-Key", "valid-env-key");
|
||||
|
||||
var response = await client.GetAsync("/api/v1/flags/");
|
||||
|
||||
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SegmentEvaluator Test Coverage
|
||||
|
||||
The `SegmentEvaluator` must have exhaustive unit tests for every operator:
|
||||
|
||||
The `SegmentEvaluator` takes no dependencies, so it is constructed directly with no mocking needed.
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class SegmentEvaluatorTests
|
||||
{
|
||||
private readonly SegmentEvaluator _sut = new();
|
||||
|
||||
[TestCase("premium", "premium", true)]
|
||||
[TestCase("premium", "free", false)]
|
||||
public void WhenEvaluatingEqualOperator_ThenResultMatchesExpectation(
|
||||
string traitValue, string conditionValue, bool expected) { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenEvaluatingPercentageSplit_WithSameIdentifier_ThenResultIsDeterministic() { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenEvaluatingPercentageSplit_WithZeroPercent_ThenNobodyMatches() { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenEvaluatingPercentageSplit_WithHundredPercent_ThenEveryoneMatches() { ... }
|
||||
|
||||
[Test]
|
||||
public void WhenASegmentHasNestedRules_ThenAllRulesAreEvaluated() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Feature Evaluation Priority Tests
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class FeatureEvaluationServiceTests
|
||||
{
|
||||
[Test]
|
||||
public async Task WhenIdentityHasAnOverride_ThenIdentityOverrideTakesPriorityOverSegment() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityMatchesSegment_ThenSegmentOverrideTakesPriorityOverEnvironmentDefault() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityMatchesMultipleSegments_ThenHighestPrioritySegmentWins() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenIdentityMatchesNoSegment_ThenEnvironmentDefaultIsReturned() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Authentication Tests
|
||||
|
||||
```csharp
|
||||
[TestFixture]
|
||||
public class EnvironmentKeyAuthenticationHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyHeaderIsMissing_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsInvalid_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenEnvironmentKeyIsValid_ThenAuthenticationSucceedsWithEnvironmentIdClaim() { ... }
|
||||
}
|
||||
|
||||
[TestFixture]
|
||||
public class ApiKeyAuthenticationHandlerTests
|
||||
{
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsRevoked_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsExpired_ThenAuthenticationFails() { ... }
|
||||
|
||||
[Test]
|
||||
public async Task WhenApiKeyIsValid_ThenAuthenticationSucceedsWithOrganizationIdClaim() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Domain Constraint Tests
|
||||
|
||||
```csharp
|
||||
// For each enforced constraint, there must be a test:
|
||||
WhenAFeatureIsCreated_AndProjectHasReachedMaxFeatures_ThenAnExceptionIsThrown
|
||||
WhenASegmentIsCreated_AndProjectHasReachedMaxSegments_ThenAnExceptionIsThrown
|
||||
WhenASegmentConditionIsAdded_AndRuleHasReachedMaxConditions_ThenAnExceptionIsThrown
|
||||
WhenAFeatureStateValueExceedsMaxBytes_ThenValidationFails
|
||||
WhenATraitValueExceedsMaxBytes_ThenValidationFails
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Coverage Target
|
||||
|
||||
- `MicCheck.Api` services and domain logic: 90%+ line coverage
|
||||
- `MicCheck.Api` controllers and auth handlers: 80%+ line coverage
|
||||
- All new code must include corresponding tests per CLAUDE.md requirements
|
||||
|
||||
Run coverage via:
|
||||
```bash
|
||||
dotnet test --collect:"XPlat Code Coverage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CI Test Execution
|
||||
|
||||
Tests are separated by NUnit category:
|
||||
```bash
|
||||
# Run all unit tests (fast, no external dependencies):
|
||||
dotnet test --filter "Category!=Integration"
|
||||
|
||||
# Run integration tests (requires database):
|
||||
dotnet test --filter "Category=Integration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] All tests use BDD-style names (`WhenA{Subject}_{Condition}_Then{Outcome}`)
|
||||
- [ ] No mocked objects named with the word "Mock" (use descriptive names or `_sut`)
|
||||
- [ ] No Arrange/Act/Assert comments in test code
|
||||
- [ ] `SegmentEvaluator` has tests for every `SegmentConditionOperator` value
|
||||
- [ ] Feature evaluation priority (identity > segment > environment) is tested explicitly
|
||||
- [ ] All domain constraint limits have corresponding tests
|
||||
- [ ] `dotnet test` passes cleanly with zero failures
|
||||
- [ ] Core project achieves 90%+ line coverage
|
||||
Reference in New Issue
Block a user