Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,14 @@ To view the full API documentation, visit <https://api.exceptionless.io> and cli

OAuth discovery advertises the scopes the server supports. Each OAuth application has its own allowed scopes, and each authorization grant contains only the scopes and organizations approved by the user. Discovery does not grant every advertised permission to an application.

For the MCP resource, `mcp:read` is required to connect. `projects:read`, `stacks:read`, and `events:read` permit the corresponding read tools. `stacks:write` permits changing stack status (open, fixed, ignored, or discarded), snoozing stacks, changing whether future occurrences are critical, and adding or removing reference links. Access remains limited to the organizations in the grant and the user's current membership.
For the MCP resource, `mcp:read` is required to connect. `projects:read`, `stacks:read`, and `events:read` permit the corresponding read tools. `stacks:write` permits changing stack status (open, fixed, ignored, or discarded), snoozing stacks, changing whether future occurrences are critical, and adding or removing reference links. `saved-views:read` permits listing the saved views the user can see, and `saved-views:write` permits creating, changing, and deleting them, including shared views that everyone in the organization sees. Access remains limited to the organizations in the grant and the user's current membership.

`offline_access` permits refresh token issuance so the client can obtain another access token without a new interactive authorization. It adds no read or write permission. Access tokens default to one hour and refresh tokens to 30 days; these lifetimes are configured with `OAuthServer:AccessTokenLifetimeMinutes` and `OAuthServer:RefreshTokenLifetimeDays`.

Dynamic registration without a scope uses every supported scope. A self-registered client can request any supported scope whatever it registered, because the user decides on the consent page what to grant. Once an administrator saves an application's scopes, that list is enforced.

A token only carries the scopes it was granted, so a client authorized before `saved-views:read` and `saved-views:write` existed gets a `forbidden` error from the saved view tools until the user authorizes it again. The client must request these scopes in that authorization, so a client with a fixed scope list needs to add them. If an administrator has saved the application's scopes, the administrator must add these scopes first.

### Requesting scopes the application does not allow

When an administrator has restricted an application, the consent page offers the requested scopes that list allows and omits the rest, so a client that requests everything discovery advertises can still be approved. The token response's `scope` lists what was granted.
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Core/Authorization/AuthorizationRoles.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public static class AuthorizationRoles
public const string StacksRead = "stacks:read";
public const string StacksWrite = "stacks:write";
public const string SourceMapsWrite = "source-maps:write";
public const string SavedViewsRead = "saved-views:read";
public const string SavedViewsWrite = "saved-views:write";
public const string EventsRead = "events:read";
public const string OfflineAccess = "offline_access";
public static readonly ISet<string> AllScopes = new HashSet<string>([Client, User, GlobalAdmin, SourceMapsWrite]);
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Core/Models/OAuthApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ public IEnumerable<ValidationResult> Validate(ValidationContext validationContex
AuthorizationRoles.StacksRead,
AuthorizationRoles.StacksWrite,
AuthorizationRoles.EventsRead,
AuthorizationRoles.SavedViewsRead,
AuthorizationRoles.SavedViewsWrite,
AuthorizationRoles.OfflineAccess
];

Expand Down
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Services/OAuthService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient,
AuthorizationRoles.StacksRead,
AuthorizationRoles.StacksWrite,
AuthorizationRoles.EventsRead,
AuthorizationRoles.SavedViewsRead,
AuthorizationRoles.SavedViewsWrite,
AuthorizationRoles.OfflineAccess
],
[
Expand Down Expand Up @@ -56,6 +58,8 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient,
AuthorizationRoles.StacksRead,
AuthorizationRoles.StacksWrite,
AuthorizationRoles.EventsRead,
AuthorizationRoles.SavedViewsRead,
AuthorizationRoles.SavedViewsWrite,
AuthorizationRoles.OfflineAccess
];

