This commit is contained in:
@@ -73,7 +73,7 @@ internal static class AdminEndpoints
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ internal static class AdminEndpoints
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ internal static class AdminEndpoints
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.Problem(exception.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -185,6 +185,115 @@ internal static class AdminEndpoints
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"API key '{id}' was not found." }));
|
||||
|
||||
api.MapGet("/groups", (ManagementStore store) =>
|
||||
Results.Json(store.ListGroups()));
|
||||
|
||||
api.MapPost("/groups", (CreateGroupRequest request, ManagementStore store) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return Results.Json(store.CreateGroup(request.Name));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
api.MapGet("/groups/{id}", (string id, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
return group is not null
|
||||
? Results.Json(group)
|
||||
: Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
});
|
||||
|
||||
api.MapPut("/groups/{id}", (string id, UpdateGroupRequest request, ManagementStore store) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Name is required." });
|
||||
}
|
||||
|
||||
return store.UpdateGroup(id, request.Name)
|
||||
? Results.Ok(store.GetGroup(id))
|
||||
: Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
});
|
||||
|
||||
api.MapDelete("/groups/{id}", (string id, ManagementStore store) =>
|
||||
store.DeleteGroup(id)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"Group '{id}' was not found." }));
|
||||
|
||||
api.MapGet("/groups/{id}/members", (string id, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
return Results.Json(store.ListGroupMembers(id));
|
||||
});
|
||||
|
||||
api.MapPost("/groups/{id}/members", (string id, AddGroupMemberRequest request, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(id);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{id}' was not found." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var member = store.AddGroupMember(id, request.ClientId, request.Model, request.ClientPattern);
|
||||
return Results.Json(member);
|
||||
}
|
||||
catch (ArgumentException exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = $"Failed to add member: {exception.Message}" });
|
||||
}
|
||||
});
|
||||
|
||||
api.MapDelete("/groups/{groupId}/members/{memberId:long}", (string groupId, long memberId, ManagementStore store) =>
|
||||
{
|
||||
var group = store.GetGroup(groupId);
|
||||
if (group is null)
|
||||
{
|
||||
return Results.NotFound(new { error = $"Group '{groupId}' was not found." });
|
||||
}
|
||||
|
||||
return store.RemoveGroupMember(memberId)
|
||||
? Results.NoContent()
|
||||
: Results.NotFound(new { error = $"Member '{memberId}' was not found." });
|
||||
});
|
||||
|
||||
api.MapGet("/api-keys/groups", (ManagementStore store) =>
|
||||
Results.Json(store.ListApiKeyGroups()));
|
||||
|
||||
api.MapPut("/api-keys/{id}/groups", (string id, SetApiKeyGroupsRequest request, ManagementStore store) =>
|
||||
{
|
||||
var keys = store.ListApiKeys();
|
||||
if (!keys.Any(k => k.Id == id))
|
||||
{
|
||||
return Results.NotFound(new { error = $"API key '{id}' was not found." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
store.SetApiKeyGroups(id, request.GroupIds ?? []);
|
||||
return Results.Ok(new { apiKeyId = id, groupIds = store.GetApiKeyGroupIds(id) });
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Results.BadRequest(new { error = exception.Message });
|
||||
}
|
||||
});
|
||||
|
||||
var adminHome = app.MapGet("/admin", (IWebHostEnvironment environment) =>
|
||||
ServeAdminAsset(environment, null));
|
||||
var adminAssets = app.MapGet("/admin/{**assetPath}", (IWebHostEnvironment environment, string? assetPath) =>
|
||||
@@ -224,7 +333,9 @@ internal static class AdminEndpoints
|
||||
},
|
||||
clients = BuildClientSummaries(hub, store),
|
||||
models = BuildModelSummaries(hub, store),
|
||||
apiKeys = store.ListApiKeys()
|
||||
apiKeys = store.ListApiKeys(),
|
||||
groups = store.ListGroups(),
|
||||
apiKeyGroups = store.ListApiKeyGroups()
|
||||
};
|
||||
|
||||
private static IReadOnlyList<ClientSummary> BuildClientSummaries(TunnelHub hub, ManagementStore store)
|
||||
@@ -470,6 +581,17 @@ internal sealed record ModelActionRequest(
|
||||
|
||||
internal sealed record CreateApiKeyRequest(string? Name);
|
||||
|
||||
internal sealed record CreateGroupRequest(string? Name);
|
||||
|
||||
internal sealed record UpdateGroupRequest(string Name);
|
||||
|
||||
internal sealed record AddGroupMemberRequest(
|
||||
string? ClientId,
|
||||
string? Model,
|
||||
string? ClientPattern);
|
||||
|
||||
internal sealed record SetApiKeyGroupsRequest(IReadOnlyList<string>? GroupIds);
|
||||
|
||||
internal sealed record ClientSummary(
|
||||
string Id,
|
||||
bool Connected,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ReverseLlama.Server;
|
||||
@@ -497,6 +498,466 @@ internal sealed class ManagementStore
|
||||
return result;
|
||||
}
|
||||
|
||||
public string? GetApiKeyId(string apiKey)
|
||||
{
|
||||
if (!_isAvailable || string.IsNullOrWhiteSpace(apiKey))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var hash = HashApiKey(apiKey);
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
return _apiKeysByHash.TryGetValue(hash, out var key) ? key.Id : null;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<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()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_databasePath);
|
||||
@@ -551,6 +1012,30 @@ internal sealed class ManagementStore
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_metrics_model_started
|
||||
ON request_metrics (model, started_at_utc);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS groups (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at_utc TEXT NOT NULL,
|
||||
updated_at_utc TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS group_members (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
client_id TEXT NULL,
|
||||
model TEXT NULL,
|
||||
client_pattern TEXT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_group_members_group_id
|
||||
ON group_members (group_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_key_groups (
|
||||
api_key_id TEXT NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
|
||||
group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (api_key_id, group_id)
|
||||
);
|
||||
""";
|
||||
command.ExecuteNonQuery();
|
||||
}
|
||||
@@ -698,3 +1183,49 @@ internal sealed record ModelUsageStats(
|
||||
long RequestsLastHour,
|
||||
long TokensLast10Minutes,
|
||||
long TokensLastHour);
|
||||
|
||||
internal sealed record GroupInfo(
|
||||
string Id,
|
||||
string Name,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
internal sealed record GroupMemberInfo(
|
||||
long Id,
|
||||
string GroupId,
|
||||
string? ClientId,
|
||||
string? Model,
|
||||
string? ClientPattern);
|
||||
|
||||
internal sealed record ApiKeyGroupInfo(
|
||||
string ApiKeyId,
|
||||
string ApiKeyName,
|
||||
string ApiKeyPrefix,
|
||||
IReadOnlyList<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 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();
|
||||
|
||||
@@ -35,13 +35,16 @@ internal static class ReverseProxyEndpoint
|
||||
EmbeddingCache embeddingCache,
|
||||
ManagementStore managementStore)
|
||||
{
|
||||
if (!TokenAuthentication.IsAuthorized(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true))
|
||||
var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true);
|
||||
if (!auth.IsAuthorized)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||
|
||||
var pathTokenRemoved = TokenAuthentication.TryRemovePathToken(context.Request.Path, settings, managementStore, out var proxyPath);
|
||||
if (!pathTokenRemoved)
|
||||
{
|
||||
@@ -56,6 +59,13 @@ internal static class ReverseProxyEndpoint
|
||||
|
||||
if (TryGetClientAddress(proxyPath, out var pathClientId, out var clientPath))
|
||||
{
|
||||
if (!groupAccess.IsClientAllowed(pathClientId))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync($"Access to client '{pathClientId}' is not permitted.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
await ForwardToClientAsync(
|
||||
context,
|
||||
pathClientId,
|
||||
@@ -65,7 +75,14 @@ internal static class ReverseProxyEndpoint
|
||||
settings,
|
||||
loggerFactory,
|
||||
embeddingCache,
|
||||
managementStore);
|
||||
managementStore,
|
||||
groupAccess);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsTagsRequest(context.Request, proxyPath))
|
||||
{
|
||||
await HandleTagsAsync(context, hub, managementStore, groupAccess);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -79,7 +96,8 @@ internal static class ReverseProxyEndpoint
|
||||
var requestedModel = embeddingRequest?.Model ?? await GetRequestedModelAsync(context.Request, proxyPath);
|
||||
var connection = hub.SelectBest(
|
||||
requestedModel,
|
||||
clientId => !managementStore.GetClientAccess(clientId).IsDisabled);
|
||||
clientId => !managementStore.GetClientAccess(clientId).IsDisabled
|
||||
&& groupAccess.IsClientAllowed(clientId));
|
||||
if (connection is null)
|
||||
{
|
||||
if (!hub.HasClient)
|
||||
@@ -117,13 +135,22 @@ internal static class ReverseProxyEndpoint
|
||||
EmbeddingCache embeddingCache,
|
||||
ManagementStore managementStore)
|
||||
{
|
||||
if (!TokenAuthentication.IsAuthorized(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true))
|
||||
var auth = TokenAuthentication.Authorize(context.Request, settings, managementStore, allowQueryToken: false, allowPathToken: true);
|
||||
if (!auth.IsAuthorized)
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
await context.Response.WriteAsync(UnauthorizedMessage, context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var groupAccess = ResolveGroupAccess(auth.ApiKeyId, managementStore);
|
||||
if (!groupAccess.IsClientAllowed(clientId))
|
||||
{
|
||||
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||
await context.Response.WriteAsync($"Access to client '{clientId}' is not permitted.", context.RequestAborted);
|
||||
return;
|
||||
}
|
||||
|
||||
var pathAndQuery = $"/{path}{context.Request.QueryString}";
|
||||
var clientPath = new PathString($"/{path}");
|
||||
await ForwardToClientAsync(
|
||||
@@ -135,7 +162,8 @@ internal static class ReverseProxyEndpoint
|
||||
settings,
|
||||
loggerFactory,
|
||||
embeddingCache,
|
||||
managementStore);
|
||||
managementStore,
|
||||
groupAccess);
|
||||
}
|
||||
|
||||
private static async Task ForwardToClientAsync(
|
||||
@@ -147,7 +175,8 @@ internal static class ReverseProxyEndpoint
|
||||
ServerSettings settings,
|
||||
ILoggerFactory loggerFactory,
|
||||
EmbeddingCache embeddingCache,
|
||||
ManagementStore managementStore)
|
||||
ManagementStore managementStore,
|
||||
GroupAccess? groupAccess = null)
|
||||
{
|
||||
var clientAccess = managementStore.GetClientAccess(clientId);
|
||||
if (clientAccess.IsDisabled)
|
||||
@@ -231,6 +260,69 @@ internal static class ReverseProxyEndpoint
|
||||
return true;
|
||||
}
|
||||
|
||||
private static GroupAccess ResolveGroupAccess(string? apiKeyId, ManagementStore managementStore)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(apiKeyId))
|
||||
{
|
||||
return GroupAccess.Unrestricted;
|
||||
}
|
||||
|
||||
return managementStore.ResolveGroupAccess(apiKeyId);
|
||||
}
|
||||
|
||||
private static bool IsTagsRequest(HttpRequest request, PathString proxyPath) =>
|
||||
HttpMethods.IsGet(request.Method)
|
||||
&& string.Equals(proxyPath.Value, "/api/tags", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static async Task HandleTagsAsync(
|
||||
HttpContext context,
|
||||
TunnelHub hub,
|
||||
ManagementStore managementStore,
|
||||
GroupAccess groupAccess)
|
||||
{
|
||||
var models = new Dictionary<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) =>
|
||||
string.IsNullOrWhiteSpace(requestedModel)
|
||||
? "No tunnel client is available for this request."
|
||||
|
||||
@@ -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,35 +15,69 @@ 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,
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,6 +440,28 @@ tr:last-child td {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.group-link {
|
||||
color: var(--blue);
|
||||
text-decoration: none;
|
||||
font-weight: 800;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.group-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.code-inline {
|
||||
font-family: "Cascadia Mono", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
background: var(--panel-alt);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 4px;
|
||||
padding: 2px 6px;
|
||||
display: inline-block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -455,7 +477,7 @@ tr:last-child td {
|
||||
}
|
||||
|
||||
.nav {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
.nav a {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const state = {
|
||||
summary: null,
|
||||
detail: null,
|
||||
groupDetail: null,
|
||||
newKey: null,
|
||||
loading: false
|
||||
};
|
||||
@@ -20,7 +21,7 @@ content.addEventListener("click", async (event) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { action, clientId, model, keyId } = button.dataset;
|
||||
const { action, clientId, model, keyId, groupId, memberId } = button.dataset;
|
||||
|
||||
try {
|
||||
setBusy(button, true);
|
||||
@@ -67,6 +68,31 @@ content.addEventListener("click", async (event) => {
|
||||
await navigator.clipboard.writeText(button.dataset.key);
|
||||
setNotice("API key copied.");
|
||||
}
|
||||
|
||||
if (action === "delete-group") {
|
||||
if (!confirm("Delete this group and all its members and key assignments?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api(`/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" });
|
||||
setNotice("Group deleted.");
|
||||
window.location.hash = "#groups";
|
||||
await refresh();
|
||||
}
|
||||
|
||||
if (action === "remove-member") {
|
||||
if (!confirm("Remove this member from the group?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await api(`/groups/${encodeURIComponent(groupId)}/members/${memberId}`, { method: "DELETE" });
|
||||
setNotice("Member removed.");
|
||||
await loadGroupDetail(groupId);
|
||||
}
|
||||
|
||||
if (action === "toggle-key-assignment") {
|
||||
await toggleApiKeyAssignment(groupId, keyId, button.dataset.assigned === "true");
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message, true);
|
||||
} finally {
|
||||
@@ -104,6 +130,54 @@ content.addEventListener("submit", async (event) => {
|
||||
setNotice("API key created.");
|
||||
await refresh();
|
||||
}
|
||||
|
||||
if (form.dataset.form === "create-group") {
|
||||
const result = await api("/groups", {
|
||||
method: "POST",
|
||||
body: { name: data.name }
|
||||
});
|
||||
setNotice("Group created.");
|
||||
form.reset();
|
||||
await refresh();
|
||||
window.location.hash = `#groups/${encodeURIComponent(result.id)}`;
|
||||
}
|
||||
|
||||
if (form.dataset.form === "edit-group-name") {
|
||||
const groupId = form.dataset.groupId;
|
||||
await api(`/groups/${encodeURIComponent(groupId)}`, {
|
||||
method: "PUT",
|
||||
body: { name: data.name }
|
||||
});
|
||||
setNotice("Group name updated.");
|
||||
await refresh();
|
||||
await loadGroupDetail(groupId);
|
||||
}
|
||||
|
||||
if (form.dataset.form === "add-member") {
|
||||
const groupId = form.dataset.groupId;
|
||||
const body = {};
|
||||
if (data.clientId && data.clientId.trim()) {
|
||||
body.clientId = data.clientId.trim();
|
||||
}
|
||||
if (data.model && data.model.trim()) {
|
||||
body.model = data.model.trim();
|
||||
}
|
||||
if (data.clientPattern && data.clientPattern.trim()) {
|
||||
body.clientPattern = data.clientPattern.trim();
|
||||
}
|
||||
|
||||
if (!body.clientId && !body.clientPattern) {
|
||||
throw new Error("Either Client ID or Client pattern is required.");
|
||||
}
|
||||
|
||||
await api(`/groups/${encodeURIComponent(groupId)}/members`, {
|
||||
method: "POST",
|
||||
body
|
||||
});
|
||||
setNotice("Member added.");
|
||||
form.reset();
|
||||
await loadGroupDetail(groupId);
|
||||
}
|
||||
} catch (error) {
|
||||
setNotice(error.message, true);
|
||||
} finally {
|
||||
@@ -166,19 +240,28 @@ async function api(path, options = {}) {
|
||||
|
||||
async function renderRoute() {
|
||||
const hash = (window.location.hash || "#clients").slice(1);
|
||||
const [view, encodedModel] = hash.split("/");
|
||||
const parts = hash.split("/");
|
||||
const view = parts[0];
|
||||
const encodedParam = parts[1];
|
||||
|
||||
document.querySelectorAll("[data-nav]").forEach((link) => {
|
||||
link.classList.toggle("active", link.dataset.nav === view);
|
||||
});
|
||||
|
||||
if (view === "models" && encodedModel) {
|
||||
const model = decodeURIComponent(encodedModel);
|
||||
if (view === "models" && encodedParam) {
|
||||
const model = decodeURIComponent(encodedParam);
|
||||
await loadModelDetail(model);
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === "groups" && encodedParam) {
|
||||
const groupId = decodeURIComponent(encodedParam);
|
||||
await loadGroupDetail(groupId);
|
||||
return;
|
||||
}
|
||||
|
||||
state.detail = null;
|
||||
state.groupDetail = null;
|
||||
|
||||
if (view === "models") {
|
||||
renderModels();
|
||||
@@ -190,6 +273,11 @@ async function renderRoute() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (view === "groups") {
|
||||
renderGroups();
|
||||
return;
|
||||
}
|
||||
|
||||
renderClients();
|
||||
}
|
||||
|
||||
@@ -203,6 +291,7 @@ function updateShell() {
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@@ -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() {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user