From 6b18cbca0ec5afc8b7b5202faa746f7e550f9a43 Mon Sep 17 00:00:00 2001 From: LD-Reborn Date: Fri, 17 Jul 2026 19:32:49 +0200 Subject: [PATCH] feat(groups): adds groups --- src/ReverseLlama.Server/AdminEndpoints.cs | 130 ++++- src/ReverseLlama.Server/ManagementStore.cs | 531 ++++++++++++++++++ src/ReverseLlama.Server/Program.cs | 4 + .../ReverseProxyEndpoint.cs | 104 +++- .../TokenAuthentication.cs | 113 +++- src/ReverseLlama.Server/wwwroot/admin/app.css | 24 +- src/ReverseLlama.Server/wwwroot/admin/app.js | 349 +++++++++++- .../wwwroot/admin/index.html | 1 + 8 files changed, 1219 insertions(+), 37 deletions(-) diff --git a/src/ReverseLlama.Server/AdminEndpoints.cs b/src/ReverseLlama.Server/AdminEndpoints.cs index c02da4e..1333643 100644 --- a/src/ReverseLlama.Server/AdminEndpoints.cs +++ b/src/ReverseLlama.Server/AdminEndpoints.cs @@ -73,7 +73,7 @@ internal static class AdminEndpoints } catch (Exception exception) { - return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest); + return Results.BadRequest(new { error = exception.Message }); } }); @@ -86,7 +86,7 @@ internal static class AdminEndpoints } catch (Exception exception) { - return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest); + return Results.BadRequest(new { error = exception.Message }); } }); @@ -176,7 +176,7 @@ internal static class AdminEndpoints } catch (Exception exception) { - return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest); + return Results.BadRequest(new { error = exception.Message }); } }); @@ -185,6 +185,115 @@ internal static class AdminEndpoints ? Results.NoContent() : Results.NotFound(new { error = $"API key '{id}' was not found." })); + api.MapGet("/groups", (ManagementStore store) => + Results.Json(store.ListGroups())); + + api.MapPost("/groups", (CreateGroupRequest request, ManagementStore store) => + { + try + { + return Results.Json(store.CreateGroup(request.Name)); + } + catch (Exception exception) + { + return Results.BadRequest(new { error = exception.Message }); + } + }); + + api.MapGet("/groups/{id}", (string id, ManagementStore store) => + { + var group = store.GetGroup(id); + return group is not null + ? Results.Json(group) + : Results.NotFound(new { error = $"Group '{id}' was not found." }); + }); + + api.MapPut("/groups/{id}", (string id, UpdateGroupRequest request, ManagementStore store) => + { + if (string.IsNullOrWhiteSpace(request.Name)) + { + return Results.BadRequest(new { error = "Name is required." }); + } + + return store.UpdateGroup(id, request.Name) + ? Results.Ok(store.GetGroup(id)) + : Results.NotFound(new { error = $"Group '{id}' was not found." }); + }); + + api.MapDelete("/groups/{id}", (string id, ManagementStore store) => + store.DeleteGroup(id) + ? Results.NoContent() + : Results.NotFound(new { error = $"Group '{id}' was not found." })); + + api.MapGet("/groups/{id}/members", (string id, ManagementStore store) => + { + var group = store.GetGroup(id); + if (group is null) + { + return Results.NotFound(new { error = $"Group '{id}' was not found." }); + } + + return Results.Json(store.ListGroupMembers(id)); + }); + + api.MapPost("/groups/{id}/members", (string id, AddGroupMemberRequest request, ManagementStore store) => + { + var group = store.GetGroup(id); + if (group is null) + { + return Results.NotFound(new { error = $"Group '{id}' was not found." }); + } + + try + { + var member = store.AddGroupMember(id, request.ClientId, request.Model, request.ClientPattern); + return Results.Json(member); + } + catch (ArgumentException exception) + { + return Results.BadRequest(new { error = exception.Message }); + } + catch (Exception exception) + { + return Results.BadRequest(new { error = $"Failed to add member: {exception.Message}" }); + } + }); + + api.MapDelete("/groups/{groupId}/members/{memberId:long}", (string groupId, long memberId, ManagementStore store) => + { + var group = store.GetGroup(groupId); + if (group is null) + { + return Results.NotFound(new { error = $"Group '{groupId}' was not found." }); + } + + return store.RemoveGroupMember(memberId) + ? Results.NoContent() + : Results.NotFound(new { error = $"Member '{memberId}' was not found." }); + }); + + api.MapGet("/api-keys/groups", (ManagementStore store) => + Results.Json(store.ListApiKeyGroups())); + + api.MapPut("/api-keys/{id}/groups", (string id, SetApiKeyGroupsRequest request, ManagementStore store) => + { + var keys = store.ListApiKeys(); + if (!keys.Any(k => k.Id == id)) + { + return Results.NotFound(new { error = $"API key '{id}' was not found." }); + } + + try + { + store.SetApiKeyGroups(id, request.GroupIds ?? []); + return Results.Ok(new { apiKeyId = id, groupIds = store.GetApiKeyGroupIds(id) }); + } + catch (Exception exception) + { + return Results.BadRequest(new { error = exception.Message }); + } + }); + var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) => ServeAdminAsset(environment, null)); var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) => @@ -224,7 +333,9 @@ internal static class AdminEndpoints }, clients = BuildClientSummaries(hub, store), models = BuildModelSummaries(hub, store), - apiKeys = store.ListApiKeys() + apiKeys = store.ListApiKeys(), + groups = store.ListGroups(), + apiKeyGroups = store.ListApiKeyGroups() }; private static IReadOnlyList BuildClientSummaries(TunnelHub hub, ManagementStore store) @@ -470,6 +581,17 @@ internal sealed record ModelActionRequest( internal sealed record CreateApiKeyRequest(string? Name); +internal sealed record CreateGroupRequest(string? Name); + +internal sealed record UpdateGroupRequest(string Name); + +internal sealed record AddGroupMemberRequest( + string? ClientId, + string? Model, + string? ClientPattern); + +internal sealed record SetApiKeyGroupsRequest(IReadOnlyList? GroupIds); + internal sealed record ClientSummary( string Id, bool Connected, diff --git a/src/ReverseLlama.Server/ManagementStore.cs b/src/ReverseLlama.Server/ManagementStore.cs index 67ec18f..5f5475c 100644 --- a/src/ReverseLlama.Server/ManagementStore.cs +++ b/src/ReverseLlama.Server/ManagementStore.cs @@ -1,5 +1,6 @@ using System.Security.Cryptography; using System.Text; +using System.Text.RegularExpressions; using Microsoft.Data.Sqlite; namespace ReverseLlama.Server; @@ -497,6 +498,466 @@ internal sealed class ManagementStore return result; } + public string? GetApiKeyId(string apiKey) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey)) + { + return null; + } + + var hash = HashApiKey(apiKey); + + lock (_lock) + { + return _apiKeysByHash.TryGetValue(hash, out var key) ? key.Id : null; + } + } + + public IReadOnlyList ListGroups() + { + if (!_isAvailable) + { + return []; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, name, created_at_utc, updated_at_utc FROM groups ORDER BY name"; + + var result = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + result.Add(new GroupInfo( + reader.GetString(0), + reader.GetString(1), + ReadDateTimeOffset(reader.GetString(2)), + ReadDateTimeOffset(reader.GetString(3)))); + } + + return result; + } + } + + public GroupInfo? GetGroup(string groupId) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(groupId)) + { + return null; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT id, name, created_at_utc, updated_at_utc FROM groups WHERE id = $id"; + command.Parameters.AddWithValue("$id", groupId); + + using var reader = command.ExecuteReader(); + if (!reader.Read()) + { + return null; + } + + return new GroupInfo( + reader.GetString(0), + reader.GetString(1), + ReadDateTimeOffset(reader.GetString(2)), + ReadDateTimeOffset(reader.GetString(3))); + } + } + + public GroupInfo CreateGroup(string? name) + { + EnsureAvailable(); + + var now = DateTimeOffset.UtcNow; + var id = Guid.NewGuid().ToString("n"); + var groupName = string.IsNullOrWhiteSpace(name) ? "Group" : name.Trim(); + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO groups (id, name, created_at_utc, updated_at_utc) + VALUES ($id, $name, $created_at_utc, $updated_at_utc) + """; + command.Parameters.AddWithValue("$id", id); + command.Parameters.AddWithValue("$name", groupName); + command.Parameters.AddWithValue("$created_at_utc", now.ToString("O")); + command.Parameters.AddWithValue("$updated_at_utc", now.ToString("O")); + command.ExecuteNonQuery(); + } + + return new GroupInfo(id, groupName, now, now); + } + + public bool UpdateGroup(string groupId, string name) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(groupId) || string.IsNullOrWhiteSpace(name)) + { + return false; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + UPDATE groups SET name = $name, updated_at_utc = $updated_at_utc + WHERE id = $id + """; + command.Parameters.AddWithValue("$id", groupId); + command.Parameters.AddWithValue("$name", name.Trim()); + command.Parameters.AddWithValue("$updated_at_utc", DateTimeOffset.UtcNow.ToString("O")); + return command.ExecuteNonQuery() > 0; + } + } + + public bool DeleteGroup(string groupId) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(groupId)) + { + return false; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM groups WHERE id = $id"; + command.Parameters.AddWithValue("$id", groupId); + return command.ExecuteNonQuery() > 0; + } + } + + public IReadOnlyList ListGroupMembers(string groupId) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(groupId)) + { + return []; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT id, group_id, client_id, model, client_pattern + FROM group_members + WHERE group_id = $group_id + ORDER BY client_id, model, client_pattern + """; + command.Parameters.AddWithValue("$group_id", groupId); + + var result = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + result.Add(new GroupMemberInfo( + reader.GetInt64(0), + reader.GetString(1), + reader.IsDBNull(2) ? null : reader.GetString(2), + reader.IsDBNull(3) ? null : reader.GetString(3), + reader.IsDBNull(4) ? null : reader.GetString(4))); + } + + return result; + } + } + + public GroupMemberInfo AddGroupMember(string groupId, string? clientId, string? model, string? clientPattern) + { + EnsureAvailable(); + + if (string.IsNullOrWhiteSpace(groupId)) + { + throw new ArgumentException("Group id is required.", nameof(groupId)); + } + + if (string.IsNullOrWhiteSpace(clientId) && string.IsNullOrWhiteSpace(clientPattern)) + { + throw new ArgumentException("Either client_id or client_pattern is required."); + } + + if (!string.IsNullOrWhiteSpace(clientPattern)) + { + try + { + _ = Regex.IsMatch("", clientPattern); + } + catch (RegexParseException ex) + { + throw new ArgumentException($"Unable to add client - invalid regex: {ex.Message}", nameof(clientPattern)); + } + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + INSERT INTO group_members (group_id, client_id, model, client_pattern) + VALUES ($group_id, $client_id, $model, $client_pattern) + """; + command.Parameters.AddWithValue("$group_id", groupId); + command.Parameters.AddWithValue("$client_id", string.IsNullOrWhiteSpace(clientId) ? DBNull.Value : clientId); + command.Parameters.AddWithValue("$model", string.IsNullOrWhiteSpace(model) ? DBNull.Value : model); + command.Parameters.AddWithValue("$client_pattern", string.IsNullOrWhiteSpace(clientPattern) ? DBNull.Value : clientPattern); + command.ExecuteNonQuery(); + + using var idCommand = connection.CreateCommand(); + idCommand.CommandText = "SELECT last_insert_rowid()"; + var insertedId = (long)idCommand.ExecuteScalar()!; + return new GroupMemberInfo(insertedId, groupId, clientId, model, clientPattern); + } + } + + public bool RemoveGroupMember(long memberId) + { + if (!_isAvailable) + { + return false; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM group_members WHERE id = $id"; + command.Parameters.AddWithValue("$id", memberId); + return command.ExecuteNonQuery() > 0; + } + } + + public IReadOnlyList GetApiKeyGroupIds(string apiKeyId) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(apiKeyId)) + { + return []; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT g.id FROM groups g + INNER JOIN api_key_groups akg ON g.id = akg.group_id + WHERE akg.api_key_id = $api_key_id + ORDER BY g.name + """; + command.Parameters.AddWithValue("$api_key_id", apiKeyId); + + var result = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + result.Add(reader.GetString(0)); + } + + return result; + } + } + + public void SetApiKeyGroups(string apiKeyId, IReadOnlyList groupIds) + { + EnsureAvailable(); + + if (string.IsNullOrWhiteSpace(apiKeyId)) + { + throw new ArgumentException("API key id is required.", nameof(apiKeyId)); + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var transaction = connection.BeginTransaction(); + + try + { + using (var deleteCommand = connection.CreateCommand()) + { + deleteCommand.Transaction = transaction; + deleteCommand.CommandText = "DELETE FROM api_key_groups WHERE api_key_id = $api_key_id"; + deleteCommand.Parameters.AddWithValue("$api_key_id", apiKeyId); + deleteCommand.ExecuteNonQuery(); + } + + using (var insertCommand = connection.CreateCommand()) + { + insertCommand.Transaction = transaction; + insertCommand.CommandText = """ + INSERT INTO api_key_groups (api_key_id, group_id) + VALUES ($api_key_id, $group_id) + """; + + var apiKeyParam = insertCommand.Parameters.Add("$api_key_id", SqliteType.Text); + var groupParam = insertCommand.Parameters.Add("$group_id", SqliteType.Text); + apiKeyParam.Value = apiKeyId; + + foreach (var groupId in groupIds.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.OrdinalIgnoreCase)) + { + groupParam.Value = groupId; + insertCommand.ExecuteNonQuery(); + } + } + + transaction.Commit(); + } + catch + { + transaction.Rollback(); + throw; + } + } + } + + public IReadOnlyList ListApiKeyGroups() + { + if (!_isAvailable) + { + return []; + } + + lock (_lock) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT ak.id, ak.name, ak.key_prefix, + GROUP_CONCAT(g.id) as group_ids, + GROUP_CONCAT(g.name) as group_names + FROM api_keys ak + LEFT JOIN api_key_groups akg ON ak.id = akg.api_key_id + LEFT JOIN groups g ON akg.group_id = g.id + GROUP BY ak.id + ORDER BY ak.name + """; + + var result = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var keyId = reader.GetString(0); + var keyName = reader.GetString(1); + var keyPrefix = reader.GetString(2); + var groupIds = reader.IsDBNull(3) + ? [] + : reader.GetString(3).Split(',', StringSplitOptions.RemoveEmptyEntries); + var groupNames = reader.IsDBNull(4) + ? [] + : reader.GetString(4).Split(',', StringSplitOptions.RemoveEmptyEntries); + + result.Add(new ApiKeyGroupInfo(keyId, keyName, keyPrefix, groupIds, groupNames)); + } + + return result; + } + } + + public GroupAccess ResolveGroupAccess(string? apiKeyId) + { + if (!_isAvailable || string.IsNullOrWhiteSpace(apiKeyId)) + { + return GroupAccess.Unrestricted; + } + + lock (_lock) + { + var groupIds = GetApiKeyGroupIdsLocked(apiKeyId); + if (groupIds.Count == 0) + { + return GroupAccess.Unrestricted; + } + + var clientModels = new HashSet(StringComparer.OrdinalIgnoreCase); + var allClients = new HashSet(StringComparer.OrdinalIgnoreCase); + + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = """ + SELECT gm.client_id, gm.model, gm.client_pattern + FROM group_members gm + INNER JOIN api_key_groups akg ON gm.group_id = akg.group_id + WHERE akg.api_key_id = $api_key_id + """; + command.Parameters.AddWithValue("$api_key_id", apiKeyId); + + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var clientId = reader.IsDBNull(0) ? null : reader.GetString(0); + var model = reader.IsDBNull(1) ? null : reader.GetString(1); + var pattern = reader.IsDBNull(2) ? null : reader.GetString(2); + + if (!string.IsNullOrWhiteSpace(pattern)) + { + Regex? regex = null; + try + { + regex = new Regex( + pattern, + RegexOptions.IgnoreCase | RegexOptions.Compiled); + } + catch (RegexParseException) + { + continue; + } + + foreach (var connectedClient in _getConnectedClientIds()) + { + if (regex.IsMatch(connectedClient)) + { + allClients.Add(connectedClient); + if (!string.IsNullOrWhiteSpace(model)) + { + clientModels.Add($"{connectedClient}:{model}"); + } + } + } + } + else if (!string.IsNullOrWhiteSpace(clientId)) + { + allClients.Add(clientId); + if (!string.IsNullOrWhiteSpace(model)) + { + clientModels.Add($"{clientId}:{model}"); + } + } + } + + return new GroupAccess(clientModels, allClients); + } + } + + private IReadOnlyList GetApiKeyGroupIdsLocked(string apiKeyId) + { + using var connection = OpenConnection(); + using var command = connection.CreateCommand(); + command.CommandText = "SELECT group_id FROM api_key_groups WHERE api_key_id = $api_key_id"; + command.Parameters.AddWithValue("$api_key_id", apiKeyId); + + var result = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + result.Add(reader.GetString(0)); + } + + return result; + } + + private Func> _getConnectedClientIds = () => []; + + public void SetConnectedClientProvider(Func> provider) + { + _getConnectedClientIds = provider; + } + private void Initialize() { var directory = Path.GetDirectoryName(_databasePath); @@ -551,6 +1012,30 @@ internal sealed class ManagementStore CREATE INDEX IF NOT EXISTS idx_request_metrics_model_started ON request_metrics (model, started_at_utc); + + CREATE TABLE IF NOT EXISTS groups ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + created_at_utc TEXT NOT NULL, + updated_at_utc TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS group_members ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + client_id TEXT NULL, + model TEXT NULL, + client_pattern TEXT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_group_members_group_id + ON group_members (group_id); + + CREATE TABLE IF NOT EXISTS api_key_groups ( + api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE, + group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + PRIMARY KEY (api_key_id, group_id) + ); """; command.ExecuteNonQuery(); } @@ -698,3 +1183,49 @@ internal sealed record ModelUsageStats( long RequestsLastHour, long TokensLast10Minutes, long TokensLastHour); + +internal sealed record GroupInfo( + string Id, + string Name, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); + +internal sealed record GroupMemberInfo( + long Id, + string GroupId, + string? ClientId, + string? Model, + string? ClientPattern); + +internal sealed record ApiKeyGroupInfo( + string ApiKeyId, + string ApiKeyName, + string ApiKeyPrefix, + IReadOnlyList GroupIds, + IReadOnlyList GroupNames); + +internal sealed class GroupAccess +{ + public static GroupAccess Unrestricted { get; } = new(new HashSet(), new HashSet(), isUnrestricted: true); + + public IReadOnlySet ClientModels { get; } + + public IReadOnlySet AllClients { get; } + + public bool IsUnrestricted { get; } + + public GroupAccess(IReadOnlySet clientModels, IReadOnlySet allClients, bool isUnrestricted = false) + { + ClientModels = clientModels; + AllClients = allClients; + IsUnrestricted = isUnrestricted; + } + + public bool IsClientAllowed(string clientId) => + IsUnrestricted || AllClients.Contains(clientId); + + public bool IsClientModelAllowed(string clientId, string model) => + IsUnrestricted + || AllClients.Contains(clientId) + || ClientModels.Contains($"{clientId}:{model}"); +} diff --git a/src/ReverseLlama.Server/Program.cs b/src/ReverseLlama.Server/Program.cs index 1473b75..85a0d90 100644 --- a/src/ReverseLlama.Server/Program.cs +++ b/src/ReverseLlama.Server/Program.cs @@ -74,6 +74,10 @@ builder.Services.AddAuthorization(); var app = builder.Build(); +var managementStore = app.Services.GetRequiredService(); +var tunnelHub = app.Services.GetRequiredService(); +managementStore.SetConnectedClientProvider(() => tunnelHub.ClientSnapshots.Select(c => c.Id)); + if (settings.Keycloak.IsConfigured) { app.UseAuthentication(); diff --git a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs index abf16de..e5687e5 100644 --- a/src/ReverseLlama.Server/ReverseProxyEndpoint.cs +++ b/src/ReverseLlama.Server/ReverseProxyEndpoint.cs @@ -35,13 +35,16 @@ internal static class ReverseProxyEndpoint EmbeddingCache embeddingCache, ManagementStore managementStore) { - if (!TokenAuthentication.IsAuthorized(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true)) + var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true); + if (!auth.IsAuthorized) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted); return; } + var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore); + var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath); if (!pathTokenRemoved) { @@ -56,6 +59,13 @@ internal static class ReverseProxyEndpoint if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath)) { + if (!groupAccess.IsClientAllowed(pathClientId)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync($"Access to client '{pathClientId}' is not permitted.", context.RequestAborted); + return; + } + await ForwardToClientAsync( context, pathClientId, @@ -65,7 +75,14 @@ internal static class ReverseProxyEndpoint settings, loggerFactory, embeddingCache, - managementStore); + managementStore, + groupAccess); + return; + } + + if (IsTagsRequest(context.Request, proxyPath)) + { + await HandleTagsAsync(context, hub, managementStore, groupAccess); return; } @@ -79,7 +96,8 @@ internal static class ReverseProxyEndpoint var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, proxyPath); var connection = hub.SelectBest( requestedModel, - clientId => !managementStore.GetClientAccess(clientId).IsDisabled); + clientId => !managementStore.GetClientAccess(clientId).IsDisabled + && groupAccess.IsClientAllowed(clientId)); if (connection is null) { if (!hub.HasClient) @@ -117,13 +135,22 @@ internal static class ReverseProxyEndpoint EmbeddingCache embeddingCache, ManagementStore managementStore) { - if (!TokenAuthentication.IsAuthorized(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true)) + var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true); + if (!auth.IsAuthorized) { context.Response.StatusCode = StatusCodes.Status401Unauthorized; await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted); return; } + var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore); + if (!groupAccess.IsClientAllowed(clientId)) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsync($"Access to client '{clientId}' is not permitted.", context.RequestAborted); + return; + } + var pathAndQuery = $"/{path}{context.Request.QueryString}"; var clientPath = new PathString($"/{path}"); await ForwardToClientAsync( @@ -135,7 +162,8 @@ internal static class ReverseProxyEndpoint settings, loggerFactory, embeddingCache, - managementStore); + managementStore, + groupAccess); } private static async Task ForwardToClientAsync( @@ -147,7 +175,8 @@ internal static class ReverseProxyEndpoint ServerSettings settings, ILoggerFactory loggerFactory, EmbeddingCache embeddingCache, - ManagementStore managementStore) + ManagementStore managementStore, + GroupAccess? groupAccess = null) { var clientAccess = managementStore.GetClientAccess(clientId); if (clientAccess.IsDisabled) @@ -231,6 +260,69 @@ internal static class ReverseProxyEndpoint return true; } + private static GroupAccess ResolveGroupAccess(string? apiKeyId, ManagementStore managementStore) + { + if (string.IsNullOrWhiteSpace(apiKeyId)) + { + return GroupAccess.Unrestricted; + } + + return managementStore.ResolveGroupAccess(apiKeyId); + } + + private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) => + HttpMethods.IsGet(request.Method) + && string.Equals(proxyPath.Value, "/api/tags", StringComparison.OrdinalIgnoreCase); + + private static async Task HandleTagsAsync( + HttpContext context, + TunnelHub hub, + ManagementStore managementStore, + GroupAccess groupAccess) + { + var models = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var client in hub.ClientSnapshots) + { + foreach (var model in client.Models) + { + if (!models.TryGetValue(model, out var clients)) + { + clients = new SortedSet(StringComparer.OrdinalIgnoreCase); + models[model] = clients; + } + + clients.Add(client.Id); + } + } + + var filteredModels = new List(); + foreach (var (model, clients) in models.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase)) + { + var accessibleClients = clients + .Where(clientId => groupAccess.IsClientModelAllowed(clientId, model) || groupAccess.IsClientAllowed(clientId)) + .ToList(); + + if (accessibleClients.Count > 0) + { + filteredModels.Add(new + { + name = model, + model, + modified_at = DateTimeOffset.UtcNow, + size = 0, + digest = "", + details = new { } + }); + } + } + + var response = new { models = filteredModels }; + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(response, context.RequestAborted); + } + private static string GetNoRouteMessage(string? requestedModel) => string.IsNullOrWhiteSpace(requestedModel) ? "No tunnel client is available for this request." diff --git a/src/ReverseLlama.Server/TokenAuthentication.cs b/src/ReverseLlama.Server/TokenAuthentication.cs index 93901b3..ac4199d 100644 --- a/src/ReverseLlama.Server/TokenAuthentication.cs +++ b/src/ReverseLlama.Server/TokenAuthentication.cs @@ -6,7 +6,7 @@ internal static class TokenAuthentication { private static readonly PathString PathTokenPrefix = new("/token"); - public static bool IsAuthorized( + public static AuthResult Authorize( HttpRequest request, ServerSettings settings, ManagementStore managementStore, @@ -15,36 +15,70 @@ internal static class TokenAuthentication { if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys) { - return true; + return AuthResult.Success(null); } - if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues) - && headerValues.Any(value => IsTokenAuthorized(value, settings, managementStore, updateApiKeyLastUsed: true))) + if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues)) { - return true; + foreach (var value in headerValues) + { + var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } + } } - // Bearer form for OpenAI-compatible clients (e.g. n8n's OpenAI nodes pointed - // at /clients/{id}/v1) that can send an API key but no custom headers. - if (request.Headers.TryGetValue("Authorization", out var authorizationValues) - && authorizationValues.Any(value => TryGetBearerToken(value, out var bearerToken) - && IsTokenAuthorized(bearerToken, settings, managementStore, updateApiKeyLastUsed: true))) + if (request.Headers.TryGetValue("Authorization", out var authorizationValues)) { - return true; + foreach (var value in authorizationValues) + { + if (TryGetBearerToken(value, out var bearerToken)) + { + var result = AuthorizeToken(bearerToken, settings, managementStore, updateApiKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } + } + } } if (allowPathToken - && TryGetPathToken(request.Path, out var pathToken, out _) - && IsTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: true)) + && TryGetPathToken(request.Path, out var pathToken, out _)) { - return true; + var result = AuthorizeToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } } - return allowQueryToken - && request.Query.TryGetValue("token", out var queryValues) - && queryValues.Any(value => IsTokenAuthorized(value, settings, managementStore, updateApiKeyLastUsed: true)); + if (allowQueryToken + && request.Query.TryGetValue("token", out var queryValues)) + { + foreach (var value in queryValues) + { + var result = AuthorizeToken(value, settings, managementStore, updateApiKeyLastUsed: true); + if (result.IsAuthorized) + { + return result; + } + } + } + + return AuthResult.Failure; } + public static bool IsAuthorized( + HttpRequest request, + ServerSettings settings, + ManagementStore managementStore, + bool allowQueryToken, + bool allowPathToken = false) => + Authorize(request, settings, managementStore, allowQueryToken, allowPathToken).IsAuthorized; + public static bool TryRemovePathToken( PathString path, ServerSettings settings, @@ -69,7 +103,7 @@ internal static class TokenAuthentication TryGetBearerToken(value, out var token) && IsTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false); - public static bool IsTokenAuthorized( + private static AuthResult AuthorizeToken( string? token, ServerSettings settings, ManagementStore managementStore, @@ -77,14 +111,32 @@ internal static class TokenAuthentication { if (string.IsNullOrWhiteSpace(token)) { - return false; + return AuthResult.Failure; } - return !string.IsNullOrWhiteSpace(settings.Token) - && string.Equals(token, settings.Token, StringComparison.Ordinal) - || managementStore.IsApiKeyValid(token, updateApiKeyLastUsed); + if (!string.IsNullOrWhiteSpace(settings.Token) + && string.Equals(token, settings.Token, StringComparison.Ordinal)) + { + return AuthResult.Success(null); + } + + var apiKeyId = managementStore.GetApiKeyId(token); + if (apiKeyId is not null) + { + managementStore.IsApiKeyValid(token, updateApiKeyLastUsed); + return AuthResult.Success(apiKeyId); + } + + return AuthResult.Failure; } + public static bool IsTokenAuthorized( + string? token, + ServerSettings settings, + ManagementStore managementStore, + bool updateApiKeyLastUsed) => + AuthorizeToken(token, settings, managementStore, updateApiKeyLastUsed).IsAuthorized; + private static bool TryGetBearerToken(string? authorization, out string token) { token = ""; @@ -131,3 +183,20 @@ internal static class TokenAuthentication return true; } } + +internal sealed class AuthResult +{ + public static AuthResult Failure { get; } = new(false, null); + + public static AuthResult Success(string? apiKeyId) => new(true, apiKeyId); + + public bool IsAuthorized { get; } + + public string? ApiKeyId { get; } + + private AuthResult(bool isAuthorized, string? apiKeyId) + { + IsAuthorized = isAuthorized; + ApiKeyId = apiKeyId; + } +} diff --git a/src/ReverseLlama.Server/wwwroot/admin/app.css b/src/ReverseLlama.Server/wwwroot/admin/app.css index 9e03e20..33e8b67 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/app.css +++ b/src/ReverseLlama.Server/wwwroot/admin/app.css @@ -440,6 +440,28 @@ tr:last-child td { overflow-wrap: anywhere; } +.group-link { + color: var(--blue); + text-decoration: none; + font-weight: 800; + overflow-wrap: anywhere; +} + +.group-link:hover { + text-decoration: underline; +} + +.code-inline { + font-family: "Cascadia Mono", Consolas, monospace; + font-size: 12px; + background: var(--panel-alt); + border: 1px solid var(--line); + border-radius: 4px; + padding: 2px 6px; + display: inline-block; + overflow-wrap: anywhere; +} + @media (max-width: 860px) { .app-shell { grid-template-columns: 1fr; @@ -455,7 +477,7 @@ tr:last-child td { } .nav { - grid-template-columns: repeat(3, 1fr); + grid-template-columns: repeat(4, 1fr); } .nav a { diff --git a/src/ReverseLlama.Server/wwwroot/admin/app.js b/src/ReverseLlama.Server/wwwroot/admin/app.js index 3db8bc6..3d5615f 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/app.js +++ b/src/ReverseLlama.Server/wwwroot/admin/app.js @@ -1,6 +1,7 @@ const state = { summary: null, detail: null, + groupDetail: null, newKey: null, loading: false }; @@ -20,7 +21,7 @@ content.addEventListener("click", async (event) => { return; } - const { action, clientId, model, keyId } = button.dataset; + const { action, clientId, model, keyId, groupId, memberId } = button.dataset; try { setBusy(button, true); @@ -67,6 +68,31 @@ content.addEventListener("click", async (event) => { await navigator.clipboard.writeText(button.dataset.key); setNotice("API key copied."); } + + if (action === "delete-group") { + if (!confirm("Delete this group and all its members and key assignments?")) { + return; + } + + await api(`/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" }); + setNotice("Group deleted."); + window.location.hash = "#groups"; + await refresh(); + } + + if (action === "remove-member") { + if (!confirm("Remove this member from the group?")) { + return; + } + + await api(`/groups/${encodeURIComponent(groupId)}/members/${memberId}`, { method: "DELETE" }); + setNotice("Member removed."); + await loadGroupDetail(groupId); + } + + if (action === "toggle-key-assignment") { + await toggleApiKeyAssignment(groupId, keyId, button.dataset.assigned === "true"); + } } catch (error) { setNotice(error.message, true); } finally { @@ -104,6 +130,54 @@ content.addEventListener("submit", async (event) => { setNotice("API key created."); await refresh(); } + + if (form.dataset.form === "create-group") { + const result = await api("/groups", { + method: "POST", + body: { name: data.name } + }); + setNotice("Group created."); + form.reset(); + await refresh(); + window.location.hash = `#groups/${encodeURIComponent(result.id)}`; + } + + if (form.dataset.form === "edit-group-name") { + const groupId = form.dataset.groupId; + await api(`/groups/${encodeURIComponent(groupId)}`, { + method: "PUT", + body: { name: data.name } + }); + setNotice("Group name updated."); + await refresh(); + await loadGroupDetail(groupId); + } + + if (form.dataset.form === "add-member") { + const groupId = form.dataset.groupId; + const body = {}; + if (data.clientId && data.clientId.trim()) { + body.clientId = data.clientId.trim(); + } + if (data.model && data.model.trim()) { + body.model = data.model.trim(); + } + if (data.clientPattern && data.clientPattern.trim()) { + body.clientPattern = data.clientPattern.trim(); + } + + if (!body.clientId && !body.clientPattern) { + throw new Error("Either Client ID or Client pattern is required."); + } + + await api(`/groups/${encodeURIComponent(groupId)}/members`, { + method: "POST", + body + }); + setNotice("Member added."); + form.reset(); + await loadGroupDetail(groupId); + } } catch (error) { setNotice(error.message, true); } finally { @@ -166,19 +240,28 @@ async function api(path, options = {}) { async function renderRoute() { const hash = (window.location.hash || "#clients").slice(1); - const [view, encodedModel] = hash.split("/"); + const parts = hash.split("/"); + const view = parts[0]; + const encodedParam = parts[1]; document.querySelectorAll("[data-nav]").forEach((link) => { link.classList.toggle("active", link.dataset.nav === view); }); - if (view === "models" && encodedModel) { - const model = decodeURIComponent(encodedModel); + if (view === "models" && encodedParam) { + const model = decodeURIComponent(encodedParam); await loadModelDetail(model); return; } + if (view === "groups" && encodedParam) { + const groupId = decodeURIComponent(encodedParam); + await loadGroupDetail(groupId); + return; + } + state.detail = null; + state.groupDetail = null; if (view === "models") { renderModels(); @@ -190,6 +273,11 @@ async function renderRoute() { return; } + if (view === "groups") { + renderGroups(); + return; + } + renderClients(); } @@ -203,6 +291,7 @@ function updateShell() {
${escapeHtml(summary.user?.name || "Signed in")}
${summary.clients.length} clients
${summary.models.length} models
+
${(summary.groups || []).length} groups
${formatDate(summary.generatedAtUtc)}
`; } @@ -565,6 +654,258 @@ function apiKeysTable(keys) { `; } +function renderGroups() { + const groups = state.summary?.groups || []; + pageTitle.textContent = "Groups"; + pageSubtitle.textContent = "Manage access groups that control which clients and models API keys can reach."; + + content.innerHTML = ` +
+
+

Create group

+
+
+
+
+ + +
+ +
+
+
+
+
+

Groups

+ ${groups.length} total +
+
+ ${groups.length ? groupsTable(groups) : emptyState("No groups have been created.")} +
+
+ `; +} + +function groupsTable(groups) { + const rows = groups.map((group) => ` + + + ${escapeHtml(group.name)} + + ${formatDate(group.createdAtUtc)} + +
+ Edit + +
+ + + `).join(""); + + return ` + + + + + + + + + ${rows} +
NameCreated
+ `; +} + +async function loadGroupDetail(groupId) { + pageTitle.textContent = "Group Detail"; + pageSubtitle.textContent = groupId; + content.innerHTML = `
Loading group...
`; + + try { + const [group, members, apiKeyGroups] = await Promise.all([ + api(`/groups/${encodeURIComponent(groupId)}`), + api(`/groups/${encodeURIComponent(groupId)}/members`), + api("/api-keys/groups") + ]); + + state.groupDetail = { group, members, apiKeyGroups }; + renderGroupDetail(); + } catch (error) { + content.innerHTML = `
${escapeHtml(error.message)}
`; + } +} + +function renderGroupDetail() { + const { group, members, apiKeyGroups } = state.groupDetail; + const allApiKeys = state.summary?.apiKeys || []; + const assignedKeyIds = new Set( + apiKeyGroups + .filter((akg) => akg.apiKeyId && (akg.groupIds || []).includes(group.id)) + .map((akg) => akg.apiKeyId) + ); + + pageTitle.textContent = "Group Detail"; + pageSubtitle.textContent = group.name; + + content.innerHTML = ` + +
+
+

Group name

+
+
+
+
+ + +
+ +
+
+
+
+
+

Add member

+
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+
Provide either a Client ID (for explicit client access) or a Client pattern (for regex-based matching). Model is optional to restrict to a specific model.
+
+
+
+
+

Members

+ ${members.length} total +
+
+ ${members.length ? groupMembersTable(members, group.id) : emptyState("No members in this group.")} +
+
+
+
+

API key assignments

+
+
+ ${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")} +
+
+ `; +} + +function groupMembersTable(members, groupId) { + const rows = members.map((member) => ` + + + ${member.clientId + ? `
${escapeHtml(member.clientId)}
` + : `
-
` + } + + + ${member.model + ? `
${escapeHtml(member.model)}
` + : `
All models
` + } + + + ${member.clientPattern + ? `
${escapeHtml(member.clientPattern)}
` + : `
-
` + } + + + + + + `).join(""); + + return ` + + + + + + + + + + ${rows} +
Client IDModelClient pattern
+ `; +} + +function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) { + const rows = apiKeys.map((key) => { + const isAssigned = assignedKeyIds.has(key.id); + return ` + + +
${escapeHtml(key.name)}
+
${escapeHtml(key.keyPrefix)}...
+ + + + + + `; + }).join(""); + + return ` + + + + + + + + ${rows} +
API Key
+ `; +} + +async function toggleApiKeyAssignment(groupId, keyId, currentlyAssigned) { + const apiKeyGroups = state.groupDetail?.apiKeyGroups || []; + const allApiKeys = state.summary?.apiKeys || []; + + const keyGroups = apiKeyGroups.find((akg) => akg.apiKeyId === keyId); + const currentGroupIds = keyGroups ? [...keyGroups.groupIds] : []; + + let newGroupIds; + if (currentlyAssigned) { + newGroupIds = currentGroupIds.filter((id) => id !== groupId); + } else { + newGroupIds = [...currentGroupIds, groupId]; + } + + await api(`/api-keys/${encodeURIComponent(keyId)}/groups`, { + method: "PUT", + body: { groupIds: newGroupIds } + }); + + setNotice(currentlyAssigned ? "API key unassigned from group." : "API key assigned to group."); + await loadGroupDetail(groupId); +} + function connectedClients() { return (state.summary?.clients || []).filter((client) => client.connected); } diff --git a/src/ReverseLlama.Server/wwwroot/admin/index.html b/src/ReverseLlama.Server/wwwroot/admin/index.html index 1273314..d96d92b 100644 --- a/src/ReverseLlama.Server/wwwroot/admin/index.html +++ b/src/ReverseLlama.Server/wwwroot/admin/index.html @@ -20,6 +20,7 @@ Clients Models API keys + Groups