fix authorized remote message reads
Build web service image / build (push) Successful in 48s

This commit is contained in:
2026-09-21 11:18:09 +08:00
parent f8290a65bc
commit ab9ff389f2
7 changed files with 245 additions and 61 deletions
@@ -64,9 +64,20 @@ public sealed class RemoteAgentHostedService(
await client.HeartbeatAsync(CreateHeartbeat(remote, reporting, snapshot, null), stoppingToken);
await ReplayUnreportedResultsAsync(client, ledger, reporting, stoppingToken);
await client.FlushEventsAsync(eventQueue, reporting, stoppingToken);
foreach (var account in reporting.Accounts.Where(account => account.Enabled))
var pollAccountIds = reporting.Accounts
.Where(account => account.Enabled)
.Select(account => account.AccountId)
.ToList();
if (snapshot.ActiveAccountId is { Length: > 0 } activeAccountId &&
!pollAccountIds.Contains(activeAccountId, StringComparer.Ordinal))
{
var tasks = await client.PollTasksAsync(account.AccountId, stoppingToken, waitSeconds: 5);
// Poll the verified active account even before a Reporting whitelist is configured.
// The task then returns an explicit authorization result instead of staying Pending.
pollAccountIds.Add(activeAccountId);
}
foreach (var accountId in pollAccountIds)
{
var tasks = await client.PollTasksAsync(accountId, stoppingToken, waitSeconds: 5);
foreach (var task in tasks)
{
await ProcessTaskAsync(client, remote, reporting, ledger, task, stoppingToken);
@@ -254,11 +265,13 @@ public sealed class RemoteAgentHostedService(
var payload = ReadPayload<ReadSessionsPayload>(task.Payload);
ValidatePage(payload.Limit, payload.Offset);
var scopes = AuthorizedChatScopes(reporting, task.AccountId);
RequireScopes(scopes);
var sessions = (await backend.SessionsAsync(task.AccountId, cancellationToken))
.Where(session => scopes.Any(scope => scope.ChatId == session.AutomationId))
RequireScopes(reporting, task.AccountId, scopes);
var sessions = await backend.SessionsAsync(task.AccountId, cancellationToken);
var contacts = await ReadContactsForScopesAsync(task.AccountId, scopes, cancellationToken);
var visibleSessions = sessions
.Where(session => scopes.Any(scope => SessionMatchesScope(session, scope, contacts)))
.ToArray();
var page = sessions.ToPage(payload.Limit, payload.Offset);
var page = visibleSessions.ToPage(payload.Limit, payload.Offset);
return new RemoteReadExecution(JsonSerializer.SerializeToElement(page, RemoteJson.Options), scopes);
}
case "read-contacts":
@@ -267,7 +280,7 @@ public sealed class RemoteAgentHostedService(
ValidatePage(payload.Limit, payload.Offset);
var chatType = payload.GroupsOnly ? ReportingChatType.Group : ReportingChatType.Private;
var scopes = AuthorizedChatScopes(reporting, task.AccountId, chatType);
RequireScopes(scopes);
RequireScopes(reporting, task.AccountId, scopes);
var all = new List<ContactInfo>();
for (var offset = 0; ;)
{
@@ -288,17 +301,31 @@ public sealed class RemoteAgentHostedService(
{
var payload = ReadPayload<ReadMessagesPayload>(task.Payload);
ValidatePage(payload.Limit, payload.Offset);
var scopes = AuthorizedChatScopes(reporting, task.AccountId)
.Where(scope => string.Equals(scope.ChatId, payload.ChatId, StringComparison.Ordinal))
.ToArray();
if (scopes.Length != 1)
throw new ServiceException("ChatNotAuthorized", 403, "The requested chat is not enabled in the local whitelist.");
var sessions = (await backend.SessionsAsync(task.AccountId, cancellationToken))
var scopes = AuthorizedChatScopes(reporting, task.AccountId);
RequireScopes(reporting, task.AccountId, scopes);
var sessions = await backend.SessionsAsync(task.AccountId, cancellationToken);
var contacts = await ReadContactsForScopesAsync(task.AccountId, scopes, cancellationToken);
var candidateSessions = sessions
.Where(session => string.Equals(session.AutomationId, payload.ChatId, StringComparison.Ordinal))
.ToArray();
if (sessions.Length != 1)
var scope = scopes.FirstOrDefault(allowed => string.Equals(allowed.ChatId, payload.ChatId, StringComparison.Ordinal));
if (scope is null)
{
var mapped = sessions
.Where(session => scopes.Any(allowed => SessionMatchesScope(session, allowed, contacts)))
.Where(session => string.Equals(session.AutomationId, payload.ChatId, StringComparison.Ordinal))
.ToArray();
if (mapped.Length == 1)
{
candidateSessions = mapped;
scope = scopes.First(allowed => SessionMatchesScope(mapped[0], allowed, contacts));
}
}
if (scope is null)
throw new ServiceException("ChatNotAuthorized", 403, "The requested chat is not enabled in the local whitelist.");
if (candidateSessions.Length != 1)
throw new ServiceException("ChatIdentityUnconfirmed", 409, "The requested chat identity is not uniquely visible.");
var messages = await backend.MessagesAsync(task.AccountId, sessions[0].Name, payload.IncludeContent, cancellationToken);
var messages = await backend.MessagesAsync(task.AccountId, candidateSessions[0].AutomationId, payload.IncludeContent, cancellationToken);
var page = messages.ToPage(payload.Limit, payload.Offset);
return new RemoteReadExecution(JsonSerializer.SerializeToElement(page, RemoteJson.Options), scopes);
}
@@ -307,6 +334,44 @@ public sealed class RemoteAgentHostedService(
}
}
private async Task<IReadOnlyList<ContactInfo>> ReadContactsForScopesAsync(
string accountId, IReadOnlyList<RemoteReportingScope> scopes, CancellationToken cancellationToken)
{
var contacts = new Dictionary<string, ContactInfo>(StringComparer.Ordinal);
foreach (var scope in scopes)
{
for (var offset = 0; ;)
{
var page = await backend.ContactsAsync(
accountId,
contains: scope.ChatId,
groupsOnly: scope.ChatType == ReportingChatType.Group,
200,
offset,
cancellationToken);
foreach (var contact in page.Items.Where(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal)))
contacts[contact.Id] = contact;
if (!page.HasMore) break;
var next = page.NextOffset ?? offset + page.Items.Count;
if (next <= offset)
throw new ServiceException("InvalidPage", 500, "The node returned a non-advancing contact page.");
offset = next;
}
}
return contacts.Values.ToArray();
}
private static bool SessionMatchesScope(SessionInfo session, RemoteReportingScope scope, IReadOnlyList<ContactInfo> contacts)
{
if (string.Equals(scope.ChatId, session.AutomationId, StringComparison.Ordinal) ||
string.Equals(scope.ChatId, session.Name, StringComparison.Ordinal))
return true;
var matches = contacts.Where(contact => string.Equals(contact.Id, scope.ChatId, StringComparison.Ordinal)).ToArray();
return matches.Length == 1 && matches[0] is { } contact &&
(string.Equals(contact.DisplayName, session.Name, StringComparison.Ordinal) ||
string.Equals(contact.Remark, session.Name, StringComparison.Ordinal));
}
private static IReadOnlyList<RemoteReportingScope> AuthorizedChatScopes(
ReportingConfig reporting, string accountId, ReportingChatType? chatType = null) =>
(reporting.FindAccount(accountId)?.AllowedChats ?? [])
@@ -316,8 +381,13 @@ public sealed class RemoteAgentHostedService(
.Distinct()
.ToArray();
private static void RequireScopes(IReadOnlyList<RemoteReportingScope> scopes)
private static void RequireScopes(ReportingConfig reporting, string accountId, IReadOnlyList<RemoteReportingScope> scopes)
{
if (!reporting.Enabled)
throw new ServiceException("ReportingDisabled", 403, "Reporting is disabled on the node.");
var account = reporting.FindAccount(accountId);
if (account is null || !account.Enabled)
throw new ServiceException("AccountNotAuthorized", 403, "The target account is not enabled in the local Reporting configuration.");
if (scopes.Count == 0)
throw new ServiceException("ChatNotAuthorized", 403, "The requested data scope is not enabled in the local whitelist.");
}