Public Access
An invitation decided access from an assertion about an address. Everything else
in this model decides it from something a person did — an admin naming an
account, a key holder wrapping a vault key to a key they verified — and this was
the one place a token's email claim was the thing that let somebody in.
It was guarded as tightly as that can be guarded: the claim was refused outright
on an unverified or absent `email_verified`, with no setting to relax it. But the
guard and the risk were the same shape. The whole defence was one boolean sent by
a system the deployment does not control.
So `POST /teams/{id}/members` is the only way in, and an address with no account
is refused with `no-such-account` — which is now the end of the road rather than
the signal to invite. Both clients say the remedy: that person signs in here
once, which is what creates the account, and then they can be added. The desktop
leaves the address in the box, because a message telling you to come back later
is one you act on later.
Gone with it: the `team_invitation` table, the claim hook in the sign-in path,
and `Oidc:EmailVerifiedClaim`, which that hook was the only reader of. Nothing in
the server now reads the email claim to decide anything.
Pending invitations are dropped rather than converted. Converting one would mean
creating a membership because an address matched, which is the property being
removed — and an invitation to an address that did have an account here had
already been claimed by the hourly sweep, so what is left is offers to people who
never arrived.
Two tests carry the property rather than the feature: the endpoint inventory
asserts the three routes are absent, and the API suite adds an address that has
no account, watches the refusal, then signs that address in and checks it joined
nothing. Without the second half, a server that merely renamed the deferred path
would pass.
216 lines
13 KiB
C#
216 lines
13 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",
|
|
|
|
// The WebSocket, gated exactly as sync is and for the same reason — it announces changes to
|
|
// vaults, and a caller who could not read one has nothing to be told about. It appears here as
|
|
// an ordinary route because that is what it is until the upgrade: the bearer token authorises
|
|
// the handshake, unlike the relay's ticket. See ADR 0012.
|
|
"GET /api/v1/events name=VaultEvents tags=Events 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",
|
|
|
|
// There are deliberately no invitation routes here, and their absence is the assertion. A team
|
|
// was once joinable by an address the server had never seen, claimed at sign-in from the token's
|
|
// email claim; membership is now only ever granted to an account somebody named. If three
|
|
// /invitations entries reappear in this list, that property has been given back.
|
|
|
|
// 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.
|
|
// Authenticated, alone among the vault routes: renaming touches no key material, so refusing
|
|
// somebody who has not published an identity key would be refusing them for an unrelated
|
|
// reason. Gated on Admin inside the handler, which this table cannot see.
|
|
"PUT /api/v1/vaults/{vaultId:guid} name=RenameVault tags=Vaults policies=Authenticated anon=False",
|
|
|
|
// Authenticated for the same reason as the rename it sits beside — deleting withdraws grants
|
|
// rather than wrapping anything, so it needs no key of the caller's — and gated on Admin inside
|
|
// the handler, which this table cannot see. It refuses a personal vault there too.
|
|
"DELETE /api/v1/vaults/{vaultId:guid} name=DeleteVault tags=Vaults policies=Authenticated anon=False",
|
|
|
|
"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}";
|
|
}
|
|
}
|