Public Access
Let a team be joined only by somebody who is already here
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.
This commit is contained in:
@@ -17,51 +17,27 @@ public interface ICurrentUserContext
|
||||
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns pending team invitations addressed to a verified email into memberships.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Declared here, beside its only caller, and implemented in <c>Features/Teams</c>. The direction is
|
||||
/// deliberate: sign-in is what an invitation waits for, so the sign-in path names the shape it needs
|
||||
/// and the teams feature supplies it — rather than <see cref="ICurrentUserContext"/>, which every
|
||||
/// endpoint in the server depends on, growing a reference into one feature's folder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ITeamInvitationClaim
|
||||
{
|
||||
/// <summary>
|
||||
/// Claims every live invitation addressed to <paramref name="email"/> for this account.
|
||||
/// </summary>
|
||||
/// <param name="user">The account signing in.</param>
|
||||
/// <param name="email">The address the token asserted, or null if it asserted none.</param>
|
||||
/// <param name="emailVerified">
|
||||
/// Whether the provider marked that address verified. False refuses the claim outright and is the
|
||||
/// whole of what stops an invitation being taken by anybody able to assert somebody else's
|
||||
/// address.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <returns>How many invitations became memberships.</returns>
|
||||
Task<int> ClaimAsync(
|
||||
UserAccount user,
|
||||
string? email,
|
||||
bool emailVerified,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request-scoped caller identity with just-in-time provisioning.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Identity is keyed on <c>(issuer, subject)</c>, never on email. Matching an existing account by
|
||||
/// email means anyone who can obtain a token bearing a victim's email address — from any configured
|
||||
/// provider — inherits that victim's vaults, so it is opt-in configuration and off by default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here reads the email claim for authorization, and there is deliberately no hook left for
|
||||
/// anything that would.</b> This class used to claim pending team invitations on the way past, which
|
||||
/// made a provider's assertion about an address into a decision about who joins a team; invitations are
|
||||
/// gone and membership is granted only to an account somebody named — see
|
||||
/// <c>docs/adr/0009-team-access-model.md</c>. The address is still recorded, for display.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class CurrentUserContext(
|
||||
IHttpContextAccessor accessor,
|
||||
DodoDbContext database,
|
||||
IOptions<Setup.OidcOptions> oidcOptions,
|
||||
ITeamInvitationClaim invitations,
|
||||
TimeProvider clock)
|
||||
: ICurrentUserContext
|
||||
{
|
||||
@@ -73,8 +49,7 @@ internal sealed class CurrentUserContext(
|
||||
/// UPDATE on the hot path of every authenticated call and — because <c>user_account</c> carries
|
||||
/// the xmin concurrency token — would start losing races between a user's own overlapping
|
||||
/// requests. Writing it never is what made the old "last active" column impossible to offer
|
||||
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not",
|
||||
/// and it is also the window on which a pending invitation is swept for.
|
||||
/// honestly. An hour answers the question a colleague actually asks, which is "this week or not".
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan LastSeenWindow = TimeSpan.FromHours(1);
|
||||
|
||||
@@ -101,7 +76,6 @@ internal sealed class CurrentUserContext(
|
||||
var options = oidcOptions.Value;
|
||||
var email = principal.FindFirstValue(options.EmailClaim);
|
||||
var displayName = principal.FindFirstValue(options.NameClaim);
|
||||
var emailVerified = IsVerified(principal, options.EmailVerifiedClaim);
|
||||
|
||||
var existing = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -110,52 +84,28 @@ internal sealed class CurrentUserContext(
|
||||
cached = await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A first sign-in is exactly what an invitation is waiting for, so it is claimed at once
|
||||
// rather than on the next hourly sweep — which would leave somebody staring at a team
|
||||
// list that does not yet contain the team they were told they had been added to.
|
||||
await invitations
|
||||
.ClaimAsync(cached, email, emailVerified, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
cached = existing;
|
||||
|
||||
await RefreshLastSeenAsync(existing, email, emailVerified, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await RefreshLastSeenAsync(existing, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records that this account is active, and sweeps for invitations it can now claim.
|
||||
/// Records that this account is active.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The two are one operation because they want the same rate. Both are housekeeping nobody is
|
||||
/// waiting on, and doing them together costs one extra round trip per account per hour rather
|
||||
/// than two.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The sweep is what makes claiming recoverable rather than one-shot. A claim that failed at
|
||||
/// provisioning — or an invitation issued in the window between an account being created and this
|
||||
/// person next signing in — is picked up here instead of being stranded for ever.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>ExecuteUpdateAsync</c> rather than the change tracker, and the predicate rather than a
|
||||
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
||||
/// requests from one user would each read the row, each set the timestamp, and the second would
|
||||
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
||||
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
|
||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RefreshLastSeenAsync(
|
||||
UserAccount user,
|
||||
string? email,
|
||||
bool emailVerified,
|
||||
CancellationToken cancellationToken)
|
||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
@@ -171,20 +121,8 @@ internal sealed class CurrentUserContext(
|
||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await invitations
|
||||
.ClaimAsync(user, email, emailVerified, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A JWT boolean arrives as the string "true", so this parses rather than compares against a
|
||||
/// constant. Anything else — absent, "false", or a value this does not understand — is false,
|
||||
/// because the failure that matters is treating an unverified address as verified.
|
||||
/// </remarks>
|
||||
private static bool IsVerified(ClaimsPrincipal principal, string claimType) =>
|
||||
bool.TryParse(principal.FindFirstValue(claimType), out var verified) && verified;
|
||||
|
||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||
database.Users.SingleOrDefaultAsync(
|
||||
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
|
||||
|
||||
@@ -495,173 +495,6 @@ internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lists a team's invitations.</summary>
|
||||
/// <remarks>
|
||||
/// Readable by every member, as the members list is: whoever is about to be handed a vault key needs
|
||||
/// to see who else is on their way in. Accepted and withdrawn invitations are included so the screen
|
||||
/// can say an invitation was taken up rather than letting it vanish and read as never sent.
|
||||
/// </remarks>
|
||||
internal sealed class ListTeamInvitationsEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
TeamService teams,
|
||||
TeamInvitationService invitations)
|
||||
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/teams/{teamId:guid}/invitations");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ListTeamInvitations")
|
||||
.WithSummary("Lists a team's invitations.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<IReadOnlyList<TeamInvitationSummary>>, NotFound>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(await invitations.ListAsync(teamId, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Invites an address to a team.</summary>
|
||||
/// <remarks>
|
||||
/// Authenticated rather than Enrolled, and pointedly so. Every other write that ends in somebody
|
||||
/// reading a vault needs a key of the caller's own; this one does not, because an invitation grants
|
||||
/// membership and membership is not readability. Requiring enrollment here would also be requiring it
|
||||
/// of the wrong person — the invitee is the one with no key, and they have no account yet either.
|
||||
/// </remarks>
|
||||
internal sealed class CreateTeamInvitationEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
TeamService teams,
|
||||
TeamInvitationService invitations)
|
||||
: Endpoint<CreateTeamInvitationRequest, Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/teams/{teamId:guid}/invitations");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("CreateTeamInvitation")
|
||||
.WithSummary("Invites an email address to a team.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<TeamInvitationSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CreateTeamInvitationRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can invite people to it.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var invitation = await invitations
|
||||
.CreateAsync(user, teamId, req, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(invitation);
|
||||
}
|
||||
catch (TeamInvitationInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest,
|
||||
ProblemCodes.InvalidTeamInvitation,
|
||||
exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Withdraws an invitation that has not been taken up.</summary>
|
||||
/// <remarks>
|
||||
/// 404 for an invitation that is not there, is not this team's, or has already been claimed — the
|
||||
/// same answer for all three, and for the reason revoking a device grant gives: a caller driving
|
||||
/// towards "that invitation will not let anybody in" can treat 404 as having arrived. A claimed one
|
||||
/// is a membership now, and removing a member is a different operation with different consequences.
|
||||
/// </remarks>
|
||||
internal sealed class RevokeTeamInvitationEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
TeamService teams,
|
||||
TeamInvitationService invitations)
|
||||
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/v1/teams/{teamId:guid}/invitations/{invitationId:guid}");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("RevokeTeamInvitation")
|
||||
.WithSummary("Withdraws an invitation that has not been taken up.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
var invitationId = Route<Guid>("invitationId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can withdraw an invitation.");
|
||||
}
|
||||
|
||||
var revoked = await invitations
|
||||
.RevokeAsync(user, teamId, invitationId, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by a team.</summary>
|
||||
internal sealed class CreateTeamVaultEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
|
||||
@@ -36,20 +36,19 @@ internal sealed class TeamNotEmptyException(string message) : Exception(message)
|
||||
|
||||
/// <summary>The address given to an add has no account on this server.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="TeamInvalidException"/> because the caller can act on it without being
|
||||
/// told to: there is nobody to add, so the address is invited instead. Folded into the general code it
|
||||
/// would be indistinguishable from a rejected role, and a client would have to guess which it was.
|
||||
/// Separate from <see cref="TeamInvalidException"/> because it is the one refusal on this path that is
|
||||
/// not about the request: the request was well formed and named somebody who is not here. Folded into
|
||||
/// the general code it would be indistinguishable from a rejected role, and a client wanting to say
|
||||
/// "ask them to sign in first" would have to guess which of the two it had.
|
||||
/// <para>
|
||||
/// It is the end of the road rather than a step on it. Membership is only ever granted to an account
|
||||
/// that exists — see <c>docs/adr/0009-team-access-model.md</c> — so there is nothing else for a client
|
||||
/// to try, and the honest answer is to name the address and say what has to happen before it can be
|
||||
/// added.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class NoSuchAccountException(string message) : Exception(message);
|
||||
|
||||
/// <summary>An invitation was rejected.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="TeamInvalidException"/> because its commonest cause has a different
|
||||
/// remedy: an address that already has an account here should be added through the directory, which
|
||||
/// is the path that shows the caller the public key they are about to trust.
|
||||
/// </remarks>
|
||||
internal sealed class TeamInvitationInvalidException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// A vault key grant was rejected.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Invitations to addresses that have no account here yet, and the sign-in path that claims them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>An invitation is a standing instruction, not a message and not a token.</b> 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 to sign in with this address joins this team as this role", and telling
|
||||
/// them to sign in is the caller's job over a channel this server does not carry. That is a smaller
|
||||
/// feature than the design drew, and it is the whole of what can be built honestly without a mail
|
||||
/// path — a link nobody can deliver would be worse than none.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Verification is the security boundary, and it is the only one.</b> Membership is authorization
|
||||
/// (ADR 0009), so an invitation decides what the server will serve. Claiming one on an address the
|
||||
/// identity provider has not marked verified would let anybody who can get a token asserting somebody
|
||||
/// else's address walk into their team — which is precisely the attack
|
||||
/// <c>OidcOptions.AllowEmailLinking</c> exists to refuse. So an unverified address claims nothing,
|
||||
/// there is no setting that relaxes it, and the refusal is logged rather than silent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What an invitation still cannot do is make anything readable. It creates a membership, and a
|
||||
/// membership is not a key — somebody has to wrap the vault key to them afterwards, from a machine
|
||||
/// that holds it. The split ADR 0009 describes is not weakened by this; the invitation simply moves
|
||||
/// the first half of it earlier.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TeamInvitationService(
|
||||
DodoDbContext database,
|
||||
TimeProvider clock,
|
||||
ILogger<TeamInvitationService> logger)
|
||||
: ITeamInvitationClaim
|
||||
{
|
||||
/// <summary>Longest acceptable address. Matches the column, and RFC 5321's own limit.</summary>
|
||||
private const int MaxEmailLength = 320;
|
||||
|
||||
/// <summary>
|
||||
/// How long an invitation stays claimable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fourteen days, and finite for a reason rather than as a default. An invitation that never
|
||||
/// expired would be a standing offer against an address, and addresses are reassigned — a
|
||||
/// company address handed to the next person to hold the job would let them into a team the
|
||||
/// person who left was invited to. Fourteen days is long enough to survive a holiday and short
|
||||
/// enough that a forgotten invitation lapses rather than waiting.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan Lifetime = TimeSpan.FromDays(14);
|
||||
|
||||
/// <summary>Lists a team's invitations, including the ones already dealt with.</summary>
|
||||
/// <remarks>
|
||||
/// Every member may read this, as with the members list and for the same reason: whoever is about
|
||||
/// to be handed a vault key needs to see who else is on their way into the team. Accepted and
|
||||
/// revoked rows are returned too, so the screen can show that an invitation was taken up rather
|
||||
/// than having it silently vanish and read as never sent.
|
||||
/// </remarks>
|
||||
internal async Task<IReadOnlyList<TeamInvitationSummary>> ListAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invitations = await database.TeamInvitations
|
||||
.Where(i => i.TeamId == teamId)
|
||||
.OrderByDescending(i => i.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
return [.. invitations.Select(invitation => Describe(invitation, now))];
|
||||
}
|
||||
|
||||
/// <summary>Invites an address to a team.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>An address that already has an account here is accepted rather than refused.</b> The obvious
|
||||
/// alternative — refusing and pointing at the directory — would turn this endpoint into an oracle
|
||||
/// for which addresses have accounts, answerable by anybody willing to create a team first. It
|
||||
/// would also be answering a question the caller did not ask: they want that person in the team,
|
||||
/// and whether the account exists yet only changes how soon it happens. An existing account picks
|
||||
/// the invitation up on its next request, within the hour.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotent on the client-chosen id, as team and vault creation are: the same id, team and
|
||||
/// address returns the existing invitation rather than a second one. A different address under an
|
||||
/// id already in use is refused rather than reinterpreted.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<TeamInvitationSummary> CreateAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
CreateTeamInvitationRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var email = RequireEmail(request.Email);
|
||||
var role = RequireInvitableRole(request.Role);
|
||||
|
||||
if (request.InvitationId == Guid.Empty)
|
||||
{
|
||||
throw new TeamInvitationInvalidException(
|
||||
"An invitation id is required. Generate a UUIDv7 on the client.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var existing = await database.TeamInvitations
|
||||
.SingleOrDefaultAsync(i => i.Id == request.InvitationId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return ResolveExisting(existing, teamId, email, now);
|
||||
}
|
||||
|
||||
await RefuseIfAlreadyAMemberAsync(teamId, email, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var invitation = new TeamInvitation
|
||||
{
|
||||
Id = request.InvitationId,
|
||||
TeamId = teamId,
|
||||
Email = email,
|
||||
Role = role,
|
||||
InvitedByUserId = actor.Id,
|
||||
CreatedAtUtc = now,
|
||||
ExpiresAtUtc = now + Lifetime,
|
||||
};
|
||||
|
||||
database.TeamInvitations.Add(invitation);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The partial unique index on (team, email) among live invitations. Reported as its own
|
||||
// sentence because it is the one failure the caller could not see coming from their own
|
||||
// input — somebody else may have invited the same person a minute earlier.
|
||||
throw new TeamInvitationInvalidException(
|
||||
"There is already an invitation to that address for this team. Withdraw it first if "
|
||||
+ "you want to invite them at a different role.");
|
||||
}
|
||||
|
||||
TeamLog.InvitationIssued(logger, invitation.Id, teamId, role, actor.Id);
|
||||
|
||||
return Describe(invitation, now);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A retry is the same id against the same team and address. Anything else under an id already in
|
||||
/// use is refused rather than reinterpreted: returning a differently-addressed invitation would
|
||||
/// tell a client its invite went to somebody it did not.
|
||||
/// </remarks>
|
||||
private static TeamInvitationSummary ResolveExisting(
|
||||
TeamInvitation existing,
|
||||
Guid teamId,
|
||||
string email,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var isRetry = existing.TeamId == teamId
|
||||
&& string.Equals(existing.Email, email, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return isRetry
|
||||
? Describe(existing, now)
|
||||
: throw new TeamInvitationInvalidException(
|
||||
"That invitation id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
private static TeamRole RequireInvitableRole(TeamMemberRole role)
|
||||
{
|
||||
var domain = ToDomain(role);
|
||||
|
||||
return domain is TeamRole.Unspecified or TeamRole.Owner
|
||||
? throw new TeamInvitationInvalidException(
|
||||
"Invite somebody as a viewer, member or admin. Ownership is sole and is handed over "
|
||||
+ "deliberately, never conferred by an address signing in.")
|
||||
: domain;
|
||||
}
|
||||
|
||||
/// <summary>Withdraws an invitation that has not been taken up.</summary>
|
||||
/// <returns>Whether there was a live invitation to withdraw.</returns>
|
||||
/// <remarks>
|
||||
/// An invitation that has already been claimed is <em>not</em> withdrawable, and answering false
|
||||
/// rather than unpicking it is the honest outcome: it is a membership now, and removing a member
|
||||
/// is a different operation with different consequences — it revokes their vault key grants and
|
||||
/// flags every team vault for rekey.
|
||||
/// </remarks>
|
||||
internal async Task<bool> RevokeAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
Guid invitationId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var invitation = await database.TeamInvitations
|
||||
.SingleOrDefaultAsync(
|
||||
i => i.Id == invitationId
|
||||
&& i.TeamId == teamId
|
||||
&& i.AcceptedAtUtc == null
|
||||
&& i.RevokedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (invitation is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
invitation.RevokedAtUtc = clock.GetUtcNow();
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.InvitationRevoked(logger, invitation.Id, teamId, actor.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ClaimAsync(
|
||||
UserAccount user,
|
||||
string? email,
|
||||
bool emailVerified,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(user);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var pending = await database.TeamInvitations
|
||||
.Where(i => i.Email == email
|
||||
&& i.AcceptedAtUtc == null
|
||||
&& i.RevokedAtUtc == null
|
||||
&& i.ExpiresAtUtc > now)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (pending.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!emailVerified)
|
||||
{
|
||||
// Logged rather than silent, and this is the only signal an operator gets that their
|
||||
// provider is not sending the claim. Without it, invitations would simply never work and
|
||||
// there would be nothing anywhere saying why.
|
||||
TeamLog.InvitationNotClaimedUnverified(logger, pending.Count, user.Id);
|
||||
return 0;
|
||||
}
|
||||
|
||||
return await ApplyAsync(user, pending, now, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Turns each claimable invitation into an active membership.</summary>
|
||||
private async Task<int> ApplyAsync(
|
||||
UserAccount user,
|
||||
List<TeamInvitation> pending,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var teamIds = pending.Select(i => i.TeamId).ToArray();
|
||||
|
||||
// Archived teams are excluded here as well as at archive time. An invitation issued moments
|
||||
// before an archive can still be in flight, and joining a team nobody can see is worse than
|
||||
// an invitation that quietly lapses.
|
||||
var liveTeamIds = await database.Teams
|
||||
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var live = liveTeamIds.ToHashSet();
|
||||
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => teamIds.Contains(m.TeamId) && m.UserId == user.Id && m.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var claimed = 0;
|
||||
|
||||
foreach (var invitation in pending.Where(i => live.Contains(i.TeamId)))
|
||||
{
|
||||
invitation.AcceptedAtUtc = now;
|
||||
invitation.AcceptedByUserId = user.Id;
|
||||
|
||||
if (Join(user, invitation, memberships.Find(m => m.TeamId == invitation.TeamId), now))
|
||||
{
|
||||
claimed++;
|
||||
|
||||
TeamLog.InvitationClaimed(
|
||||
logger, user.Id, invitation.Id, invitation.TeamId, invitation.Role);
|
||||
}
|
||||
}
|
||||
|
||||
return await SaveClaimAsync(claimed, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Adds or reactivates the membership an invitation asks for.</summary>
|
||||
/// <returns>Whether the membership changed. False means they were already an active member.</returns>
|
||||
private bool Join(
|
||||
UserAccount user,
|
||||
TeamInvitation invitation,
|
||||
TeamMembership? membership,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (membership is null)
|
||||
{
|
||||
database.TeamMemberships.Add(new TeamMembership
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TeamId = invitation.TeamId,
|
||||
UserId = user.Id,
|
||||
Role = invitation.Role,
|
||||
Status = MembershipStatus.Active,
|
||||
InvitedByUserId = invitation.InvitedByUserId,
|
||||
JoinedAtUtc = now,
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (membership.Status == MembershipStatus.Active)
|
||||
{
|
||||
// Already in the team — the invitation is satisfied rather than applied. It must not
|
||||
// change a role somebody set deliberately in the meantime, which is what re-applying an
|
||||
// invitation issued weeks ago would silently do.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Removed earlier and invited again. The row is reactivated rather than duplicated, exactly
|
||||
// as TeamService.AddMemberAsync does, so historic audit entries stay resolvable to one
|
||||
// membership. Their revoked key grants are not restored — those were wrapped to a generation
|
||||
// the vault has since been flagged to leave behind.
|
||||
membership.Role = invitation.Role;
|
||||
membership.Status = MembershipStatus.Active;
|
||||
membership.JoinedAtUtc = now;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Its own SaveChanges, never folded into the caller's. <c>CurrentUserContext.ProvisionAsync</c>
|
||||
/// catches a unique violation and re-reads the account by (issuer, subject); a claim sharing that
|
||||
/// call would put violations from this table inside a filter written for exactly one race, and
|
||||
/// its rethrow would stop being correct.
|
||||
/// </remarks>
|
||||
private async Task<int> SaveClaimAsync(int claimed, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// Two of this account's requests claiming at once. One wins; the other finds nothing
|
||||
// left to do on the next sweep. Swallowed rather than surfaced because this runs inside
|
||||
// the authorization middleware, where a throw is a 500 on a request that was otherwise
|
||||
// fine — and because the outcome the caller wanted has happened either way.
|
||||
foreach (var entry in database.ChangeTracker.Entries<TeamMembership>().ToList())
|
||||
{
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
return claimed;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Refused only for an account that is <em>already in this team</em> — a fact about a team the
|
||||
/// caller can see, so naming it leaks nothing. Whether an address has an account at all is
|
||||
/// deliberately not answered here; see <see cref="CreateAsync"/>.
|
||||
/// </remarks>
|
||||
private async Task RefuseIfAlreadyAMemberAsync(
|
||||
Guid teamId,
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isMember = await database.TeamMemberships
|
||||
.Where(m => m.TeamId == teamId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.Join(
|
||||
database.Users.Where(u => u.Email == email && u.DeletedAtUtc == null),
|
||||
m => m.UserId,
|
||||
u => u.Id,
|
||||
(m, u) => m.Id)
|
||||
.AnyAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (isMember)
|
||||
{
|
||||
throw new TeamInvitationInvalidException(
|
||||
"That address already belongs to a member of this team. Change their role instead.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Derives what has become of an invitation from its timestamps.</summary>
|
||||
/// <remarks>
|
||||
/// Computed rather than stored, which is why <see cref="TeamInvitationState"/> has no domain twin.
|
||||
/// Expiry is a fact about the clock: a stored state would need a sweeper to keep it true, and an
|
||||
/// invitation that read Pending because nothing had run yet would be a lie the interface repeats.
|
||||
/// </remarks>
|
||||
private static TeamInvitationSummary Describe(TeamInvitation invitation, DateTimeOffset now)
|
||||
{
|
||||
var state = invitation switch
|
||||
{
|
||||
{ AcceptedAtUtc: not null } => TeamInvitationState.Accepted,
|
||||
{ RevokedAtUtc: not null } => TeamInvitationState.Revoked,
|
||||
_ when invitation.ExpiresAtUtc <= now => TeamInvitationState.Expired,
|
||||
_ => TeamInvitationState.Pending,
|
||||
};
|
||||
|
||||
return new TeamInvitationSummary(
|
||||
invitation.Id,
|
||||
invitation.Email,
|
||||
ToContract(invitation.Role),
|
||||
state,
|
||||
invitation.InvitedByUserId,
|
||||
invitation.CreatedAtUtc,
|
||||
invitation.ExpiresAtUtc,
|
||||
invitation.AcceptedAtUtc);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Deliberately shallow. This checks the shape the column and the claim path need — one at-sign
|
||||
/// with something either side, no spaces, and inside the length the column holds — and nothing
|
||||
/// more. A stricter address grammar here would reject addresses that a real identity provider
|
||||
/// will happily assert, and the only thing that ultimately decides whether an address is that
|
||||
/// person's is the provider marking it verified.
|
||||
/// </remarks>
|
||||
private static string RequireEmail(string? value)
|
||||
{
|
||||
var email = (value ?? string.Empty).Trim();
|
||||
|
||||
var at = email.IndexOf('@', StringComparison.Ordinal);
|
||||
|
||||
var acceptable = email.Length is > 2 and <= MaxEmailLength
|
||||
&& at > 0
|
||||
&& at == email.LastIndexOf('@')
|
||||
&& at < email.Length - 1
|
||||
&& !email.Any(char.IsWhiteSpace);
|
||||
|
||||
return acceptable
|
||||
? email
|
||||
: throw new TeamInvitationInvalidException(
|
||||
"That does not look like an email address. Invite the address they sign in with.");
|
||||
}
|
||||
|
||||
private static TeamRole ToDomain(TeamMemberRole role) => role switch
|
||||
{
|
||||
TeamMemberRole.Viewer => TeamRole.Viewer,
|
||||
TeamMemberRole.Member => TeamRole.Member,
|
||||
TeamMemberRole.Admin => TeamRole.Admin,
|
||||
TeamMemberRole.Owner => TeamRole.Owner,
|
||||
_ => TeamRole.Unspecified,
|
||||
};
|
||||
|
||||
private static TeamMemberRole ToContract(TeamRole role) => role switch
|
||||
{
|
||||
TeamRole.Viewer => TeamMemberRole.Viewer,
|
||||
TeamRole.Member => TeamMemberRole.Member,
|
||||
TeamRole.Admin => TeamMemberRole.Admin,
|
||||
TeamRole.Owner => TeamMemberRole.Owner,
|
||||
_ => TeamMemberRole.Unspecified,
|
||||
};
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception) =>
|
||||
string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -134,49 +134,4 @@ internal static partial class TeamLog
|
||||
+ "The former owner is now an admin.")]
|
||||
internal static partial void OwnershipTransferred(
|
||||
ILogger logger, Guid teamId, Guid formerOwnerId, Guid newOwnerId);
|
||||
|
||||
/// <remarks>
|
||||
/// The invitation id, never the address. TeamLog's rule is ids and outcomes only, and an email is
|
||||
/// exactly the kind of personal detail a log aggregator would then keep for its whole retention.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2111,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Issued invitation {InvitationId} to team {TeamId} as {Role}, by {ActorId}.")]
|
||||
internal static partial void InvitationIssued(
|
||||
ILogger logger, Guid invitationId, Guid teamId, Domain.TeamRole role, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2112,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Revoked invitation {InvitationId} to team {TeamId}, by {ActorId}.")]
|
||||
internal static partial void InvitationRevoked(
|
||||
ILogger logger, Guid invitationId, Guid teamId, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2113,
|
||||
Level = LogLevel.Information,
|
||||
Message = "User {UserId} claimed invitation {InvitationId} and joined team {TeamId} as {Role}.")]
|
||||
internal static partial void InvitationClaimed(
|
||||
ILogger logger, Guid userId, Guid invitationId, Guid teamId, Domain.TeamRole role);
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Warning, and the one log line an operator will need when invitations appear not to work at all.
|
||||
/// A provider that does not assert <c>email_verified</c> leaves every invitation pending for ever
|
||||
/// with nothing else to show for it, and this is the only place that difference is visible.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It names the count and the account, never the address — the address is the thing being refused
|
||||
/// as untrustworthy, and writing it to a log would be keeping a claim the server just rejected.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2114,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Left {InvitationCount} invitation(s) unclaimed for user {UserId}: the access token "
|
||||
+ "does not assert that their email address is verified. Check the identity provider "
|
||||
+ "sends the email_verified claim.")]
|
||||
internal static partial void InvitationNotClaimedUnverified(
|
||||
ILogger logger, int invitationCount, Guid userId);
|
||||
}
|
||||
|
||||
@@ -277,20 +277,6 @@ internal sealed class TeamService(
|
||||
membership.DeletedAtUtc = now;
|
||||
}
|
||||
|
||||
// Pending invitations go too. An invitation that outlived its team would become a
|
||||
// membership of something nobody can see, on a sign-in weeks later.
|
||||
var invitations = await database.TeamInvitations
|
||||
.Where(i => i.TeamId == team.Id
|
||||
&& i.AcceptedAtUtc == null
|
||||
&& i.RevokedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var invitation in invitations)
|
||||
{
|
||||
invitation.RevokedAtUtc = now;
|
||||
}
|
||||
|
||||
team.DeletedAtUtc = now;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -577,8 +563,9 @@ internal sealed class TeamService(
|
||||
/// Both misses are specific, and neither is a new oracle. An id confirms nothing the caller did not
|
||||
/// already know from the lookup that produced it. An address is answered only for an admin or owner
|
||||
/// of the team the add names — checked by the endpoint before this runs — and is the same fact the
|
||||
/// member list would show them a moment later. It carries its own code so the caller can invite the
|
||||
/// address instead of reporting a failure at somebody who simply is not here yet.
|
||||
/// member list would show them a moment later. It carries its own code so the caller can say what
|
||||
/// has to happen next — that person signing in here once — rather than reporting a failure at
|
||||
/// somebody who simply is not here yet.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<UserAccount> ResolveTargetAsync(
|
||||
@@ -614,8 +601,8 @@ internal sealed class TeamService(
|
||||
.ConfigureAwait(false)
|
||||
|
||||
?? throw new NoSuchAccountException(
|
||||
"No account here uses that address yet. Invite it instead — they join when they "
|
||||
+ "first sign in.");
|
||||
"No account here uses that address yet. Ask them to sign in to this server once, "
|
||||
+ "which is what creates the account, and then add them.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -37,21 +37,14 @@ builder.Services.AddScoped<DeviceService>();
|
||||
builder.Services.AddScoped<DirectoryService>();
|
||||
builder.Services.AddScoped<KeyLogService>();
|
||||
builder.Services.AddScoped<TeamService>();
|
||||
builder.Services.AddScoped<TeamInvitationService>();
|
||||
|
||||
// Registered twice on purpose, resolving to the same scoped instance: the endpoints take the
|
||||
// concrete service, and CurrentUserContext takes only the claim it needs, so the sign-in path does
|
||||
// not gain a reference to the whole of a feature it calls one method on.
|
||||
builder.Services.AddScoped<ITeamInvitationClaim>(
|
||||
provider => provider.GetRequiredService<TeamInvitationService>());
|
||||
builder.Services.AddScoped<VaultGrantService>();
|
||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
|
||||
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
|
||||
// resolving to the same instance, for the reason the invitation claim above is: the endpoint needs the
|
||||
// whole hub — admit, remove, count — while the write paths that announce a change need only the two
|
||||
// methods that announce one, and should not gain a reference to connection management to get them.
|
||||
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
|
||||
// write paths that announce a change need only the two methods that announce one, and should not gain
|
||||
// a reference to connection management to get them.
|
||||
builder.Services.AddSingleton<VaultEventHub>();
|
||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
||||
provider => provider.GetRequiredService<VaultEventHub>());
|
||||
|
||||
@@ -62,28 +62,15 @@ public sealed class OidcOptions
|
||||
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>
|
||||
/// The email and the display name are read for display and for the directory's address lookup, and
|
||||
/// for nothing that decides access. There was an <c>EmailVerifiedClaim</c> beside these, read by
|
||||
/// exactly one feature — claiming a team invitation addressed to that address — and it went with
|
||||
/// the invitations. Nothing in this server now trusts the email claim to grant anything, which is
|
||||
/// the property <see cref="AllowEmailLinking"/> spends its whole doc comment defending; re-adding a
|
||||
/// setting here would be the first step back towards a token's address being a way in.
|
||||
/// </remarks>
|
||||
public string EmailVerifiedClaim { get; set; } = "email_verified";
|
||||
public string NameClaim { get; set; } = "name";
|
||||
}
|
||||
|
||||
/// <summary>Schema management.</summary>
|
||||
|
||||
@@ -54,9 +54,6 @@ internal static class EndpointRegistration
|
||||
typeof(AddTeamMemberEndpoint),
|
||||
typeof(ChangeTeamMemberRoleEndpoint),
|
||||
typeof(RemoveTeamMemberEndpoint),
|
||||
typeof(ListTeamInvitationsEndpoint),
|
||||
typeof(CreateTeamInvitationEndpoint),
|
||||
typeof(RevokeTeamInvitationEndpoint),
|
||||
typeof(CreateTeamVaultEndpoint),
|
||||
typeof(RenameVaultEndpoint),
|
||||
typeof(DeleteVaultEndpoint),
|
||||
|
||||
Reference in New Issue
Block a user