207 lines
7.9 KiB
C#
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 && 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) { }
|
|
}
|
|
}
|
|
}
|