Expand All @@ -66,6 +70,8 @@ public class OAuthService(OAuthServerOptions options, ICacheClient cacheClient,
AuthorizationRoles.StacksRead,
AuthorizationRoles.StacksWrite,
AuthorizationRoles.EventsRead,
AuthorizationRoles.SavedViewsRead,
AuthorizationRoles.SavedViewsWrite,
AuthorizationRoles.OfflineAccess
];

Expand Down
4 changes: 2 additions & 2 deletions src/Exceptionless.Web/Api/Handlers/SavedViewHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ public async Task<Result<ModelActionResults>> Handle(DeleteSavedViews message)
return results.Failure.Count == 1 ? Result<ModelActionResults>.FromResult(PermissionToResult(results.Failure.First())) : results;

await ClearDefaultReferencesAsync(deletableItems);
await repository.RemoveAsync(deletableItems);
await repository.RemoveAsync(deletableItems, o => o.ImmediateConsistency());

if (results.Failure.Count == 0)
return new ModelActionResults();
Expand All @@ -427,7 +427,7 @@ private async Task<Result<ViewSavedView>> PostImplAsync(NewSavedView value)
mapped.CreatedByUserId = GetCurrentUserId();
mapped.Version = 1;

var model = await repository.AddAsync(mapped, o => o.Cache());
var model = await repository.AddAsync(mapped, o => o.Cache().ImmediateConsistency());
var viewModel = MapToViewModel(model);
string location = linkGenerator.GetUriByName(HttpContext, "GetSavedViewById", new { id = model.Id })
?? throw new InvalidOperationException("Unable to generate saved view location.");
Expand Down
97 changes: 97 additions & 0 deletions src/Exceptionless.Web/Assistant/AssistantConfirmation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Text.Json;

namespace Exceptionless.Web.Assistant;

