This commit is contained in:
@@ -73,7 +73,7 @@ internal static class AdminEndpoints
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
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)
|
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)
|
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.NoContent()
|
||||||
: Results.NotFound(new { error = $"API key '{id}' was not found." }));
|
: 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) =>
|
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
|
||||||
ServeAdminAsset(environment, null));
|
ServeAdminAsset(environment, null));
|
||||||
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
||||||
@@ -224,7 +333,9 @@ internal static class AdminEndpoints
|
|||||||
},
|
},
|
||||||
clients = BuildClientSummaries(hub, store),
|
clients = BuildClientSummaries(hub, store),
|
||||||
models = BuildModelSummaries(hub, store),
|
models = BuildModelSummaries(hub, store),
|
||||||
apiKeys = store.ListApiKeys()
|
apiKeys = store.ListApiKeys(),
|
||||||
|
groups = store.ListGroups(),
|
||||||
|
apiKeyGroups = store.ListApiKeyGroups()
|
||||||
};
|
};
|
||||||
|
|
||||||
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
|
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
|
||||||
@@ -470,6 +581,17 @@ internal sealed record ModelActionRequest(
|
|||||||
|
|
||||||
internal sealed record CreateApiKeyRequest(string? Name);
|
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<string>? GroupIds);
|
||||||
|
|
||||||
internal sealed record ClientSummary(
|
internal sealed record ClientSummary(
|
||||||
string Id,
|
string Id,
|
||||||
bool Connected,
|
bool Connected,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
|
|
||||||
namespace ReverseLlama.Server;
|
namespace ReverseLlama.Server;
|
||||||
@@ -497,6 +498,466 @@ internal sealed class ManagementStore
|
|||||||
return result;
|
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<GroupMemberInfo> 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<GroupMemberInfo>();
|
||||||
|
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<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.Unrestricted;
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
{
|
||||||
|
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<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()
|
private void Initialize()
|
||||||
{
|
{
|
||||||
var directory = Path.GetDirectoryName(_databasePath);
|
var directory = Path.GetDirectoryName(_databasePath);
|
||||||
@@ -551,6 +1012,30 @@ internal sealed class ManagementStore
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_request_metrics_model_started
|
CREATE INDEX IF NOT EXISTS idx_request_metrics_model_started
|
||||||
ON request_metrics (model, started_at_utc);
|
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();
|
command.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
@@ -698,3 +1183,49 @@ internal sealed record ModelUsageStats(
|
|||||||
long RequestsLastHour,
|
long RequestsLastHour,
|
||||||
long TokensLast10Minutes,
|
long TokensLast10Minutes,
|
||||||
long TokensLastHour);
|
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<string> GroupIds,
|
||||||
|
IReadOnlyList<string> GroupNames);
|
||||||
|
|
||||||
|
internal sealed class GroupAccess
|
||||||
|
{
|
||||||
|
public static GroupAccess Unrestricted { get; } = new(new HashSet<string>(), new HashSet<string>(), isUnrestricted: true);
|
||||||
|
|
||||||
|
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}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ builder.Services.AddAuthorization();
|
|||||||
|
|
||||||
var app = builder.Build();
|
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)
|
if (settings.Keycloak.IsConfigured)
|
||||||
{
|
{
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ internal static class ReverseProxyEndpoint
|
|||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
ManagementStore managementStore)
|
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;
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||||
|
|
||||||
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
|
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
|
||||||
if (!pathTokenRemoved)
|
if (!pathTokenRemoved)
|
||||||
{
|
{
|
||||||
@@ -56,6 +59,13 @@ internal static class ReverseProxyEndpoint
|
|||||||
|
|
||||||
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
|
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(
|
await ForwardToClientAsync(
|
||||||
context,
|
context,
|
||||||
pathClientId,
|
pathClientId,
|
||||||
@@ -65,7 +75,14 @@ internal static class ReverseProxyEndpoint
|
|||||||
settings,
|
settings,
|
||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
groupAccess);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsTagsRequest(context.Request, proxyPath))
|
||||||
|
{
|
||||||
|
await HandleTagsAsync(context, hub, managementStore, groupAccess);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +96,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, proxyPath);
|
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, proxyPath);
|
||||||
var connection = hub.SelectBest(
|
var connection = hub.SelectBest(
|
||||||
requestedModel,
|
requestedModel,
|
||||||
clientId => !managementStore.GetClientAccess(clientId).IsDisabled);
|
clientId => !managementStore.GetClientAccess(clientId).IsDisabled
|
||||||
|
&& groupAccess.IsClientAllowed(clientId));
|
||||||
if (connection is null)
|
if (connection is null)
|
||||||
{
|
{
|
||||||
if (!hub.HasClient)
|
if (!hub.HasClient)
|
||||||
@@ -117,13 +135,22 @@ internal static class ReverseProxyEndpoint
|
|||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
ManagementStore managementStore)
|
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;
|
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||||
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
||||||
return;
|
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 pathAndQuery = $"/{path}{context.Request.QueryString}";
|
||||||
var clientPath = new PathString($"/{path}");
|
var clientPath = new PathString($"/{path}");
|
||||||
await ForwardToClientAsync(
|
await ForwardToClientAsync(
|
||||||
@@ -135,7 +162,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
settings,
|
settings,
|
||||||
loggerFactory,
|
loggerFactory,
|
||||||
embeddingCache,
|
embeddingCache,
|
||||||
managementStore);
|
managementStore,
|
||||||
|
groupAccess);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task ForwardToClientAsync(
|
private static async Task ForwardToClientAsync(
|
||||||
@@ -147,7 +175,8 @@ internal static class ReverseProxyEndpoint
|
|||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
ILoggerFactory loggerFactory,
|
ILoggerFactory loggerFactory,
|
||||||
EmbeddingCache embeddingCache,
|
EmbeddingCache embeddingCache,
|
||||||
ManagementStore managementStore)
|
ManagementStore managementStore,
|
||||||
|
GroupAccess? groupAccess = null)
|
||||||
{
|
{
|
||||||
var clientAccess = managementStore.GetClientAccess(clientId);
|
var clientAccess = managementStore.GetClientAccess(clientId);
|
||||||
if (clientAccess.IsDisabled)
|
if (clientAccess.IsDisabled)
|
||||||
@@ -231,6 +260,69 @@ internal static class ReverseProxyEndpoint
|
|||||||
return true;
|
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) || 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) =>
|
private static string GetNoRouteMessage(string? requestedModel) =>
|
||||||
string.IsNullOrWhiteSpace(requestedModel)
|
string.IsNullOrWhiteSpace(requestedModel)
|
||||||
? "No tunnel client is available for this request."
|
? "No tunnel client is available for this request."
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ internal static class TokenAuthentication
|
|||||||
{
|
{
|
||||||
private static readonly PathString PathTokenPrefix = new("/token");
|
private static readonly PathString PathTokenPrefix = new("/token");
|
||||||
|
|
||||||
public static bool IsAuthorized(
|
public static AuthResult Authorize(
|
||||||
HttpRequest request,
|
HttpRequest request,
|
||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
ManagementStore managementStore,
|
ManagementStore managementStore,
|
||||||
@@ -15,36 +15,70 @@ internal static class TokenAuthentication
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys)
|
if (string.IsNullOrWhiteSpace(settings.Token) && !managementStore.HasApiKeys)
|
||||||
{
|
{
|
||||||
return true;
|
return AuthResult.Success(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues)
|
if (request.Headers.TryGetValue(ProtocolConstants.TokenHeader, out var headerValues))
|
||||||
&& headerValues.Any(value => IsTokenAuthorized(value, settings, managementStore, updateApiKeyLastUsed: true)))
|
|
||||||
{
|
{
|
||||||
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
|
if (request.Headers.TryGetValue("Authorization", out var authorizationValues))
|
||||||
// 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)))
|
|
||||||
{
|
{
|
||||||
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
|
if (allowPathToken
|
||||||
&& TryGetPathToken(request.Path, out var pathToken, out _)
|
&& TryGetPathToken(request.Path, out var pathToken, out _))
|
||||||
&& IsTokenAuthorized(pathToken, settings, managementStore, updateApiKeyLastUsed: true))
|
|
||||||
{
|
{
|
||||||
return true;
|
var result = AuthorizeToken(pathToken, settings, managementStore, updateApiKeyLastUsed: true);
|
||||||
|
if (result.IsAuthorized)
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return allowQueryToken
|
if (allowQueryToken
|
||||||
&& request.Query.TryGetValue("token", out var queryValues)
|
&& request.Query.TryGetValue("token", out var queryValues))
|
||||||
&& queryValues.Any(value => IsTokenAuthorized(value, settings, managementStore, updateApiKeyLastUsed: true));
|
{
|
||||||
|
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(
|
public static bool TryRemovePathToken(
|
||||||
PathString path,
|
PathString path,
|
||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
@@ -69,7 +103,7 @@ internal static class TokenAuthentication
|
|||||||
TryGetBearerToken(value, out var token)
|
TryGetBearerToken(value, out var token)
|
||||||
&& IsTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false);
|
&& IsTokenAuthorized(token, settings, managementStore, updateApiKeyLastUsed: false);
|
||||||
|
|
||||||
public static bool IsTokenAuthorized(
|
private static AuthResult AuthorizeToken(
|
||||||
string? token,
|
string? token,
|
||||||
ServerSettings settings,
|
ServerSettings settings,
|
||||||
ManagementStore managementStore,
|
ManagementStore managementStore,
|
||||||
@@ -77,14 +111,32 @@ internal static class TokenAuthentication
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(token))
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
{
|
{
|
||||||
return false;
|
return AuthResult.Failure;
|
||||||
}
|
}
|
||||||
|
|
||||||
return !string.IsNullOrWhiteSpace(settings.Token)
|
if (!string.IsNullOrWhiteSpace(settings.Token)
|
||||||
&& string.Equals(token, settings.Token, StringComparison.Ordinal)
|
&& string.Equals(token, settings.Token, StringComparison.Ordinal))
|
||||||
|| managementStore.IsApiKeyValid(token, updateApiKeyLastUsed);
|
{
|
||||||
|
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)
|
private static bool TryGetBearerToken(string? authorization, out string token)
|
||||||
{
|
{
|
||||||
token = "";
|
token = "";
|
||||||
@@ -131,3 +183,20 @@ internal static class TokenAuthentication
|
|||||||
return true;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -440,6 +440,28 @@ tr:last-child td {
|
|||||||
overflow-wrap: anywhere;
|
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) {
|
@media (max-width: 860px) {
|
||||||
.app-shell {
|
.app-shell {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -455,7 +477,7 @@ tr:last-child td {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav a {
|
.nav a {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const state = {
|
const state = {
|
||||||
summary: null,
|
summary: null,
|
||||||
detail: null,
|
detail: null,
|
||||||
|
groupDetail: null,
|
||||||
newKey: null,
|
newKey: null,
|
||||||
loading: false
|
loading: false
|
||||||
};
|
};
|
||||||
@@ -20,7 +21,7 @@ content.addEventListener("click", async (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { action, clientId, model, keyId } = button.dataset;
|
const { action, clientId, model, keyId, groupId, memberId } = button.dataset;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setBusy(button, true);
|
setBusy(button, true);
|
||||||
@@ -67,6 +68,31 @@ content.addEventListener("click", async (event) => {
|
|||||||
await navigator.clipboard.writeText(button.dataset.key);
|
await navigator.clipboard.writeText(button.dataset.key);
|
||||||
setNotice("API key copied.");
|
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) {
|
} catch (error) {
|
||||||
setNotice(error.message, true);
|
setNotice(error.message, true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -104,6 +130,54 @@ content.addEventListener("submit", async (event) => {
|
|||||||
setNotice("API key created.");
|
setNotice("API key created.");
|
||||||
await refresh();
|
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) {
|
} catch (error) {
|
||||||
setNotice(error.message, true);
|
setNotice(error.message, true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -166,19 +240,28 @@ async function api(path, options = {}) {
|
|||||||
|
|
||||||
async function renderRoute() {
|
async function renderRoute() {
|
||||||
const hash = (window.location.hash || "#clients").slice(1);
|
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) => {
|
document.querySelectorAll("[data-nav]").forEach((link) => {
|
||||||
link.classList.toggle("active", link.dataset.nav === view);
|
link.classList.toggle("active", link.dataset.nav === view);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (view === "models" && encodedModel) {
|
if (view === "models" && encodedParam) {
|
||||||
const model = decodeURIComponent(encodedModel);
|
const model = decodeURIComponent(encodedParam);
|
||||||
await loadModelDetail(model);
|
await loadModelDetail(model);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view === "groups" && encodedParam) {
|
||||||
|
const groupId = decodeURIComponent(encodedParam);
|
||||||
|
await loadGroupDetail(groupId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state.detail = null;
|
state.detail = null;
|
||||||
|
state.groupDetail = null;
|
||||||
|
|
||||||
if (view === "models") {
|
if (view === "models") {
|
||||||
renderModels();
|
renderModels();
|
||||||
@@ -190,6 +273,11 @@ async function renderRoute() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (view === "groups") {
|
||||||
|
renderGroups();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
renderClients();
|
renderClients();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,6 +291,7 @@ function updateShell() {
|
|||||||
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
<div>${escapeHtml(summary.user?.name || "Signed in")}</div>
|
||||||
<div>${summary.clients.length} clients</div>
|
<div>${summary.clients.length} clients</div>
|
||||||
<div>${summary.models.length} models</div>
|
<div>${summary.models.length} models</div>
|
||||||
|
<div>${(summary.groups || []).length} groups</div>
|
||||||
<div>${formatDate(summary.generatedAtUtc)}</div>
|
<div>${formatDate(summary.generatedAtUtc)}</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -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 = `
|
||||||
|
<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, 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 = `<div class="panel"><div class="empty">${escapeHtml(error.message)}</div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<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 member</h2>
|
||||||
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<form class="form-row" data-form="add-member" data-group-id="${escapeAttr(group.id)}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="addMemberClient">Client ID</label>
|
||||||
|
<input class="input" id="addMemberClient" name="clientId" placeholder="Client_1">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="addMemberModel">Model (optional)</label>
|
||||||
|
<input class="input" id="addMemberModel" name="model" placeholder="bge-m3">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="addMemberPattern">Client pattern (regex, optional)</label>
|
||||||
|
<input class="input" id="addMemberPattern" 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>Members</h2>
|
||||||
|
<span class="badge">${members.length} total</span>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
${members.length ? groupMembersTable(members, group.id) : emptyState("No members 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 groupMembersTable(members, groupId) {
|
||||||
|
const rows = members.map((member) => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
${member.clientId
|
||||||
|
? `<div class="cell-main">${escapeHtml(member.clientId)}</div>`
|
||||||
|
: `<div class="cell-sub">-</div>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${member.model
|
||||||
|
? `<div class="cell-main">${escapeHtml(member.model)}</div>`
|
||||||
|
: `<div class="cell-sub">All models</div>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
${member.clientPattern
|
||||||
|
? `<div class="cell-main code-inline">${escapeHtml(member.clientPattern)}</div>`
|
||||||
|
: `<div class="cell-sub">-</div>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="button danger" data-action="remove-member" data-group-id="${escapeAttr(groupId)}" data-member-id="${member.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() {
|
function connectedClients() {
|
||||||
return (state.summary?.clients || []).filter((client) => client.connected);
|
return (state.summary?.clients || []).filter((client) => client.connected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<a href="#clients" data-nav="clients">Clients</a>
|
<a href="#clients" data-nav="clients">Clients</a>
|
||||||
<a href="#models" data-nav="models">Models</a>
|
<a href="#models" data-nav="models">Models</a>
|
||||||
<a href="#api-keys" data-nav="api-keys">API keys</a>
|
<a href="#api-keys" data-nav="api-keys">API keys</a>
|
||||||
|
<a href="#groups" data-nav="groups">Groups</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
<div class="sidebar-meta" id="sidebarMeta">Loading</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
Reference in New Issue
Block a user