Files
DodoSSH/tests/DodoSSH.Api.Tests/EndpointInventoryTests.cs
T
jaap-jan d5b1a73182 Move the keys when a membership changes, not just the flag
Adding somebody to a team granted them nothing readable and removing them
rotated nothing. Both were honest — the interface said so in as many words — and
both left the actual work to a button somebody had to remember to press, on a
machine that happened to hold the key. Adding now wraps every team vault this
machine can open to the new member, and removing revokes their grants and moves
each of those vaults to a fresh key that goes to whoever is left.

The rotation is where the design had to be decided rather than written. A vault
key is per generation and an item carries the generation it was sealed under, so
advancing the vault and withdrawing the old grants would make everything already
stored unreadable to everybody, including whoever pressed the button. So earlier
grants are kept: a member holds one per generation, /me serves them as
PriorKeyWraps, and VaultKeyring holds a key per generation — the newest for
writing, the item's own for reading, chosen per item on every read path. Sharing
issues one grant per generation held, because a recipient handed only the current
key would open the vault to find most of it undecryptable; revocation takes every
generation, because leaving the history behind leaves them able to read
everything written before the rotation.

The bump itself is one server transaction. POST /vaults/{id}/rekey must name
exactly current + 1 and the vault's xmin token makes that binding, so two admins
rotating at once do not both walk away believing they succeeded — the second is
refused and told to read the vault again. The server contributes the moment and
no cryptography: it cannot generate the key, cannot tell that the one it is
handed differs from the old one, and checks that the caller held the old one the
only way it can, by requiring a live grant at the current generation.

What this does not do is re-encrypt what is already stored, and the product says
so rather than the reassuring version: everything written from the rotation
onwards is unreadable to the person who left, and nothing about the past changes.
That half is deferred and is safe to add incrementally precisely because a vault
at mixed generations stays readable. ADR 0010 records the alternatives — revoking
the old grants, chaining each key under its successor, re-sealing every item in
one request against a server that caps a push at 500 operations — and why each
was rejected.

Two things fell out of the change rather than being asked for. The grant listing
would have shown a member once per generation, so it now returns one row per
holder carrying the best key they hold, which is what makes a row below the
vault's generation mean "still owed the new key". And MarkUnreadable gives up the
write target as well as reporting: a client whose vault was rotated elsewhere
would otherwise have gone on sealing items under its superseded key — readable to
its author, unreadable to everybody else, with nothing to show for it.
2026-08-03 23:05:40 +02:00

