Merge pull request #13 from LD-Reborn/3-add-group-management

3 add group management
This commit is contained in:
LD50
2026-07-17 23:09:57 +02:00
committed by GitHub
8 changed files with 1352 additions and 43 deletions
+128 -4
View File
@@ -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}/clients", (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.ListGroupClients(id));
});
api.MapPost("/groups/{id}/clients", (string id, AddGroupClientRequest 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.AddGroupClient(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}/clients/{clientId:long}", (string groupId, long clientId, ManagementStore store) =>
{
var group = store.GetGroup(groupId);
if (group is null)
{
return Results.NotFound(new { error = $"Group '{groupId}' was not found." });
}
return store.RemoveGroupClient(clientId)
? Results.NoContent()
: Results.NotFound(new { error = $"Client '{clientId}' 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,11 @@ internal static class AdminEndpoints
},
clients = BuildClientSummaries(hub, store),
models = BuildModelSummaries(hub, store),
apiKeys = store.ListApiKeys()
apiKeys = store.ListApiKeys(),
groups = store.ListGroups(),
apiKeyGroups = store.ListApiKeyGroups(),
clientGroups = store.ResolveClientGroups(
hub.ClientSnapshots.Select(c => c.Id).ToList())
};
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
@@ -470,6 +583,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 AddGroupClientRequest(
string? ClientId,
string? Model,
string? ClientPattern);
internal sealed record SetApiKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
internal sealed record ClientSummary(
string Id,
bool Connected,
+606
View File
@@ -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,539 @@ 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<GroupInfo> 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<GroupInfo>();
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<GroupClientInfo> ListGroupClients(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<GroupClientInfo>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new GroupClientInfo(
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 GroupClientInfo AddGroupClient(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 GroupClientInfo(insertedId, groupId, clientId, model, clientPattern);
}
}
public bool RemoveGroupClient(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<string> 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<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(reader.GetString(0));
}
return result;
}
}
public void SetApiKeyGroups(string apiKeyId, IReadOnlyList<string> 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<ApiKeyGroupInfo> 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<ApiKeyGroupInfo>();
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.Empty;
}
var clientModels = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var allClients = new HashSet<string>(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))
{
if (!string.IsNullOrWhiteSpace(model))
{
clientModels.Add($"{connectedClient}:{model}");
}
else
{
allClients.Add(connectedClient);
}
}
}
}
else if (!string.IsNullOrWhiteSpace(clientId))
{
if (!string.IsNullOrWhiteSpace(model))
{
clientModels.Add($"{clientId}:{model}");
}
else
{
allClients.Add(clientId);
}
}
}
return new GroupAccess(clientModels, allClients);
}
}
public IReadOnlyDictionary<string, IReadOnlyList<string>> ResolveClientGroups(IReadOnlyList<string> clientIds)
{
if (!_isAvailable || clientIds.Count == 0)
{
return new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
}
var result = new Dictionary<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var clientId in clientIds)
{
result[clientId] = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
}
lock (_lock)
{
using var connection = OpenConnection();
using var command = connection.CreateCommand();
command.CommandText = """
SELECT g.name, gm.client_id, gm.client_pattern
FROM group_members gm
INNER JOIN groups g ON gm.group_id = g.id
""";
using var reader = command.ExecuteReader();
while (reader.Read())
{
var groupName = reader.GetString(0);
var explicitClientId = reader.IsDBNull(1) ? null : reader.GetString(1);
var pattern = reader.IsDBNull(2) ? null : reader.GetString(2);
if (!string.IsNullOrWhiteSpace(explicitClientId)
&& result.TryGetValue(explicitClientId, out var explicitGroups))
{
explicitGroups.Add(groupName);
}
else if (!string.IsNullOrWhiteSpace(pattern))
{
Regex? regex = null;
try
{
regex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.Compiled);
}
catch (RegexParseException)
{
continue;
}
foreach (var clientId in clientIds)
{
if (regex.IsMatch(clientId) && result.TryGetValue(clientId, out var groups))
{
groups.Add(groupName);
}
}
}
}
}
var frozen = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var (clientId, groups) in result)
{
frozen[clientId] = groups.ToList();
}
return frozen;
}
private IReadOnlyList<string> 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<string>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(reader.GetString(0));
}
return result;
}
private Func<IEnumerable<string>> _getConnectedClientIds = () => [];
public void SetConnectedClientProvider(Func<IEnumerable<string>> provider)
{
_getConnectedClientIds = provider;
}
private void Initialize()
{
var directory = Path.GetDirectoryName(_databasePath);
@@ -551,6 +1085,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 +1256,51 @@ 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 GroupClientInfo(
long Id,
string GroupId,
string? ClientId,
string? Model,
string? ClientPattern);
internal sealed record ApiKeyGroupInfo(
string ApiKeyId,
string ApiKeyName,
string ApiKeyPrefix,
IReadOnlyList<string> GroupIds,
IReadOnlyList<string> GroupNames);
internal sealed class GroupAccess
{
public static GroupAccess Unrestricted { get; } = new(new HashSet<string>(), new HashSet<string>(), isUnrestricted: true);
public static GroupAccess Empty { get; } = new(new HashSet<string>(), new HashSet<string>());
public IReadOnlySet<string> ClientModels { get; }
public IReadOnlySet<string> AllClients { get; }
public bool IsUnrestricted { get; }
public GroupAccess(IReadOnlySet<string> clientModels, IReadOnlySet<string> 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}");
}
+19
View File
@@ -74,6 +74,10 @@ builder.Services.AddAuthorization();
var app = builder.Build();
var managementStore = app.Services.GetRequiredService<ManagementStore>();
var tunnelHub = app.Services.GetRequiredService<TunnelHub>();
managementStore.SetConnectedClientProvider(() => tunnelHub.ClientSnapshots.Select(c => c.Id));
if (settings.Keycloak.IsConfigured)
{
app.UseAuthentication();
@@ -82,6 +86,21 @@ if (settings.Keycloak.IsConfigured)
app.UseElmah();
app.Use(async (context, next) =>
{
context.Response.Headers.AccessControlAllowOrigin = "*";
context.Response.Headers.AccessControlAllowMethods = "GET, POST, PUT, DELETE, PATCH, OPTIONS";
context.Response.Headers.AccessControlAllowHeaders = "Content-Type, Authorization";
if (HttpMethods.IsOptions(context.Request.Method))
{
context.Response.StatusCode = StatusCodes.Status204NoContent;
return;
}
await next();
});
app.UseWebSockets(new WebSocketOptions
{
KeepAliveInterval = TimeSpan.FromSeconds(30)
+107 -6
View File
@@ -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,10 @@ 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
&& (requestedModel is null
? groupAccess.IsClientAllowed(clientId)
: groupAccess.IsClientModelAllowed(clientId, requestedModel)));
if (connection is null)
{
if (!hub.HasClient)
@@ -117,13 +137,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 +164,8 @@ internal static class ReverseProxyEndpoint
settings,
loggerFactory,
embeddingCache,
managementStore);
managementStore,
groupAccess);
}
private static async Task ForwardToClientAsync(
@@ -147,7 +177,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)
@@ -173,6 +204,13 @@ internal static class ReverseProxyEndpoint
}
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, clientPath);
if (requestedModel is not null && groupAccess is not null && !groupAccess.IsClientModelAllowed(clientId, requestedModel))
{
context.Response.StatusCode = StatusCodes.Status403Forbidden;
await context.Response.WriteAsync($"Access to model '{requestedModel}' on client '{clientId}' is not permitted.", context.RequestAborted);
return;
}
await ForwardAsync(
context,
connection,
@@ -231,6 +269,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<string, SortedSet<string>>(StringComparer.OrdinalIgnoreCase);
foreach (var client in hub.ClientSnapshots)
{
foreach (var model in client.Models)
{
if (!models.TryGetValue(model, out var clients))
{
clients = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
models[model] = clients;
}
clients.Add(client.Id);
}
}
var filteredModels = new List<object>();
foreach (var (model, clients) in models.OrderBy(kvp => kvp.Key, StringComparer.OrdinalIgnoreCase))
{
var accessibleClients = clients
.Where(clientId => groupAccess.IsClientModelAllowed(clientId, model))
.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."
+91 -22
View File
@@ -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;
}
}
+42 -3
View File
@@ -281,7 +281,8 @@ textarea {
table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
table-layout: fixed;
min-width: 900px;
}
th,
@@ -292,6 +293,11 @@ td {
vertical-align: top;
}
.col-models {
width: 40%;
overflow-wrap: anywhere;
}
th {
color: var(--muted);
font-size: 12px;
@@ -338,7 +344,7 @@ tr:last-child td {
color: var(--text);
font-size: 12px;
font-weight: 700;
overflow-wrap: anywhere;
white-space: nowrap;
}
.badge.good {
@@ -359,6 +365,17 @@ tr:last-child td {
color: var(--amber);
}
.badge[href] {
text-decoration: none;
cursor: pointer;
}
.badge[href]:hover {
border-color: var(--blue);
background: #edf4ff;
color: var(--blue);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(4, minmax(120px, 1fr));
@@ -440,6 +457,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 +494,7 @@ tr:last-child td {
}
.nav {
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
}
.nav a {
+358 -8
View File
@@ -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 clients and key assignments?")) {
return;
}
await api(`/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" });
setNotice("Group deleted.");
window.location.hash = "#groups";
await refresh();
}
if (action === "remove-client") {
if (!confirm("Remove this client from the group?")) {
return;
}
await api(`/groups/${encodeURIComponent(groupId)}/clients/${memberId}`, { method: "DELETE" });
setNotice("Client 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-client") {
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)}/clients`, {
method: "POST",
body
});
setNotice("Client 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() {
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
<div>${summary.clients.length} clients</div>
<div>${summary.models.length} models</div>
<div>${(summary.groups || []).length} groups</div>
<div>${formatDate(summary.generatedAtUtc)}</div>
`;
}
@@ -226,7 +315,10 @@ function renderClients() {
}
function clientsTable(clients) {
const rows = clients.map((client) => `
const clientGroups = state.summary?.clientGroups || {};
const rows = clients.map((client) => {
const groups = clientGroups[client.id] || [];
return `
<tr>
<td>
<div class="cell-main">${escapeHtml(client.id)}</div>
@@ -244,8 +336,13 @@ function clientsTable(clients) {
<div class="cell-main">${number(client.requestStats.total)}</div>
<div class="cell-sub">${number(client.requestStats.last10Minutes)} in 10m, ${number(client.requestStats.lastHour)} in 1h</div>
</td>
<td>${modelBadges(client.models)}</td>
<td class="col-models">${modelBadges(client.models)}</td>
<td>${modelBadges(client.activeModels)}</td>
<td>
<div class="badge-row">
${groups.length ? groups.map((g) => `<a class="badge" href="#groups/${encodeURIComponent(g)}">${escapeHtml(g)}</a>`).join("") : `<span class="cell-sub">None</span>`}
</div>
</td>
<td>
<div class="actions">
<button class="button warning" data-action="disable-hour" data-client-id="${escapeAttr(client.id)}" ${client.disabled ? "disabled" : ""}>Disable 1h</button>
@@ -254,7 +351,7 @@ function clientsTable(clients) {
</div>
</td>
</tr>
`).join("");
`}).join("");
return `
<table>
@@ -264,8 +361,9 @@ function clientsTable(clients) {
<th>Status</th>
<th>Pending</th>
<th>Requests</th>
<th>Listed models</th>
<th class="col-models">Listed models</th>
<th>Active models</th>
<th>Groups</th>
<th>Actions</th>
</tr>
</thead>
@@ -565,6 +663,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 = `
<div class="panel">
<div class="panel-header">
<h2>Create group</h2>
</div>
<div class="panel-body">
<form class="form-row" data-form="create-group">
<div class="field">
<label for="groupName">Name</label>
<input class="input" id="groupName" name="name" placeholder="e.g. embeddings" required>
</div>
<button class="button" type="submit">Create</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Groups</h2>
<span class="badge">${groups.length} total</span>
</div>
<div class="table-wrap">
${groups.length ? groupsTable(groups) : emptyState("No groups have been created.")}
</div>
</div>
`;
}
function groupsTable(groups) {
const rows = groups.map((group) => `
<tr>
<td>
<a class="cell-main group-link" href="#groups/${encodeURIComponent(group.id)}">${escapeHtml(group.name)}</a>
</td>
<td>${formatDate(group.createdAtUtc)}</td>
<td>
<div class="actions">
<a class="button secondary" href="#groups/${encodeURIComponent(group.id)}">Edit</a>
<button class="button danger" data-action="delete-group" data-group-id="${escapeAttr(group.id)}">Delete</button>
</div>
</td>
</tr>
`).join("");
return `
<table>
<thead>
<tr>
<th>Name</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
async function loadGroupDetail(groupId) {
pageTitle.textContent = "Group Detail";
pageSubtitle.textContent = groupId;
content.innerHTML = `<div class="panel"><div class="empty">Loading group...</div></div>`;
try {
const [group, clients, apiKeyGroups] = await Promise.all([
api(`/groups/${encodeURIComponent(groupId)}`),
api(`/groups/${encodeURIComponent(groupId)}/clients`),
api("/api-keys/groups")
]);
state.groupDetail = { group, clients, apiKeyGroups };
renderGroupDetail();
} catch (error) {
content.innerHTML = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
}
}
function renderGroupDetail() {
const { group, clients, 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 = `
<div class="toolbar">
<a class="button secondary" href="#groups">Back to groups</a>
</div>
<div class="panel">
<div class="panel-header">
<h2>Group name</h2>
</div>
<div class="panel-body">
<form class="form-row" data-form="edit-group-name" data-group-id="${escapeAttr(group.id)}">
<div class="field">
<label for="editGroupName">Name</label>
<input class="input" id="editGroupName" name="name" value="${escapeAttr(group.name)}" required>
</div>
<button class="button" type="submit">Save</button>
</form>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Add client</h2>
</div>
<div class="panel-body">
<form class="form-row" data-form="add-client" data-group-id="${escapeAttr(group.id)}">
<div class="field">
<label for="addClientClientId">Client ID</label>
<input class="input" id="addClientClientId" name="clientId" placeholder="Client_1">
</div>
<div class="field">
<label for="addClientModel">Model (optional)</label>
<input class="input" id="addClientModel" name="model" placeholder="bge-m3">
</div>
<div class="field">
<label for="addClientPattern">Client pattern (regex, optional)</label>
<input class="input" id="addClientPattern" name="clientPattern" placeholder="GPU_[0-9]*">
</div>
<button class="button" type="submit">Add</button>
</form>
<div class="cell-sub" style="margin-top:8px">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.</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Clients</h2>
<span class="badge">${clients.length} total</span>
</div>
<div class="table-wrap">
${clients.length ? groupClientsTable(clients, group.id) : emptyState("No clients in this group.")}
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>API key assignments</h2>
</div>
<div class="table-wrap">
${allApiKeys.length ? apiKeyAssignmentTable(allApiKeys, group.id, assignedKeyIds) : emptyState("No API keys have been created.")}
</div>
</div>
`;
}
function groupClientsTable(clients, groupId) {
const rows = clients.map((client) => `
<tr>
<td>
${client.clientId
? `<div class="cell-main">${escapeHtml(client.clientId)}</div>`
: `<div class="cell-sub">-</div>`
}
</td>
<td>
${client.model
? `<div class="cell-main">${escapeHtml(client.model)}</div>`
: `<div class="cell-sub">All models</div>`
}
</td>
<td>
${client.clientPattern
? `<div class="cell-main code-inline">${escapeHtml(client.clientPattern)}</div>`
: `<div class="cell-sub">-</div>`
}
</td>
<td>
<button class="button danger" data-action="remove-client" data-group-id="${escapeAttr(groupId)}" data-member-id="${client.id}">Remove</button>
</td>
</tr>
`).join("");
return `
<table>
<thead>
<tr>
<th>Client ID</th>
<th>Model</th>
<th>Client pattern</th>
<th></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
function apiKeyAssignmentTable(apiKeys, groupId, assignedKeyIds) {
const rows = apiKeys.map((key) => {
const isAssigned = assignedKeyIds.has(key.id);
return `
<tr>
<td>
<div class="cell-main">${escapeHtml(key.name)}</div>
<div class="cell-sub">${escapeHtml(key.keyPrefix)}...</div>
</td>
<td>
<button class="button ${isAssigned ? "danger" : "secondary"}"
data-action="toggle-key-assignment"
data-group-id="${escapeAttr(groupId)}"
data-key-id="${escapeAttr(key.id)}"
data-assigned="${isAssigned}">${isAssigned ? "Remove" : "Assign"}</button>
</td>
</tr>
`;
}).join("");
return `
<table>
<thead>
<tr>
<th>API Key</th>
<th></th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
}
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);
}
@@ -20,6 +20,7 @@
<a href="#clients" data-nav="clients">Clients</a>
<a href="#models" data-nav="models">Models</a>
<a href="#api-keys" data-nav="api-keys">API keys</a>
<a href="#groups" data-nav="groups">Groups</a>
</nav>
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
</aside>