Public Access
Add sync engine: cursors, push/pull, and the advisory-lock ordering proof (M1)
The vault write path. Push is the only way items change — no per-entity POST/PUT/DELETE — so one place enforces revisions, the change log and access control. The concurrency hazard, now proven rather than asserted: bigserial assigns sequence values when the INSERT runs, not at commit, so transaction A can take sequence 5 while B takes 6 and commits first. A reader polling in between sees only 6, advances past 5, and never learns about it. AdvisoryLockOrderingTests reproduces that gap WITHOUT the lock first — otherwise the with-lock test proves nothing, since it would pass just as happily if the interleaving never occurred — then shows pg_advisory_xact_lock removes it, and that 12 concurrent writers produce no gaps. Cursors are opaque and HMAC-tagged, and carry their vault id. 29 unit tests cover the rejections, which are the point: an accepted-but-wrong cursor is silent data loss, strictly worse than an error a client can resync from. Rejected: tampered tag, tampered payload, foreign signing key, a legitimately-issued cursor from another vault, truncation, and hostile input (never throws — cursors come from clients). Push semantics: - 200 even on partial failure, with per-operation status, so one stale item cannot block everything a client queued while offline. - Conflict returns the server's current row for client-side three-way merge. The server cannot merge ciphertext, so never last-writer-wins. - opId receipts make retries exactly-once per operation, not per batch — a client retrying a partially-overlapping batch after a timeout would otherwise double-apply what landed. - A tombstone beats a late upsert, and delete clears hostname/port: leaving the address would keep the server able to resolve a host the user believes they deleted. - Relay field validation mirrors the DB CHECK so a bad request is a clear Invalid rather than a constraint violation surfacing as a 500. Authorization goes through IVaultAccessService, which returns the same answer for "absent" and "forbidden" — distinguishing them is an existence oracle for other tenants' vault ids. Team vaults are explicitly denied until M3 rather than falling through to a permissive default. JIT provisioning keys on (issuer, subject), never email, and handles the concurrent-first-request race via the unique index. Renamed two domain types: Host -> SshHost, because Host collides with Microsoft.Extensions.Hosting.Host in every file of a web project, and SyncChange -> VaultChange to stop it colliding with the Contracts DTO of the same name. Aliasing at every use site would have been permanent friction. Worth noting: `ef migrations has-pending-model-changes` reported clean after those renames even though the snapshot still said "DodoSSH.Domain.Host" — it diffs tables, not CLR type names. The snapshot was regenerated and the emitted DDL diffed against the previous artifacts/schema/v0.1.sql to confirm the rename produced no schema change. Also removed ConfigureAwait(false) from test methods: xUnit1030 flags it as bypassing parallelization limits, which is why MA0004 is suppressed in test projects. Verified: 0 warnings on a clean rebuild, 146 tests pass (up from 122), format clean. Endpoint-level tests are the immediate next step: they need a WireMock OIDC/JWKS stub and real JWT minting, so the "wrong user is denied" matrix does not exist yet for these two routes. The service-layer authorization and the concurrency property are covered.
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
using System.Security.Claims;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Authorization;
|
||||
|
||||
/// <summary>Resolves the authenticated caller, provisioning them on first sight.</summary>
|
||||
public interface ICurrentUserContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the caller's account, creating it if this is their first authenticated request.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The request is not authenticated.</exception>
|
||||
Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request-scoped caller identity with just-in-time provisioning.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal sealed class CurrentUserContext(
|
||||
IHttpContextAccessor accessor,
|
||||
DodoDbContext database,
|
||||
IOptions<Setup.OidcOptions> oidcOptions,
|
||||
TimeProvider clock)
|
||||
: ICurrentUserContext
|
||||
{
|
||||
private UserAccount? cached;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserAccount> GetOrProvisionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var principal = accessor.HttpContext?.User
|
||||
?? throw new InvalidOperationException("No HTTP context is available.");
|
||||
|
||||
if (principal.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
throw new InvalidOperationException("The request is not authenticated.");
|
||||
}
|
||||
|
||||
var issuer = RequireClaim(principal, "iss");
|
||||
var subject = RequireClaim(principal, "sub");
|
||||
var options = oidcOptions.Value;
|
||||
var email = principal.FindFirstValue(options.EmailClaim);
|
||||
var displayName = principal.FindFirstValue(options.NameClaim);
|
||||
|
||||
cached = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false)
|
||||
?? await ProvisionAsync(issuer, subject, email, displayName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||
database.Users.SingleOrDefaultAsync(
|
||||
u => u.Issuer == issuer && u.Subject == subject && u.DeletedAtUtc == null,
|
||||
cancellationToken);
|
||||
|
||||
private async Task<UserAccount> ProvisionAsync(
|
||||
string issuer,
|
||||
string subject,
|
||||
string? email,
|
||||
string? displayName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var user = new UserAccount
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Issuer = issuer,
|
||||
Subject = subject,
|
||||
Email = email,
|
||||
DisplayName = displayName,
|
||||
Status = UserStatus.Active,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
LastSeenAtUtc = now,
|
||||
};
|
||||
|
||||
database.Users.Add(user);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
return user;
|
||||
}
|
||||
catch (DbUpdateException exception)
|
||||
when (string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
// Two concurrent first requests from the same new user. The unique index on
|
||||
// (issuer, subject) is what makes this safe: one insert wins and the other reads it
|
||||
// back, rather than both proceeding with a duplicate account.
|
||||
database.Entry(user).State = EntityState.Detached;
|
||||
|
||||
var winner = await FindAsync(issuer, subject, cancellationToken).ConfigureAwait(false);
|
||||
if (winner is not null)
|
||||
{
|
||||
return winner;
|
||||
}
|
||||
|
||||
// The unique violation came from something other than the race we expected — for
|
||||
// instance the partial unique index on email. Do not swallow it.
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string RequireClaim(ClaimsPrincipal principal, string claimType) =>
|
||||
principal.FindFirstValue(claimType)
|
||||
?? throw new InvalidOperationException(
|
||||
$"The access token is missing the required '{claimType}' claim.");
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Authorization;
|
||||
|
||||
/// <summary>The result of an access check.</summary>
|
||||
/// <param name="Vault">The vault, when access was granted.</param>
|
||||
/// <param name="Permissions">The caller's effective permissions.</param>
|
||||
public readonly record struct VaultAccess(Vault? Vault, PermissionFlags Permissions)
|
||||
{
|
||||
/// <summary>Whether the caller may act on the vault at all.</summary>
|
||||
public bool Granted => Vault is not null;
|
||||
|
||||
/// <summary>Denied access.</summary>
|
||||
public static VaultAccess Denied => new(null, PermissionFlags.None);
|
||||
}
|
||||
|
||||
/// <summary>Resolves what the caller may do with a vault.</summary>
|
||||
public interface IVaultAccessService
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves the caller's effective permissions on a vault.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Returns <see cref="VaultAccess.Denied"/> both when the vault does not exist and when the
|
||||
/// caller cannot see it. Deliberately indistinguishable: a distinct "exists but forbidden"
|
||||
/// answer is an existence oracle that lets a caller enumerate other tenants' vault ids.
|
||||
/// </remarks>
|
||||
Task<VaultAccess> ResolveAsync(Guid userId, Guid vaultId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vault access resolution.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the
|
||||
/// <c>v_user_vault_permission</c> view and per-resource ACLs arrive in M3 — this is the one place
|
||||
/// that changes, which is why every caller goes through it rather than comparing owner ids inline.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A team vault is explicitly denied for now rather than falling through to a permissive default.
|
||||
/// Failing closed on an unimplemented path is the only safe direction.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
|
||||
{
|
||||
/// <summary>Everything the owner of a personal vault may do with it.</summary>
|
||||
private const PermissionFlags OwnerPermissions =
|
||||
PermissionFlags.Read
|
||||
| PermissionFlags.Write
|
||||
| PermissionFlags.Connect
|
||||
| PermissionFlags.Share
|
||||
| PermissionFlags.Admin;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<VaultAccess> ResolveAsync(
|
||||
Guid userId,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vault = await database.Vaults
|
||||
.SingleOrDefaultAsync(v => v.Id == vaultId && v.DeletedAtUtc == null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (vault is null)
|
||||
{
|
||||
return VaultAccess.Denied;
|
||||
}
|
||||
|
||||
if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId)
|
||||
{
|
||||
return new VaultAccess(vault, OwnerPermissions);
|
||||
}
|
||||
|
||||
// Team vaults are not readable until M3 wires up membership and grants. Denying is the
|
||||
// correct behaviour in the meantime.
|
||||
return VaultAccess.Denied;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user