Add helpers (#2159)

This commit is contained in:
Leendert de Borst
2026-08-01 20:09:19 +02:00
parent e8f6adbe6c
commit 9dcdce7707
2 changed files with 279 additions and 0 deletions
@@ -0,0 +1,118 @@
//-----------------------------------------------------------------------
// <copyright file="EmailAccessHelper.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.Api.Helpers;
using AliasServerDb;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Decides who may read the mail delivered to an email alias. Every alias is always tied to a manifest.
/// </summary>
public static class EmailAccessHelper
{
/// <summary>
/// Check if the user may read mail delivered to the email claim.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="claim">The email claim to check access for.</param>
/// <param name="userId">The user requesting access.</param>
/// <returns>True when the user holds an access key on the alias's manifest.</returns>
public static async Task<bool> CanReadClaimAsync(AliasServerDbContext context, EmailClaim claim, string userId)
{
// An orphaned claim has no manifest and therefore no owner: it is a tombstone holding an address, and nobody may read mail for it.
if (claim.VaultManifestId is null)
{
return false;
}
// Holding any access key on the manifest is proof of access: AccountKey on a root manifest, GrantKey on a shared folder (owner self-grant and recipient alike).
var hasAccessKey = await context.VaultManifestAccessKeys.AnyAsync(k => k.UserId == userId && k.VaultManifestId == claim.VaultManifestId);
if (hasAccessKey)
{
return true;
}
// TODO: legacy fallback — pre-KEK/VEK accounts have no AccountKey row on their root manifest yet, so owning the manifest's group is their only proof of access. Delete once all clients have migrated.
return await context.VaultManifests.AnyAsync(m => m.ManifestId == claim.VaultManifestId && context.GroupMembers.Any(gm => gm.GroupId == m.OwnerGroupId && gm.UserId == userId && gm.Role == GroupRole.Owner));
}
/// <summary>
/// Get the addresses that the user may read.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="addresses">The addresses to check access for.</param>
/// <param name="userId">The user requesting access.</param>
/// <returns>The addresses that the user may read.</returns>
public static async Task<List<string>> FilterReadableAddressesAsync(AliasServerDbContext context, List<string> addresses, string userId)
{
if (addresses.Count == 0)
{
return [];
}
// Get the claims that the user may read.
var claims = await context.EmailClaims
.Where(claim => addresses.Contains(claim.Address) && !claim.Disabled && claim.VaultManifestId != null)
.Select(claim => new { claim.Address, ManifestId = claim.VaultManifestId!.Value, claim.VaultManifest!.OwnerGroupId })
.ToListAsync();
if (claims.Count == 0)
{
return [];
}
// Holding any access key on the manifest is proof of access: AccountKey on a root manifest, GrantKey on a shared folder
// (owner self-grant and recipient alike).
var manifestIds = claims.Select(c => c.ManifestId).Distinct().ToList();
var keyedManifestIds = (await context.VaultManifestAccessKeys
.Where(k => k.UserId == userId && manifestIds.Contains(k.VaultManifestId))
.Select(k => k.VaultManifestId)
.ToListAsync()).ToHashSet();
// Legacy fallback: pre-KEK/VEK accounts have no AccountKey row on their root manifest yet
// so owning the manifest's group is their only proof of access. TODO: delete once all clients have migrated.
var ownedGroupIds = (await GroupHelper.GetOwnedGroupIdsAsync(context, userId)).ToHashSet();
return claims.Where(c => keyedManifestIds.Contains(c.ManifestId) || ownedGroupIds.Contains(c.OwnerGroupId)).Select(c => c.Address).ToList();
}
/// <summary>
/// Get the ids of the encryption keys the user holds the private half of: their own personal
/// keys, plus the keypair of every shared folder they can open.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="userId">The user requesting access.</param>
/// <returns>The ids of the encryption keys the user can decrypt with.</returns>
public static async Task<List<Guid>> ResolveDecryptableKeyIdsAsync(AliasServerDbContext context, string userId)
{
// Get the ids of the user's personal keys.
var personalKeyIds = await context.VaultManifestDeliveryKeys
.Where(k => context.VaultManifests.Any(m => m.ManifestId == k.VaultManifestId && m.IsRoot && context.AliasVaultUsers.Any(u => u.Id == userId && u.PersonalGroupId == m.OwnerGroupId)))
.Select(k => k.Id)
.ToListAsync();
// Get the ids of the encryption keys the user can decrypt with.
var accessibleManifestIds = await context.VaultManifestAccessKeys
.Where(k => k.UserId == userId && k.Type == ManifestKeyType.GrantKey)
.Select(k => k.VaultManifestId)
.Distinct()
.ToListAsync();
if (accessibleManifestIds.Count == 0)
{
return personalKeyIds;
}
// Get the ids of the encryption keys the user can decrypt with.
var folderKeyIds = await context.VaultManifestDeliveryKeys
.Where(k => accessibleManifestIds.Contains(k.VaultManifestId))
.Select(k => k.Id)
.ToListAsync();
return [.. personalKeyIds, .. folderKeyIds];
}
}
@@ -0,0 +1,161 @@
//-----------------------------------------------------------------------
// <copyright file="GroupHelper.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.Api.Helpers;
using AliasServerDb;
using Microsoft.EntityFrameworkCore;
/// <summary>
/// Helper for group operations.
/// </summary>
public static class GroupHelper
{
/// <summary>
/// Get the ids of every group the user owns.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="userId">The user.</param>
/// <returns>The owned group ids.</returns>
public static async Task<List<Guid>> GetOwnedGroupIdsAsync(AliasServerDbContext context, string userId)
{
return await context.GroupMembers.Where(m => m.UserId == userId && m.Role == GroupRole.Owner).Select(m => m.GroupId).ToListAsync();
}
/// <summary>
/// Create a new user's personal group in memory.
/// </summary>
/// <param name="user">The user the group belongs to.</param>
/// <param name="now">Current time.</param>
/// <returns>The unpersisted personal group.</returns>
public static Group CreatePersonalGroup(AliasVaultUser user, DateTime now)
{
var group = new Group
{
Id = Guid.NewGuid(),
Name = user.UserName ?? "Personal",
Type = GroupType.Personal,
CreatedAt = now,
UpdatedAt = now,
};
user.PersonalGroupId = group.Id;
return group;
}
/// <summary>
/// Create the owner's membership row for a group in memory.
/// </summary>
/// <param name="group">The group.</param>
/// <param name="userId">The owning user.</param>
/// <param name="now">Current time.</param>
/// <returns>The unpersisted membership.</returns>
public static GroupMember CreateOwnerMembership(Group group, string userId, DateTime now)
{
return new GroupMember
{
Id = Guid.NewGuid(),
GroupId = group.Id,
UserId = userId,
Role = GroupRole.Owner,
CreatedAt = now,
UpdatedAt = now,
};
}
/// <summary>
/// Check if the user may administer the group.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="groupId">The group.</param>
/// <param name="userId">The user.</param>
/// <returns>True when the user is an owner or admin of the group.</returns>
public static async Task<bool> IsGroupAdminAsync(AliasServerDbContext context, Guid groupId, string userId)
{
return await context.GroupMembers.AnyAsync(m => m.GroupId == groupId && m.UserId == userId && (m.Role == GroupRole.Admin || m.Role == GroupRole.Owner));
}
/// <summary>
/// Get the root manifest of a personal group, i.e. the manifest a user's personal keys and
/// personal aliases are scoped to.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="personalGroupId">The user's personal group.</param>
/// <returns>The root manifest id, or null when the group has none.</returns>
public static async Task<Guid?> GetRootManifestIdAsync(AliasServerDbContext context, Guid personalGroupId)
{
return await context.VaultManifests.Where(m => m.IsRoot && m.OwnerGroupId == personalGroupId).Select(m => (Guid?)m.ManifestId).FirstOrDefaultAsync();
}
/// <summary>
/// Check if the user may administer the shared folder.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="manifestId">The shared folder manifest.</param>
/// <param name="userId">The user.</param>
/// <returns>True when the user can administer the folder.</returns>
public static async Task<bool> CanAdministerManifestAsync(AliasServerDbContext context, Guid manifestId, string userId)
{
var groupId = await context.VaultManifests
.Where(m => m.ManifestId == manifestId && !m.IsRoot)
.Select(m => (Guid?)m.OwnerGroupId)
.FirstOrDefaultAsync();
return groupId is not null && await IsGroupAdminAsync(context, groupId.Value, userId);
}
/// <summary>
/// Get the ownership of a shared folder.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="manifestIds">The shared folder manifests to get the ownership of.</param>
/// <returns>Manifest id to (owning group id, owning user id).</returns>
public static async Task<Dictionary<Guid, (Guid GroupId, string OwnerUserId)>> ResolveQuotaOwnersAsync(AliasServerDbContext context, IEnumerable<Guid> manifestIds)
{
var ids = manifestIds.Distinct().ToList();
if (ids.Count == 0)
{
return [];
}
var rows = await context.VaultManifests
.Where(m => ids.Contains(m.ManifestId))
.Join(context.GroupMembers.Where(gm => gm.Role == GroupRole.Owner), m => m.OwnerGroupId, gm => gm.GroupId, (m, gm) => new { m.ManifestId, gm.GroupId, gm.UserId, gm.CreatedAt })
.ToListAsync();
return rows
.GroupBy(r => r.ManifestId)
.ToDictionary(g => g.Key, g => g.OrderBy(r => r.CreatedAt).ThenBy(r => r.UserId).Select(r => (r.GroupId, r.UserId)).First());
}
/// <summary>
/// Adds the user to the group as a plain member when they are not already in it.
/// </summary>
/// <param name="context">Database context.</param>
/// <param name="groupId">The group.</param>
/// <param name="userId">The user to add.</param>
/// <param name="now">Current time.</param>
/// <returns>A task.</returns>
public static async Task EnsureMembershipAsync(AliasServerDbContext context, Guid groupId, string userId, DateTime now)
{
var exists = await context.GroupMembers.AnyAsync(m => m.GroupId == groupId && m.UserId == userId);
if (exists)
{
return;
}
context.GroupMembers.Add(new GroupMember
{
Id = Guid.NewGuid(),
GroupId = groupId,
UserId = userId,
Role = GroupRole.Member,
CreatedAt = now,
UpdatedAt = now,
});
}
}