Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
9a90dda
Add unit tests for TenantAccessService, TenantOperationService, Token…
idotta Mar 15, 2026
cc4ab61
Update rate limiting options to be disabled by default and adjust doc…
idotta Mar 15, 2026
2f8ef84
fix(admin): require sysadmin + restore tenant context
idotta Apr 29, 2026
59d31f0
fix(admin): self-target → 403; add 401/403 arms
idotta Apr 29, 2026
b9af1bf
feat(identity): add SysRoleKind enum and PendingEmail to IdmtUser
idotta Apr 29, 2026
b92a73f
feat(identity)!: make IdmtUser global; drop TenantId column
idotta Apr 29, 2026
dd63598
refactor(admin): rewrite GrantTenantAccess as TenantAccess-only
idotta Apr 29, 2026
167ebc8
refactor(admin): rewrite RevokeTenantAccess by canonical UserId
idotta Apr 29, 2026
110872b
refactor(auth)!: drop TenantIdentifier from ConfirmEmail surface
idotta Apr 29, 2026
84a0ec6
fix(auth)!: stop ResetPassword from confirming email on success
idotta Apr 29, 2026
c6b10e9
feat(auth)!: stage email change for OOB confirmation
idotta Apr 29, 2026
c7b2ed1
refactor(links)!: strip tenantIdentifier from confirm/reset URLs
idotta Apr 29, 2026
1aa5235
feat(admin)!: shrink default roles + bootstrap invoker tenant access
idotta Apr 29, 2026
ac0f0e1
feat(auth)!: enforce TenantAccess gate at login
idotta Apr 29, 2026
d59be07
feat(migration): add canonical identity data migrator harness
idotta Apr 29, 2026
2dbfd0c
chore(release): cut 2.0.0; document Phase 1 in CHANGELOG and CLAUDE.md
idotta Apr 29, 2026
8bc3a60
docs(adr): add IDMT v2 OpenIddict authorization design + prototype spike
idotta Jun 5, 2026
8fe5636
feat(spike): prove §7.0 gates 5, 6, 7 and ratify ADR-0002
idotta Jun 5, 2026
dba2e43
feat(spike): prove gate 8 — auth-code + PKCE browser login (subject=u…
idotta Jun 5, 2026
8a20996
Merge origin/main (V1 improvements #25) into security-improv
idotta Jun 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
196 changes: 196 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# Changelog

All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.0.0] - 2026-04-29

Phase 1 — Canonical Identity Migration. Major version bump due to
breaking schema, API, and behavior changes. Consumers must run the
canonical identity data migration before deploying this version against
existing data. Multi-instance deployments require blue/green; rolling
restart is unsupported across the v1.x → v2.0.0 boundary.

### Breaking Changes

- `IdmtUser` is global, not per-tenant. The `TenantId` column is dropped
from `AspNetUsers`. `GetTenantId()` returns null. The
`(Email, UserName, TenantId)` composite unique index is replaced by a
global unique index on `NormalizedEmail`. One identity row per human;
cross-tenant access is gated by `TenantAccess` for every user
(including SysAdmin) per locked decision #4.
- `IdmtDefaultRoleTypes.DefaultRoles` no longer contains `SysAdmin` or
`SysSupport`. SysAdmin / SysSupport identities are now expressed via
`IdmtUser.SysRole` and projected as role-string claims at sign-in.
The string constants `IdmtDefaultRoleTypes.SysAdmin` /
`IdmtDefaultRoleTypes.SysSupport` remain unchanged so existing
`RequireRole` / `RequireSysAdmin` / `RequireSysUser` policies match
without code change. Pre-existing per-tenant `AspNetRoles` rows for
SysAdmin / SysSupport become inert after migration.
- `LoginHandler` and `TokenLoginHandler` now reject authentication when
the credential-verified user has no active `TenantAccess` row for the
request's resolved tenant. The check fires after
`CheckPasswordSignInAsync` (no enumeration oracle: tenant mismatch and
bad password share the `Auth.Unauthorized` response) and before any
cookie or token is issued. Closes KR-1.
- `RegisterUser` now writes a `TenantAccess` row for the inviting tenant
in the same transaction as user creation. Without this, the
TenantAccess gate would lock newly registered users out on their next
request.
- `CreateTenantHandler` now requires `ICurrentUserService` and inserts
`TenantAccess(invokerUserId, newTenantId, IsActive=true)` in the same
inner-scope transaction as default-role seeding. Boot-time seeding
paths must use `IMultiTenantStore` + `ITenantOperationService`
directly — the handler is fail-closed when no current user is
resolved.
- `ConfirmEmailRequest` and `ResetPasswordRequest` no longer accept
`TenantIdentifier` in the body. Tenant context is derived from the
ambient request strategy. The body field is silently ignored if sent.
- `IIdmtLinkGenerator.GenerateConfirmEmailLink` /
`GeneratePasswordResetLink` no longer embed `tenantIdentifier` as a
query parameter in either the ServerConfirm or ClientForm branches.
Route-based tenant strategies still inject the configured route
token, so `/{tenant}/...` links are unaffected. Consumer SPAs that
read `tenantIdentifier` from the link URL and echoed it back in the
body must switch to host/path-based tenant routing.
- `ResetPassword` no longer flips `EmailConfirmed = true` as a side
effect of a successful reset. Email confirmation must travel through
its own confirm-email flow.
- `PUT /manage/info` no longer mutates `Email` immediately when
`NewEmail` is set. The new address is staged in
`IdmtUser.PendingEmail` and a confirmation link is sent to that
address; `Email` is committed only when the recipient POSTs to
`POST /auth/confirm-email-change` with the token. The endpoint
returns `202 Accepted` (Location: `/auth/confirm-email-change`)
instead of `200 OK` in this case. Existing clients that treated 200
as success must accept 202 and surface a "check your inbox" prompt.
- `RevokeTenantAccess` revokes by canonical `UserId` only; the prior
shadow-user deactivation path inside `ExecuteInTenantScopeAsync` is
removed.
- `GrantTenantAccess` no longer creates shadow `IdmtUser` rows; it
writes only `TenantAccess` plus optional `IdentityUserRole` rows in
a single transaction.

### Added

- `Idmt.Plugin/Models/SysRoleKind.cs` — `None=0`, `SysAdmin=1`,
`SysSupport=2`. Enum string values are deliberately equal to the
policy strings `"SysAdmin"` / `"SysSupport"` so `RequireRole` matches
without bridge code.
- `IdmtUser.SysRole` column on `AspNetUsers` — global system-role flag.
- `IdmtUser.PendingEmail` column on `AspNetUsers` — bare nullable
string staging the next email until OOB confirmation.
- `POST /auth/confirm-email-change` (AllowAnonymous) — verifies the
Identity-issued token via `ChangeEmailAsync`, atomically commits
`Email` + `EmailConfirmed`, rotates the security stamp, and clears
`PendingEmail`.
- `IIdmtLinkGenerator.GenerateConfirmEmailChangeLink` and
`ApplicationOptions.ConfirmEmailChangeFormPath` (default
`/confirm-email-change`). No `tenantIdentifier` embedded.
- `Idmt.Plugin/Migration/CanonicalIdentityDataMigrator/` — dry-run /
apply harness with SHA-256 plan-fingerprint ack handshake. Bulk
rewrites (`TenantAccess.UserId`, `IdmtAuditLog.UserId`,
`IdentityUserRole`, `AspNetUserTokens`, legacy `RevokedToken`
deletion, duplicate `IdmtUser` deletion, `SysRole` fold,
per-survivor `SecurityStamp` rotation) run inside a single
`BeginTransactionAsync` / `CommitAsync` block so any
`SaveChangesAsync` failure rolls everything back.
- `tools/Idmt.Migrator` — net10.0 console host. Args:
`--dry-run`, `--apply`,
`--ack-dryrun-fingerprint <sha>`,
`--accept-cross-tenant-merges <ids>`,
`--provider {sqlite,sqlserver}`.
- New errors: `Email.NoPendingChange`, `Email.PendingMismatch`.
- `IdmtUserClaimsPrincipalFactory` emits a `SysRole` claim when the
user's `SysRole != None` and sources the tenant claim from the
ambient `IMultiTenantContextAccessor`. Throws
`InvalidOperationException` if the ambient context is null
(fail-closed, CD-4).

### Removed

- `IdmtUser.TenantId` column.
- `IsMultiTenant()` on `IdmtUser` in `IdmtDbContext`.
- The `(Email, UserName, TenantId)` composite unique index on
`AspNetUsers`.
- Default per-tenant `SysAdmin` / `SysSupport` rows from
`IdmtDefaultRoleTypes.DefaultRoles`.
- `tenantIdentifier` query parameter from confirm-email and
password-reset URLs.
- `TenantIdentifier` field from `ConfirmEmailRequest` and
`ResetPasswordRequest`.
- The `EmailConfirmed = true` side effect from `ResetPassword`.
- Shadow-user creation in `GrantTenantAccess` and shadow-user
deactivation in `RevokeTenantAccess`.

### Migration

Required before deploying v2.0.0 against existing v1.x data. Detailed
runbook lives in `SECURITY_PHASE_1_CANONICAL_IDENTITY.md` §5 and the
v3 plan §D / §E. Short version:

1. Snapshot `Phase0SchemaSnapshot.sql` from the v1.x deployment.
2. Stop writes (blue/green cutover; rolling restart is not supported).
3. Backup the database.
4. `dotnet run --project tools/Idmt.Migrator -- --dry-run
--provider sqlserver --connection "<conn>"`. Review the divergence
report and capture the SHA-256 plan fingerprint plus any
cross-tenant merge group ids.
5. `dotnet run --project tools/Idmt.Migrator -- --apply
--ack-dryrun-fingerprint "<sha>"
--accept-cross-tenant-merges "${ACCEPT_GROUP_IDS:-}"
--provider sqlserver --connection "<conn>"`. Migrator refuses to
run if the recomputed fingerprint diverges from the ack value.
6. Apply the EF schema migration that drops `IdmtUser.TenantId`, adds
`SysRole` + `PendingEmail`, and replaces the unique index.
7. Deploy the v2.0.0 image into the green slot and cut traffic.

Audit emission during migration uses the literal sentinel TenantId
`"__migration__"` for migrator-emitted rows; query with this sentinel
to isolate migration audit traffic.

Pre-migration password-reset tokens are invalidated by the migration's
per-survivor SecurityStamp rotation. Issue fresh links if any are
in-flight.

### Security Fixes

- **Audit C3** — `ConfirmEmail` no longer trusts `TenantIdentifier`
from the request body; tenant context is derived from the ambient
resolver. Closes the cross-tenant confirmation oracle.
- **Audit C4** — `ResetPassword` no longer trusts request-body
`TenantIdentifier` and no longer flips `EmailConfirmed = true` on
success. Closes the email-confirm-via-password-reset takeover leg.
- **Audit C7** — `PUT /manage/info` stages email change in
`PendingEmail` and routes confirmation through OOB
`POST /auth/confirm-email-change`. An attacker holding a session
cookie can no longer rebind `Email` to an address they control
without proving control of the new mailbox.
- **Audit N1** — Login enforces a uniform `TenantAccess` gate (no
SysRole short-circuit). A user with credentials in tenant A can no
longer log in to tenant B by hitting B's login endpoint.
- **Audit N3** — `IdmtUserClaimsPrincipalFactory` is fail-closed when
the ambient `IMultiTenantContextAccessor` is null, preventing
silently-tenant-less principals from being constructed during
background work.
- **Audit H7** — `IdmtLinkGenerator` no longer embeds
`tenantIdentifier` in confirm-email or password-reset URLs;
hardened `AddTenantRouteParameter` skips injection when a custom
route strategy is configured under the literal name
`"tenantIdentifier"`.

### Security Notes

- **R18 (deferred to Phase 4).** `UpdateUserInfo` dropped its
`FindByEmailAsync` pre-check on `NewEmail` to avoid an enumeration
oracle. The trade-off is a third-party email-spam vector when
`PUT /manage/info` is unrate-limited. The in-plugin `RateLimiting`
option defaults to disabled (post-`cc4ab61`); consumers must opt in
today. Phase 4 will wire `PUT /manage/info` into a per-user
rate-limit policy by default.
- **KR-1 / KR-2 / KR-3 (residual).** Bearer / cookie tickets minted
before TenantAccess revocation, before migration, or against a user
with `EmailConfirmed=false` post-merge remain valid until natural
expiry. Phase 2 closes the bearer-revocation enforcement and refresh
rotation gaps; tests F31, HS-10, and F38 pin the regression windows.
99 changes: 99 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

IDMT (Identity MultiTenant) Plugin — a reusable NuGet library for ASP.NET Core providing multi-tenant identity management. Built on Finbuckle.MultiTenant and ASP.NET Core Identity with per-tenant cookie isolation, hybrid cookie/bearer authentication, and vertical slice architecture. Uses ErrorOr for result handling and FluentValidation for request validation.

**Target:** net10.0

## Build & Development Commands

```bash
# Build
dotnet build Idmt.slnx
dotnet build Idmt.slnx --configuration Release

# Format (CI enforces this)
dotnet format Idmt.slnx --verify-no-changes --verbosity diagnostic # check
dotnet format Idmt.slnx # fix

# Test
dotnet test Idmt.slnx
dotnet test tests/Idmt.UnitTests/Idmt.UnitTests.csproj # unit only
dotnet test tests/Idmt.BasicSample.Tests/Idmt.BasicSample.Tests.csproj # integration only
dotnet test --filter "FullyQualifiedName~TenantAccessServiceTests" # single test class

# Pack
dotnet pack Idmt.Plugin/Idmt.Plugin.csproj --configuration Release
```

CI runs: format check → build (warnings as errors) → analyzers → tests → pack.

## Architecture

### Vertical Slice Pattern

Each feature (Login, ForgotPassword, CreateTenant, etc.) is a self-contained static class in `Idmt.Plugin/Features/` containing:

- Request/Response records
- Handler interface returning `ErrorOr<T>`
- Internal sealed handler implementation
- FluentValidation validator (registered via DI auto-discovery)
- Endpoint mapping method using Minimal APIs

Features are grouped into: `Auth/`, `Manage/`, `Admin/`, `Health/`. Endpoints are mapped via `AuthEndpoints.cs`, `ManageEndpoints.cs`, and `AdminEndpoints.cs`.

### Error Handling

All handlers return `ErrorOr<T>`. Centralized error definitions in `Idmt.Plugin/Errors/IdmtErrors.cs` organized by domain (Auth, Tenant, User, Token, Email, Password, General). Endpoint delegates map `ErrorType` to HTTP status codes.

### Multi-Tenancy

- **Finbuckle.MultiTenant** resolves tenants via configurable strategies (Header, Route, Claim, BasePath)
- `IdmtUser` extends `IdentityUser<Guid>` and is **global** (not multi-tenant). `GetTenantId()` returns null. One identity row per human; `NormalizedEmail` is globally unique.
- `IdmtRole` remains per-tenant. The default role catalog (`IdmtDefaultRoleTypes.DefaultRoles`) was shrunk and no longer seeds `SysAdmin` / `SysSupport` per tenant.
- `IdmtUser.SysRole` (`SysRoleKind` enum: `None` / `SysAdmin` / `SysSupport`) is a global system-role flag projected as a role-string claim at sign-in. Enum string values equal the policy strings so `RequireRole` / `RequireSysAdmin` / `RequireSysUser` match without bridge code.
- `TenantAccess` maps users to tenants with `IsActive` and optional `ExpiresAt`. The TenantAccess gate is **uniform** across all users (including SysAdmin) per locked decision #4 — there is no SysRole short-circuit. `LoginHandler` / `TokenLoginHandler` enforce it after `CheckPasswordSignInAsync` and before any cookie/token is issued.
- Password and `SecurityStamp` are single-source on the canonical user row. Rotations propagate everywhere automatically — no shadow rows to keep in sync.
- `IdmtUser.PendingEmail` (nullable string) stages the next email during the OOB email-change flow. `Email` is committed only when the recipient POSTs to `/auth/confirm-email-change` with the Identity-issued token (returns 202 Accepted from `PUT /manage/info` while staged).
- Per-tenant cookie isolation: each tenant gets a separate authentication cookie name
- `ValidateBearerTokenTenantMiddleware` ensures bearer token tenant matches request tenant
- Two EF contexts: `IdmtDbContext` (multi-tenant app data) and `IdmtTenantStoreDbContext` (tenant metadata)
- `ITenantOperationService` executes code within a tenant-scoped DI scope. Invariant: inner-scope `CurrentUserService.User` must stay null; capture invoker context outside `ExecuteInTenantScopeAsync`.

### Authentication & Authorization

- **PolicyScheme** (`CookieOrBearerScheme`) auto-selects cookie vs bearer based on `Authorization` header
- Pre-configured policies: `RequireSysAdmin`, `RequireSysUser`, `RequireTenantManager`, `CookieOnly`, `BearerOnly`
- Token revocation via `ITokenRevocationService` with background cleanup (`TokenRevocationCleanupService`)

### Key Services

- `ICurrentUserService` (scoped) — current user, tenant, IP, user agent context
- `ITenantAccessService` — tenant access validation
- `ITokenRevocationService` — bearer token revocation store
- `IIdmtLinkGenerator` — email confirmation/password reset link generation
- `PiiMasker` — masks emails in structured logs

### DI Entry Point

`AddIdmt<TDbContext>()` extension method in `ServiceCollectionExtensions` with parameters: `configuration`, `configureDb`, `configureOptions`, `customizeAuthentication`, `customizeAuthorization`.

## Testing

- **Unit tests** (`tests/Idmt.UnitTests`): xUnit + Moq + EF InMemory + TimeProvider.Testing
- **Integration tests** (`tests/Idmt.BasicSample.Tests`): xUnit + `Microsoft.AspNetCore.Mvc.Testing` with in-memory SQLite
- `IdmtApiFactory` — WebApplicationFactory with mocked email sender and test data seeding
- `BaseIntegrationTest` — helpers for authenticated requests and token extraction

## Key References

- [Finbuckle.MultiTenant Docs](https://www.finbuckle.com/MultiTenant/Docs/)
- [Finbuckle GitHub](https://github.com/Finbuckle/Finbuckle.MultiTenant) (check older tag samples like v8.0.0)
- [ASP.NET Core Identity](https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity)

## Commit Conventions

- Do NOT add `Co-Authored-By: Claude` (or any AI attribution) trailers to commit messages. Author the commit as the user only.
1 change: 1 addition & 0 deletions Idmt.Plugin/Configuration/IdmtOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ public class ApplicationOptions

public string ResetPasswordFormPath { get; set; } = "/reset-password";
public string ConfirmEmailFormPath { get; set; } = "/confirm-email";
public string ConfirmEmailChangeFormPath { get; set; } = "/confirm-email-change";

/// <summary>
/// Controls how email confirmation links are generated.
Expand Down
12 changes: 12 additions & 0 deletions Idmt.Plugin/Errors/IdmtErrors.cs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ public static class Email
public static Error ConfirmationFailed => Error.Failure(
code: "Email.ConfirmationFailed",
description: "Unable to confirm email");

public static Error NoPendingChange => Error.Validation(
code: "Email.NoPendingChange",
description: "No pending email change to confirm.");

public static Error PendingMismatch => Error.Validation(
code: "Email.PendingMismatch",
description: "Pending email does not match request.");
}

public static class Password
Expand All @@ -149,5 +157,9 @@ public static class General
public static Error Unexpected => Error.Unexpected(
code: "General.Unexpected",
description: "An unexpected error occurred");

public static Error SelfTarget => Error.Forbidden(
code: "General.SelfTarget",
description: "This operation cannot target the caller");
}
}
Loading
Loading