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:
@@ -62,47 +62,6 @@ public sealed class TeamMembershipConfiguration : IEntityTypeConfiguration<TeamM
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps <see cref="TeamInvitation"/>.</summary>
|
||||
public sealed class TeamInvitationConfiguration : IEntityTypeConfiguration<TeamInvitation>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void Configure(EntityTypeBuilder<TeamInvitation> builder)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
|
||||
builder.ToTable("team_invitation");
|
||||
builder.HasKey(i => i.Id);
|
||||
builder.Property(i => i.Id).ValueGeneratedNever();
|
||||
builder.UseXminConcurrencyToken();
|
||||
|
||||
// citext, matching user_account.email: the claim at sign-in compares an address the identity
|
||||
// provider chose the casing of against one a person typed, and lower() on both sides of that
|
||||
// is a rule somebody eventually forgets on one side.
|
||||
builder.Property(i => i.Email).HasColumnType("citext").HasMaxLength(320).IsRequired();
|
||||
builder.Property(i => i.Role).HasConversion<int>();
|
||||
|
||||
builder.HasOne(i => i.Team)
|
||||
.WithMany(t => t.Invitations)
|
||||
.HasForeignKey(i => i.TeamId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// One live invitation per address per team. Filtered on the two tombstones rather than on a
|
||||
// deletion marker, as vault_key_grant is: an accepted or withdrawn invitation is kept, and
|
||||
// re-inviting an address whose invitation lapsed has to be possible.
|
||||
//
|
||||
// Expiry is deliberately not in this predicate. A partial index predicate must be IMMUTABLE,
|
||||
// so now() cannot appear in one; an expired invitation therefore still holds the slot, and
|
||||
// the service treats replacing one as a revoke-and-reissue rather than a second insert.
|
||||
builder.HasIndex(i => new { i.TeamId, i.Email })
|
||||
.IsUnique()
|
||||
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
|
||||
|
||||
// The claim at sign-in knows the address and nothing else — it is looking for every team that
|
||||
// invited this person, across all of them — so the address is the hot direction here.
|
||||
builder.HasIndex(i => i.Email);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Maps <see cref="Vault"/>.</summary>
|
||||
public sealed class VaultConfiguration : IEntityTypeConfiguration<Vault>
|
||||
{
|
||||
|
||||
@@ -45,9 +45,6 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
||||
/// <summary>Team memberships.</summary>
|
||||
public DbSet<TeamMembership> TeamMemberships => Set<TeamMembership>();
|
||||
|
||||
/// <summary>Invitations to addresses with no account here yet.</summary>
|
||||
public DbSet<TeamInvitation> TeamInvitations => Set<TeamInvitation>();
|
||||
|
||||
/// <summary>Vaults.</summary>
|
||||
public DbSet<Vault> Vaults => Set<Vault>();
|
||||
|
||||
|
||||
+1809
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Infrastructure.Migrations
|
||||
{
|
||||
/// <summary>
|
||||
/// Removes the invitation table. Membership is now only ever granted to an account that exists.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Pending invitations are dropped rather than converted, and that is the decision rather than
|
||||
/// the omission.</b> Converting one would mean creating a membership because an address matched —
|
||||
/// which is the exact property this change exists to remove, and the same one
|
||||
/// <c>OidcOptions.AllowEmailLinking</c> refuses one door along. In practice almost nothing is lost:
|
||||
/// an invitation to an address that already had an account here was claimed within the hour by the
|
||||
/// sign-in sweep, so what is left in this table is offers to people who never arrived, and there
|
||||
/// was never an account to make a membership out of.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The Down is a faithful rebuild of the empty table and nothing else. It cannot bring the rows
|
||||
/// back, and a migration that pretended otherwise would be worse than one that says so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public partial class DropTeamInvitation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "team_invitation",
|
||||
schema: "dodo");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "team_invitation",
|
||||
schema: "dodo",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
team_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
accepted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
accepted_by_user_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
email = table.Column<string>(type: "citext", maxLength: 320, nullable: false),
|
||||
expires_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||
invited_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
revoked_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||
role = table.Column<int>(type: "integer", nullable: false),
|
||||
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_team_invitation", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "fk_team_invitation_team_team_id",
|
||||
column: x => x.team_id,
|
||||
principalSchema: "dodo",
|
||||
principalTable: "team",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_team_invitation_email",
|
||||
schema: "dodo",
|
||||
table: "team_invitation",
|
||||
column: "email");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "ix_team_invitation_team_id_email",
|
||||
schema: "dodo",
|
||||
table: "team_invitation",
|
||||
columns: new[] { "team_id", "email" },
|
||||
unique: true,
|
||||
filter: "accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -316,70 +316,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.ToTable("team", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset?>("AcceptedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("accepted_at_utc");
|
||||
|
||||
b.Property<Guid?>("AcceptedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("accepted_by_user_id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at_utc");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("citext")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("expires_at_utc");
|
||||
|
||||
b.Property<Guid>("InvitedByUserId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("invited_by_user_id");
|
||||
|
||||
b.Property<DateTimeOffset?>("RevokedAtUtc")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("revoked_at_utc");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<Guid>("TeamId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
b.Property<uint>("xmin")
|
||||
.IsConcurrencyToken()
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("xid")
|
||||
.HasColumnName("xmin");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_team_invitation");
|
||||
|
||||
b.HasIndex("Email")
|
||||
.HasDatabaseName("ix_team_invitation_email");
|
||||
|
||||
b.HasIndex("TeamId", "Email")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_team_invitation_team_id_email")
|
||||
.HasFilter("accepted_at_utc IS NULL AND revoked_at_utc IS NULL");
|
||||
|
||||
b.ToTable("team_invitation", "dodo");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -1644,18 +1580,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
b.Navigation("Vault");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.TeamInvitation", b =>
|
||||
{
|
||||
b.HasOne("DodoSSH.Domain.Team", "Team")
|
||||
.WithMany("Invitations")
|
||||
.HasForeignKey("TeamId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_team_invitation_team_team_id");
|
||||
|
||||
b.Navigation("Team");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.TeamMembership", b =>
|
||||
{
|
||||
b.HasOne("DodoSSH.Domain.Team", "Team")
|
||||
@@ -1858,8 +1782,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Domain.Team", b =>
|
||||
{
|
||||
b.Navigation("Invitations");
|
||||
|
||||
b.Navigation("Memberships");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user