diff --git a/CHANGELOG.md b/CHANGELOG.md index 259b651..a4e4865 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this repository will be documented in this file. The form ### Added +- **`Trellis.Microservices.Abstractions` package** — first package in this repo. Ships one public static class `TrellisInternalJwtClaimNames` with the canonical contract literals (`Subject`, `JwtId`, `Permissions`, `ForbiddenPermissions`, `ContractVersion`, `PermissionsCount`, `ForbiddenPermissionsCount`, `CurrentContractVersion = "1"`). Promotes the previously-internal `TrellisInternalJwtClaimNames` (from `xavierjohn/Trellis`'s `Trellis.Yarp`) to `public`, eliminating the duplication where the consumer side (`Trellis.Asp.Authorization.TrellisInternalJwtActorOptions` defaults) hard-coded the same strings by convention. AOT-compatible, no runtime dependencies. +- Tests: `Trellis.Microservices.Abstractions.Tests` — pins every literal value, snapshots the public const surface (catches silent additions / removals), and asserts non-empty + unique values. + +### Notes + +- The pre-existing `internal` copy of `TrellisInternalJwtClaimNames` (currently in `xavierjohn/Trellis`'s `Trellis.Yarp/src/`) is the duplication risk this package eliminates. When `Trellis.Yarp` lands in this repo via PR C, that version will reference `Trellis.Microservices.Abstractions.TrellisInternalJwtClaimNames` instead of carrying its own internal copy. **Removal of the internal copy from the upstream `xavierjohn/Trellis` repository is a separate PR (PR D) against that repo**, documented in that repo's CHANGELOG — not this one. + +## 0.1-alpha — Initial bootstrap + +### Added + - Initial repository scaffolding: `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, `.gitattributes`, `global.json`, `version.json`, `nuget.config`, `LICENSE`, `README.md`, `CHANGELOG.md`, `Trellis.Microservices.slnx`, `build/test.props`, `build/Trellis.ApiReference.targets`, `icon.png`. - LLM-discoverability documentation under `docs/docfx_project/api_reference/`: - `trellis-api-microservices-cookbook.md` — entry-point cookbook with task-lookup table and recipe placeholders (Recipes 1 and 2 will be inlined verbatim from `xavierjohn/Trellis` Recipes 33 + 34 when the source files land). @@ -14,8 +25,5 @@ All notable changes to this repository will be documented in this file. The form - `trellis-api-microservices-abstractions.md` — new abstractions package reference. - `.github/copilot-instructions.md` — agent instructions with the "P4 invariants — never regress" 14-row checklist for any change touching minter / validator / provider code. - `.github/dependabot.yml` — weekly GitHub Actions and NuGet updates. +- `docs/lint-api-reference.{ps1,md}` — API-reference doc lint (opt-in per project via `true`). -### Notes - -- No `.cs` files yet. Source for the three packages lands in follow-up PRs: B (`Trellis.Microservices.Abstractions`), then C (`Trellis.Yarp` move + `Trellis.Microservices.AspNetCore` carve-out from `xavierjohn/Trellis`). -- Package preview NuGets will be published once PR C lands. diff --git a/Trellis.Microservices.Abstractions/src/NUGET_README.md b/Trellis.Microservices.Abstractions/src/NUGET_README.md new file mode 100644 index 0000000..e8c3093 --- /dev/null +++ b/Trellis.Microservices.Abstractions/src/NUGET_README.md @@ -0,0 +1,38 @@ +# Trellis.Microservices.Abstractions + +Shared contract constants for the Trellis internal-network JWT v1. + +This package ships **one public static class** — `TrellisInternalJwtClaimNames` — that pairs the gateway-side minter (`Trellis.Yarp`) with the consumer-side actor provider (`Trellis.Microservices.AspNetCore`). Both sides reference these literals so any future contract version bump is one coordinated change. + +## Properties + +- AOT-compatible — ships only `public const string` literals +- No runtime dependencies +- Tiny — single class + +## Usage + +```csharp +using Trellis.Microservices.Abstractions; + +identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.ContractVersion, + TrellisInternalJwtClaimNames.CurrentContractVersion)); +identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.PermissionsCount, "3")); +identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.Permissions, "orders:read")); +``` + +If you are using `Trellis.Yarp` AND `Trellis.Microservices.AspNetCore` (the standard pairing), you do NOT need to reference this package directly — both reference it transitively. + +## When to reference directly + +- You are implementing a third-party gateway against the Trellis internal JWT contract. +- You are implementing a custom consumer-side actor provider. +- You are writing an integration test that hand-crafts JWTs. + +## Documentation + +Full reference: [`trellis-api-microservices-abstractions.md`](https://github.com/xavierjohn/Trellis.Microservices/blob/main/docs/docfx_project/api_reference/trellis-api-microservices-abstractions.md). + +## License + +MIT. diff --git a/Trellis.Microservices.Abstractions/src/README.md b/Trellis.Microservices.Abstractions/src/README.md new file mode 100644 index 0000000..7edd302 --- /dev/null +++ b/Trellis.Microservices.Abstractions/src/README.md @@ -0,0 +1,65 @@ +# Trellis.Microservices.Abstractions + +Shared contract constants for the [Trellis](https://github.com/xavierjohn/Trellis) internal-network JWT v1. + +This package ships **one public static class** — `TrellisInternalJwtClaimNames` — that pairs the gateway-side minter ([`Trellis.Yarp`](../../docs/docfx_project/api_reference/trellis-api-yarp.md#use-this-file-when)) with the consumer-side actor provider ([`Trellis.Microservices.AspNetCore`](../../docs/docfx_project/api_reference/trellis-api-internal-jwt.md#use-this-file-when)). Both sides reference these literals so a future contract version bump is one coordinated change. + +## Why it exists + +Without this package, the canonical claim names lived as `internal const` literals inside `Trellis.Yarp`, with the consumer side hard-coding the same strings as defaults in `TrellisInternalJwtActorOptions`. Both sides agreed by **convention** — the only enforcement was code review and a contract test that loaded both projects and asserted equality. The risk was real: a typo or future-contract-version change to one side without the other would create a silent fail-open / fail-closed divergence (one side accepts a token, the other rejects, depending on the direction of the typo). + +This package promotes those constants to `public`, gives them a stable namespace, and lets BOTH sides reference the same literals. Third-party gateway and consumer implementations now have a versioned NuGet contract to compile against. + +## Properties + +- AOT-compatible — ships only `public const string` literals +- No runtime dependencies +- Tiny — single class, ~80 lines + +## Usage + +Add a NuGet reference: + +```xml + +``` + +Then reference the constants: + +```csharp +using Trellis.Microservices.Abstractions; + +// In a custom gateway or test: +identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.ContractVersion, + TrellisInternalJwtClaimNames.CurrentContractVersion)); +identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.PermissionsCount, "3")); +identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.Permissions, "orders:read")); +// ... +``` + +If you are using `Trellis.Yarp` AND `Trellis.Microservices.AspNetCore` (the standard pairing), you do NOT need to reference this package directly. Both packages reference it transitively and you can rely on the defaults in `TrellisActorForwardingOptions` / `TrellisInternalJwtActorOptions`. + +## Contract integrity rules + +These are enforced jointly by the gateway and consumer. A third-party implementation that omits any of them is **not** contract-conformant. + +1. **Always emit the sentinel.** Every minted token MUST carry `ContractVersion = CurrentContractVersion`. The consumer fails closed (`Maybe.None`) on missing or duplicated sentinel. +2. **Always emit both counts.** `PermissionsCount` and `ForbiddenPermissionsCount` MUST be emitted as decimal-string non-negative integers, including `"0"`. +3. **Always emit `JwtId`.** Fresh per token; the audit-correlation key. +4. **Permissions / ForbiddenPermissions are multi-valued, never joined.** The consumer's `StrictClaimShape = true` (default) rejects values containing `,` or starting with `[` / `{`. +5. **Counts must equal observed multi-valued occurrences.** Off-by-one yields `Maybe.None`. + +See the full spec in [`trellis-api-microservices-abstractions.md`](../../docs/docfx_project/api_reference/trellis-api-microservices-abstractions.md). + +## Version compatibility + +This package is **versioned independently** from the gateway and consumer packages. Within a single contract version (`CurrentContractVersion = "1"`), the literals are immutable and will not change in any v1.x release. + +A future v2 will: +- Ship a new major version of `Trellis.Microservices.Abstractions` with `CurrentContractVersion = "2"` (and potentially renamed / added claim members). +- Ship matching major versions of `Trellis.Yarp` and `Trellis.Microservices.AspNetCore` that depend on the new abstractions major. +- Provide a migration runbook for operators standing up a heterogeneous-version fleet during rollout. + +## License + +[MIT](../../LICENSE). diff --git a/Trellis.Microservices.Abstractions/src/Trellis.Microservices.Abstractions.csproj b/Trellis.Microservices.Abstractions/src/Trellis.Microservices.Abstractions.csproj new file mode 100644 index 0000000..c33248b --- /dev/null +++ b/Trellis.Microservices.Abstractions/src/Trellis.Microservices.Abstractions.csproj @@ -0,0 +1,24 @@ + + + + Trellis.Microservices.Abstractions + Shared contract constants for the Trellis internal-network JWT v1, paired with Trellis.Yarp (gateway-side minter) and Trellis.Microservices.AspNetCore (consumer-side actor provider). Ships only public string literals — AOT-compatible, no runtime dependencies. + trellis;microservices;jwt;contract;abstractions;ddd + + + microservices-abstractions + + + true + + + true + + + + + + + diff --git a/Trellis.Microservices.Abstractions/src/TrellisInternalJwtClaimNames.cs b/Trellis.Microservices.Abstractions/src/TrellisInternalJwtClaimNames.cs new file mode 100644 index 0000000..8ba9700 --- /dev/null +++ b/Trellis.Microservices.Abstractions/src/TrellisInternalJwtClaimNames.cs @@ -0,0 +1,86 @@ +namespace Trellis.Microservices.Abstractions; + +/// +/// Canonical JWT claim names that pair the gateway-side minter (in +/// Trellis.Yarp) with the consumer-side actor provider (in +/// Trellis.Microservices.AspNetCore) for the Trellis internal-network +/// JWT v1 contract. +/// +/// +/// +/// These names match the default TrellisInternalJwtActorOptions values on +/// the consumer side AND the literal strings the gateway-side minter emits. +/// Centralizing them in a single public class minimizes the risk of operational +/// drift between the gateway operator and the downstream service operator — both +/// sides reference these literals so any future contract version bump is a single +/// coordinated change. +/// +/// +/// Consumers who need different claim names MUST configure both the gateway-side +/// minter AND the downstream TrellisInternalJwtActorOptions in lock-step. +/// v1 does not expose claim-name overrides on the gateway side — the contract is +/// the contract. +/// +/// +/// Third-party gateway and consumer implementations targeting the same contract +/// MUST reference this package and these literals; freshly typing the strings +/// risks introducing a typo that splits the gateway and consumer sides on a +/// silent fail-open / fail-closed boundary. +/// +/// +public static class TrellisInternalJwtClaimNames +{ + /// + /// JWT sub claim (the registered subject claim). Carries the namespaced + /// actor identifier produced by the gateway's ActorIdResolver. + /// + public const string Subject = "sub"; + + /// + /// JWT jti claim (the registered token-identifier claim). Fresh per token + /// (cryptographically-random GUID-N) so audit pipelines can correlate every minted + /// JWT to a single mint event without leaking actor identity. + /// + public const string JwtId = "jti"; + + /// + /// Per-actor authorization-grant claim. Emitted multi-valued (one JSON-array entry + /// per permission) — NEVER comma-joined or JSON-stringified, per the strict-shape + /// contract enforced by the consumer side. + /// + public const string Permissions = "permissions"; + + /// + /// Per-actor deny-set claim. Emitted multi-valued. The deny-overrides-allow + /// contract invariant requires the matching + /// claim to ALWAYS be emitted (even when the set is empty) so the consumer can + /// distinguish "evaluated to empty" from "stripped by a misbehaving proxy." + /// + public const string ForbiddenPermissions = "forbidden_permissions"; + + /// + /// Sentinel claim asserting which version of the internal-JWT contract this token + /// conforms to. v1 emits the literal "1" (see ). + /// + public const string ContractVersion = "trellis_actor_contract_version"; + + /// + /// Decimal-string count of claims emitted in the same + /// token. Always emitted (including "0" for empty sets) so the consumer + /// can fail closed when a proxy strips the multi-valued permission claims. + /// + public const string PermissionsCount = "trellis_permissions_count"; + + /// + /// Decimal-string count of claims emitted in the + /// same token. Always emitted (including "0" for empty sets) so the consumer + /// can detect the privilege-escalation footgun where a malicious proxy strips the + /// deny set silently. + /// + public const string ForbiddenPermissionsCount = "trellis_forbidden_permissions_count"; + + /// + /// The contract version value emitted by v1. + /// + public const string CurrentContractVersion = "1"; +} diff --git a/Trellis.Microservices.Abstractions/tests/Trellis.Microservices.Abstractions.Tests.csproj b/Trellis.Microservices.Abstractions/tests/Trellis.Microservices.Abstractions.Tests.csproj new file mode 100644 index 0000000..a62e265 --- /dev/null +++ b/Trellis.Microservices.Abstractions/tests/Trellis.Microservices.Abstractions.Tests.csproj @@ -0,0 +1,11 @@ + + + + false + + + + + + + diff --git a/Trellis.Microservices.Abstractions/tests/TrellisInternalJwtClaimNamesTests.cs b/Trellis.Microservices.Abstractions/tests/TrellisInternalJwtClaimNamesTests.cs new file mode 100644 index 0000000..5afdd73 --- /dev/null +++ b/Trellis.Microservices.Abstractions/tests/TrellisInternalJwtClaimNamesTests.cs @@ -0,0 +1,132 @@ +using System.Reflection; +using Trellis.Microservices.Abstractions; + +namespace Trellis.Microservices.Abstractions.Tests; + +/// +/// These tests pin the EXACT public claim-name literals shipped by v1 of the +/// Trellis internal JWT contract. Any change to a value here is a contract break +/// and requires a coordinated major-version bump of this package, Trellis.Yarp, +/// and Trellis.Microservices.AspNetCore — do not "fix" a failing test here by +/// editing the expected literal. +/// +public class TrellisInternalJwtClaimNamesTests +{ + [Fact] + public void Subject_is_exactly_sub() => + TrellisInternalJwtClaimNames.Subject.Should().Be("sub"); + + [Fact] + public void JwtId_is_exactly_jti() => + TrellisInternalJwtClaimNames.JwtId.Should().Be("jti"); + + [Fact] + public void Permissions_is_exactly_permissions() => + TrellisInternalJwtClaimNames.Permissions.Should().Be("permissions"); + + [Fact] + public void ForbiddenPermissions_is_exactly_forbidden_permissions() => + TrellisInternalJwtClaimNames.ForbiddenPermissions.Should().Be("forbidden_permissions"); + + [Fact] + public void ContractVersion_is_exactly_trellis_actor_contract_version() => + TrellisInternalJwtClaimNames.ContractVersion.Should().Be("trellis_actor_contract_version"); + + [Fact] + public void PermissionsCount_is_exactly_trellis_permissions_count() => + TrellisInternalJwtClaimNames.PermissionsCount.Should().Be("trellis_permissions_count"); + + [Fact] + public void ForbiddenPermissionsCount_is_exactly_trellis_forbidden_permissions_count() => + TrellisInternalJwtClaimNames.ForbiddenPermissionsCount.Should().Be("trellis_forbidden_permissions_count"); + + [Fact] + public void CurrentContractVersion_is_exactly_1() => + TrellisInternalJwtClaimNames.CurrentContractVersion.Should().Be("1"); + + /// + /// Snapshot guard: catches silent additions / removals to the public surface. If a + /// new constant is added to the contract (a v2 evolution), this test fails so the + /// reviewer is forced to acknowledge the contract surface change explicitly. + /// + [Fact] + public void Public_const_surface_is_exactly_eight_members() + { + var members = typeof(TrellisInternalJwtClaimNames) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => f.Name) + .OrderBy(n => n, StringComparer.Ordinal) + .ToArray(); + + members.Should().Equal( + "ContractVersion", + "CurrentContractVersion", + "ForbiddenPermissions", + "ForbiddenPermissionsCount", + "JwtId", + "Permissions", + "PermissionsCount", + "Subject"); + } + + /// + /// All values must be non-null, non-empty strings — defensive guard against a + /// future refactor that introduces a typo collapsing one to the empty string. + /// + [Fact] + public void All_constants_are_non_empty() + { + var values = typeof(TrellisInternalJwtClaimNames) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly) + .Select(f => (string)f.GetRawConstantValue()!); + + values.Should().OnlyContain(v => !string.IsNullOrEmpty(v)); + } + + /// + /// Reserved JWT claim names (sub, jti) and structural Trellis names + /// (permissions, forbidden_permissions, trellis_*) must not collide with + /// each other. Catches a copy-paste bug where two consts get the same value. + /// + [Fact] + public void All_constant_values_are_unique() + { + var values = typeof(TrellisInternalJwtClaimNames) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.IsLiteral && !f.IsInitOnly && f.Name != nameof(TrellisInternalJwtClaimNames.CurrentContractVersion)) + .Select(f => (string)f.GetRawConstantValue()!) + .ToArray(); + + values.Should().OnlyHaveUniqueItems(); + } + + /// + /// Type modifier pin: the class MUST be `public static class`. Because + /// `Directory.Build.props` declares `InternalsVisibleTo` on the test + /// assembly unconditionally, the const-value pinning tests above would + /// still pass if the class accidentally got changed to `internal` — but + /// the published NuGet package would be unusable by external consumers. + /// This test catches that regression. + /// + [Fact] + public void Type_modifiers_match_public_static_class_contract() + { + var type = typeof(TrellisInternalJwtClaimNames); + + type.IsPublic.Should().BeTrue("the consumer-facing contract requires the class to be public"); + type.IsAbstract.Should().BeTrue("static classes are abstract sealed in CLR metadata"); + type.IsSealed.Should().BeTrue("static classes are abstract sealed in CLR metadata"); + + var publicMembers = type.GetMembers(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly); + var nonConstMembers = publicMembers + .Where(m => m is not FieldInfo f || !f.IsLiteral || f.IsInitOnly) + .Select(m => $"{m.MemberType}:{m.Name}") + .ToArray(); + + nonConstMembers.Should().BeEmpty( + "the only declared public members are the eight const string fields — adding " + + "any other member is a contract surface change that must be acknowledged"); + } +} diff --git a/Trellis.Microservices.slnx b/Trellis.Microservices.slnx index 2230e17..0bb5c35 100644 --- a/Trellis.Microservices.slnx +++ b/Trellis.Microservices.slnx @@ -22,4 +22,11 @@ + + + + + + +