Files
DodoSSH/src/DodoSSH.Api/Setup/DodoOptions.cs
T
jaap-janandClaude Opus 5 a43286ece8 Let a team change hands, and be joined by somebody with no account yet
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>
2026-08-03 14:31:43 +02:00

223 lines
9.5 KiB
C#

using System.ComponentModel.DataAnnotations;
namespace DodoSSH.Api.Setup;
/// <summary>Identity provider settings.</summary>
/// <remarks>
/// Provider-agnostic by design. Claim names differ between providers — Keycloak nests roles at
/// <c>realm_access.roles</c>, Entra uses <c>roles</c> and <c>groups</c>, Auth0 namespaces them,
/// Authentik uses <c>groups</c> — so the mapping is configuration rather than code.
/// </remarks>
public sealed class OidcOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Oidc";
/// <summary>Issuer URL. Discovery and JWKS are fetched from here.</summary>
[Required]
[Url]
public string Authority { get; set; } = string.Empty;
/// <summary>Expected audience of access tokens.</summary>
[Required]
public string Audience { get; set; } = string.Empty;
/// <summary>Public client identifier the desktop app uses.</summary>
[Required]
public string ClientId { get; set; } = string.Empty;
/// <summary>Scopes the client should request.</summary>
public IList<string> Scopes { get; } = ["openid", "profile", "email", "offline_access"];
/// <summary>
/// Registered loopback redirect pattern. RFC 8252: a native client uses a loopback redirect on
/// an ephemeral port with the system browser, never a custom scheme and never an embedded
/// browser, so the user can see the real address bar.
/// </summary>
/// <remarks>
/// No wildcard in the port. RFC 8252 requires a native client to use an ephemeral loopback port, and
/// providers implement that by ignoring the port when the host is a loopback literal — Keycloak
/// included. Writing <c>http://127.0.0.1:*/callback</c> looks more explicit and is worse: Keycloak
/// parses the <c>*</c> as a literal port and rejects every real redirect with "Invalid parameter:
/// redirect_uri". Pinning the path is the part that matters, since it stops another local process
/// having a code delivered somewhere else.
/// </remarks>
public string LoopbackRedirectPattern { get; set; } = "http://127.0.0.1/callback";
/// <summary>Whether HTTPS metadata is required. Only ever false for local development.</summary>
public bool RequireHttpsMetadata { get; set; } = true;
/// <summary>
/// Whether a new identity-provider subject may be linked to an existing account by matching
/// email.
/// </summary>
/// <remarks>
/// Defaults to false, and must stay that way. If an attacker can obtain a token from any
/// configured provider carrying a victim's email address, email linking hands them the
/// victim's account.
/// </remarks>
public bool AllowEmailLinking { get; set; }
/// <summary>Claim type holding the user's email.</summary>
public string EmailClaim { get; set; } = "email";
/// <summary>Claim type holding the user's display name.</summary>
public string NameClaim { get; set; } = "name";
/// <summary>
/// Claim type asserting that the provider has verified the user's email.
/// </summary>
/// <remarks>
/// <para>
/// Read for exactly one purpose: deciding whether a pending team invitation addressed to that
/// email may be claimed. Nothing else in this server trusts the email claim for anything, and
/// <see cref="AllowEmailLinking"/> records why — a token from any configured provider carrying a
/// victim's address must not confer access to anything of theirs. An invitation is access, so it
/// needs the same bar.
/// </para>
/// <para>
/// <b>Absence is a refusal, not a default.</b> A provider that does not send this claim leaves
/// every invitation pending for ever, which is visible on the teams screen and diagnosable in the
/// log. There is deliberately no option to trust an unverified address instead: a flag that exists
/// is a flag somebody turns on for the afternoon their provider is misconfigured, and this is the
/// one it must not be possible to turn on.
/// </para>
/// </remarks>
public string EmailVerifiedClaim { get; set; } = "email_verified";
}
/// <summary>Schema management.</summary>
public sealed class DatabaseOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Database";
/// <summary>
/// Whether the API applies pending migrations as it starts.
/// </summary>
/// <remarks>
/// <para>
/// On by default, because the alternative asks every self-hosted operator to run a second thing in
/// the right order and gives them an instance that boots and then fails readiness when they do not.
/// The schema and the code that expects it ship in the same image, so the image is the natural place
/// for the two to be reconciled.
/// </para>
/// <para>
/// Turn it off where the deployment already owns schema changes: a migrator job in the release
/// pipeline, a rollout where the new code must run against the old schema first, or a database user
/// that is deliberately denied DDL. With it off the behaviour is exactly what it was before this
/// setting existed — pending migrations fail readiness and say which, and nothing repairs itself.
/// </para>
/// </remarks>
public bool AutoMigrate { get; set; } = true;
}
/// <summary>Relay settings. See ADR 0004.</summary>
public sealed class RelayOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Relay";
/// <summary>Whether this deployment offers a relay at all.</summary>
public bool Enabled { get; set; }
/// <summary>Public WebSocket URL clients should dial. Required when enabled.</summary>
public string? WebSocketUrl { get; set; }
/// <summary>
/// Whether RFC1918 and other private ranges may be dialled.
/// </summary>
/// <remarks>
/// Defaults to true, unlike a typical SSRF allow-list, because reaching private
/// infrastructure is the entire purpose of a self-hosted SSH tool. The control that matters is
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. Loopback,
/// link-local and cloud metadata ranges are denied unconditionally and are not configurable.
/// </remarks>
public bool AllowPrivateNetworks { get; set; } = true;
/// <summary>Maximum concurrent sessions per user.</summary>
[Range(1, 1000)]
public int MaxConcurrentSessionsPerUser { get; set; } = 10;
/// <summary>Maximum concurrent sessions per node.</summary>
[Range(1, 100_000)]
public int MaxConcurrentSessionsTotal { get; set; } = 200;
/// <summary>Maximum session lifetime.</summary>
public TimeSpan MaxSessionDuration { get; set; } = TimeSpan.FromHours(12);
/// <summary>Idle timeout.</summary>
public TimeSpan IdleTimeout { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>Timeout for the outbound TCP connect.</summary>
public TimeSpan ConnectTimeout { get; set; } = TimeSpan.FromSeconds(5);
/// <summary>Ticket lifetime. Deliberately tiny: single-use and single-host.</summary>
public TimeSpan TicketLifetime { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>How long to keep draining live sessions during shutdown.</summary>
public TimeSpan DrainTimeout { get; set; } = TimeSpan.FromSeconds(30);
}
/// <summary>Sync protocol limits.</summary>
public sealed class SyncOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Sync";
/// <summary>Maximum operations in one push. Enforced before the transaction opens.</summary>
[Range(1, 10_000)]
public int MaxOperationsPerPush { get; set; } = 500;
/// <summary>Maximum total ciphertext in one push.</summary>
[Range(1024, 1024L * 1024 * 1024)]
public long MaxPayloadBytes { get; set; } = 8L * 1024 * 1024;
/// <summary>Maximum ciphertext for a single item.</summary>
[Range(1024, 1024L * 1024 * 1024)]
public long MaxItemPayloadBytes { get; set; } = 256L * 1024;
/// <summary>Default page size for a pull.</summary>
[Range(1, 10_000)]
public int DefaultPullLimit { get; set; } = 200;
/// <summary>Maximum page size for a pull.</summary>
[Range(1, 10_000)]
public int MaxPullLimit { get; set; } = 1000;
/// <summary>How long tombstones are retained before collection.</summary>
[Range(1, 3650)]
public int TombstoneRetentionDays { get; set; } = 90;
/// <summary>
/// Key used to sign sync cursors, base64.
/// </summary>
/// <remarks>
/// Cursors are integrity-tagged so a tampered one is rejected rather than silently
/// mis-serving. Generated per deployment; losing it only invalidates in-flight cursors, since
/// clients simply resync from the beginning.
/// </remarks>
public string? CursorSigningKey { get; set; }
}
/// <summary>Server identity and client compatibility.</summary>
public sealed class ServerOptions
{
/// <summary>Configuration section name.</summary>
public const string SectionName = "Server";
/// <summary>Public base URL clients should use for API calls.</summary>
[Required]
[Url]
public string PublicBaseUrl { get; set; } = string.Empty;
/// <summary>
/// Oldest client version this server will serve.
/// </summary>
/// <remarks>
/// Self-hosted means version skew is normal, not exceptional. A client below this must be
/// shown a clear remediation screen rather than failing obscurely mid-sync.
/// </remarks>
public string? MinClientVersion { get; set; }
}