/// <summary>
/// Deleting a view can't be undone, and changing a shared view changes it for everyone in the organization, so both
/// need the user's say-so in a later message. The model reads text from events and tool results, so it can be talked
/// into calling a tool, but it can't write the user's next message. The prompt asking for this is only the courtesy;
/// these checks are what enforce it.
/// </summary>
internal static class AssistantConfirmation
{
/// <summary>What the follow-up button sends, so people writing in any language can confirm.</summary>
public const string Prompt = "Yes, continue";

private const string DeleteSavedViewTool = "delete_saved_view";
private const string UpdateSavedViewTool = "update_saved_view";
private const string RequiredCode = "confirmation_required";

private static readonly HashSet<string> s_confirmations = new(StringComparer.OrdinalIgnoreCase)
{
"y", "yes", "yep", "yeah", "ok", "okay", "sure", "confirm", "confirmed", "go ahead", "do it", "do that", "please do",
"delete it", "delete them", "change it", "update it",
"yes please", "yes continue", "yes go ahead", "yes do it", "yes do that", "yes please do", "yes delete it", "yes delete them", "yes change it", "yes update it"
};

/// <summary>Deleting a view always asks first, and changing one asks only when it is shared.</summary>
public static bool IsRequired(string toolName, bool isSharedView)
=> toolName == DeleteSavedViewTool || (toolName == UpdateSavedViewTool && isSharedView);

/// <summary>The pending action this call repeats, when the user's latest message confirms it.</summary>
public static AssistantConversationToolResult? FindConfirmed(string toolName, string arguments, AssistantChatRequest request, IEnumerable<AssistantConversationToolResult> pendingConfirmations)
{
string? latestUserMessage = request.Messages.LastOrDefault(message => message.Role == "user")?.Content;
if (!IsConfirmation(latestUserMessage)
|| (toolName == UpdateSavedViewTool && latestUserMessage!.Contains("delete", StringComparison.OrdinalIgnoreCase))
|| (toolName == DeleteSavedViewTool && (latestUserMessage!.Contains("change", StringComparison.OrdinalIgnoreCase) || latestUserMessage.Contains("update", StringComparison.OrdinalIgnoreCase))))
return null;

return pendingConfirmations.FirstOrDefault(pending => pending.ToolName == toolName
&& HasSameArguments(pending.Arguments, arguments)
&& IsRequiredResult(pending.Result));
}

public static bool IsRequiredResult(string result)
{
try
{
using var document = JsonDocument.Parse(result);
var root = document.RootElement;
return root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("ok", out var ok) && ok.ValueKind == JsonValueKind.False
&& root.TryGetProperty("error", out var error) && error.ValueKind == JsonValueKind.Object
&& error.TryGetProperty("code", out var code) && code.ValueKind == JsonValueKind.String
&& code.GetString() == RequiredCode;
}
catch (JsonException)
{
return false;
}
}

public static string CreateRequiredResult(string toolName, JsonSerializerOptions options)
{
string action = toolName == DeleteSavedViewTool
? "Deleting this saved view"
: "Changing a shared view changes it for everyone in the organization, so it";
return JsonSerializer.Serialize(new
{
ok = false,
error = new
{
code = RequiredCode,
message = $"{action} needs the user's confirmation. Nothing has changed yet. Ask the user, and when they confirm, call this tool again with the same arguments."
}
}, options);
}

/// <summary>Only a complete, plain yes counts, so "yes?" or "yes, but not yet" can't approve the action.</summary>
internal static bool IsConfirmation(string? message)
=> message is not null && s_confirmations.Contains(message.Trim().TrimEnd('.', '!').Replace(",", String.Empty, StringComparison.Ordinal));

/// <summary>Compared the way tool execution reads arguments, which accepts repeated property names instead of throwing.</summary>
private static bool HasSameArguments(string first, string second)
{
try
{
using var firstArguments = JsonDocument.Parse(first);
using var secondArguments = JsonDocument.Parse(second);
return firstArguments.RootElement.ValueKind == JsonValueKind.Object && JsonElement.DeepEquals(firstArguments.RootElement, secondArguments.RootElement);
}
catch (JsonException)
{
return false;
}
}
}
44 changes: 38 additions & 6 deletions src/Exceptionless.Web/Assistant/AssistantConversationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,43 @@ public sealed class AssistantConversationService(
{
private readonly ScopedCacheClient _cache = new(cacheClient, "AssistantConversation");

public async Task<AssistantConversationState?> GetAsync(
public async Task<AssistantConversationState> BeginTurnAsync(
string userId,
string organizationId,
string conversationId)
string conversationId,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
string key = GetKey(userId, organizationId, conversationId);
var value = await _cache.GetAsync<AssistantConversationState>(key);
return value.HasValue ? value.Value : null;
AssistantConversationState? turn = null;
bool started = await lockProvider.TryUsingAsync($"assistant-conversation:{key}", async () =>
{
var cached = await _cache.GetAsync<AssistantConversationState>(key);
turn = (cached.HasValue ? cached.Value : new AssistantConversationState([])) with { TurnId = Guid.NewGuid().ToString("N") };

// Every user turn consumes the previous question, even when its answer produces no tool results.
// Only this caller receives the pending action, so concurrent turns cannot share its approval.
if (!await _cache.SetAsync(key, turn with { PendingConfirmations = [] }, TimeSpan.FromMinutes(AssistantLimits.ConversationRetentionMinutes)))
throw new InvalidOperationException("Unable to save the assistant conversation turn.");
}, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5));

if (!started || turn is null)
throw new InvalidOperationException("Unable to start the assistant conversation turn.");

return turn;
}

public async Task<bool> IsCurrentTurnAsync(string userId, string organizationId, string conversationId, string turnId)
{
var cached = await _cache.GetAsync<AssistantConversationState>(GetKey(userId, organizationId, conversationId));
return cached.HasValue && cached.Value.TurnId == turnId;
}

public async Task AppendToolResultsAsync(
string userId,
string organizationId,
string conversationId,
string turnId,
IReadOnlyCollection<AssistantConversationToolResult> toolResults,
CancellationToken cancellationToken)
{
Expand All @@ -36,15 +59,24 @@ public async Task AppendToolResultsAsync(
bool updated = await lockProvider.TryUsingAsync($"assistant-conversation:{key}", async () =>
{
var cached = await _cache.GetAsync<AssistantConversationState>(key);
var merged = (cached.HasValue ? cached.Value.ToolResults : [])
// A slow response from an earlier turn must not replace a newer question or restore consumed approval.
if (!cached.HasValue || cached.Value.TurnId != turnId)
return;

var merged = cached.Value.ToolResults
.Concat(toolResults)
.DistinctBy(result => result.ToolCallId, StringComparer.Ordinal)
.ToList();

while (merged.Count > 0 && JsonSerializer.Serialize(merged).Length > AssistantLimits.MaximumToolContextCharacters)
merged.RemoveAt(0);

var state = new AssistantConversationState(merged);
// Every action this turn held back stays pending, so one yes can confirm a request that covered several views.
var pendingConfirmations = cached.Value.PendingConfirmations
.Concat(toolResults.Where(result => AssistantConfirmation.IsRequiredResult(result.Result)))
.DistinctBy(result => result.ToolCallId, StringComparer.Ordinal)
.ToList();
var state = cached.Value with { ToolResults = merged, PendingConfirmations = pendingConfirmations };
await _cache.SetAsync(key, state, TimeSpan.FromMinutes(AssistantLimits.ConversationRetentionMinutes));
}, TimeSpan.FromSeconds(2), TimeSpan.FromSeconds(5));

Expand Down
19 changes: 16 additions & 3 deletions src/Exceptionless.Web/Assistant/AssistantModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,30 @@ public sealed record AssistantChatMessage(string Role, string Content)
}

// ProjectIds are the projects a result came from. A later turn reuses the result only while all of them still exist
// in the organization; results recorded before this was tracked have none and are not reused.
// in the organization; a successful result with none, such as one recorded before this was tracked, is not reused.
public sealed record AssistantConversationToolResult(
string ToolCallId,
string ToolName,
string Arguments,
string Result,
string? Path,
DateTimeOffset CapturedAtUtc,
IReadOnlyCollection<string>? ProjectIds = null);
IReadOnlyCollection<string>? ProjectIds = null)
{
/// <summary>The cache serializer omits empty collections, so no projects is read back as missing.</summary>
public IReadOnlyCollection<string> ProjectIds { get; init; } = ProjectIds ?? [];
}

public sealed record AssistantConversationState(IReadOnlyCollection<AssistantConversationToolResult> ToolResults)
{
/// <summary>The cache serializer omits empty collections, so an empty history is read back as missing.</summary>
public IReadOnlyCollection<AssistantConversationToolResult> ToolResults { get; init; } = ToolResults ?? [];

public string? TurnId { get; init; }

public sealed record AssistantConversationState(IReadOnlyCollection<AssistantConversationToolResult> ToolResults);
/// <summary>The actions the previous turn asked the user to confirm, which this turn may run once each.</summary>
public IReadOnlyCollection<AssistantConversationToolResult> PendingConfirmations { get; init; } = [];
}

public sealed record AssistantAccessResponse(
bool Enabled,
Expand Down
9 changes: 9 additions & 0 deletions src/Exceptionless.Web/Assistant/AssistantRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,13 @@ public static string Event(string stackId, string eventId)
public static string ProjectStacks(string projectId) => $"/project/{Uri.EscapeDataString(projectId)}/stacks";

public static string Stack(string stackId) => $"/stack/{Uri.EscapeDataString(stackId)}";

/// <summary>Matches savedViewHref in the app: a stream view loads by id, and the other pages load by slug.</summary>
public static string SavedView(string viewType, string slug, string id) => viewType switch
{
"events" => $"/event/{Uri.EscapeDataString(slug)}",
"sessions" => $"/sessions/{Uri.EscapeDataString(slug)}",
"stream" => $"/stream?saved={Uri.EscapeDataString(id)}",
_ => $"/stack/{Uri.EscapeDataString(slug)}"
};
}
Loading
Loading