Update API controllers (#2159)

This commit is contained in:
Leendert de Borst
2026-07-23 08:11:52 +02:00
parent 019723c64d
commit 88d168bca2
7 changed files with 280 additions and 100 deletions
@@ -253,12 +253,18 @@ public class SharingController(
}
/// <summary>
/// List the shared folders the caller has been granted access to, each with the wrapped VEK the caller unwraps
/// with its private key.
/// Upload a new revision of a shared-folder manifest. Allowed for the owner and for every user holding a
/// <c>shared</c> grant on it — all members write the same manifest, guarded by the optimistic revision check
/// (a stale writer gets <see cref="VaultStatus.Outdated"/> and must re-pull + merge, like the root manifest).
/// Blob bytes are uploaded beforehand via <c>POST /v2/Vault/blobs</c> into the pusher's own store; the manifest
/// snapshot endpoints resolve referenced blobs across member stores.
/// </summary>
/// <returns>The shared folders available to the caller.</returns>
[HttpGet("shared-with-me")]
public async Task<IActionResult> SharedWithMe()
/// <param name="manifestId">The shared folder manifest id.</param>
/// <param name="model">The update request.</param>
/// <param name="clientHeader">The client identifier header.</param>
/// <returns>The update response.</returns>
[HttpPost("folders/{manifestId:guid}")]
public async Task<IActionResult> UpdateFolder(Guid manifestId, [FromBody] UpdateSharedFolderRequest model, [FromHeader(Name = "X-AliasVault-Client")] string? clientHeader)
{
await using var context = await dbContextFactory.CreateDbContextAsync();
var me = await GetCurrentUserAsync();
@@ -267,44 +273,57 @@ public class SharingController(
return Unauthorized();
}
var grants = await context.VaultKeys
.Where(x => x.UserId == me.Id && x.KeyType == AuthHelper.VaultKeyTypeShared)
.ToListAsync();
var response = new SharedWithMeResponse();
if (grants.Count == 0)
var manifest = await context.VaultManifests.FirstOrDefaultAsync(x => x.ManifestId == manifestId && !x.IsRoot);
var canWrite = manifest != null && (manifest.OwnerUserId == me.Id || await context.VaultKeys.AnyAsync(k => k.UserId == me.Id && k.KeyType == AuthHelper.VaultKeyTypeShared && k.VaultManifestId == manifestId));
if (manifest == null || !canWrite)
{
return Ok(response);
return NotFound(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.SHARED_MANIFEST_NOT_FOUND, 404));
}
var manifestIds = grants.Where(g => g.VaultManifestId != null).Select(g => g.VaultManifestId!.Value).ToList();
var manifestsById = await context.VaultManifests
.Where(m => manifestIds.Contains(m.ManifestId))
.ToDictionaryAsync(m => m.ManifestId);
var ownerIds = manifestsById.Values.Select(m => m.OwnerUserId).Distinct().ToList();
var ownerUsernamesById = await context.AliasVaultUsers
.Where(u => ownerIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.UserName);
foreach (var g in grants)
var newRevision = model.CurrentRevision + 1;
if (manifest.RevisionNumber >= newRevision)
{
if (g.VaultManifestId is null || !manifestsById.TryGetValue(g.VaultManifestId.Value, out var manifest))
return Ok(new UpdateSharedFolderResponse { Status = VaultStatus.Outdated, NewRevisionNumber = manifest.RevisionNumber });
}
var strategy = context.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync<IActionResult>(async () =>
{
await using var tx = await context.Database.BeginTransactionAsync();
// Referenced blob bytes must already exist in some member's store (uploaded via POST /v2/Vault/blobs).
if (model.BlobReferences.Count > 0)
{
continue;
var refHashes = model.BlobReferences.Select(r => r.Hash).Distinct().ToList();
var presentHashes = await context.VaultBlobObjects.Where(b => refHashes.Contains(b.Hash)).Select(b => b.Hash).Distinct().ToListAsync();
if (refHashes.Except(presentHashes).Any())
{
await tx.RollbackAsync();
return BadRequest(ApiErrorCodeHelper.CreateValidationErrorResponse(ApiErrorCode.VAULT_NOT_UP_TO_DATE, 400));
}
}
response.Folders.Add(new SharedWithMeItem
{
ManifestId = manifest.ManifestId,
Name = manifest.Name,
OwnerUserId = manifest.OwnerUserId,
OwnerUsername = ownerUsernamesById.GetValueOrDefault(manifest.OwnerUserId),
WrappedVek = g.WrappedVek,
WrapScheme = g.WrapScheme,
});
}
// Archive the current revision, then update the row in place. TODO: apply a retention policy to
// shared-manifest history (currently only the root manifest history is pruned).
context.VaultManifestsHistory.Add(VaultManifestsHistory.CreateFrom(manifest));
return Ok(response);
manifest.ManifestBlob = model.ManifestBlob;
manifest.ManifestCiphertextHash = model.ManifestCiphertextHash;
manifest.Version = model.Version;
manifest.RevisionNumber = newRevision;
manifest.FileSize = FileHelper.Base64StringToKilobytes(model.ManifestBlob);
manifest.Client = clientHeader;
manifest.UpdatedAt = timeProvider.UtcNow;
foreach (var dto in model.BlobReferences)
{
context.VaultBlobReferences.Add(new VaultBlobReference { ManifestId = manifest.ManifestId, RevisionNumber = newRevision, BlobHash = dto.Hash });
}
await context.SaveChangesAsync();
await tx.CommitAsync();
return Ok(new UpdateSharedFolderResponse { Status = VaultStatus.Ok, NewRevisionNumber = newRevision });
});
}
}
@@ -73,8 +73,9 @@ public class VaultController(
return Unauthorized();
}
// Current revision per logical manifest.
var manifestRevisions = await context.VaultManifests
// Current revision per logical manifest: everything the user owns plus manifests shared with them,
// so revision-based pull detection covers shared folders too.
var ownedManifestRevisions = await context.VaultManifests
.Where(x => x.OwnerUserId == user.Id && x.StorageFormat == ManifestFormat)
.Select(x => new ManifestRevision { ManifestId = x.ManifestId, IsRoot = x.IsRoot, Revision = x.RevisionNumber })
.ToListAsync();
@@ -83,6 +84,14 @@ public class VaultController(
// IsRoot=false, owned by another user) must never make a not-yet-migrated user look migrated, or the client
// would push without CreateVaultKey and the upload would fail with VAULT_KEY_NOT_FOUND.
var isMigrated = ownedManifestRevisions.Any(x => x.IsRoot);
var manifestRevisions = ownedManifestRevisions;
var grantedManifestIds = await GetGrantedManifestIdsAsync(context, user.Id);
manifestRevisions.AddRange(await context.VaultManifests
.Where(x => grantedManifestIds.Contains(x.ManifestId) && x.StorageFormat == ManifestFormat)
.Select(x => new ManifestRevision { ManifestId = x.ManifestId, IsRoot = false, Revision = x.RevisionNumber })
.ToListAsync());
// Latest revision per bucket kind.
var bucketRevisions = await context.VaultDataBuckets
.Where(x => x.OwnerUserId == user.Id)
@@ -118,7 +127,7 @@ public class VaultController(
// Current revision per logical manifest (one row per manifest in the VaultManifests table).
var latestManifests = await context.VaultManifests
.Where(x => x.OwnerUserId == user.Id && x.StorageFormat == ManifestFormat)
.Select(x => new { x.ManifestId, x.IsRoot, x.ManifestBlob, x.ManifestCiphertextHash, x.RevisionNumber })
.Select(x => new { x.ManifestId, x.IsRoot, x.Name, x.ManifestBlob, x.ManifestCiphertextHash, x.RevisionNumber })
.ToListAsync();
if (latestManifests.Count == 0)
@@ -178,12 +187,17 @@ public class VaultController(
{
ManifestId = m.ManifestId,
IsRoot = m.IsRoot,
Name = m.Name,
Blob = m.ManifestBlob,
CiphertextHash = m.ManifestCiphertextHash,
Revision = m.RevisionNumber,
BlobReferences = refsByManifest.TryGetValue(m.ManifestId, out var refs) ? refs : [],
}).ToList();
// Append manifests shared with this user by other owners, each carrying the wrapped VEK the caller
// unwraps with its private key.
manifests.AddRange(await BuildSharedWithMeManifestsAsync(context, user.Id));
return Ok(new GetResponse
{
Status = VaultStatus.Ok,
@@ -212,8 +226,9 @@ public class VaultController(
return Unauthorized();
}
// The caller can fetch a manifest it owns, or one another user granted to it (a shared folder).
var latest = await context.VaultManifests
.Where(x => x.OwnerUserId == user.Id && x.StorageFormat == ManifestFormat && x.ManifestId == manifestId)
.Where(x => x.StorageFormat == ManifestFormat && x.ManifestId == manifestId && (x.OwnerUserId == user.Id || context.VaultKeys.Any(k => k.UserId == user.Id && k.KeyType == AuthHelper.VaultKeyTypeShared && k.VaultManifestId == x.ManifestId)))
.FirstOrDefaultAsync();
if (latest == null)
@@ -221,24 +236,36 @@ public class VaultController(
return NotFound();
}
var blobRefs = await context.VaultBlobReferences
.Where(r => r.ManifestId == latest.ManifestId && r.RevisionNumber == latest.RevisionNumber)
.Join(
context.VaultBlobObjects.Where(b => b.OwnerUserId == user.Id),
r => r.BlobHash,
b => b.Hash,
(r, b) => new BlobReference { Hash = b.Hash, Category = b.Category })
.ToListAsync();
var blobRefs = (await context.VaultBlobReferences
.Where(r => r.ManifestId == latest.ManifestId && r.RevisionNumber == latest.RevisionNumber)
.Join(context.VaultBlobObjects, r => r.BlobHash, b => b.Hash, (r, b) => new { b.Hash, b.Category })
.Distinct()
.ToListAsync())
.Select(x => new BlobReference { Hash = x.Hash, Category = x.Category })
.ToList();
return Ok(new Manifest
var manifest = new Manifest
{
ManifestId = latest.ManifestId,
IsRoot = latest.IsRoot,
Name = latest.Name,
Blob = latest.ManifestBlob,
CiphertextHash = latest.ManifestCiphertextHash,
Revision = latest.RevisionNumber,
BlobReferences = blobRefs,
});
};
// Not the owner: enrich with the caller's grant (wrapped VEK + owner identity), same shape as the snapshot.
if (latest.OwnerUserId != user.Id)
{
var grant = await context.VaultKeys.FirstOrDefaultAsync(k => k.UserId == user.Id && k.KeyType == AuthHelper.VaultKeyTypeShared && k.VaultManifestId == latest.ManifestId);
var ownerUsername = await context.AliasVaultUsers.Where(u => u.Id == latest.OwnerUserId).Select(u => u.UserName).FirstOrDefaultAsync();
manifest.WrappedVek = grant?.WrappedVek;
manifest.WrapScheme = grant?.WrapScheme;
manifest.OwnerUsername = ownerUsername;
}
return Ok(manifest);
}
/// <summary>
@@ -565,6 +592,46 @@ public class VaultController(
})
.ToListAsync();
// Hashes not in the caller's own store may belong to a shared folder: any blob referenced by the current
// revision of a manifest the caller can access (granted to them, or a manifest they own that another member
// pushed blobs for) is downloadable regardless of which member's store holds the ciphertext.
var missing = wanted.Except(rows.Select(r => r.Hash), StringComparer.Ordinal).ToList();
if (missing.Count > 0)
{
var accessibleManifests = await context.VaultManifests
.Where(m => m.StorageFormat == ManifestFormat && !m.IsRoot && (m.OwnerUserId == user.Id || context.VaultKeys.Any(k => k.UserId == user.Id && k.KeyType == AuthHelper.VaultKeyTypeShared && k.VaultManifestId == m.ManifestId)))
.Select(m => new { m.ManifestId, m.RevisionNumber })
.ToListAsync();
var accessibleIds = accessibleManifests.Select(m => m.ManifestId).ToList();
var currentRevisionById = accessibleManifests.ToDictionary(m => m.ManifestId, m => m.RevisionNumber);
if (accessibleIds.Count > 0)
{
var referencedHashes = (await context.VaultBlobReferences
.Where(r => accessibleIds.Contains(r.ManifestId) && missing.Contains(r.BlobHash))
.Select(r => new { r.ManifestId, r.RevisionNumber, r.BlobHash })
.ToListAsync())
.Where(r => currentRevisionById.TryGetValue(r.ManifestId, out var rev) && rev == r.RevisionNumber)
.Select(r => r.BlobHash)
.Distinct()
.ToList();
if (referencedHashes.Count > 0)
{
var sharedRows = await context.VaultBlobObjects
.Where(b => referencedHashes.Contains(b.Hash))
.Select(b => new Blob
{
Hash = b.Hash,
Category = b.Category,
EncryptedDataBase64 = Convert.ToBase64String(b.EncryptedData),
})
.ToListAsync();
rows.AddRange(sharedRows.GroupBy(b => b.Hash, StringComparer.Ordinal).Select(g => g.First()));
}
}
}
return Ok(rows);
}
@@ -601,6 +668,79 @@ public class VaultController(
return newRev;
}
/// <summary>
/// The ids of manifests other users have granted to <paramref name="userId"/> via a <c>shared</c> VaultKey row.
/// </summary>
private static async Task<List<Guid>> GetGrantedManifestIdsAsync(AliasServerDbContext context, string userId)
{
return await context.VaultKeys
.Where(k => k.UserId == userId && k.KeyType == AuthHelper.VaultKeyTypeShared && k.VaultManifestId != null)
.Select(k => k.VaultManifestId!.Value)
.ToListAsync();
}
/// <summary>
/// Builds the manifest DTOs for every shared folder granted to <paramref name="userId"/> by other users: the
/// encrypted manifest blob plus the grant's wrapped VEK, wrap scheme, and owner identity. Blob references are
/// taken straight from the manifest's current revision, unscoped by store owner (see DownloadBlobs).
/// </summary>
private static async Task<List<Manifest>> BuildSharedWithMeManifestsAsync(AliasServerDbContext context, string userId)
{
var grants = await context.VaultKeys
.Where(k => k.UserId == userId && k.KeyType == AuthHelper.VaultKeyTypeShared && k.VaultManifestId != null)
.ToListAsync();
if (grants.Count == 0)
{
return [];
}
var manifestIds = grants.Select(g => g.VaultManifestId!.Value).ToList();
var manifestsById = await context.VaultManifests
.Where(m => manifestIds.Contains(m.ManifestId) && m.StorageFormat == ManifestFormat)
.ToDictionaryAsync(m => m.ManifestId);
var ownerIds = manifestsById.Values.Select(m => m.OwnerUserId).Distinct().ToList();
var ownerUsernamesById = await context.AliasVaultUsers
.Where(u => ownerIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.UserName);
var refRows = await context.VaultBlobReferences
.Where(r => manifestIds.Contains(r.ManifestId))
.Join(context.VaultBlobObjects, r => r.BlobHash, b => b.Hash, (r, b) => new { r.ManifestId, r.RevisionNumber, b.Hash, b.Category })
.ToListAsync();
var result = new List<Manifest>();
foreach (var grant in grants)
{
if (!manifestsById.TryGetValue(grant.VaultManifestId!.Value, out var manifestRow))
{
continue;
}
var blobRefs = refRows
.Where(r => r.ManifestId == manifestRow.ManifestId && r.RevisionNumber == manifestRow.RevisionNumber)
.GroupBy(r => r.Hash, StringComparer.Ordinal)
.Select(g => new BlobReference { Hash = g.Key, Category = g.First().Category })
.ToList();
result.Add(new Manifest
{
ManifestId = manifestRow.ManifestId,
IsRoot = false,
Name = manifestRow.Name,
Blob = manifestRow.ManifestBlob,
CiphertextHash = manifestRow.ManifestCiphertextHash,
Revision = manifestRow.RevisionNumber,
BlobReferences = blobRefs,
OwnerUsername = ownerUsernamesById.GetValueOrDefault(manifestRow.OwnerUserId),
WrappedVek = grant.WrappedVek,
WrapScheme = grant.WrapScheme,
});
}
return result;
}
/// <summary>
/// Upserts a batch of encrypted blob objects for a user in one round-trip. Existing blobs (same hash) only get
/// their LastReferencedAt bumped, unless <paramref name="overwrite"/> is set (KEK/VEK migration) in which case
@@ -1,33 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="SharedWithMeItem.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.V2.Sharing;
/// <summary>
/// One shared folder the caller has been granted access to. The caller unwraps <see cref="WrappedVek"/> with the
/// private key matching the public key it was wrapped for, then uses the resulting VEK to decrypt the folder manifest.
/// </summary>
public class SharedWithMeItem
{
/// <summary>Gets or sets the shared folder manifest id.</summary>
public required Guid ManifestId { get; set; }
/// <summary>Gets or sets the folder's display name.</summary>
public string? Name { get; set; }
/// <summary>Gets or sets the owner's user id.</summary>
public required string OwnerUserId { get; set; }
/// <summary>Gets or sets the owner's username.</summary>
public string? OwnerUsername { get; set; }
/// <summary>Gets or sets the folder VEK wrapped with the caller's public key (base64).</summary>
public required string WrappedVek { get; set; }
/// <summary>Gets or sets the wrap scheme of the grant (e.g. "x25519-sealedbox").</summary>
public required string WrapScheme { get; set; }
}
@@ -1,17 +0,0 @@
//-----------------------------------------------------------------------
// <copyright file="SharedWithMeResponse.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.V2.Sharing;
/// <summary>
/// Response for GET /v2/Sharing/shared-with-me. Lists every shared folder the caller holds a grant for.
/// </summary>
public class SharedWithMeResponse
{
/// <summary>Gets or sets the shared folders the caller has access to.</summary>
public List<SharedWithMeItem> Folders { get; set; } = [];
}
@@ -0,0 +1,33 @@
//-----------------------------------------------------------------------
// <copyright file="UpdateSharedFolderRequest.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.V2.Sharing;
using AliasVault.Shared.Models.WebApi.V2.Vault;
/// <summary>
/// Request for POST /v2/Sharing/folders/{manifestId}. Uploads a new revision of a shared-folder manifest. Allowed
/// for the manifest owner and for every user holding a <c>shared</c> grant on it; concurrency is guarded by the
/// same optimistic revision check the root manifest upload uses.
/// </summary>
public class UpdateSharedFolderRequest
{
/// <summary>Gets or sets the encrypted folder manifest blob (AES-GCM ciphertext under the folder VEK, base64).</summary>
public required string ManifestBlob { get; set; }
/// <summary>Gets or sets the SHA-256 (hex) of the manifest ciphertext, for storage-layer integrity verification.</summary>
public string? ManifestCiphertextHash { get; set; }
/// <summary>Gets or sets the vault data model version string.</summary>
public required string Version { get; set; }
/// <summary>Gets or sets the manifest revision the client last synced; the new revision must be exactly one above it.</summary>
public required long CurrentRevision { get; set; }
/// <summary>Gets or sets the blob references of this manifest revision. Blob bytes are uploaded beforehand via POST /v2/Vault/blobs.</summary>
public List<BlobReference> BlobReferences { get; set; } = [];
}
@@ -0,0 +1,22 @@
//-----------------------------------------------------------------------
// <copyright file="UpdateSharedFolderResponse.cs" company="aliasvault">
// Copyright (c) aliasvault. All rights reserved.
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
// </copyright>
//-----------------------------------------------------------------------
namespace AliasVault.Shared.Models.WebApi.V2.Sharing;
using AliasVault.Shared.Models.Enums;
/// <summary>
/// Response for POST /v2/Sharing/folders/{manifestId}.
/// </summary>
public class UpdateSharedFolderResponse
{
/// <summary>Gets or sets the outcome: Ok when stored, Outdated when the client's revision is stale (re-pull and merge).</summary>
public required VaultStatus Status { get; set; }
/// <summary>Gets or sets the manifest's current server revision after this call.</summary>
public required long NewRevisionNumber { get; set; }
}
@@ -35,4 +35,20 @@ public class Manifest
/// <summary>Gets or sets the blob references this manifest revision needs (so the client can detect cache misses).</summary>
public List<BlobReference> BlobReferences { get; set; } = [];
/// <summary>Gets or sets the plaintext display name of a shared-folder manifest. Null for the root manifest.</summary>
public string? Name { get; set; }
/// <summary>Gets or sets the username of the manifest owner. Set only on manifests granted to the caller by another user.</summary>
public string? OwnerUsername { get; set; }
/// <summary>
/// Gets or sets the manifest VEK wrapped with the caller's public key. Set only on manifests granted to the
/// caller by another user; the caller unwraps it with their private key. Null on manifests the caller owns
/// (the owner keeps their own copy of the folder VEK inside their root vault).
/// </summary>
public string? WrappedVek { get; set; }
/// <summary>Gets or sets the wrap scheme of <see cref="WrappedVek"/> (e.g. "rsa-oaep"). Null when <see cref="WrappedVek"/> is null.</summary>
public string? WrapScheme { get; set; }
}