feat(groups): adds groups
Build & Deploy / build (push) Successful in 2m8s

This commit is contained in:
2026-07-17 19:32:49 +02:00
parent 19c5b9bce1
commit 6b18cbca0e
8 changed files with 1219 additions and 37 deletions
@@ -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."