Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions SW.Bitween.Api/Data/BitweenDbContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -222,13 +222,15 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
gav.HasKey(i => i.Id);
gav.Property(p => p.Id).IsUnicode(false).HasMaxLength(200);
gav.Property(p => p.Values).StoreAsJson();
gav.Property(p => p.SecretProperties).StoreAsJson();
});
modelBuilder.Entity<Partner>(b =>
{
b.ToTable("Partners");
b.Metadata.SetNavigationAccessMode(PropertyAccessMode.Field);
b.Property(p => p.Name).IsRequired().IsUnicode(false).HasMaxLength(200);
b.Property(p => p.AdapterProperties).StoreAsJson();
b.Property(p => p.SecretProperties).StoreAsJson();
b.HasMany(p => p.Subscriptions).WithOne().IsRequired(false).HasForeignKey(p => p.PartnerId)
.OnDelete(DeleteBehavior.Restrict);
b.OwnsMany(p => p.ApiCredentials, apicred =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,11 @@ public class GlobalAdapterValuesSet:BaseEntity<string>
{
public string Name { get; set; }
public Dictionary<string, string> Values { get; set; }

/// <summary>
/// Names within <see cref="Values"/> whose values are never returned by the API in clear.
/// A set is shared across every adapter that references it, so one secret in a set must not
/// force the whole set out of sight — the flag is per value, not per set.
/// </summary>
public List<string> SecretProperties { get; set; } = new();
}
8 changes: 8 additions & 0 deletions SW.Bitween.Api/Domain/Partner/Partner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,14 @@ public Partner(string name)
public string Name { get; set; }
public Dictionary<string,string> AdapterProperties { get; set; }

/// <summary>
/// Names within <see cref="AdapterProperties"/> whose values are never returned by the API
/// in clear. Opt-in and freely reversible: a partner property is ordinary text until
/// someone marks it, which is why the list lives here rather than being inferred from the
/// key's name — inferring would have hidden values that were readable yesterday.
/// </summary>
public List<string> SecretProperties { get; set; } = new();

readonly HashSet<Subscription> _Subscriptions;
public IReadOnlyCollection<Subscription> Subscriptions => _Subscriptions;

Expand Down
4 changes: 3 additions & 1 deletion SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Create.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Linq;
using System.Threading.Tasks;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
Expand All @@ -22,7 +23,8 @@ public async Task<object> Handle(GlobalAdapterValuesSetCreate request)
{
Id = request.Id,
Name = request.Name,
Values = request.Values
Values = request.Values,
SecretProperties = request.SecretProperties?.ToList() ?? []
};

dbContext.Add(entity);
Expand Down
3 changes: 2 additions & 1 deletion SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Get.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ public async Task<object> Handle(string key)
{
Id = entity.Id,
Name = entity.Name,
Values = entity.Values
Values = AdapterSecretProperties.Mask(entity.Values, entity.SecretProperties),
SecretProperties = entity.SecretProperties
};
}
}
Expand Down
12 changes: 10 additions & 2 deletions SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Search.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public async Task<object> Handle(SearchyRequest searchyRequest, bool lookup = fa
{
Id = item.Id,
Name = item.Name,
Values = item.Values
Values = item.Values,
SecretProperties = item.SecretProperties
};

query = query.AsNoTracking();
Expand All @@ -32,10 +33,17 @@ public async Task<object> Handle(SearchyRequest searchyRequest, bool lookup = fa
return await query.Search(searchyRequest.Conditions).ToDictionaryAsync(k => k.Id, v => v.Name);
}

var result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync();

// The keys stay — the reference picker offers {{globals.set.key}} from this list, and a
// secret you cannot point at is no use to anyone. Only the values behind them go.
foreach (var row in result)
row.Values = AdapterSecretProperties.Mask(row.Values, row.SecretProperties);

return new SearchyResponse<GlobalAdapterValuesSetRow>
{
TotalCount = await query.Search(searchyRequest.Conditions).CountAsync(),
Result = await query.Search(searchyRequest.Conditions, searchyRequest.Sorts, searchyRequest.PageSize, searchyRequest.PageIndex).ToListAsync()
Result = result
};
}
}
Expand Down
6 changes: 5 additions & 1 deletion SW.Bitween.Api/Resources/GlobalAdapterValuesSets/Update.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Linq;
using System.Threading.Tasks;
using FluentValidation;
using SW.Bitween.Domain;
Expand All @@ -18,7 +19,10 @@ public async Task<object> Handle(string key, GlobalAdapterValuesSetUpdate reques
throw new SWValidationException("NOT_FOUND", $"GlobalAdapterValuesSet with id {key} was not found");

entity.Name = request.Name;
entity.Values = request.Values;
// A secret the form did not touch comes back as the sentinel; restore it from storage
// rather than saving the mask.
entity.Values = AdapterSecretProperties.Merge(entity.Values, request.Values);
entity.SecretProperties = request.SecretProperties?.ToList() ?? [];

await dbContext.SaveChangesAsync();
await cache.BroadcastRevoke();
Expand Down
4 changes: 3 additions & 1 deletion SW.Bitween.Api/Resources/Partners/Create.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using SW.Bitween.Domain;
using System.Linq;
using SW.Bitween.Domain;
using SW.Bitween.Model;
using SW.PrimitiveTypes;
using System.Threading.Tasks;
Expand All @@ -17,6 +18,7 @@ public async Task<object> Handle(PartnerCreate model)
// the insert, so a partner is never created half-configured.
if (model.AdapterProperties != null)
entity.AdapterProperties = model.AdapterProperties;
entity.SecretProperties = model.SecretProperties?.ToList() ?? [];
dbContext.Add(entity);
await dbContext.SaveChangesAsync();
return entity.Id;
Expand Down
12 changes: 10 additions & 2 deletions SW.Bitween.Api/Resources/Partners/Get.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ async public Task<object> Handle(int key)
{
await requestContext.EnsurePermission(dbContext, Model.Permissions.Partners.View);

return await dbContext.Set<Partner>().AsNoTracking().
var partner = await dbContext.Set<Partner>().AsNoTracking().
Search("Id", key).
Select(partner => new PartnerUpdate
{
Expand All @@ -38,9 +38,17 @@ async public Task<object> Handle(int key)

}).ToList(),

AdapterProperties = partner.AdapterProperties
AdapterProperties = partner.AdapterProperties,
SecretProperties = partner.SecretProperties

}).AsNoTracking().SingleOrDefaultAsync();