203 lines
12 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Every route the server exposes, and the authorization decision behind each one.
/// </summary>
/// <remarks>
/// <para>
/// The inventory test ADR 0002 asked for. Endpoints are registered from an explicit type list in
/// <c>Setup/EndpointRegistration.cs</c> rather than found by scanning, which trades one failure mode for
/// another: nothing can appear by accident, but a type left off the list is a route that quietly does
/// not exist, with no compile error and — without this test — no failure either. Asserting the whole set
/// rather than a subset is the point. A new endpoint cannot ship until somebody writes down, here, who
/// is allowed to call it.
/// </para>
/// <para>
/// It is also the only coverage <c>/api/v1/meta</c> and <c>/.well-known/dodossh-configuration</c> have
/// in <em>this</em> assembly — <c>DodoSSH.SystemTests</c> does fetch both anonymously, but that suite
/// needs a Docker daemon and several containers, so it is not what a developer runs before pushing.
/// Both are anonymous only because they say so, and a client has nothing to authenticate with at the
/// point it reads them, so a 401 on either is unrecoverable rather than merely wrong.
/// </para>
/// <para>
/// What this does <em>not</em> cover: <see cref="Describe"/> reads only <see cref="IAuthorizeData.Policy"/>,
/// so a role, claim, scope or authentication-scheme requirement could be added or removed without
/// failing here. Nothing uses those today; the day something does, this table needs a column.
/// </para>
/// </remarks>
[Collection(ApiCollection.Name)]
public sealed class EndpointInventoryTests(ApiFixture fixture)
{
private static readonly string[] Expected =
[
// Anonymous by necessity: discovery has to work before a token exists.
"GET /api/v1/meta name=GetMeta tags= policies= anon=True",
"GET /.well-known/dodossh-configuration name=GetDodoSshConfiguration tags= policies= anon=True",
// Authenticated, not Enrolled: these three are how a caller discovers it must enroll, does so,
// and — for revocation — withdraws a lost machine even if its enrollment state is in doubt.
"GET /api/v1/me name=GetMe tags=Identity policies=Authenticated anon=False",
"POST /api/v1/me/enrollment name=Enroll tags=Identity policies=Authenticated anon=False",
"DELETE /api/v1/me/devices/{deviceId:guid} name=RevokeDevice tags=Identity policies=Authenticated anon=False",
// The one endpoint in the /me area that needs a key bundle to already exist.
"POST /api/v1/me/devices name=RegisterDevice tags=Identity policies=Authenticated,Enrolled anon=False",
// Enrolled: a caller with no identity key can neither write ciphertext anyone can read nor read
// what is there.
"POST /api/v1/vaults/{vaultId:guid}/sync/pull name=SyncPull tags=Sync policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/sync/push name=SyncPush tags=Sync policies=Enrolled anon=False",
// Enrolled, because the answer exists to be wrapped to and a caller with no key of their own has
// nothing to wrap and no signature to attribute it with. There is no search here — see
// DirectoryService for why an exact-match-only directory is a decision rather than a shortcut.
"GET /api/v1/directory name=LookupDirectory tags=Identity policies=Enrolled anon=False",
// The other half of the same decision: the directory says what a key is, this is how a client
// checks that claim against a chain the server cannot rewrite without every other client
// noticing. Nothing in it is secret.
"GET /api/v1/keylog name=ReadKeyLog tags=Identity policies=Enrolled anon=False",
// Authenticated, not Enrolled: reading and joining teams needs no key, and a member added before
// they have set a vault up must still be able to see the team they are now in.
"GET /api/v1/teams name=ListTeams tags=Teams policies=Authenticated anon=False",
"GET /api/v1/teams/{teamId:guid}/members name=ListTeamMembers tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/members name=AddTeamMember tags=Teams policies=Authenticated anon=False",
"PUT /api/v1/teams/{teamId:guid}/members/{userId:guid}/role name=ChangeTeamMemberRole tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/members/{userId:guid} name=RemoveTeamMember tags=Teams policies=Authenticated anon=False",
// Administering a team you are already in, and none of it moves key material — so Authenticated
// for the same reason the membership routes above are. Two of the three are gated harder inside
// the handler than this table can show: archiving and handing the team over check for the owner
// rather than for an admin, because an admin the owner promoted must not be able to take the
// team from them. See TeamAccess.IsOwner.
"PUT /api/v1/teams/{teamId:guid} name=UpdateTeam tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid} name=ArchiveTeam tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/owner name=TransferTeamOwnership tags=Teams policies=Authenticated anon=False",
// Authenticated, and pointedly not Enrolled. An invitation names an address that may have no
// account at all and certainly holds no key; gating these on Enrolled would be demanding a key
// of the one participant the feature exists for. Membership is not readability — somebody still
// has to wrap the vault key afterwards — so no key is involved on either side.
"GET /api/v1/teams/{teamId:guid}/invitations name=ListTeamInvitations tags=Teams policies=Authenticated anon=False",
"POST /api/v1/teams/{teamId:guid}/invitations name=CreateTeamInvitation tags=Teams policies=Authenticated anon=False",
"DELETE /api/v1/teams/{teamId:guid}/invitations/{invitationId:guid} name=RevokeTeamInvitation tags=Teams policies=Authenticated anon=False",
// Enrolled, because both end in a vault key being wrapped: creating a team means creating a vault
// in it, and neither is reachable without a key of one's own.
"POST /api/v1/teams name=CreateTeam tags=Teams policies=Enrolled anon=False",
"POST /api/v1/teams/{teamId:guid}/vaults name=CreateTeamVault tags=Teams policies=Enrolled anon=False",
// Enrolled. The listing is gated on Read rather than Share — every member can already see the
// sharing graph — and the two writes are gated on Share inside the handler, which this table
// cannot see. See VaultGrantEndpoints.
"GET /api/v1/vaults/{vaultId:guid}/grants name=ListVaultGrants tags=Vaults policies=Enrolled anon=False",
"POST /api/v1/vaults/{vaultId:guid}/grants name=IssueVaultGrant tags=Vaults policies=Enrolled anon=False",
"DELETE /api/v1/vaults/{vaultId:guid}/grants/{userId:guid} name=RevokeVaultGrant tags=Vaults policies=Enrolled anon=False",
// Gated on Share inside the handler as the two writes above are, and additionally on holding the
// current key — which no policy could express, since it is a row in vault_key_grant.
"POST /api/v1/vaults/{vaultId:guid}/rekey name=RekeyVault tags=Vaults policies=Enrolled anon=False",
// Anonymous on purpose, and load-bearing: DodoSSH.SystemTests waits on /healthz/ready before any
// token exists, and an orchestrator probe that needs credentials reports the wrong thing.
// MapHealthChecks constrains no verb, hence ANY.
"ANY /healthz/live name= tags= policies= anon=True",
"ANY /healthz/ready name= tags= policies= anon=True",
"ANY /healthz/startup name= tags= policies= anon=True",
// Not ours. FastEndpoints maps this one itself, in every environment, with no way to opt out; it
// answers with the server's whole endpoint-name-to-route table. It stays registered and is
// short-circuited to 404 instead — see RouteTableIsNotReachable below. Listed so that a version
// bump which adds a second hidden route, or renames this one out from under the block, fails
// here rather than in production.
"GET _test_url_cache_ name= tags= policies= anon=False",
];
[Fact]
public void TheServerExposesExactlyTheEndpointsWeMeantTo()
{
// Forces the pipeline to be built. FastEndpoints registers its routes when the application is
// built, not when its services are, so resolving the data sources from an unstarted host finds
// the health checks and nothing else.
using var client = fixture.CreateClient();
var actual = fixture.Services.GetServices<EndpointDataSource>()
.SelectMany(source => source.Endpoints)
.OfType<RouteEndpoint>()
.Select(Describe)
.Order(StringComparer.Ordinal)
.ToArray();
actual.ShouldBe([.. Expected.Order(StringComparer.Ordinal)]);
}
/// <remarks>
/// <para>
/// Distinct from the inventory above, which only proves the route is registered. This proves it does
/// not answer.
/// </para>
/// <para>
/// Every spelling routing accepts is asserted, not just the canonical one. Routing matches literal
/// segments case-insensitively and tolerates a trailing slash, so a block that compares the request
/// path with <c>StringComparison.Ordinal</c> passes a canonical-spelling test while leaving the
/// listing fully readable at <c>/_TEST_URL_CACHE_</c>. That is not hypothetical — it is what the
/// first version of this guard did, and a single-spelling test is what let it look correct.
/// </para>
/// </remarks>
[Theory]
[InlineData("/_test_url_cache_")]
[InlineData("/_TEST_URL_CACHE_")]
[InlineData("/_Test_Url_Cache_")]
[InlineData("/_test_url_cache_/")]
public async Task RouteTableIsNotReachable(string path)
{
// Authenticated on purpose: the deny-by-default policy already stops an anonymous caller, so a
// 404 for one would prove nothing about whether the listing is exposed.
var client = fixture.CreateClientFor(subject: $"route-table-{Guid.CreateVersion7()}");
var response = await client.GetAsync(
new Uri(path, UriKind.Relative),
TestContext.Current.CancellationToken);
response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
// A 404 with the listing in the body would satisfy the status assertion on its own.
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
body.ShouldNotContain("Endpoint", Case.Insensitive);
}
private static string Describe(RouteEndpoint endpoint)
{
var methods = endpoint.Metadata.GetMetadata<HttpMethodMetadata>()?.HttpMethods;
var verbs = methods is { Count: > 0 } ? string.Join(",", methods) : "ANY";
var name = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? string.Empty;
var tags = string.Join(",", endpoint.Metadata.GetMetadata<ITagsMetadata>()?.Tags ?? []);
// FastEndpoints adds a synthetic "epPolicy:<full type name>" beside the named policies, and
// including it would couple this table to endpoint class names. Dropping it is not free: that
// policy is also where FastEndpoints folds Roles(), Claims() and Permissions(), so those become
// invisible here. Nothing calls them — the two named policies carry the whole authorization
// decision — and the class remarks say so, rather than this filter pretending it discards nothing.
var policies = string.Join(
",",
endpoint.Metadata.OfType<IAuthorizeData>()
.Select(data => data.Policy)
.Where(policy => !string.IsNullOrEmpty(policy))
.Where(policy => !policy!.StartsWith("epPolicy:", StringComparison.Ordinal)));
var anonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>() is not null;
return $"{verbs} {endpoint.RoutePattern.RawText} name={name} tags={tags} policies={policies} anon={anonymous}";
}
}