Skip to content

Commit 9ed7147

Browse files
committed
RE1-T117 Working on chatbot permission and security.
1 parent d54a7b3 commit 9ed7147

58 files changed

Lines changed: 4608 additions & 1700 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,5 +273,7 @@ Web/Resgrid.WebCore/wwwroot/lib/*
273273
/Web/Resgrid.Web/wwwroot/lib
274274
.dual-graph/
275275
.claude/settings.local.json
276-
.claude/settings.local.json
277276
/Web/Resgrid.Web/wwwroot/js/ng/chunks
277+
opencode.json
278+
/.dual-graph-pro
279+
.mcp.json

Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ public class KeywordIntentClassifier : INLUProvider
135135
"list_departments", null),
136136
};
137137

138-
public Task<NLUResult> ClassifyAsync(string text, string context = null)
138+
public Task<NLUResult> ClassifyAsync(string text, string context = null, int departmentId = 0)
139139
{
140140
if (string.IsNullOrWhiteSpace(text))
141141
return Task.FromResult(new NLUResult { IntentName = "unknown", Confidence = 0, ProviderName = ProviderName });

Core/Resgrid.Chatbot.NLU/Providers/MLNetNluProvider.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ public MLNetNluProvider()
1414
{
1515
}
1616

17-
public Task<NLUResult> ClassifyAsync(string text, string context = null)
17+
public Task<NLUResult> ClassifyAsync(string text, string context = null, int departmentId = 0)
1818
{
1919
if (string.IsNullOrWhiteSpace(text))
2020
return Task.FromResult(new NLUResult { IntentName = "unknown", Confidence = 0, ProviderName = ProviderName });

Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class OpenAiCompatibleNluProvider : INLUProvider
3737
public int Priority => 100;
3838

3939
private readonly HttpClient _httpClient;
40+
private readonly IChatbotDepartmentConfigService _configService;
4041

4142
private static readonly string IntentSystemPrompt = @"You are a classification engine for emergency service chatbot commands.
4243
Classify the user's message into exactly one of these intent categories:
@@ -97,8 +98,9 @@ public class OpenAiCompatibleNluProvider : INLUProvider
9798
Confidence should be between 0.0 and 1.0 based on how certain you are.
9899
If the user's message doesn't clearly match any intent, set intent to ""unknown"" with confidence 0.0.";
99100

100-
public OpenAiCompatibleNluProvider()
101+
public OpenAiCompatibleNluProvider(IChatbotDepartmentConfigService configService)
101102
{
103+
_configService = configService;
102104
_httpClient = new HttpClient
103105
{
104106
Timeout = TimeSpan.FromSeconds(ChatbotConfig.CloudNluTimeoutSeconds > 0
@@ -107,7 +109,7 @@ public OpenAiCompatibleNluProvider()
107109
};
108110
}
109111

110-
public async Task<NLUResult> ClassifyAsync(string text, string context = null)
112+
public async Task<NLUResult> ClassifyAsync(string text, string context = null, int departmentId = 0)
111113
{
112114
if (string.IsNullOrWhiteSpace(text))
113115
return new NLUResult { IntentName = "unknown", Confidence = 0, ProviderName = ProviderName };
@@ -116,9 +118,17 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null)
116118

117119
try
118120
{
119-
var endpoint = ResolveEndpoint();
120-
var apiKey = ResolveApiKey();
121-
var model = ResolveModel();
121+
// A department may supply its own LLM provider so its processing stays with that
122+
// provider; otherwise fall back to the Resgrid system-level configuration.
123+
DepartmentLlmOverride departmentLlm = null;
124+
if (departmentId > 0 && _configService != null)
125+
departmentLlm = await _configService.GetLlmOverrideAsync(departmentId);
126+
127+
var endpoint = departmentLlm != null ? departmentLlm.Endpoint : ResolveEndpoint();
128+
var apiKey = departmentLlm != null ? departmentLlm.ApiKey : ResolveApiKey();
129+
var model = departmentLlm != null && !string.IsNullOrWhiteSpace(departmentLlm.Model)
130+
? departmentLlm.Model
131+
: ResolveModel();
122132

123133
if (string.IsNullOrWhiteSpace(apiKey))
124134
{
@@ -166,8 +176,9 @@ public async Task<NLUResult> ClassifyAsync(string text, string context = null)
166176
};
167177
request.Headers.Add("Authorization", $"Bearer {apiKey}");
168178

169-
// Azure OpenAI uses api-key header instead
170-
if (ChatbotConfig.CloudNluProvider == CloudNluProviderType.AzureOpenAI)
179+
// Azure OpenAI uses api-key header instead (system config only; a department override
180+
// is assumed OpenAI-compatible with Bearer auth).
181+
if (departmentLlm == null && ChatbotConfig.CloudNluProvider == CloudNluProviderType.AzureOpenAI)
171182
{
172183
request.Headers.Remove("Authorization");
173184
request.Headers.Add("api-key", apiKey);

Core/Resgrid.Chatbot/ChatbotModule.cs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ protected override void Load(ContainerBuilder builder)
2222
.As<IChatbotSessionManager>()
2323
.SingleInstance();
2424

25+
// InstancePerLifetimeScope (not SingleInstance) because it now depends on the
26+
// per-scope IChatbotIdentityRepository (a singleton would capture a scoped DB connection).
2527
builder.RegisterType<ChatbotUserIdentityService>()
2628
.As<IChatbotUserIdentityService>()
27-
.SingleInstance();
29+
.InstancePerLifetimeScope();
2830

2931
// Phase 2: New services
3032
builder.RegisterType<IntentMapper>()
@@ -39,6 +41,14 @@ protected override void Load(ContainerBuilder builder)
3941
.As<IChatbotTemplateRenderer>()
4042
.SingleInstance();
4143

44+
builder.RegisterType<ChatbotDepartmentConfigService>()
45+
.As<IChatbotDepartmentConfigService>()
46+
.InstancePerLifetimeScope();
47+
48+
builder.RegisterType<ChatbotRateLimiter>()
49+
.As<IChatbotRateLimiter>()
50+
.SingleInstance();
51+
4252
builder.RegisterType<OAuthLinkingService>()
4353
.AsSelf()
4454
.InstancePerLifetimeScope();

Core/Resgrid.Chatbot/Config/ChatbotConfig.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,19 @@ public static class ChatbotConfig
3737
public static string SlackBotToken = "";
3838
public static string SlackAppToken = "";
3939
public static string TelegramBotToken = "";
40+
// Secret token sent by Telegram in the X-Telegram-Bot-Api-Secret-Token header
41+
// (configured via setWebhook). When set, inbound webhooks lacking a matching token are rejected.
42+
public static string TelegramWebhookSecretToken = "";
4043
public static string LinkingBaseUrl = "";
4144

45+
// OAuth2 app credentials for Discord/Slack account linking (server-side code exchange).
46+
public static string DiscordClientId = "";
47+
public static string DiscordClientSecret = "";
48+
public static string SlackClientId = "";
49+
public static string SlackClientSecret = "";
50+
// Redirect URI registered with the OAuth apps; the platform returns the code here.
51+
public static string OAuthRedirectUri = "";
52+
4253
// Message Logging
4354
public static int MessageLogRetentionDays = 90;
4455
public static bool LogMessageContent = true;

Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,13 @@ public class CallDetailActionHandler : IChatbotActionHandler
1313
{
1414
private readonly ICallsService _callsService;
1515
private readonly IDepartmentsService _departmentsService;
16+
private readonly IAuthorizationService _authorizationService;
1617

17-
public CallDetailActionHandler(ICallsService callsService, IDepartmentsService departmentsService)
18+
public CallDetailActionHandler(ICallsService callsService, IDepartmentsService departmentsService, IAuthorizationService authorizationService)
1819
{
1920
_callsService = callsService;
2021
_departmentsService = departmentsService;
22+
_authorizationService = authorizationService;
2123
}
2224

2325
public ChatbotIntentType IntentType => ChatbotIntentType.GetCallDetail;
@@ -32,11 +34,20 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
3234
}
3335

3436
var call = await _callsService.GetCallByIdAsync(callId);
35-
if (call == null)
37+
38+
// Tenant isolation (anti-IDOR): a call that doesn't exist OR belongs to another
39+
// department must be indistinguishable so call ids can't be enumerated across tenants.
40+
if (call == null || call.DepartmentId != session.DepartmentId)
3641
{
3742
return new ChatbotResponse { Text = $"Call #{callId} not found.", Processed = true };
3843
}
3944

45+
// Authorization: the call is in the user's department, but they still need view permission.
46+
if (!await _authorizationService.CanUserViewCallAsync(session.UserId, call.CallId))
47+
{
48+
return new ChatbotResponse { Text = "You don't have permission to view this call.", Processed = false };
49+
}
50+
4051
var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId);
4152
var sb = new StringBuilder();
4253
sb.AppendLine($"Call #{call.CallId}: {call.Name}");

Core/Resgrid.Chatbot/Handlers/PersonnelActionHandler.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,22 @@ public class PersonnelActionHandler : IChatbotActionHandler
1515
private readonly IActionLogsService _actionLogsService;
1616
private readonly IUserStateService _userStateService;
1717
private readonly ICustomStateService _customStateService;
18+
private readonly IAuthorizationService _authorizationService;
1819

1920
public PersonnelActionHandler(
2021
IDepartmentsService departmentsService,
2122
IUsersService usersService,
2223
IActionLogsService actionLogsService,
2324
IUserStateService userStateService,
24-
ICustomStateService customStateService)
25+
ICustomStateService customStateService,
26+
IAuthorizationService authorizationService)
2527
{
2628
_departmentsService = departmentsService;
2729
_usersService = usersService;
2830
_actionLogsService = actionLogsService;
2931
_userStateService = userStateService;
3032
_customStateService = customStateService;
33+
_authorizationService = authorizationService;
3134
}
3235

3336
public ChatbotIntentType IntentType => ChatbotIntentType.PersonnelLookup;
@@ -36,6 +39,12 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
3639
{
3740
try
3841
{
42+
// Authorization: only users permitted to view the roster may list personnel.
43+
if (!await _authorizationService.CanUserViewAllPeopleAsync(session.UserId, session.DepartmentId))
44+
{
45+
return new ChatbotResponse { Text = "You don't have permission to view personnel for your department.", Processed = false };
46+
}
47+
3948
var allUsers = await _usersService.GetUserGroupAndRolesByDepartmentIdInLimitAsync(session.DepartmentId, false, false, false);
4049
var lastActionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(session.DepartmentId);
4150
var userStates = await _userStateService.GetLatestStatesForDepartmentAsync(session.DepartmentId);
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System.Threading.Tasks;
2+
using Resgrid.Chatbot.Models;
3+
using Resgrid.Model;
4+
5+
namespace Resgrid.Chatbot.Interfaces
6+
{
7+
/// <summary>
8+
/// Loads and persists per-department chatbot configuration (cache-aside), and resolves a
9+
/// department's own LLM override (decrypted). The system-level <c>ChatbotConfig</c> remains the
10+
/// default for departments without a row or without an LLM override.
11+
/// </summary>
12+
public interface IChatbotDepartmentConfigService
13+
{
14+
/// <summary>Gets the department's config (or null if none). The LlmApiKey remains encrypted.</summary>
15+
Task<ChatbotDepartmentConfig> GetConfigAsync(int departmentId, bool bypassCache = false);
16+
17+
/// <summary>
18+
/// Returns the department's own LLM endpoint/key(decrypted)/model when both endpoint and key
19+
/// are configured; otherwise null (caller falls back to the system provider).
20+
/// </summary>
21+
Task<DepartmentLlmOverride> GetLlmOverrideAsync(int departmentId);
22+
23+
/// <summary>
24+
/// Inserts or updates the department's config. <paramref name="newPlaintextLlmKey"/>:
25+
/// null = keep existing key, "" = clear it, non-empty = encrypt and store it.
26+
/// </summary>
27+
Task<ChatbotDepartmentConfig> SaveConfigAsync(ChatbotDepartmentConfig config, string newPlaintextLlmKey = null);
28+
29+
Task InvalidateCacheAsync(int departmentId);
30+
}
31+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
using System.Threading.Tasks;
2+
3+
namespace Resgrid.Chatbot.Interfaces
4+
{
5+
/// <summary>
6+
/// Per-minute rate limiting for inbound chatbot messages, enforced per user and per department.
7+
/// Uses Redis for cross-instance counting when available, falling back to in-memory otherwise.
8+
/// </summary>
9+
public interface IChatbotRateLimiter
10+
{
11+
/// <summary>
12+
/// Records an inbound message and returns false if it would exceed either the per-user or
13+
/// per-department limit for the current minute. A limit &lt;= 0 means unlimited.
14+
/// </summary>
15+
Task<bool> TryAcquireAsync(string userId, int departmentId, int perUserPerMinute, int perDepartmentPerMinute);
16+
}
17+
}

0 commit comments

Comments
 (0)