Public Access
M3 built teams and stopped short of the two operations that decide who controls one. Both were written down as refusals rather than omissions: ADR 0009 listed ownership transfer under "deliberately not built", and design-import-gaps said an invitation needed "a token with a lifetime and an outbound mail path". One of those reasons had expired and the other never applied — an invitation does not need a token if it is not a thing anybody presents. Handing a team over is one write. The member you name becomes owner and you become an admin, in a single transaction, because ownership is sole: promoting first leaves the team owned twice, demoting first leaves it owned by nobody, and there is nobody left with the authority to finish a transfer that stopped in the middle. That is also why it is not two calls to the role endpoint, which refuses Owner outright. The outgoing owner is demoted rather than removed — removing them would revoke their vault key grants and flag every team vault for rekey, which is a far larger act than the one asked for, and somebody handing over a team is usually staying in it. It unblocks the thing that was impossible before: an owner can now leave, by handing the team on first. An invitation is a standing instruction rather than a message. This server has no outbound mail path, so nothing is sent and there is nothing for the invitee to present. The row says the next account signing in with that address joins this team at this role, and telling them to sign in is the caller's job over a channel this server does not carry. A link nobody can deliver would be worse than none. It lives in its own table rather than becoming a membership with MembershipStatus.Invited, and that member stays unwritten for the reason it always was: team_membership.user_id is not nullable and carries a foreign key, so somebody who has never signed in has nothing for that row to point at. Widening it would make the unique index on (team, user) meaningless, because PostgreSQL counts every NULL as distinct. Verification is the security boundary, and nothing in this server read it before. A claim requires the access token to assert email_verified. An invitation decides what the server will serve, so one claimable by anybody able to obtain a token carrying somebody else's address is a way into a team — which is precisely the attack OidcOptions.AllowEmailLinking exists to refuse, and it would have been reintroduced by the back door. There is deliberately no setting that relaxes it: a flag that exists is one somebody turns on for the afternoon their provider is misconfigured. Absence is refused rather than trusted, and logged, because a provider that never sends the claim otherwise leaves every invitation pending with nothing anywhere saying why. Claiming happens at just-in-time provisioning and again on an hourly sweep. The sweep is what makes it recoverable rather than one-shot — an invitation issued between an account being created and that person next signing in would otherwise be stranded for ever — and it shares its rate with the last-seen write because both are housekeeping nobody is waiting on. Archiving is refused while a team owns a vault, and that refusal is the end of the road rather than a step on it. A team vault is readable because of membership, so archiving one that still owned vaults would take them away from everybody holding a key, including the caller, quietly and all at once. Nothing in this product deletes a vault, so no order of operations gets past it today — which is stated with a count of what is in the way, for the reason the SFTP layer refuses a recursive delete: a refusal is visible and a quiet removal is not. It is owner-only, as handing over is; renaming is not, because a rename is visible to everybody and reversible by anybody who can do it. The slug is not renameable at all: it is unique only among live teams, so a rename could take one an archived team is still holding, and that team could then never be restored. LAST ACTIVE is real and coarse on purpose. UserAccount.LastSeenAtUtc is refreshed on ordinary authenticated requests, at most once per account per hour, through ExecuteUpdateAsync — user_account carries the xmin concurrency token, so a read-then-write on the hot path would start losing races between one user's own overlapping requests. An hour is the granularity the question is actually asked at, and the interface draws it to the day rather than the minute so it does not read as a precision that is not there. The remarks in Contracts and in the view model that argued at length for the column's absence are rewritten rather than extended; both had become false. Two endpoints already existed and nothing called them. ChangeTeamMemberRole and ListVaultGrants have been reachable since M3. The role picker refuses Owner itself rather than letting the server do it, since the interface already knew the rule; the key-holder list sits under the vault rather than beside the member, because a grant is per vault and a count on a member row would imply per-item sharing, which is M5. It lists withdrawn and stale grants and says which they are — a list that dropped them would show a departed colleague as merely absent rather than as somebody whose key was taken away — and staleness is decided by comparing generations, since a grant can be Active and still open nothing. ADD MEMBER stopped being a dead end. An address the directory did not know used to end at a sentence telling the user their colleague had to sign in first. It invites them instead, from the same button, because which of the two applies is a fact about the server's account table rather than about what the user is doing; which one happened is reported afterwards, because that decides what they do next. An address that merely has an account is invited rather than refused: refusing would have made the endpoint an oracle for which addresses have accounts here, answerable by anybody willing to create a team first. The phone has a TEAMS screen, behind MORE, and it is the reverse of every other row in design-import-gaps: a shipped screen the design had no slot for. It is there because an invitation is claimed by signing in, so somebody told they are now in a team is at least as likely to be holding a phone — and a membership visible only on a head they never installed is one they cannot see. It draws SHARE KEY and nothing that takes something away: wrapping a key is the one act on that screen a server cannot perform at all, and the desktop guards its revocations with a tooltip, which is a control a touch screen cannot show. Two defects were found by an adversarial pass and both were green against the whole suite at the time. The owner-only check on archiving and handing over had been weakened to the admin check while their messages and comments still said owner — and since nothing behind the archive endpoint re-checks it, an admin the owner had promoted could have archived the team out from under them. And the rename endpoint built its response with a hardcoded Owner role, so an admin who renamed a team was handed a summary claiming they owned it, and a client trusting that instead of re-listing would have offered them the two owner-only buttons the server then refuses. The new table gets its constraints tested rather than merely migrated: live uniqueness per (team, address), the citext proof that an address typed by a person matches one cased by a provider, and reissue after both revocation and acceptance. The teams screen gets its first entries in the layout suite, at the minimum window with every list populated and with each of the two states that cover half of it — it had none, and it just grew four sections and a second line in the member row. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
12 KiB
C#
199 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",
|
|
|
|
// 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}";
|
|
}
|
|
}
|