Adding Flags API

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

View File

@@ -0,0 +1,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
```

View 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
```

142
docs/05_flags_api_output.md Normal file
View 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
```