Files

207 lines
7.9 KiB
C#

using System.Text.Json;
namespace WxAgent.Core;
public static class ReportingAuthorization
{
public static ReportingDecision Check(
ReportingConfig? config,
string accountId,
string chatId,
ReportingChatType chatType,
ReportingDataType dataType)
{
if (config is null)
return Denied("ReportingConfigInvalid");
try
{
config.NormalizeAndValidate();
}
catch (WxAgentException)
{
return Denied("ReportingConfigInvalid");
}
if (!config.Enabled)
return Denied("ReportingDisabled");
if (string.IsNullOrWhiteSpace(accountId) || string.IsNullOrWhiteSpace(chatId))
return Denied("ChatIdentityUnconfirmed");
var account = config.FindAccount(accountId);
if (account is null || !account.Enabled)
return Denied("AccountNotAuthorized");
var chat = account.AllowedChats.FirstOrDefault(candidate =>
candidate.Type == chatType && (candidate.ChatId == "*" || string.Equals(candidate.ChatId, chatId, StringComparison.Ordinal)));
if (chat is null)
return Denied("ChatNotAuthorized");
if (!chat.Enabled)
return Denied("ChatNotAuthorized");
if (!chat.IdentityVerified)
return Denied("ChatIdentityUnconfirmed");
if (!IsDataTypeAllowed(dataType))
return Denied("DataTypeNotAuthorized");
return new ReportingDecision(true, "Authorized", config.ConfigVersion);
}
public static bool IsAllowed(
ReportingConfig? config,
string accountId,
string chatId,
ReportingChatType chatType,
ReportingDataType dataType) => Check(config, accountId, chatId, chatType, dataType).Allowed;
public static RemoteMessageEvent? FilterEvent(
ReportingConfig? config,
RemoteMessageEvent messageEvent,
out ReportingDecision decision)
{
decision = Check(config, messageEvent.AccountId, messageEvent.ChatId, messageEvent.ChatType, ReportingDataType.Message);
if (!decision.Allowed)
return null;
RemoteAgentOptions.ValidateIdentifier(messageEvent.NodeId, "nodeId", 200);
RemoteAgentOptions.ValidateIdentifier(messageEvent.EventType, "eventType", 80);
RemoteAgentOptions.ValidateIdentifier(messageEvent.CorrelationId, "correlationId", 128);
if (messageEvent.EventSeq <= 0)
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "eventSeq must be positive.");
if (messageEvent.Content is { Length: > RemoteProtocol.MaxEventContentLength })
throw new WxAgentException(WxAgentErrorCode.InvalidArgument, "Event content is too large.");
if (messageEvent.Content is null)
return messageEvent with { AuthorizationVersion = decision.AuthorizationVersion, Authorized = true };
return messageEvent with { AuthorizationVersion = decision.AuthorizationVersion, Authorized = true };
}
public static RemoteTaskResult FilterTaskResult(
ReportingConfig? config,
RemoteTaskResult result,
string? chatId,
ReportingChatType? chatType,
out ReportingDecision decision)
{
if (result.Content is null || string.IsNullOrWhiteSpace(chatId) || chatType is null)
{
decision = new ReportingDecision(true, "ControlMetadataOnly", config?.ConfigVersion ?? 0);
return result with { Content = null, Message = result.Message is null ? null : "Control metadata only." };
}
decision = Check(config, result.AccountId, chatId, chatType.Value, ReportingDataType.TaskResult);
return decision.Allowed
? result
: result with { Content = null, Message = "Control metadata only." };
}
public static RemoteTaskResult FilterTaskResultForChats(
ReportingConfig? config,
RemoteTaskResult result,
IReadOnlyList<RemoteReportingScope> chatScopes,
out ReportingDecision decision)
{
if (result.Content is null)
{
decision = new ReportingDecision(true, "ControlMetadataOnly", config?.ConfigVersion ?? 0);
return result;
}
if (chatScopes.Count == 0)
{
decision = Denied("ChatNotAuthorized");
return result with { Content = null, Message = "Control metadata only." };
}
var decisions = chatScopes.Select(scope => Check(config, result.AccountId, scope.ChatId, scope.ChatType, ReportingDataType.TaskResult)).ToArray();
decision = decisions.FirstOrDefault(item => !item.Allowed) ?? new ReportingDecision(true, "Authorized", config?.ConfigVersion ?? 0);
return decision.Allowed
? result
: result with { Content = null, Message = "Control metadata only." };
}
private static bool IsDataTypeAllowed(ReportingDataType dataType) => dataType is ReportingDataType.Message or ReportingDataType.TaskResult;
private static ReportingDecision Denied(string reason) => new(false, reason, 0);
}
public static class ReportingConfigStore
{
private static readonly SemaphoreSlim Gate = new(1, 1);
public static async Task<ReportingConfig> LoadAsync(string path, CancellationToken cancellationToken = default)
{
try
{
await using var stream = File.OpenRead(path);
var config = await JsonSerializer.DeserializeAsync<ReportingConfig>(stream, RemoteJson.Options, cancellationToken);
return (config ?? new ReportingConfig()).NormalizeAndValidate();
}
catch (FileNotFoundException)
{
return new ReportingConfig();
}
catch (DirectoryNotFoundException)
{
return new ReportingConfig();
}
catch (JsonException)
{
return new ReportingConfig();
}
catch (WxAgentException)
{
return new ReportingConfig();
}
}
public static async Task SaveAsync(string path, ReportingConfig config, CancellationToken cancellationToken = default)
{
var normalized = config.NormalizeAndValidate();
var fullPath = Path.GetFullPath(path);
var directory = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory;
Directory.CreateDirectory(directory);
await Gate.WaitAsync(cancellationToken);
try
{
var temporary = fullPath + ".tmp-" + Guid.NewGuid().ToString("N");
try
{
await using (var stream = new FileStream(temporary, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough))
{
await JsonSerializer.SerializeAsync(stream, normalized, RemoteJson.Options, cancellationToken);
await stream.FlushAsync(cancellationToken);
}
ReplaceFile(temporary, fullPath);
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
finally
{
Gate.Release();
}
}
public static ReportingConfig Update(ReportingConfig current, Func<ReportingConfig, ReportingConfig> change)
{
ArgumentNullException.ThrowIfNull(current);
ArgumentNullException.ThrowIfNull(change);
var changed = change(current) with { ConfigVersion = checked(current.ConfigVersion + 1) };
return changed.NormalizeAndValidate();
}
private static void ReplaceFile(string temporary, string destination)
{
if (OperatingSystem.IsWindows() && File.Exists(destination))
{
File.Replace(temporary, destination, null);
return;
}
File.Move(temporary, destination, true);
if (!OperatingSystem.IsWindows())
{
try { File.SetUnixFileMode(destination, UnixFileMode.UserRead | UnixFileMode.UserWrite); }
catch (PlatformNotSupportedException) { }
}
}
}