// The names come back so the form can draw the locks; the values behind them do not.
if (partner != null)
partner.AdapterProperties =
AdapterSecretProperties.Mask(partner.AdapterProperties, partner.SecretProperties);

return partner;
}
}
}
13 changes: 12 additions & 1 deletion SW.Bitween.Api/Resources/Partners/Update.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,19 @@ public async Task<object> Handle(int key, PartnerUpdate model)

var entity = await dbContext.FindAsync<Partner>(key);
entity.SetApiCredentials(model.ApiCredentials.Select(kv => new ApiCredential(kv.Key, kv.Value)));
entity.AdapterProperties = model.AdapterProperties;

// Read while they are still there: SetProperties copies the model's own
// AdapterProperties over the entity's, so the stored values are gone after it runs.
var storedProperties = entity.AdapterProperties;
dbContext.Entry(entity).SetProperties(model);

// A secret left untouched arrives as the sentinel, because that is all Get sent. The
// page saves every property together, so without this an edit to the partner's name
// would overwrite its password with a row of dots. After SetProperties, which would
// otherwise put the mask straight back.
entity.AdapterProperties =
AdapterSecretProperties.Merge(storedProperties, model.AdapterProperties);
entity.SecretProperties = model.SecretProperties?.ToList() ?? [];
await dbContext.SaveChangesAsync();
return null;
}
Expand Down
25 changes: 25 additions & 0 deletions SW.Bitween.Api/Services/AdapterSecretProperties.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
Expand Down Expand Up @@ -62,6 +63,30 @@ public async Task<Dictionary<string, string>> Mask(
: kv.Value);
}

/// <summary>
/// Masks the values whose names are in <paramref name="declared"/>, for the property bags whose
/// secrets nobody can ask an adapter about.
/// </summary>
/// <remarks>
/// A partner property or a global value is not a startup value of any one adapter — it is
/// referenced as <c>{{partner.KEY}}</c> by however many adapters point at it, so there is no
/// single adapter to describe. The owner names its own secrets instead, exactly as a data
/// source does. Unlike <c>DataSources.Secrets</c> this does not also guess from the key's name:
/// a partner property has always been readable, and inferring would hide values that an
/// operator could see yesterday.
/// </remarks>
public static Dictionary<string, string> Mask(
IReadOnlyDictionary<string, string> properties, IEnumerable<string> declared)
{
if (properties == null) return null;

var secretNames = new HashSet<string>(declared ?? [], StringComparer.OrdinalIgnoreCase);
return properties.ToDictionary(kv => kv.Key,
kv => secretNames.Contains(kv.Key) && !string.IsNullOrEmpty(kv.Value)
? Sentinel
: kv.Value);
}

/// <summary>
/// Resolves the sentinels in <paramref name="incoming"/> against what is already stored. A
/// sentinel with nothing stored under that key is dropped rather than saved literally.
Expand Down
Loading
Loading