Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 12 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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 `<TrellisEnableApiReferenceLint>true</TrellisEnableApiReferenceLint>`).

### 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.
38 changes: 38 additions & 0 deletions Trellis.Microservices.Abstractions/src/NUGET_README.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions Trellis.Microservices.Abstractions/src/README.md
Original file line number Diff line number Diff line change
@@ -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
<PackageReference Include="Trellis.Microservices.Abstractions" />
```

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<Actor>.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<Actor>.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).
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<PackageId>Trellis.Microservices.Abstractions</PackageId>
<Description>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.</Description>
<PackageTags>trellis;microservices;jwt;contract;abstractions;ddd</PackageTags>

<!-- Auto-pack the API reference markdown to trellis/ folder in the .nupkg so LLMs
discover it without a separate doc fetch. See Directory.Build.targets. -->
<TrellisApiRefName>microservices-abstractions</TrellisApiRefName>

<!-- The foundational package every microservices consumer references transitively;
ship the cookbook into .github/ once via this package. See Directory.Build.targets. -->
<TrellisShipsCookbook>true</TrellisShipsCookbook>

<!-- AOT compatibility — this package ships only public const string literals. -->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

<ItemGroup>
<None Include="NUGET_README.md" Pack="true" PackagePath="\README.md" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
namespace Trellis.Microservices.Abstractions;

/// <summary>
/// Canonical JWT claim names that pair the gateway-side minter (in
/// <c>Trellis.Yarp</c>) with the consumer-side actor provider (in
/// <c>Trellis.Microservices.AspNetCore</c>) for the Trellis internal-network
/// JWT v1 contract.
/// </summary>
/// <remarks>
/// <para>
/// These names match the default <c>TrellisInternalJwtActorOptions</c> 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.
/// </para>
/// <para>
/// Consumers who need different claim names MUST configure both the gateway-side
/// minter AND the downstream <c>TrellisInternalJwtActorOptions</c> in lock-step.
/// v1 does not expose claim-name overrides on the gateway side — the contract is
/// the contract.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
public static class TrellisInternalJwtClaimNames
{
/// <summary>
/// JWT <c>sub</c> claim (the registered subject claim). Carries the namespaced
/// actor identifier produced by the gateway's <c>ActorIdResolver</c>.
/// </summary>
public const string Subject = "sub";

/// <summary>
/// JWT <c>jti</c> 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.
/// </summary>
public const string JwtId = "jti";

/// <summary>
/// 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.
/// </summary>
public const string Permissions = "permissions";

/// <summary>
/// Per-actor deny-set claim. Emitted multi-valued. The deny-overrides-allow
/// contract invariant requires the matching <see cref="ForbiddenPermissionsCount"/>
/// 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."
/// </summary>
public const string ForbiddenPermissions = "forbidden_permissions";

/// <summary>
/// Sentinel claim asserting which version of the internal-JWT contract this token
/// conforms to. v1 emits the literal <c>"1"</c> (see <see cref="CurrentContractVersion"/>).
/// </summary>
public const string ContractVersion = "trellis_actor_contract_version";

/// <summary>
/// Decimal-string count of <see cref="Permissions"/> claims emitted in the same
/// token. Always emitted (including <c>"0"</c> for empty sets) so the consumer
/// can fail closed when a proxy strips the multi-valued permission claims.
/// </summary>
public const string PermissionsCount = "trellis_permissions_count";

/// <summary>
/// Decimal-string count of <see cref="ForbiddenPermissions"/> claims emitted in the
/// same token. Always emitted (including <c>"0"</c> for empty sets) so the consumer
/// can detect the privilege-escalation footgun where a malicious proxy strips the
/// deny set silently.
/// </summary>
public const string ForbiddenPermissionsCount = "trellis_forbidden_permissions_count";

/// <summary>
/// The contract version value emitted by v1.
/// </summary>
public const string CurrentContractVersion = "1";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\src\Trellis.Microservices.Abstractions.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
using System.Reflection;
using Trellis.Microservices.Abstractions;

namespace Trellis.Microservices.Abstractions.Tests;

/// <summary>
/// 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.
/// </summary>
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");

/// <summary>
/// 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.
/// </summary>
[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");
}

/// <summary>
/// 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.
/// </summary>
[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));
}

/// <summary>
/// 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.
/// </summary>
[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();
}

/// <summary>
/// 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.
/// </summary>
[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");
}
}
Loading