ci/woodpecker/push/test Pipeline was successful
The ProxyHeader scheme took the username from X-Authentik-Username for any peer inside Auth:ReverseProxy:TrustedProxies. The deployment set that to the whole pod CIDR (a hand copy of the Talos podSubnets), so every pod the network policies let reach :8080 (Hermes, Gitea, Traefik, all of ai and monitoring) could claim any human's name on the Facts page and on saved answers. Authentik's proxy provider sends no signed identity header, so the header cannot be verified at all. Humans now sign in with OpenID Connect against Authentik: a public client with PKCE, the username is preferred_username from the ID token, whose signature is checked against the provider's JWKS. Nothing about the network path or the request headers names a user any more. - Auth:Oidc:Authority, ClientId and PublicOrigin are required; the host refuses to start without them. PublicOrigin builds the redirect URI, because TLS ends at Traefik and the request scheme is http. - UI pages, static assets and the Blazor hub need a session (fallback policy); an anonymous browser is redirected to the provider. - The read API (/api/v1 reads, /ask, /questions, /rank, /status) needs a session or an API key (policy "read") and answers 401, never a redirect. Before, it was anonymous and relied on the gate, BasicAuth or network policy in front. - Writes still need an API key (policy "write"). - /health, /ready and the HMAC-signed /webhook stay anonymous. - ProxyHeaderAuthenticationHandler and Auth:ReverseProxy are removed. Tests: an in-process OpenID provider issues RS256 ID tokens. They cover the PKCE challenge and redirect URI, the full code flow ending in a session named after the token's user, rejection of a token signed by a foreign key, the Authentik header signing in nobody, and 401s for credential-less API calls.
164 lines
6.9 KiB
C#
164 lines
6.9 KiB
C#
using MudBlazor.Services;
|
|
using Wiki.Adapters.FileIndex;
|
|
using Wiki.Adapters.Gitea;
|
|
using Wiki.Adapters.LlamaCpp;
|
|
using Wiki.Adapters.TypeSafe;
|
|
using Wiki.Adapters.Anthropic;
|
|
using Wiki.Application.Retrieval;
|
|
using Wiki.Application.Status;
|
|
using Wiki.Application.Sync;
|
|
using Wiki.Core.Ports;
|
|
using Wiki.Host.Api;
|
|
using Wiki.Host.Auth;
|
|
using Wiki.Host.Components;
|
|
using Wiki.Host.Composition;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
var wiki = builder.Configuration.GetSection("Wiki").Get<WikiOptions>() ?? new WikiOptions();
|
|
builder.Services.AddSingleton(wiki);
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
|
|
// ---- adapters, selected by configuration ------------------------------------------------
|
|
builder.Services.AddGiteaNoteStore(o =>
|
|
{
|
|
o.BaseUrl = wiki.NoteStore.Gitea.BaseUrl;
|
|
o.Repository = wiki.NoteStore.Gitea.Repository;
|
|
o.Branch = wiki.NoteStore.Gitea.Branch;
|
|
o.Token = builder.Configuration["Wiki:NoteStore:Gitea:Token"] ?? string.Empty;
|
|
});
|
|
builder.Services.AddFileIndexAdapters(o => o.Directory = wiki.Index.File.Directory);
|
|
foreach (var (name, model) in wiki.Embeddings.Models)
|
|
{
|
|
switch (model.Adapter)
|
|
{
|
|
case "LlamaCpp":
|
|
builder.Services.AddLlamaCppEmbeddings(name, o =>
|
|
{
|
|
o.Url = model.Url;
|
|
o.Model = model.Model;
|
|
o.ModelId = name;
|
|
o.Dimension = model.Dimension;
|
|
o.BasicAuth = builder.Configuration[$"Wiki:Embeddings:Models:{name}:BasicAuth"];
|
|
});
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"unknown embeddings adapter '{model.Adapter}' for model '{name}'");
|
|
}
|
|
}
|
|
builder.Services.AddSingleton<IReadOnlyList<IEmbeddingModel>>(sp =>
|
|
wiki.Embeddings.Models.Keys.Select(sp.GetRequiredKeyedService<IEmbeddingModel>).ToList());
|
|
builder.Services.AddSingleton<IEmbeddingModel>(sp => sp.GetRequiredKeyedService<IEmbeddingModel>(wiki.Embeddings.Active));
|
|
foreach (var (name, judge) in wiki.Judges.Models)
|
|
{
|
|
switch (judge.Adapter)
|
|
{
|
|
case "TypeSafe":
|
|
builder.Services.AddTypeSafeJudge(name, o =>
|
|
{
|
|
o.Url = judge.Url;
|
|
o.Model = judge.Model;
|
|
o.ModelId = name;
|
|
o.ApiKey = builder.Configuration[$"Wiki:Judges:Models:{name}:ApiKey"] ?? string.Empty;
|
|
});
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"unknown judge adapter '{judge.Adapter}' for '{name}'");
|
|
}
|
|
}
|
|
builder.Services.AddSingleton<IReadOnlyList<IJudge>>(sp =>
|
|
wiki.Judges.Models.Keys.Select(sp.GetRequiredKeyedService<IJudge>).ToList());
|
|
builder.Services.AddSingleton<IJudge>(sp => sp.GetRequiredKeyedService<IJudge>(wiki.Judges.Active));
|
|
foreach (var (name, answer) in wiki.AnswerModels.Models)
|
|
{
|
|
switch (answer.Adapter)
|
|
{
|
|
case "Anthropic":
|
|
Action<Wiki.Adapters.Anthropic.AnthropicOptions> anthropic = o =>
|
|
{
|
|
o.BaseUrl = answer.Url;
|
|
o.Model = answer.Model;
|
|
o.ModelId = name;
|
|
o.MaxTokens = answer.MaxTokens;
|
|
o.ApiKey = builder.Configuration[$"Wiki:AnswerModels:Models:{name}:ApiKey"] ?? string.Empty;
|
|
};
|
|
builder.Services.AddAnthropicAnswerModel(name, anthropic);
|
|
builder.Services.AddAnthropicFactDrafter(name, anthropic);
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"unknown answer-model adapter '{answer.Adapter}' for '{name}'");
|
|
}
|
|
}
|
|
builder.Services.AddSingleton<IReadOnlyList<IAnswerModel>>(sp =>
|
|
wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService<IAnswerModel>).ToList());
|
|
builder.Services.AddSingleton<IAnswerModel>(sp => sp.GetRequiredKeyedService<IAnswerModel>(wiki.AnswerModels.Active));
|
|
// Fact drafters ride with the answer models: same entries, same keys.
|
|
builder.Services.AddSingleton<IReadOnlyList<IFactDrafter>>(sp =>
|
|
wiki.AnswerModels.Models.Keys.Select(sp.GetRequiredKeyedService<IFactDrafter>).ToList());
|
|
builder.Services.AddSingleton<IFactDrafter>(sp => sp.GetRequiredKeyedService<IFactDrafter>(wiki.AnswerModels.Active));
|
|
// One adapter may serve several ports; list it once.
|
|
builder.Services.AddSingleton<IReadOnlyList<IAdapterHealth>>(sp =>
|
|
{
|
|
List<IAdapterHealth> all =
|
|
[
|
|
sp.GetRequiredService<INoteStore>(),
|
|
sp.GetRequiredService<IVectorIndex>(),
|
|
sp.GetRequiredService<IDocumentCatalog>(),
|
|
.. sp.GetRequiredService<IReadOnlyList<IEmbeddingModel>>(),
|
|
.. sp.GetRequiredService<IReadOnlyList<IJudge>>(),
|
|
.. sp.GetRequiredService<IReadOnlyList<IAnswerModel>>(),
|
|
.. sp.GetRequiredService<IReadOnlyList<IFactDrafter>>(),
|
|
];
|
|
return all.Distinct().ToList();
|
|
});
|
|
|
|
// ---- use cases ---------------------------------------------------------------------------
|
|
builder.Services.AddSingleton(new SyncOptions());
|
|
builder.Services.AddSingleton<SyncUseCase>();
|
|
builder.Services.AddSingleton<SyncCoordinator>();
|
|
builder.Services.AddSingleton<RankUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Reading.NoteQueries>();
|
|
builder.Services.AddSingleton<IChunker, Wiki.Core.Parsing.ParagraphChunker>();
|
|
builder.Services.AddSingleton<Wiki.Application.Ask.AskUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Facts.DepositFactUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Facts.RecordStatementUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Evaluation.EvaluateUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Questions.QuestionUseCase>();
|
|
builder.Services.AddSingleton<Wiki.Application.Questions.SaveAnswerUseCase>();
|
|
builder.Services.AddSingleton<StatusQuery>();
|
|
builder.Services.AddSingleton<SyncBackgroundService>();
|
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<SyncBackgroundService>());
|
|
|
|
// ---- auth: humans sign in with OIDC against Authentik, machines with an API key ----------
|
|
builder.Services.AddWikiAuthentication(builder.Configuration);
|
|
|
|
// ---- UI ----------------------------------------------------------------------------------
|
|
builder.Services.AddRazorComponents().AddInteractiveServerComponents();
|
|
builder.Services.AddCascadingAuthenticationState();
|
|
builder.Services.AddMudServices();
|
|
|
|
var app = builder.Build();
|
|
|
|
if (!app.Environment.IsDevelopment())
|
|
{
|
|
app.UseExceptionHandler("/Error", createScopeForErrors: true);
|
|
}
|
|
// The not-found page is for the UI only: an API 401/404 must stay a JSON status, not be
|
|
// re-executed as a POST against a Blazor page (which fails antiforgery with an empty 400).
|
|
app.UseWhen(
|
|
ctx => HttpMethods.IsGet(ctx.Request.Method) && !ctx.Request.Path.StartsWithSegments("/api"),
|
|
branch => branch.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true));
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
app.UseAntiforgery();
|
|
|
|
app.MapHealthEndpoints();
|
|
app.MapIndexEndpoints();
|
|
app.MapApiV1();
|
|
|
|
app.MapStaticAssets();
|
|
app.MapRazorComponents<App>().AddInteractiveServerRenderMode();
|
|
|
|
app.Run();
|
|
|
|
public partial class Program;
|